Week 11 · Phase 4 · Choosing well

Dictionaries and Sets: Lookup That Doesn't Slow Down

Hashing intuition and the single biggest speed win a beginner can learn.

Big question: How can looking something up cost the same in a list of 10 and a list of 10 million?dictset≈3 hours
By the end of this week you can
  • create and use dictionaries and sets;
  • explain, in plain words, how hashing makes lookup independent of size;
  • trace by hand how a key lands in a bucket, and what a collision is;
  • measure the difference between in on a list and in on a set;
  • count occurrences with get, setdefault and Counter;
  • rewrite a quadratic "check against a list" loop into a linear one;
  • say what dictionaries and sets cost you in memory and in guarantees, and when a set is the wrong tool.

11.1The problem this solves

Week 10 left us with an uncomfortable fact: x in my_list is O(n). Put that inside a loop over m queries and you have O(n × m) — the shape that turns a working prototype into a frozen laptop.

the everyday disaster
known_emails = list_of_100000_addresses
new_signups  = list_of_10000_addresses

duplicates = []
for address in new_signups:            # 10 000 times ...
    if address in known_emails:        # ... a scan of 100 000
        duplicates.append(address)

A billion comparisons for a job that sounds like nothing. This week's tool reduces it to about a hundred and ten thousand — roughly ten thousand times less work — by changing one word.

11.2Sets: a bag with instant membership

A set is an unordered collection with no duplicates, written with curly braces.

sets
enrolled = {"Ada", "Bilal", "Cem"}

enrolled.add("Dilek")
enrolled.add("Ada")            # already there — no effect, no error

print("Cem" in enrolled)       # True   — and this is O(1)
print(len(enrolled))           # 4

a = {1, 2, 3}
b = {3, 4}
print(a & b, a | b, a - b)     # {3} {1,2,3,4} {1,2}

The last line — intersection, union, difference — often replaces an entire nested loop with one character.

One trap to note now

{} is an empty dictionary, not an empty set — Python got there first with dictionaries. For an empty set you must write set(). This catches almost everyone once.

11.3Dictionaries: a label on every value

dictionaries
grades = {"Ada": 88, "Bilal": 72, "Cem": 91}

print(grades["Ada"])              # 88     — O(1)
grades["Dilek"] = 67              # add    — O(1)
print("Ece" in grades)            # False  — O(1), checks the keys
print(grades.get("Ece", 0))       # 0      — a safe default

for name, score in grades.items():
    print(f"{name}: {score}")

A dictionary maps keys to values. Keys must be unchangeable things — text, numbers, tuples — and each appears once. Looking one up costs the same whether the dictionary holds ten entries or ten million.

Bracket vs get

grades["Ece"] raises KeyError if the key is missing; grades.get("Ece", 0) hands back a default instead. Use the bracket form when a missing key is a genuine bug you want to hear about, and get when "not present" is a normal, expected case.

11.4Counting, done properly

Tallying how often each thing occurs is the single most common use of a dictionary, and it replaces a lot of clumsy list code. Three ways, worst to best:

counting with a dictionary
text = "the quick brown fox jumps over the lazy dog the end"

counts = {}
for word in text.split():
    counts[word] = counts.get(word, 0) + 1     # O(1) per word

print(counts["the"])       # 3

One pass over n words, constant work each: O(n) for the whole tally. The list-based equivalent — searching a list of seen words each time — is O(n²).

get(word, 0) is the idiom to remember: "the current count, or nought if we have not seen this word before". A close cousin, setdefault, does the same job when the value is a list you want to grow:

grouping with setdefault
students = [("maths", "Ada"), ("history", "Bilal"), ("maths", "Cem")]

by_subject = {}
for subject, name in students:
    by_subject.setdefault(subject, []).append(name)   # make the list if absent, then append

print(by_subject)
{'maths': ['Ada', 'Cem'], 'history': ['Bilal']}

And when all you want is a count, the standard library hands it to you outright. Counter is a dictionary that already knows how to tally:

Counter — a dictionary that counts
from collections import Counter

