Module 5.3

Advanced Trees

Ever wondered how databases keep searches lightning-fast, or how autocomplete predicts your next word? The secret lies in advanced tree structures! In this lesson, you'll master AVL Trees that automatically balance themselves, Heaps that always give you the highest (or lowest) priority element, Segment Trees for blazing-fast range queries, and Tries for powerful string operations. These are the secret weapons of competitive programmers and interview aces!

55 min read
Advanced
Interview Essential
What You'll Learn
  • AVL Tree rotations
  • Heap operations
  • Segment Tree queries
  • Trie for strings
  • TreeMap/TreeSet
Contents
01

AVL Trees (Self-Balancing BST)

AVL Tree

An AVL Tree (named after inventors Adelson-Velsky and Landis) is a self-balancing Binary Search Tree that automatically keeps itself balanced after every insertion and deletion.

What Problem Does AVL Tree Solve?

The Problem with Regular BST: If you insert sorted numbers like 1, 2, 3, 4, 5 into a normal BST, it becomes a "skewed tree" — essentially a linked list! This makes searching O(n) instead of O(log n).

AVL Tree Solution: AVL trees automatically "rotate" nodes after every insert/delete to keep the tree balanced, guaranteeing O(log n) for all operations.

Key Concept: Balance Factor

Balance Factor = Height(Left Subtree) − Height(Right Subtree)

For every node in an AVL tree, the balance factor must be -1, 0, or +1. If it goes beyond this range, the tree performs rotations to fix it.

  • BF = -1: Right subtree is 1 level deeper (slightly right-heavy)
  • BF = 0: Both subtrees have equal height (perfectly balanced)
  • BF = +1: Left subtree is 1 level deeper (slightly left-heavy)
  • BF < -1 or BF > +1: Unbalanced! Rotation needed
The 4 Types of Rotations
When a node becomes unbalanced, AVL uses these rotations to fix it
SINGLE ROTATIONS One rotation fixes the imbalance
1. Left-Left (LL) Case
Right Rotation

When: Node inserted in the left subtree of the left child

BEFORE Z Y X AFTER Y X Z

Left child (Y) becomes the new root, Z moves to right

2. Right-Right (RR) Case
Left Rotation

When: Node inserted in the right subtree of the right child

BEFORE Z Y X AFTER Y Z X

Right child (Y) becomes the new root, Z moves to left

DOUBLE ROTATIONS Two rotations needed for "zig-zag" patterns
3. Left-Right (LR) Case
Left → Right

When: Node inserted in right subtree of left child

BEFORE Z X Y Left MIDDLE Z Y X Right BALANCED Y X Z

First left-rotate X, then right-rotate Z → Y becomes root

4. Right-Left (RL) Case
Right → Left

When: Node inserted in left subtree of right child

BEFORE Z X Y Right MIDDLE Z Y X Left BALANCED Y Z X

First right-rotate X, then left-rotate Z → Y becomes root

Quick Memory Trick
LL Left-Left → Single Right (opposite)
RR Right-Right → Single Left (opposite)
LR Left-Right → Left then Right
RL Right-Left → Right then Left
Time Complexity (All Guaranteed!)
Search: O(log n)
Insert: O(log n)
Delete: O(log n)

Unlike regular BST, these are guaranteed — never degrades to O(n)!

Left Rotation

RR Case Fix

When to use: Right subtree is too heavy (balance factor < -1)

The right child becomes the new root, and the original root becomes its left child.

BEFORE X Y Z AFTER Y X Z
Rotate Left O(1) Time Fixes RR

Right Rotation

LL Case Fix

When to use: Left subtree is too heavy (balance factor > 1)

The left child becomes the new root, and the original root becomes its right child.

BEFORE X Y Z AFTER Y Z X
Rotate Right O(1) Time Fixes LL
AVL Rotation Visualized
How AVL trees rebalance themselves after insertions
Right Rotation
LL Case
BEFORE
30 20 T3 T1 T2
AFTER
20 T1 30 T2 T3
Node 30 was left-heavy → 20 becomes new root
Left Rotation
RR Case
BEFORE
10 T1 20 T2 T3
AFTER
20 10 T3 T1 T2
Node 10 was right-heavy → 20 becomes new root
Left-Right Rotation
LR Case
STEP 1
30 10 20
STEP 2
30 20 10
RESULT
20 10 30
2-step: Left rotate 10, then Right rotate 30
Right-Left Rotation
RL Case
STEP 1
10 30 20
STEP 2
10 20 30
RESULT
20 10 30
2-step: Right rotate 30, then Left rotate 10
Quick Reference: When to Use Which Rotation?
Left-Left
→ Right Rotate
Right-Right
→ Left Rotate
Left-Right
→ Left + Right
Right-Left
→ Right + Left
AVL Node & Balance Helpers Foundation
class AVLNode {
    int val, height;        // Value and height for balance calculation
    AVLNode left, right;    // Child pointers
    
    AVLNode(int val) {
        this.val = val;
        this.height = 1;    // New nodes start with height 1
    }
}

Each AVL node stores its value and height (distance to deepest leaf). New nodes start with height 1. Unlike regular BST nodes, the height field enables balance checking.

// Height helper - safely returns 0 for null nodes
private int height(AVLNode node) {
    return node == null ? 0 : node.height;
}

Safely gets height even for null nodes (returns 0). This avoids NullPointerException when calculating balance factor for nodes with missing children.

// Balance Factor = leftHeight - rightHeight
// If |balance| > 1, tree needs rotation!
private int getBalance(AVLNode node) {
    return node == null ? 0 : height(node.left) - height(node.right);
}
// Balance Factor:  -1, 0, +1 = balanced
//                  > +1 = left-heavy (needs rotation)
//                  < -1 = right-heavy (needs rotation)

