Lecture 14 - Trees and Graphs: Two More Shapes for Linked Nodes¶
Back in Lecture 9 we built our first data structure out of heap nodes: the
linked list, where every node held a value and one next pointer. Lecture
10 reused that node without changing it - a hash table was just an array of
linked lists. Today we change the node itself. Give a node two next
pointers and you get a tree; let a node point at any number of others
and you get a graph. Neither is a new idea so much as the linked node with
richer wiring. We will build the binary search tree (the O(log n) rung we
sketched only in pseudocode last week, now real C), then generalize to
graphs stored the same way a hash table is - an array of linked lists - and
walk a graph breadth-first.
1. From one next to two children¶
A linked-list node had exactly one link, so the nodes fell into a single line. That is why search was O(n): with only one direction to go, you must walk past everything. What if a node had two links instead of one? Then at each node you get a choice of which way to descend, and - if we are disciplined about which values go which way - each choice can throw away half of what is left.
A binary tree is made of nodes, each holding a value and pointers to a
left child and a right child, either of which may be NULL. It is the
self-referential struct from Lecture 9 with one more pointer:
struct tnode {
int key; /* the value this node holds */
struct tnode *left; /* smaller keys live here, or NULL */
struct tnode *right; /* larger keys live here, or NULL */
};
typedef struct tnode tnode_t;
- Everything you already know transfers. The struct is self-referential, so
it must keep its tag (
struct tnode) - the same reason as the list node in Lecture 9. ANULLchild is an end, exactly like aNULLnext. The whole tree is named by a single pointer to its top node, the root, just as a list was named by itshead. An empty tree isroot == NULL. - The vocabulary: the top node is the root; a node with no children is a leaf; each node is the parent of its children; a node plus everything hanging below it is a subtree. Every child pointer points at the root of a smaller subtree - which is why nearly everything today is recursive.
Picture a tree of eight keys (this is the one bst.c builds):
Each (k) is a heap node; the lines are left and right pointers; a missing
line is a NULL child.
2. The BST ordering invariant¶
A binary tree by itself is just a shape. What makes it a binary search tree is one rule we promise to maintain at every node:
Every key in a node's left subtree is less than the node's key, and every key in its right subtree is greater.
Look back at the picture and check it at 50: everything on the left (30, 20,
40, 10) is below 50, everything on the right (70, 60, 90) is above. And it
holds again at 30, and at 70, all the way down. That invariant is the whole
point - it is what lets a search rule out half the tree at every step. This is
the structure we drew as board pseudocode for the O(log n) rung of the search
ladder in Lecture 10; now we build it for real.
3. Inserting a key¶
To insert, we follow the invariant down to the empty spot where the key
belongs, and put a new leaf there. Smaller than this node? Go left. Larger?
Go right. Fell off the tree (NULL)? That empty spot is the answer.
tnode_t *insert(tnode_t *root, int key) {
if (root == NULL) {
return new_node(key); /* empty spot: this is where key belongs */
}
if (key < root->key) {
root->left = insert(root->left, key);
} else if (key > root->key) {
root->right = insert(root->right, key);
}
/* key == root->key: already present, ignore the duplicate */
return root;
}
- This is recursion on the call stack from Lecture 6, not the two-pointer
surgery from Lecture 9. Insert into the whole tree = insert into the correct
subtree, and a subtree is just a smaller tree. The base case is the
NULLthat means "empty spot found." - Why return the root? Because
insertmay need to change which node a parent'sleft/rightpoints at (when it creates the new leaf). A by-value pointer cannot change the caller's variable - the same limit as Lecture 9 - so we return the subtree and the parent reattaches it withroot->left = insert(root->left, key);. Forgetting the reassignment is the classic bug: the new node is created and then leaked. The top-level caller does the same:root = insert(root, key);. new_node(key)justmallocs one node with twoNULLchildren, the tree version of the list node maker.
Insert order decides the shape. Insert 50, 30, 70, 20, ... and you get the
balanced tree above. But insert the same keys already sorted - 10, 20, 30,
40, ... - and every key is larger than the last, so it always goes right:
That degenerate tree is O(n) to search - we are back to a linked list. Keeping trees balanced is a real topic (and the reason libraries use red-black or AVL trees); we will name it and move on.
In-class exercise: Part A, Exercise A1 (pen and paper) - insert a given key sequence into an empty BST and draw the result, then insert a sorted sequence and watch it degenerate into a chain.
4. Searching a key¶
Search is the invariant read the other direction. At each node: equal? found. Smaller? it can only be on the left. Larger? only on the right. Every comparison discards an entire subtree.
tnode_t *search(tnode_t *root, int key) {
if (root == NULL || root->key == key) {
return root; /* NULL (not found) or the match */
}
if (key < root->key) {
return search(root->left, key);
}
return search(root->right, key);
}
- Two base cases share one line:
root == NULLmeans we walked off the tree without finding it (returnNULL), androot->key == keymeans we found it (return the node). Otherwise recurse into exactly one child. - The cost is the height of the tree. A balanced tree of
nkeys has height aboutlog2(n), so search is O(log n) - each step halves the search space, precisely the win we wanted over the list's O(n). The honest caveat is the same one from the hash-table lecture: this is the balanced case. A degenerate tree (Section 3) has heightn-1and search rots back to O(n).
In-class exercise: Part B, Exercise B1 (on the computer) - implement
insertandsearch, build a tree from numbers you read in, and answer membership queries.
5. Traversals: visiting every node¶
Search follows one path. Often you want to visit every node - to print the tree, sum it, or free it. With two children there is a choice: do you handle a node before its children, between them, or after them? Those three choices are the three classic traversals, and each is a three-line recursion.
void inorder(tnode_t *root) { /* left, SELF, right */
if (root == NULL) return;
inorder(root->left);
printf("%d ", root->key);
inorder(root->right);
}
void preorder(tnode_t *root) { /* SELF, left, right */
if (root == NULL) return;
printf("%d ", root->key);
preorder(root->left);
preorder(root->right);
}
void postorder(tnode_t *root) { /* left, right, SELF */
if (root == NULL) return;
postorder(root->left);
postorder(root->right);
printf("%d ", root->key);
}
On the tree from Section 1, the three orders are:
inorder (sorted): 10 20 30 40 50 60 70 90
preorder : 50 30 20 10 40 70 60 90
postorder : 10 20 40 30 60 90 70 50
Two of these earn special attention:
- Inorder gives you the keys in sorted order. This is the BST's quiet superpower and falls straight out of the invariant: everything left of a node (all smaller) is printed first, then the node, then everything right (all larger). A BST is a structure you can both search quickly and read out in order for free.
- Postorder is how you free a tree. You may only free a node after both
its children are gone, or you would read a freed node to find its children.
This is the tree form of Lecture 9's "save
nextbefore youfree" discipline - here the recursion saves it for you:
void free_tree(tnode_t *root) {
if (root == NULL) return;
free_tree(root->left); /* free the whole left subtree ... */
free_tree(root->right); /* ... then the whole right subtree ... */
free(root); /* ... and only now this node */
}
Every node came from its own malloc, so every node needs its own free - the
ownership rule intact since Lecture 6, now walking a tree instead of a list.
In-class exercise: Part A, Exercise A2 (pen and paper) - given a drawn tree, write out its inorder, preorder, and postorder sequences by hand. Part B, Exercise B2 (on the computer) - print a BST inorder to confirm it comes out sorted, and free it with a postorder walk.
6. From two children to many neighbors: the graph¶
A tree connects nodes in a strict hierarchy: one parent, children below, no cycles. Plenty of real relationships are not that tidy. Cities joined by roads, people who follow each other, web pages that link around in loops - any node can connect to any number of others, and connections can form cycles. That is a graph.
- A graph is a set of vertices (the nodes) and edges (connections
between pairs of vertices). In an undirected graph an edge
{u, v}goes both ways (a road); in a directed graph an edgeu -> vis one-way (a "follows" link). We will number our vertices0 .. V-1and work undirected. - We cannot store a graph the way we stored a tree, because a fixed struct with
leftandrightonly holds two links. A vertex might have zero neighbors or twenty. We need a per-vertex list whose length can vary - and we already built exactly that.
Here is the graph graph.c builds:
7. The adjacency list: an array of linked lists¶
Give every vertex its own linked list of the vertices it connects to, and
put those lists in an array indexed by vertex number. buckets[u] is the
head of vertex u's neighbor list. This is the adjacency list, and its
shape is one you have seen: it is the hash table's spine from Lecture 10, an
array of linked lists.
struct adj { /* one neighbor entry: a list node */
int to; /* the neighbor's vertex number */
struct adj *next; /* next neighbor of the same vertex, or NULL */
};
typedef struct adj adj_t;
struct graph {
int nverts; /* vertices are 0 .. nverts-1 */
adj_t **buckets; /* array of nverts adjacency-list heads */
};
typedef struct graph graph_t;
There is one clarifying difference from the hash table, and it makes graphs simpler, not harder:
- A hash table had to compute which bucket a key belonged in with a hash
function, and different keys could collide into the same bucket. Here the
bucket for vertex
uis just indexu. Vertices are already0 .. V-1, so we index the array directly - no hash function, no collisions. The linked lists are still there, but only to hold a variable number of neighbors, not to absorb collisions.
Adding an edge is push_front from Lecture 9, reused without change. An
undirected edge {u, v} is stored twice - v in u's list and u in
v's - so walking from either endpoint finds the other:
static void add_directed(graph_t *g, int u, int v) {
adj_t *node = malloc(sizeof(adj_t));
if (node == NULL) return;
node->to = v;
node->next = g->buckets[u]; /* new node points at old head ... */
g->buckets[u] = node; /* ... and becomes the new head (push_front) */
}
static void add_edge(graph_t *g, int u, int v) {
add_directed(g, u, v); /* v is a neighbor of u */
add_directed(g, v, u); /* and u is a neighbor of v (undirected) */
}
Printing the graph is the same walk-to-NULL loop as every list before it. For
the graph above it produces (neighbors appear front-to-back in the order
push_front left them, which is why they read newest-first - the order within a
list does not matter for a graph):
- Board aside - the other representation. You could instead use an adjacency
matrix: a
V x Vgrid where entry[u][v]is 1 if there is an edge. That answers "is there an edge u-v?" in one step, but it always costsV*Vspace even if there are almost no edges. The adjacency list costsO(V + E)- one slot per vertex plus one node per edge - and is the right default for the sparse graphs that show up most often. We use the list.
In-class exercise: Part B, Exercise B3 (on the computer) - build an undirected graph as an array of linked lists, add its edges, and print each vertex's adjacency list.
8. Breadth-first traversal¶
To traverse a graph is to visit every vertex reachable from a start, following edges. Unlike a tree there is no root and there are cycles, so we must remember where we have been or we will loop forever. Breadth-first traversal (BFS) visits vertices in rings: first the start, then all of its neighbors, then everything one step further out, and so on.
BFS needs two helpers:
- A queue - a first-in-first-out line of vertices waiting to be visited. BFS is breadth-first precisely because the queue hands back vertices in the order they were discovered, so a whole ring comes out before the next ring goes in.
- A visited array - one flag per vertex, so we enqueue each vertex at most once. This is what stops cycles from spinning forever.
Our queue is a plain array with a head and tail index: enqueue by writing at
tail++, dequeue by reading at head++. Since each vertex is enqueued at most
once, an array of V slots is always big enough. (A general queue could be a
linked list - the array is enough when vertices are 0 .. V-1.)
void bfs(const graph_t *g, int start) {
int *visited = calloc(g->nverts, sizeof(int)); /* 0 = unvisited */
int *queue = malloc(g->nverts * sizeof(int));
int head = 0, tail = 0;
visited[start] = 1; /* mark BEFORE enqueue, never twice */
queue[tail++] = start;
while (head < tail) { /* while the queue is not empty */
int u = queue[head++]; /* dequeue the front vertex */
printf(" %d", u);
for (adj_t *e = g->buckets[u]; e != NULL; e = e->next) {
if (!visited[e->to]) { /* first time we have seen this vertex */
visited[e->to] = 1;
queue[tail++] = e->to; /* enqueue it for later */
}
}
}
free(visited);
free(queue);
}
The single most important detail: mark a vertex visited the moment you enqueue it, not when you dequeue it. Mark on dequeue and the same vertex can be enqueued several times before its turn comes up - the traversal still finishes but does redundant work, and on a big graph that matters.
Trace BFS from vertex 0 on our graph, watching the queue:
start: visit 0, queue = [1, 2] (0's neighbors discovered)
visit next in line ...
step: dequeue 2, its only neighbor 0 already visited, queue = [1]
step: dequeue 1, discover 3, 4, queue = [3, 4]
step: dequeue 3, discover 5, queue = [4, 5]
step: dequeue 4, 5 already discovered, queue = [5]
step: dequeue 5, all neighbors seen, queue = []
Reading off the actual program output:
Notice the rings: 0 first (distance 0), then {1, 2} (distance 1), then
{3, 4} (distance 2), then {5} (distance 3). The exact order within a ring
follows each vertex's adjacency-list order, which is why 2 prints before 1
here - push_front put 2 at the front of 0's list. That "visit everything
one step away, then two steps away" property is what makes BFS the tool for
shortest-path-in-edges questions, which you will meet again well beyond this
course.
In-class exercise: Part A, Exercise A3 (pen and paper) - hand-simulate BFS on a small graph, tracking the queue and the visited set at each step. Part B, Exercise B4 (on the computer) - implement
bfsand print the visit order from a start vertex.
9. Wrap-up¶
- A tree node is a linked-list node with two child pointers; a graph
lets a node have any number of neighbors. Both are the self-referential
heap node from Lecture 9 with richer wiring, so tags,
NULLends, and the named-by-one-pointer idiom all carry over. - A binary search tree keeps the invariant left subtree < node < right subtree. Insert and search are short recursions that discard half the tree per step - O(log n) when balanced, O(n) when a bad insert order makes it degenerate into a chain. Both use the return-the-subtree idiom, the tree version of return-the-head.
- The three traversals differ only in when a node is handled relative to its children. Inorder prints a BST in sorted order for free; postorder is the order you must free a tree in (children before parent).
- A graph is stored as an adjacency list - an array of linked lists,
the hash table's spine - but indexed by vertex number directly, so there is
no hash function and no collisions. Adding an edge is
push_front; an undirected edge is stored at both endpoints. Space is O(V + E). - Breadth-first traversal visits the graph in rings using a queue and a visited array, marking each vertex visited when it is enqueued so cycles cannot loop forever.
- Cliffhanger: all lecture we have thrown around O(n), O(log n), O(V + E) without pinning them down. Next time we make algorithmic complexity precise, and then sort a linked list in O(n log n) by relinking its nodes - no shifting, no random access - which is our last data-structure move before the final.