Part 3 · Data Structures · Module 27

Advanced Structures

Lists, dicts, trees, heaps, and graphs cover most programs. This module covers the specialists: structures built for one job that they do dramatically better than anything general. Each answers a question that would otherwise be slow: "which words start with pro?", "are these two in the same group?", "what is the sum of this range after a million updates?", "have I possibly seen this URL?", and "what did I use least recently?"

⏱ ~3 hoursPrerequisites: M21, M23, M24, M26

The specialists at a glance

StructureQuestion it answersKey operationsCost
TrieWhich keys share this prefix?insert, search, starts_withO(L) for a key of length L
Union-findAre a and b in the same group?union, findO(α(n)) amortized, effectively constant
Fenwick treePrefix sums while values changeupdate, prefix_sumO(log n) each
Segment treeAny associative range query (sum, min, max, gcd)update, query(l, r)O(log n) each
Bloom filterDefinitely not seen, or probably seen?add, might_containO(k) time, a few bits per item
LRU cacheKeep the most recently used n itemsget, putO(1)
Skip listSorted set with fast insertinsert, search, deleteO(log n) expected

Tries: prefix trees

A trie (from retrieval, usually pronounced "try") stores strings character by character along paths from the root. Each node has a map from the next character to a child, plus a flag marking whether a word ends there. Words that share a prefix share the path for it. Looking up a word of length L takes L steps no matter how many words are stored, and finding everything with a given prefix is a walk to the prefix's node followed by a traversal of its subtree.

A dict or set answers "is this exact word present?" just as fast, but cannot answer "which words start with car?" without scanning every key. That is why tries power autocomplete, spell checkers, IP routing tables (longest-prefix match on bits), and T9-style keyboards.

What to remember: O(L) per operation regardless of n; prefix queries are the reason to use one. You will build a scored trie in Project 3.

Union-find (disjoint set union)

Union-find maintains a partition of n items into disjoint groups and supports two operations: union(a, b) merges the groups containing a and b, and find(a) returns a representative (the root) of a's group. Two items are connected exactly when they have the same root. Each group is stored as a tree of parent pointers in a single list: parent[i] is i's parent, and a root is its own parent.

Naively, trees can become long chains and find is O(n). Two small tricks fix this:

Together they give an amortized cost of O(α(n)) per operation, where α is the inverse Ackermann function. The Ackermann function grows so absurdly fast that α(n) ≤ 4 for any n that could fit in the observable universe. In practice, union-find is constant time; theoretically, it is the slowest-growing non-constant bound you will ever meet (and Tarjan proved you cannot do better).

Where it is used

Fenwick trees: prefix sums with updates

Suppose you track sales per day and must answer "total sales from day l to day r" while sales keep being corrected. A plain prefix-sum array (from M30) answers range sums in O(1) but needs O(n) to rebuild after each update; the raw array updates in O(1) but sums in O(n). A Fenwick tree (binary indexed tree, 1994) does both in O(log n) with a single list and about ten lines of code.