Balance Factor = left height − right height. If it's -1, 0, or +1 → balanced. If > +1 → left subtree too heavy. If < -1 → right subtree too heavy. This tells us which rotation to apply!

Height Tracking
Each node stores its height
Balance Factor
left - right height
Balanced if
-1 ≤ factor ≤ +1

Foundation: AVL trees extend regular BST nodes with a height field. The balance factor (left height minus right height) determines if a node is balanced. When balance factor exceeds ±1 after insertion/deletion, rotations restore balance.

Single Rotations O(1) Core Operation
// RIGHT ROTATION (LL Case) - when left subtree is too heavy
//      y                 x
//     / \               / \
//    x   T3    →       T1  y
//   / \                   / \
//  T1  T2               T2  T3
private AVLNode rightRotate(AVLNode y) {
    AVLNode x = y.left;      // x becomes new root
    AVLNode T2 = x.right;    // Save T2 (will move)
    
    // Perform rotation
    x.right = y;             // y becomes x's right child
    y.left = T2;             // T2 becomes y's left child
    
    // Update heights (y first, then x - order matters!)
    y.height = Math.max(height(y.left), height(y.right)) + 1;
    x.height = Math.max(height(x.left), height(x.right)) + 1;
    
    return x;  // Return new root of this subtree
}

When to use: Left subtree is too heavy (balance > 1) and new node was inserted in left child's left subtree. The left child (x) becomes new root, and original root (y) becomes right child. T2 moves to maintain BST property.

// LEFT ROTATION (RR Case) - when right subtree is too heavy
//    x                   y
//   / \                 / \
//  T1  y       →       x   T3
//     / \             / \
//    T2  T3          T1  T2
private AVLNode leftRotate(AVLNode x) {
    AVLNode y = x.right;     // y becomes new root
    AVLNode T2 = y.left;     // Save T2 (will move)
    
    // Perform rotation
    y.left = x;              // x becomes y's left child
    x.right = T2;            // T2 becomes x's right child
    
    // Update heights (x first, then y - order matters!)
    x.height = Math.max(height(x.left), height(x.right)) + 1;
    y.height = Math.max(height(y.left), height(y.right)) + 1;
    
    return y;  // Return new root of this subtree
}

When to use: Right subtree is too heavy (balance < -1) and new node was inserted in right child's right subtree. Mirror of right rotation. The right child (y) becomes new root. Always update child's height before parent!

Right Rotate
LL case (left-left heavy)
Left Rotate
RR case (right-right heavy)
Height Update
Child first, then parent

How Rotations Work: Think of it as "promoting" the heavy subtree's root. In right rotation, the left child becomes the new root, and the old root becomes its right child. The middle subtree (T2) moves to maintain BST property. Always update heights bottom-up!

AVL Insert with Auto-Balance O(log n) ⭐ Key Method
public AVLNode insert(AVLNode node, int val) {
    // STEP 1: Standard BST insert
    if (node == null) return new AVLNode(val);
    
    if (val < node.val)
        node.left = insert(node.left, val);
    else if (val > node.val)
        node.right = insert(node.right, val);
    else
        return node;  // Duplicates not allowed

First, insert like a normal BST: compare value, go left if smaller, right if larger. Recursively insert until you find an empty spot. Nothing special here yet!

    // STEP 2: Update height of current node
    node.height = 1 + Math.max(height(node.left), height(node.right));
    
    // STEP 3: Get balance factor to check if unbalanced
    int balance = getBalance(node);

After recursion returns, update this node's height (1 + max of children). Then calculate balance factor. If |balance| > 1, we need rotations!

    // STEP 4: Handle the 4 unbalanced cases
    
    // Left Left Case (LL): inserted in left subtree of left child
    if (balance > 1 && val < node.left.val)
        return rightRotate(node);
    
    // Right Right Case (RR): inserted in right subtree of right child
    if (balance < -1 && val > node.right.val)
        return leftRotate(node);

LL Case: Unbalanced left (balance > 1) AND value went to left-left → one right rotation. RR Case: Unbalanced right (balance < -1) AND value went to right-right → one left rotation.

    // Left Right Case (LR): inserted in right subtree of left child
    if (balance > 1 && val > node.left.val) {
        node.left = leftRotate(node.left);   // First: left rotate left child
        return rightRotate(node);             // Then: right rotate node
    }
    
    // Right Left Case (RL): inserted in left subtree of right child
    if (balance < -1 && val < node.right.val) {
        node.right = rightRotate(node.right); // First: right rotate right child
        return leftRotate(node);              // Then: left rotate node
    }
    
    return node;  // Return unchanged node if balanced
}

LR Case: "Zig-zag" left then right → first left-rotate the left child (straighten), then right-rotate the node. RL Case: Mirror - first right-rotate right child, then left-rotate node. Two rotations needed for zig-zag patterns!

LL → Right
Single rotation
RR → Left
Single rotation
LR → Left+Right
Double rotation
RL → Right+Left
Double rotation

The 4 Cases Explained: After BST insert, check balance factor. LL/RR are "straight" imbalances fixed with single rotation. LR/RL are "zig-zag" imbalances requiring two rotations to first straighten, then fix. Remember: balance factor > 1 means left-heavy, < -1 means right-heavy!

02

Heaps (Complete Binary Tree)

Heap (Priority Queue)

A complete binary tree stored as an array where every parent has a specific relationship with its children: In a Max-Heap, parent ≥ children. In a Min-Heap, parent ≤ children.

Why Do We Need Heaps?
Understanding the problem heaps solve
🏥 The Emergency Room Problem

Imagine you're running a hospital ER. Patients arrive constantly, but you always need to treat the most critical patient first. How do you efficiently track who's next?

✨ Heaps Are Perfect For This!

A Min-Heap always gives you the smallest (most urgent) element in O(1) time, and adding/removing patients takes only O(log n) time.

