Bonus Topics
Trie | Union-Find (DSU) | Monotonic Stack | Sliding Window | Two Pointers
Topic A ā Trie (Prefix Tree)
A.1 ā What Is a Trie?
A Trie (pronounced 'try', from retrieval) is a tree-shaped data structure for storing strings where each node represents a single character. Strings that share a common prefix share the same path from the root. This gives O(L) insert, search, and prefix-search operations where L is the string length ā independent of the number of stored strings.
Trie Structure: Character-by-Character Branching
Insert: ['apple', 'app', 'apt', 'bat', 'bad']
root
/ \
a b
| |
p a
/ \ / \
p t t d
| | * * (* = isEnd marker)
l *
|
e
|
*
'app' -> root->a->p->p* (isEnd=true at second p)
'apple' -> root->a->p->p->l->e* (extends 'app' branch)
'apt' -> root->a->p->t* (shares 'ap' with above)
Search 'app': traverse a->p->p, check isEnd -> true.
StartsWith 'ap': traverse a->p, node exists -> true.
Search 'ap': traverse a->p, isEnd=false -> false.
A.2 ā Trie Implementation
// Trie ā O(L) per operation
// Insert/Search/StartsWith: O(L) time, O(L) space per word
struct TrieNode {
TrieNode* children[26] = {}; // null = child doesn't exist
bool isEnd = false;
};
class Trie {
TrieNode* root;
public:
Trie() { root = new TrieNode(); }
void insert(const string& word) {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx])
cur->children[idx] = new TrieNode();
cur = cur->children[idx];
}
cur->isEnd = true;
}
bool search(const string& word) {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx]) return false;
cur = cur->children[idx];
}
return cur->isEnd; // must be a complete word
}
bool startsWith(const string& prefix) {
TrieNode* cur = root;
for (char c : prefix) {
int idx = c - 'a';
if (!cur->children[idx]) return false;
cur = cur->children[idx];
}
return true; // prefix exists, word completeness irrelevant
}
};A.3 ā Word Search II (Trie + Backtracking)
// LeetCode 212 ā Word Search II
// Find all words from a dictionary that exist in a 2D grid.
// Time: O(M*N * 4^L) L=longest word Space: O(total chars in dict)
struct TrieNode {
TrieNode* ch[26] = {};
string word; // non-empty = this node is end of a word
};
class Solution {
void dfs(vector<vector<char>>& g, TrieNode* node,
int r, int c, vector<string>& res) {
if (r<0||r>=(int)g.size()||c<0||c>=(int)g[0].size()) return;
char ch = g[r][c];
if (ch=='#' || !node->ch[ch-'a']) return; // visited or no prefix
node = node->ch[ch-'a'];
if (!node->word.empty()) { // found a complete word
res.push_back(node->word);
node->word = ""; // de-duplicate
}
g[r][c] = '#'; // mark visited
dfs(g,node,r+1,c,res); dfs(g,node,r-1,c,res);
dfs(g,node,r,c+1,res); dfs(g,node,r,c-1,res);
g[r][c] = ch; // restore
}
public:
vector<string> findWords(vector<vector<char>>& board,
vector<string>& words) {
TrieNode* root = new TrieNode();
for (auto& w : words) { // build trie
TrieNode* cur = root;
for (char c : w) {
if (!cur->ch[c-'a']) cur->ch[c-'a'] = new TrieNode();
cur = cur->ch[c-'a'];
}
cur->word = w;
}
vector<string> res;
for (int r=0;r<(int)board.size();r++)
for (int c=0;c<(int)board[0].size();c++)
dfs(board, root, r, c, res);
return res;
}
};A.4 ā When to Use a Trie
| Operation | Trie | Hash Set | Sorted Array (binary search) |
|---|---|---|---|
| Insert word | O(L) | O(L) | O(L + log n) |
| Exact search | O(L) | O(L) | O(L log n) |
| Prefix search | O(L) | O(n*L) scan | O(L log n) |
| All words with prefix | O(L + output) | O(n*L) scan | O(L log n + output) |
| Space | O(total chars * 26) | O(total chars) | O(total chars) |
Trie Complexity
- Insert / Search / StartsWith (length L): O(L) time
- Build Trie from n words avg length L: O(n*L) time, O(n*L*26) space worst case
- Autocomplete (all words with prefix p): O(|p| + k) time where k = output size
- Trie vs Hash Set: Hash set gives O(L) exact lookup. Trie gives O(L) exact lookup PLUS O(L) prefix queries. Use Trie when prefix operations are needed.
Topic B ā Union-Find (Disjoint Set Union)
B.1 ā What Is Union-Find?
Union-Find (also called Disjoint Set Union, DSU) is a data structure that tracks a partition of elements into disjoint sets. It supports two operations in near-constant amortised time: union(a, b) ā merge the sets containing a and b ā and find(a) ā identify which set a belongs to (by its representative).
Union-Find: Union by Rank + Path Compression
Elements: {0, 1, 2, 3, 4, 5} Initially each element is its own set.
parent = [0, 1, 2, 3, 4, 5] rank = [0, 0, 0, 0, 0, 0]
union(0, 1): find(0)=0, find(1)=1. rank equal -> parent[1]=0, rank[0]++.
union(2, 3): find(2)=2, find(3)=3. parent[3]=2, rank[2]++.
union(0, 2): find(0)=0, find(2)=2. parent[2]=0, rank[0]++.
parent = [0, 0, 0, 2, 4, 5]
find(3) with PATH COMPRESSION:
find(3) -> parent[3]=2 -> parent[2]=0 -> root=0.
Compress: parent[3] = 0, parent[2] = 0.
parent = [0, 0, 0, 0, 4, 5] (3 now directly points to root 0)
Sets: Set A: {0, 1, 2, 3} Set B: {4} Set C: {5}
B.2 ā Union-Find Implementation
// Union-Find with Union by Rank + Path Compression
// find: amortised O(alpha(n)) ~ O(1) unite: amortised O(alpha(n))
class UnionFind {
vector<int> parent, rank_;
int components;
public:
UnionFind(int n) : parent(n), rank_(n,0), components(n) {
iota(parent.begin(), parent.end(), 0); // parent[i] = i
}
// Path compression: make every node on the find-path point to root
int find(int x) {
if (parent[x] != x)
parent[x] = find(parent[x]); // recursive compression
return parent[x];
}
// Union by rank: attach smaller tree under larger tree
bool unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false; // already in same set
if (rank_[ra] < rank_[rb]) swap(ra, rb);
parent[rb] = ra; // attach rb under ra
if (rank_[ra] == rank_[rb]) rank_[ra]++;
components--;
return true;
}
bool connected(int a, int b) { return find(a) == find(b); }
int count() { return components; }
};B.3 ā Number of Islands Using Union-Find
// LeetCode 200 ā Number of Islands (Union-Find approach)
// Time: O(M*N * alpha(M*N)) Space: O(M*N)
int numIslands(vector<vector<char>>& grid) {
int m = grid.size(), n = grid[0].size();
UnionFind uf(m * n);
int water = 0;
int dr[] = {0, 1, 0, -1};
int dc[] = {1, 0, -1, 0};
for (int r = 0; r < m; r++) {
for (int c = 0; c < n; c++) {
if (grid[r][c] == '0') { water++; continue; }
for (int d = 0; d < 4; d++) {
int nr = r+dr[d], nc = c+dc[d];
if (nr>=0&&nr<m&&nc>=0&&nc<n&&grid[nr][nc]=='1')
uf.unite(r*n+c, nr*n+nc); // merge adjacent land
}
}
}
return uf.count() - water; // subtract water cells
}| Operation | Time | Notes |
|---|---|---|
| find(x) ā no optimisation | O(n) worst | Linear chain without path compression |
| find(x) ā path compression only | O(log n) amortised | Better but not optimal |
| find(x) ā union by rank only | O(log n) worst | Tree height bounded by log n |
| find(x) ā both optimisations | O(alpha(n)) amortised | Alpha = inverse Ackermann, < 5 for all practical n |
| n operations total | O(n * alpha(n)) ~ O(n) | Entire DSU effectively linear |
Topic C ā Monotonic Stack
C.1 ā What Is a Monotonic Stack?
A monotonic stack is a stack that maintains its elements in either strictly increasing or strictly decreasing order from bottom to top. When a new element is pushed, elements that violate the monotone property are popped first. This gives O(n) solutions to problems that naively require O(n²) nested loops.
Monotonic Stack: Next Greater Element Trace
Array: [2, 1, 5, 6, 2, 3]
Goal: find Next Greater Element (NGE) for each index.
Use a DECREASING monotonic stack. Stack stores INDICES.
When we pop index j because nums[i] > nums[j], nums[i] is the NGE for j.
i=0: nums[0]=2. Stack empty. Push 0. stack=[0] (values: [2])
i=1: nums[1]=1. 1 < nums[top]=2 -> push 1. stack=[0,1] (values: [2,1])
i=2: nums[2]=5. 5 > nums[1]=1 -> pop 1, NGE[1]=5.
5 > nums[0]=2 -> pop 0, NGE[0]=5.
Stack empty. Push 2. stack=[2] (values: [5])
i=3: nums[3]=6. 6 > nums[2]=5 -> pop 2, NGE[2]=6. Push 3.
i=4: nums[4]=2. 2 < nums[3]=6 -> push 4. stack=[3,4]
i=5: nums[5]=3. 3 > nums[4]=2 -> pop 4, NGE[4]=3. Push 5.
End: remaining {3,5} have no NGE -> NGE[3]=NGE[5]=-1.
Result: NGE = [5, 5, 6, -1, 3, -1]
Each element pushed and popped at most once -> O(n) total.
C.2 ā Next Greater Element
// Next Greater Element I (LC 496) ā O(n)
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
unordered_map<int,int> nge; // nge[val] = next greater element value
stack<int> stk; // decreasing monotonic stack of VALUES
for (int x : nums2) {
while (!stk.empty() && stk.top() < x) {
nge[stk.top()] = x; // x is NGE for stk.top()
stk.pop();
}
stk.push(x);
}
vector<int> res;
for (int x : nums1) res.push_back(nge.count(x) ? nge[x] : -1);
return res;
}C.3 ā Daily Temperatures
// LeetCode 739 ā Daily Temperatures
// For each day, how many days until a warmer temperature?
// Time: O(n) Space: O(n)
vector<int> dailyTemperatures(vector<int>& temps) {
int n = temps.size();
vector<int> res(n, 0);
stack<int> stk; // decreasing monotonic stack of indices
for (int i = 0; i < n; i++) {
while (!stk.empty() && temps[i] > temps[stk.top()]) {
int j = stk.top(); stk.pop();
res[j] = i - j; // days to wait = i - j
}
stk.push(i);
}
return res; // indices still in stack: res[j]=0 (never warmer)
}| Problem | Stack Type | Pop trigger | Answer at pop time |
|---|---|---|---|
| Next Greater Element | Decreasing | new > top |
NGE = current element |
| Next Smaller Element | Increasing | new < top |
NSE = current element |
| Daily Temperatures | Decreasing | new > top |
wait = current_idx - popped_idx |
| Largest Rectangle | Increasing | new < top |
area = h * calculated_width |
| Trapping Rain Water | Decreasing | new >= top |
trapped = (min(L,R)-h)*width |
| Remove K Digits | Increasing | new < top AND k > 0 |
remove top |
Key Insight
- DECREASING stack (top is smallest): use to find NEXT GREATER ELEMENT. Pop when new element > top.
- INCREASING stack (top is largest): use to find NEXT SMALLER ELEMENT. Pop when new element < top.
- Store INDICES not values when you need width/distance calculations (Histogram, Trapping Rain Water).
- Each element is pushed once and popped at most once → O(n) total operations.
Topic D ā Sliding Window
D.1 ā Fixed vs Variable Window
The sliding window technique maintains a contiguous subarray or substring by moving two pointers. It reduces O(n²) brute-force to O(n) by avoiding recomputation ā instead of restarting from scratch, we slide forward: add the new element on the right, remove the old element on the left.
| Type | When to use | Template shape | Classic problems |
|---|---|---|---|
| Fixed window (size k) | Window size is given | Advance both ends together | Max sum subarray of size k, sliding window max |
| Variable window (shrink) | Optimise window size under constraint | Expand right, shrink left when violated | Longest substring without repeat, min window substring |
// āā FIXED WINDOW: Maximum sum subarray of size k āāāāāāāāāāāā
// Time: O(n) Space: O(1)
int maxSumFixed(vector<int>& nums, int k) {
int sum = 0;
for (int i = 0; i < k; i++) sum += nums[i]; // initial window
int best = sum;
for (int i = k; i < (int)nums.size(); i++) {
sum += nums[i] - nums[i-k]; // slide: add right, remove left
best = max(best, sum);
}
return best;
}
// āā VARIABLE WINDOW: Longest substring without repeating chars ā
// Time: O(n) Space: O(min(n, charset))
int lengthOfLongestSubstring(string s) {
unordered_map<char,int> freq;
int lo = 0, best = 0;
for (int hi = 0; hi < (int)s.size(); hi++) {
freq[s[hi]]++;
while (freq[s[hi]] > 1) { // constraint violated: shrink left
freq[s[lo]]--;
lo++;
}
best = max(best, hi - lo + 1);
}
return best;
}
// āā VARIABLE WINDOW: Minimum Window Substring (LC 76) āāāāāāāā
// Time: O(n) Space: O(charset)
string minWindow(string s, string t) {
unordered_map<char,int> need, have;
for (char c : t) need[c]++;
int formed = 0, required = need.size();
int lo = 0, minLen = INT_MAX, start = 0;
for (int hi = 0; hi < (int)s.size(); hi++) {
char c = s[hi];
have[c]++;
if (need.count(c) && have[c] == need[c]) formed++;
while (formed == required) { // valid window: try to shrink
if (hi - lo + 1 < minLen) { minLen = hi-lo+1; start = lo; }
have[s[lo]]--;
if (need.count(s[lo]) && have[s[lo]] < need[s[lo]]) formed--;
lo++;
}
}
return minLen == INT_MAX ? "" : s.substr(start, minLen);
}Topic E ā Two Pointers
E.1 ā Two Pointer Patterns
Two pointers uses two indices that move through a data structure, typically towards each other or in the same direction. This eliminates the inner loop of an O(n²) solution when the data has monotone properties (sorted array, or a constraint that improves/worsens as pointers move).
| Pattern | Pointer motion | Classic problems |
|---|---|---|
| Opposite ends (converge) | lo starts left, hi starts right; move toward each other | Two Sum (sorted), Container With Most Water, 3Sum |
| Same direction (fast/slow) | Both move left-to-right at different speeds | Remove duplicates, find middle of linked list, cycle detection |
| Partition | lo tracks write position, hi reads | Dutch National Flag, partition for QuickSort |
// āā OPPOSITE ENDS: Two Sum on sorted array āāāāāāāāāāāāāāāāāā
// Time: O(n) Space: O(1)
vector<int> twoSumSorted(vector<int>& nums, int target) {
int lo = 0, hi = (int)nums.size()-1;
while (lo < hi) {
int sum = nums[lo] + nums[hi];
if (sum == target) return {lo+1, hi+1}; // 1-indexed
else if (sum < target) lo++; // need larger sum
else hi--; // need smaller sum
}
return {};
}
// āā SAME DIRECTION: Remove duplicates from sorted array āāāāāā
// Time: O(n) Space: O(1)
int removeDuplicates(vector<int>& nums) {
int lo = 0; // write pointer
for (int hi = 1; hi < (int)nums.size(); hi++) {
if (nums[hi] != nums[lo]) { // new unique element
lo++;
nums[lo] = nums[hi];
}
}
return lo + 1; // new length
}
// āā FAST/SLOW: Floyd's cycle detection āāāāāāāāāāāāāāāāāāāāāāā
// Time: O(n) Space: O(1)
bool hasCycle(ListNode* head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // cycle detected
}
return false;
}Solved Problem 1: Implement Trie (Prefix Tree)
Implement a Trie supporting insert(word), search(word), and startsWith(prefix). (LeetCode 208 ā Medium)
OBSERVATIONS
- Each node has 26 children (one per lowercase letter) and a boolean
isEndflag. Traversal always takes O(L) steps. search()requiresisEnd=trueat the final node.startsWith()only requires the path to exist.- Common mistake: returning true in
search()as soon as the path exists without checkingisEnd. 'app' exists as a path even if only 'apple' was inserted.
class Trie {
struct Node {
Node* ch[26] = {};
bool isEnd = false;
};
Node* root = new Node();
Node* traverse(const string& s) { // returns node after last char or null
Node* cur = root;
for (char c : s) {
if (!cur->ch[c-'a']) return nullptr;
cur = cur->ch[c-'a'];
}
return cur;
}
public:
void insert(string word) {
Node* cur = root;
for (char c : word) {
if (!cur->ch[c-'a']) cur->ch[c-'a'] = new Node();
cur = cur->ch[c-'a'];
}
cur->isEnd = true;
}
bool search(string word) {
Node* end = traverse(word);
return end && end->isEnd; // path exist AND complete word
}
bool startsWith(string prefix) {
return traverse(prefix) != nullptr; // path existence is sufficient
}
};Complexity: Time O(L) per operation, Space O(total_chars * 26)
Solved Problem 2: Number of Connected Components
Given n nodes (0 to n-1) and a list of undirected edges, return the number of connected components. (LeetCode 323 ā Medium)
OBSERVATIONS
- Classic Union-Find application: initialise n components (each node is its own set), then for each edge union the two endpoints. Final component count = answer.
- Alternative: BFS/DFS counting visited components. Both O(V+E). Union-Find is cleaner for dynamic edge-addition scenarios.
- Every time
unite(a,b)successfully merges two different sets, the component count decreases by 1. Start from n, apply all edges.
class Solution {
public:
int countComponents(int n, vector<vector<int>>& edges) {
vector<int> parent(n), rank_(n, 0);
iota(parent.begin(), parent.end(), 0);
function<int(int)> find = [&](int x) -> int {
if (parent[x] != x) parent[x] = find(parent[x]); // path compress
return parent[x];
};
int components = n;
for (auto& e : edges) {
int ra = find(e[0]), rb = find(e[1]);
if (ra == rb) continue; // already connected: skip
if (rank_[ra] < rank_[rb]) swap(ra, rb);
parent[rb] = ra;
if (rank_[ra] == rank_[rb]) rank_[ra]++;
components--;
}
return components;
}
};Complexity: Time O((V+E) * alpha(V)), Space O(V)
Solved Problem 3: Largest Rectangle in Histogram
Given an array of bar heights representing a histogram, return the area of the largest rectangle. (LeetCode 84 ā Hard)
OBSERVATIONS
- For each bar, the largest rectangle using that bar as the shortest has height = heights[i] and extends left and right as far as all bars are >= heights[i].
- Brute force: For each pair (i,j), compute min height à width. O(n²). TLE for large n.
- Monotonic stack insight: Maintain an increasing stack of indices. When we encounter a bar shorter than the stack top, the top bar is the shortest in its maximal range ā compute its area immediately.
- Width calculation: when popping index j at trigger i, width = i - stack.top() - 1 (or just i if stack is empty).
- Sentinel: append 0 to heights to force all bars to pop at the end.
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
heights.push_back(0); // sentinel forces complete flush
stack<int> stk; // increasing monotonic stack of indices
int maxArea = 0;
for (int i = 0; i < (int)heights.size(); i++) {
while (!stk.empty() && heights[i] < heights[stk.top()]) {
int h = heights[stk.top()]; stk.pop();
// Width: from after current stack top to just before i
int w = stk.empty() ? i : i - stk.top() - 1;
maxArea = max(maxArea, h * w);
}
stk.push(i);
}
return maxArea;
}
};Complexity: Time O(n), Space O(n)
Complexity Reference ā All Bonus Topics
| Data Structure / Algorithm | Operation | Time | Space |
|---|---|---|---|
| Trie | Insert / Search / StartsWith | O(L) | O(n*L*26) |
| Trie | Build from n words | O(n*L) | O(n*L*26) |
| Union-Find (both optimisations) | find / unite | O(alpha(n)) ~ O(1) | O(n) |
| Monotonic Stack | Next Greater/Smaller Element | O(n) | O(n) |
| Monotonic Stack | Largest Rectangle in Histogram | O(n) | O(n) |
| Sliding Window (fixed) | Max/min over window of size k | O(n) | O(1) |
| Sliding Window (variable) | Longest/shortest window under constraint | O(n) | O(charset) |
| Two Pointers (converge) | Two Sum on sorted array | O(n) | O(1) |
| Two Pointers (fast/slow) | Cycle detection, find middle | O(n) | O(1) |
Common Interview Questions
| Topic | Problem | Key Detail |
|---|---|---|
| Trie | Implement Trie (LC 208) | insert, search, startsWith |
| Design Add and Search Words (LC 211) | Trie with wildcard '.' DFS | |
| Word Search II (LC 212) | Trie + DFS backtracking on grid | |
| Replace Words (LC 648) | Trie prefix replacement in sentence | |
| Maximum XOR of Two Numbers (LC 421) | Binary Trie (30 levels) | |
| Union-Find | Number of Connected Components (LC 323) | Classic DSU application |
| Redundant Connection (LC 684) | First edge creating a cycle | |
| Graph Valid Tree (LC 261) | n-1 edges + 1 component | |
| Accounts Merge (LC 721) | Union on shared emails | |
| Number of Islands (LC 200) | BFS or Union-Find on grid | |
| Monotonic Stack | Daily Temperatures (LC 739) | Days until warmer, decreasing stack |
| Next Greater Element I (LC 496) | NGE with hash map + stack | |
| Largest Rectangle in Histogram (LC 84) | Increasing stack, O(n) | |
| Maximal Rectangle (LC 85) | Histogram per row + LC 84 | |
| Trapping Rain Water (LC 42) | Monotonic stack or two pointers | |
| Sliding Window | Maximum Average Subarray (LC 643) | Fixed window of size k |
| Longest Substring Without Repeating (LC 3) | Variable window, freq map | |
| Minimum Window Substring (LC 76) | Variable window, formed count | |
| Permutation in String (LC 567) | Fixed window, freq comparison | |
| Sliding Window Maximum (LC 239) | Fixed window, monotonic deque | |
| Two Pointers | Two Sum II ā sorted array (LC 167) | Converging pointers |
| 3Sum (LC 15) | Sort + two pointers for each fixed element | |
| Container With Most Water (LC 11) | Always move the shorter pointer | |
| Linked List Cycle II (LC 142) | Floyd's algorithm, find cycle entry | |
| Sort Colors / Dutch Flag (LC 75) | 3-way partition, lo/mid/hi pointers |
Chapter 11 ā Key Takeaways
- TRIE: O(L) insert/search/prefix-check regardless of dictionary size. Children array [26] + isEnd flag. Use when prefix queries are needed.
- UNION-FIND: near-O(1) amortised connectivity queries. Path compression + union by rank together give O(alpha(n)). Use for dynamic connectivity, cycle detection, and component counting.
- MONOTONIC STACK: O(n) Next Greater/Smaller Element and histogram problems. Store indices (not values) when width/distance is needed.
- SLIDING WINDOW: O(n) for subarray/substring optimisation. Fixed window: slide both ends. Variable window: expand right, shrink left when constraint violated.
- TWO POINTERS: O(n) on sorted arrays or linked lists. Converging pointers for sum problems; fast/slow for cycle detection; partition for in-place array reorganisation.
- Pattern selection: prefix queries → Trie. Dynamic connectivity → Union-Find. Next greater in O(n) → Monotonic Stack. Optimal contiguous window → Sliding Window. O(n) on sorted structure → Two Pointers.