◈ python mapVol 3 · Ch 37/47
Volume 3 Python, from the metal up · chapter 37

37Hashing — buying O(1) with space

In Chapter 36 we made things fast by halving: cut the problem in two, throw away the half that can't hold the answer, and pay O(log n). In this chapter we buy speed a different way — we spend memory to abolish searching altogether. Here's why that matters: almost every slow program is slow for one reason. Somewhere inside a loop it is searching, asking "is this thing in here?" and walking the whole collection to find out. The plan is to turn that walk into a jump. Feed a value to a hash, land on its slot, done. And the whole way through we keep asking the one question that matters — what if you never had to look for a thing at all, because you already knew where it lived? By the end you'll spot any nested loop whose inner half is a lookup, and you'll delete that inner loop on sight. That move collapses O(n²) into O(n) with nothing but a set or a dict. Volume 1 chapter 7 showed how a hash table works inside. Here we learn what to do with one.

★ YOU ALREADY RUN THIS · hashingThe coat check at midnight
Four hundred coats on rail after rail, and the woman at the counter never searches. You handed her your coat at eight and she handed you a paper tab: 47. At midnight you give the tab back, she walks to hook 47, and your coat is in your hands before you finish thanking her. She never looks at hook 1. She never compares your coat to anyone else's. The number was never a label on your coat — it decided where the coat went. And it cost her something real: four hundred hooks standing empty all evening, waiting for coats that might never arrive.
the tab number, 47hash(value) → a slot index
walking straight to hook 47one jump · O(1), no scan
reading every rail insteadthe linear scan · O(n)
hooks empty all eveningthe space you bought the speed with
two coats sharing one hooka collision — a short chain, then compare
pin it: nobody searches a coat check, because the ticket never described your coat — it decided where the coat went.
iolinked · chapter 37 — the checkpoints6 steps
$ sections covered in Hashing — buying O(1) with space
01The question that costs O(n) — and the trick that makes it free
02The set: membership and dedup in one pass
03The tally: counting with a dict (and Counter)
04The "seen" dict: how a hash deletes an inner loop
05The cache: answer each question once
06The bill: space, hashable keys, and the worst case

01The question that costs O(n) — and the trick that makes it free

Let's start with the most ordinary question a program ever asks: "is X in this collection?" Hand that question to a list and watch what Python is forced to do. It has no choice but to look. It starts at index 0 and compares, element by element, until it finds a match or falls off the end. If the thing isn't there, Python has touched every single element just to tell you so. That pattern is called a linear scan. Its cost grows in lockstep with the size of the list — one comparison per item, n items, so the bill is O(n). We refuse to hand-wave a claim like that, so we counted it:

superpower.pypython
def scan_ops(haystack, needle):
    ops = 0
    for x in haystack:          # look at one element…
        ops += 1
        if x == needle:         # …compare it…
            return ops          # …stop the instant we match
    return ops                  # a MISS touched everything

for n in (10, 100, 1000, 10000):
    print(n, scan_ops(list(range(n)), -1))   # -1 is absent → worst case
# 10 10 · 100 100 · 1000 1000 · 10000 10000

Read the code line by line: the loop visits elements one at a time, and each visit costs exactly one ops += 1. A match lets the function return early, but a value that isn't there forces the loop to the very end. The output tells the whole story, because the comparison count is exactly n. Double the list and you double the work. A million-item list means a million comparisons just to answer one "is it here?".

Now the trick: a set does not store your values in a row to be walked, and neither does a dict, its key-value sibling. Instead, it computes where the value belongs from the value itself. Feed the value to a hash function, get back a number, and use that number to jump straight to one slot. So membership is hash-the-query, jump, and look. That is one hash, one jump, one compare — a cost that does not grow with n at all. We call that O(1), "constant time." We timed the exact same "is this missing value here?" question against both:

Let's make "jump straight to one slot" concrete, because it can sound like magic the first time. A hash table is really just an array of slots, say 8 of them, numbered 0 through 7. The hash function turns any value into an integer, and the table takes that integer modulo the slot count to pick an index. Try it with a number, since Python hashes small integers to themselves. Storing 41 in an 8-slot table means computing 41 % 8, and the remainder is 1, so it lives in slot 1. Check that yourself: 8 goes into 41 five times, which uses up 40 and leaves 1 over. Looking up 41 later repeats the same arithmetic and lands on the same slot, with no search required. That is the whole jump: the address is computed from the data, not discovered by walking.

superpower.pypython
import timeit
for n in (1_000, 10_000, 100_000, 1_000_000):
    lst, st = list(range(n)), set(range(n))
    t_list = timeit.timeit(lambda: -1 in lst, number=1000) / 1000
    t_set  = timeit.timeit(lambda: -1 in st,  number=1000) / 1000
    print(n, round(t_list*1e9), "ns   vs", round(t_set*1e9), "ns")
