Trie & Union Find¶
This chapter covers two specialized data structures. Neither is as general as a hash table or a balanced tree, but each does one job so well that it turns otherwise awkward problems into short, fast code:
- A trie stores a set of strings so that everything sharing a prefix shares storage and search effort. It answers prefix questions — "which words start with
pre?" — in time proportional to the prefix, independent of how many words are stored. - A union-find (disjoint set) structure maintains a partition of elements into groups under merging, answering "are these two in the same group?" in effectively constant time.
Both illustrate a recurring lesson of data structure design: when the obvious structures each make one of your operations fast and another slow, look for a structure that balances them.
Part 1: Tries¶
Why Another Dictionary?¶
Suppose you store a set of \(n\) words and need to answer queries about a string \(q\) of length \(k\).
| Structure | Is \(q\) a word? | Do any words start with \(q\)? | List words starting with \(q\) |
|---|---|---|---|
| Hash set | \(O(k)\) expected | \(O(n \cdot k)\): check every word | \(O(n \cdot k)\) |
| Sorted list + binary search | \(O(k \log n)\) | \(O(k \log n)\) | \(O(k \log n + \text{output})\) |
| Trie | \(O(k)\) | \(O(k)\) | \(O(k + \text{output size})\) |
A hash set is great at exact lookups but useless for prefixes: hashing scatters car, card, and care to unrelated buckets. A sorted list keeps words with a common prefix together, which is why it handles prefix queries — but every comparison costs up to \(k\) character checks.
There's a subtler advantage too. Often queries arrive incrementally: first c, then ca, then car, each extending the last by one character (a user typing, a DFS extending a path on a board). A hash set must hash each new string from scratch, costing \(O(k)\) per query. A trie just takes one more step from where the previous query ended: \(O(1)\) per character.
Structure¶
A trie is a tree in which each edge is labeled by a character, and the path from the root to a node spells out a string. Words with a common prefix share the path for that prefix and branch at their first differing character. Nodes that end a word are marked.
(root)
/ \
t w
| / \
h a h
| | |
e* s* e
/ \ |
i r n*
| |
r* e*
words: the, their, there, was, when (* = end of word)
Searching for \(q\) walks down from the root following \(q\)'s characters. If an edge is missing, \(q\) isn't in the set — and neither is any word with \(q\) as a prefix. Otherwise, the search ends after exactly \(|q|\) steps, no matter how many words are stored.
Implementation (LC 208)¶
class TrieNode:
__slots__ = ("children", "word")
def __init__(self) -> None:
self.children: dict[str, TrieNode] = {}
self.word: str | None = None # the word ending here, if any
class Trie:
def __init__(self) -> None:
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.word = word
def _walk(self, s: str) -> TrieNode | None:
node = self.root
for ch in s:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word: str) -> bool:
node = self._walk(word)
return node is not None and node.word is not None
def starts_with(self, prefix: str) -> bool:
return self._walk(prefix) is not None
Each operation is \(O(k)\) for a string of length \(k\). Space is \(O(\text{total characters inserted})\) in the worst case (no shared prefixes), and much less when prefixes overlap. A dict of children works for any alphabet; a fixed array of 26 slots is faster in compiled languages but wastes space on sparse nodes.
Marking word ends with the word itself rather than a boolean costs one reference per word, and a search that reaches the node gets the word back without rebuilding it — Word Search II below relies on this.
Searching with Wildcards (LC 211)¶
When the query contains . (matches any single character), the walk branches: at a ., try every child. The trie still prunes aggressively — a branch dies the moment its prefix isn't present.
class WordDictionary(Trie): # words are added with Trie.insert
def search(self, word: str) -> bool:
def match(node: TrieNode, i: int) -> bool:
if i == len(word):
return node.word is not None
ch = word[i]
if ch == ".":
return any(match(child, i + 1) for child in node.children.values())
child = node.children.get(ch)
return child is not None and match(child, i + 1)
return match(self.root, 0)
Word Search II (LC 212): Walking a Trie and a Grid Together¶
Find all dictionary words that can be traced on a letter grid by moving between adjacent cells. The naive approach runs a separate board search for each word — hopeless for thousands of words.
The key insight is the incremental-query advantage from above. A DFS on the board extends its current path by one letter at a time, so walk the trie in lockstep with it. Each board step is one trie step, and the moment the path's prefix is absent from the trie, that entire branch of the board search is abandoned. One DFS from each cell finds all words at once.
def grid_neighbors(r: int, c: int, rows: int, cols: int):
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < rows and 0 <= nc < cols:
yield nr, nc
def find_words(board: list[list[str]], words: list[str]) -> list[str]:
trie = Trie()
for word in words:
trie.insert(word)
rows, cols = len(board), len(board[0])
found: list[str] = []
def dfs(r: int, c: int, parent: TrieNode) -> None:
letter = board[r][c]
node = parent.children[letter]
if node.word is not None:
found.append(node.word)
node.word = None # report each word only once
board[r][c] = "#" # mark as used on this path
for nr, nc in grid_neighbors(r, c, rows, cols):
if board[nr][nc] in node.children:
dfs(nr, nc, node)
board[r][c] = letter # backtrack
if not node.children:
del parent.children[letter] # prune: nothing left below this prefix
for r in range(rows):
for c in range(cols):
if board[r][c] in trie.root.children:
dfs(r, c, trie.root)
return found
Two refinements make a big difference in practice: removing each word from the trie once it's found (so it isn't reported twice), and pruning trie nodes that have become empty, so later searches don't walk into exhausted branches.
Take-Home Lesson
A trie lets a search that grows one character at a time take one step per character, and it lets you abandon a search as soon as the current prefix leads nowhere. Whenever a backtracking search builds strings and checks them against a dictionary, drive the search with a trie.
More Trie Applications¶
- Autocomplete / Search Suggestions (LC 1268): walk to the prefix's node, then DFS (in alphabetical child order) to collect completions. For heavy traffic, cache the top suggestions at each node.
- Replace Words (LC 648): for each word, walk the trie and stop at the first node marked as a root word.
- Longest Word in Dictionary (LC 720): a word counts only if every prefix is also a word — a DFS that only descends through word-ending nodes.
- Binary tries for XOR (LC 421): store numbers as fixed-width bit strings, most significant bit first. To maximize
x ^ y, walk the trie from the top, at each level preferring the child with the opposite bit ofx— a greedy choice that's correct because a higher bit outweighs all lower bits combined.
def find_maximum_xor(nums: list[int]) -> int:
bits = max(nums).bit_length()
root: dict[int, dict] = {} # binary trie: bit -> child
for x in nums: # insert every number, high bit first
node = root
for i in reversed(range(bits)):
node = node.setdefault((x >> i) & 1, {})
best = 0
for x in nums: # greedily take the opposite bit when possible
node, xor = root, 0
for i in reversed(range(bits)):
bit = (x >> i) & 1
opposite = bit ^ 1
if opposite in node:
xor |= 1 << i
node = node[opposite]
else:
node = node[bit]
best = max(best, xor)
return best
Suffix Tries: Every Substring Is a Prefix¶
A suffix trie stores all suffixes of a string \(S\). Its magic is a simple observation: every substring of \(S\) is a prefix of some suffix. So "is \(q\) a substring of \(S\)?" becomes a walk of \(|q|\) steps from the root.
The catch: \(S\) has \(n\) suffixes of average length \(n/2\), so the naive suffix trie has \(\Theta(n^2)\) nodes. Suffix trees compress each unbranching path into a single edge labeled by a pair of indices into \(S\), which brings the size down to \(O(n)\), and clever algorithms build them in linear time. Suffix arrays — the sorted list of suffix start positions — offer most of the same power in a simpler, more memory-friendly package. You'll rarely implement either in an interview, but knowing they exist is valuable: they turn many quadratic string problems (longest repeated substring, longest common substring of two strings) into linear or near-linear ones.
Part 2: Union-Find¶
Dynamic Connectivity¶
Many problems maintain a partition of elements into disjoint groups — connected components of a growing graph, accounts belonging to the same person, variables forced equal — and repeatedly ask two things:
- Find: which group is \(x\) in? (Equivalently: are \(x\) and \(y\) in the same group?)
- Union: merge the groups containing \(x\) and \(y\).
The two obvious representations each make one operation fast and the other slow:
| Representation | Same group? | Merge two groups |
|---|---|---|
Label array: group[x] |
\(O(1)\) | \(O(n)\): relabel one group's elements |
| Graph of the merges, traversed on demand | \(O(n)\): run BFS/DFS | \(O(1)\): add an edge |
| Union-find | \(O(\alpha(n))\) amortized | \(O(\alpha(n))\) amortized |
Backward Trees¶
Union-find represents each group as a tree in which every node points to its parent, and the root names the group. There are no child pointers at all — just one parent array.
- Find(\(x\)): follow parent pointers from \(x\) until reaching a root (a node that is its own parent).
- Union(\(x\), \(y\)): find both roots; if they differ, make one root point to the other.
Both cost \(O(\text{height})\), so everything depends on keeping the trees short. Two simple rules do that.
Union by size. Always hang the smaller tree under the root of the larger one. Every node in the smaller tree gets one step deeper; nobody in the larger tree does. Why does this keep trees short? A node gets deeper only when its tree is merged into one at least as large — so its tree's size at least doubles every time the node's depth increases. A size can double at most \(\log_2 n\) times, so every tree has height \(O(\log n)\).
Path compression. During a find, after locating the root, repoint every node on the path directly at it. Future finds on those nodes take one step.
With both rules, a sequence of \(m\) operations costs \(O(m \cdot \alpha(n))\), where \(\alpha\) is the inverse Ackermann function — which grows so slowly that \(\alpha(n) \le 4\) for any \(n\) that could fit in this universe. For all practical purposes, union-find operations are constant time.
class UnionFind:
def __init__(self, n: int) -> None:
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x: int) -> int:
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # path compression
next_x = self.parent[x]
self.parent[x] = root
x = next_x
return root
def union(self, x: int, y: int) -> bool:
"""Merge the groups of x and y. Return False if they were already together."""
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False
if self.size[rx] < self.size[ry]: # union by size
rx, ry = ry, rx
self.parent[ry] = rx
self.size[rx] += self.size[ry]
self.components -= 1
return True
def connected(self, x: int, y: int) -> bool:
return self.find(x) == self.find(y)
union returning whether a merge happened is a small design choice that pays off constantly: "this edge connected two already-connected vertices" is exactly what cycle detection and Kruskal's algorithm need to know.
Applications¶
Components and cycles¶
- Number of Connected Components (LC 323) and Number of Provinces (LC 547): union every edge; the answer is
components. - Redundant Connection (LC 684): a tree plus one extra edge. Process edges in order; the first edge whose
unionreturnsFalsecloses a cycle. - Graph Valid Tree (LC 261): exactly \(n - 1\) edges, and no
unionfails.
def find_redundant_connection(edges: list[list[int]]) -> list[int]:
uf = UnionFind(len(edges) + 1)
for u, v in edges:
if not uf.union(u, v):
return [u, v]
return []
Grouping by shared keys: Accounts Merge (LC 721)¶
Accounts sharing any email belong to the same person. Rather than comparing accounts pairwise, union each account with the first account that claimed each email. Then gather emails by root.
from collections import defaultdict
def accounts_merge(accounts: list[list[str]]) -> list[list[str]]:
uf = UnionFind(len(accounts))
owner: dict[str, int] = {} # email -> first account that listed it
for i, account in enumerate(accounts):
for email in account[1:]: # account[0] is the name
if email in owner:
uf.union(i, owner[email])
else:
owner[email] = i
groups: dict[int, list[str]] = defaultdict(list)
for email, i in owner.items():
groups[uf.find(i)].append(email)
return [[accounts[root][0]] + sorted(emails) for root, emails in groups.items()]
Equivalence constraints: Satisfiability of Equality Equations (LC 990)¶
Given constraints like a==b and b!=c, decide whether they can all hold. Equality is an equivalence relation, so union all == pairs first; then every != pair must lie in different groups.
def letter_index(ch: str) -> int:
return ord(ch) - ord("a")
def equations_possible(equations: list[str]) -> bool:
uf = UnionFind(26)
for x, op, _, y in equations: # "a==b" unpacks to "a", "=", "=", "b"
if op == "=":
uf.union(letter_index(x), letter_index(y))
for x, op, _, y in equations:
if op == "!" and uf.connected(letter_index(x), letter_index(y)):
return False
return True
Kruskal's minimum spanning tree¶
Sort edges by weight and add each one whose endpoints are in different components — the union-find test is exactly "would this edge close a cycle?" See Graphs.
Online connectivity: Number of Islands II (LC 305)¶
Land cells appear one at a time on a grid; after each addition, report the number of islands. Re-running a flood fill after each addition costs \(O(mn)\) per step. With union-find, each new cell starts as its own island and unions with its land neighbors: \(O(\alpha(mn))\) per step.
def num_islands2(m: int, n: int, positions: list[list[int]]) -> list[int]:
def cell_id(r: int, c: int) -> int:
return r * n + c
uf = UnionFind(m * n)
land = [[False] * n for _ in range(m)]
islands = 0
result: list[int] = []
for r, c in positions:
if not land[r][c]: # the same cell may be added twice
land[r][c] = True
islands += 1
for nr, nc in grid_neighbors(r, c, m, n):
if land[nr][nc] and uf.union(cell_id(r, c), cell_id(nr, nc)):
islands -= 1 # two islands just merged into one
result.append(islands)
return result
uf.components would count water cells too, so the island count is tracked separately: \(+1\) for each new cell, and \(-1\) for each union that actually merges two islands.
This is where union-find truly beats BFS/DFS: when edges arrive over time and connectivity questions are interleaved with them.
Union-Find vs. Traversal¶
| Situation | Better tool |
|---|---|
| Static graph, count or label components once | Either; DFS/BFS is just as fast and needs no extra structure |
| Edges arrive incrementally; queries interleaved | Union-find |
| Need actual paths, distances, or traversal order | BFS / DFS |
| Need to delete edges or split groups | Neither directly — union-find can't split. If all operations are known in advance, process them in reverse, turning deletions into unions |
| Directed reachability | DFS / BFS (union-find is for symmetric relations) |
Common Mistakes¶
Tries¶
-
Confusing "prefix exists" with "word exists."
starts_with("app")can be true whilesearch("app")is false. The end-of-word flag is what distinguishes them. -
Rebuilding strings during DFS. Concatenating
path + chat each step costs \(O(k)\) per step. Store the full word at its terminal node (asTrieNode.worddoes), or keep a list and join only when reporting. -
Reporting duplicates in Word Search II. A word reachable by two board paths is found twice unless you remove it from the trie (or use a set).
-
Forgetting memory cost. A trie with a node object per character can use far more memory than a hash set of the same words. Use it when you need prefix operations.
Union-Find¶
-
Comparing elements instead of roots.
parent[x] == parent[y]is not the same-group test;find(x) == find(y)is. -
Skipping union by size (or rank) and path compression. Without them, a sequence of unions can build a linked list, and finds degrade to \(O(n)\).
-
Unioning non-roots.
parent[x] = ylinks \(x\)'s node, not \(x\)'s whole tree. Always linkfind(x)tofind(y). -
Passing raw labels as elements.
UnionFindworks on integers0..n-1. Map letters, strings or grid cells to indices first (letter_index,cell_id, or adict), or key the parent map itself by the element. -
Expecting deletions to work. Union-find merges only. For splits, reverse time or use a different structure.
Practice Problems¶
| Problem | Structure / idea |
|---|---|
| Implement Trie (LC 208) | Basic trie |
| Design Add and Search Words (LC 211) | Trie + DFS on wildcards |
| Word Search II (LC 212) | Trie driving a grid DFS, with pruning |
| Replace Words (LC 648) | Shortest root prefix |
| Search Suggestions System (LC 1268) | Trie + DFS, or sort + binary search |
| Maximum XOR of Two Numbers (LC 421) | Binary trie, greedy by bit |
| Number of Connected Components (LC 323) | Union-find |
| Number of Provinces (LC 547) | Union-find on an adjacency matrix |
| Redundant Connection (LC 684) | First failed union |
| Graph Valid Tree (LC 261) | \(n - 1\) edges and no cycle |
| Accounts Merge (LC 721) | Union by shared email |
| Satisfiability of Equality Equations (LC 990) | Union ==, check != |
| Longest Consecutive Sequence (LC 128) | Union neighbors (or a hash set) |
| Number of Islands II (LC 305) | Online union-find on a grid |
| Min Cost to Connect All Points (LC 1584) | Kruskal with union-find |