← All Problems · Patterns & Complexity Cheat Sheet

Binary Search on Answer, Union-Find, Trie, DP & Backtracking

Five patterns, one page, each with a plain (non-Fin) worked example small enough to trace by hand. Every number in every trace table below was actually produced by running the code, not worked out on paper — see the scripts for yourself in the code blocks.

Union-Find (Disjoint Set)

Union-Find tracks a partition of items into disjoint groups under two operations: "are these two items in the same group?" (find) and "merge these two items' groups" (union). Each item starts as its own group of one, pointing to itself as its own "parent." find(x) walks parent pointers up to the group's root; union(a, b) finds both roots and, if they differ, attaches one root under the other.

Two optimizations turn this from "works" into "near O(1) per operation": path compression (every node visited on the way up during a find gets re-pointed straight at the root, so the next lookup is instant) and union by rank/size (always attach the shorter/smaller tree under the taller/larger one, so trees never get needlessly deep). Either one alone helps; both together give the amortized inverse-Ackermann bound.

Worked Example: Connected Components Over 8 Nodes

Eight nodes, numbered 0-7, and seven edges processed in order: (0,1), (1,2), (3,4), (5,6), (6,7), (2,3), (0,4). How many connected components remain at the end?

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.components = n

    def find(self, x):
        path = []
        while self.parent[x] != x:
            path.append(x)
            x = self.parent[x]
        # path compression: point every visited node straight at the root
        for node in path:
            self.parent[node] = x
        return x

    def union(self, a, b):
        root_a, root_b = self.find(a), self.find(b)
        if root_a == root_b:
            return False
        # union by rank: attach the shorter tree under the taller one
        if self.rank[root_a] < self.rank[root_b]:
            root_a, root_b = root_b, root_a
        self.parent[root_b] = root_a
        if self.rank[root_a] == self.rank[root_b]:
            self.rank[root_a] += 1
        self.components -= 1
        return True

Initial parent = [0, 1, 2, 3, 4, 5, 6, 7], initial components = 8.

CallMerged?parent afterrank aftercomponents after
union(0,1)True[0,0,2,3,4,5,6,7][1,0,0,0,0,0,0,0]7
union(1,2)True[0,0,0,3,4,5,6,7][1,0,0,0,0,0,0,0]6
union(3,4)True[0,0,0,3,3,5,6,7][1,0,0,1,0,0,0,0]5
union(5,6)True[0,0,0,3,3,5,5,7][1,0,0,1,0,1,0,0]4
union(6,7)True[0,0,0,3,3,5,5,5][1,0,0,1,0,1,0,0]3
union(2,3)True[0,0,0,0,3,5,5,5][2,0,0,1,0,1,0,0]2
union(0,4)False[0,0,0,0,0,5,5,5][2,0,0,1,0,1,0,0]2

The last call is the interesting one: union(0,4) returns False (0 and 4 are already in the same group after the union(2,3) step made 0 the root of the whole {0,1,2,3,4} tree), yet the parent array still changes — parent[4] flips from 3 straight to 0. That's path compression firing inside find(4) even though the union itself is rejected: find walked 4 → 3 → 0 and flattened it to 4 → 0 on the way out. Final state: find(0)==find(1)==find(2)==find(3)==find(4)==0 and find(5)==find(6)==find(7)==5 — exactly 2 components, {0,1,2,3,4} and {5,6,7}.

Complexity: amortized O(α(n)) per find/union call with both optimizations — effectively O(1); O(n) space for the parent and rank arrays.

Examples in This Set

#9 (clustering near-duplicate queries into groups) and #45 (validating an escalation hierarchy is a valid tree — no cycles, one root) both use Union-Find directly.

Trie (Prefix Tree)

A trie is a tree of nested dictionaries, one level of nesting per character, shared across every word that starts with the same prefix. Each node's children maps a single character to the next node; a boolean flag on a node (commonly called is_word) marks "a real word ends exactly here," which matters because one word can be a strict prefix of another ("car" ends inside the path that also leads to "card").