# 1000       6006 ns  vs 65 ns
# 10000     61067 ns  vs 37 ns
# 100000   600563 ns  vs 38 ns
# 1000000 6392406 ns  vs 39 ns   (6.4 MILLISECONDS vs 39 nanoseconds)

Now read the two columns side by side, because they tell two completely different stories. The list column marches: 6 µs, 61 µs, 600 µs, 6.4 ms, a straight multiple of n, exactly as the op-count predicted. The set column does not move: it answers in ~38 nanoseconds whether it holds a thousand items or a million. At a million items the set is roughly 160,000 times faster at the same question. Timings are from this machine and will vary, but the shape is universal — one line climbing, one line flat.

list — walk every box until you match or run out (O(n)) 200 247 245 255 634 compare, step, compare, step, … up to n times set — hash the query, jump to the one slot (O(1)) hash(634) → slot 4 0 1 2 3 634 5 one probe — and it never gets slower as the set grows
Fig — A list makes you look. A hash lets you compute the address from the data — no search at all. That is the whole game.
InteractiveSlide n — watch the scan tower over the jump
work (log scale) list scan n compares set jump ≈1 probe
10,000
The set's answer costs the same at n=10 and n=1,000,000. That flat green line is what O(1) looks like.

One question, made free. But the real prize isn't one lookup — it's what happens when a lookup was hiding inside a loop

02The set: membership and dedup in one pass

A set is a bag of unique, hashable values with no order and no duplicates — a dict that kept only the keys. Hashable simply means Python can turn the value into a stable number, and we'll pay that word its full bill later in this chapter. Two everyday jobs fall out of this design for free. The first is membership, because x in some_set is exactly the O(1) jump you just met. The second is deduplication, which means collapsing a collection down to its distinct values. A set physically cannot hold the same value twice, so adding a value that's already there is a no-op:

set.pypython
plays = ["Levels", "Titanium", "Levels", "Wake Me Up", "Titanium", "Levels"]
unique = set(plays)
print(unique)         # {'Wake Me Up', 'Levels', 'Titanium'}  (order not preserved)
print(len(unique))    # 3  — six plays, three distinct songs

That set(plays) call is a single pass: it hashes each of the six songs and drops it into its slot, while repeats land on an occupied slot and simply vanish. One line, O(n), done. Now compare the beginner's instinct: a nested loop that asks "have I seen this before?" by scanning a growing result list. That approach is O(n²) for the exact same answer, because every new item rescans everything kept so far. The set replaces that inner scan with a hash.

It's worth counting what the beginner's version actually costs, because the numbers are the argument. Say every play in a 1,000-song history is distinct, the worst case for that inner scan. The first item scans a result list of length 0, the second scans 1, and the thousandth scans 999. Add the staircase up and you get 0 + 1 + … + 999, which is 499,500 comparisons. The set version hashes each play once, so the same job costs 1,000 hashes — roughly 500 times less work. Notice the check: 499,500 is 1000 × 999 ÷ 2, the every-pair triangle we will meet again in two-sum.

The reflex to build
The instant you catch yourself writing if x not in result: where result is a list, stop. If you only need to know "have I seen it?", make result a set — the in test drops from O(n) to O(1) and the whole loop from O(n²) to O(n). Same code shape, a different container, a different universe of speed.
plays — 6 items, with repeats Levels Titanium Levels Wake Me Up Titanium Levels dashed = duplicate, hashes to a taken slot → dropped set(plays) — one pass, O(n) Levels Titanium Wake Me Up unique — 3 items, no order, no repeats
Fig — A set is a dedup machine: repeats hash to an occupied slot and quietly disappear. One pass, not a nested scan.
Wait —
A set throws away the duplicates. But what if the duplicates were the point — what if I wanted to know that "Levels" played three times, not just that it played?

03The tally: counting with a dict (and Counter)

Keep the keys, but give each one a value that counts. That is a tally: a dict from thing to how-many. For each item you jump to its key and bump the number by one — every step O(1), the whole tally O(n). The idiom uses .get(key, 0), which reads "the current count, or 0 if we've never seen this key":

One line in that idiom deserves a pause: why not just write tally[s] += 1? Try it on an empty dict and Python raises a KeyError, because you're asking to read a count that doesn't exist yet. The very first "Levels" play has no entry to increment, and there is no 0 sitting there waiting for you. That is exactly the hole .get(s, 0) plugs: it returns the stored count if the key exists, and the default 0 if it doesn't. So the first play computes 0 + 1 and writes 1, and every later play reads the real count and bumps it. Same jump, one graceful default.

count.pypython
tally = {}
for s in plays:
    tally[s] = tally.get(s, 0) + 1     # jump to s, read count, add 1, store
print(tally)     # {'Levels': 3, 'Titanium': 2, 'Wake Me Up': 1}

There are only three lines of mechanism here. tally.get(s, 0) fetches the running count in O(1), we add one to it, and tally[s] = … writes the new count back in O(1). No list, no scan, no re-counting — each play is touched exactly once. Python ships this pattern pre-built as collections.Counter, which does the same thing and adds the questions you actually ask of a tally:

count.pypython
from collections import Counter
print(Counter(plays).most_common(1))          # [('Levels', 3)]  — the top song
print(Counter("mississippi").most_common(2))  # [('i', 4), ('s', 4)]

That second line is worth a pause. A whole class of interview questions just got reduced to one line. Think "most frequent character", "top-k words", or "find the anagram groups". Each of those problems is a tally wearing a costume. That is the theme of this whole section: counting is just hashing with a running total.

SYNTAX · the three hash-backed loops — seen, Counter, defaultdict(list)have I seen it, how many of each, which ones belong together — four lines apiece, each one deleting an inner scan
1. HAVE I SEEN IT? -- a set. one probe replaces one scan. seen = set() set(), not {} -- an empty {} is a dict for x in items: if x in seen: ... O(1) hash-and-probe, not an O(n) walk seen.add(x) 2. HOW MANY OF EACH? -- a Counter. a dict whose missing keys read as 0. from collections import Counter c = Counter(items) one pass, the whole tally c[missing] 0, never a KeyError c.most_common(3) the top-k question, already written 3. WHICH ONES BELONG TOGETHER? -- defaultdict(list). the bucket makes itself. from collections import defaultdict groups = defaultdict(list) for x in items: groups[key(x)].append(x) a missing key is CREATED as [] on first touch
set()The empty set has no literal of its own. {} builds an empty dict, so set() is the only spelling.
x in seenOne hash, one probe. This single line is what replaces the O(n) scan, and why the loop around it drops to O(n).
CounterA dict subclass whose missing keys read as 0, so c[x] += 1 works on the very first play.
most_common(k)The top-k question, already written. It is a heap query over the tally you just built in one pass.
defaultdict(list)The factory fires on the first missing lookup, so groups[k].append(x) needs no setdefault and no if.
the key functionThe whole design lives in the key. Sorted letters group anagrams, x % k groups residues, len(w) groups lengths.
hashable keys onlyKeys must be immutable, so reach for a string or tuple(sorted(w)) — never a list.
the billAll three spend O(n) memory to delete a scan. Section 06 prices that trade, including the case where it collapses.
you type
# ---------- three_patterns.py ----------
from collections import Counter, defaultdict

plays = ["Levels", "Titanium", "Levels", "Wake Me Up", "Titanium", "Levels"]

print("1. HAVE I SEEN IT?  -- a set: one probe per item, no inner scan")
seen, repeats = set(), []
for s in plays:
    if s in seen: repeats.append(s)
    seen.add(s)
print("   seen    ", sorted(seen), "  (sorted for printing -- a set has no order)")
print("   repeats ", repeats)

print("\n2. HOW MANY OF EACH? -- a Counter: a dict that starts every key at 0")
c = Counter(plays)
print("   Counter ", c)
print("   c['Faded'] ->", c["Faded"], "  (a missing key is 0, never a KeyError)")
print("   most_common(2) ->", c.most_common(2))

print("\n3. WHICH ONES BELONG TOGETHER? -- defaultdict(list): the bucket makes itself")
words = ["listen", "silent", "google", "enlist", "gogole", "banana"]
groups = defaultdict(list)
for w in words:
    groups["".join(sorted(w))].append(w)      # the KEY is the design decision
print("   keyed by sorted letters ->", dict(groups))
print("   the groups themselves   ->", list(groups.values()))

print("\ntwo one-liners the same machinery buys you")
print("   dedupe, order kept  ->", list(dict.fromkeys(plays)))
print("   anagram test        ->", Counter("listen") == Counter("silent"))

$ python three_patterns.py
you see
1. HAVE I SEEN IT?  -- a set: one probe per item, no inner scan
   seen     ['Levels', 'Titanium', 'Wake Me Up']   (sorted for printing -- a set has no order)
   repeats  ['Levels', 'Titanium', 'Levels']

2. HOW MANY OF EACH? -- a Counter: a dict that starts every key at 0
   Counter  Counter({'Levels': 3, 'Titanium': 2, 'Wake Me Up': 1})
   c['Faded'] -> 0   (a missing key is 0, never a KeyError)
   most_common(2) -> [('Levels', 3), ('Titanium', 2)]

3. WHICH ONES BELONG TOGETHER? -- defaultdict(list): the bucket makes itself
   keyed by sorted letters -> {'eilnst': ['listen', 'silent', 'enlist'], 'eggloo': ['google', 'gogole'], 'aaabnn': ['banana']}
   the groups themselves   -> [['listen', 'silent', 'enlist'], ['google', 'gogole'], ['banana']]

two one-liners the same machinery buys you
   dedupe, order kept  -> ['Levels', 'Titanium', 'Wake Me Up']
   anagram test        -> True
