Ch 9 / 30 Sets, Maps and Choosing the Right Container 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 9

Part 2 · Linear Structures · 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.

Reading
Focus
Choosing well
Covers
list · set · dict · deque
Ends
Part 2

By the end of this chapter you can

  1. Walk a decision procedure from an access pattern to a container, and defend the choice
  2. Use Counter, defaultdict and set algebra instead of hand-rolling them
  3. Recognise the four container smells that mean you picked wrong
  4. Say when a plain list is still the right answer despite a worse Big-O
  5. 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:

  1. How do I find things? By position, by key, by value, by order, or “the smallest”?
  2. Does order matter? Insertion order, sorted order, or none?
  3. Where do things get added and removed? End, front, middle, anywhere?
  4. How often do I read versus write?
  5. 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…UseCostBecause
Access by integer positionlistO(1)Address arithmetic
Add and remove at the end onlylistO(1) amortisedNothing shifts
Add and remove at both endsdequeO(1)Doubly linked blocks
Look up a value by a keydictO(1) averageKey computes its own address
Ask only “have I seen this?”setO(1) averageA dict with no values
Count occurrencesCounterO(1) per itemA dict that defaults to 0
Repeatedly take the smallestheapqO(log n)Chapter 17
Keys in sorted order, or range queriesbalanced treeO(log n)Chapters 15–16
Prefix queries on stringstrieO(key length)Chapter 18
The single most valuable line in that table

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.

set_algebra.py
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?”

difference.py
# 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)
What a set costs you
  • 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.

counter.py
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.

defaultdict.py
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)
defaultdict creates on read

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 byCanonical keyExample
Anagram"".join(sorted(word))eat, tea, ate → aet
Case-insensitive namename.lower()Ada, ADA → ada
Same file contentshash of the bytesduplicate-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

smell1.py
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, …)

smell2.py
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()

smell3.py
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 +=

smell4.py
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).
All four are the same mistake

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).

crossover.py
# 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.
The rule that survives contact with reality

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 needComposeWhere it appears
O(1) membership and insertion orderset + listDeduplication (Ch. 2)
O(1) lookup and O(1) “which is oldest”dict + doubly linked listLRU cache (Ch. 29)
O(1) top and O(1) minimumtwo stacksMinStack (Ch. 6)
O(1) queue from stack-only operationstwo stacksCh. 7
O(1) “same group?” and O(1) mergeparent array + rank arrayUnion-find (Ch. 23)
Fast lookup and ordered iterationdict + sorted keys, or a treeCh. 15
composition.py
# 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).
The question to ask

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.
  • Counter for counting, defaultdict for grouping. And the canonical-form pattern: group by whatever makes items equivalent.
  • Four smells: in on a list in a loop, pop(0), min() then remove(), += 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →