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.
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.
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
Unlike regular BST, these are guaranteed — never degrades to O(n)!
Left Rotation
RR Case FixWhen 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.
Right Rotation
LL Case FixWhen 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.
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!
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.
// 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!
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!
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!
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!
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.
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)!
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 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).
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).
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 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.
After removing 1, the last element (5) goes to root. Then 5 "sinks down" by swapping with 2 (smaller child). Heap property restored!
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'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!
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.
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.
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!
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!
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.
// 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 [1,2] hits partial overlap at root → recurse both children. Left gives 3, right gives 5. Sum = 8. Only O(log n) nodes visited!
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.
// 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)!
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).
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!
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!
children[c - 'a']
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.
Words sharing prefix ("app") reuse the same path. The ★ marks where complete words end. "app" and "apple" share a→p→p, then diverge.
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".
// 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 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 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!
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.
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!
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.
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.
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 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
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!
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!
EasyTop 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).
MediumFind Median from Data Stream
Use TWO heaps: maxHeap for lower half, minHeap for upper half. Balance sizes. Median is from heap tops!
HardMerge K Sorted Lists
Put first node from each list into min-heap. Poll smallest, add its next to heap. Repeat until heap empty.
HardImplement Trie (Prefix Tree)
Classic implementation: insert(), search(), startsWith(). Each node has 26 children array and isEndOfWord flag.
MediumSearch Suggestions System
Build trie from products. For each prefix typed, traverse trie and collect up to 3 words in lexicographic order.
MediumAdd and Search Words with '.'
Modified trie search: when encountering '.', try all 26 children recursively. Normal character = normal traversal.
MediumWord Search II (Board)
Build trie from word list. DFS on board while walking through trie. Pruning: if current path not in trie, backtrack immediately.
HardRange Sum Query - Mutable
Classic segment tree application: update single element, query sum of range. Both O(log n). Build tree O(n).
MediumCount of Range Sum
Use prefix sums + segment tree to count subarrays with sum in [lower, upper]. Merge sort approach also works.
HardInteractive: 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!
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
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
What is the maximum allowed balance factor in an AVL tree?
What is the time complexity of range query in Segment Tree?
Which data structure is best for prefix-based string search?
What backs Java's TreeMap implementation?
How much extra space does a Segment Tree require for an array of n elements?
What is the time complexity to search for a word in a Trie?