text = "the quick brown fox jumps over the lazy dog the end"

counts = Counter(text.split())     # one pass, O(n), written in C

print(counts["the"])               # 3
print(counts["missing"])           # 0   — never a KeyError
print(counts.most_common(2))       # [('the', 3), ('quick', 1)]
3 0 [('the', 3), ('quick', 1)]

Same class as the hand-written loop — O(n) — but a smaller constant, because the counting happens in C, and most_common saves you a sort. This is the same space-for-time trade you met with the anagram counter in week 9.

11.5How can lookup not depend on size?

It feels like a trick. Here is the idea, without any mathematics.

Imagine a cloakroom with 1 000 numbered hooks. Instead of searching for a free hook, the attendant computes one from your name: add up the letters, divide by 1 000, take the remainder. "Bilal" always gives the same number — say 417 — so your coat goes on hook 417 and is found on hook 417 later. No searching, ever. The computation is called a hash, and it takes the same time regardless of how many coats are already hanging.

Two names can compute to the same hook — a collision. The structure keeps a small chain there and checks it, which is why the honest statement is "O(1) on average". With a good hash function, collisions are rare enough that this holds up in practice, and Python's is very good.

What it costs you
  • Memory. Hash tables keep spare room to stay fast — expect roughly two to three times the memory of the equivalent list.
  • Order. Sets have none. (Dictionaries do keep insertion order in modern Python, but never rely on sets for ordering.)
  • Hashable keys only. Text, numbers and tuples work; lists cannot be dictionary keys or set members, because they can change.
  • No range queries. "All names starting with B" still means walking everything.

11.6Inside the table: buckets and collisions

Let us make the cloakroom concrete and small enough to trace by hand. Build a tiny table of eight buckets and drop five integers into it, sending each number key to bucket key % 8. Integers are perfect for a trace because in Python hash(n) == n for a small integer, so the arithmetic is reproducible.

a hash table you can see
size = 8
buckets = [[] for _ in range(size)]     # eight empty chains

for key in [10, 18, 3, 26, 7]:
    home = key % size                   # the "hook number"
    buckets[home].append(key)
    print(f"{key:>3}  ->  bucket {home}")

print(buckets)
10 -> bucket 2 18 -> bucket 2 3 -> bucket 3 26 -> bucket 2 7 -> bucket 7 [[], [], [10, 18, 26], [3], [], [], [], [7]]

Work the arithmetic yourself: 10 % 8 = 2, 18 % 8 = 2, 3 % 8 = 3, 26 % 8 = 2, 7 % 8 = 7. Three keys — 10, 18 and 26 — all landed in bucket 2. That is a collision, and the bucket holds a short chain of them.

Now watch a lookup. To find 26 the table computes 26 % 8 = 2, goes straight to bucket 2, and walks its tiny chain — three items — until it matches. To decide that 99 is absent it computes 99 % 8 = 3, looks in bucket 3, finds only [3], and stops. Neither lookup ever touched the other buckets. That is the whole secret: the hash jumps you to a tiny neighbourhood, and only the neighbourhood is searched.

Two things keep the neighbourhoods tiny. First, a good hash function scatters keys evenly, so chains stay short. Second, when the table starts to fill up Python quietly builds a bigger one and re-files everything — this is called resizing, and it is why a set needs spare room and therefore extra memory. If chains were ever allowed to grow long, lookup would slide back towards O(n); the average-case O(1) promise depends on keeping them short.

Why real string hashes are not reproducible

Try the same trace with hash("Ada") and you will get a huge number that changes between program runs. Python deliberately randomises string hashing so that an attacker cannot feed your server keys engineered to collide (which would drag every lookup down to O(n)). The integer trace above is stable only because integers hash to themselves.

11.7What each operation costs

Keep this table beside you when you reach for a container. "Average" is the honest word: a pathological run of collisions can degrade any single operation, but in practice Python holds these costs.