where beginners trip
  • seen = {} gives you a dict, not a set. x in seen still works, and then seen.add(x) raises AttributeError.
  • Reading a missing key from a defaultdict creates it: after a bare groups['nope'] the dict holds {'nope': []}.
  • Counter is gentler — c['Faded'] returns 0 and inserts nothing. On a plain dict the equivalent is d.get(k, 0).
  • A set has no order, and string hashing is randomised per process, so print(a_set) can print a different order on the next run.
  • Need dedupe that keeps order? list(dict.fromkeys(items)) — a dict remembers insertion order, a set remembers nothing.
  • Counter(a) == Counter(b) is the whole anagram test in O(n). sorted(a) == sorted(b) answers the same question at n log n.
  • Ties inside most_common break by first-seen order, so Counter('mississippi').most_common(2) gives [('i', 4), ('s', 4)].
  • A list can never be a key: {['a']: 1} raises TypeError: unhashable type: 'list'. Make it a tuple and it files fine.
Counting is hashing
"How many of each?" and "which appears most?" are the same O(n) tally underneath. A word-frequency counter for a novel, a vote tally, a histogram of pixel colours, "how many 404s per URL" in a server log — all one pass with a dict. The moment a problem says count, frequency, how many, or duplicate, your hand should already be reaching for a dict.
for s in plays: tally[s] = tally.get(s, 0) + 1 3Levels 2Titanium 1Wake Me Up .most_common(1) → ('Levels', 3)
Fig — A dict-of-counts is a histogram you build in one pass. Counter is that pattern with the top-k question attached.

Membership, dedup, counting — all one pass because the lookup is free. Now the headline act: a lookup that was trapped inside a second loop, and how a dict sets it free →

04The "seen" dict: how a hash deletes an inner loop

Here is the pattern that separates the two programmers from the volume's opening. Take two-sum: given a list of numbers and a target, find two of them that add to the target. The obvious solution tries every pair — for each number, loop over all the others looking for its partner. That inner loop is a search, and it runs for every element, so the cost is every-pair: n(n−1)/2, which is O(n²). We counted the pair-checks exactly:

Where does n(n−1)/2 come from? It's worth counting honestly once, so the formula stops being a spell. Each of the n numbers can pair with the n−1 others. That gives n(n−1) ordered pairs. But the pair (3, 7) is the same as (7, 3) for us. So every pair got counted twice, and we divide by 2. Check it small with the 4 numbers 2, 5, 8, 11. The formula says 4 × 3 ÷ 2 = 6. List the pairs: (2,5), (2,8), (2,11), (5,8), (5,11), (8,11). That is exactly 6. That count grows like , and that is why the naive version drowns.

seen.pypython
def two_sum_naive(nums, target):          # O(n^2): every pair
    for i in range(len(nums)):
        for j in range(i+1, len(nums)):   # the inner loop IS a search
            if nums[i] + nums[j] == target:
                return (i, j)

def two_sum_hash(nums, target):           # O(n): one pass, a dict remembers
    seen = {}                             # value → index we saw it at
    for i, x in enumerate(nums):
        need = target - x                 # the exact partner x is looking for
        if need in seen:                  # O(1) — ask the dict, don't scan
            return (seen[need], i)
        seen[x] = i                       # write x down; value → index seen

The insight is small and total. In the naive version, when we hold x we don't know where its partner is, so we search for it. But we know exactly what the partner must be: target - x. So instead of searching, we keep a "seen" dict of every number we've passed, and simply ask it "have you got target - x?" — an O(1) jump. The inner loop doesn't get faster. It disappears. We ran both and counted operations at the worst case (the answer is the last pair):

seen.pypython
# n     naive ops     hash ops
# 10          45          10
# 100       4,950         100
# 1000    499,500        1000     ← n(n-1)/2 vs n

At n=1000 the nested loop does 499,500 pair-checks, while the dict version does just 1,000 lookups. That is not a tweak: it is 500× at a thousand items, and it grows to 500,000× at a million. Two-sum is stepped line by line in trace T31, so step it and watch the "seen" dict fill as the loop walks. The point here is the move itself, not this one problem.

NOW WRITE IT YOURSELFwrite first_duplicate and group_anagrams — then let a brute-force oracle grade them
Two classics, and both of them are one of the three idioms wearing a costume. First, first_duplicate(items): return the first value that shows up a second time, or None if every value is distinct. One pass, a seen set, and the answer is the element you are holding when the probe comes back True — not the earlier copy, and not the smallest repeat. Second, group_anagrams(words): return the words grouped so that listen, silent and enlist land in one list together. The entire design is the key you choose, so ask what all anagrams share, and make that shared thing the dict key. A defaultdict(list) then does the bookkeeping for you. Now grade yourself against a machine, not against your eyes. Write both again the obvious slow way — first_duplicate with a growing list and if x in out, and group_anagrams comparing every word against every later word — and use those as oracles. Seed with random.seed(37), throw 2,000 random cases at both pairs, and assert they agree every time. Then time them where it hurts: 20,001 numbers whose only repeat is the very last item, and 3,000 six-letter words. Two questions to answer from your own numbers. What exactly did the list-based version spend those milliseconds on, in terms of comparisons? And why is sorted(w) — which costs k·log k per word — still the right key, when the whole point was to avoid extra work?
show the solution
"""seen_and_group.py -- two classics, each one of the three idioms wearing a costume."""
import random, time
from collections import defaultdict

