Structs & Data Structures · advanced · ~15 min
## What you will learn - How a hash table turns a *key* into an array *index* so lookups run in amortised O(1) time (each operation is effectively constant on average, even if an occasional resize costs more). - How to write and reason about a **hash function**, and why `hash(key) % capacity` keeps the result inside the array bounds. - What a **collision** is and how to resolve it with **separate chaining** (a linked list per bucket) versus **open addressing** (linear probing). - How the **load factor** controls performance and when to **resize (rehash)** the table. - How to build a complete, memory-safe `string -> int` hash table in C11: insert, lookup, delete, and a full `free` of every allocation. - The common pitfalls — using raw `key % cap`, forgetting to handle deleted slots, and integer/overflow surprises in the hash function.
Imagine you have a phone book and you want the number for "Maria". With an unsorted array you would scan every entry — that is O(n). With a sorted array and binary search it is O(log n). A hash table does something cleverer: it computes where "Maria" should live from the letters in her name, then jumps straight there. On average that jump is O(1) — constant time, no matter how many entries the table holds.
A hash table is an array (called the bucket array) plus a hash function. The hash function takes a key — a string, an integer, anything — and produces a large integer. You then reduce that integer to a valid array index with the modulo operator:
index = hash(key) % capacity;
hash(key) mixes the bytes of the key into a number; % capacity folds that number into the range 0 .. capacity-1 so it is a legal subscript.
This lesson builds directly on Arrays — the bucket array is a plain C array, and the whole point is to compute an index into it instead of scanning. It also builds on Structs: each stored entry is a struct holding the key, the value, and (for chaining) a pointer to the next entry in the same bucket. If you understood how a struct groups related fields and how an array gives O(1) indexed access, you already have both halves of a hash table — the new idea is the function that decides which index a key belongs to.
Dictionaries, sets, caches, symbol tables in compilers, database indexes, and the Map/dict/HashMap types in nearly every high-level language are all hash tables underneath. When you need "look something up by name, fast", a hash table is usually the answer.
count / capacity — how full the table is.The difference between O(1) and O(n) is the difference between a program that scales and one that grinds to a halt. A spell checker holding 100,000 words, a web server caching 1,000,000 sessions, or a compiler tracking every variable name relies on lookups being near-instant. If those were linear scans, each lookup would touch every entry and the system would slow down in proportion to its own success.
Hash tables are also a correctness tool, not just a speed tool. A set (membership testing — "have I seen this value before?") is a hash table where you only care about the keys. De-duplicating a stream, detecting cycles, counting word frequencies, and joining two datasets on a shared key are all hash-table problems.
Finally, understanding hash tables makes you a better user of them. Knowing about load factors explains why a language's dictionary occasionally pauses (it is resizing). Knowing about collisions explains why a bad hash function can turn an O(1) structure into an O(n) one — a fact attackers have exploited (hash-flooding denial-of-service), which is why production libraries seed their hash functions with a random value.
Definition. A hash function maps a key of any size to a fixed-size integer (the hash code).
Plain language. It scrambles the bytes of the key so that even similar keys ("cat" and "car") land in very different places. A good hash spreads keys evenly across the whole array.
How it works internally. A common string hash is djb2: start with a seed, then for each byte multiply the running value and add the byte. The multiply-and-add mixes every character into the result.
unsigned long hash = 5381;
for each byte c in key:
hash = hash * 33 + c; /* 33 is a constant that mixes bits well */
For integer keys, Knuth's multiplicative hash works well:
index = ((uint32_t)k * 2654435761u) % cap;
When to use which. Match the hash to the key type. Strings need a function that reads every byte; integers can use a single multiply. Do not use the raw key (key % cap) — sequential or patterned keys then cluster.
Pitfall. A hash function that ignores part of the key (e.g. only the first character) collides constantly. Always consume the whole key.
Knowledge check (explain in your own words): Why does
hash(key) % capacityrather thanhash(key)give a valid array index?
Definition. A collision is when two distinct keys produce the same index.
Plain language. Even a perfect hash cannot avoid this: you are squeezing a huge space of possible keys into a small array, so two keys must sometimes share a slot (the pigeonhole principle). The table must store both without losing either.
Definition. Each bucket holds a linked list of entries; colliding keys are appended to the list for that bucket.
Structure (text diagram).
buckets[] linked lists
+-----+
| 0 | --> ["sky":7] --> NULL
+-----+
| 1 | --> NULL
+-----+
| 2 | --> ["cat":3] --> ["dog":9] --> NULL <- two keys collided here
+-----+
| 3 | --> ["sun":1] --> NULL
+-----+
How it works. To insert, compute the index, then walk the list: if the key already exists, update it; otherwise add a new node at the head. To look up, walk the list comparing keys. Average list length is the load factor, so with load factor near 1 each operation touches roughly one node.
When to use / not use. Use chaining when entries are large, deletions are frequent, or you cannot predict the number of keys (lists grow gracefully). Avoid it when you want maximum cache locality — each node is a separate malloc, so traversal jumps around memory.
Pitfall. Forgetting to free every node in every list on cleanup leaks memory.
Definition. All entries live directly in the array. On a collision you probe — check the next slot, then the next — until you find a free one.
Structure (text diagram).
Insert "dog" -> hash gives index 2, but slot 2 is taken by "cat",
so probe forward to slot 3.
index: 0 1 2 3 4
[ . ] [ . ] ["cat"] ["dog"] [ . ]
^ ^
hashed placed after probing
How it works. Lookup repeats the same probe sequence and stops at either the key (found) or an empty slot (not found). Deletion is tricky: you cannot just blank a slot, because that empty cell would break the probe chain for keys placed after it. The fix is a tombstone — a special "was deleted" marker that lookups skip over but inserts may reuse.
When to use / not use. Use open addressing for small fixed-size values and great cache performance (everything is in one contiguous array). Avoid it when the table gets very full — probe sequences lengthen sharply above ~0.7 load factor.
Pitfall. Blanking a deleted slot instead of using a tombstone makes later lookups return "not found" for keys that are actually present.
Knowledge check (predict the output): Keys A, B, C all hash to index 5 in a linear-probing table of capacity 8. In what slots do they end up after inserting A, then B, then C (assuming 6 and 7 are free)?
Definition. Load factor = count / capacity. It measures fullness.
How it works. As the load factor rises, collisions multiply and operations slow down. When it crosses a threshold (commonly ~0.7), you resize: allocate a larger array (usually double the capacity) and rehash — reinsert every existing entry, because index = hash(key) % capacity changes when capacity changes. Resizing is O(n), but it happens rarely, which is why the amortised cost per insert stays O(1).
Pitfall. Copying buckets to the new array without recomputing indices. The old indices are meaningless under the new capacity; you must rehash each key.
Knowledge check (find the bug): A teammate resizes by
memcpy-ing the old bucket array into a bigger one and freeing the old array. Lookups now fail for many keys. What did they forget?
#include <stdint.h> /* uint32_t, fixed-width integers */
#include <string.h> /* strcmp, strlen for string keys */
/* One stored entry; ->next links collisions in the same bucket. */
typedef struct Entry {
char *key; /* owned copy of the key string */
int value;
struct Entry *next; /* NULL = end of this bucket's chain */
} Entry;
typedef struct {
Entry **buckets; /* array of 'capacity' list heads */
size_t capacity; /* number of buckets */
size_t count; /* entries stored (for load factor) */
} HashTable;
/* djb2 string hash, reduced to a valid index. */
static size_t hash_index(const char *key, size_t cap) {
unsigned long h = 5381;
for (const unsigned char *p = (const unsigned char *)key; *p; p++)
h = h * 33u + *p; /* mix every byte of the key */
return (size_t)(h % cap); /* fold into 0 .. cap-1 */
}
Key points: the entry owns its key (we strdup it on insert and free it on removal), buckets is an array of pointers (each a list head), and count lets us compute the load factor without scanning.
A hash table stores entries in an array. Each entry's position is decided by its key.
To find the slot for a key, you compute:
index = hash(key) % capacity;
Here hash(key) turns the key into a number, and % capacity keeps that number inside the array bounds.
A collision happens when two different keys map to the same slot. You need a way to store both. Two common strategies:
Pick a hash function that suits your key type.
For integer keys, Knuth's multiplicative hash is fast and good enough for most uses:
index = ((uint32_t)k * 2654435761u) % cap;
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
typedef struct Entry {
char *key;
int value;
struct Entry *next;
} Entry;
typedef struct {
Entry **buckets;
size_t capacity;
size_t count;
} HashTable;
static size_t hash_index(const char *key, size_t cap) {
unsigned long h = 5381;
for (const unsigned char *p = (const unsigned char *)key; *p; p++)
h = h * 33u + *p;
return (size_t)(h % cap);
}
/* Create a table; returns NULL if allocation fails. */
HashTable *ht_create(size_t capacity) {
if (capacity == 0) capacity = 8;
HashTable *t = malloc(sizeof *t);
if (!t) return NULL;
t->buckets = calloc(capacity, sizeof *t->buckets); /* all NULL */
if (!t->buckets) { free(t); return NULL; }
t->capacity = capacity;
t->count = 0;
return t;
}
/* Grow and rehash when the load factor gets high. */
static int ht_resize(HashTable *t, size_t new_cap) {
Entry **nb = calloc(new_cap, sizeof *nb);
if (!nb) return 0; /* keep old table intact */
for (size_t i = 0; i < t->capacity; i++) {
Entry *e = t->buckets[i];
while (e) {
Entry *next = e->next; /* save before relinking */
size_t j = hash_index(e->key, new_cap); /* recompute index */
e->next = nb[j]; /* push onto new bucket */
nb[j] = e;
e = next;
}
}
free(t->buckets);
t->buckets = nb;
t->capacity = new_cap;
return 1;
}
/* Insert or update key -> value. Returns 1 on success, 0 on failure. */
int ht_put(HashTable *t, const char *key, int value) {
if ((double)(t->count + 1) / t->capacity > 0.7)
ht_resize(t, t->capacity * 2); /* best-effort grow */
size_t i = hash_index(key, t->capacity);
for (Entry *e = t->buckets[i]; e; e = e->next)
if (strcmp(e->key, key) == 0) { e->value = value; return 1; } /* update */
Entry *e = malloc(sizeof *e);
if (!e) return 0;
e->key = strdup(key); /* own our own copy */
if (!e->key) { free(e); return 0; }
e->value = value;
e->next = t->buckets[i]; /* prepend to chain */
t->buckets[i] = e;
t->count++;
return 1;
}
/* Look up key. Writes the value through out and returns 1 if found. */
int ht_get(const HashTable *t, const char *key, int *out) {
size_t i = hash_index(key, t->capacity);
for (Entry *e = t->buckets[i]; e; e = e->next)
if (strcmp(e->key, key) == 0) { *out = e->value; return 1; }
return 0;
}
/* Remove key. Returns 1 if it was present. */
int ht_remove(HashTable *t, const char *key) {
size_t i = hash_index(key, t->capacity);
Entry **link = &t->buckets[i]; /* pointer to the link to fix */
while (*link) {
Entry *e = *link;
if (strcmp(e->key, key) == 0) {
*link = e->next; /* unlink */
free(e->key);
free(e);
t->count--;
return 1;
}
link = &e->next;
}
return 0;
}
/* Free every node, every key, and the table itself. */
void ht_free(HashTable *t) {
if (!t) return;
for (size_t i = 0; i < t->capacity; i++) {
Entry *e = t->buckets[i];
while (e) { Entry *n = e->next; free(e->key); free(e); e = n; }
}
free(t->buckets);
free(t);
}
int main(void) {
HashTable *t = ht_create(4);
if (!t) { fprintf(stderr, "out of memory\n"); return 1; }
ht_put(t, "apples", 3);
ht_put(t, "bananas", 5);
ht_put(t, "apples", 10); /* updates existing key */
int v;
if (ht_get(t, "apples", &v)) printf("apples = %d\n", v);
if (ht_get(t, "bananas", &v)) printf("bananas = %d\n", v);
if (!ht_get(t, "cherries", &v)) printf("cherries not found\n");
ht_remove(t, "apples");
if (!ht_get(t, "apples", &v)) printf("apples removed\n");
ht_free(t); /* no leaks */
return 0;
}
What it does. It builds a table mapping fruit names to counts, demonstrates insert, update, lookup (hit and miss), deletion, and a complete cleanup.
Expected output:
apples = 10
bananas = 5
cherries not found
apples removed
Edge cases handled: allocation failure at every malloc/calloc/strdup; updating an existing key instead of duplicating it; resizing before the load factor exceeds 0.7; and ht_free(NULL) being safe. Compile with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined ht.c to catch leaks and undefined behaviour. (strdup is POSIX; if your toolchain rejects it, add #define _POSIX_C_SOURCE 200809L at the top or write a tiny copy helper.)
hash_index("apples", 4) — start h = 5381, then fold in each byte: h = h*33 + 'a', then + 'p', and so on for all six characters. The final h % 4 yields a number in 0..3. The whole string is consumed, so "apples" and "apricot" land in different chains.
ht_put(t, "apples", 3) with capacity 4:
| Step | What happens | State |
|---|---|---|
| Load-factor check | (0+1)/4 = 0.25 <= 0.7 |
no resize |
| Compute index | say hash_index returns 1 |
i = 1 |
| Scan chain | buckets[1] is NULL |
key not present |
| Allocate | malloc Entry, strdup("apples") |
owned copy |
| Prepend | e->next = NULL, buckets[1] = e |
chain has 1 node |
| Bookkeeping | count becomes 1 |
ht_put(t, "apples", 10) — same index 1. The scan loop finds the existing node (strcmp == 0), so it sets e->value = 10 and returns without allocating. This is why duplicate keys never accumulate.
ht_get(t, "apples", &v) — recompute the index (1), walk the chain, match on strcmp, copy 10 into *v, return 1. The caller prints apples = 10.
ht_get(t, "cherries", &v) — compute its index, walk that chain, no strcmp matches, reach the end (NULL), return 0. The caller prints the "not found" branch.
ht_remove(t, "apples") — the link variable is a pointer to a pointer: it points at the slot or next field that currently references the node. When we find the node, *link = e->next splices it out in one assignment regardless of whether it was the list head or in the middle. Then free(e->key) and free(e) release both allocations and count drops.
Why resize is correct. In ht_resize, each node is re-hashed with the new capacity (hash_index(e->key, new_cap)) and pushed onto the new bucket array. We save e->next before overwriting it, or we would lose the rest of the chain. Only after all nodes move do we free the old array.
/* WRONG */
size_t i = key_int % cap; /* no mixing at all */
Why it is wrong: patterned keys (1, 2, 3, ... or all multiples of cap) cluster into a few buckets, turning O(1) into O(n).
/* CORRECT */
size_t i = (size_t)((uint32_t)key_int * 2654435761u) % cap;
Recognise it when lookups slow down as the table grows even though the load factor is low — the keys are colliding because they are not being mixed.
/* WRONG */
e->key = (char *)key; /* caller may free or reuse this buffer */
Why it is wrong: the table now points at memory it does not own; if the caller frees or overwrites it, later lookups read garbage (a dangling pointer / use-after-free).
/* CORRECT */
e->key = strdup(key);
if (!e->key) { free(e); return 0; }
In a linear-probing table, setting a deleted slot back to "empty" cuts the probe chain, so keys inserted after it become unreachable. The fix is a tombstone marker that lookups skip but inserts can overwrite.
Copying buckets verbatim into a bigger array leaves every entry at an index computed for the old capacity. Lookups recompute with the new capacity and miss. Always reinsert (rehash) each entry, as ht_resize does.
/* WRONG */
void ht_free(HashTable *t) { free(t->buckets); free(t); }
This frees the array of list heads but not the nodes or their strdup'd keys. Walk and free every node first (see the full ht_free).
Compiler errors.
implicit declaration of function 'strdup': add #define _POSIX_C_SOURCE 200809L before the includes, or supply your own copy helper.comparison of integer expressions of different signedness: prefer size_t for capacities and indices to silence -Wextra warnings and avoid surprises.incompatible pointer type on calloc(cap, sizeof *t->buckets): make sure buckets is declared Entry **.Runtime errors.
buckets (allocation failed and you did not check) or dereferencing past the end of a chain. Run under AddressSanitizer (-fsanitize=address) — it pinpoints the bad access.leaks/Valgrind/ASan: you freed nodes but not e->key, or you freed the table but not the chains.Logic errors.
== (pointer identity) instead of strcmp (content). For strings always use strcmp.Questions to ask: Did every allocation get checked? Am I mixing the whole key? Do insert, lookup, and resize all use the same hash_index with the same capacity? Did I save next before relinking during resize?
strdup makes a copy; free it exactly once in ht_remove and ht_free. Storing the caller's pointer risks use-after-free; freeing it twice is a double-free.malloc, calloc, and strdup can return NULL. Dereferencing a NULL buckets or key is undefined behaviour (typically a crash). The example checks every one and unwinds partial allocations.% capacity. If capacity is ever 0, the modulo is undefined behaviour (division by zero); the constructor forces a minimum of 8.e->next before you overwrite e->next, or you orphan the rest of the chain — a memory leak, and the entries vanish from the table.h = h*33 + c on an unsigned long wraps around by definition — for unsigned types this is well-defined modular arithmetic, which is exactly what we want. Doing the same on a signed type would be undefined behaviour, so keep the accumulator unsigned.-fsanitize=address,undefined and run your tests; on macOS the leaks tool and Valgrind elsewhere confirm a clean teardown.dict, Java HashMap, JavaScript objects, Go map, and C++ unordered_map are all hash tables. When you write users[name], you are using one.Beginner rules:
key % cap.strcmp for string keys, not ==.hash_index function so insert, lookup, and resize stay consistent.Advanced rules:
% cap with a faster mask, or use a prime capacity to tame weak hashes.Objective: count how many times each word appears in a fixed array of strings.
Requirements: use the lesson's chaining table; for each word, ht_get it, add one (or insert with 1 if absent), then print every word with its count.
Example: input {"a","b","a"} -> a=2, b=1 (order may vary).
Hints: you only need ht_get and ht_put. Concepts: insert/update, lookup.
Objective: write int ht_contains(const HashTable *t, const char *key) returning 1 if the key exists, else 0.
Requirements: reuse hash_index; do not modify the table; no value output needed.
Constraints: O(1) average time. Hint: it is ht_get without the out parameter. Concepts: hashing, chain traversal.
Objective: add void ht_stats(const HashTable *t) that prints count, capacity, the load factor, and the longest chain length.
Requirements: walk every bucket; track the maximum chain length seen.
Output example: count=12 capacity=16 load=0.75 longest_chain=3.
Hints: a long chain at a low load factor signals a weak hash. Concepts: load factor, collisions.
Objective: add void ht_compact(HashTable *t) that halves the capacity (rehashing) when the load factor drops below 0.2, but never below capacity 8.
Requirements: reuse ht_resize; verify all keys are still findable afterwards.
Constraints: must not lose entries. Hint: mirror the grow path in ht_put. Concepts: rehashing, load factor.
Objective: reimplement the table with linear probing instead of chaining.
Requirements: store entries inline in the array; mark slots EMPTY, OCCUPIED, or TOMBSTONE; implement insert (reusing tombstones), lookup (skipping tombstones, stopping at EMPTY), delete (writing a tombstone), and resize (rehash, dropping tombstones).
Constraints: resize at load factor 0.7 counting tombstones; never enter an infinite probe loop. Input/output: same behaviour as the chaining version's main. Hints: the probe sequence is (start + i) % cap; lookup must stop at the first EMPTY but continue past TOMBSTONE. Concepts: open addressing, probing, tombstones, rehashing.
index = hash(key) % capacity, giving amortised O(1) lookup, insert, and delete.key % cap. Strings use functions like djb2; integers use a multiplicative hash.count / capacity) drives performance; resize and rehash around ~0.7 to keep operations near O(1).Entry **buckets, hash_index(key, cap), strdup for owned keys, strcmp for comparison, and a full free of every node and key.