Operationlistsetdict
x in c (membership)O(n)O(1) avgO(1) avg (keys)
add an itemO(1) at end, O(n) at frontO(1) avgO(1) avg
remove a given itemO(n)O(1) avgO(1) avg
look up by position / keyO(1) by index—O(1) avg by key
walk everythingO(n)O(n)O(n)
keeps order?yesnoyes (insertion)
duplicates?yesnokeys no, values yes

Read the first row downwards: it is the entire reason this week exists. Everything a set and dictionary give you is bought with the memory and the lost order in the bottom rows — a trade that is almost always worth it once you are searching inside a loop.

11.8The measurement

list vs set membership
import time

for n in [10000, 100000, 1000000]:
    as_list = list(range(n))
    as_set = set(as_list)
    target = -1                     # missing: the worst case

    start = time.perf_counter()
    for _ in range(100):
        target in as_list
    t_list = (time.perf_counter() - start) / 100

    start = time.perf_counter()
    for _ in range(100):
        target in as_set
    t_set = (time.perf_counter() - start) / 100

    print(f"n={n:>8}  list {t_list*1e6:10.2f} µs   set {t_set*1e6:6.2f} µs   ratio {t_list/t_set:,.0f}x")
n= 10000 list 52.31 µs set 0.05 µs ratio 1,046x n= 100000 list 521.90 µs set 0.05 µs ratio 10,438x n= 1000000 list 5208.44 µs set 0.05 µs ratio 104,169x

The list column multiplies by ten every row. The set column does not move at all — and so the ratio column multiplies by ten instead. This is what a difference in class looks like when you see it directly: not "faster", but "stops caring how much data there is".

Now the disaster from §11.1, repaired:

one word changed
known_set = set(known_emails)          # O(n), paid once

duplicates = [a for a in new_signups if a in known_set]   # O(m)

Total cost O(n + m) instead of O(n × m). On the numbers from §11.1 that is about 110 000 operations instead of a billion — and the code is shorter.

11.9Choosing the container

You need to…UseCost
Keep things in order, access by positionlistindex O(1), search O(n)
Ask "have I seen this?" repeatedlysetO(1) average
Look up a value by a label or iddictO(1) average
Count occurrencesdict or CounterO(n) total
Remove duplicatesset(data)O(n), loses order
Add and remove at both endsdequeO(1) both ends
Keep sorted order at all timeslist + bisect, or sort oncesee week 12
The rule of thumb worth carrying out of this course

If you are searching a list inside a loop, you probably want a set or a dictionary. That one sentence covers most beginner performance problems in the wild.

11.10Common mistakes, and when a set is the wrong answer

A set is a hammer that makes a lot of problems look like nails. Four situations where reaching for one is a genuine error:

And two mistakes that produce wrong answers rather than slow ones:

predict the output before you run it
seen = set()
seen.add([1, 2])            # TypeError: unhashable type: 'list'

scores = {"Ada": 88}
scores["Ada"] = 91          # not a new entry — it overwrites 88
print(len(scores))          # 1, not 2: keys are unique

A list cannot go into a set or be a dictionary key, because it can change and so its hash would move — Python refuses outright with TypeError. Use a tuple when you need a compound key. And assigning to an existing key replaces its value; it does not add a second entry. Both of these surprise people exactly once.

11.11Try it yourself

Task 1 — reproduce the plateau

Run §11.8 and plot both columns against n on log–log axes. The set line should be flat and the list line a straight rising slope. Label everything.

Task 2 — word frequency two ways

Take a long text (paste a chapter, or repeat a paragraph 5 000 times). Count word frequencies (a) with a list of seen words and in, and (b) with a dictionary. Time both, and report the ratio at two text sizes.

What to expect

The list version is O(n × unique words) and the dictionary version O(n). The gap widens with the vocabulary size, so a bigger text makes it worse — the opposite of what most people expect from "it worked on the small file".

Task 3 — find the common elements

Given two lists of 50 000 random numbers, find the values present in both, first with nested loops, then with set(a) & set(b). Time both and confirm they agree on the answer.

Solution
both ways
common_slow = []
for x in a:                       # O(n × m)
    if x in b:
        common_slow.append(x)

