Topic 04

Hash Tables

Data Structures

A hash table answers "what goes with this key" in the same time whether it holds ten keys or ten million. A hash function turns the key into an array index (see Arrays and Dynamic Arrays), and the answer sits at that index or a few slots away. The structure runs underneath every Python dictionary and set, every module's global variables, every in-memory cache and a database's hash joins.

Its constant time has a price: memory kept empty on purpose, a hash function that spreads keys evenly, and now and then a full rebuild. Lantern's index, from about 1.5 million terms to the records that contain them, is a hash table, and it is the reason one server can answer a search without reading the catalogue.

From Key to Slot

The hash function maps a key of any size to a large integer, 64 bits in CPython. The table reduces that integer to a slot number; when the table size is a power of two, it keeps the low bits. A lookup is then one hash, one array read at that slot and one key comparison to confirm that the entry found really is the key asked for.

One key, one hash, one slot
"dispossessed"hash function64-bit numberending …1011low 4 bits= 110123456789101112131415a 16-slot table: one read at slot 11, then one comparison to confirm the key

So a lookup is O(1) in the number of keys and O(length) in the key, because the whole key is hashed. CPython pays that once per string and then keeps the hash inside the string object. On CPython 3.15, hashing a 10-megabyte string took 3 milliseconds the first time and well under a microsecond the second.

Collisions Are Certain

There are far more possible keys than slots, so some keys will share a slot, and much sooner than intuition expects. In a table of about four million slots, the size CPython would use for Lantern's terms, a collision becomes more likely than not after only about 2,400 keys. This is the birthday effect: the chance grows with the number of pairs of keys, and pairs grow with the square of the keys.

So every hash table needs a policy for collisions, and the two in use differ in where the colliding keys go.

Chaining and Open Addressing

Chaining keeps a small list of entries per slot, and a lookup walks the list at its slot. Java's HashMap works this way, and since Java 8 it turns a chain longer than eight entries into a balanced tree (Chapter 6). Open addressing stores every entry in the array itself. On a collision it probes a sequence of other slots until it finds the key or an empty slot. Python's dictionary and set work this way, and so do the current built-in maps of Rust and, since version 1.24, Go.

The same five keys, chained and open-addressed
chaining: a list per slotopen addressing: probe the next slots01234567antcatelkbeedog01ant2cat3elk4bee56dog7elk hashes to slot 1,finds 1 and 2 takenand settles in slot 3

Open addressing wins on cache behaviour (Chapter 4): there are no chain nodes to hop between, and the probe reads slots of one array. It has one subtlety. Deleting a key cannot leave its slot empty, or a later lookup whose probe sequence passed through that slot would stop early and miss its key. So the slot gets a tombstone, a marker that says "deleted, keep probing", and tombstones are cleared only when the table is rebuilt.

Load Factor and Resizing

The load factor is the number of keys divided by the number of slots. As it rises, chains lengthen and probe sequences run into each other, so the table resizes at a threshold: two thirds full for CPython's dictionary, three quarters for Java's HashMap. Resizing allocates a larger array and reinserts every key. The insert that triggers it costs O(n); the average stays O(1), by the same amortized argument as a list's growth (Chapter 1).

Where a CPython 3.15 dictionary resizes
d = {}
for i in range(3_000_000):
    d[i] = None       # resizes at 6, 11, 22, 43, 86, 171 ... 699,051, 1,398,102 and 2,796,203 keys

The listing inserts three million keys and notes each length at which the dictionary's size jumped. Each jump comes when the table would pass two thirds full, and each one moves the table to the next power of two. For Lantern's 1.5 million terms, the dictionary resized at 1,398,102 keys to a table of 4,194,304 slots, and with string keys the dictionary structure alone, before the strings and lists it points to, measured 62 megabytes on CPython 3.15. At least a third of the table is always empty on purpose: memory spent to buy speed (Chapter 1).

Lantern's Index

Lantern's inverted index maps each of its about 1.5 million terms to that term's postings list, the sorted ids of the records that contain it. Looking up the term dispossessed is one hash, one probe and one comparison, and it returns a short list with record 1048576, The Dispossessed, among the ids. Scanning the 2 million records instead would read every one of them (Chapter 1). On disk the same map is kept in term order, as the B-tree of Chapter 6.

