Chapter 12: Advanced Data Structures
Master advanced data structures that unlock efficient solutions to complex problems: Union Find for dynamic connectivity, Tries for prefix-based string operations, LRU Cache for O(1) eviction, and Segment Trees for range queries. These structures appear frequently in system design and competitive programming.
- Topics
- 6
- Demos
- 6
- Min read
- 31
Chapter Overview
Advanced data structures go beyond arrays, hash maps, and trees to solve specialized problems with optimal efficiency. Each structure in this chapter addresses a specific class of problems that would be significantly harder or slower to solve with basic data structures.
Union Find (Disjoint Set Union) maintains a partition of elements into non-overlapping sets, supporting near-constant-time merge and membership queries. It is the backbone of Kruskal's algorithm, network connectivity, and image segmentation.
Trie (Prefix Tree) stores strings character by character in a tree, enabling prefix searches, autocomplete, and dictionary lookups in time proportional to the word length rather than the dictionary size.
LRU Cache combines a hash map with a doubly linked list (or Python's OrderedDict) to provide O(1) get and put operations with automatic eviction of the least recently used entry when capacity is exceeded.
Segment Tree supports both range queries (sum, min, max over a subarray) and point updates in O(log n) time, making it indispensable for problems where the underlying array changes frequently.
This chapter covers:
- Union Find: Path compression, union by rank, connected components
- Trie: Insert, search, prefix matching, and autocomplete via DFS
- LRU Cache: OrderedDict-based O(1) implementation
- Segment Tree: Build, query, and update operations
Chapter Roadmap
Click any topic to jump in
Union Find (DSU)
Path compression + union by rank gives amortized $O(\alpha(n))$ per operation.
Connected Components
Static counting via DFS/DSU; dynamic connectivity via link-cut trees.
Each tackles a different aspect
Trie
$O(L)$ insert and search for prefix queries, independent of dictionary size.
Autocomplete with Tries
DFS from prefix nodes and precomputed top-k lists for fast ranked suggestions.
Each tackles a different aspect
LRU Cache
$O(1)$ get/put via hashmap + doubly linked list (or OrderedDict).
Segment Tree
$O(\log n)$ range queries and point updates on any associative aggregate.
Topics
6 topics in this chapter
A social network records friendships as they happen and must answer, at any moment, whether two users are linked through some chain of friends. Running BFS for every question costs time proportional to the whole graph, and with millions of questions that is far too slow. What we need is a structure that merges groups cheaply and names the group of any element almost instantly.
The previous chapter ended with Partition Equal Subset Sum, where the work was a table of reachable totals. This chapter turns from DP to data structures built for one kind of query, and Union Find comes first. It stores each group as a tree in a parent array: every element points to a parent, and the root names the group. Two elements are in the same group exactly when their roots match, and merging two groups needs only one pointer change, from one root to the other. The danger is height. Careless merging can build a long chain, and then each root lookup walks the whole chain.
We first make lookups fast with path compression, which flattens paths as they are walked. Then we keep trees shallow with union by rank.
Definition
A Union Find (disjoint-set union) structure maintains a partition of elements into disjoint sets. find(x) returns the representative root of x's set, and union(x, y) merges the two sets. With path compression and union by rank, a sequence of operations costs , where is the inverse Ackermann function, at most 4 for any practical .
In this topic
- 1Find with Path Compression
- 2Union by Rank
Find with Path Compression
find(x) follows parent pointers from x until it reaches a root, a node with parent[r] = r, and returns that root. Its cost is the number of hops, so a tall tree makes every lookup slow. Path compression repairs this during the walk: after the root is known, every node visited on the way is re-pointed directly at the root. The work is paid once, and later finds on any of those nodes take a single hop. Ranks are left unchanged, so they become upper bounds on height. Compression alone gives amortized per operation; combined with union by rank it gives .
Path compression makes every node on the find path point directly to the root. Combined with union by rank, it gives amortized complexity per operation, where is the inverse Ackermann function. For all practical (even ), , so operations are effectively constant time.
Given parent = [0, 0, 1, 1, 3], what does the tree look like after calling find(4) with path compression?
Union by Rank
union(x, y) finds both roots and, if they differ, makes one root the parent of the other. Which one matters. Linking a tall tree under a short root makes every node in it one level deeper. Union by rank keeps a rank per root, an upper bound on its height, and always hangs the lower-rank root under the higher-rank one, so no node gets deeper. Only when ranks are equal does the new root's rank grow by one. A root of rank then has at least nodes in its tree, so height is at most even without compression. Union by size, which compares node counts, gives the same bound.
Union by rank attaches the shorter tree under the taller tree's root, which keeps the tree depth at without path compression. With both heuristics, Tarjan proved the amortized bound per operation; omitting either one degrades to amortized, and omitting both gives worst case. Rank is an upper bound on height, not exact height, since path compression only shrinks trees.
Two sets have roots A (rank 2) and B (rank 1). How does union by rank merge them?
Related Problems on PixelBank
A power company's network map lists which substations are wired together, and after a storm the engineers need to know how many separate islands the grid has split into and which substations share an island. The same question appears when counting separate pixel regions in an image, or checking whether a set of network links forms one tree.
The previous topic, Union Find, built the machinery: find names a set, and union merges two sets in nearly constant time. Connected components is its main application. Start with every vertex in its own set, one set per vertex, and call union for every edge. Each union that joins two different roots merges two components into one, so the count drops by exactly one. A union whose endpoints already share a root changes nothing, which means the edge closes a cycle. BFS or DFS gives the same count in when the graph is fixed, but Union Find also handles edges that keep arriving.
We first count components of a fixed graph. Then we answer connectivity queries while edges stream in, and see where Union Find stops: it cannot delete edges.
Definition
A connected component of an undirected graph is a maximal set of vertices in which every pair is joined by a path. Starting from singleton sets and calling union on each edge leaves exactly one set per component. The count is minus the number of unions that merged distinct roots, computed in time for edges.
In this topic
- 1Static Component Counting
- 2Dynamic Connectivity
Static Component Counting
When the whole edge list is known, set count = and call union(u, v) for each edge. A union succeeds when find(u) and find(v) differ; then the two components merge and count drops by one. When the roots already match, the edge is redundant: u and v were already connected, so the edge closes a cycle. After all edges, count is the number of components, which also equals the number of with find(x) = x. Each edge costs , so the total is practically linear. Recording the failed unions turns the same loop into a cycle detector, the test behind Graph Valid Tree.
For a static graph with vertices and edges, components can be counted in via DFS/BFS or via Union-Find. Start with components; each edge that unites two distinct roots decrements the count. Final count = .
Graph with 7 nodes and edges: (0,1), (1,2), (3,4), (5,6). How many connected components?
Dynamic Connectivity
In the online setting, edges arrive one at a time and connectivity queries are interleaved with them: are u and v connected right now? Rerunning BFS after each edge costs per query. Union Find answers each query with two finds, comparing roots in , and each new edge is one union, with nothing rebuilt. The limitation is deletion. A union cannot be undone, because the structure does not record which edge caused a merge. If deletions are known in advance, process the timeline backwards so they become insertions. Fully dynamic connectivity needs heavier structures, such as Euler-tour trees, at polylogarithmic cost per operation.
Union-Find supports online insertions and connectivity queries in amortized but does not support edge deletions directly. Fully dynamic connectivity (insertions and deletions) requires link-cut trees or Euler-tour trees with per operation. This distinction matters for online problems — DSU is incremental-only.
Edges arrive in order: (0,1), query(0,2), (1,2), query(0,2), (3,4), query(1,4). What are the query results?
Related Problems on PixelBank
A search box must check, as each key is pressed, whether any dictionary word starts with what has been typed. A hash set answers "is this exact word present?" in constant time, but it cannot answer "does any word begin with ca?" without scanning every stored word. With a million words and a lookup per keystroke, that scan is too slow.
The previous topic, Connected Components, grouped vertices into sets and answered same-set queries. A trie groups strings instead, by their shared beginnings. It is a tree in which each edge carries one character, so the path from the root to any node spells a prefix, and every word sharing that prefix lives below that node. Inserting "app" and "apple" stores the letters a, p, p once and branches only after them. Lookups walk one edge per character, so their cost depends on the query length, not on how many words are stored. The price is memory: every node needs a table of children.
We first design the node, a child map plus an end-of-word flag, and count what it stores. Then we write insert, search, and startsWith, and see why the flag separates words from prefixes.
Definition
A trie (prefix tree) stores a set of strings as a rooted tree whose edges are labelled with characters. The path from the root to a node spells a prefix, and a flag marks nodes that end a stored word. Insert, exact search, and prefix search each take time for a query of length , independent of the number of stored words.
In this topic
- 1Trie Node Structure
- 2Insert and Search Operations
Trie Node Structure
Each node holds two things: a map from a character to the child node reached by that character, and a boolean is_end that is true when the path to this node spells a complete stored word. The root is empty and stands for the empty prefix. The child map can be a fixed array of 26 slots for lowercase letters, which is fast to index but wastes memory when most slots are empty, or a dictionary, which stores only the children that exist. Shared prefixes are stored once, so a trie of words of total length has at most nodes, and far fewer when many words share beginnings.
A trie storing strings over alphabet uses space with array-based nodes or with hash maps, where is max string length. Insert and search are — independent of . This is asymptotically better than hash tables for prefix queries, which would need per query.
Draw the Trie after inserting 'cat', 'car', and 'card'. How many nodes total?
Insert and Search Operations
All three operations walk from the root one character at a time. insert creates any missing child along the way and sets is_end on the last node. search follows the same path, returns False as soon as a character has no child, and at the end returns the node's is_end flag. startsWith is identical except that it returns True whenever the whole path exists, since a prefix need not be a stored word. Each step is one dictionary lookup, so all three run in for a string of length . A common bug is letting search return True whenever the path exists, which reports every prefix as a word.
Both insert and search walk one character at a time from the root, each step a hashmap lookup or array indexing. Total time is where is the query length. There is no hashing collision concern and no resizing; the cost is spatial: empty children slots waste memory in array-based representations.
After inserting 'apple' and 'app', what do search('app'), search('ap'), and startsWith('ap') return?
Related Problems on PixelBank
Typing "car" into a search box should immediately offer car, card, care, and cars, and ideally put the most popular ones first. The list must appear between keystrokes, a few milliseconds, even though the dictionary may hold millions of entries. Scanning every word and testing whether it starts with the typed text wastes nearly all its effort on words that cannot match.
The previous topic, Trie, built the structure and its basic operations: walking one character at a time to test whether a word or prefix exists. Autocomplete uses the property that makes tries worth their memory. Every word starting with a prefix lives in the subtree under that prefix's node, and no other word does. So the work splits in two: walk down to the prefix node in time proportional to the prefix length, then explore only that subtree. Words that do not match are never touched. Real systems also rank results, which adds a second question: how to return the best k matches without collecting and sorting all of them.
We first collect every completion with a depth-first search from the prefix node. Then we rank them, using a size-k heap or top-k lists cached at each node.
Definition
Trie autocomplete returns the stored words that begin with a query prefix . It walks from the root to the node for in time, then runs a depth-first search of that node's subtree, emitting each word-end node. Ranked top- completion keeps a heap of size during the search, or precomputes a top- list at every node for lookup.
In this topic
- 1DFS from Prefix Node
- 2Top-K Suggestions
DFS from Prefix Node
Autocomplete has two phases. First walk from the root along the prefix's characters; if any character is missing, no stored word has this prefix, so return an empty list at once. Otherwise the walk ends at the prefix node, and every matching word lies in its subtree. Second, run a depth-first search from that node, carrying the string built so far: append a character when descending, drop it when returning, and emit the string at every node flagged is_end. Visiting children in sorted order yields alphabetical completions. The cost is the prefix walk plus the subtree size, independent of how many unrelated words the dictionary holds.
To autocomplete a prefix , walk to the trie node representing in , then DFS into its subtree to collect all words. If the subtree has words of average length , the collection step is . This is optimal for returning all completions; top-k ranking requires augmenting nodes with frequencies or a priority queue.
Trie contains: 'car', 'card', 'care', 'careful', 'cars'. DFS from prefix 'car' yields what?
Top-K Suggestions
Users want a few good suggestions, not every match. Store a frequency at each word-end node. The simple method collects all matches and sorts them by frequency, costing for matches. Better, keep a min-heap of size during the DFS: push each match, and when the heap exceeds , pop the least frequent, for total. The heap is not in ranked order, so sort its items at the end. For interactive speed, precompute each node's top- list; lookup is then , but an insertion must update the list at every ancestor. Break frequency ties by word so results are deterministic.
Augment each trie node with the top-k most popular words in its subtree (precomputed). Lookup becomes — walk to the prefix node and read off the cached list. Updates cost because each ancestor's cached list may change. This is the standard trade-off between query and update cost in static-vs-dynamic data structures.
Words with frequencies: ('car', 5), ('card', 3), ('care', 8), ('careful', 1), ('cars', 10). Top 3 for prefix 'car'?
Related Problems on PixelBank
A web server keeps recently rendered pages in memory, and when the cache is full one page must go. Evicting at random discards hot pages, and evicting the oldest insertion throws out a page requested every second just because it was loaded first. A good policy removes the page that has gone unused the longest, and both lookup and eviction must be constant time, because they run on every request.
The previous topic, Autocomplete with Tries, combined a trie with a heap to answer ranked queries. The LRU cache is another composite, where two structures cover each other's weaknesses. A hash map finds any key in but has no notion of order. A doubly linked list keeps entries in recency order and can unlink any node in , but finding a node means walking the list. Store list nodes as the hash map's values, and each structure supplies what the other lacks: the map locates a node, and the list moves it to the front or removes the oldest.
We first build the cache with Python's OrderedDict, which already combines the two. Then we build the hash map and doubly linked list by hand.
Definition
A least-recently-used (LRU) cache stores at most key-value pairs. get(key) returns the value and marks the key most recently used; put(key, value) inserts or updates it and, if the size exceeds , evicts the key unused for the longest time. A hash map from keys to nodes of a doubly linked list ordered by recency makes both operations .
In this topic
- 1OrderedDict Approach
- 2Hash Map + Doubly Linked List
OrderedDict Approach
Python's collections.OrderedDict is a hash map that also remembers the order of its keys, backed internally by a doubly linked list. Two methods are all an LRU cache needs, both : move_to_end(key) marks a key as most recently used, and popitem(last=False) removes the key at the front, the least recently used. On get, return -1 for a missing key; otherwise move it to the end and return its value. On put, store the value, move the key to the end, and if the size exceeds capacity, pop from the front. The frequent bug is forgetting move_to_end on get, which silently turns LRU into first-in, first-out eviction.
Python's OrderedDict maintains insertion order and supports move_to_end and popitem. For LRU: on access or insert, move_to_end(key); when full, popitem(last=False) evicts the oldest. Both get and put are amortized. This is the simplest correct implementation in languages with ordered hash maps.
Cache capacity = 2. Operations: put(1,'a'), put(2,'b'), get(1), put(3,'c'). What is evicted?
Hash Map + Doubly Linked List
Without an ordered map, build both parts yourself. Each list node stores a key, a value, and prev and next pointers; the hash map sends each key to its node. Sentinel head and tail nodes bracket the list, so no operation has to handle an empty list. The node after head is least recently used; the node before tail is most recent. On get, look up the node, unlink it, and re-insert it before tail. On put, update or create the node and move it before tail; if over capacity, unlink the node after head and delete its key. Every step is a constant number of pointer changes.
A hash map maps keys to DLL nodes; the DLL maintains recency order with most-recent at head. get: hash lookup () + detach-and-move-to-head (). put: same plus evict tail if over capacity (). Total worst-case per operation. This is the canonical implementation in languages without ordered maps and is what every LRU implementation reduces to.
Why is a singly linked list insufficient for O(1) LRU operations?
Related Problems on PixelBank
A dashboard shows the total sales over any date range an analyst selects, while new orders keep changing individual days. Summing the range on every request costs . A prefix-sum array answers any range in , but one changed day forces every later prefix to be rewritten, which is again. When queries and updates are both frequent, neither approach works well.
The previous topic, LRU Cache, combined two structures so that two operations were both constant time. The segment tree makes a similar trade for range queries: it accepts for both queries and updates instead of for one and for the other. It is a binary tree over the array. The root covers the whole range, each node's children split its range in half, and each leaf holds one element. Every node stores the aggregate of its range, such as the sum, minimum, or maximum. Any query range can be assembled from stored nodes, and changing one element only affects the nodes on the path from its leaf to the root.
We first build the tree bottom-up and see how it fits in a flat array. Then we trace a range query and a point update.
Definition
A segment tree over an array is a binary tree in which each node stores an associative aggregate, such as a sum or minimum, of a contiguous range. The root covers and each child covers half of its parent's range. Building takes ; a range query or point update takes . A flat array of size holds the tree.
In this topic
- 1Tree Structure and Building
- 2Range Query and Point Update
Tree Structure and Building
Store the tree in a flat array with no pointers: the root is index 1, and node has children and . Node covers a range ; its left child covers and its right child , where . To build, recurse to the leaves, copy each array value into its leaf, and on the way back set tree[i] = tree[2i] + tree[2i+1], or min or max for other aggregates. There are nodes and each is computed once, so building is . When is not a power of two, indices can exceed , so allocate entries.
A segment tree on elements is a balanced binary tree with nodes (ceil to power of 2 plus leaves). Each internal node stores an aggregate over the range of its children. Building bottom-up takes time. The bound allows flat array storage indexed by node number, avoiding pointer overhead.
Build a sum segment tree for array [1, 3, 5, 7, 9, 11]. What is stored at the root?
Range Query and Point Update
A range query for starts at the root and compares each node's range with the query. If the node's range lies entirely inside, return its stored value. If it lies entirely outside, return the identity, 0 for sums or infinity for minima. Otherwise recurse into both children and combine. At most four nodes per level are visited, so a query is . A point update at index descends to the leaf for , replaces its value, and recomputes each ancestor from its children on the way back, one node per level. Updating a whole range this way costs ; lazy propagation reduces it to .
Both range query and point update are because the recursion visits at most 2 nodes per level (one leftmost and one rightmost touched segment). For range updates, lazy propagation keeps amortized by deferring updates to children until needed. The tree gives the optimal query/update balance for associative aggregates.
Array [2, 1, 5, 3, 4]. After update(index=2, value=10), what is query(1, 3)?