Solutions: Lecture 14 In-Class Exercises¶
Solutions to the Lecture 14 in-class exercises. Try each exercise yourself before reading these. Every C solution starts from:
#include <stdio.h>
#include <stdlib.h>
struct tnode { int key; struct tnode *left, *right; };
typedef struct tnode tnode_t;
Compile with clang -Wall -Wextra -std=c17 myprog.c -o myprog.
Part A - Reasoning on paper (pen and paper)¶
Exercise A1 - Build a BST, then break it¶
Inserting 42 25 63 12 30 55 70 into an empty tree, each key walks down (smaller
left, larger right) until it hits an empty spot:
- The root is
42(the first key inserted). The leaves are12,30,55,70. 28: from42go left (28 < 42) to25; from25go right (28 > 25) to30; from30go left (28 < 30) into the empty spot. So28becomes the left child of30.- Inserting the sorted sequence
5 10 15 20 25- every key is larger than the last, so each one goes right:
-
The sorted insert produced a right-leaning chain - a linked list in disguise - of height 4 (four edges from
5down to25).searchon it is O(n): to find25you visit all five nodes. The tree from part 1 is balanced, height 2, sosearchthere is O(log n). -
Check your understanding: what determines the shape is the insertion order, not the set of keys. A BST places each key relative to the ones already there, so already-sorted input makes every key the new far-right node and builds a chain. That chain has no left subtrees to discard, so a search cannot halve anything - it degrades to walking a list, losing the O(log n) the BST exists to provide. (Balanced-tree variants like AVL or red-black trees rearrange on insert to prevent exactly this.)
Exercise A2 - Read a tree three ways¶
- Inorder (left, self, right):
1 3 4 6 7 8 10 13 14 - Preorder (self, left, right):
8 3 1 6 4 7 10 14 13 - Postorder (left, right, self):
1 4 7 6 3 13 14 10 8 -
The inorder sequence is special: for a BST it comes out in sorted order. That is the ordering invariant read left-to-right - everything in a node's left subtree (all smaller) is emitted before the node, and everything in its right subtree (all larger) after it.
-
Check your understanding: free the nodes in postorder. You may only free a node after both of its subtrees are gone, because freeing it first and then evaluating
root->leftreads a freed node to find its children - a use-after-free, undefined behaviour. Postorder frees both children first, so by the time you free a node nothing needs to look inside it again.
Exercise A3 - Hand-simulate a breadth-first traversal¶
BFS from 0, marking on enqueue and tracing the queue after each dequeue:
enqueue 0 queue = [0]
dequeue 0 -> visit 0; enqueue 1,2 queue = [1, 2]
dequeue 1 -> visit 1; enqueue 3,4 queue = [2, 3, 4]
dequeue 2 -> visit 2; (0,4 seen) queue = [3, 4]
dequeue 3 -> visit 3; enqueue 5 queue = [4, 5]
dequeue 4 -> visit 4; (all seen) queue = [5]
dequeue 5 -> visit 5 queue = []
- Visit order:
0 1 2 3 4 5. - Rings by distance from
0: distance 0 ={0}, distance 1 ={1, 2}, distance 2 ={3, 4}(and5is at distance 3). -
BFS from
5: visit order5 3 4 1 2 0(5, then its neighbors3, 4, then1, 2, then0). -
Check your understanding: the visited array stops the cycle - a vertex is enqueued only the first time it is seen, so BFS never revisits it and cannot loop. If you marked on dequeue instead, a vertex could be enqueued by several neighbors before its turn (here
4would be enqueued by both1and2), so it would appear in the queue twice and be visited twice. The traversal still terminates - the queue is finite and drains - but it does redundant work, which is why we mark on enqueue.
Part B - At the keyboard¶
Exercise B1 - Insert and search a BST¶
#include <stdio.h>
#include <stdlib.h>
struct tnode { int key; struct tnode *left, *right; };
typedef struct tnode tnode_t;
static tnode_t *new_node(int key) {
tnode_t *n = malloc(sizeof(tnode_t));
if (n == NULL) return NULL;
n->key = key;
n->left = n->right = NULL;
return n;
}
tnode_t *insert(tnode_t *root, int key) {
if (root == NULL) return new_node(key);
if (key < root->key) root->left = insert(root->left, key);
else if (key > root->key) root->right = insert(root->right, key);
return root; /* reattach and hand back */
}
tnode_t *search(tnode_t *root, int key) {
if (root == NULL || root->key == key) return root;
if (key < root->key) return search(root->left, key);
return search(root->right, key);
}
static void free_tree(tnode_t *root) {
if (root == NULL) return;
free_tree(root->left);
free_tree(root->right);
free(root);
}
int main(void) {
int n;
if (scanf("%d", &n) != 1) return 0;
tnode_t *root = NULL;
for (int i = 0; i < n; i++) {
int k;
if (scanf("%d", &k) != 1) break;
root = insert(root, k); /* reassign the returned root */
}
int q;
while (scanf("%d", &q) == 1) {
printf("%d: %s\n", q, search(root, q) ? "found" : "not found");
}
free_tree(root);
return 0;
}
insertrecurses into exactly one child and reassigns the returned subtree so a newly created leaf is actually attached; the top-levelroot = insert(...)is the same move.searchshares one base-case line for "off the end" (NULL) and "found" (keymatches).- Check your understanding: on sorted input the tree is a right chain, so
searchfor the largest key visits allnnodes - that is the degenerate case from A1. The code is correct either way; only the shape, and therefore the cost, changed.
Exercise B2 - Inorder print, height, and free¶
void inorder_print(tnode_t *root) {
if (root == NULL) return;
inorder_print(root->left);
printf("%d ", root->key);
inorder_print(root->right);
}
int tree_height(tnode_t *root) {
if (root == NULL) return -1; /* empty tree: -1 so a leaf is 0 */
int lh = tree_height(root->left);
int rh = tree_height(root->right);
return 1 + (lh > rh ? lh : rh);
}
void free_tree(tnode_t *root) {
if (root == NULL) return;
free_tree(root->left); /* children first ... */
free_tree(root->right);
free(root); /* ... then the node (postorder) */
}
int main(void) {
int keys[] = { 50, 30, 70, 40, 90 };
tnode_t *root = NULL;
for (int i = 0; i < 5; i++) root = insert(root, keys[i]);
printf("inorder: ");
inorder_print(root);
printf("\n");
printf("height : %d\n", tree_height(root));
free_tree(root);
return 0;
}
- The inorder output is sorted, as every BST's is.
tree_heightreturns-1for the empty tree so a single node comes out0and the tree above comes out2. - Check your understanding:
free_treemust be postorder because it readsroot->leftandroot->rightto recurse. Freeingrootfirst and then readingroot->leftdereferences freed memory - a use-after-free. Doing both child recursions beforefree(root)guarantees nothing looks inside a node after it is gone.
Exercise B3 - Build a graph as an array of linked lists¶
#include <stdio.h>
#include <stdlib.h>
struct adj { int to; struct adj *next; };
typedef struct adj adj_t;
struct graph { int nverts; adj_t **buckets; };
typedef struct graph graph_t;
graph_t *graph_create(int nverts) {
graph_t *g = malloc(sizeof(graph_t));
if (g == NULL) return NULL;
g->nverts = nverts;
g->buckets = calloc((size_t) nverts, sizeof(adj_t *)); /* all NULL */
if (g->buckets == NULL) { free(g); return NULL; }
return g;
}
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]; /* push_front onto u's list */
g->buckets[u] = node;
}
void add_edge(graph_t *g, int u, int v) {
add_directed(g, u, v);
add_directed(g, v, u); /* undirected: store both ends */
}
void print_graph(const graph_t *g) {
for (int u = 0; u < g->nverts; u++) {
printf("%d:", u);
for (adj_t *e = g->buckets[u]; e != NULL; e = e->next) {
printf(" -> %d", e->to);
}
printf("\n");
}
}
int main(void) {
graph_t *g = graph_create(5);
add_edge(g, 0, 1);
add_edge(g, 0, 3);
add_edge(g, 1, 2);
add_edge(g, 2, 3);
add_edge(g, 3, 4);
print_graph(g);
/* graph_free omitted here; see B4 */
return 0;
}
graph_createusescallocso every adjacency-list head startsNULL.add_edgecallspush_front(from Lecture 9) twice, once for each direction, because an undirected edge belongs to both endpoints' lists.- Check your understanding: the one thing a hash table needed that this does
not is a hash function (and with it, collision handling). A hash table
had arbitrary keys and had to compute a bucket, where different keys could
collide. Here the "key" is the vertex number, already in
0 .. V-1, so it is the array index - direct indexing, no hashing, no collisions.
Exercise B4 - Breadth-first traversal¶
Add bfs (and a proper graph_free) to B3:
void bfs(const graph_t *g, int start) {
int *visited = calloc((size_t) g->nverts, sizeof(int));
int *queue = malloc((size_t) g->nverts * sizeof(int));
int head = 0, tail = 0;
visited[start] = 1; /* mark on enqueue, never twice */
queue[tail++] = start;
printf("bfs from %d:", start);
while (head < tail) { /* queue not empty */
int u = queue[head++]; /* dequeue */
printf(" %d", u);
for (adj_t *e = g->buckets[u]; e != NULL; e = e->next) {
if (!visited[e->to]) {
visited[e->to] = 1;
queue[tail++] = e->to; /* enqueue */
}
}
}
printf("\n");
free(visited);
free(queue);
}
void graph_free(graph_t *g) {
for (int u = 0; u < g->nverts; u++) {
adj_t *e = g->buckets[u];
while (e != NULL) {
adj_t *next = e->next; /* save next before free (Lec 9) */
free(e);
e = next;
}
}
free(g->buckets);
free(g);
}
Calling bfs(g, 0) after building the B3 graph:
- The queue is an array with
head/tailindices; enqueue writes attail++, dequeue reads athead++. Marking on enqueue keeps each vertex out of the queue after its first sighting. - Check your understanding: if you marked on dequeue, vertex
2would be enqueued twice here - once by3and once by1, both of which reach it before it is dequeued. The traversal still terminates because the queue is finite and only drains, but2gets visited twice - wasted work that marking-on-enqueue avoids.
Stretch - take it further¶
Depth-first traversal, and is the graph connected?¶
/* DFS: the call stack is the queue. Mark, visit, recurse into unvisited
neighbors. */
void dfs(const graph_t *g, int u, int *visited) {
visited[u] = 1;
printf(" %d", u);
for (adj_t *e = g->buckets[u]; e != NULL; e = e->next) {
if (!visited[e->to]) {
dfs(g, e->to, visited);
}
}
}
int main(void) {
graph_t *g = graph_create(5);
add_edge(g, 0, 1);
add_edge(g, 0, 3);
add_edge(g, 1, 2);
add_edge(g, 2, 3);
add_edge(g, 3, 4);
int *visited = calloc(5, sizeof(int));
printf("dfs from 0:");
dfs(g, 0, visited);
printf("\n");
int connected = 1; /* did we reach every vertex? */
for (int v = 0; v < g->nverts; v++) {
if (!visited[v]) connected = 0;
}
printf("connected: %s\n", connected ? "yes" : "no");
free(visited);
graph_free(g);
return 0;
}
- DFS visit order (
0 3 4 2 1) differs from BFS (0 3 1 4 2): DFS plunges down0 -> 3 -> 4, backs up when it dead-ends, then takes3 -> 2 -> 1; BFS fans out ring by ring. DFS needs no explicit queue - each recursive call is a stack frame (Lecture 6), and "back up" is areturn. - Connectivity: one traversal from
0marks every vertex reachable from0. If anyvisited[v]is still0afterward,vis unreachable and the graph is not connected. Delete the{3,4}edge (removeadd_edge(g, 3, 4);) and rerun: vertex4is never marked, so the check printsconnected: no. - The sentence to keep: BFS and DFS reach the same set of vertices from a start, so either answers "connected?"; they differ only in order. BFS's order is set by the queue (breadth, nearest first); DFS's by the call stack (depth, follow one path to its end first).