Hash Tables
Finding an item in a list means checking item after item; a hash table computes where the item belongs instead, so lookups take about the same time among a million items as among ten. How it works: a hash function turns each key into a number, the remainder picks a bucket, collisions share a bucket, and the table doubles when it fills. A hash table built by hand in Python; dict and set as hash tables, measured fifty thousand times faster than a list; why Python's string hashes change from run to run; why lists can't be dictionary keys; and bash's own hash table of commands.
- 10 min
- 8 steps
- 3 questions
- Lesson 76 of 80
In this lesson
- Finding without searching
- How a hash table works
- Building one
- Python’s dict and set
- Keys can’t change
- Hash tables everywhere
- Your turn
- So
Picking up where you left off.
Finding without searching
To answer x in items for a list, Python compares x with each item in turn until it finds it or runs out: O(n) 1. That’s fine for ten items and slow for ten million. A hash table avoids the search: it computes where an item belongs from the item itself, and looks only there. Python’s dict and set are hash tables 2.
This program asks the same question of a list and of a set holding the same million numbers, choosing the last number, the worst case for a scan. Save it as lookup.py:
# lookup.py: is a number in the collection? A list scans; a set hashes.
import time
N = 1_000_000
as_list = list(range(N))
as_set = set(as_list)
for name, coll in [("list", as_list), ("set", as_set)]:
start = time.perf_counter()
for _ in range(100):
found = (N - 1) in coll # the last number: worst case for a scan
each = (time.perf_counter() - start) / 100
print(f"{name:<5} {each * 1e6:10.2f} microseconds per lookup")
On a desktop PC:
me@linuxbox:~$ python3 lookup.py
list 3043.62 microseconds per lookup
set 0.06 microseconds per lookup
About fifty thousand times faster, and the gap grows with the data: a list twice as long takes twice as long to scan, while the set’s lookup stays about the same.
Quick check
x in my_set take 0.06 microseconds while x in my_list took 3 milliseconds?Membership in a set is O(1) on average; in a list it’s O(n).
How a hash table works
A hash table with chaining is an array of buckets, each a short list 3. To store a key:
- A hash function turns the key into an integer, its hash.
- The remainder after dividing the hash by the number of buckets picks a bucket.
- The key and its value go into that bucket’s list.
To look a key up, do steps 1 and 2 again and search only that bucket. Two keys that land in the same bucket are a collision; with chaining they simply share the bucket’s list 3.
What keeps lookups fast is keeping buckets short. The table keeps the number of items no more than the number of buckets, so buckets average one item or fewer; when it gets fuller than that, it doubles the number of buckets and places every item again 3, the same doubling trick the array used. A hash function must give equal keys equal hashes, or a lookup would search the wrong bucket, and should give different keys different hashes as often as possible, so collisions stay rare 3.
Quick check
That’s a collision, handled by chaining. Keeping buckets short, by doubling the table as it fills, keeps those comparisons few.
Building one
This hash table uses a simple hash function for strings: start at 0, and for each character multiply the total by 31 and add the character’s code, keeping the result to 32 bits. Hashes built from a polynomial like this are a standard way to hash strings 3. Save it as hashtable.py:
# hashtable.py: a small hash table with chaining, built on a Python list of lists.
def string_hash(s):
"""Turn a string into a big number: each character shifts the total and adds its code."""
h = 0
for ch in s:
h = (h * 31 + ord(ch)) % 2**32
return h
class HashTable:
def __init__(self, size=8):
self.buckets = [[] for _ in range(size)]
self.count = 0
def _bucket(self, key):
return self.buckets[string_hash(key) % len(self.buckets)]
def put(self, key, value):
bucket = self._bucket(key)
for pair in bucket:
if pair[0] == key: # key already there: replace its value
pair[1] = value
return
bucket.append([key, value])
self.count += 1
if self.count > len(self.buckets):
self._grow() # keep buckets short: about one item each
def get(self, key):
for k, v in self._bucket(key): # only one short bucket is searched
if k == key:
return v
raise KeyError(key)
def _grow(self):
pairs = [pair for bucket in self.buckets for pair in bucket]
self.buckets = [[] for _ in range(2 * len(self.buckets))]
self.count = 0
for k, v in pairs:
self.put(k, v)
stock = HashTable()
for wood, boards in [("oak", 12), ("ash", 7), ("pine", 30), ("birch", 4), ("maple", 9), ("elm", 2)]:
stock.put(wood, boards)
for k in ["oak", "ash", "pine"]:
print(f"{k!r:8} hashes to {string_hash(k):>8}, bucket {string_hash(k) % 8}")
for i, bucket in enumerate(stock.buckets):
print(i, bucket)
print("pine:", stock.get("pine"))
me@linuxbox:~$ python3 hashtable.py
'oak' hashes to 109785, bucket 1
'ash' hashes to 96886, bucket 6
'pine' hashes to 3441008, bucket 0
0 [['pine', 30], ['birch', 4]]
1 [['oak', 12]]
2 []
3 []
4 []
5 [['maple', 9]]
6 [['ash', 7], ['elm', 2]]
7 []
pine: 30
Six woods in eight buckets: four landed alone, and two pairs collided and share a bucket. get("pine") hashed "pine" to bucket 0 and compared at most two keys. With a million items in a million or so buckets, it would still compare about one or two.
Python’s dict and set
CPython’s dictionaries are resizable hash tables, and sets work the same way 2 1. On average, key in d, d[key], d[key] = value, and del d[key] are all O(1). The catch is “on average”: if every key hashed to the same value, they’d all share one bucket and each operation would become O(n) 1.
That worst case could be forced on purpose: someone who knew the hash function could feed a program thousands of keys that all collide, making a dictionary of them take O(n²) time to build. So Python salts the hashes of strings with a random value chosen when each Python process starts 4. Within one run a string’s hash never changes; between runs it does:
me@linuxbox:~$ python3 -c 'print(hash("oak"), {"oak", "ash", "pine", "elm"})'
-5631937654534608314 {'oak', 'pine', 'ash', 'elm'}
me@linuxbox:~$ python3 -c 'print(hash("oak"), {"oak", "ash", "pine", "elm"})'
7551618212067019069 {'elm', 'pine', 'ash', 'oak'}
me@linuxbox:~$ python3 -c 'print(hash(42), hash(7))'
42 7
The set’s order changed too: a set is listed in the order of its internal table, so different hashes mean a different order 4. Never rely on the order of a set. A dict, by contrast, always keeps its keys in the order they were added 5. Small integers just hash to themselves.
Keys can’t change
A key’s hash decides its bucket when it’s stored. If the key could change afterward, its hash would change, and a lookup would search a different bucket and never find it. So dictionary keys must be immutable: strings, numbers, and tuples, but not lists 2.
me@linuxbox:~$ python3 -c 'd = {}; d[[1, 2]] = "x"'
Traceback (most recent call last):
File "<string>", line 1, in <module>
TypeError: unhashable type: 'list'
To key a dictionary by a sequence, convert it to a tuple: d[(1, 2)] = "x" works 2.
Quick check
Use a tuple, which can’t change, when you need a sequence as a key.
Hash tables everywhere
Bash keeps a hash table of the commands you’ve run, mapping each name to its full path, so it doesn’t search every directory in PATH each time 6. The hash command shows it, with how often each was used:
me@linuxbox:~$ hash
hits command
3 /usr/bin/ls
1 /usr/bin/date
1 /usr/bin/sort
If you install a program somewhere earlier in PATH and bash keeps running the old one, hash -r makes it forget 6.
Counting is a dictionary job: counts[word] = counts.get(word, 0) + 1 for each word in a file tallies a whole book in one pass.
Git names every object by a SHA-1 hash of its contents (Git course). That’s a different, much longer kind of hash, used as a fingerprint to check the data’s integrity 7, but the idea is the same: a fixed-size number computed from the data.
Your turn
Exercises
- Work out
string_hash("oak")by hand:ois 111,ais 97,kis 107. Does it match? - Add
("cherry", 5), ("walnut", 3), ("cedar", 8)to the woods inhashtable.py. How many buckets are there now, and where didoakgo? - Run
python3 -c 'print(hash("oak"))'twice. Then runPYTHONHASHSEED=0 python3 -c 'print(hash("oak"))'twice. What changes? - Write a short program that counts how often each word appears in a text file, using a dict, and prints the ten most common.
- Make a dict of a few points on a grid, keyed by
(x, y)tuples. Why does a tuple work as a key when a list doesn’t?
Answers
111→111 × 31 + 97 = 3538→3538 × 31 + 107 = 109785. Yes.- Nine items is more than eight buckets, so the table doubled to 16.
oakmoved from bucket 1 to bucket 9 (109785 % 16), sharing it withcherry. - Without the variable, the hash differs each run.
PYTHONHASHSEED=0turns the randomization off, so the same value appears both times 8. -
For example:
import sys counts = {} for word in open(sys.argv[1], encoding="utf-8").read().lower().split(): counts[word] = counts.get(word, 0) + 1 for word in sorted(counts, key=counts.get, reverse=True)[:10]: print(counts[word], word) -
A tuple can’t change after it’s made, so its hash can’t change either; a list could be changed after being stored, stranding it in the wrong bucket 2.
So
A hash table computes where each key belongs: a hash function turns the key into a number, and the remainder picks a bucket, so a lookup searches one short bucket instead of everything. Collisions share a bucket; doubling the table as it fills keeps buckets short and operations O(1) on average. Python’s dict and set are hash tables, fifty thousand times faster than a list for membership among a million items. String hashes are salted per run, so set order changes; keys must be immutable; and the same idea runs bash’s command lookup.
Lesson complete
Nice work.
Sources for this lesson
- 1Time complexity of operations on built-in types (Python documentation). Python Software Foundation. verifiedCosts of operations on list, tuple, dict, set, str, and range in CPython, in big-O notation. list: append O(1) amortized, get and set item O(1), insert(k, x) and pop(k) O(n - k), so index 0 moves the whole list, x in l O(n); for adding and removing at both ends, use collections.deque. dict and set: key in d, get, set, and delete O(1) on average, assuming few collisions; O(n) in the worst case when every key hashes alike.
- 2Design and History FAQ (Python documentation). Python Software Foundation. verifiedCPython's lists are variable-length arrays, not linked lists: a contiguous array of references, so indexing costs the same whatever the size or index; when the array must grow, extra space is allocated so the next few appends don't need a resize. Dictionaries are resizable hash tables: a key's hash code, from hash(), varies widely and depends on a per-process seed, and picks a location in an internal array, so lookups take constant time on average. Dictionary keys must be immutable, because a key whose value changed would have a different hash and could no longer be found; use a tuple instead of a list.
- 3Pat Morin. Open Data Structures (in pseudocode), Edition 0.1G beta. opendatastructures.org (paperback by AU Press). verifiedFree textbook under a Creative Commons Attribution license. Used: ch. 1, the need for efficiency (a million searches of a million items is 10^12 inspections, over 16 minutes at a billion operations a second), interfaces versus implementations, the Queue (FIFO), Stack (LIFO), and Deque; ch. 2, array-based lists (constant-time get and set, add and remove at i costing O(n - i) for the shifting, growing by allocating an array of twice the size and copying, amortized O(1) growth); ch. 3, singly and doubly linked lists (nodes with references; get(i) walks from the head; insert or delete next to a known node in constant time); ch. 5, hash tables with chaining (an array of lists, hash(x) choosing the list, n kept at most the table length so lists average one item, doubling and reinserting when full) and hash codes (equal objects must have equal hash codes, unequal ones should rarely collide); ch. 6, binary trees (root, parent, child, leaf, depth, height) and the binary search tree property, O(n) worst case when unbalanced; ch. 7, random binary search trees, with search paths of length at most about 2 ln n; ch. 12, graphs (vertices, directed edges, paths, cycles; adjacency matrix with O(n^2) space versus adjacency lists with O(n + m); breadth-first search with a queue and a seen array, O(n + m), visiting vertices in order of distance and giving shortest paths; depth-first search, breadth-first search with a stack instead of a queue, used to detect cycles); ch. 14, B-trees, whose nodes have between B and 2B children and fit in one external-memory block, the primary data structure in file systems including ext4, NTFS, and HFS+, every major database, and cloud key-value stores.
- 4Data model (Python language reference). Python Software Foundation. verifiedobject.__hash__: by default, hashes of str and bytes objects are salted with an unpredictable random value; they stay constant within one Python process but differ between runs, to protect against denial-of-service attacks with inputs chosen to make dict insertion O(n^2). This also changes the iteration order of sets between runs.
- 5Built-in Types (Python documentation). Python Software Foundation. verifiedMapping types, dict: dictionary order is guaranteed to be insertion order (a language guarantee since 3.7, a CPython implementation detail from 3.6).
- 6Chet Ramey, Brian Fox. Bash Reference Manual, Edition 5.3. GNU Project, Free Software Foundation. 2025. verifiedThe reference for Bash 5.3 (May 18, 2025). Redirections (3.6) are processed left to right and order matters: ls > dirlist 2>&1 sends both streams to dirlist, while ls 2>&1 > dirlist sends only standard output there. With set -o noclobber, > fails on an existing regular file and >| overrides it. &> word is equivalent to > word 2>&1 and &>> word to >> word 2>&1. Here documents (<<word, with <<- stripping leading tabs; quoting word disables expansion) and here strings (<<<). Expansions (3.5) happen in a fixed order: brace; tilde, parameter, arithmetic, and command substitution left to right; word splitting; filename expansion; quote removal last. Startup files (6.2): an interactive login shell reads /etc/profile then the first of ~/.bash_profile, ~/.bash_login, ~/.profile; an interactive non-login shell reads ~/.bashrc. HISTCONTROL (ignorespace, ignoredups, ignoreboth), HISTSIZE, HISTFILESIZE; set -x traces expanded commands; shell functions and variables, export.
- 7Scott Chacon, Ben Straub. Pro Git, 2nd edition. Apress; free online at git-scm.com. 2014. verifiedFree CC BY-NC-SA 3.0 book, maintained online. Ch. 1: version control; Git's 2005 origin when the Linux kernel lost free use of BitKeeper; snapshots, not differences (unchanged files stored once); nearly every operation local; integrity through 40-character SHA-1 checksums; the three states (modified, staged, committed) and three areas (working tree, staging area or index, .git directory); first-time setup with system/global/local config levels, user.name and user.email baked into commits, core.editor, git config --list --show-origin. Ch. 2: git init, status (and -s), add, diff and diff --staged, commit (-m, -a), .gitignore, log options, amending, undoing, remotes, tags, aliases. Ch. 3: branches as movable pointers, HEAD, merging and conflicts, remote branches, rebasing and its rule. Ch. 7: reset demystified, stashing, revision selection. Ch. 8: core.autocrlf true on Windows, input on Linux and macOS. Ch. 10: objects (blob, tree, commit) and references.
- 8Command line and environment (Python documentation). Python Software Foundation. verifiedPYTHONHASHSEED: unset or random, a random value seeds the hashes of str and bytes objects; an integer is used as a fixed seed, for repeatable hashing; 0 disables hash randomization.