def first_duplicate(items):
    """The first value that shows up a SECOND time, or None. One pass, O(n)."""
    seen = set()
    for x in items:
        if x in seen:              # O(1) probe -- the whole point
            return x
        seen.add(x)                # write it down and walk on
    return None

def group_anagrams(words):
    """Words that are the same letters rearranged, grouped together. One pass."""
    groups = defaultdict(list)
    for w in words:
        groups["".join(sorted(w))].append(w)   # the canonical form IS the key
    return list(groups.values())

print("first_duplicate('playlist')      ->", repr(first_duplicate("playlist")))
print("first_duplicate([3, 1, 4, 1, 5]) ->", first_duplicate([3, 1, 4, 1, 5]))
print("first_duplicate([1, 2, 3])       ->", first_duplicate([1, 2, 3]))
print("group_anagrams([...])            ->",
      group_anagrams(["listen", "silent", "google", "enlist", "gogole", "banana"]))

def first_duplicate_brute(items):          # the oracle: obviously right, obviously slow
    out = []
    for x in items:
        if x in out: return x              # a LIST scan: O(n) per item
        out.append(x)
    return None

def group_anagrams_brute(words):           # the oracle: compare every pair
    groups, used = [], [False] * len(words)
    for i, w in enumerate(words):
        if used[i]: continue
        g, used[i] = [w], True
        for j in range(i + 1, len(words)):
            if not used[j] and sorted(words[j]) == sorted(w):
                g.append(words[j]); used[j] = True
        groups.append(g)
    return groups

random.seed(37)
for _ in range(2_000):                     # property test, both functions, 2,000 cases
    xs = [random.randrange(12) for _ in range(random.randint(0, 15))]
    assert first_duplicate(xs) == first_duplicate_brute(xs), xs
    ws = ["".join(random.sample("abcde", random.randint(1, 4))) for _ in range(6)]
    assert sorted(map(sorted, group_anagrams(ws))) == sorted(map(sorted, group_anagrams_brute(ws)))
print("\n2,000 random cases against the brute-force oracle: 0 mismatches")

items = random.sample(range(10 ** 7), 20_000)   # worst case: the repeat is the LAST item
items.append(items[0])
for fn in (first_duplicate_brute, first_duplicate):
    t0 = time.perf_counter(); hit = fn(items); dt = (time.perf_counter() - t0) * 1000
    print(f"  {fn.__name__:<22} n={len(items):,}  found {hit}  {dt:>9.2f} ms")

words = ["".join(random.sample("abcdefghijkl", 6)) for _ in range(3_000)]
for fn in (group_anagrams_brute, group_anagrams):
    t0 = time.perf_counter(); g = fn(words); dt = (time.perf_counter() - t0) * 1000
    print(f"  {fn.__name__:<22} {len(words):,} words -> {len(g):,} groups  {dt:>9.2f} ms")