🔮 The Magic of Array Storage

Unlike other trees that use pointers, heaps use a simple array. For any element at index i:

Parent: (i-1)/2 Left: 2i+1 Right: 2i+2
🌍 Real-World Uses
Task Schedulers (OS) Dijkstra's Algorithm Top K Elements Heap Sort PriorityQueue
Heap Structure Visualized
Complete binary tree stored as an array
Min-Heap
parent ≤ children
MIN 1 3 2 7 6 5 4
Array:
1
3
2
7
6
5
4
0123456
Max-Heap
parent ≥ children
MAX 9 7 8 4 5 2 3
Array:
9
7
8
4
5
2
3
0123456
Index Formulas (The Key to Heap Implementation!)
Parent of i
(i - 1) / 2
Left Child
2 * i + 1
Right Child
2 * i + 2
MinHeap - Structure & Helpers Array-Based
class MinHeap {
    private int[] heap;   // Array to store heap elements
    private int size;     // Current number of elements
    
    public MinHeap(int capacity) {
        heap = new int[capacity];
        size = 0;
    }
}

A heap is stored as a simple array - no pointers needed! We just track the array and current size. The "tree structure" is virtual, calculated using index formulas.

    // Index formulas - THE KEY to heap implementation!
    private int parent(int i)     { return (i - 1) / 2; }  // Parent index
    private int leftChild(int i)  { return 2 * i + 1; }    // Left child index
    private int rightChild(int i) { return 2 * i + 2; }    // Right child index

Memorize these! For any node at index i: parent = (i-1)/2, leftChild = 2*i+1, rightChild = 2*i+2. This lets us navigate the "tree" using just array indices. No pointers!

    // Helper to swap two elements
    private void swap(int i, int j) {
        int temp = heap[i];
        heap[i] = heap[j];
        heap[j] = temp;
    }
    
    // Peek at minimum (always at root)
    public int peek() {
        return heap[0];  // Min is always at index 0
    }
}

swap() exchanges two elements - used heavily during bubble up/down. peek() returns the min in O(1) - it's always at index 0 (root)!

Parent of i
(i - 1) / 2
Left Child of i
2 × i + 1
Right Child of i
2 × i + 2

Array Magic: Heaps use a clever array representation. A complete binary tree can be stored in an array where parent-child relationships are calculated using simple formulas. No pointers needed! The root is always at index 0, and for any node at index i, you can find its parent and children instantly.

Insert - Bubble Up O(log n)
// Insert element - O(log n)
public void insert(int val) {
    // Step 1: Add at the END of array
    heap[size] = val;
    int current = size;
    size++;

First, simply add the new value at the end of the array. This maintains the complete tree shape (level-by-level filling).

    // Step 2: BUBBLE UP - swap with parent while smaller
    //         (maintain min-heap property)
    while (current > 0 && heap[current] < heap[parent(current)]) {
        swap(current, parent(current));  // Swap with parent
        current = parent(current);        // Move up to parent's position
    }
    // Element finds its correct position!
}

Bubble Up: If new element is smaller than parent, swap them! Keep swapping upward until heap property is restored (parent ≤ child) or we reach root. Max swaps = tree height = O(log n).

Step 1: Add at End 1 5 3 7 6 - 2 NEW Step 2: Compare 2 < 5 1 5 parent 3 7 6 2 SWAP! Step 3: Done! (2 > 1) 1 2 3 7 6 5

The new element (2) is added at the end, then "bubbles up" by swapping with 5 since 2 < 5. It stops when 2 > 1 (parent).

Step 1
Add at end of array
Step 2
Compare with parent
Step 3
Swap if child < parent

Bubble Up Process: New element is added at the end (maintaining complete tree shape), then "bubbles up" by swapping with parent until it finds the right spot. In min-heap, element stops when it's ≥ parent or reaches root. Maximum bubbles = tree height = O(log n).

Extract Min - Heapify Down O(log n) ⭐ Key Method
// Extract minimum element - O(log n)
public int extractMin() {
    int min = heap[0];           // Save the minimum (root)
    heap[0] = heap[size - 1];    // Move LAST element to root
    size--;
    
    heapify(0);  // Fix heap property from root
    
    return min;
}

Save the root (minimum), replace it with the last element (maintains complete tree), decrease size, then call heapify to restore order.

// Heapify (bubble down) - restore heap property
private void heapify(int i) {
    int smallest = i;
    int left = leftChild(i);
    int right = rightChild(i);
    
    // Find smallest among node and its children
    if (left < size && heap[left] < heap[smallest])
        smallest = left;
    
    if (right < size && heap[right] < heap[smallest])
        smallest = right;
    
    // If smallest is not the current node, swap and recurse
    if (smallest != i) {
        swap(i, smallest);
        heapify(smallest);  // Continue down the tree
    }
}

Heapify Down: Compare node with both children, find the smallest. If node isn't smallest, swap with the smallest child and recurse. Continues until node ≤ both children or reaches leaf.

Step 1: Extract Min 1 REMOVE 2 3 7 6 5 LAST Step 2: Last → Root 5 5 > 2! SWAP! 2 smaller 3 7 6 Step 3: Heap Restored! 2 ✓ MIN 5 3 7 6

After removing 1, the last element (5) goes to root. Then 5 "sinks down" by swapping with 2 (smaller child). Heap property restored!

Step 1
Save root (minimum)
Step 2
Move last to root
Step 3
Heapify down

Heapify Down Process: After removing root, the last element takes its place (maintaining complete tree). Then it "sinks down" by swapping with the smaller child until heap property is restored. This ensures O(log n) time for removal while keeping the tree structure.

Java PriorityQueue & Heap Sort Built-in Interview Tip
// Java's built-in heap implementation
import java.util.*;

