Tree Fundamentals
CEO ← root
/ \
CTO CFO ← children of root
/ \ \
Dev QA Acct ← leaves
| Term | Definition |
| Root | Top node, no parent |
| Node | Element storing data |
| Edge | Link between parent & child |
| Parent | Node directly above |
| Child | Node directly below |
| Leaf | Node with no children |
| Depth | Distance from root (root = 0) |
| Height | Longest path to a leaf |
| Subtree | A node + all its descendants |
| Type | Description |
| Binary | At most 2 children per node |
| BST | Binary + left < parent < right |
| Balanced BST | Height = O(log n) |
| Complete | All levels full except possibly last (filled left) |
| Full | Every node has 0 or 2 children |
| Perfect | All internal nodes have 2 children, all leaves same level |
| N-ary | Up to N children per node |
| Trie | Prefix tree for strings |
| Segment Tree | Range queries on arrays |
| Heap | Complete tree with heap property |
- Hierarchical parent-child structure — no cycles
- n nodes = n − 1 edges
- Exactly one path between any two nodes
Full Complete Perfect Degenerate
1 1 1 1
/ \ / \ / \ \
2 3 2 3 2 3 2
/ \ / \ / \ / \ \
4 5 4 5 4 5 6 7 3
\
4
class TreeNode(
var `val`: Int,
var left: TreeNode? = null,
var right: TreeNode? = null
)
| Property | Formula |
| Max nodes at level d | 2d |
| Max nodes height h | 2(h+1) − 1 |
| Min height | floor(log₂(n)) |
| Shape | Height | Search |
| Balanced | O(log n) | O(log n) |
| Degenerate | O(n) | O(n) |
- Each node has at most 2 children
- Height determines operation complexity
- Balanced BSTs guarantee O(log n) operations
TreeNode on JVM (~40 bytes)
┌─────────────────────┐
│ header 16 bytes │
│ val: Int 4 bytes │
│ left: ref 8 bytes │
│ right: ref 8 bytes │
│ padding 4 bytes │
└─────────────────────┘
Array representation (complete binary tree)
Index: 0 1 2 3 4 5 6
Value: [1, 2, 3, 4, 5, 6, 7]
Parent of i → (i - 1) / 2
Left child of i → 2 * i + 1
Right child of i→ 2 * i + 2
- Pointer-based representation for general trees
- Array representation for heaps & segment trees
- ~40 bytes per node on JVM
Traversals
1
/ \
2 3
/ \ \
4 5 6
Inorder (L, Root, R): 4, 2, 5, 1, 3, 6
Preorder (Root, L, R): 1, 2, 4, 5, 3, 6
Postorder (L, R, Root): 4, 5, 2, 6, 3, 1
// Inorder: left → root → right
fun inorder(node: TreeNode?, result: MutableList<Int>) {
if (node == null) return
inorder(node.left, result)
result.add(node.`val`)
inorder(node.right, result)
}
// Preorder: root → left → right
fun preorder(node: TreeNode?, result: MutableList<Int>) {
if (node == null) return
result.add(node.`val`)
preorder(node.left, result)
preorder(node.right, result)
}
// Postorder: left → right → root
fun postorder(node: TreeNode?, result: MutableList<Int>) {
if (node == null) return
postorder(node.left, result)
postorder(node.right, result)
result.add(node.`val`)
}
| Order | When to Use |
| Inorder | BST sorted order |
| Preorder | Serialize / copy tree |
| Postorder | Delete tree / compute heights |
- Name tells when the root is processed (in=middle, pre=first, post=last)
- Inorder traversal of a BST produces sorted order
fun levelOrder(root: TreeNode?): List<Int> {
if (root == null) return emptyList()
val result = mutableListOf<Int>()
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
while (queue.isNotEmpty()) {
val node = queue.poll()
result.add(node.`val`)
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
return result
}
// Grouped by level
fun levelOrderGrouped(root: TreeNode?): List<List<Int>> {
if (root == null) return emptyList()
val result = mutableListOf<List<Int>>()
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
while (queue.isNotEmpty()) {
val size = queue.size // snapshot!
val level = mutableListOf<Int>()
repeat(size) {
val node = queue.poll()
level.add(node.`val`)
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
result.add(level)
}
return result
}
| Aspect | DFS | BFS |
| Data structure | Stack (recursion) | Queue |
| Space | O(h) | O(w) |
| Best for | Path problems | Level / distance queries |
- Queue-based — process nodes level by level
- Snapshot
queue.size before inner loop for level grouping
- Use BFS for level-based or shortest-distance queries
// Inorder iterative
fun inorderIterative(root: TreeNode?): List<Int> {
val result = mutableListOf<Int>()
val stack = ArrayDeque<TreeNode>()
var cur = root
while (cur != null || stack.isNotEmpty()) {
while (cur != null) {
stack.addFirst(cur)
cur = cur.left // push all left
}
cur = stack.removeFirst()
result.add(cur.`val`) // visit
cur = cur.right // go right
}
return result
}
// Preorder iterative
fun preorderIterative(root: TreeNode?): List<Int> {
if (root == null) return emptyList()
val result = mutableListOf<Int>()
val stack = ArrayDeque<TreeNode>()
stack.addFirst(root)
while (stack.isNotEmpty()) {
val node = stack.removeFirst()
result.add(node.`val`)
node.right?.let { stack.addFirst(it) }
node.left?.let { stack.addFirst(it) }
}
return result
}
// Postorder iterative (modified preorder + reverse)
fun postorderIterative(root: TreeNode?): List<Int> {
if (root == null) return emptyList()
val result = LinkedList<Int>()
val stack = ArrayDeque<TreeNode>()
stack.addFirst(root)
while (stack.isNotEmpty()) {
val node = stack.removeFirst()
result.addFirst(node.`val`)
node.left?.let { stack.addFirst(it) }
node.right?.let { stack.addFirst(it) }
}
return result
}
- Avoids StackOverflowError on deep / skewed trees
- All three use O(h) space
fun morrisInorder(root: TreeNode?): List<Int> {
val result = mutableListOf<Int>()
var cur = root
while (cur != null) {
if (cur.left == null) {
result.add(cur.`val`)
cur = cur.right
} else {
var pred = cur.left!!
while (pred.right != null && pred.right != cur)
pred = pred.right!!
if (pred.right == null) {
pred.right = cur // create thread
cur = cur.left
} else {
pred.right = null // remove thread
result.add(cur.`val`)
cur = cur.right
}
}
}
return result
}
| Pros | Cons |
| O(1) extra space | Temporarily modifies tree |
| O(n) time | More complex logic |
- Uses temporary threaded links to ancestors
- O(n) time, O(1) space — no stack or recursion
- Not thread-safe — modifies tree during traversal
Binary Search Trees
Valid BST Invalid BST
8 8
/ \ / \
3 10 3 10
/ \ \ / \ \
1 6 14 1 9 14
9 > 8 ✗
Search for 6:
8 → go left
/
3 → go right
\
6 ✓ found!
| Operation | BST (balanced) | BST (unbalanced) | Sorted Array |
| Search | O(log n) | O(n) | O(log n) |
| Insert | O(log n) | O(n) | O(n) |
| Delete | O(log n) | O(n) | O(n) |
- Left subtree < parent < right subtree
- Inorder traversal = sorted order
- Enables binary search on a dynamic data structure
// Search (iterative)
fun searchBST(root: TreeNode?, target: Int): TreeNode? {
var node = root
while (node != null) {
node = when {
target < node.`val` -> node.left
target > node.`val` -> node.right
else -> return node
}
}
return null
}
// Insert
fun insertBST(root: TreeNode?, v: Int): TreeNode {
if (root == null) return TreeNode(v)
if (v < root.`val`) root.left = insertBST(root.left, v)
else root.right = insertBST(root.right, v)
return root
}
// Delete (3 cases)
fun deleteBST(root: TreeNode?, key: Int): TreeNode? {
if (root == null) return null
if (key < root.`val`) root.left = deleteBST(root.left, key)
else if (key > root.`val`) root.right = deleteBST(root.right, key)
else {
if (root.left == null) return root.right // case 1 & 2
if (root.right == null) return root.left
val succ = findMin(root.right!!) // case 3
root.`val` = succ.`val`
root.right = deleteBST(root.right, succ.`val`)
}
return root
}
Delete cases:
Leaf: remove directly
One child: replace with child
Two children: replace with inorder successor
(smallest in right subtree)
fun findMin(node: TreeNode): TreeNode {
var cur = node
while (cur.left != null) cur = cur.left!!
return cur
}
fun findMax(node: TreeNode): TreeNode {
var cur = node
while (cur.right != null) cur = cur.right!!
return cur
}
- All operations are O(h) — O(log n) balanced, O(n) skewed
- Delete with two children: replace with inorder successor (smallest in right subtree)
// Correct: check with min/max bounds
fun isValidBST(root: TreeNode?): Boolean {
fun validate(node: TreeNode?, min: Long, max: Long): Boolean {
if (node == null) return true
if (node.`val` <= min || node.`val` >= max) return false
return validate(node.left, min, node.`val`.toLong()) &&
validate(node.right, node.`val`.toLong(), max)
}
return validate(root, Long.MIN_VALUE, Long.MAX_VALUE)
}
// WRONG: only checks immediate children
fun isValidBSTWrong(node: TreeNode?): Boolean {
if (node == null) return true
if (node.left != null && node.left!!.`val` >= node.`val`) return false
// ✗ Misses: grandchild could violate ancestor constraint
return isValidBSTWrong(node.left) && isValidBSTWrong(node.right)
}
- Must check ENTIRE subtree constraint, not just parent-child
- Use Long for min/max bounds to avoid Int overflow
AVL Rotations:
Right (LL) Left (RR) Left-Right (LR) Right-Left (RL)
z z z z
/ \ / \
y y y y
/ \ \ /
x x x x
↓ ↓ ↓ ↓
y y y y
/ \ / \ / \ / \
x z z x x z z x
| Property | AVL | Red-Black |
| Balance | Strictly balanced (|bf| ≤ 1) | Loosely balanced |
| Height | ~1.44 log n | ~2 log n |
| Rotations on insert | Up to 2 | Up to 2 |
| Rotations on delete | Up to O(log n) | Up to 3 |
| Used in | Databases, lookups | Java TreeMap, Linux CFS |
- AVL: strictly balanced — faster lookups, more rotations on mutation
- Red-Black: loosely balanced — fewer rotations, better for insert-heavy
- Java TreeMap / TreeSet use Red-Black trees internally
// TreeMap — sorted key-value store
val map = TreeMap<Int, String>()
map[5] = "five"; map[2] = "two"; map[8] = "eight"
map.firstKey() // 2
map.lastKey() // 8
map.higherKey(5) // 8 (strictly greater)
map.lowerKey(5) // 2 (strictly less)
map.ceilingKey(5) // 5 (≥)
map.floorKey(5) // 5 (≤)
map.subMap(2, 8) // {2=two, 5=five} [2, 8)
map.headMap(5) // {2=two} [first, 5)
map.tailMap(5) // {5=five, 8=eight} [5, last]
// TreeSet — sorted set
val set = TreeSet<Int>()
set.addAll(listOf(5, 2, 8, 1))
set.first() // 1
set.last() // 8
set.higher(2) // 5
set.lower(5) // 2
set.ceiling(3) // 5
set.floor(3) // 2
set.subSet(2, 8) // [2, 5]
- Red-Black tree internally — all operations O(log n)
- Navigation ops: first/last, higher/lower, ceiling/floor
- Kotlin:
sortedSetOf() / sortedMapOf() or use TreeMap/TreeSet directly
Algorithm Patterns
Iterative Traversals → DFS with explicit stack (see Stacks page) |
BFS / Level-Order → queue-based traversal (see Queues page) |
Flatten to Linked List → tree to linked list conversion (see Linked Lists page)
fun maxDepth(root: TreeNode?): Int {
if (root == null) return 0
return maxOf(maxDepth(root.left), maxDepth(root.right)) + 1
}
fun minDepth(root: TreeNode?): Int {
if (root == null) return 0
val l = minDepth(root.left)
val r = minDepth(root.right)
if (l == 0 || r == 0) return l + r + 1 // single-child case
return minOf(l, r) + 1
}
fun minDepthBFS(root: TreeNode?): Int {
if (root == null) return 0
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
var depth = 0
while (queue.isNotEmpty()) {
depth++
repeat(queue.size) {
val node = queue.poll()
if (node.left == null && node.right == null) return depth
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
}
return depth
}
When to Use
maximum depthWhy: Classic bottom-up: max(left, right) + 1. Base case null → 0. Every tree height question uses this.
minimum depthWhy: Shortest root-to-leaf path. Must handle single-child nodes (not leaves). BFS is more efficient — stops at first leaf.
balanced tree checkWhy: Height at each node feeds into |left - right| ≤ 1 check. Combines depth with validation.
count nodes at depthWhy: Depth calculation is the foundation. Level = depth + 1. Count by tracking depth during traversal.
- maxDepth is simple recursion: max(left, right) + 1
- minDepth must handle nodes with only one child
- BFS finds min depth faster — stops at first leaf
▶ Visualize
fun diameterOfBinaryTree(root: TreeNode?): Int {
var maxDiam = 0
fun height(node: TreeNode?): Int {
if (node == null) return 0
val l = height(node.left)
val r = height(node.right)
maxDiam = maxOf(maxDiam, l + r)
return maxOf(l, r) + 1
}
height(root)
return maxDiam
}
When to Use
diameter of binary treeWhy: Longest path between any two nodes. At each node: height(left) + height(right). Track global max — path may not go through root.
longest path in treeWhy: Same as diameter. Bottom-up height calculation with global max tracking.
farthest nodesWhy: Diameter = farthest pair. One DFS computes all heights and tracks max left+right sum.
- At each node, diameter through it = height(left) + height(right)
- May not pass through root — track global max
▶ Visualize
fun isBalanced(root: TreeNode?): Boolean {
fun check(node: TreeNode?): Int {
if (node == null) return 0
val l = check(node.left); if (l == -1) return -1
val r = check(node.right); if (r == -1) return -1
if (Math.abs(l - r) > 1) return -1
return maxOf(l, r) + 1
}
return check(root) != -1
}
fun isSymmetric(root: TreeNode?): Boolean {
fun isMirror(a: TreeNode?, b: TreeNode?): Boolean {
if (a == null && b == null) return true
if (a == null || b == null) return false
return a.`val` == b.`val` &&
isMirror(a.left, b.right) &&
isMirror(a.right, b.left)
}
return isMirror(root?.left, root?.right)
}
When to Use
check if balancedWhy: Return height or -1 (unbalanced). One DFS, O(n). Avoids the naive O(n²) of computing height separately at each node.
symmetric / mirror treeWhy: Compare left subtree with mirror of right. Recursive: left.left↔right.right AND left.right↔right.left.
check if completeWhy: BFS level-order: once a non-full node is seen, all subsequent nodes must be leaves.
- Balanced check returns -1 to short-circuit — avoids O(n²)
- Symmetric compares left.left↔right.right and left.right↔right.left
▶ Visualize
fun invertTree(root: TreeNode?): TreeNode? {
if (root == null) return null
val tmp = root.left
root.left = invertTree(root.right)
root.right = invertTree(tmp)
return root
}
// Iterative BFS version
fun invertTreeIterative(root: TreeNode?): TreeNode? {
if (root == null) return null
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
while (queue.isNotEmpty()) {
val node = queue.poll()
val tmp = node.left
node.left = node.right
node.right = tmp
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
return root
}
When to Use
invert binary treeWhy: Swap left↔right at every node, recurse. The canonical "simple tree recursion" interview question.
mirror a treeWhy: Identical to invert. Create mirror image for comparison or display purposes.
flip equivalent treesWhy: Two trees are flip-equivalent if one can become the other by swapping children at some nodes. Recursive check with both swap/no-swap options.
- Swap left↔right recursively at every node
- Iterative version uses queue — same O(n) time
▶ Visualize
// Path Sum I: root-to-leaf
fun hasPathSum(root: TreeNode?, target: Int): Boolean {
if (root == null) return false
if (root.left == null && root.right == null)
return root.`val` == target
val rem = target - root.`val`
return hasPathSum(root.left, rem) || hasPathSum(root.right, rem)
}
// Path Sum II: all root-to-leaf paths (backtracking)
fun pathSum(root: TreeNode?, target: Int): List<List<Int>> {
val result = mutableListOf<List<Int>>()
fun dfs(node: TreeNode?, rem: Int, path: MutableList<Int>) {
if (node == null) return
path.add(node.`val`)
if (node.left == null && node.right == null && rem == node.`val`)
result.add(ArrayList(path))
dfs(node.left, rem - node.`val`, path)
dfs(node.right, rem - node.`val`, path)
path.removeAt(path.size - 1) // backtrack
}
dfs(root, target, mutableListOf())
return result
}
// Path Sum III: any-start/end (prefix sum + HashMap)
fun pathSumIII(root: TreeNode?, target: Long): Int {
val prefixMap = HashMap<Long, Int>()
prefixMap[0L] = 1
var count = 0
fun dfs(node: TreeNode?, curSum: Long) {
if (node == null) return
val sum = curSum + node.`val`
count += prefixMap.getOrDefault(sum - target, 0)
prefixMap[sum] = prefixMap.getOrDefault(sum, 0) + 1
dfs(node.left, sum)
dfs(node.right, sum)
prefixMap[sum] = prefixMap[sum]!! - 1 // backtrack
}
dfs(root, 0L)
return count
}
When to Use
path sum (root to leaf)Why: Pass remaining target down. At leaf: check if remaining == 0. Classic top-down parameter passing.
all paths with target sumWhy: Same as path sum but collect ALL paths. Backtrack: add node to path, recurse, remove after.
path sum iii (any start/end)Why: Prefix sum + HashMap on tree paths. If currentSum - target exists in map → valid subpath. Backtrack map entry after subtree.
binary tree max path sumWhy: Related pattern: at each node compute best path through it (left + right + node). Report one branch up to parent.
sum root to leaf numbersWhy: Each root-to-leaf path forms a number. Pass accumulated value × 10 + node.val down. Sum at leaves.
- Path Sum III uses prefix sum + HashMap (like subarray sum)
- Backtrack by removing last element after exploring children
▶ Visualize
// Binary tree LCA — O(n)
fun lowestCommonAncestor(
root: TreeNode?, p: TreeNode, q: TreeNode
): TreeNode? {
if (root == null || root == p || root == q) return root
val left = lowestCommonAncestor(root.left, p, q)
val right = lowestCommonAncestor(root.right, p, q)
if (left != null && right != null) return root
return left ?: right
}
// BST LCA — O(h)
fun lcaBST(root: TreeNode?, p: TreeNode, q: TreeNode): TreeNode? {
var node = root
while (node != null) {
node = when {
p.`val` < node.`val` && q.`val` < node.`val` -> node.left
p.`val` > node.`val` && q.`val` > node.`val` -> node.right
else -> return node // split point
}
}
return null
}
When to Use
lowest common ancestorWhy: If p in left subtree and q in right → current node is LCA. If both same side → LCA is deeper. O(n) for binary tree.
lca in bstWhy: BST ordering: if both < node go left, both > go right, else split point = LCA. O(h) — much faster than general LCA.
distance between two nodesWhy: Find LCA, then dist(p,q) = dist(root,p) + dist(root,q) - 2×dist(root,LCA). LCA is the key step.
lca with parent pointersWhy: Like intersection of two linked lists: walk both pointers up, they meet at LCA. O(h) time, O(1) space.
- Binary tree LCA is O(n) — must check both subtrees
- BST LCA is O(h) — follow the split point where p and q diverge
▶ Visualize
class Codec {
fun serialize(root: TreeNode?): String {
val sb = StringBuilder()
fun preorder(node: TreeNode?) {
if (node == null) { sb.append("#,"); return }
sb.append("${node.`val`},")
preorder(node.left)
preorder(node.right)
}
preorder(root)
return sb.toString()
}
fun deserialize(data: String): TreeNode? {
val queue: Queue<String> = LinkedList(data.split(","))
fun build(): TreeNode? {
val token = queue.poll() ?: return null
if (token == "#") return null
val node = TreeNode(token.toInt())
node.left = build()
node.right = build()
return node
}
return build()
}
}
When to Use
serialize / deserialize treeWhy: Convert tree ↔ string for storage, transmission, or comparison. Preorder + null markers preserves structure completely.
encode n-ary as binaryWhy: Serialize N-ary to string, deserialize as binary (left-child right-sibling). Same serialize/deserialize pattern.
tree comparison via stringWhy: Serialize both trees, compare strings. Useful for subtree matching — is serialized(t) a substring of serialized(s)?
- Preorder with null markers (#) — BFS variant also works
- Deserialize consumes tokens from queue in same order
▶ Visualize
fun buildTreeFromPreIn(
preorder: IntArray, inorder: IntArray
): TreeNode? {
val inMap = HashMap<Int, Int>()
inorder.forEachIndexed { i, v -> inMap[v] = i }
var preIdx = 0
fun build(lo: Int, hi: Int): TreeNode? {
if (lo > hi) return null
val rootVal = preorder[preIdx++]
val node = TreeNode(rootVal)
val mid = inMap[rootVal]!!
node.left = build(lo, mid - 1)
node.right = build(mid + 1, hi)
return node
}
return build(0, inorder.size - 1)
}
When to Use
build from preorder + inorderWhy: Preorder[0] = root. Find root in inorder → everything left = left subtree, right = right subtree. Recurse. O(n) with HashMap.
build from inorder + postorderWhy: Postorder[last] = root. Same split logic but process right subtree first (postorder visits right before root).
build from preorder + postorderWhy: Only unique for full binary trees. Preorder[1] = left root, find it in postorder to determine left subtree size.
- Preorder's first element = root, find root in inorder to split left/right
- HashMap for O(1) index lookup in inorder array
▶ Visualize
fun rightSideView(root: TreeNode?): List<Int> {
if (root == null) return emptyList()
val result = mutableListOf<Int>()
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
while (queue.isNotEmpty()) {
val size = queue.size
repeat(size) { i ->
val node = queue.poll()
if (i == size - 1) result.add(node.`val`)
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
}
return result
}
fun zigzagLevelOrder(root: TreeNode?): List<List<Int>> {
if (root == null) return emptyList()
val result = mutableListOf<List<Int>>()
val queue: Queue<TreeNode> = LinkedList()
queue.add(root)
var leftToRight = true
while (queue.isNotEmpty()) {
val level = LinkedList<Int>()
repeat(queue.size) {
val node = queue.poll()
if (leftToRight) level.addLast(node.`val`)
else level.addFirst(node.`val`)
node.left?.let { queue.add(it) }
node.right?.let { queue.add(it) }
}
result.add(level)
leftToRight = !leftToRight
}
return result
}
When to Use
right side viewWhy: BFS: last node in each level is the rightmost visible. Or DFS right-first: first node at each new depth is the answer.
zigzag level-orderWhy: BFS level-by-level, reverse every other level. Queue.size snapshot separates levels cleanly.
bottom-up level-orderWhy: Standard level-order then reverse the result list. Or collect levels and return reversed.
largest value in each rowWhy: BFS level snapshot: track max per level. Single pass, no sorting needed.
- BFS with level size snapshot — take last node per level for right side view
- DFS alternative: visit right subtree first, take first node at each new depth
▶ Visualize
fun flatten(root: TreeNode?) {
var cur = root
while (cur != null) {
if (cur.left != null) {
// Find rightmost of left subtree
var pred = cur.left!!
while (pred.right != null) pred = pred.right!!
// Connect to right subtree
pred.right = cur.right
// Move left to right
cur.right = cur.left
cur.left = null
}
cur = cur.right
}
}
When to Use
flatten to linked listWhy: Convert tree to right-pointer-only chain in preorder. Morris-style: find rightmost of left subtree, link to right subtree. O(1) space.
tree to doubly linked listWhy: Inorder traversal linking prev↔current. Similar flatten but with bidirectional links.
preorder without recursion/stackWhy: Flatten first, then iterate right pointers. Trades tree structure for simple iteration.
- Find rightmost node of left subtree, connect to right subtree
- Move left subtree to right, nullify left — O(1) space
▶ Visualize
fun kthSmallest(root: TreeNode?, k: Int): Int {
val stack = ArrayDeque<TreeNode>()
var cur = root
var count = 0
while (cur != null || stack.isNotEmpty()) {
while (cur != null) { stack.addFirst(cur); cur = cur.left }
cur = stack.removeFirst()
if (++count == k) return cur.`val`
cur = cur.right
}
return -1
}
class BSTIterator(root: TreeNode?) {
private val stack = ArrayDeque<TreeNode>()
init { pushLeft(root) }
private fun pushLeft(node: TreeNode?) {
var n = node
while (n != null) { stack.addFirst(n); n = n.left }
}
fun hasNext(): Boolean = stack.isNotEmpty()
fun next(): Int {
val node = stack.removeFirst()
pushLeft(node.right)
return node.`val`
}
}
When to Use
kth smallest in bstWhy: Inorder traversal of BST = sorted order. Stop at kth element. O(h + k) time.
bst iteratorWhy: Controlled inorder using a stack: pushAllLeft, then pop+process+pushAllLeft(right). O(h) space, O(1) amortized next().
kth largest in bstWhy: Reverse inorder (right→root→left). Stop at kth element. Same technique, opposite direction.
closest value in bstWhy: Binary search down the tree tracking closest so far. O(h) — no need for full traversal.
- Inorder traversal stops at kth element — no need to visit all nodes
- BST iterator uses controlled inorder with stack — O(h) space, O(1) amortized next()
▶ Visualize
fun maxPathSum(root: TreeNode?): Int {
var maxSum = Int.MIN_VALUE
fun gain(node: TreeNode?): Int {
if (node == null) return 0
val l = maxOf(0, gain(node.left)) // ignore negatives
val r = maxOf(0, gain(node.right))
maxSum = maxOf(maxSum, l + r + node.`val`) // use both
return maxOf(l, r) + node.`val` // contribute one
}
gain(root)
return maxSum
}
fun goodNodes(root: TreeNode?): Int {
fun dfs(node: TreeNode?, maxSoFar: Int): Int {
if (node == null) return 0
val good = if (node.`val` >= maxSoFar) 1 else 0
val newMax = maxOf(maxSoFar, node.`val`)
return good + dfs(node.left, newMax) + dfs(node.right, newMax)
}
return dfs(root, Int.MIN_VALUE)
}
When to Use
binary tree max path sumWhy: At each node: pathSum = val + leftGain + rightGain (both branches). Report upward = val + max(left, right) (one branch). Track global max.
count good nodesWhy: Pass maxSoFar top-down. Node is "good" if its value ≥ maxSoFar on the path from root.
longest univalue pathWhy: Similar to diameter: at each node compute matching-value path through it. Track global max. Bottom-up return.
house robber iiiWhy: DP on tree: each node returns (rob, skip). Rob = val + skip(left) + skip(right). Skip = max of children's rob/skip.
- Max path sum: node can use both branches locally but only contributes one to parent
- Ignore negative gains — maxOf(0, gain) to prune bad paths
▶ Visualize
fun isSameTree(p: TreeNode?, q: TreeNode?): Boolean {
if (p == null && q == null) return true
if (p == null || q == null) return false
return p.`val` == q.`val` &&
isSameTree(p.left, q.left) &&
isSameTree(p.right, q.right)
}
fun isSubtree(root: TreeNode?, sub: TreeNode?): Boolean {
if (root == null) return sub == null
return isSameTree(root, sub) ||
isSubtree(root.left, sub) ||
isSubtree(root.right, sub)
}
fun mergeTrees(t1: TreeNode?, t2: TreeNode?): TreeNode? {
if (t1 == null) return t2
if (t2 == null) return t1
t1.`val` += t2.`val`
t1.left = mergeTrees(t1.left, t2.left)
t1.right = mergeTrees(t1.right, t2.right)
return t1
}
When to Use
same treeWhy: Recursively compare: both null → true. One null → false. Values equal AND both subtrees same → true. O(min(n,m)).
subtree of another treeWhy: At every node of s, check if subtree rooted there equals t (using isSameTree). O(m×n) brute force, O(m+n) with serialization.
merge two binary treesWhy: Overlapping nodes: sum values. Non-overlapping: take whichever exists. Natural recursive structure.
leaf-similar treesWhy: Collect leaf sequences via DFS on both trees, compare the sequences. Uses same isSameTree building block.
- Same tree is the base comparison for subtree check
- Subtree checks isSameTree at every node — O(m*n) worst case
- Merge sums overlapping node values
▶ Visualize
fun verticalOrder(root: TreeNode?): List<List<Int>> {
if (root == null) return emptyList()
val colMap = TreeMap<Int, MutableList<Int>>()
val queue: Queue<Pair<TreeNode, Int>> = LinkedList()
queue.add(root to 0)
while (queue.isNotEmpty()) {
val (node, col) = queue.poll()
colMap.getOrPut(col) { mutableListOf() }.add(node.`val`)
node.left?.let { queue.add(it to col - 1) }
node.right?.let { queue.add(it to col + 1) }
}
return colMap.values.toList()
}
fun sortedArrayToBST(nums: IntArray): TreeNode? {
fun build(lo: Int, hi: Int): TreeNode? {
if (lo > hi) return null
val mid = lo + (hi - lo) / 2
val node = TreeNode(nums[mid])
node.left = build(lo, mid - 1)
node.right = build(mid + 1, hi)
return node
}
return build(0, nums.size - 1)
}
When to Use
vertical order traversalWhy: Assign column to each node (root=0, left=col-1, right=col+1). BFS + TreeMap groups by column. Sort by row within column.
sorted array to balanced bstWhy: Middle element = root ensures balance. Recurse on left half and right half. O(n), produces minimum-height BST.
sorted list to bstWhy: Same middle-as-root idea but use slow/fast pointers to find middle. Or inorder simulation with advancing pointer.
boundary of binary treeWhy: Left boundary (top-down) + leaves (DFS) + right boundary (bottom-up). Combines vertical/level thinking.
- Vertical order uses column tracking with BFS + TreeMap
- Sorted array → BST picks middle as root recursively
▶ Visualize
Trie for: apple, app, ape, bat, bar
(root)
/ \
a b
/ \
p a
/ \ / \
p e* t* r*
|
l
|
e* (* = end of word)
app* is also end of word
class TrieNode {
val children = arrayOfNulls<TrieNode>(26)
var isEnd = false
}
class Trie {
private val root = TrieNode()
fun insert(word: String) {
var node = root
for (c in word) {
val i = c - 'a'
if (node.children[i] == null) node.children[i] = TrieNode()
node = node.children[i]!!
}
node.isEnd = true
}
fun search(word: String): Boolean {
val node = findNode(word)
return node?.isEnd == true
}
fun startsWith(prefix: String): Boolean =
findNode(prefix) != null
private fun findNode(s: String): TrieNode? {
var node = root
for (c in s) {
node = node.children[c - 'a'] ?: return null
}
return node
}
fun wordsWithPrefix(prefix: String): List<String> {
val result = mutableListOf<String>()
val node = findNode(prefix) ?: return result
fun collect(n: TrieNode, sb: StringBuilder) {
if (n.isEnd) result.add(sb.toString())
for (i in 0..25) {
if (n.children[i] != null) {
sb.append('a' + i)
collect(n.children[i]!!, sb)
sb.deleteCharAt(sb.length - 1)
}
}
}
collect(node, StringBuilder(prefix))
return result
}
}
// HashMap-based for flexible character sets
class TrieMap {
val children = HashMap<Char, TrieMap>()
var isEnd = false
}
When to Use
autocomplete / prefix searchWhy: startsWith(prefix) in O(L). Then DFS from prefix node to enumerate all completions. Foundation of search bars.
word search ii (trie pruning)Why: Build trie from word list, DFS the board following trie edges. Prune branches not in trie — dramatically faster than checking each word.
longest common prefixWhy: Insert all strings, walk trie until a node has >1 child or is end-of-word. Path so far = longest common prefix.
spell checkerWhy: O(L) exact lookup + prefix search. Can also find words within edit distance by exploring adjacent trie branches.
count distinct substringsWhy: Insert all suffixes of string into trie. Number of trie nodes = number of distinct substrings.
maximum xorWhy: Binary trie (bit-by-bit). For each number, greedily take opposite bit at each level to maximize XOR.
- O(L) per operation where L = word length
- Each root-to-node path = a prefix
- Array children (26 slots) — fast but memory-heavy; HashMap children — flexible character set
▶ Visualize
Segment Tree for sum: [1, 3, 5, 7, 9, 11]
[36] (0..5)
/ \
[9] [27] (0..2)(3..5)
/ \ / \
[4] [5] [16] [11] (0..1)(2)(3..4)(5)
/ \ / \
[1][3] [7][9]
class SegmentTree(private val n: Int) {
private val tree = IntArray(4 * n)
fun build(arr: IntArray, node: Int = 1, s: Int = 0, e: Int = n - 1) {
if (s == e) { tree[node] = arr[s]; return }
val mid = (s + e) / 2
build(arr, 2 * node, s, mid)
build(arr, 2 * node + 1, mid + 1, e)
tree[node] = tree[2 * node] + tree[2 * node + 1]
}
fun query(l: Int, r: Int, node: Int = 1, s: Int = 0, e: Int = n - 1): Int {
if (r < s || e < l) return 0
if (l <= s && e <= r) return tree[node]
val mid = (s + e) / 2
return query(l, r, 2 * node, s, mid) +
query(l, r, 2 * node + 1, mid + 1, e)
}
fun update(idx: Int, v: Int, node: Int = 1, s: Int = 0, e: Int = n - 1) {
if (s == e) { tree[node] = v; return }
val mid = (s + e) / 2
if (idx <= mid) update(idx, v, 2 * node, s, mid)
else update(idx, v, 2 * node + 1, mid + 1, e)
tree[node] = tree[2 * node] + tree[2 * node + 1]
}
}
When to Use
range sum queryWhy: Query any range [l,r] in O(log n). Supports sum, min, max, GCD — any associative operation. Build in O(n).
range update + range queryWhy: Lazy propagation: defer updates to children until needed. Both range update and range query in O(log n).
count elements in rangeWhy: Merge-sort tree variant or segment tree with sorted lists at nodes. Count elements in [lo, hi] within index range [l, r].
interval scheduling queriesWhy: Segment tree on timeline: update ranges (add/remove intervals), query point for active count.
- Array of 4n size — build O(n), query O(log n), update O(log n)
- Lazy propagation for efficient range updates
▶ Visualize
class FenwickTree(private val n: Int) {
private val tree = IntArray(n + 1)
fun update(i: Int, delta: Int) {
var idx = i + 1
while (idx <= n) {
tree[idx] += delta
idx += idx and (-idx) // add lowest set bit
}
}
fun prefixSum(i: Int): Int {
var idx = i + 1
var sum = 0
while (idx > 0) {
sum += tree[idx]
idx -= idx and (-idx) // remove lowest set bit
}
return sum
}
fun rangeSum(l: Int, r: Int): Int =
prefixSum(r) - if (l > 0) prefixSum(l - 1) else 0
}
| Aspect | Fenwick | Segment Tree |
| Code | ~15 lines | ~40 lines |
| Space | O(n) | O(4n) |
| Supports | Sum only | Sum, min, max, etc. |
| Range update | Limited | Lazy propagation |
When to Use
prefix sum with point updatesWhy: O(log n) update + O(log n) prefix sum query. Simpler and faster constant than segment tree for sum-only problems.
count inversionsWhy: Process elements right-to-left (or by value), BIT tracks how many smaller elements seen so far. O(n log n).
range sum with updatesWhy: rangeSum(l, r) = prefixSum(r) - prefixSum(l-1). Point update O(log n). Much simpler than segment tree.
count smaller after selfWhy: Process right-to-left, BIT indexed by value. Query prefix sum up to current value = count of smaller elements to the right.
- Simpler than segment tree (~15 lines), O(n) space
- Uses lowest set bit trick:
i & (-i)
- Only supports sum — cannot do min/max like segment tree
▶ Visualize
Common Pitfalls
// ✗ Height vs depth confusion
// Height = edges from node to deepest leaf (bottom-up)
// Depth = edges from root to node (top-down)
// ✗ Forgetting null root check
fun bad(root: TreeNode): Int = root.`val` // NPE if null!
fun good(root: TreeNode?): Int = root?.`val` ?: 0
// ✗ Wrong BST validation (only checking parent-child)
// Must check entire subtree constraint with bounds
// ✗ Integer overflow in BST validation
// Use Long.MIN_VALUE / Long.MAX_VALUE for bounds
// ✗ Forgetting to backtrack in path problems
path.add(node.`val`)
dfs(node.left, path)
dfs(node.right, path)
path.removeAt(path.size - 1) // ← don't forget!
// ✗ === vs == for node identity
// Use === for reference equality (is it the same node?)
// Use == for value equality
// ✗ Stack overflow on skewed trees
// Use iterative traversal for trees that could be deep
// ✗ Modifying tree when you shouldn't
// Some problems expect the original tree unchanged
// ✗ Assuming inorder = sorted for non-BST trees
// Inorder is only sorted for Binary Search Trees
- Always handle null root — most tree functions should accept nullable
- Validate BST with min/max bounds, not just parent-child comparison
- Use Long for BST bounds to avoid Int overflow edge cases
- Always backtrack (remove from path) after exploring children
Quick Reference
| Operation | Balanced BST | Unbalanced BST | Notes |
| Search | O(log n) | O(n) | Binary search on tree |
| Insert | O(log n) | O(n) | Find position + insert |
| Delete | O(log n) | O(n) | Find + reconnect |
| Min / Max | O(log n) | O(n) | Follow left / right |
| Inorder successor | O(log n) | O(n) | Next in sorted order |
| Traversal | Time | Space | Notes |
| DFS (recursive) | O(n) | O(h) | h = log n balanced, n skewed |
| DFS (iterative) | O(n) | O(h) | Explicit stack |
| BFS | O(n) | O(w) | w = max width ≤ n/2 |
| Morris | O(n) | O(1) | Modifies tree temporarily |
- Balanced BSTs (AVL, Red-Black) guarantee O(log n) for all operations
- TreeMap / TreeSet provide O(log n) with navigation operations
| Algorithm | Time | Space |
| Max / Min Depth | O(n) | O(h) |
| Diameter | O(n) | O(h) |
| Balanced / Symmetric | O(n) | O(h) |
| Invert Tree | O(n) | O(h) |
| Path Sum I, II | O(n) | O(h) |
| Path Sum III | O(n) | O(n) |
| LCA (binary tree) | O(n) | O(h) |
| LCA (BST) | O(h) | O(1) |
| Serialize / Deserialize | O(n) | O(n) |
| Construct from traversals | O(n) | O(n) |
| Right Side View / Zigzag | O(n) | O(w) |
| Flatten to Linked List | O(n) | O(1) |
| Kth Smallest / BST Iterator | O(h+k) | O(h) |
| Max Path Sum | O(n) | O(h) |
| Same / Subtree / Merge | O(n) | O(h) |
| Vertical Order | O(n log n) | O(n) |
| Sorted Array → BST | O(n) | O(log n) |
| Trie insert / search | O(L) | O(N·L) |
| Segment Tree query/update | O(log n) | O(n) |
| Fenwick Tree query/update | O(log n) | O(n) |
| Problem Signal | Pattern |
| Depth / height / balanced | DFS return value (bottom-up) |
| Path sum / constraints on path | DFS with parameters (top-down) |
| BST sorted order / kth element | Inorder traversal |
| Level query / shortest distance | BFS level-by-level |
| Serialize / copy / reconstruct | Preorder traversal |
| Delete / compute heights | Postorder traversal |
| Prefix matching / autocomplete | Trie |
| Range sum / min / max query | Segment tree or Fenwick tree |
| Compare two trees | Two-node parallel DFS |
| Any-path sum | Prefix sum on paths (HashMap) |
| BST search / insert / delete | BST binary search (go left / right) |
| Deep / skewed tree | Iterative traversal (avoid stack overflow) |
1. DFS with return value — height, balanced, diameter
2. DFS with parameters — path sum, good nodes, BST validate
3. BFS level-by-level — level order, right view, zigzag
4. BST binary search — search, insert, delete, LCA
5. Build from traversals — preorder+inorder, serialize
6. Two-node comparison — same tree, symmetric, merge
7. Prefix sum on paths — path sum III, HashMap tracking
- Most tree problems are variations of these seven patterns
- Identify the pattern first, then apply the template
- Combine patterns for complex problems (e.g., BFS + HashMap for vertical order)