# ---------- what it printed (milliseconds are this machine's; the ratios are the lesson) ----------
#
# first_duplicate('playlist')      -> 'l'
# first_duplicate([3, 1, 4, 1, 5]) -> 1
# first_duplicate([1, 2, 3])       -> None
# group_anagrams([...])            -> [['listen', 'silent', 'enlist'], ['google', 'gogole'], ['banana']]
#
# 2,000 random cases against the brute-force oracle: 0 mismatches
#   first_duplicate_brute  n=20,001  found 5190241    1368.68 ms
#   first_duplicate        n=20,001  found 5190241       2.06 ms
#   group_anagrams_brute   3,000 words -> 884 groups     589.66 ms
#   group_anagrams         3,000 words -> 884 groups       1.32 ms
#
#
# ---------- the two questions, answered from those numbers ----------
#
# WHERE THE 1,368 MILLISECONDS WENT.
#   The list version rescans everything it has kept so far, so item k costs k-1
#   comparisons. The total is the staircase 0 + 1 + ... + 19,999 = n(n-1)/2 =
#   199,990,000 comparisons -- the same every-pair triangle as two-sum. The set
#   version does 20,001 hashes and 20,001 probes. That is 664x here, and the
#   multiplier is n/2, so it doubles every time the input doubles.
#
# WHY sorted(w) IS STILL THE RIGHT KEY.
#   It looks like a betrayal: we came here to delete work, and the key does
#   k*log(k) of it per word. But look at what it buys. Anagrams are exactly the
#   words with the same multiset of letters, and sorting is a canonical form --
#   one string that every anagram of a word maps to, and nothing else does.
#   With it, each word is touched once: total n*k*log(k), linear in the number
#   of WORDS. Without it you must compare words pairwise, which is n^2/2
#   comparisons that each cost a sort anyway. Here: 3,000 words, 884 groups,
#   1.32 ms against 589.66 ms. A cheap canonical key beats a cheap comparison,
#   because the key is paid n times and the comparison n^2 times.
#   (tuple(sorted(w)) works identically -- a tuple is hashable, a list is not.
#    For fixed alphabets, a 26-slot count tuple is the O(k) version of the key.)
↺ The thing people get backwards
People say hashing "makes the search faster." It does something better: it abolishes the search. There is no faster search hiding in two_sum_hash — there is no search at all. You spent memory (the seen dict) to buy the right to never look. The whole discipline of this chapter is that trade: when you can't afford to search, remember instead.
nested loop — every pair n(n-1)/2 checks one pass + a "seen" dict each cell: "is target−x in seen?" → O(1) n checks the whole triangle collapses onto a single row
Fig — The O(n²) triangle of pairs collapses to an O(n) row. The dict is what lets each element skip straight to its partner.
InteractiveGrow n — watch the nested loop balloon while the pass stays a line
nested loop n(n-1)/2 dict pass n
12
At n=12 the nested loop does 66 pair-checks; the dict pass does 12. The gap is n/2 and it only widens.
🌍 Where you meet this
The "seen" dict is everywhere the moment you look. A database hash index answers WHERE id = ? without scanning the table — a dict on disk. Redis and memcached are, essentially, one giant dict shared over the network — the layer that keeps big sites from re-computing everything on every request. Git stores every file and commit under the hash of its own contents (content-addressed storage: the address is the SHA of the data). Spell-check, git diff, and DNA read-alignment all hash fixed-length chunks to find matches fast. And Python itself is built on this: every variable name, every attribute, every keyword argument, every module's namespace is a dict lookup — you have been riding O(1) hashing on every line you have ever written.

Remembering what you've seen kills the search. Remembering what you've already computed kills something bigger — repeated work →

05The cache: answer each question once

The "seen" dict remembers values you've passed. A cache remembers answers you've computed, so you never compute the same one twice. This is memoization, and it is the same dict trick pointed at a function. The classic victim is naive recursive Fibonacci, which re-solves the same subproblems an exponential number of times. A dict cache turns the tree of repeated calls into a straight line:

Before the fix, feel the disease at a size small enough to check by hand. fib(5) calls fib(4) and fib(3). Then fib(4) calls fib(3) again, so fib(3) is already being solved twice in this tiny computation. Count every call in the tree and you get 15 of them for fib(5), yet there are only 6 distinct values from fib(0) to fib(5). The waste roughly doubles with every step up, because each new call spawns two more below it. That doubling is what "exponential" means in practice, and it is why the counter below reads in the millions. The cache's promise is simple: each distinct value gets solved once, so 6 distinct values should cost about 6 real computations.

memo.pypython
cache = {}
def fib(n):
    if n < 2:
        return n
    if n not in cache:                    # already answered? jump to it
        cache[n] = fib(n-1) + fib(n-2)    # first time only: compute, store
    return cache[n]

Walk the lines: the base cases return immediately, and otherwise we ask the cache in O(1) whether we've solved this n before. If we haven't, we compute it once and write the answer down in the dict. Every later request for the same n is then a free jump straight to that stored answer. The effect is violent, and to prove it we instrumented both versions with a call counter:

memo.pypython
# fib(30):
#   naive recursion : 2,692,537 calls
#   with the cache  :        59 calls

Two and a half million calls become 59. The naive tree recomputes fib(28) tens of thousands of times; the cache computes it once and answers the rest from memory. (The recursion tree exploding then collapsing is a live widget in the Algorithms Lab, and stepped in the traces.)

naive fib(5) — every branch recomputes f5 f4 f3 f3 f2 f2 f1 amber = same subproblem, re-solved → 2,692,537 calls at fib(30) cached fib — each value once f5 f4 f3 f2 repeat request? → O(1) cache hit, no recursion at all the branching tree straightens into a line → 59 calls at fib(30)
Fig — The cache turns an exponential tree of repeated subcalls into a linear chain. The dict is the memory that makes "already solved" mean "free."
This is the doorway to dynamic programming
"Don't re-solve a subproblem you've already solved" is the entire seed of dynamic programming (Chapter 40). Memoization is DP discovered from the top down, with a dict as the memory. You just met the technique; Chapter 40 turns it into a way of designing algorithms.
The deeper cut: you rarely write the cache by hand

The hand-rolled cache = {} above is exactly what functools.lru_cache and functools.cache do for you. Decorate the naive fib with @cache and the dict is created, keyed, and consulted automatically, with no change to the body. The catch is the same one that runs through this whole chapter: the arguments must be hashable, because they become dict keys. That's why a memoized function taking a list argument fails, and why the standard fix is to pass a tuple instead. The cache also costs memory, and that memory lives as long as the function does. lru_cache(maxsize=N) caps it by evicting the least-recently-used answers. That eviction policy is the same one a CPU's hardware cache and a CDN use. The dynamic-programming chapter builds the intuition for which subproblems are worth remembering.

