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 knowi, 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 %:
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:
- The same key always lands in the same bucket, so we can find it again.
- Different keys can land in the same bucket, so we need a way to handle that (Section 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 %:
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 numberh. - 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. Ifhwere a signedintand went negative,h % nbucketscould be negative - and a negative array index is out of bounds. Unsigned arithmetic keeps the result in0 .. nbuckets-1after the%.
So the bucket for a key is one line:
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
nextpointer to the following entry in the same bucket. An empty bucket is justNULL- an empty list, the same "empty list ishead == 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 **bucketscarefully:bucketspoints at an array, and each element of that array is itself anentry_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 fornbucketshead pointers, not for the entries themselves - the entries get their ownmallocs later, one per key, exactly as each list node did last lecture.- The loop that NULLs every bucket is not optional: fresh
mallocmemory holds garbage, and a garbage "head" pointer is not an empty list. (One-line alternative you will meet later:callochands 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, nevere->key == key. Two strings with the same letters can sit at different addresses;==compares the addresses and would say "different" for two equal words.strcmpcompares the actual characters and returns0when they match. This is the single most common hash-table bug.- Why the out-parameter
int *out_valueinstead 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 (1or0) and write the value through the pointer - the out-parameter pattern from the pointers lecture. The caller writes:
In-class exercise: Part B, Exercise B2 (on the computer) - write
ht_getand 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
keypointer - the caller's buffer may be reused or freed the moment we return. Instead wemalloc(strlen(key) + 1)andstrcpya private copy, the heap-owned-string idiom from the structs lecture. That copy is ours to free later. - The insert is just
push_frontfrom last lecture, applied tobuckets[i]: point the new entry'snextat 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, andht_getwould 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 freeht->buckets; and only thenht. Freeht->bucketstoo early and you have thrown away the pointers you still needed to reach the chains. - Every
mallocwe ever made has exactly one matchingfree: one per entry, one per key, one for the bucket array, one for the struct. Run the word counter undervalgrindand you should see zero leaks and zero invalid frees. - As last lecture, the caller's
htpointer is dangling after this returns, so the caller should setht = NULL.
In-class exercise: Part B, Exercise B4 (on the computer) - write
ht_freeand 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_getandht_putdo 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
nentries - 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_getwalks and compares withstrcmp(never==);ht_putwalks to update in place, orpush_fronts a new entry that owns a copy of its key (malloc(strlen+1)/strcpy).ht_freeunwinds inside out - key, entry, bucket array, struct - savingnextbefore 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
nbucketsup 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.