AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 9
Sets, Maps and Choosing the Right Container
Half of practical algorithm work is picking a container and letting it do the work. Here is a decision procedure you can actually follow, with the real costs written on it.
By the end of this chapter you can
- Walk a decision procedure from an access pattern to a container, and defend the choice
- Use
Counter,defaultdictand set algebra instead of hand-rolling them - Recognise the four container smells that mean you picked wrong
- Say when a plain list is still the right answer despite a worse Big-O
- Combine two containers to get properties neither has alone
1Five questions
Chapter 1 introduced these. Now you know enough to answer them, and the answers determine the container.
Before you type a variable name, answer these about the data:
- How do I find things? By position, by key, by value, by order, or “the smallest”?
- Does order matter? Insertion order, sorted order, or none?
- Where do things get added and removed? End, front, middle, anywhere?
- How often do I read versus write?
- Do duplicates exist, and should they?
Then read the answer off this table. It is the whole of Part 2 in one place:
| If your access pattern is… | Use | Cost | Because |
|---|---|---|---|
| Access by integer position | list | O(1) | Address arithmetic |
| Add and remove at the end only | list | O(1) amortised | Nothing shifts |
| Add and remove at both ends | deque | O(1) | Doubly linked blocks |
| Look up a value by a key | dict | O(1) average | Key computes its own address |
| Ask only “have I seen this?” | set | O(1) average | A dict with no values |
| Count occurrences | Counter | O(1) per item | A dict that defaults to 0 |
| Repeatedly take the smallest | heapq | O(log n) | Chapter 17 |
| Keys in sorted order, or range queries | balanced tree | O(log n) | Chapters 15–16 |
| Prefix queries on strings | trie | O(key length) | Chapter 18 |
Membership testing in a loop belongs in a set.
x in some_list is O(n). Inside a loop that is O(n²),
and it is by a wide margin the most common accidental quadratic in working code. The fix is one
word.
2Sets, and set algebra
A set is a hash table that threw away the values. What is left is membership — and a small algebra that replaces a lot of loops.
Sets do three things: fast membership, automatic deduplication, and set algebra. The third is the one people forget, and it usually replaces a nested loop with a single operator.
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a | b # union {1,2,3,4,5,6} A
a & b # intersection {3,4} B
a - b # difference {1,2} C
a ^ b # symmetric diff{1,2,5,6} D
a <= b # subset?
a.isdisjoint(b) # no overlap at all?
# Each of these is O(len of the smaller set) and runs in C.
# The hand-written equivalent is a nested loop in Python.A worked example. “Which users are in group A but not group B?”
# The loop most people write first — O(n × m)
result = []
for u in group_a:
if u not in group_b: # O(m) scan of a list
result.append(u)
# The same thing, O(n + m), and it says what it means
result = set(group_a) - set(group_b)- No order. Iteration order is an implementation detail; do not rely on it.
- No duplicates. Usually the point, occasionally a silent data loss.
- No indexing. There is no
s[0]. - Elements must be hashable. A set of lists is impossible; a set of tuples is fine.
3Dictionaries you did not have to write
Two standard-library containers that remove the most common boilerplate around dicts.
Counter
Counting things is so common that Python ships a dict subclass for it.
from collections import Counter
# What everyone writes first:
counts = {}
for w in words:
if w not in counts:
counts[w] = 0
counts[w] += 1
# The same thing:
counts = Counter(words)
counts.most_common(3) # the three most frequent, already sorted
counts["missing"] # 0, not a KeyError
counts + other # element-wise addition of two Counters
counts - other # element-wise subtraction (drops non-positives)defaultdict
For grouping, where the value is a container that must exist before you can append to it.
from collections import defaultdict
# The boilerplate:
groups = {}
for word in words:
key = word[0]
if key not in groups:
groups[key] = []
groups[key].append(word)
# With defaultdict — the missing key materialises as an empty list:
groups = defaultdict(list)
for word in words:
groups[word[0]].append(word)
# Or, without importing anything:
groups = {}
for word in words:
groups.setdefault(word[0], []).append(word)groups["z"] on a defaultdict(list) does not raise
KeyError — it silently creates an empty list and stores it. So merely
looking at a key adds it.
That is fine while building, and a nuisance afterwards. Use .get(k) or convert back
with dict(groups) once you are done writing.
The canonical-form pattern
A move that shows up constantly once you notice it: reduce each item to the thing that makes it equivalent to others, and use that as the key.
| Grouping by | Canonical key | Example |
|---|---|---|
| Anagram | "".join(sorted(word)) | eat, tea, ate → aet |
| Case-insensitive name | name.lower() | Ada, ADA → ada |
| Same file contents | hash of the bytes | duplicate-file finders |
| Same shape of number | (len(s), s[0]) | any grouping rule you can compute |
4Four container smells
How to notice from the code itself that the container is wrong, before you profile anything.
Smell 1 — in on a list, inside a loop
for user in all_users: # n
if user in banned_list: # m → O(n × m)
...
# FIX: banned = set(banned_list) once, outside the loop. O(n).Smell 2 — pop(0) or insert(0, …)
while queue:
job = queue.pop(0) # O(n) each → O(n²)
# FIX: collections.deque, then popleft(). O(1).Smell 3 — min() or max() then remove()
while tasks:
nxt = min(tasks) # O(n)
tasks.remove(nxt) # O(n) again → O(n²)
# FIX: a heap. heapq.heappop(tasks) is O(log n). Chapter 17.Smell 4 — string building with +=
out = ""
for line in lines:
out += line # strings are immutable — this COPIES
# the whole accumulated string each time
# FIX: collect into a list and B .join(parts) at the end. O(n).In every case, an operation that looks like one step is secretly a full pass over the data, and it is sitting inside a loop.
The general habit: when you see a loop, ask what the most expensive line inside it really costs. That question finds nearly all accidental quadratics.
5When a list is still right
Big-O is a model, and models leave things out. Here is what it leaves out, and when that matters.
Big-O ignores constant factors, and one constant factor is enormous: the CPU cache. Reading memory that is already in cache is roughly a hundred times faster than reading memory that is not. Arrays are contiguous, so scanning one pulls in several elements per fetch. Hash tables and linked structures scatter their data deliberately, so almost every access is a potential cache miss.
The practical consequence: for small collections, a linear scan of a list beats a hash
lookup, despite O(n) versus O(1).
# Roughly where the crossover falls in CPython, for membership testing:
#
# under ~10 items list scan is usually faster (no hashing cost)
# 10 to ~100 about even; it depends on the data
# over ~100 the set wins, and the gap grows without limit
#
# So: build the set once and reuse it, and the set is nearly always right.
# Building a set inside a loop to test 5 items is worse than the scan.
# The mistake:
for row in rows:
if row.id in set(valid_ids): # rebuilds the set EVERY iteration
...
# The fix — hoist it:
valid = set(valid_ids)
for row in rows:
if row.id in valid:
...Three more places a list is genuinely the right answer:
- You need order and position. A set cannot give you “the third item.”
- You iterate far more than you look up. Iteration is where arrays are strongest.
- The collection is tiny and short-lived. Clarity beats a microsecond.
Use Big-O to choose the approach, then measure to tune the constant. In that order.
Choosing without a model means guessing. Measuring without a model means micro-optimising the wrong loop. You need both, and the model comes first.
6Composing containers
The most useful move in the whole chapter: when no single container has the properties you need, use two.
Chapter 2's dedupe kept a set for O(1) membership and a list for
order, because neither alone could do both. That is not a hack — it is the standard technique,
and it recurs throughout the rest of the course.
| You need | Compose | Where it appears |
|---|---|---|
| O(1) membership and insertion order | set + list | Deduplication (Ch. 2) |
| O(1) lookup and O(1) “which is oldest” | dict + doubly linked list | LRU cache (Ch. 29) |
| O(1) top and O(1) minimum | two stacks | MinStack (Ch. 6) |
| O(1) queue from stack-only operations | two stacks | Ch. 7 |
| O(1) “same group?” and O(1) merge | parent array + rank array | Union-find (Ch. 23) |
| Fast lookup and ordered iteration | dict + sorted keys, or a tree | Ch. 15 |
# A structure with O(1) insert, delete AND random choice — which no
# single built-in container gives you. Two containers, glued by an index.
import random
class RandomSet:
def __init__(self):
self._items = [] # for O(1) random choice by position
self._index = {} # value -> its position in _items
def add(self, x):
if x in self._index:
return False
self._index[x] = len(self._items)
self._items.append(x)
return True
def remove(self, x):
if x not in self._index:
return False
i = self._index.pop(x)
last = self._items.pop() # O(1) — always remove the END
if i < len(self._items):
self._items[i] = last # move the last item into the hole
self._index[last] = i # and fix its recorded position
return True
def random(self):
return random.choice(self._items)
# The trick in remove(): never delete from the middle of the list — swap
# the last element into the hole instead. Order is not promised, so this
# is free, and it keeps every operation O(1).When you find yourself thinking “I need X and Y and no container does both”, do not compromise. Ask which container gives X, which gives Y, and what keeps them in sync.
The answer is usually two containers and one invariant — and that is exactly how the LRU cache, union-find and the priority queue in Dijkstra are all built.
What to carry forward
- Answer the five access-pattern questions first, then read the container off the table. Choosing without naming the pattern is guessing.
- A set is a dict without values: membership, deduplication, and set algebra that replaces nested loops with one operator.
Counterfor counting,defaultdictfor grouping. And the canonical-form pattern: group by whatever makes items equivalent.- Four smells:
inon a list in a loop,pop(0),min()thenremove(),+=on strings. All are one expensive line inside a loop. - Below roughly a hundred items a list scan beats a hash lookup, because of cache locality. Use Big-O to choose, then measure to tune.
- Compose containers. When you need two properties no single structure offers, use two structures and one invariant that keeps them in sync.
>_Playground
Measure the crossover for yourself, and try the composed RandomSet.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 10 — Recursion and the Call Stack
Russian dolls, mirrors facing mirrors, and a definition that refers to itself without going in circles. The idea that makes trees and graphs tractable.