Lantern's index: a hash table from terms to postings lists
term (hashed to a slot)postings list: sorted record idsdispossessed1048576earthsea120331112033121734405guin1048576120331112033121734405…library10002131450907…millbrook1622048about 1.5 million terms; record 1048576 is The Dispossessed

A three-term query is three lookups plus the work of combining three postings lists, however large the catalogue grows. That is how one server answers 300 searches a second at the Saturday-morning peak. Doubling the catalogue would roughly double the scan and leave each lookup where it is.

Order, Memory and the Occasional Rebuild

A hash table has no notion of order. The query year greater than 1970 cannot be answered by hashing years, a prefix such as le cannot be found by hashing it (Chapter 6), and "the next term after this one" does not exist in a hash table. Its O(1) is also an average that assumes an even spread, and an attacker who makes every key collide turns each operation into O(n), which is the next topic.

The everyday bill is memory: the table itself, its empty third, and two or three 8-byte words per entry, for the key, the value and, unless the key is a string that already carries it, the hash. The key and value objects come on top. Deleting keys does not shrink it. On CPython 3.15, a dictionary that held a million keys still took 42 megabytes after every key was deleted. And one insert in a while pauses for a full rebuild, on whichever request happens to trigger it.

Misconceptions
  • "A hash table lookup is O(1), always." It is O(1) on average, assuming an even spread and a bounded load, plus the cost of hashing the key. The worst case is O(n), and an attacker can choose it (see Hash Functions and Their Attacks).
  • "An extra dictionary lookup costs nothing." Each one is a hash, a probe and a key comparison, and on a table of millions of entries, far larger than any cache, it is a likely cache miss of about 100 nanoseconds (Chapter 4).
  • "A dictionary is either sorted or randomly ordered." CPython's dictionary keeps insertion order, a language guarantee since Python 3.7. A set keeps no order at all.
  • "Any object can be a key." A key's hash must not change while it is in the table. A mutable key changed after insertion stays filed under its old hash and is never found again, which is why Python refuses lists as dictionary keys.
  • "Deleting keys shrinks the table." Deletion leaves tombstones and the table keeps its size until a later resize, so a dictionary that once held a million keys keeps a table sized for a million.
Why It Matters
  • Use a dictionary or a set whenever the question is "is it there" or "what goes with it". One probe replaces a scan.
  • Keep keys immutable and cheap to hash: short strings, integers and tuples of them, never mutable objects. A key whose hash changes is lost inside the table.
  • Reach for a sorted structure when a query has a range, a prefix or a "next" (Chapter 6). The hash table threw order away to get its speed.
  • Budget memory for the empty slots. A table of n keys reserves room for about 1.5n entries or more.
RelatedHash functions and flooding where the average-case promise is attacked (see Hash Functions and Their Attacks)Bloom filters answer "definitely not here" in far less memory (Chapter 9)B-trees the ordered alternative that answers ranges (Chapter 6)Consistent hashing maps keys to machines, not slots; belongs to the future System Design course

Knowledge Check

Why does a hash table lookup depend on the length of the key but not on the size of the table?

  • Longer keys collide more often, while table size is hidden by resizing
  • The whole key is hashed, but the slot is then found by arithmetic
  • Short keys are stored in the cache, while long keys always live in RAM
  • Large tables are split into small ones, so the size never really grows

What does raising a hash table's maximum load factor from two thirds to 0.9 trade?

  • Less memory and fewer collisions, at the cost of slower hashing
  • More memory and faster lookups, at the cost of more frequent resizing
  • Less memory per key, at the cost of longer probes and chains
  • Nothing measurable, since resizing keeps lookups at O(1) whatever the load

Why must open addressing leave a tombstone when it deletes a key, instead of an empty slot?

  • So that the deleted key can be restored if the program asks for it again
  • So that the table's size stays a power of two after the deletion
  • An empty slot would end later probes early and hide keys beyond it
  • So that the next insert of the same key lands in the very same slot

Which of these Lantern queries can its hash-table index answer directly?

  • All records containing the term dispossessed
  • All records published after 1970
  • All terms that start with the letters le
  • The term that comes right after guin

A CPython dictionary is filled to a million keys, one insert at a time. Which inserts pay for a resize, and what do they pay?

  • Every insert pays a little, since each one rebuilds part of the table
  • None of them, because CPython sizes the table for a million keys up front
  • Only the millionth insert, which copies the table into a larger array
  • The few that pass two thirds full; each reinserts every key so far

You got correct