common_fast = list(set(a) & set(b))    # O(n + m)

print(sorted(common_slow) == sorted(common_fast))

Expect a factor of thousands, and identical results. Always check that the fast version is still correct.

Task 4 — when a set is the wrong answer

Name two situations where converting a list to a set would be a mistake, and explain why in one sentence each.

Answers

(1) When duplicates carry meaning — a set silently discards them, changing your results. (2) When order matters, such as a ranked leaderboard. A third: when you do only one or two lookups, building the set costs more than the scan it saves.

Task 5 — trace the buckets by hand

Without running anything, work out which bucket each of [16, 24, 5, 40, 9] lands in for a table of size = 8, and draw the eight buckets with their chains. Which bucket holds a collision? Then run the §11.6 code with these numbers to check yourself.

Answer

16 % 8 = 0, 24 % 8 = 0, 5 % 8 = 5, 40 % 8 = 0, 9 % 8 = 1. So bucket 0 holds the chain [16, 24, 40] — a three-way collision — bucket 1 holds [9], bucket 5 holds [5], and the rest are empty. A poor choice of table size (here, everything divisible by 8 collides) is exactly the sort of thing a real hash function is designed to avoid.

Task 6 — count without a set

You are handed a list of a million dice rolls (each an integer 1–6) and asked for the most common face. Write it with Counter, then argue in two sentences why turning the list into a set first would destroy the answer.

Solution
counting, not de-duplicating
from collections import Counter
print(Counter(rolls).most_common(1))      # e.g. [(4, 167012)]

A set would collapse the million rolls to at most six values {1,2,3,4,5,6}, erasing the very frequencies you were asked for. This is the "duplicates carry meaning" trap: the counts are the data.

11.12Self-check

x in data where data is a set of one million items costs about:

The hash computes the location directly. Size does not enter into it — that is the whole point of a hash table.

Converting a 100 000-item list to a set before 10 000 lookups changes the total cost from:

Building the set is one pass (n); each lookup is then constant, so the queries cost m. About a billion operations become about a hundred and ten thousand.

Which is not a real cost of using a dictionary or set?

That is precisely what does not happen. The other three are genuine trade-offs you should state when you recommend one.

Three keys hash to the same bucket. This is called a collision, and the table handles it by:

Collisions are normal. A good hash keeps chains short, which is why membership stays O(1) on average rather than O(n).

You have a million dice rolls and need the most common face. The right tool is:

A set would discard the repeats, and the repeats are exactly what you were asked to count. Counting needs a count, not a de-duplication.

11.13Homework

Due before week 12 — about three hours
  1. Create AA_Week11.ipynb and complete Tasks 1–6.
  2. Take any O(n²) function you wrote in weeks 4, 9 or 10 and rewrite it using a set or dictionary. Benchmark both at three sizes and prove the class changed with a ratio column.
  3. Produce one labelled log–log figure showing the flat set line against the rising list line from Task 1.
  4. Extend the §11.6 hash-table demo to report the longest chain for 1 000 random integers at three table sizes (say 128, 256, 1024). Write two sentences on what the longest chain tells you about lookup cost.
  5. Write five sentences for a non-technical manager explaining why the report that "took 3 hours" now takes 4 seconds, without using the words "hash" or "complexity".

11.14Words from this week

TermMeaning in plain words
setUnordered collection, no duplicates, instant membership test.
dictionaryKey → value store with instant lookup by key.
key / valueThe label you look things up by / the thing stored under it.
hashA computed number that says where an item belongs — no searching required.
bucketOne slot of the table, holding all keys whose hash points there.
collisionTwo keys landing in the same bucket; handled by a short chain, and why we say "O(1) on average".
resizingBuilding a bigger table and re-filing everything when it starts to fill — the source of the spare memory.
hashableA value stable enough to be used as a key: text, numbers, tuples — not lists.
CounterA ready-made dictionary from collections that tallies occurrences.
Where this leads

Hashing bought O(1) lookup but threw away order. Week 12 is for when you need order back — searching sorted data in O(log n) with binary search.