The trick is the lowbit: i & -i isolates the lowest set bit of i (two's complement makes -i flip every bit above it). With 1-based indexes, slot tree[i] stores the sum of the lowbit(i) elements ending at i. So tree[8] holds elements 1..8, tree[6] holds 5..6, and tree[7] holds just 7.

1234 5678 t[1]t[3]t[5]t[7] t[2] = a1+a2t[6] = a5+a6 t[4] = a1..a4t[8] = a1..a8 prefix_sum(7) = t[7] + t[6] + t[4] (7 → 6 → 4 → 0, subtracting lowbit) update(3) touches t[3], t[4], t[8] (3 → 4 → 8, adding lowbit)
Each bar is one slot of the tree list. Any prefix is covered by at most log₂ n bars, and any element sits under at most log₂ n bars.

Segment trees: any associative range query

A segment tree is a complete binary tree over the array: each leaf is one element, and each internal node stores the combination (sum, min, max, …) of its two children's ranges. The root covers the whole array. A query for [l, r) combines O(log n) nodes whose ranges tile the query exactly; a point update changes one leaf and its O(log n) ancestors. The combining operation only needs to be associative with an identity (0 for sum, ∞ for min), so the same code handles many problems.

[0,8) 1[0,4) 2[4,8) 1 [0,2) 5[2,4) 2[4,6) 4[6,8) 1 5826 9471
A range-minimum segment tree over [5, 8, 2, 6, 9, 4, 7, 1]. The query min over [2, 6) combines just the two green nodes: min(2, 4) = 2.
ApproachRange queryPoint updateSupports
Plain listO(n)O(1)anything
Prefix-sum arrayO(1)O(n)invertible ops (sum, xor)
Fenwick treeO(log n)O(log n)invertible ops; tiny and fast
Segment treeO(log n)O(log n)any associative op; with lazy propagation, range updates too

Bloom filters: probably yes, definitely no

A Bloom filter answers set membership using a fraction of the memory of a set, at the price of occasional false positives. It is an array of m bits, all 0, plus k independent hash functions. To add an item, set the k bits it hashes to. To query, check those k bits: if any is 0, the item was definitely never added; if all are 1, it was probably added (or other items happened to set all k bits). There are never false negatives, and items cannot be removed (clearing a bit might erase other items).

After inserting n items, the probability that a particular bit is still 0 is (1 − 1/m)kn ≈ e−kn/m. A false positive needs all k probed bits to be 1, so

false_positive_rate ≈ (1 - e^(-k*n/m)) ** k
best k = (m / n) * ln 2          # about 0.7 * bits per item
bits per item for rate p: m/n = -ln(p) / (ln 2)^2  ≈ 9.6 bits for 1%, 14.4 bits for 0.1%

About ten bits per item for a 1% error rate, whatever the items are: a 100-byte URL costs 1.2 bytes. Browsers have used Bloom filters to screen URLs against malware lists, databases such as Cassandra and RocksDB use them to skip disk reads for keys that are not in a file, and CDNs use them to avoid caching one-hit wonders.

LRU caches

A cache keeps a limited number of expensive results. When it is full, which entry should go? Least recently used (LRU) evicts the entry that has gone unused longest, betting that recent use predicts future use (temporal locality). It is the default policy for CPU caches, page caches, CDN edges, and functools.lru_cache.

An LRU cache needs O(1) lookup by key and O(1) "move to most recent" and "remove least recent". No single structure does both, so combine two: a dict from key to node, and a doubly linked list (M21) ordered by recency. The dict finds the node; the list unlinks and relinks it in O(1) because each node knows both neighbors.

For caching function results, the standard library already has it. @functools.lru_cache(maxsize=...) memoizes calls keyed by their arguments (which must be hashable), and @functools.cache is the unbounded version. cache_info() reports hits and misses; cache_clear() empties it.

Skip lists, and sorted containers in Python

A skip list is a sorted linked list with express lanes. Every node is in level 0; each node is also promoted to level 1 with probability ½, to level 2 with probability ¼, and so on. A search starts in the top lane, runs right while the next key is smaller, and drops down a level when it would overshoot. Expected cost is O(log n) for search, insert, and delete, the same as a balanced tree, with no rotations: randomness does the balancing. Redis uses skip lists for sorted sets, and they are popular in concurrent code because inserts touch only local pointers.

L2L1L0 head123 4567nil
Searching for 7: run along L2 to 4 (the next is nil), drop to L1 and run to 6, drop to L0 and step to 7. Five moves instead of seven.

Python's standard library has no skip list or balanced tree. The usual answer is the pure-Python sortedcontainers package (pixi add sortedcontainers): SortedList, SortedDict, and SortedSet keep values in a list of sorted sublists, which turns out to be faster than trees in Python because it works with bisect and CPU caches rather than against them.

Pitfalls

Practice

Implement Trie with insert(word), search(word), starts_with(prefix), and with_prefix(prefix) returning all stored words with that prefix in sorted order. Use nested dicts or node objects, not a list of words.

Hint

With nested dicts, mark the end of a word with a special key such as "$" (it cannot collide if you never insert $). For sorted output, visit child keys in sorted order, skipping the marker.

Show solution
END = "$"

class Trie:
    def __init__(self):
        self.root = {}

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
        node[END] = True

    def _node(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node:
                return None
            node = node[ch]
        return node

    def search(self, word):
        node = self._node(word)
        return node is not None and END in node

    def starts_with(self, prefix):
        return self._node(prefix) is not None

    def with_prefix(self, prefix):
        node = self._node(prefix)
        out = []
        def walk(n, text):
            if END in n:
                out.append(text)
            for ch in sorted(k for k in n if k != END):
                walk(n[ch], text + ch)
        if node is not None:
            walk(node, prefix)
        return out

Implement UnionFind(n) with find, union (returns True if two groups were merged), connected(a, b), and an attribute groups (the current number of groups). Use union by rank or size and path compression. A test runs 200,000 operations, so it must be fast.

Hint

Write find iteratively (a long chain would exceed the recursion limit before compression kicks in). Decrement groups only when roots differ.

Show solution
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n
        self.groups = n

    def find(self, x):
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:
            self.parent[x], x = root, self.parent[x]
        return root

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.groups -= 1
        return True

    def connected(self, a, b):
        return self.find(a) == self.find(b)

Implement Fenwick(values) built from a 0-based list, with add(i, delta), prefix(i) (sum of the first i values, so prefix(0) == 0), and range_sum(l, r) for the half-open range [l, r). All indexes are 0-based at the API; store a 1-based tree internally.

Hint

Internal index is i + 1. Build by calling add for each value (O(n log n)), or in O(n) by pushing each slot's total to j = i + (i & -i).

Show solution
class Fenwick:
    def __init__(self, values):
        n = len(values)
        self.tree = [0] + list(values)
        for i in range(1, n + 1):
            j = i + (i & -i)
            if j <= n:
                self.tree[j] += self.tree[i]

    def add(self, i, delta):
        i += 1
        while i < len(self.tree):
            self.tree[i] += delta
            i += i & -i

    def prefix(self, i):
        total = 0
        while i > 0:
            total += self.tree[i]
            i -= i & -i
        return total

    def range_sum(self, l, r):
        return self.prefix(r) - self.prefix(l)

Implement LRUCache(capacity) with get(key) (returns -1 on a miss, like the classic interview version) and put(key, value), both O(1), using collections.OrderedDict. Add __len__.

Hint

move_to_end(key) marks a key as most recent; popitem(last=False) removes the oldest.

Show solution
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return -1
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key, value):
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)

    def __len__(self):
        return len(self.data)

