You already know how to build a hash table from scratch; now it's time to use the two that Python ships with, fine-tuned over decades: dict and set. This lesson is the return on the investment of 05-01 and 05-02 — every "arbitrary" Python rule (why a list can't be a key, why in is lightning fast on a set and slow on a list) will become self-evident now that you know the machinery underneath. The second half is pure practice: the professional patterns with dictionaries and sets — indexing, grouping, counting, inverting — applied to TaskFlow, which in this lesson debuts its definitive id index and its first tag search. We'll close with the question that matters most for your engineering judgment: when a hash is not the answer.
Contents
dictandset: production-grade hash tables- The requirement on keys: being hashable
- The
hash/__eq__contract dictoperations and costs- Insertion order preserved
- TaskFlow patterns with
dict set: the dictionary without values- Set algebra for tags
- When NOT to use a hash table
dict and set: production-grade hash tables
The two protagonists, translated into this module's vocabulary:
dict: the dictionary ADT from 05-01 implemented as an open-addressing hash table (the second family from 05-02, in a sophisticated variant), with automatic resizing upon exceeding 2/3 load. Everything ourHashTabledid, plus years of optimization in C.set: the same table, storing keys only, no values. Its specialty is a single question answered in O(1): is it there or not?
We've been using dict since the very first task of the course; the difference is that from today on you know what you pay and what you get with each operation — and why its rules exist.
The requirement on keys: being hashable
Try this:
index = {}
index[("backend", "urgent")] = "valid combination" # tuple: works
index[["backend", "urgent"]] = "?" # list...
# TypeError: unhashable type: 'list'Why the tuple but not the list? The answer sits at the heart of 05-01: the key is the address. On insertion, the table computes hash(key) and stores the pair in the resulting bucket; on lookup, it repeats the computation. Now imagine Python allowed lists as keys:
tags = ["backend", "urgent"]
index[tags] = task # say, bucket 5
tags.append("blocked") # the list CHANGES...
index[tags] # ...its hash would change → it would look in ANOTHER bucketThe pair would physically remain in bucket 5, but the lookup would go elsewhere: lost data with no error whatsoever, the silent version of the NaiveHashTable disaster. That's why Python requires keys to be hashable: to have a hash that cannot change during their lifetime. In practice, that means immutable:
| Type | Hashable? | Reason |
|---|---|---|
int, float, str, bool, None |
Yes | Immutable |
tuple |
Yes, if everything inside it is | Immutable on the outside |
tuple containing a list, e.g. (1, [2]) |
No | Its interior can mutate |
list, dict, set |
No | Mutable |
frozenset |
Yes | The frozen set, immutable |
Two immediate practical consequences: to use several values as a compound key, pack them into a tuple ((project_id, task_id)); to use a set as a key (we'll do it with tags), freeze it with frozenset.
The hash/__eq__ contract
Under the hood, hashability rests on two special methods: __hash__ (what the hash() function calls) and __eq__ (what == calls). The table uses them in tandem, exactly like our HashTable: the hash picks the bucket, equality identifies the key inside it (p[0] == key). Hence the sacred contract:
If
a == b, thenhash(a) == hash(b).
If two "equal" keys had different hashes, they would go to different buckets and the table would contain duplicates impossible to find. Python honors this out of the box (hash(1) == hash(1.0) because 1 == 1.0), and it only affects you when you define your own classes: if you override __eq__, Python disables the inherited __hash__ (your object stops being hashable) precisely so you don't break the contract by accident; recovering hashability requires defining a coherent __hash__, usually by delegating to a tuple of immutable fields:
class TaskRef:
"""Lightweight reference to a task, usable as a dict key."""
def __init__(self, task_id):
self.id = task_id
def __eq__(self, other):
return isinstance(other, TaskRef) and self.id == other.id
def __hash__(self):
return hash(self.id) # same id → same hash: contract honoredFor TaskFlow we don't need that much: our task dicts are mutable (they change status, they change priority), so they will never be keys; the key is their id, which is an immutable integer. That separation — mutable data as value, immutable identifier as key — is the canonical design.
dict operations and costs
The reference table, now with a known explanation (averages; the O(n) worst case from 05-02 exists, but hash randomization confines it):
| Operation | Syntax | Average cost |
|---|---|---|
| Insert / update | d[k] = v |
O(1) |
| Get (key assumed present) | d[k] |
O(1); KeyError if absent |
| Get with fallback | d.get(k, default) |
O(1), no exception |
| Get-or-create | d.setdefault(k, initial) |
O(1) |
| Delete | del d[k] / d.pop(k, default) |
O(1) |
| Membership | k in d |
O(1) |
| Iterate everything | for k, v in d.items() |
O(n) |
| Dump sorted by key | sorted(d) |
O(n log n) — not free! |
The two "safety net" accessors deserve to become reflexes of yours:
getto read without fear:d.get(id, None)instead of anif id in dfollowed byd[id](which also pays the hash twice).setdefaultfor the pattern "if the key doesn't exist, create it with an initial value and hand it back to me": one call instead of three lines. We'll see it in action in the inverted index.
Insertion order preserved
Since Python 3.7, the language guarantees that iterating a dict visits the keys in the order they were inserted (a property of CPython's compact implementation that ended up elevated to a language contract). Three clarifications so you don't misread it:
- It's insertion order, not order by key:
{3: "c", 1: "a"}iterates 3, then 1. Nobody sorts anything. - Updating the value of an existing key does not move it to the end: it keeps its original position.
- The
setdoes not offer this guarantee: its iteration order is undefined. Don't write code that depends on it.
It's a very convenient property — TaskFlow's records come out in chronological sign-up order for free — but be careful not to ask of it more than it gives: "arrival order" is not "alphabetical order" nor "priority order". For those, keep reading through to section 9.
TaskFlow patterns with dict
Working data for the whole section — note the optional fields assigned_to and tags we're introducing:
tasks = [
{"id": 1, "title": "Design logo", "priority": 2, "status": "pending",
"assigned_to": "anna", "tags": ["design", "web"]},
{"id": 2, "title": "Migrate server", "priority": 1, "status": "in_progress",
"assigned_to": "bruno", "tags": ["backend", "urgent"]},
{"id": 3, "title": "Review budget", "priority": 3, "status": "pending",
"assigned_to": "anna", "tags": ["web"]},
{"id": 4, "title": "Production hotfix", "priority": 1, "status": "pending",
"assigned_to": "bruno", "tags": ["backend", "urgent", "api"]},
{"id": 5, "title": "Close sprint", "priority": 2, "status": "done",
"assigned_to": "carla", "tags": ["admin"]},
]Pattern 1 — The id index. The TaskIndex from 05-01, definitive version, in a one-line dict comprehension:
index = {t["id"]: t for t in tasks}
def find_by_id(task_id):
return index.get(task_id) # O(1), None if it doesn't exist
print(find_by_id(4)["title"]) # Production hotfix — nothing gets walkedThe 02-03 hint is now fully repaid: if the tasks live in a LinkedList, the index can point id→node and give O(1) access into the middle of the list. Maintenance rule: whoever adds or removes a task must touch both structures (list and index); an out-of-sync index is worse than no index.
Pattern 2 — Grouping by status with defaultdict. We want status → list of tasks. With a plain dict, every insertion requires checking whether the key exists; collections.defaultdict removes that noise by manufacturing the initial value (here, list()) on the first access to each new key:
from collections import defaultdict
by_status = defaultdict(list) # absent key → creates [] automatically
for t in tasks:
by_status[t["status"]].append(t)
print([t["id"] for t in by_status["pending"]]) # [1, 3, 4]
print(len(by_status["cancelled"])) # 0 (and it creates the key: see Mistakes)Pattern 3 — Counting by priority with Counter. Counting occurrences is so common that the standard library ships it ready-made: Counter is a dict whose default value is 0:
from collections import Counter
by_priority = Counter(t["priority"] for t in tasks)
print(by_priority) # Counter({2: 2, 1: 2, 3: 1})
print(by_priority[1]) # 2 → priority 1 (highest) tasks; absent key → 0, no error
print(by_priority.most_common(1)) # [(2, 2)] — a 2-2 tie: returns the key seen firstPattern 4 — The inverted index tag→tasks. The id index answers "give me task 4"; TaskFlow's search needs the inverse: "give me the tasks with the tag urgent". The structure is called an inverted index — we invert the task→tags relation to obtain tag→ids — and it is, at small scale, the same thing a web search engine does with word→documents:
def build_tag_index(tasks):
inv_index = {}
for t in tasks:
for tag in t.get("tags", []): # get: the field is optional
inv_index.setdefault(tag, set()).add(t["id"])
return inv_index
by_tag = build_tag_index(tasks)
print(by_tag["urgent"]) # {2, 4}
print(by_tag["web"]) # {1, 3}Three tricks of the trade: t.get("tags", []) tolerates tasks without the field; setdefault(tag, set()) creates the set the first time each tag appears (the get-or-create pattern as promised); and the value is a set of ids, not a list of tasks — which enables the algebra of section 8. Building it costs O(total number of tags); querying it, O(1).
set: the dictionary without values
When only membership matters, the set is the tool. The comparison we've been making all course long, in its final form:
ids_list = [t["id"] for t in tasks] # list
ids_set = {t["id"] for t in tasks} # set (set comprehension)
999 in ids_list # O(n): walks and compares one by one
999 in ids_set # O(1): hash, bucket, answerBasic operations: add(x), discard(x) (doesn't complain if missing; remove(x) raises KeyError), x in s, len(s) — all O(1) average. Its elements, like dict keys, must be hashable: you can have a set of ids or of tuples, not of lists or of task dicts.
The reflex use is deduplication: len(ids) != len(set(ids)) detects duplicates in O(n); set(ids) removes them. And the "seen" pattern — a set accumulating what's already processed to skip repeats in O(1) — will reappear verbatim in the graph BFS of module 7 under the name visited.
Set algebra for tags
The jewel of the set is its binary operations, inherited from set theory:
| Operation | Operator | Method | Result |
|---|---|---|---|
| Union | a | b |
a.union(b) |
In a, in b, or in both |
| Intersection | a & b |
a.intersection(b) |
Only what's shared |
| Difference | a - b |
a.difference(b) |
In a but not in b |
| Symmetric difference | a ^ b |
— | In exactly one of the two |
| Subset? | a <= b |
a.issubset(b) |
Is all of a inside b? |
On top of the inverted index from section 6, these operations are the query language of TaskFlow's search:
urgent = by_tag.get("urgent", set())
backend = by_tag.get("backend", set())
web = by_tag.get("web", set())
print(urgent & backend) # {2, 4} → urgent AND backend
print(urgent | web) # {1, 2, 3, 4} → urgent OR web
print(backend - web) # {2, 4} → backend but NOT webEvery compound query resolves in one line and in time proportional to the size of the sets involved — not to the total number of tasks. And if one day you need the combination of tags as a dict key (e.g. to cache queries), remember section 2: frozenset({"urgent", "backend"}) is hashable; the regular set is not.
When NOT to use a hash table
A good engineer's judgment isn't knowing how to swing the hammer, but knowing when the screw is not a nail. The hash table buys its O(1) by destroying the order of the keys: the hash function scatters on purpose (uniformity, 05-02), so neighboring keys (ids 41, 42, 43) end up in unrelated buckets. Consequences:
- "Give me the tasks sorted by id" → the hash doesn't know; you're stuck with
sorted(index)at O(n log n), every time. - "Give me the tasks with id between 100 and 200" (a range query) → the hash can't even approximate it: there is no "next key". Your only options are trying all 101 keys one by one or walking everything.
- "What's the lowest pending id?" → a full O(n) walk. (For repeatedly extracting minimums you already have the priority queue from 04-04.)
| Need | Right structure |
|---|---|
| Look up ONE exact key | Hash table — unbeatable: O(1) |
| Iterate in key order | Ordered structure (module 6) |
| Range queries (between a and b) | Ordered structure (module 6) |
| Predecessor / successor of a key | Ordered structure (module 6) |
That "ordered structure" which keeps the keys navigable without paying a sort per query exists, it is hierarchical, and it is the syllabus of module 6: trees. For now, keep the frontier in mind: exact → hash; ordered or range → something else.
Common Mistakes and Tips
- Using a task dict (or any mutable) as a key: immediate
TypeError— and now you know it's Python protecting you from the silent data loss of section 2. Key = immutable identifier; mutable data = value. - Bare
d[k]with keys that may be absent: everyKeyErrorin production usually betrays agetthat should have been there. Reserved[k]for when absence is a program error. - The curious lookup on a
defaultdict: queryingby_status["cancelled"]just to "have a look" creates the key with an empty list (that's its job). To query without creating, use"cancelled" in by_statusor.get. Adefaultdictthat grows merely from being queried is a classic head-scratcher. - Mutating a dict while iterating it:
RuntimeError: dictionary changed size during iteration. Collect the keys to delete into a list first and delete afterwards, or iterate over a copy (list(d.items())). - Trusting the order of a
set: it has none. If output order matters, sort explicitly (sorted(s)) or use adictwith dummy values if what you want is "a set with insertion order". - Tip: memorize the trio
get/setdefault/defaultdictas levels of the same pattern — read with a fallback, read-or-create one-off, create-always in bulk. Picking the right level makes the code short and readable.
Exercises
- Key tribunal. Without running it, verdict (hashable or not) and reason:
(1, "a"),[1, 2],("x", (2, 3)),(1, [2, 3]),frozenset({"a", "b"}),{"a", "b"}. Bonus: why ishash(True) == hash(1)not an accident but an obligation? - Team dashboard. With the
taskslist from section 6, build in a single pass over the data (plus whatever you need fromCounter): (a)by_person: assigned_to → list of titles, withdefaultdict; (b)workload: assigned_to → number of tasks not done, withCounter; and (c) print the busiest person withmost_common. - AND/NOT search. Using
build_tag_indexand the id index, writesearch(with_tags, without_tags)that takes two lists of tags and returns the titles of the tasks that have all the tags inwith_tagsand none of those inwithout_tags.search(["backend", "urgent"], ["api"])must return["Migrate server"]. Watch out for the nonexistent tag.
Solutions
Exercise 1. (1, "a"): hashable — tuple of immutables. [1, 2]: no — a list, mutable. ("x", (2, 3)): hashable — nested tuples, everything immutable. (1, [2, 3]): no — the tuple is immutable on the outside, but it contains a list that can mutate; its hash wouldn't be stable (the attempt raises TypeError). frozenset({"a", "b"}): hashable — it's frozen. {"a", "b"}: no — the set is mutable (to use as a key, freeze it). Bonus: since True == 1, the contract from section 3 forces hash(True) == hash(1); otherwise d[1] = "x" and d[True] would look in different buckets despite being "equal" keys.
Exercise 2.
from collections import defaultdict, Counter
by_person = defaultdict(list)
workload = Counter()
for t in tasks: # a single pass: O(n)
by_person[t["assigned_to"]].append(t["title"])
if t["status"] != "done":
workload[t["assigned_to"]] += 1 # Counter: the key is born at 0
print(dict(by_person))
# {'anna': ['Design logo', 'Review budget'], 'bruno': ['Migrate server', 'Production hotfix'],
# 'carla': ['Close sprint']}
print(workload) # Counter({'anna': 2, 'bruno': 2})
person, n = workload.most_common(1)[0]
print(f"Busiest: {person} with {n} tasks") # anna (or bruno: tied at 2)Carla doesn't appear in workload: her only task is done and Counter only creates keys when adding — the correct behavior for "pending workload". With ties, most_common returns first the key that reached the count earliest (insertion order); if the tie-break mattered, it would have to be defined explicitly.
Exercise 3.
def search(with_tags, without_tags):
if not with_tags:
return []
# AND: intersect the id sets of all required tags
result = set(by_tag.get(with_tags[0], set())) # copy: don't mutate the index
for tag in with_tags[1:]:
result &= by_tag.get(tag, set())
# NOT: subtract the ids of every excluded tag
for tag in without_tags:
result -= by_tag.get(tag, set())
return [index[id_]["title"] for id_ in sorted(result)]
print(search(["backend", "urgent"], ["api"])) # ['Migrate server']
print(search(["backend", "missing"], [])) # [] — get(..., set()) saves the queryAnatomy: the AND is a chained intersection (each &= can only shrink the result); the NOT, a difference; get(tag, set()) turns the unknown tag into an empty set instead of a KeyError — with a nonexistent required tag the AND collapses to empty, which is the right answer. The final step translates ids→titles with the id index: the lesson's two indexes cooperating, the inverted one to filter and the direct one to resolve. The trailing sorted gives reproducible output (sets promise no order).
Conclusion
You now command "real" hash tables: dict and set with their rules explained from the inside — hashable keys because the key is the address and an address must not mutate, the hash/__eq__ contract, O(1) average costs and guaranteed insertion order (which is not order by key). On the practical side, TaskFlow has gained its query infrastructure: an id index, grouping by status (defaultdict), priority statistics (Counter), an inverted tag index and an AND/OR/NOT query language thanks to set algebra. And you have the frontier clear: the hash is unbeatable with an exact key, but knows nothing of order or ranges. The next lesson introduces no new theory: it's pure training — six progressive exercises where these patterns (and the HashTable from 05-02) solve classic interview problems and real TaskFlow needs. Time to warm up: see you in 05-04.
Data Structures Course
Module 1: Introduction to Data Structures
- What Are Data Structures?
- The Importance of Data Structures in Programming
- Types of Data Structures
- Algorithmic Complexity and Big O Notation
- Arrays and Memory: the Foundation of Data Structures
Module 2: Lists
Module 3: Stacks
- Introduction to Stacks
- Basic Stack Operations
- Stack Implementation
- Stack Applications
- Stack Exercises
Module 4: Queues
- Introduction to Queues
- Basic Queue Operations
- Circular Queues
- Priority Queues
- Double-Ended Queues (Deques)
- Queue Exercises
Module 5: Hash Tables and Dictionaries
- Introduction to Hash Tables
- Hash Functions and Collision Resolution
- Dictionaries and Sets in Practice
- Hash Table Exercises
Module 6: Trees
- Introduction to Trees
- Binary Trees
- Tree Traversals
- Binary Search Trees
- AVL Trees
- B-Trees
- Heaps
- Tree Exercises
Module 7: Graphs
- Introduction to Graphs
- Graph Representation
- Graph Search Algorithms
- Shortest Path Algorithms
- Minimum Spanning Trees
- Graph Applications
- Graph Exercises