// MinHeap (default) - smallest element has highest priority
PriorityQueue minHeap = new PriorityQueue<>();
minHeap.offer(5);  // Add elements
minHeap.offer(2);
minHeap.offer(8);
minHeap.poll();    // Returns 2 (smallest)
minHeap.peek();    // Returns 5 (next smallest)

PriorityQueue is a MinHeap by default. Use offer() to add, poll() to remove min, peek() to view min without removing.

// MaxHeap - largest element has highest priority
PriorityQueue maxHeap = new PriorityQueue<>(Collections.reverseOrder());
// OR: new PriorityQueue<>((a, b) -> b - a);
maxHeap.offer(5);
maxHeap.offer(2);
maxHeap.offer(8);
maxHeap.poll();    // Returns 8 (largest)

For MaxHeap, use Collections.reverseOrder() or custom comparator (a, b) -> b - a. Now poll() returns largest element!

// ========== HEAP SORT - O(n log n) ==========
void heapSort(int[] arr) {
    int n = arr.length;
    
    // Step 1: Build max heap - O(n)
    // Start from last non-leaf node and heapify all
    for (int i = n / 2 - 1; i >= 0; i--)
        heapifyForSort(arr, n, i);
    
    // Step 2: Extract elements one by one - O(n log n)
    for (int i = n - 1; i > 0; i--) {
        // Move current root (max) to end
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        
        // Heapify reduced heap
        heapifyForSort(arr, i, 0);
    }
    // Array is now sorted in ascending order!
}

Heap Sort: 1) Build max-heap from array. 2) Repeatedly swap root (max) with last element, shrink heap, heapify. Each max goes to its correct position at the end!

PriorityQueue
Default: MinHeap
MaxHeap
Collections.reverseOrder()
Heap Sort
O(n log n) in-place

In Interviews: Use Java's PriorityQueue instead of implementing from scratch! It's a min-heap by default. For max-heap, use Collections.reverseOrder() or a custom comparator. Heap Sort works by building a max-heap, then repeatedly extracting the max to the end of the array.

03

Segment Trees

Segment Tree

A tree data structure for range queries (sum, min, max) and point updates in O(log n) time. Each node represents a segment of the array.

Why Do We Need Segment Trees?

Imagine you have an array of 1 million numbers and need to answer questions like "What's the sum from index 100,000 to 500,000?" thousands of times, while also updating values!

The Problem: A simple loop takes O(n) per query = too slow! Prefix sums give O(1) queries but O(n) updates. We need BOTH to be fast!

Segment Trees solve this! They precompute sums for different "segments" of the array. Need sum of [100K, 500K]? Combine a few precomputed segments instead of adding 400K numbers!

Real-World Uses: Database range queries, competitive programming, finding min/max in ranges, counting elements in ranges, and interval scheduling problems!

Segment Tree Structure Visualized
Array: [1, 3, 5, 7] → Range Sum Query
Tree Structure
Sum Tree
[0,3] = 16 ROOT [0,1] = 4 [2,3] = 12 [0] 1 [1] 3 [2] 5 [3] 7 ← Leaves: Original array elements
Original Array:
1
3
5
7
0123
Query(1, 2)
O(log n)
Find sum of arr[1..2] = arr[1] + arr[2]
1
Start at root [0,3]
2
Partial overlap → go to children
3
[0,1] partial → check children
4
[1]=3 complete match!
5
[2]=5 complete match!
Answer: 3 + 5 = 8
Visit at most 4 nodes per level!
Node Types
Root Node
Sum of entire array
Internal Nodes
Store range aggregates
Leaf Nodes
Original array elements
Segment Tree - Build O(n) Foundation
class SegmentTree {
    private int[] tree;  // Stores the segment tree nodes
    private int n;       // Original array size
    
    public SegmentTree(int[] arr) {
        n = arr.length;
        tree = new int[4 * n];  // 4n space is safe for all cases
        build(arr, 0, 0, n - 1);
    }
}

We allocate 4n space for the tree array (safe upper bound). The tree is built in the constructor by calling recursive build function.

    // Build segment tree recursively - O(n)
    // node = current tree index
    // start, end = range this node represents
    private void build(int[] arr, int node, int start, int end) {
        if (start == end) {
            // Leaf node: store array element
            tree[node] = arr[start];
        } else {
            int mid = (start + end) / 2;
            int leftChild = 2 * node + 1;
            int rightChild = 2 * node + 2;
            
            // Build left subtree: covers [start, mid]
            build(arr, leftChild, start, mid);
            
            // Build right subtree: covers [mid+1, end]
            build(arr, rightChild, mid + 1, end);
            
            // Internal node: aggregate of children (sum in this case)
            tree[node] = tree[leftChild] + tree[rightChild];
        }
    }
}

Leaf nodes (start == end) store array elements. Internal nodes store the sum of their children. Each node covers a range [start, end].

// Tree structure for arr = [1, 3, 5, 7]
//              [0,3]=16
//             /        \
//        [0,1]=4      [2,3]=12
//        /    \        /    \
//     [0]=1  [1]=3  [2]=5  [3]=7

Root [0,3] stores sum of entire array (16). Each level divides ranges in half. Leaves store individual elements. This structure enables O(log n) queries!

Leaf Nodes
Store array elements
Internal Nodes
Store aggregate (sum)
Space
4n array size

Build Process: The tree is built bottom-up recursively. Leaf nodes (when start == end) store individual array elements. Internal nodes store the aggregate (sum, min, max, etc.) of their children. Each node covers a specific range [start, end]. The root covers the entire array.

Range Query O(log n) ⭐ Key Method
// Public API: Get sum of range [L, R]
public int query(int L, int R) {
    return query(0, 0, n - 1, L, R);
}

Simple public method that calls the recursive helper. User just provides [L, R] range to query.

