Bonus Topics

Trie | Union-Find (DSU) | Monotonic Stack | Sliding Window | Two Pointers

šŸ“˜ 5 Topics 🧩 3 Solved Problems Intermediate Difficulty Prerequisite: Ch10

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 isEnd flag. Traversal always takes O(L) steps.
  • search() requires isEnd=true at the final node. startsWith() only requires the path to exist.
  • Common mistake: returning true in search() as soon as the path exists without checking isEnd. '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.