Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Foundations: Complexity & Arrays  ›  Lesson

Hash Tables & the Song Index

Hash Tables 22 minTurning a key into an address for O(1) lookups
You're building a piece ofTunebox — the library
This piece — build_index(): id → track lookups and artist grouping, instantly.
Scenario Scanning millions of tracks on every tap is too slow. The song index is a pair of hash tables — id → track and artist → tracks — that make "play this" and "everything by this artist" instant.
Your task
Build build_index(tracks). Given track dicts like {"id": "t1", "title": "Glow", "artist": "Nova"} (ids are unique), return {"by_id": …, "by_artist": …}: by_id maps each id to its title, and by_artist maps each artist to their tracks' titles in library order. Build both in one pass. Example: build_index([{"id": "t1", "title": "Glow", "artist": "Nova"}, {"id": "t2", "title": "Tide", "artist": "Echo"}]) → {"by_id": {"t1": "Glow", "t2": "Tide"}, "by_artist": {"Nova": ["Glow"], "Echo": ["Tide"]}}.

Hash Tables & the Song Index

Tunebox gets a request: play track t48213. With 10 million tracks in a list, finding it means checking them one by one — O(n), on every single play. A hash table turns the id itself into the place the track is stored, so the lookup takes the same few steps however big the library gets. Python's dict and set are hash tables, and after arrays they're the most-used structure in this course.

Turning a key into an address

Arrays give O(1) access by position. A hash table borrows that: a hash function turns a key into an integer, and % size folds that integer into a slot number — a bucket. Same key, same bucket, every time, so storing and finding both go straight there.

python

A good hash spreads keys evenly across the buckets. This toy one already put three of four ids in bucket 5 — and it has a worse flaw:

What does this print?
def toy_hash(key):
    return sum(ord(ch) for ch in key)

print(toy_hash("Nova") % 8 == toy_hash("Avon") % 8)

Python's built-in hash() does this properly. Strings get a hash randomised per process — run hash("Nova") in two programs and you'll see two different numbers — so nobody can pre-compute keys that collide. Small integers simply hash to themselves:

python

Collisions: two keys, one bucket

There are more possible keys than buckets, so collisions are guaranteed. The two classic fixes:

  • Chaining — each bucket holds a short list of (key, value) pairs. A collision appends to the list; a lookup walks the list in its bucket.
  • Open addressing — each bucket holds at most one entry. If a key's bucket is taken, probe onward (the next bucket, say) until you find a free one. A lookup follows the same path until it meets the key or an empty bucket. CPython's dict and set use open addressing.

A complete chained hash table fits in a dozen lines:

python

Step through a lookup: hash to one bucket, then walk only that chain.

step through it
1buckets = [
2 [[4, "Drift"]],
3 [[1, "Glow"], [5, "Haze"]],
4 [[2, "Tide"], [6, "Echo"]],
5 [[3, "Ember"]],
6]
7key = 5
8bucket = buckets[key % len(buckets)]
9found = None
10for k, v in bucket:
11 if k == key:
12 found = v
13print(found)

Load factor and resizing

The load factor is entries / buckets. With chaining it's the average chain length — how many comparisons a lookup expects. Keep it bounded and lookups stay O(1). So once a table passes a threshold it resizes: allocates about twice the buckets and rehashes every key into its new home.

python

A resize is O(n), but — just like a list's append — it only happens after the table has doubled its contents, so the cost spreads to amortized O(1) per insert.

What does this print?
entries, buckets, resizes = 0, 8, 0
for track in range(20):
    entries += 1
    if entries / buckets > 0.75:
        buckets *= 2
        resizes += 1
print(buckets, resizes)

O(1) on average — and O(n) in the worst case

A lookup is: hash the key, jump to its bucket, compare against what's there. With a decent hash and a bounded load factor, "what's there" is a handful of entries — O(1) on average. (Hashing a string does take time proportional to its length; Python caches each string's hash after the first time.)

The worst case is every key landing in one bucket. The table becomes a list in disguise and each operation is O(n). A deliberately terrible __hash__ shows it:

python

That's the attack string randomisation defends against: without it, someone could send Tunebox thousands of playlist names built to collide and make every request O(n²).

Hashable keys

A key's hash must never change while it's in the table — otherwise the key is filed in one bucket and looked up in another. So only immutable values can be keys: str, int, float, bool, None, and tuples or frozensets built from them.

What happens when this runs?
plays = {}
plays[["Nova", "Glow"]] = 12
print(plays)

❓ Cross-question — "Can my own class be a key?" Yes. By default objects hash by identity, so two Track("t1") objects are different keys. To make equal-looking objects the same key, define __eq__ and __hash__ together over fields that never change — or use @dataclass(frozen=True), which writes both for you.

Counting, grouping and two-sum

Three patterns cover most everyday hash-table code. Counting — how many plays did each track get? The classic bug is adding to a count that doesn't exist yet:

broken — fix itKeyError

This should count each track's plays. It crashes on the very first play — read the error, then fix the update line.

Grouping — every track id by artist, in one pass. setdefault returns the list for a key, creating an empty one the first time:

python

Two-sum — two tracks that exactly fill a gap, in unsorted order. For each track the partner you need is gap − minutes, and asking a dict whether you've seen it is O(1): one O(n) pass, no sort, O(n) memory.

python

Complexity at a glance

operation average worst
d[key], d[key] = v, key in d, del d[key] O(1) O(n)
s.add(x), x in s, s.discard(x) O(1) O(n)
build from n items O(n) O(n²)
iterate over all entries O(n) O(n)
a single resize O(n) — amortized O(1) per insert
memory O(n) O(n)

Coming from Java/JS: dict is HashMap/Map and set is HashSet/Set; Java's hashCode/equals contract is Python's __hash__/__eq__. One JS trap doesn't exist here: a plain JS object turns every key into a string, so obj[1] and obj["1"] are the same entry. A Python dict keeps 1 and "1" apart.


Idioms & real-world patterns

Counter and defaultdict

The standard library has counting and grouping built in:

python

Sets for "have I seen this?"

A set is a hash table with keys and no values — made for dedupe and membership:

python

⚡ Advanced — tuples as composite keys

When a key is "more than one thing", pack it into a tuple. frozenset is the hashable cousin of set, for when the parts have no order.

python

⚡ Advanced — how a dict remembers order

Since Python 3.7 a dict iterates in insertion order. Entries live in a dense array in the order they were added, and the hash table itself is a sparse array of small integers pointing into it. Lookups go through the sparse table; iteration just walks the dense array. It uses less memory than the old design and gives you ordering.


🎯 Your turn

Build the song index: build_index(tracks) takes track dicts and returns two lookups, built in one pass:

  • "by_id" — each track's id → its title
  • "by_artist" — each artist → their tracks' titles, in library order

For example, build_index([{"id": "t1", "title": "Glow", "artist": "Nova"}, {"id": "t2", "title": "Tide", "artist": "Echo"}, {"id": "t3", "title": "Ember", "artist": "Nova"}]){"by_id": {"t1": "Glow", "t2": "Tide", "t3": "Ember"}, "by_artist": {"Nova": ["Glow", "Ember"], "Echo": ["Tide"]}}

Hint — start two empty dicts, loop over the tracks once, and use setdefault(artist, []) for the grouping.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Tunebox is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCount the plays

play_log is a list of track ids in the order they were played. Write play_counts(play_log) returning a dict of each id → how many times it was played.

frequency-countdict.get
DrillFirst repeat play

Tunebox warns when a listener replays something. Write first_repeat(play_log) returning the id of the first play that repeats an earlier one (the repeat that happens earliest in the log), or None if every play is different.

hashingmembership
BuildCount the gap-fillers

Write pairs_for_gap(mins, gap) returning how many pairs of different tracks (positions i < j) have minutes adding up to exactly gap. mins is not sorted. Aim for one O(n) pass.

hash-mapfrequency-count
BossOpen addressing by hand

Simulate an open-addressing hash table of size slots, all starting empty (None). Insert the integer keys in order: a key's home slot is key % size; if that slot holds a different key, try the next slot (wrapping from the last slot to 0) until you reach an empty slot or the key itself. A key that's already in the table is not inserted again. Write linear_probe(keys, size) returning the final table as a list. There are never more distinct keys than slots.

hashinghash-map
CapstonePlaylists with the same tracks

Each playlist is {"name": …, "tracks": [ids]}. Two playlists have the same tracklist when they contain the same set of ids — order and repeats don't matter. Write same_tracklist(playlists) returning a list of groups, each a list of playlist names that share a tracklist, keeping only groups of two or more. Names within a group stay in input order; groups are ordered by where their first playlist appears.

hashinggroupingset
build_index.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
build_index(tracks) → dictTwo lookups: id → title, and artist → titles in library order.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.