Skip to content

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:

          (42)
         /    \
      (25)     (63)
     /    \    /    \
  (12)  (30)(55)   (70)
  1. The root is 42 (the first key inserted). The leaves are 12, 30, 55, 70.
  2. 28: from 42 go left (28 < 42) to 25; from 25 go right (28 > 25) to 30; from 30 go left (28 < 30) into the empty spot. So 28 becomes the left child of 30.
  3. Inserting the sorted sequence 5 10 15 20 25 - every key is larger than the last, so each one goes right:
 (5)
    \
    (10)
       \
       (15)
          \
          (20)
             \
             (25)
  1. The sorted insert produced a right-leaning chain - a linked list in disguise - of height 4 (four edges from 5 down to 25). search on it is O(n): to find 25 you visit all five nodes. The tree from part 1 is balanced, height 2, so search there is O(log n).

  2. 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

  1. Inorder (left, self, right): 1 3 4 6 7 8 10 13 14
  2. Preorder (self, left, right): 8 3 1 6 4 7 10 14 13
  3. Postorder (left, right, self): 1 4 7 6 3 13 14 10 8
  4. 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.

  5. 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->left reads 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 = []
  1. Visit order: 0 1 2 3 4 5.
  2. Rings by distance from 0: distance 0 = {0}, distance 1 = {1, 2}, distance 2 = {3, 4} (and 5 is at distance 3).
  3. BFS from 5: visit order 5 3 4 1 2 0 (5, then its neighbors 3, 4, then 1, 2, then 0).

  4. 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 4 would be enqueued by both 1 and 2), 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;
}
$ printf '5\n50 30 70 40 90\n40 55 90\n' | ./b1
40: found
55: not found
90: found
  • insert recurses into exactly one child and reassigns the returned subtree so a newly created leaf is actually attached; the top-level root = insert(...) is the same move. search shares one base-case line for "off the end" (NULL) and "found" (key matches).
  • Check your understanding: on sorted input the tree is a right chain, so search for the largest key visits all n nodes - 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;
}
inorder: 30 40 50 70 90
height : 2
  • The inorder output is sorted, as every BST's is. tree_height returns -1 for the empty tree so a single node comes out 0 and the tree above comes out 2.
  • Check your understanding: free_tree must be postorder because it reads root->left and root->right to recurse. Freeing root first and then reading root->left dereferences freed memory - a use-after-free. Doing both child recursions before free(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;
}
0: -> 3 -> 1
1: -> 2 -> 0
2: -> 3 -> 1
3: -> 4 -> 2 -> 0
4: -> 3
  • graph_create uses calloc so every adjacency-list head starts NULL. add_edge calls push_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:

bfs from 0: 0 3 1 4 2
  • The queue is an array with head/tail indices; enqueue writes at tail++, dequeue reads at head++. Marking on enqueue keeps each vertex out of the queue after its first sighting.
  • Check your understanding: if you marked on dequeue, vertex 2 would be enqueued twice here - once by 3 and once by 1, both of which reach it before it is dequeued. The traversal still terminates because the queue is finite and only drains, but 2 gets 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 from 0: 0 3 4 2 1
connected: yes
  • DFS visit order (0 3 4 2 1) differs from BFS (0 3 1 4 2): DFS plunges down 0 -> 3 -> 4, backs up when it dead-ends, then takes 3 -> 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 a return.
  • Connectivity: one traversal from 0 marks every vertex reachable from 0. If any visited[v] is still 0 afterward, v is unreachable and the graph is not connected. Delete the {3,4} edge (remove add_edge(g, 3, 4);) and rerun: vertex 4 is never marked, so the check prints connected: 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).