// Recursive helper
// node = current tree index
// [start, end] = range this node covers
// [L, R] = range we're querying
private int query(int node, int start, int end, int L, int R) {
    
    // Case 1: NO OVERLAP - query range doesn't intersect node range
    if (R < start || end < L) {
        return 0;  // Return identity for sum (would be MAX_VALUE for min)
    }
    
    // Case 2: COMPLETE OVERLAP - node range is fully inside query range
    if (L <= start && end <= R) {
        return tree[node];  // Return this node's precomputed value
    }

No Overlap: If [L,R] doesn't touch [start,end], return 0. Complete Overlap: If node's range is fully inside query range, return precomputed value directly!

    // Case 3: PARTIAL OVERLAP - need to check both children
    int mid = (start + end) / 2;
    int leftSum = query(2 * node + 1, start, mid, L, R);
    int rightSum = query(2 * node + 2, mid + 1, end, L, R);
    
    return leftSum + rightSum;
}

Partial Overlap: Range partially intersects both halves. Query both children and combine results. This is the recursive case.

Query Range [1, 2] → Sum = 8 arr = 1 3 5 7 0 1 2 3 [0,3] 16 Partial [0,1] 4 [2,3] 12 [0] 1 [1] 3 [2] 5 [3] 7 Result 3 + 5 = 8 O(log n)

Query [1,2] hits partial overlap at root → recurse both children. Left gives 3, right gives 5. Sum = 8. Only O(log n) nodes visited!

No Overlap
Return 0 (identity)
Complete Overlap
Return node value
Partial Overlap
Recurse both children

Three Cases: At each node, we check if query range [L,R] overlaps with node's range [start,end]. No overlap = skip. Complete overlap = use precomputed value. Partial overlap = recurse to children. This ensures O(log n) time by visiting only relevant nodes.

Point Update O(log n)
// Public API: Update element at index idx to val
public void update(int idx, int val) {
    update(0, 0, n - 1, idx, val);
}

Simple wrapper that calls recursive helper. User provides index and new value.

// Recursive helper
private void update(int node, int start, int end, int idx, int val) {
    if (start == end) {
        // Found the leaf node for this index
        tree[node] = val;
    } else {
        int mid = (start + end) / 2;
        
        // Decide which child contains our index
        if (idx <= mid) {
            // Index is in left half
            update(2 * node + 1, start, mid, idx, val);
        } else {
            // Index is in right half
            update(2 * node + 2, mid + 1, end, idx, val);
        }

Navigate down the tree: if target index is in left half, go left; otherwise go right. When start == end, we've found the leaf - update it!

        // Recalculate this node from updated children
        tree[node] = tree[2 * node + 1] + tree[2 * node + 2];
    }
}

After recursion returns, recalculate this node's value from its children. This propagates the change up to all ancestors.

// Example Usage:
int[] arr = {1, 3, 5, 7, 9, 11};
SegmentTree st = new SegmentTree(arr);

st.query(1, 3);    // Sum of arr[1..3] = 3+5+7 = 15
st.update(2, 10);  // Change arr[2] from 5 to 10
st.query(1, 3);    // Now = 3+10+7 = 20

After update, subsequent queries automatically reflect the change. Both query and update are O(log n)!

Find Leaf
Navigate to index
Update Value
Set new value at leaf
Propagate Up
Update all ancestors

Update Flow: Navigate to the leaf node for the target index, update its value, then propagate changes upward to all ancestor nodes. Each ancestor recalculates its aggregate from its children. Only O(log n) nodes are visited (one per tree level).

04

Tries (Prefix Trees)

Trie (Prefix Tree)

A tree-like data structure for storing strings where each node represents a single character. Enables O(m) search, insert, and prefix operations where m = word length, regardless of how many words are stored!

Why Do We Need Tries?

Open Google and type "prog"... instantly you see suggestions: "programming", "progress", "program". How does Google search through billions of searches so fast?

The Answer: Tries! Instead of checking every word, a Trie follows a path: p → r → o → g and instantly finds all words with that prefix. No matter if there are 10 or 10 billion words!

How It Works: Each node is a character. Words share common prefixes. "app", "apple", "application" share the path a→p→p. We mark where complete words end with a special flag.

Real-World Uses: Autocomplete (Google, phones), spell checkers, IP routing tables, phone directories, word games (Scrabble/Wordle), and DNA sequence matching!

Trie Structure Visualized
Storing: "app", "apple", "bat", "ball"
Trie Structure
Prefix Tree
root a b p a p ★ app t ★ bat l e → apple l ★ ball ★ = End Word
Words Stored:
app apple bat ball
Search Operations
O(m)
Search "app"
a p p★
Found! (isEndOfWord = true)
Search "ap"
a p
Not a word! (isEndOfWord = false)
Prefix "app"
a p p
Prefix exists! (node found)
Each node has 26 children (a-z). Access: children[c - 'a']
Key Properties
Prefix Sharing
Common prefixes stored once
O(m) Search
m = word length
Autocomplete
Find all words with prefix
Space: O(26×n)
n = total characters
Trie Node & Insert O(m) Foundation
class Trie {
    // Each node can have 26 children (a-z)
    class TrieNode {
        TrieNode[] children = new TrieNode[26];  // 26 letters
        boolean isEndOfWord = false;              // Marks complete word
    }
    
    private TrieNode root;
    
    public Trie() {
        root = new TrieNode();  // Empty root node
    }
}

Each node has 26 children (one for each letter a-z) and an isEndOfWord flag to mark complete words. Root is always empty.

    // Insert a word - O(m) where m = word length
    public void insert(String word) {
        TrieNode node = root;
        
        for (char c : word.toCharArray()) {
            int index = c - 'a';  // 'a'→0, 'b'→1, ... 'z'→25
            
            // Create child node if it doesn't exist
            if (node.children[index] == null) {
                node.children[index] = new TrieNode();
            }
            
            // Move to child
            node = node.children[index];
        }
        
        // Mark last node as end of word
        node.isEndOfWord = true;
    }
}

