02 · Trees & Basic Graph Representations¶
Module 01's linked list chains nodes one after another. A tree relaxes that to let each node point at multiple children, and a graph relaxes it further so connections don't even have to flow in one direction. Both are still just structs and pointers — the only thing that changes is the shape of the relationships.
Binary search trees¶
A binary search tree (BST) keeps every value in its left subtree smaller than the node, and every value in its right subtree larger. That ordering is what makes search fast: at each node you eliminate half the remaining tree, the same way you'd search a sorted array by halves.
// bst.c
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode {
int value;
struct TreeNode *left;
struct TreeNode *right;
} TreeNode;
TreeNode *tree_insert(TreeNode *root, int value) {
if (root == NULL) {
TreeNode *n = malloc(sizeof(TreeNode));
if (!n) { fprintf(stderr, "out of memory\n"); exit(1); }
n->value = value;
n->left = n->right = NULL;
return n;
}
if (value < root->value) {
root->left = tree_insert(root->left, value);
} else if (value > root->value) {
root->right = tree_insert(root->right, value);
}
// equal values are ignored -- no duplicates in this tree
return root;
}
TreeNode *tree_find(TreeNode *root, int value) {
if (root == NULL || root->value == value) {
return root;
}
if (value < root->value) {
return tree_find(root->left, value);
}
return tree_find(root->right, value);
}
void tree_print_inorder(const TreeNode *root) {
if (root == NULL) return;
tree_print_inorder(root->left);
printf("%d ", root->value);
tree_print_inorder(root->right);
}
void tree_free(TreeNode *root) {
if (root == NULL) return;
tree_free(root->left);
tree_free(root->right);
free(root);
}
int main(void) {
TreeNode *root = NULL;
int values[] = {50, 30, 70, 20, 40, 60, 80};
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
root = tree_insert(root, values[i]);
}
printf("in-order: ");
tree_print_inorder(root);
printf("\n");
int target = 40;
TreeNode *found = tree_find(root, target);
printf("find(%d): %s\n", target, found ? "found" : "not found");
target = 99;
found = tree_find(root, target);
printf("find(%d): %s\n", target, found ? "found" : "not found");
tree_free(root);
return 0;
}
Two things worth noticing:
tree_insertreturns aTreeNode *that callers must reassign, exactly likepush_frontin Module 01.root->left = tree_insert(root->left, value)is what actually attaches a freshly allocated node into the tree — miss the assignment and the new node is allocated, then immediately leaked.- In-order traversal (left, node, right) visits a BST's values in sorted
order — that's not a coincidence, it falls directly out of the ordering
invariant. It's also why the pattern (
recurse left,visit,recurse right) is worth memorizing; swapping the visit to before or after the recursive calls gives you pre-order and post-order instead, useful for different jobs (pre-order to copy a tree, post-order to free one bottom-up, which is exactly whattree_freedoes).
The trap: an unbalanced tree degrades to a linked list¶
tree_insert's O(log n) search only holds if the tree stays roughly
balanced. Insert already-sorted data (1, 2, 3, 4, 5...) and every new node
becomes the right child of the previous one — you get a straight chain, and
search degrades to O(n). Real-world BST-based containers (like C++'s
std::map or Java's TreeMap) use self-balancing variants (red-black
trees, AVL trees) specifically to guarantee this can't happen; a plain BST
like this one doesn't protect you from it.
Graphs: adjacency list representation¶
A graph is a set of vertices plus a set of edges connecting them, and edges don't have to form a hierarchy — cycles are allowed, and a vertex can connect to any number of others. The two standard ways to represent one in C are an adjacency matrix (an N×N grid of 0/1) and an adjacency list (each vertex keeps a linked list of its neighbors). Adjacency lists win for sparse graphs — most real graphs (social networks, road maps, dependency graphs) — because memory scales with the number of edges, not the square of the vertex count.
// graph_bfs.c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTICES 6
typedef struct EdgeNode {
int dest;
struct EdgeNode *next;
} EdgeNode;
typedef struct {
EdgeNode *adj[MAX_VERTICES]; // one linked list of neighbors per vertex
int num_vertices;
} Graph;
void graph_init(Graph *g, int num_vertices) {
g->num_vertices = num_vertices;
for (int i = 0; i < num_vertices; i++) {
g->adj[i] = NULL;
}
}
void graph_add_edge(Graph *g, int src, int dest) {
// undirected: add dest to src's list, and src to dest's list
EdgeNode *n1 = malloc(sizeof(EdgeNode));
n1->dest = dest;
n1->next = g->adj[src];
g->adj[src] = n1;
EdgeNode *n2 = malloc(sizeof(EdgeNode));
n2->dest = src;
n2->next = g->adj[dest];
g->adj[dest] = n2;
}
void graph_bfs(const Graph *g, int start) {
int visited[MAX_VERTICES] = {0};
int queue[MAX_VERTICES];
int head = 0, tail = 0;
visited[start] = 1;
queue[tail++] = start;
printf("BFS from %d: ", start);
while (head < tail) {
int current = queue[head++];
printf("%d ", current);
for (EdgeNode *e = g->adj[current]; e != NULL; e = e->next) {
if (!visited[e->dest]) {
visited[e->dest] = 1;
queue[tail++] = e->dest;
}
}
}
printf("\n");
}
void graph_free(Graph *g) {
for (int i = 0; i < g->num_vertices; i++) {
EdgeNode *cur = g->adj[i];
while (cur != NULL) {
EdgeNode *next = cur->next;
free(cur);
cur = next;
}
}
}
int main(void) {
Graph g;
graph_init(&g, 6);
// 0 -- 1 -- 3
// | |
// 2 4 -- 5
graph_add_edge(&g, 0, 1);
graph_add_edge(&g, 0, 2);
graph_add_edge(&g, 1, 3);
graph_add_edge(&g, 1, 4);
graph_add_edge(&g, 4, 5);
graph_bfs(&g, 0);
graph_free(&g);
return 0;
}
graph_bfs uses a plain fixed-size array as its frontier queue, not the
circular buffer from Module 01 — a simple head/tail pair is enough here
because each vertex is enqueued at most once (guarded by visited), so
tail can never overrun MAX_VERTICES slots. The visit order (0 2 1 4 3 5) depends on the order neighbors
were linked into each adjacency list — graph_add_edge prepends, so the
last edge added to a vertex is the first one BFS explores from it.
Depth-first search (DFS) uses the identical structure with one change:
replace the FIFO queue with the stack from Module 01 (or a recursive call,
which uses the call stack instead of an explicit one) — same visited-set
bookkeeping, different order of exploration.
Tree/graph terms cheat sheet¶
| Term | Meaning |
|---|---|
| Root | The tree's single entry node with no parent |
| Leaf | A node with no children |
| Depth of a node | Number of edges from the root to that node |
| Height of a tree | Depth of its deepest leaf |
| Balanced | Left and right subtree heights differ by at most a small constant at every node |
| Directed vs. undirected graph | Whether an edge A → B implies B → A (undirected, as used above) or not |
| Adjacency matrix | O(V²) space, O(1) "is there an edge?" check |
| Adjacency list | O(V + E) space, O(degree) "is there an edge?" check |
| BFS | Explores level by level; finds shortest path in an unweighted graph |
| DFS | Explores as deep as possible before backtracking; simpler with recursion |
How It Actually Works¶
Recursive tree functions like tree_insert, tree_print_inorder, and
tree_free are silently using the call stack as their own traversal
stack — every recursive call pushes a new stack frame holding that call's
own root parameter and local variables, exactly as described for plain
recursion in Level 1, Module 4. This is why
an unbalanced tree (built from sorted input, degrading into a straight
chain) is dangerous beyond just slow search: tree_free's recursion on a
chain of 100,000 sorted-insert nodes would need 100,000 nested stack
frames simultaneously alive, which can exhaust the process's stack region
(commonly a few MB) and crash with a stack overflow — a balanced tree of
the same size needs only about 17 levels of recursion (log₂ 100,000), so
the practical failure mode of an unbalanced BST isn't just "slower," it's
"can crash a program that would otherwise run fine."
The reason in-order traversal visits a BST's values in sorted order is a direct, provable consequence of the insertion invariant, not a property you must separately verify: every node's entire left subtree was built from values compared as "less than" that node, and its entire right subtree from values "greater than" it, recursively, all the way down. Since in-order recursion always fully exhausts the left subtree (all smaller values) before visiting the node itself, then fully exhausts the right subtree (all larger values) afterward, the output is sorted by induction on subtree size — the same reasoning that shows pre-order (visit before recursing) naturally produces a valid rebuild sequence for copying a tree, since a node's value is emitted before either child, letting a copy routine insert nodes in an order that reconstructs the same shape.
graph_bfs's frontier queue reveals the real distinction between BFS and
DFS at the mechanism level, not just "order of visiting": BFS explicitly
maintains a FIFO queue as data — an array with head/tail indices that
persists across the whole traversal — so it can always resume from the
oldest still-undiscovered vertex, guaranteeing it explores everything at
distance 1 before anything at distance 2. DFS achieves its different
exploration order specifically because recursion (or an explicit stack)
processes the most recently discovered vertex next, diving one path as
deep as possible before backtracking — the underlying adjacency-list walk
is identical in both, only the discipline used to choose "which discovered
vertex to visit next" (FIFO versus LIFO) differs, which is the entire
mechanical difference between "explores level by level" and "explores as
deep as possible first."
Exercise¶
Add a tree_height(const TreeNode *root) function to bst.c that returns
0 for an empty tree and 1 + max(height(left), height(right)) otherwise.
Run it against the tree built in main (50, 30, 70, 20, 40, 60, 80) and
confirm it prints 3. Then insert values 1, 2, 3, 4, 5 into a fresh
empty tree (nothing else) and print the height again — it should come out
as 5, demonstrating the unbalanced-degrades-to-a-list problem described
above.