One warning before you decorate everything in sight: a cache is only honest for a pure function, one whose answer depends on its arguments and nothing else. fib(30) is pure, because the 30 tells you everything and the answer never changes. But a function that reads the clock, rolls a random number, or fetches a URL is a different animal. Cache it and the first answer gets frozen and replayed forever, even after the world behind it has moved on. That failure is quiet, which makes it worse than a crash. So the rule of thumb we carry: memoize computations, never observations.

Wait —
If a hash lookup is always O(1) and costs a little memory, why doesn't Python make everything a dict? What's the catch nobody mentions?

06The bill: space, hashable keys, and the worst case

O(1) is bought, not free. Three costs come with it. An engineer names them out loud.

Cost one — space. A set or dict holds your data plus a table of slots that is deliberately kept larger than the data (so collisions stay rare — Volume 1 chapter 7). Trading memory for time is the whole deal; on a huge dataset that memory can be the thing you don't have.

InteractiveFill the table — watch the last sliver of space cost 10× the time
A hash table is a parking lot. How many spaces must you check to park? HOW FULL · LOAD FACTOR α 50% EXPECTED PROBES PER LOOKUP · 1 ⁄ (1 − α) 2.0 ≈ park in seconds — half the lot is open Python resizes here (~66% full) to keep the walk ≈3 — the empty third is the price of the jump. Buying O(1) with space means buying it with the empty slots — the gaps are the speed. the table — 100 slots
50%
Slide from half-full to 99% full. The bar and counter track 1/(1−α): probes are set by the empty fraction, so halving the free space doubles the walk. That's why Python keeps its tables ~⅓ empty — and why a table you let fill to the brim turns the O(1) jump back into an O(n) scan.

Cost two — the keys must be hashable. To hash a key, its value must be fixed, so that its hash never changes underneath the table. If a key could mutate after it was filed, its hash would point at the wrong slot and the table would lose it. That means immutable keys only, and lists are mutable, so Python bars them loudly:

cost.pypython
{ ["Levels", 200]: 1 }         # TypeError: unhashable type: 'list'
{ ("Levels", 200): 1 }         # fine — a tuple is immutable, so hashable
print([].__hash__)             # None   — lists opt out of hashing entirely

Look at that error, because it is a design choice and not an accident. A list's __hash__ is literally None — the type refuses to be a key. Tuples of immutable things are the standard fix when you need a compound key. That's why grid coordinates, dates, and (name, version) pairs are so often tuples in real code.

Immutable keys are a safety rail, not a nuisance
If a key's hash could drift after you filed it, the table would look in the wrong slot and your value would vanish while still sitting in memory. "Keys must be hashable" is Python refusing to build a container that silently loses data — stopping you at the door with a TypeError instead of at 3 a.m. with a missing record.

Cost three — O(1) is the average, not a guarantee. The jump is fast only when values scatter nicely across the slots. If many keys land in the same slot, we call that a collision, and the table has to walk the pile-up one entry at a time. In the pathological case where everything collides, the lookup degrades to the very linear scan we were escaping. We forced that case on purpose, using keys whose hash is a constant so they all target one slot:

cost.pypython
class AllSame:                       # a deliberately terrible key
    def __init__(self, v): self.v = v
    def __hash__(self): return 42    # EVERY instance → the same slot
    def __eq__(self, o): return self.v == o.v

# building a set of these, timed on the test machine:
#   n=1000   0.030 s      n=2000   0.109 s      n=4000   0.442 s
#   → doubling n roughly QUADRUPLES the time: O(n^2) build, O(n) per op

When every key collides, doubling the data quadruples the work — the signature of O(n²). The O(1) promise didn't just weaken; it collapsed all the way back to a scan. In normal use this never happens, because a good hash function scatters keys evenly. But "normal use" assumes nobody is choosing the keys on purpose.

The quadrupling isn't mysterious once you count the pile-up. When every key targets one slot, the k-th insertion has to walk past the k−1 entries already parked there. The total is the staircase again: 0 + 1 + … + (n−1), which is n(n−1)/2. Plug the numbers in: n=1000 gives 499,500 steps, and n=2000 gives 1,999,000, which is almost exactly 4×. Doubling n quadruples the bill because the cost formula holds an inside it. So "doubling the data quadruples the work" is not just an observation — it is the fingerprint of O(n²), and you can read it off any timing table.

good hash — keys spread, ~1 per slot (O(1)) A B C D E F any lookup = jump to one slot, done flooded — every key hashes to one slot (O(n)) · · A B C · · D E F … n keys stacked here any lookup = walk the whole pile → a scan
Fig — The hash function is the whole difference between O(1) and O(n). Scatter buys constant time; a pile-up gives it all back.