Write optimal_params(n, p) returning (m, k): m = ⌈−n·ln p / (ln 2)²⌉ bits and k = max(1, round(m/n · ln 2)) hashes. Then implement BloomFilter(n, p) with add(item) and __contains__ for strings, using hashlib (not hash()). Tests check there are no false negatives and the false-positive rate is near p.

Hint

Store bits in a bytearray or a Python int. Derive k positions from one digest with double hashing: (h1 + i * h2) % m.

Show solution
import hashlib
import math

def optimal_params(n, p):
    m = math.ceil(-n * math.log(p) / math.log(2) ** 2)
    k = max(1, round(m / n * math.log(2)))
    return m, k

class BloomFilter:
    def __init__(self, n, p):
        self.m, self.k = optimal_params(n, p)
        self.bits = bytearray((self.m + 7) // 8)

    def _positions(self, item):
        d = hashlib.sha256(item.encode("utf-8")).digest()
        h1 = int.from_bytes(d[:8], "big")
        h2 = int.from_bytes(d[8:16], "big") | 1
        return [(h1 + i * h2) % self.m for i in range(self.k)]

    def add(self, item):
        for pos in self._positions(item):
            self.bits[pos // 8] |= 1 << (pos % 8)

    def __contains__(self, item):
        return all(self.bits[pos // 8] >> (pos % 8) & 1 for pos in self._positions(item))

Implement MinSegmentTree(values) with update(i, value) and query(l, r) returning the minimum of values[l:r] (half-open, non-empty), both in O(log n).

Hint

The bottom-up layout puts leaves at t[n:2n] and node i's children at 2i, 2i + 1; it works for any n, not only powers of two.

Show solution
class MinSegmentTree:
    def __init__(self, values):
        self.n = n = len(values)
        self.t = [float("inf")] * n + list(values)
        for i in range(n - 1, 0, -1):
            self.t[i] = min(self.t[2 * i], self.t[2 * i + 1])

    def update(self, i, value):
        i += self.n
        self.t[i] = value
        while i > 1:
            i //= 2
            self.t[i] = min(self.t[2 * i], self.t[2 * i + 1])

    def query(self, l, r):
        best = float("inf")
        l += self.n
        r += self.n
        while l < r:
            if l & 1:
                best = min(best, self.t[l])
                l += 1
            if r & 1:
                r -= 1
                best = min(best, self.t[r])
            l //= 2
            r //= 2
        return best

Mini-project: a rate limiter with a sliding-window log and LRU

Mini-projectPer-client rate limiter

APIs limit how often each client may call them ("100 requests per minute"). Build ratelimit.py with a sliding-window log: for each client keep a deque of request timestamps; on each request drop timestamps older than the window, then allow the request if fewer than the limit remain. To stop memory growing with millions of one-off clients, keep the per-client logs in an LRU cache with a fixed capacity, so inactive clients are evicted.

pixi init ratelimit && cd ratelimit
pixi add python=3.14 pytest
pixi run python -m pytest -q
  • RateLimiter(limit, window_seconds, max_clients, clock=time.monotonic): inject the clock so tests can control time without sleeping.
  • allow(client_id) -> bool, remaining(client_id) -> int, and retry_after(client_id) -> float (seconds until the oldest timestamp leaves the window).
  • Store logs in your own LRU (dict plus doubly linked list from this module), touched on every request.
  • Rejected requests are not recorded (or make it configurable and explain the trade-off in the README).
  • Exactly limit requests pass within one window, and the next one is rejected
  • Requests are allowed again as soon as old timestamps slide out (tested with a fake clock)
  • retry_after is exact, and remaining never goes negative
  • With max_clients=1000 and 100,000 distinct clients, memory stays bounded (len of the LRU ≤ 1000)
  • Each allow is amortized O(1): every timestamp is appended and popped at most once

Stretch goals: implement a fixed-window counter and a token bucket and compare their burst behavior on the same request trace; add a Bloom filter of known-abusive client ids that are rejected immediately; make it thread-safe with a lock and measure the overhead.

Quiz

Recap

Going further