Skip to content

Lecture 10 - Hash Tables: An Array of Linked Lists

Last time we built a linked list and paid a price for it: to find a value you must walk the list, one node at a time - O(n). Today we fix that. We want a structure that answers "what value goes with this key?" in roughly one step, no matter how much data it holds. The tool is the hash table, and it is built out of the exact linked-list machinery we wrote last lecture - an array of linked lists. First, though, let us see where a hash table sits among the other ways to look things up.

1. The search-complexity ladder

Every structure below answers the same question - look up a value by its key - but at a different cost. We will climb down the ladder O(n) -> O(log n) -> O(1). The first three are sketched in pseudocode on the board only; the point is the cost, not the code.

Parallel arrays (unsorted) - O(n). Back in Lecture 7 we motivated structs by pointing at parallel arrays: two arrays kept in lockstep, one for keys and one for values. To look up a key you scan the key array until you find it:

keys    = [ "cat", "dog", "ax" ]
values  = [    3,     5,    1  ]

lookup(target):
    for i in 0 .. n-1:
        if keys[i] == target:
            return values[i]
    return NOT_FOUND        # worst case: touched every slot -> O(n)
  • This is the same O(n) as walking last lecture's linked list. Notice contiguity bought us nothing here: a[i] is instant only when you already know i, but we are searching by key, not by index, so we must look at every element.

Binary search tree (BST) - O(log n). Keep the keys ordered in a tree, each node with a smaller-keys-left and larger-keys-right child. Every comparison throws away half of what is left:

        (dog)
        /    \
     (cat)   (fox)

lookup(node, target):
    if node is empty:       return NOT_FOUND
    if target == node.key:  return node.value
    if target <  node.key:  return lookup(node.left,  target)
    else:                   return lookup(node.right, target)
                            # each step halves the tree -> O(log n)
  • Much better, but it still grows with n, it needs keys you can order (a < that makes sense), and a lopsided tree can rot back to O(n).

The dream - O(1). What if, instead of comparing the key against others, we could compute the slot the key belongs in and jump straight there - no scanning, no comparisons? That is the hash table. Our running target for the whole lecture is a word-frequency counter: read text and map each word to how many times it appeared ("the" -> 12). The array does it slowly; the BST does it with extra machinery; the hash table does it in about one step.

  • One honest caveat we will return to in Section 8: that O(1) is an average, not a guarantee. Keep it in the back of your mind.

2. The hash function

The whole trick rests on one idea: turn a key into an array index. A hash function takes a key and returns a number. We then fold that number into a valid bucket index with %:

bucket index = hash(key) % nbuckets

If our keys are numbers, we could use just the number itself as the hash. For example, if the key is 42 and we have 16 buckets, 42 % 16 = 10, so we put the value for key 42 in buckets[10]. What are the consequences of that choice? Here are some properties that we need from a hash function to make a O(1) hash table work:

  1. The same key always lands in the same bucket, so we can find it again.
  2. Different keys can land in the same bucket, so we need a way to handle that (Section 3).
  3. Will the buckets fill evenly? If the keys are all multiples of 16, they all land in bucket 0 and the table is useless. A good hash function spreads keys across the buckets.

To spread keys, we can use more sophisicated math. For example, multiply the key by a large prime and then %:

bucket index = (key * 2654435761) % nbuckets

The effect of the multiplication by a prime is we don't have any factors for prime numbers that could cause crowding in those buckets.

Our keys are strings, so we need a hash that chews through a string and produces a number. Here is a classic small one, djb2:

unsigned long hash_string(const char *key) {
    unsigned long h = 5381;
    for (int i = 0; key[i] != '\0'; i++) {
        h = h * 33 + (unsigned char) key[i];   /* the djb2 step, letter by letter */
    }
    return h;
}
  • It walks the string to the '\0' - the same walk-to-the-terminator loop from the strings lecture - mixing each character into a running number h.
  • Two properties are all we need. It is deterministic: the same key always produces the same number, so a key always lands in the same bucket. And it spreads keys out: similar strings ("cat", "cap") land in very different buckets, so the buckets fill evenly.
  • Why unsigned long? Because the multiplications overflow constantly, and we want that to wrap cleanly rather than wander into negative numbers. If h were a signed int and went negative, h % nbuckets could be negative - and a negative array index is out of bounds. Unsigned arithmetic keeps the result in 0 .. nbuckets-1 after the %.

So the bucket for a key is one line:

unsigned long i = hash_string(key) % ht->nbuckets;   /* which bucket this key lives in */

Note: Hash functions are a deep topic, and there are different types of hash function designs for different use cases. A notable variant of hash functions are cryptographic hash functions, which are designed to be secure and resistant to collisions and has non-invertible properties. The hash functions we are using here are intended just for routing keys to buckets in a hash table, and are engineered for speed and bucket distribution rather than security.

In-class exercise: Part A, Exercise A1 (pen and paper) - compute the bucket for a handful of words by hand on a tiny table, and find the collision.


3. Collisions and separate chaining

Here is the catch. A hash function maps a huge space of possible keys (every string) onto a small number of buckets. By the pigeonhole principle, two different keys must sometimes land in the same bucket. Even with only a few keys and many buckets, coincidences happen. A collision is two keys wanting the same slot, and it is not a rare accident - it is guaranteed. So the question is not "how do I avoid collisions" but "what do I do when they happen."

Our answer is separate chaining, and it is the payoff of last lecture: make each bucket the head of a linked list. All the keys that hash to bucket i live in the little list hanging off buckets[i]. To find a key you jump to its bucket (the fast array step) and then walk that one short list (the linked-list step) looking for it.

Picture the table as an array of chains:

buckets[0] ─▶ NULL
buckets[1] ─▶ ["cat" | 3 | ─]─▶ ["ax" | 1 | NULL]     (both hashed to bucket 1)
buckets[2] ─▶ ["dog" | 5 | NULL]
buckets[3] ─▶ NULL
  • Each box is one entry: its key, its value, and a next pointer to the following entry in the same bucket. An empty bucket is just NULL - an empty list, the same "empty list is head == NULL" idea from last lecture.
  • "cat" and "ax" collided into bucket 1, and the chain absorbed it: they simply sit in the same little list. Arrays for the fast jump, linked lists to soak up the collisions - exactly the two structures working together.
  • Board aside (name it, do not build it): the other classic strategy is open addressing - keep everything in one flat array and, on a collision, probe to the next empty slot. It is a fine design, but we are building chaining because we already own the linked-list toolkit.

4. The data structure

We need two types. First, the entry - one key-value pair, plus a next so it can live in a chain. It is self-referential, so, just like last lecture's node, it must keep its struct tag:

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;

Second, the table itself: an array of chain heads, and a count of how many buckets that array has.

struct hash_table {
    entry_t **buckets;    /* array of nbuckets chain heads (each an entry_t *) */
    int       nbuckets;   /* number of buckets */
};
typedef struct hash_table hash_table_t;
  • Read entry_t **buckets carefully: buckets points at an array, and each element of that array is itself an entry_t * (the head of one chain). A pointer to a pointer, but nothing new - it is just "an array of the head pointers we drew above."

To create a table we allocate the struct, allocate the bucket array, and set every bucket to NULL so each chain starts empty:

hash_table_t *ht_create(int nbuckets) {
    hash_table_t *ht = malloc(sizeof(hash_table_t));
    if (ht == NULL) {
        return NULL;
    }
    ht->buckets = malloc(nbuckets * sizeof(entry_t *));
    if (ht->buckets == NULL) {
        free(ht);                 /* undo the first malloc before bailing out */
        return NULL;
    }
    ht->nbuckets = nbuckets;
    for (int i = 0; i < nbuckets; i++) {
        ht->buckets[i] = NULL;    /* every chain starts empty */
    }
    return ht;
}
  • malloc(nbuckets * sizeof(entry_t *)) reserves room for nbuckets head pointers, not for the entries themselves - the entries get their own mallocs later, one per key, exactly as each list node did last lecture.
  • The loop that NULLs every bucket is not optional: fresh malloc memory holds garbage, and a garbage "head" pointer is not an empty list. (One-line alternative you will meet later: calloc hands back zeroed memory and does this for you.)

5. Looking up a value: ht_get

Lookup is the two-step dance the whole design was built for: hash to the bucket, then walk that one chain. The walk is last lecture's traversal, and the comparison is strcmp, because we are matching key contents, not addresses.

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 (strcmp(e->key, key) == 0) {   /* same contents? */
            *out_value = e->value;        /* hand the value back through the pointer */
            return 1;                     /* found */
        }
    }
    return 0;                             /* not in the table */
}
  • We only ever walk one bucket's chain, never the whole table. If the hash spreads keys well, that chain is short - a handful of entries - so the lookup is effectively constant time.
  • strcmp(e->key, key) == 0, never e->key == key. Two strings with the same letters can sit at different addresses; == compares the addresses and would say "different" for two equal words. strcmp compares the actual characters and returns 0 when they match. This is the single most common hash-table bug.
  • Why the out-parameter int *out_value instead of just returning the value? Because "the key is not present" has to be distinguishable from "the value happens to be 0." So we return a found/not-found flag (1 or 0) and write the value through the pointer - the out-parameter pattern from the pointers lecture. The caller writes:
int count = 0;
if (ht_get(ht, "cat", &count)) {
    /* count now holds the value for "cat" */
}

In-class exercise: Part B, Exercise B2 (on the computer) - write ht_get and test it on a key that is present and one that is not.


6. Inserting or updating: ht_put

ht_put has to do two jobs with one name. If the key is already in the table, we update its value in place. If it is new, we insert it. Both start by walking the bucket's chain, just like ht_get:

void ht_put(hash_table_t *ht, const char *key, int value) {
    unsigned long i = hash_string(key) % ht->nbuckets;

    /* already here? update in place and we are done */
    for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
        if (strcmp(e->key, key) == 0) {
            e->value = value;
            return;
        }
    }

    /* new key: build an entry that owns its own copy of the key string */
    entry_t *e = malloc(sizeof(entry_t));
    if (e == NULL) {
        return;                        /* out of memory: leave the table unchanged */
    }
    e->key = malloc(strlen(key) + 1);  /* +1 for the '\0' */
    if (e->key == NULL) {
        free(e);
        return;
    }
    strcpy(e->key, key);
    e->value = value;

    e->next = ht->buckets[i];          /* push the new entry on the front ... */
    ht->buckets[i] = e;                /* ... it is the new head of this chain */
}
  • The table owns its keys. We do not store the caller's key pointer - the caller's buffer may be reused or freed the moment we return. Instead we malloc(strlen(key) + 1) and strcpy a private copy, the heap-owned-string idiom from the structs lecture. That copy is ours to free later.
  • The insert is just push_front from last lecture, applied to buckets[i]: point the new entry's next at the old head, then make the new entry the head. O(1), no matter how long the chain.
  • Update-in-place is what makes a hash table a map, not a pile. Without the first loop, putting "cat" twice would leave two "cat" entries, and ht_get would return whichever we happened to reach first. Checking for the key before inserting keeps each key unique.

Now the capstone falls out in a few lines. To count words, read each one, get its current count (0 if unseen), and put it back one higher:

int main(void) {
    hash_table_t *ht = ht_create(16);
    char word[64];

    while (scanf("%63s", word) == 1) {
        int count = 0;
        ht_get(ht, word, &count);     /* count stays 0 if the word is new */
        ht_put(ht, word, count + 1);
    }

    /* print every entry by walking all the chains */
    for (int i = 0; i < ht->nbuckets; i++) {
        for (entry_t *e = ht->buckets[i]; e != NULL; e = e->next) {
            printf("%s: %d\n", e->key, e->value);
        }
    }

    ht_free(ht);                       /* Section 7 */
    return 0;
}

In-class exercise: Part B, Exercise B3 (on the computer) - write ht_put, then test insert, update (put the same key twice), and a collision.


7. Freeing the whole table: ht_free

The table owns two levels of heap memory: the entries and their keys inside each chain, and the bucket array holding the chains. Freeing it means unwinding both, inside out. It is last lecture's free_list run once per bucket - with the same save next before you free rule - plus one extra free for each key and two more at the end:

void ht_free(hash_table_t *ht) {
    for (int i = 0; i < ht->nbuckets; i++) {
        entry_t *cur = ht->buckets[i];
        while (cur != NULL) {
            entry_t *next = cur->next;   /* SAVE next before freeing anything */
            free(cur->key);              /* free the owned key ... */
            free(cur);                   /* ... then the entry itself */
            cur = next;
        }
    }
    free(ht->buckets);                   /* then the array of chain heads */
    free(ht);                            /* then the table struct */
}
  • Order is everything, and it runs inside out: free each entry's key, then the entry, walking the chain; only once a whole chain is gone do we move on; only once every chain is gone do we free ht->buckets; and only then ht. Free ht->buckets too early and you have thrown away the pointers you still needed to reach the chains.
  • Every malloc we ever made has exactly one matching free: one per entry, one per key, one for the bucket array, one for the struct. Run the word counter under valgrind and you should see zero leaks and zero invalid frees.
  • As last lecture, the caller's ht pointer is dangling after this returns, so the caller should set ht = NULL.

In-class exercise: Part B, Exercise B4 (on the computer) - write ht_free and confirm the whole program is valgrind-clean.


8. How fast is it, really?

The number that governs a hash table's speed is the load factor: the number of stored entries divided by the number of buckets, n / nbuckets. It is the average chain length.

  • Keep the load factor small - say, below 1 or 2 - and every chain is a step or two long, so ht_get and ht_put do a constant amount of walking. That is the average-case O(1) we promised at the top of the ladder.
  • But it is only average. In the worst case, a bad hash function (or bad luck) piles every key into one bucket. Then that bucket is a single long linked list, the other buckets are empty, and lookup is back to walking all n entries - O(n). A hash table with one bucket is exactly last lecture's linked list. The array of buckets only helps when the hash spreads keys across it.
  • Two things buy the good case: a hash that spreads well (that is djb2's whole job) and enough buckets to keep the load factor low.

9. Wrap-up

  • A hash table maps keys to values in average O(1) by turning each key into a bucket index with a hash function (hash(key) % nbuckets) instead of searching for it. That beats the parallel-array scan (O(n)) and the BST (O(log n)) from the top of the lecture.
  • Because different keys can collide into the same bucket, we resolve collisions by separate chaining: each bucket is the head of a linked list, so the table is literally an array of linked lists - last lecture's nodes, reused.
  • The core operations are the two-step "hash to a bucket, then walk that one chain": ht_get walks and compares with strcmp (never ==); ht_put walks to update in place, or push_fronts a new entry that owns a copy of its key (malloc(strlen+1)/strcpy). ht_free unwinds inside out - key, entry, bucket array, struct - saving next before each free.
  • Speed rides on the load factor n / nbuckets: small means short chains and O(1); a table where everything collides degrades to a single O(n) list.
  • Cliffhanger: we chose nbuckets up front. If we guess too small, the chains grow long and we slide back toward O(n). Next time we let the table grow itself: when the load factor gets too high, allocate a bigger bucket array and rehash every entry into it - the hash-table cousin of the dynamic array that grew by copying. The stretch exercise gets you started.