Insertion walks the word character by character, creating a new child node only where one doesn't already exist, then marks the final node as a word. A prefix search (startsWith) does the identical walk but doesn't check the is_word flag; an exact-word search does the same walk and then requires it. Both operations cost O(L) where L is the length of the word/prefix — completely independent of how many other words are stored.

Worked Example: Building and Querying a Small Trie

Insert six words in order: cat, car, card, care, dog, do.

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

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_word = True

    def search(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:
                return False
            node = node.children[ch]
        return node.is_word

    def starts_with(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return False
            node = node.children[ch]
        return True
InsertNew nodes created
insert("cat")c, a, t
insert("car")r (shares c → a with cat)
insert("card")d (shares c → a → r)
insert("care")e (shares c → a → r)
insert("dog")d, o, g (new top-level branch)
insert("do")none -- d → o already exists from dog, only is_word flips on o
QueryResultPath visited
search("card")Truec, a, r, d
search("ca")Falsec, a (nodes exist, but is_word is never set on a)
search("care")Truec, a, r, e
starts_with("ca")Truec, a
starts_with("do")Trued, o
starts_with("cars")Falsec, a, r -- stops here, r's children are only {d, e}, no s

Complexity: O(L) time per insert/search/starts_with, where L is the word/prefix length; O(total characters across all inserted words) space in the worst case, less in practice since shared prefixes reuse nodes.

Examples in This Set

#17 (KB autocomplete) is the only problem in this set that uses a literal trie — it's a narrow pattern, most "prefix matching" needs elsewhere in this set get solved with simpler string methods instead.

Dynamic Programming

DP applies when a problem has overlapping subproblems — the naive recursive solution would solve the exact same smaller instance over and over — and optimal substructure, meaning the optimal answer to the whole problem can be built directly from optimal answers to smaller subproblems. The entire discipline is: define what dp[i] (or dp[i][j]) precisely means in words first, write the recurrence that computes it from smaller states, nail the base case(s), then fill the table in an order where every dependency is already computed by the time you need it.

Bottom-up (fill a table iteratively) and top-down (recursion plus a memo dict) compute the exact same values — bottom-up avoids recursion-depth limits and repeated function-call overhead, top-down is often easier to write directly from the recursive definition. Both are "DP"; neither is more correct than the other.

Worked Example: Coin Change (Fewest Coins to Make a Target)

Coins [1, 3, 4], target amount 6. dp[a] means "the fewest coins needed to make exactly amount a," with dp[0] = 0 as the base case (zero coins needed to make nothing). The recurrence: dp[a] = 1 + min(dp[a - c] for every coin c <= a).

def coin_change(coins, amount):
    INF = float("inf")
    # dp[0] = 0 is the base case; every other amount starts "unreachable"
    dp = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a:
                dp[a] = min(dp[a], dp[a - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

print(coin_change([1, 3, 4], 6))
adp[a]Best coin used
00(base case)
111 (dp[0] + 1)
221 (dp[1] + 1)
313 (dp[0] + 1)
414 (dp[0] + 1)
521 (dp[4] + 1) -- ties with coin 4 (dp[1] + 1 = 2), first found wins
623 (dp[3] + 1) -- beats coin 1's dp[5] + 1 = 3

dp[6] = 2 — two coins reach 6 (e.g. 3 + 3), confirmed by running coin_change([1, 3, 4], 6), which prints 2. Note that the "obviously biggest first" greedy instinct doesn't actually drive the optimum here: dp[6] is won by coin 3, not the largest coin 4 — greedy coin selection is a different (and not always correct) algorithm from this DP.

Complexity: O(amount × len(coins)) time — every amount is checked against every coin once; O(amount) space for the 1D table.

Examples in This Set

#19 (word break), #23 (edit distance), #26 (longest common subsequence), #27 (regex matching DP), #48 (0/1 knapsack), and #49 (longest palindromic substring) all use this pattern, each with its own state definition and recurrence.

Backtracking

Backtracking is recursive, exhaustive search with early pruning: at each step, try every legal choice, recurse into it, and if that path doesn't pan out, undo the choice before trying the next one. The undo step is what makes it backtracking rather than plain recursion — state that got mutated to make a choice (a cell marked visited, a value pushed onto a partial solution, a constraint set updated) must be restored to exactly what it was before trying the next sibling choice, or later branches see corrupted state.

It differs from DP in that it doesn't memoize — it's for problems asking "find all X" or "does any valid X exist" where the state space doesn't compress into a small number of reusable subproblems the way DP's does. Pruning (bailing out of a branch the instant it's provably invalid, instead of building it out fully first) is what keeps the exponential worst case tractable in practice.

Worked Example: 4-Queens

Place 4 queens on a 4x4 board, one per row, so that no two share a column or diagonal. Track used columns and both diagonal directions (row - col and row + col are each constant along one diagonal) as sets; place a queen in row row by trying every column, skipping any that conflict, recursing to the next row, then removing the queen and its constraints before trying the next column.

def solve_n_queens(n):
    solutions = []
    cols = set()
    diag1 = set()  # row - col is constant along one diagonal
    diag2 = set()  # row + col is constant along the other diagonal
    placement = []

    def backtrack(row):
        if row == n:
            solutions.append(list(placement))
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue
            # place the queen and mark its column/diagonals as used
            placement.append(col)
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            backtrack(row + 1)
            # undo -- remove the queen and its constraints before trying the next column
            placement.pop()
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    backtrack(0)
    return solutions

print(solve_n_queens(4))

Tracing just the row 0, col 0 branch shows the full place-recurse-undo cycle playing out to a dead end and backing all the way out:

StepRowActionplacement after
10place col 0[0]
21try col 0 -- conflict (same column), skip[0]
31try col 1 -- conflict (diagonal), skip[0]
41place col 2[0, 2]
5-82try col 0, 1, 2, 3 -- all conflict, skip[0, 2]
91undo col 2[0]
101place col 3[0, 3]
112try col 0 -- conflict, skip[0, 3]
122place col 1[0, 3, 1]
13-163try col 0, 1, 2, 3 -- all conflict, skip[0, 3, 1]
172undo col 1[0, 3]
18-192try col 2, 3 -- conflict, skip[0, 3]
201undo col 3[0]
210undo col 0[]

Every column choice starting from row 0, col 0 dead-ends by row 3, and the undo steps correctly restore placement to [] before row 0 tries col 1 next. Continuing the same process for col 1, col 2, and col 3 at row 0, running solve_n_queens(4) finds exactly two valid boards: [1, 3, 0, 2] and [2, 0, 3, 1] (each list is the column chosen for row 0, row 1, row 2, row 3 in order) — the known count of solutions for 4-queens.

Complexity: exponential worst case (naive column-permutation search is O(n!)); the column/diagonal conflict sets prune the vast majority of branches before they're ever fully built, which is why 4-queens above never even explores most of the 4⁴ = 256 raw column combinations. Space is O(n) for the recursion depth and constraint sets.

Examples in This Set

#19 (Word Break II -- enumerating every valid segmentation, not just whether one exists) and #25 (word search over a grid) both use backtracking.

Common Pitfalls Across All Five

  • Binary search on the answer requires actually verifying the predicate is monotonic before reaching for this pattern -- that's a classic wrong turn, exactly what trips up #4, where precision-vs-threshold looks monotonic but isn't, and the correct approach is a sort-then-sweep instead.
  • Union-Find needs both path compression and union by rank/size for the near-O(1) guarantee -- either one alone is still correct but noticeably weaker, and it's easy to implement one and forget the other.
  • Trie memory can blow up fast with a large alphabet or many long, mostly-distinct words if nodes aren't shared correctly -- always double check that only genuinely new prefix paths create new nodes, as in the insert("do") step above, which created zero new nodes.
  • DP state-definition bugs -- getting what dp[i] actually means subtly wrong -- are the single most common DP bug, more common than the recurrence itself being wrong. Write the state's meaning in a full sentence before writing any code.
  • Backtracking's most common bug is forgetting to un-mark/undo state on the way back out of a failed branch, which silently corrupts every later branch's view of what's already used -- the undo half of place-recurse-undo is exactly as important as the place half.