Myth

"A dict is O(1), full stop — you can quote it in an interview and never think about it again."

Reality

O(1) is the average with a good hash and honest inputs. The worst case is O(n) per operation, and an attacker who can pick your keys can force it — a real denial-of-service.

That attack has a name: hash flooding. Around 2011, researchers showed that many web platforms built dicts straight from untrusted input. Python, PHP, Java, Ruby, ASP.NET and more all hashed POST fields, JSON keys, and HTTP headers with a fixed, published hash function. So an attacker could craft thousands of keys that all collide, turning one small request into an O(n²) grind that pinned a server's CPU. The fix Python shipped is quietly brilliant: randomize the hash. Each process seeds its string hash with a secret chosen at startup, so an attacker can't predict which keys collide. You can watch the seed change between runs:

cost.pypython
# two separate Python processes, same line:
hash("Levels")   #  4417089987191320694   (first process)
hash("Levels")   #   -41086165888829000   (second process — different!)
hash(200)        #  200   (integers still hash to themselves, both runs)

Same string, different hash every process — that unpredictability is the defense. One aside on the earlier demo: integers hash to themselves, which is why the crafted-collision demo needed a broken __hash__. CPython's probing manages to scatter even adversarial integer keys on its own. This randomization is also why you must never rely on a set or dict's iteration order being stable across runs. And it is why security-sensitive code exists to make hashing unguessable.

The mutable-key trap, for real
Cost two isn't hypothetical. Put a mutable object in a dict via a custom __hash__ that reads a field, then change that field: the object is now filed under its old hash, so obj in d looks in the new slot and returns False — the entry is still in the table, permanently unreachable. Immutable keys aren't pedantry; they're the guarantee that "I put it in" and "I can find it" stay the same statement.
The deeper cut: what actually replaced the broken hash

Python's answer to hash flooding was PEP 456 in Python 3.4. It swapped the old fast-but-guessable string hash for SipHash, a keyed hash designed for exactly this job. SipHash is fast on short inputs, yet effectively impossible to find collisions for without the secret key. The per-process secret is the PYTHONHASHSEED, and hash randomization has been on by default since Python 3.3. Set PYTHONHASHSEED=0 and hashing becomes deterministic again — on this machine that pins hash("Levels") to a fixed 9063540593168268057 every run. Deterministic hashing is useful for reproducible tests and dangerous in production. One honesty note on cost one: the per-lookup time is independent of n, but it is not independent of the key. Hashing a 10,000-character string reads all 10,000 characters. So O(1) here means "constant in the number of items," with the key's own size folded into the constant.

So here is the reflex this chapter installs. It is the payoff of the whole trade. When you see x in some_list inside a loop, stop. The same goes for a nested loop whose inner half is a search. Ask the question that opened the chapter: could I already know where this lives? Membership wants a set. Counting wants a dict or a Counter. A missing-partner search wants a "seen" dict. A repeated computation wants a cache. Every one of them spends memory to delete a search. And the bill is almost always worth paying: space, hashable keys, the rare bad worst case. That single reflex, applied on sight, is the difference between the O(n²) programmer and the O(n) programmer. And that is what makes this chapter matter.

InteractivePile keys into one slot — watch O(1) rot into O(k)
hash table — one slot, and the pile-up inside it · · slot · · 1 key here → 1 probe. This is the O(1) you paid for. worst-case lookup = length of the chain
1
A good hash keeps k≈1 for everyone — that's O(1). An attacker who forces every key into one slot makes k=n — that's O(n).

Hashing bought O(1) by scattering keys to random slots — deliberately destroying order for speed. The next chapter asks the opposite question: the next chapter buys that same speed for free — no extra memory at all — with two pointers and a sliding window: array sweeps that compute the change between overlapping states instead of restarting the work each time → →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 37, in working code

Twelve tiny programs where the same move keeps winning: stop searching, start remembering — a hash turns "go look for it" into "compute where it lives," and hands you O(1) for the price of a little memory.

The lookup, made free
"Is X in here?" costs a full scan on a list and nothing on a set — because a hash computes the address instead of walking to it.
The set — dedup in one pass
A set physically can't hold the same value twice, so collapsing to distinct values is one O(n) pass, and "who's in both?" is a single operation.
Counting is hashing
Give each key a running total and "how many of each?" is one pass with a dict. Counter is that pattern with the top-k question already attached.
The "seen" dict deletes the inner loop
You don't know WHERE a number's partner is, but you know exactly WHAT it must be. Remember what you've passed, and the search loop doesn't shrink — it vanishes.
The cache — answer each question once
The seen dict remembers values you passed; a cache remembers answers you computed. Point the same dict trick at a function and an exponential tree straightens into a line.
The bill — what O(1) costs
O(1) is bought, not free: keys must be hashable (immutable), and the constant time is an average — pile every key into one slot and the jump rots back into a scan.
end of chapter 37 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked