Skip to content

Handout: Lecture 10 In-Class Exercises

Part A is pen-and-paper: hash a few words into buckets by hand and draw the resulting array-of-lists, then spot two classic hash-table bugs. Part B is at the keyboard: build the hash table one operation at a time - the hash function, lookup, insert-or-update, and the nested free. None of these repeats an earlier exercise; they drill today's moves - the hash function, the bucket index, separate chaining over last lecture's linked lists, strcmp on keys, and heap-owned key copies.

Try each one yourself first; we will discuss in class, and the solutions are in a separate document afterward. Compile with warnings on:

clang -Wall -Wextra -std=c17 myprog.c -o myprog

Reminders that will keep you out of trouble today:

  • A bucket index is hash_string(key) % ht->nbuckets. Keep the hash unsigned so the % never gives a negative index.
  • Each bucket is the head of a linked list; an empty bucket is NULL. To search a bucket, walk it: for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next).
  • Compare keys with strcmp(e->key, key) == 0, never e->key == key - == compares addresses, not letters.
  • The table owns its keys: on insert, malloc(strlen(key) + 1) and strcpy a private copy. On free, that copy needs its own free.
  • To free the table, save cur->next before freeing, free each entry's key then the entry, then free ht->buckets, then ht. One free per malloc.

Set up

mkdir -p ~/cmsc14300/lec10
cd ~/cmsc14300/lec10

Start every keyboard exercise from these includes, type definitions, and a given ht_create. You will write hash_string in B1 and reuse it from then on.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct entry {
    char         *key;    /* heap-owned copy of the key string */
    int           value;  /* the value mapped to this key */
    struct entry *next;   /* next entry in this bucket's chain, or NULL */
};
typedef struct entry entry_t;

struct hash_table {
    entry_t **buckets;    /* array of nbuckets chain heads */
    int       nbuckets;   /* number of buckets */
};
typedef struct hash_table hash_table_t;

/* given: allocate a table with all buckets empty (NULL) */
hash_table_t *ht_create(int nbuckets) {
    hash_table_t *ht = malloc(sizeof(hash_table_t));
    ht->buckets = malloc(nbuckets * sizeof(entry_t *));
    ht->nbuckets = nbuckets;
    for (int i = 0; i < nbuckets; i++) {
        ht->buckets[i] = NULL;
    }
    return ht;
}

Part A - Hashing by hand (pen and paper)

Exercise A1 - Put five words in their buckets

For this exercise only, use a toy hash that is easy to compute by hand: add up the alphabet positions of the letters (a = 1, b = 2, ..., z = 26), then take % nbuckets. Use nbuckets = 5. (The real hash_string you write in B1 is stronger, but the bucketing idea is identical.)

Hash these five words: cat, dog, ax, bee, ox.

  1. Compute the bucket index for each word. (For example, dog is 4 + 15 + 7 = 26, and 26 % 5 = 1, so dog lands in bucket 1.)
  2. Two of these words collide into the same bucket. Which two, and which bucket?
  3. Draw the whole table as an array of chains, buckets[0] through buckets[4], showing each chain (use push_front, so the most recently inserted word is at the head of its chain).
  4. Suppose you had built the table with nbuckets = 1 instead. Draw it. What structure is it now, and what does that do to lookup time?

Exercise A2 - Spot the bug

Each function below has one bug that today's rules warn about. Name the bug and fix it.

/* (a) return 1 and write the value if key is present, else return 0 */
int ht_get(hash_table_t *ht, const char *key, int *out_value) {
    unsigned long i = hash_string(key) % ht->nbuckets;
    for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
        if (e->key == key) {            /* <-- */
            *out_value = e->value;
            return 1;
        }
    }
    return 0;
}
/* (b) free the whole table */
void ht_free(hash_table_t *ht) {
    free(ht->buckets);
    free(ht);
}
  1. In (a): the comparison finds nothing even for a key that is in the table. What is being compared instead of the letters, and what should it be?
  2. In (b): the program runs but valgrind reports leaked blocks. What memory never gets freed? Rewrite ht_free so every malloc has its free.

Part B - At the keyboard

Exercise B1 - hash_string and the bucket index

Write the djb2 hash and use it to print which bucket each word lands in.

unsigned long hash_string(const char *key);
nbuckets = 8
cat -> bucket 3
dog -> bucket 1
ax  -> bucket 6
cap -> bucket 4
  • Start h at 5381 (an unsigned long), and for each character do h = h * 33 + (unsigned char) key[i], walking to the '\0'.
  • In main, pick an nbuckets (say 8) and print hash_string(word) % nbuckets for several words. Your exact bucket numbers will differ from the sample - what matters is that the same word always gives the same bucket, and different words spread out.
  • Try enough words and you will see two land in the same bucket: a collision. That is what B3's chaining handles. (cat and cap differ only in the last letter but should land in different buckets - notice how djb2 spreads them.)

Exercise B2 - ht_get: look up a value

Before you have an insert function, build a small table by hand to test against, the way you hand-built a linked list last lecture. Make two entries, strcpy their keys, and link them into their buckets. Then write ht_get.

int ht_get(hash_table_t *ht, const char *key, int *out_value);   /* 1 found, 0 not */
get "cat" -> found, value 3
get "dog" -> found, value 5
get "fox" -> not found
  • Hash the key to a bucket, then walk that one chain comparing with strcmp(e->key, key) == 0. On a match, write *out_value and return 1; if the walk reaches NULL, return 0.
  • To build the test table: entry_t *e = malloc(sizeof(entry_t));, then e->key = malloc(strlen("cat") + 1); strcpy(e->key, "cat"); e->value = 3;, and splice it into ht->buckets[hash_string("cat") % ht->nbuckets].
  • Return the flag, not the value: a missing key must be distinguishable from a value of 0.

Exercise B3 - ht_put: insert or update

Write ht_put, then use it to build a real table with no hand-linking.

void ht_put(hash_table_t *ht, const char *key, int value);
put "cat" 3
put "dog" 5
put "cat" 9      (same key again: updates, does not duplicate)
get "cat" -> 9
  • First walk the key's bucket. If strcmp finds the key already there, set e->value = value and return - that is the update case.
  • Otherwise malloc a new entry, give it an owned copy of the key (malloc(strlen(key) + 1) then strcpy), set its value, and push_front it onto the bucket (e->next = ht->buckets[i]; ht->buckets[i] = e;).
  • Test all three cases: a fresh insert, an update (put "cat" twice with different values and confirm ht_get returns the second), and a collision (two different keys that land in the same bucket - confirm both are findable).

Exercise B4 - ht_free: free every block

Write ht_free and confirm the whole program is leak-free under valgrind.

void ht_free(hash_table_t *ht);
valgrind ./myprog
# ... All heap blocks were freed -- no leaks are possible
  • For each bucket, walk the chain: save cur->next first, then free(cur->key), then free(cur), then advance. After the loop over all buckets, free(ht->buckets) and finally free(ht).
  • Build a table with B3, print it, then free it, and run the whole thing under valgrind. You should see zero leaks and zero invalid frees.

Stretch - take it further

Remove a key

Add ht_remove, which deletes the entry for a key (if present) and frees both the entry and its key. Return 1 if something was removed, 0 if the key was not there.

int ht_remove(hash_table_t *ht, const char *key);
put "cat" 3, put "dog" 5
remove "cat" -> 1
get "cat" -> not found
remove "cat" -> 0   (already gone)
  • This is last lecture's delete_value surgery, but inside one bucket and keyed by string. Walk the bucket with prev and cur, stopping when strcmp(cur->key, key) == 0. If prev == NULL you are removing the chain's head (ht->buckets[i] = cur->next); otherwise splice (prev->next = cur->next). Then free(cur->key) and free(cur).

Grow the table (rehash)

When chains get long, a table performs better with more buckets. Write ht_resize, which builds a bigger table and re-inserts every entry into it.

hash_table_t *ht_resize(hash_table_t *ht, int new_nbuckets);   /* returns the new table */
  • Make a new table with ht_create(new_nbuckets). Walk every chain of the old table and ht_put each entry's key and value into the new one - each key gets rehashed into a bucket of the bigger array. Then ht_free the old table and return the new.
  • Notice why you cannot just copy the old buckets over: a key's bucket index depends on nbuckets, so changing the count sends keys to different buckets. This is the cliffhanger from the notes, and the hash-table version of a dynamic array growing by copying.