Module 5 Β· days 55–65 Β· cheatsheet

Advanced Data Structures

The whole module on one page β€” analogy on the left of your memory, definition on the right. Print it (Ctrl/Cmd+P) and stick it above your desk.

1.Heaps & Priority QueuesA triage nurse, not a queue

A binary heap is a complete binary tree stored in a flat array β€” children of index i live at 2i+1 and 2i+2, so no pointers are needed. The heap property (every parent ≀ its children, for a min-heap) is weaker than full ordering, which is exactly why push and pop are O(log n) rather than O(n log n): only one root-to-leaf path is repaired per operation.

import heapq

h = []
heapq.heappush(h, (2, "ship it"))
heapq.heappush(h, (1, "fix prod"))   # urgent
heapq.heappush(h, (3, "refactor"))

heapq.heappop(h)     # (1, "fix prod")
h[0]                 # peek β€” O(1)

# top-k without sorting everything: O(n log k)
heapq.nlargest(3, scores)

# max-heap: negate
heapq.heappush(h, (-priority, item))
2.Heapify & HeapsortBuilding the pile bottom-up

Building a heap by n successive pushes costs O(n log n). Building it bottom-up (sift-down from index n//2βˆ’1 to 0) costs O(n): most nodes are near the leaves and sift down almost no levels, and the sum n/2Β·0 + n/4Β·1 + n/8Β·2 + … converges to n.

import heapq

a = [5, 3, 8, 1, 9, 2, 7, 4]
heapq.heapify(a)      # O(n) bottom-up, in place

# heapsort
def heapsort(a):
    heapq.heapify(a)                       # O(n)
    return [heapq.heappop(a) for _ in range(len(a))]

# why bottom-up is O(n):
# n/2 nodes sift 0 levels, n/4 sift 1, n/8 sift 2 ...
# sum(k * n / 2**(k+1)) -> n
3.Tries / Prefix TreesA signpost at every letter

A trie stores keys along the edges of a tree, so lookup is O(m) in the key length β€” independent of how many keys are stored. That is its real advantage over a hash map: not raw speed, but that prefix queries are free.

class Trie:
    def __init__(self):
        self.children = {}
        self.is_word = False

    def insert(self, word):
        node = self
        for ch in word:
            node = node.children.setdefault(ch, Trie())
        node.is_word = True          # matters!

    def find(self, prefix):
        node = self
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node                  # subtree = all completions

# lookup is O(len(word)), NOT O(number of words)
4.Union-Find (Disjoint Set Union)Merging friend groups

DSU maintains a forest where each set is a tree with its root as the representative. Two optimisations make it fast: union by rank/size (always attach the smaller tree under the larger, keeping depth low) and path compression (on every find, re-point every node visited directly at the root).

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n

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

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                  # already together -> cycle
        if self.size[ra] < self.size[rb]:  # union by size
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        return True
5.Segment TreesNested summaries of a shelf

A segment tree stores an associative aggregate (sum, min, max, gcd) for every node covering a contiguous range, in a 4n array. Both range query and point update are O(log n) because any range decomposes into at most O(log n) canonical nodes, and an update touches only the root path.

class SegTree:
    def __init__(self, a):
        self.n = len(a)
        self.t = [0] * (4 * self.n)
        self._build(a, 1, 0, self.n - 1)

    def _build(self, a, node, lo, hi):
        if lo == hi:
            self.t[node] = a[lo]; return
        mid = (lo + hi) // 2
        self._build(a, 2*node,   lo,      mid)
        self._build(a, 2*node+1, mid + 1, hi)
        self.t[node] = self.t[2*node] + self.t[2*node+1]

    def query(self, node, lo, hi, l, r):
        if r < lo or hi < l:  return 0            # disjoint
        if l <= lo and hi <= r: return self.t[node]  # fully inside
        mid = (lo + hi) // 2
        return (self.query(2*node,   lo,      mid, l, r) +
                self.query(2*node+1, mid + 1, hi,  l, r))
6.Fenwick Tree (BIT)Rulers of clever lengths

A Fenwick tree (binary indexed tree) stores, at index i, the sum of the i & -i elements ending at i. Prefix sum walks i -= i & -i; point update walks i += i & -i.

class Fenwick:
    def __init__(self, n):
        self.n = n
        self.t = [0] * (n + 1)        # 1-indexed!

    def add(self, i, delta):
        while i <= self.n:
            self.t[i] += delta
            i += i & -i               # next block up

    def prefix(self, i):
        s = 0
        while i > 0:
            s += self.t[i]
            i -= i & -i               # strip lowest set bit
        return s

    def range_sum(self, l, r):
        return self.prefix(r) - self.prefix(l - 1)
7.Self-Balancing Trees & RotationsStraightening a leaning tower

A rotation is a constant-time, local restructuring that changes height while preserving the in-order sequence β€” the invariant that makes it legal. AVL trees keep every node's subtree heights within 1, giving tighter balance and faster lookups; red-black trees allow looser balance with fewer rotations per write, so they win on write-heavy workloads (which is why they back most standard libraries).

# Right rotation β€” O(1), and the in-order walk is unchanged
def rotate_right(y):
    x = y.left
    y.left = x.right
    x.right = y
    return x            # x is the new subtree root

def height(n):  return 0 if n is None else n.height
def balance(n): return height(n.left) - height(n.right)
# AVL invariant: abs(balance(n)) <= 1 for every node

# In practice, in Python:
from sortedcontainers import SortedDict
d = SortedDict()
d.irange(10, 20)      # ordered range query
8.Hash Map Internals: Probing & ResizingLooking for the next free seat

Open addressing stores entries directly in the table and resolves collisions by probing: linear (i+1, cache-friendly but suffers primary clustering), quadratic (i+kΒ², spreads clusters), or double hashing (step size from a second hash, best distribution). Deletion cannot simply clear a slot β€” that would truncate probe chains β€” so implementations write a tombstone, and tombstones accumulate until a rehash clears them.

# open addressing with linear probing + tombstones
EMPTY, TOMB = object(), object()

def find_slot(table, key):
    i = hash(key) % len(table)
    while table[i] is not EMPTY:
        if table[i] is not TOMB and table[i][0] == key:
            return i                    # found
        i = (i + 1) % len(table)        # probe on
    return None

# deleting must leave a TOMB, never EMPTY,
# or later probes stop early and lose entries

# load factor > ~0.7 -> resize and rehash everything
9.Designing an LRU CacheA desk with limited space

LRU needs O(1) lookup AND O(1) recency reordering, and no single structure gives both β€” so you compose two: a hash map for key β†’ node, and a doubly linked list holding nodes in recency order. Lookup goes through the map; promotion unlinks the node and re-inserts at the head, which needs prev pointers, which is exactly why the list must be doubly linked.

from collections import OrderedDict

class LRU:
    def __init__(self, cap):
        self.cap, self.d = cap, OrderedDict()

    def get(self, k):
        if k not in self.d:
            return -1
        self.d.move_to_end(k)          # promote β€” O(1)
        return self.d[k]

    def put(self, k, v):
        if k in self.d:
            self.d.move_to_end(k)
        self.d[k] = v
        if len(self.d) > self.cap:
            self.d.popitem(last=False)  # evict oldest

# hash map = O(1) find, linked list = O(1) reorder
from functools import lru_cache        # the real one
10.Bloom FiltersA bouncer with a bad memory

A Bloom filter is a bit array plus k independent hash functions. Insert sets k bits; a query returns "possibly present" only if all k bits are set.

import hashlib

class Bloom:
    def __init__(self, m=1024, k=3):
        self.m, self.k = m, k
        self.bits = bytearray(m)

    def _hashes(self, item):
        h = hashlib.sha256(str(item).encode()).digest()
        return [int.from_bytes(h[i*4:(i+1)*4], "big") % self.m
                for i in range(self.k)]

    def add(self, item):
        for i in self._hashes(item):
            self.bits[i] = 1

    def __contains__(self, item):
        return all(self.bits[i] for i in self._hashes(item))
        # True  -> PROBABLY present
        # False -> DEFINITELY absent