For each character: calculate index (c - 'a'), create child if needed, move down. Finally mark isEndOfWord = true.

Trie Structure: "app" + "apple" root a index: 0 p index: 15 p "app" l e "apple" Legend Node End of Word Shared Path

Words sharing prefix ("app") reuse the same path. The ★ marks where complete words end. "app" and "apple" share a→p→p, then diverge.

26 Children
One per letter a-z
isEndOfWord
Marks complete words
Index Formula
c - 'a' gives 0-25

Insert Process: Start at root, for each character compute its index (c - 'a'), create the child node if needed, then move down. After processing all characters, mark the final node as isEndOfWord = true. This allows distinguishing "app" from just the prefix of "apple".

Search & Prefix Check O(m) ⭐ Key Methods
// Helper: Navigate trie following a string
private TrieNode searchNode(String word) {
    TrieNode node = root;
    
    for (char c : word.toCharArray()) {
        int index = c - 'a';
        
        // Character path doesn't exist → word not in trie
        if (node.children[index] == null) {
            return null;
        }
        
        node = node.children[index];
    }
    
    return node;  // Return the node at end of path
}

Navigates the trie following the string. Returns null if path doesn't exist, otherwise returns the final node.

// Search for EXACT word - O(m)
public boolean search(String word) {
    TrieNode node = searchNode(word);
    // Must exist AND be marked as end of word
    return node != null && node.isEndOfWord;
}

search() returns true only if path exists AND node has isEndOfWord = true. "ap" exists as path but isn't a complete word!

// Check if ANY word starts with prefix - O(m)
public boolean startsWith(String prefix) {
    // Just check if path exists - don't need isEndOfWord
    return searchNode(prefix) != null;
}

startsWith() just checks if path exists - doesn't care about isEndOfWord. "ap" returns true because it's a prefix of "app"!

// Examples after inserting "app", "apple", "application":
//
// search("app")      → true  (path exists + isEndOfWord)
// search("ap")       → false (path exists, but NOT isEndOfWord)
// search("apx")      → false (path doesn't exist)
// startsWith("app")  → true  (path exists)
// startsWith("ap")   → true  (path exists)
// startsWith("apx")  → false (path doesn't exist)

Key difference: "ap" fails search (not a word) but passes startsWith (is a prefix). "apx" fails both (path doesn't exist at all).

search(word)
Path exists + isEndOfWord
startsWith(prefix)
Just path exists (any prefix)

Search vs StartsWith: Both traverse the trie the same way. The difference is search() requires the final node to have isEndOfWord = true (complete word), while startsWith() only checks if the path exists (any prefix works). This is why "ap" returns false for search but true for startsWith!

Count Words with Prefix O(m + k) Useful for Autocomplete
// Count all words that start with given prefix
public int countWordsWithPrefix(String prefix) {
    TrieNode node = searchNode(prefix);
    
    if (node == null) return 0;  // Prefix doesn't exist
    
    return countWords(node);  // Count words in subtree
}

First navigate to the prefix node. If it doesn't exist, return 0. Otherwise count all words in that subtree.

// Recursively count words in subtree
private int countWords(TrieNode node) {
    // Count this node if it's end of word
    int count = node.isEndOfWord ? 1 : 0;
    
    // Add counts from all children
    for (TrieNode child : node.children) {
        if (child != null) {
            count += countWords(child);
        }
    }
    
    return count;
}

DFS through subtree: count current node if it's end of word, then recursively count all children.

// ========== USAGE EXAMPLE ==========
Trie trie = new Trie();
trie.insert("apple");
trie.insert("app");
trie.insert("application");
trie.insert("banana");

trie.search("app");                  // true  - exact word exists
trie.search("ap");                   // false - not a complete word
trie.startsWith("app");              // true  - prefix exists
trie.countWordsWithPrefix("app");    // 3 (app, apple, application)
trie.countWordsWithPrefix("ban");    // 1 (banana)

"app" prefix matches 3 words (app, apple, application). Perfect for autocomplete suggestions!

Autocomplete
Suggest words by prefix
Spell Checker
Validate & suggest words
IP Routing
Longest prefix match

Real-World Uses: Tries power autocomplete (type "app" → see "apple", "application"), spell checkers (is this word valid?), IP routing tables (longest prefix matching), word games (Scrabble/Boggle word validation), and dictionary implementations. Time is O(m + k) where m = prefix length, k = nodes in subtree.

05

Java Collections (TreeMap/TreeSet)

TreeMap & TreeSet

Java's built-in sorted collections backed by a Red-Black Tree (a self-balancing BST). They provide O(log n) operations while keeping elements automatically sorted!

Why Not Just Use HashMap/HashSet?

HashMap/HashSet are faster (O(1) average) but elements are unordered. You can't ask "what's the smallest element?" or "find the largest number ≤ 50".

TreeMap/TreeSet are slightly slower (O(log n)) but elements are always sorted. They support powerful queries like floor(), ceiling(), range views!

Rule of Thumb: Need order, floor/ceiling, or range operations? → TreeMap/TreeSet. Just need fast lookups? → HashMap/HashSet.

TreeSet - Sorted Set Red-Black Tree Interview Essential
import java.util.*;

// TreeSet - Sorted Set backed by Red-Black Tree
TreeSet set = new TreeSet<>();
set.add(5);
set.add(2);
set.add(8);
set.add(1);
// Set is ALWAYS sorted: {1, 2, 5, 8}

🎯 What's happening here? TreeSet is like a magical container that automatically keeps your numbers sorted!

Key Points:
• When you add elements in any order (5, 2, 8, 1), TreeSet automatically arranges them as {1, 2, 5, 8}
• No duplicates allowed - adding 5 twice still keeps only one 5
• Uses a Red-Black Tree internally (self-balancing BST)
• All operations (add, remove, contains) take O(log n) time - much faster than sorting an array!

// ========== Navigation Methods ==========
set.first();         // 1 (smallest element)
set.last();          // 8 (largest element)

// Floor/Ceiling - find nearest element
set.floor(3);        // 2 (largest element ≤ 3)
set.ceiling(3);      // 5 (smallest element ≥ 3)

// Lower/Higher - strictly less/greater
set.lower(5);        // 2 (largest element < 5)
set.higher(5);       // 8 (smallest element > 5)

🔍 Understanding Navigation Methods: These are super useful for finding "nearest" elements!

How to remember:
floor(3) = "What's the largest number ≤ 3?" → Answer: 2 (it's on the "floor" below or equal)
ceiling(3) = "What's the smallest number ≥ 3?" → Answer: 5 (it's on the "ceiling" above or equal)
lower(5) = "Strictly less than 5" → 2 (doesn't include 5 itself)
higher(5) = "Strictly greater than 5" → 8 (doesn't include 5 itself)
All these operations are O(log n) - extremely efficient for finding nearby values!

// ========== Subset Views ==========
set.headSet(5);      // {1, 2} - all elements < 5
set.tailSet(5);      // {5, 8} - all elements ≥ 5
set.subSet(2, 8);    // {2, 5} - elements in [2, 8)

// ========== Common Patterns ==========
// Find Kth smallest (sorted iteration)
int k = 2;
Iterator it = set.iterator();
for (int i = 1; i < k; i++) it.next();
int kthSmallest = it.next();  // 2nd smallest = 2

📊 Subset Views Explained: Think of these as "windows" into your sorted data!

Breaking it down:
headSet(5) = "Give me everything BEFORE 5" → {1, 2} (head of the list)
tailSet(5) = "Give me 5 and everything AFTER" → {5, 8} (tail of the list)
subSet(2, 8) = "Give me elements from 2 (inclusive) to 8 (exclusive)" → {2, 5}
⚠️ Important: These views are "live" - if you modify the original set, the view changes too!
Pattern: To find Kth smallest, just iterate K times - elements come out in sorted order automatically.

Always Sorted
Natural ordering
No Duplicates
Like HashSet
O(log n)
Add, remove, contains

When to Use TreeSet: Use when you need a sorted set with O(log n) operations. Perfect for finding floor/ceiling values, maintaining sliding window of sorted elements, or when you need range views. Use HashSet instead if you don't need sorting (O(1) average).

TreeMap - Sorted Map Red-Black Tree ⭐ Key-Value
// TreeMap - Sorted Map backed by Red-Black Tree
TreeMap map = new TreeMap<>();
map.put(3, "three");
map.put(1, "one");
map.put(2, "two");
// Map is sorted by KEYS: {1=one, 2=two, 3=three}

🗺️ What's a TreeMap? It's like a dictionary that keeps entries sorted by their "words" (keys)!

Key Concepts:
• Stores key-value pairs like HashMap, but keys are ALWAYS in sorted order
• When you add {3="three", 1="one", 2="two"}, it automatically becomes {1="one", 2="two", 3="three"}
• Sorting is based on KEYS only (not values) - values can be in any order
• Uses Red-Black Tree internally, so put/get/remove are all O(log n)
Trade-off: Slightly slower than HashMap (O(log n) vs O(1)), but you get sorted keys!

// ========== Key Navigation ==========
map.firstKey();       // 1 (smallest key)
map.lastKey();        // 3 (largest key)
map.floorKey(2);      // 2 (largest key ≤ 2)
map.ceilingKey(2);    // 2 (smallest key ≥ 2)
map.lowerKey(2);      // 1 (largest key < 2)
map.higherKey(2);     // 3 (smallest key > 2)

🔑 Key Navigation Methods: Find the nearest keys without knowing exact values!

How they work:
firstKey()/lastKey() - Get the smallest/largest key instantly
floorKey(2) - "What's the largest key ≤ 2?" → 2 itself exists, so returns 2
ceilingKey(2) - "What's the smallest key ≥ 2?" → 2 exists, so returns 2
lowerKey(2)/higherKey(2) - Strictly less/greater → 1 and 3
💡 Use Case: Perfect for "find nearest available time slot" or "find closest price point" problems!

// ========== Entry Navigation ==========
map.firstEntry();     // 1=one (smallest entry)
map.lastEntry();      // 3=three (largest entry)
map.pollFirstEntry(); // Remove and return smallest
map.pollLastEntry();  // Remove and return largest

📋 Entry Navigation: Get both key AND value together!

Understanding the difference:
firstEntry() returns the entire entry (1="one") - you get both key and value
firstKey() returns just the key (1) - no value included
pollFirstEntry() = "Give me the smallest entry AND remove it" - like a priority queue!
pollLastEntry() = "Give me the largest entry AND remove it"
💡 Pattern: Use poll* methods to process elements in sorted order (like merging K sorted lists)!

// ========== Common Interview Pattern ==========
// Maintain sorted frequency count
TreeMap freqMap = new TreeMap<>();
freqMap.merge(key, 1, Integer::sum);  // Increment count

// All operations are O(log n)!

🎯 Interview Pattern - Frequency Counting: A must-know technique!

The merge() method explained:
merge(key, 1, Integer::sum) means: "If key exists, add 1 to its value. If not, set it to 1"
• This is cleaner than: map.put(key, map.getOrDefault(key, 0) + 1)
• TreeMap keeps counts sorted by key - useful for finding min/max frequency
💼 Real Interview Problems:
• Calendar scheduling (find overlapping meetings)
• Interval merging (sorted start/end times)
• Skyline problem, meeting rooms II

Sorted by Keys
Natural key ordering
Key-Value Pairs
Like HashMap but sorted
Range Operations
subMap, headMap, tailMap

When to Use TreeMap: Use when you need a sorted map with floor/ceiling lookups. Common in problems involving intervals, calendar events, or maintaining sorted frequency counts. Use HashMap if you don't need key ordering (O(1) average).

Quick Reference: When to Use What?
Data Structure Best For Time Complexity
AVL / Red-Black Balanced BST, guaranteed O(log n) in worst case O(log n) all ops
Heap / PriorityQueue Priority Queue, Kth largest/smallest, top-K problems O(log n) insert/extract
Segment Tree Range queries (sum/min/max) with point updates O(log n) query/update
Trie Prefix search, autocomplete, dictionary lookup O(m) word length
TreeSet / TreeMap Sorted collections with floor/ceiling operations O(log n) all ops

Practice Problems

Master these classic problems to solidify your understanding of advanced tree structures!

Heap Problems
Kth Largest Element in Stream

Maintain a min-heap of size k. When new element comes, add it and remove smallest if size > k. Top of heap is always kth largest!

Easy
Top K Frequent Elements

Count frequencies with HashMap, then use min-heap of size k. Keep only top k frequent elements. Alternative: bucket sort for O(n).

Medium
Find Median from Data Stream

Use TWO heaps: maxHeap for lower half, minHeap for upper half. Balance sizes. Median is from heap tops!

Hard
Merge K Sorted Lists

Put first node from each list into min-heap. Poll smallest, add its next to heap. Repeat until heap empty.

Hard
Trie Problems
Implement Trie (Prefix Tree)

Classic implementation: insert(), search(), startsWith(). Each node has 26 children array and isEndOfWord flag.

Medium
Search Suggestions System

Build trie from products. For each prefix typed, traverse trie and collect up to 3 words in lexicographic order.

Medium
Add and Search Words with '.'

Modified trie search: when encountering '.', try all 26 children recursively. Normal character = normal traversal.

Medium
Word Search II (Board)

Build trie from word list. DFS on board while walking through trie. Pruning: if current path not in trie, backtrack immediately.

Hard
Segment Tree Problems
Range Sum Query - Mutable

Classic segment tree application: update single element, query sum of range. Both O(log n). Build tree O(n).

Medium
Count of Range Sum

Use prefix sums + segment tree to count subarrays with sum in [lower, upper]. Merge sort approach also works.

Hard
Pro Tip: For heap problems, ask "Do I need quick access to min or max?" For trie problems, ask "Am I dealing with prefixes or patterns?" For segment tree, ask "Do I need range queries with updates?"

Interactive: DFS vs BFS Tree Traversal

Watch how Depth-First Search (DFS) and Breadth-First Search (BFS) traverse the same binary tree differently. DFS explores as deep as possible before backtracking, while BFS explores level by level. The animation runs automatically — observe the stack vs queue behavior!

DFS vs BFS Tree Traversal Comparison Auto Step-by-Step
Binary Tree Structure
1 2 3 4 5 6 7 8
Unvisited Currently Processing Visited
DFS (Stack-Based)

Strategy: Go as deep as possible, then backtrack. Uses a Stack (LIFO - Last In First Out).

Stack State:
Empty
Current Step:
Waiting to start...
Visited Order:
None yet
BFS (Queue-Based)

Strategy: Explore level by level like ripples in water. Uses a Queue (FIFO - First In First Out).

Queue State:
Empty
Current Step:
Waiting to start...
Visited Order:
None yet
Step: 0 / 8
DFS vs BFS: Complete Comparison
Aspect DFS (Depth-First) BFS (Breadth-First)
Data Structure Stack (or recursion's call stack) Queue
Strategy Go deep first, explore one branch completely before backtracking Go wide first, explore all neighbors at current level before going deeper
Memory Usage O(h) — height of tree
More memory-efficient for balanced trees
O(w) — max width of tree
Can use more memory for wide trees
Time Complexity O(V + E) O(V + E)
Shortest Path? No
Doesn't guarantee shortest path
Yes
Finds shortest path in unweighted graphs!
Ideal For
  • Detecting cycles
  • Topological sorting
  • Path finding (any path)
  • Solving mazes
  • Tree traversals (inorder, preorder, postorder)
  • Shortest path (unweighted)
  • Level-order traversal
  • Finding connected components
  • Social network analysis
  • Web crawling
Real-World Analogy Exploring a maze: go as far as you can, hit a dead end, backtrack Ripples in water: spreading out equally in all directions
Use DFS When...
  • You need to explore all possibilities (backtracking)
  • The solution is far from the root
  • You want to detect cycles
  • You need topological ordering
  • Memory is limited (uses less space for tall, narrow trees)
Use BFS When...
  • You need the shortest path (unweighted graphs)
  • The solution is close to the root
  • You need level-order traversal
  • You want to find minimum steps
  • The tree/graph is very deep but not wide
DFS Tree Traversal Types:
Preorder: Root → Left → Right
Visit before going to children
Inorder: Left → Root → Right
Visit between children (BST → sorted!)
Postorder: Left → Right → Root
Visit after children (good for deletion)

Key Takeaways

AVL = Strict Balance

Balance factor ≤ 1. Uses rotations to maintain balance.

Heap = Priority

Array-based complete binary tree. Use PriorityQueue in Java.

Segment Tree = Range

O(log n) range queries and point updates.

Trie = Prefix

O(m) operations. Perfect for autocomplete and spell check.

Knowledge Check

Question 1 of 6

What is the maximum allowed balance factor in an AVL tree?

Question 2 of 6

What is the time complexity of range query in Segment Tree?

Question 3 of 6

Which data structure is best for prefix-based string search?

Question 4 of 6

What backs Java's TreeMap implementation?

Question 5 of 6

How much extra space does a Segment Tree require for an array of n elements?

Question 6 of 6

What is the time complexity to search for a word in a Trie?