LC Patterns — Trees & BSTs
The Master Insight (read before the patterns)
A tree is a recursive structure, so almost every tree problem is answered by one question asked at a single node: "What do I need from my children, and what do I hand back to my parent?" Get that contract right and the recursion does the rest. Two directions of information cover nearly everything:
- Bottom-up (returns): children report up, the node combines — heights, sums, "is it balanced," tree DP. Write down what
f(node)returns in one precise sentence before coding — the exact discipline as the DP state sentence. - Top-down (parameters): the parent passes accumulated context down — the current path sum, the allowed value range, the max seen so far on the path.
- Many hard problems need both at once: the value you return to the parent differs from the value you record globally (Pattern 9 — diameter, max path sum). Spotting that split is the single biggest tree-interview skill.
The BST master key: the BST rule (left < node < right, all the way down) gives you two superpowers — an inorder traversal visits the nodes in sorted order, and comparisons let you prune half the tree (O(h) search/insert/delete, the pointer-world twin of binary search). If a BST problem feels hard, ask: "what would I do to a sorted array, and how does inorder or pruning give me that here?"
§0Conventions & Universal Pitfalls
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};- Null is the base case — handle it first.
if (!node) return ⟨identity⟩;opens almost every recursive tree function; the identity (0, true, INT_MIN, nullptr) is a design decision, not boilerplate. - Nodes vs edges: "depth/diameter in nodes" vs "in edges" differ by one; LC 543 counts edges, 104 counts nodes. Decide the unit before coding and write it in a comment (the fencepost bug of this topic).
- Return value ≠ global answer: in diameter/max-path problems,
f(node)returns the best downward arm while the answer (the best arch through any node) updates a captured variable. Mixing them is the #1 hard-tree bug. - BST validation needs ranges, not parent-child checks: comparing each node only to its parent wrongly accepts invalid trees (a right-grandchild smaller than the grandparent). Pass
(lo, hi)bounds down. And uselong longbounds — test values includeINT_MIN/INT_MAX. - BFS level loops must snapshot the queue size:
int sz = q.size(); for (int i = 0; i < sz; ++i)— readingq.size()live while pushing children merges levels together. - Recursion depth: a skewed tree of 10⁴ nodes can overflow the stack — mention the iterative/explicit-stack conversion (and know Morris traversal by name for the O(1)-space follow-up; implement only if asked).
- Smart pointers/new in interviews: raw pointers are fine and expected for LC-style tree code; say so and move on.
1Bottom-Up Aggregates (height, balance, subtree sums)
What it is: Compute a property of every subtree from its children's answers: height = 1 + max(child heights); a tree is balanced when both subtrees are balanced and their heights differ by ≤ 1. One post-order pass, O(n).
Intuition: Post-order (children before self) is the order for bottom-up: by the time you reach a node, its children's answers already exist. The efficiency idiom: fold the check into the height computation — a naive "isBalanced calls height at every node" is O(n²); returning height and balance together (a sentinel -1 = "unbalanced somewhere below") makes it O(n). Combining several return values into one pass is the skill this pattern drills.
Approach:
int check(TreeNode* n) { // returns height, or -1 if unbalanced below
if (!n) return 0;
int l = check(n->left); if (l == -1) return -1;
int r = check(n->right); if (r == -1) return -1;
if (abs(l - r) > 1) return -1;
return 1 + max(l, r);
}
bool isBalanced(TreeNode* root) { return check(root) != -1; }Challenges & pitfalls
the O(n²)-to-O(n) fusion (interviewers accept the naive version then ask "complexity? improve it" — arrive with the fused one); the sentinel must be outside the legal range (heights ≥ 0 ⇒ −1 is free); nodes-vs-edges (§0.2).
3, children 9 (a leaf) and 20, where 20 has children 15 and 7.- Compute heights bottom-up: leaves
9,15,7all have height 1. Node20=1 + max(1, 1) = 2. Root3=1 + max(1, 2) =3. - Max depth 3. ✓ Each node just added 1 to the taller child's height — one post-order pass.
- At
20: child heights 1 and 1, differ by 0 → balanced, report height 2. At root3: child heights 1 (from9) and 2 (from20), differ by 1 → still balanced. - true. ✓ The fused version returns the height and short-circuits to
-1the instant any subtree is unbalanced, so it never re-walks — O(n).
2Top-Down Path State (root-to-leaf accumulation)
What it is: Carry state down from the root as parameters — the remaining target sum, the number built so far, the max value seen on this path — and evaluate at leaves (or at every node). "Does a root-to-leaf path sum to k?", "sum all root-to-leaf numbers," "count nodes ≥ every ancestor."
Intuition: Path-from-root state is exactly what call parameters model: each recursive call is one step along a path, and the call stack is the path. No data structure needed — the recursion shape mirrors the problem shape. Evaluate-at-leaf problems must define "leaf" precisely (!left && !right), not "null child" — checking at null double-counts through one-child nodes.
Approach:
int dfs(TreeNode* n, int cur) { // 129: cur = number formed root..parent
if (!n) return 0;
cur = cur * 10 + n->val;
if (!n->left && !n->right) return cur; // true leaf: contribute the path's number
return dfs(n->left, cur) + dfs(n->right, cur);
}Challenges & pitfalls
leaf-vs-null evaluation (112's classic wrong answer on one-child nodes — trace [1,2], target 1); overflow when accumulating (129 fits int per constraints — say you checked); passing by value vs reference: path-vector variants (backtracking territory) need push/pop discipline, plain accumulators don't — know which you're in.
5; left 4 → 11 → (7, 2); right 8. Target 22.- Carry the remaining target down: start 22, subtract each node's value. Down the left spine:
22 − 5 = 17,− 4 = 13,− 11 = 2, then at leaf2:2 − 2 = 0→ true, a root-to-leaf path (5 → 4 → 11 → 2) sums to 22. ✓ - The subtlety: only check "reached target" at a true leaf (
!left && !right). Checking at a null child would wrongly accept paths through one-child nodes.
3Two-Tree Recursion (compare / mirror / merge)
What it is: Recurse on two nodes in lockstep: same-tree (equal here && left-left equal && right-right equal), symmetric (mirror: compare left-with-right and right-with-left), subtree-of (a same-tree check launched at every node), merge (combine two trees node-wise).
Intuition: The contract just takes two nodes: f(a, b). Null-handling does most of the work — both null = true/nullptr, one null = false/the-other. Symmetry's one insight: a tree is symmetric when its left subtree mirrors its right, and "mirrors" swaps children in the comparison (mirror(a->left, b->right) && mirror(a->right, b->left)).
Approach:
bool same(TreeNode* a, TreeNode* b) {
if (!a || !b) return a == b; // both null → true; one null → false
return a->val == b->val && same(a->left, b->left) && same(a->right, b->right);
}Challenges & pitfalls
the two-null idiom (a == b covers both cases — elegant, but explain it); 572's complexity: a same-check at every node is O(n·m) — the senior follow-up is serialize-both + substring search or subtree hashing (Pattern 11); value equality isn't structural equality — [1,2] vs [1,null,2].
p = [1, 2, 3], q = [1, 2, 3].- Compare roots:
1 == 1✓. Recurse left pair (2vs2) ✓, right pair (3vs3) ✓. All leaves' children are null-null → true. Result true. ✓ - Contrast
p = [1, 2](2 is a left child) vsq = [1, null, 2](2 is a right child): comparing the left pair gives node-vs-null → false. Same values, different shape.
4Structural Modification (invert, merge, flatten)
What it is: Rebuild or rewire the tree itself: swap every node's children (invert), overlay two trees (merge), rewrite the tree into a linked list in preorder (flatten). The recursion returns the new subtree root, and the parent stitches it in.
Intuition: Treat "transform this tree" as "transform the children, then fix up this node" — post-order rewiring. The contract sentence matters doubly here because you're mutating: f(node) returns the root of the transformed subtree. Flatten's trick: recurse right first, thread each node's rewired left list between itself and its already-flattened right — or the O(1)-space version: for each node with a left child, splice the left subtree's rightmost node onto the right child.
Approach:
TreeNode* invertTree(TreeNode* root) {
if (!root) return nullptr;
TreeNode* l = invertTree(root->left);
root->left = invertTree(root->right);
root->right = l;
return root;
}Challenges & pitfalls
losing a subtree pointer mid-swap (save before you overwrite — the temp variable is not optional); flatten's ordering constraint (result must be preorder — verify on a 5-node example); mutating vs building-new (merging trees: reusing tree1's nodes is allowed by the problem — ask/state which you're doing).
4; left 2 → (1, 3); right 7 → (6, 9).- Post-order: at
2, swap children →2→ (3,1). At7, swap →7→ (9,6). At root4, swap the whole left/right →4→ (7,2). - Result: root
4; left7→ (9,6); right2→ (3,1). ✓ Saving one child in a temp before overwriting is what keeps the swap from clobbering itself.
5Construction from Traversals
What it is: Rebuild a unique binary tree from two traversal orders (preorder + inorder, or inorder + postorder), or build a balanced BST from a sorted array. The recurring shape: one traversal names the root, the other (inorder) splits the left subtree from the right.
Intuition: Preorder's first element is the root; find it in inorder — everything to its left is the left subtree, everything to its right is the right subtree; recurse on the matching slices. Sorted-array→BST is the same idea with the middle element as the root (balance for free). Fact to state: preorder + postorder alone is ambiguous (one-child nodes) — inorder is the disambiguator.
Approach:
unordered_map<int,int> pos; // inorder value → index (build once: O(1) splits)
TreeNode* build(vector<int>& pre, int& pi, int lo, int hi) {
if (lo > hi) return nullptr;
TreeNode* root = new TreeNode(pre[pi++]);
int mid = pos[root->val];
root->left = build(pre, pi, lo, mid - 1); // preorder consumes left-first —
root->right = build(pre, pi, mid + 1, hi); // order of these two calls is load-bearing
return root;
}Challenges & pitfalls
O(n²) from linear root-searches — the hashmap index is the expected fix; the shared preorder cursor (pi by reference) must advance left-then-right (swap the calls and it silently builds garbage — for postorder, walk the cursor backwards and build right-first); duplicate values break the position map (constraints usually forbid them — notice that aloud).
preorder = [3, 9, 20, 15, 7], inorder = [9, 3, 15, 20, 7].- Preorder's first element,
3, is the root. Find3in inorder (index 1): everything left ([9]) is the left subtree, everything right ([15, 20, 7]) is the right subtree. - Left subtree: next in preorder is
9, and its inorder slice is[9]→ leaf9. - Right subtree: next in preorder is
20; in the inorder slice[15, 20, 7],20sits in the middle → left child15, right child7. - Tree: root
3, left9, right20→ (15,7). ✓ Preorder kept naming the next root; inorder kept splitting left from right.
6BFS / Level-Order Family
What it is: Process the tree level by level with a queue: collect levels, take each level's last node (right-side view), alternate direction (zigzag), connect next pointers within a level. Also the right tool whenever the ask is "nearest/shallowest" — BFS finds the minimum depth before DFS finishes exploring.
Intuition: The queue-size snapshot (§0.5) turns a plain BFS into a level-aware one: everything in the queue at the loop's start is exactly one level. Every level-order variant is that loop plus a different per-level action (record all / record last / reverse alternate levels / wire next pointers). Right-side view = each level's final node — a one-line change, not a new algorithm.
Approach:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> out;
if (!root) return out;
queue<TreeNode*> q; q.push(root);
while (!q.empty()) {
int sz = q.size(); out.emplace_back();
for (int i = 0; i < sz; ++i) {
TreeNode* n = q.front(); q.pop();
out.back().push_back(n->val);
if (n->left) q.push(n->left);
if (n->right) q.push(n->right);
}
}
return out;
}Challenges & pitfalls
the size snapshot (§0.5); zigzag — reverse the collected level (simple) rather than juggling a deque live; DFS-with-depth can also produce level lists (record node at levels[depth]) — right-side view via DFS (visit right first, first node seen per depth wins) is a nice alternative to name; 116's O(1)-space version uses the already-wired parent level as the "queue" — the senior follow-up.
3, children 9 and 20, and 20 → (15, 7).- Queue starts
[3]. Snapshot size 1 → pop3, record[3], push its children9, 20. - Snapshot size 2 → pop
9and20, record[9, 20], push20's children15, 7. - Snapshot size 2 → pop
15and7, record[15, 7]. - Answer
[[3], [9, 20], [15, 7]]. ✓ Snapshotting the size at the start of each round is what keeps the levels from bleeding into each other.
[3, 20, 7]. ✓ Same loop, one line changed.7Lowest Common Ancestor
What it is: Find the deepest node that has both targets in its subtree. General tree: post-order — if the searches of the left and right subtrees each found one target, this node is the LCA. BST: no recursion insight needed — prune by value: both targets smaller → go left; both bigger → go right; they split (or one equals the node) → found.
Intuition: The general-tree recursion returns "the target found in this subtree, or the LCA if both were found": null+null = null, one-sided = pass it up, both sides non-null = I'm the split point. It works because the first node where the targets diverge is, by definition, their lowest common ancestor. The BST version is the pruning superpower: the LCA is the first node whose value falls between the targets on the root-down walk — O(h), no recursion required.
Approach:
TreeNode* lca(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || root == p || root == q) return root;
TreeNode* l = lca(root->left, p, q);
TreeNode* r = lca(root->right, p, q);
if (l && r) return root; // targets split here → this is the LCA
return l ? l : r;
}Challenges & pitfalls
the standard version assumes both nodes exist (root == p returns immediately without checking q — the "what if q is absent?" follow-up needs a found-count variant; know it); compare values not pointers in the BST version; "LCA of deepest leaves" (865) fuses this with Pattern 1 — return (depth, lca) pairs.
6; left 2 → (0, 4→(3,5)); right 8 → (7, 9). Find LCA of 2 and 8.- At
6:2 < 6and8 > 6— the targets straddle6→6is the LCA. ✓ One comparison, no recursion. - Find LCA of
2and4instead: at6, both< 6→ go left to2. At2, one target is2(and4is in its subtree) → LCA is 2.
8BST Invariant: Inorder & Range Machinery
What it is: Everything that exploits "inorder = sorted": validate a BST (the inorder sequence must be strictly increasing — or pass ranges down), k-th smallest (inorder, stop at the k-th visit), BST iterator (a paused inorder traversal: a stack of left-spines; next() pops and then pushes the popped node's right spine).
Intuition: The two interchangeable tools: range-passing (each node constrains its subtrees to (lo, hi) — top-down, Pattern 2 for BSTs) and inorder-walking (mentally flatten to sorted order and apply array thinking — k-th, prev/next, two-sum-in-BST). The iterator teaches the deeper skill: turning a recursion into an explicit stack you can pause — the left spine in the stack is exactly the recursion's pending frames, giving O(h) memory and O(1) amortized next().
Approach:
bool validate(TreeNode* n, long long lo, long long hi) {
if (!n) return true;
if (n->val <= lo || n->val >= hi) return false;
return validate(n->left, lo, n->val) && validate(n->right, n->val, hi);
}
// call: validate(root, LLONG_MIN, LLONG_MAX)Challenges & pitfalls
§0.4 in full (the parent-only comparison bug + long long bounds — 98 is the problem where both bite); strict vs non-strict inequality (duplicates policy — read the statement); the k-th smallest follow-up "the tree changes often" → augment nodes with left-subtree counts (order-statistics tree — describe, don't build); the iterator's amortized O(1) argument ("each node is pushed and popped once across all calls").
3; left 1 (with right child 2); right 4. Find k = 1.- An inorder walk visits nodes in sorted order:
1, 2, 3, 4. Stop at the 1st → 1. ✓ (Fork = 3it would be3.) No sorting — the BST already lays the values out in order under inorder traversal.
5; left 1; right 4 → (3, 6).- Range-check top-down:
5is fine in(−∞, +∞). Right subtree must be in(5, +∞), but node3(a descendant on the right) is< 5→ false. ✓ Comparing only against the immediate parent would have missed this — you need the inherited(lo, hi)bounds.
9Tree DP with a Global Answer (the return/record split)
What it is: The hard-tree archetype: max path sum (any node to any node), longest univalue path, house-robber-on-a-tree. At each node, the value you return to the parent (best single downward arm — the parent can only extend one arm) differs from the value you record globally (best arch joining both arms through this node).
Intuition: A path through a tree has a unique highest node — its apex. Enumerate paths by their apex: at each node, the best path apexed here = node + best-left-arm + best-right-arm; record it globally. But a parent extending through this node can use only ONE arm (paths can't fork) — so return node + max(arm). Clamp negative arms to 0 ("don't take a bad arm"). House Robber III is the same discipline with a pair return: (best if this node robbed, best if not).
Approach:
int best = INT_MIN;
int arm(TreeNode* n) { // best downward path STARTING at n (≥ its value)
if (!n) return 0;
int l = max(arm(n->left), 0); // negative arm → drop it
int r = max(arm(n->right), 0);
best = max(best, n->val + l + r); // arch apexed at n → record globally
return n->val + max(l, r); // parent may extend only one arm → return
}Challenges & pitfalls
the return/record split (§0.3 — if your function returns the arch, the parent double-uses both arms: the canonical wrong answer); all-negative trees (that's why best seeds at INT_MIN and arms clamp at 0 — trace [-3]); the univalue variant returns arm-length conditional on matching the parent's value — the condition rides the return, not the record.
-10; left 9; right 20 → (15, 7).- Compute best downward arm at each node, and record the best arch:
15→ arm 15.7→ arm 7.20: arch through it =20 + 15 + 7 = 42→ record 42. But it can only return one arm to its parent:20 + max(15, 7) = 35.9→ arm 9. Root-10: arch =−10 + 9 + 35 = 34(doesn't beat 42); returns−10 + max(9, 35) = 25.- Best path sum = 42 (the path
15 → 20 → 7). ✓ The recorded arch uses both arms; the returned value uses only one — mixing them is the classic bug.
10BST Surgery (insert, delete, trim)
What it is: Structural edits that keep the BST rule intact. Insert: walk down by comparison, attach at the null spot. Delete: the three-case classic — leaf (drop it), one child (splice the child up), two children (replace the value with the inorder successor — the leftmost node of the right subtree — then delete that node, which is guaranteed to be case 1 or 2). Trim: recursively discard subtrees wholly outside [lo, hi].
Intuition: All three are "recurse toward the target by comparison, and return the (possibly new) subtree root so the parent re-links" — the returning-root contract from Pattern 4 under the BST pruning from Pattern 8. Delete's two-child case works because the inorder successor is the smallest value greater than the node — promoting it keeps everything on the left smaller and everything on the right bigger. Trim's power move: if node->val < lo, then the entire left subtree is also < lo — return the trimmed right subtree and skip half the tree.
Approach:
TreeNode* deleteNode(TreeNode* root, int key) {
if (!root) return nullptr;
if (key < root->val) root->left = deleteNode(root->left, key);
else if (key > root->val) root->right = deleteNode(root->right, key);
else {
if (!root->left) return root->right; // cases 1 & 2: splice
if (!root->right) return root->left;
TreeNode* s = root->right; // case 3: inorder successor
while (s->left) s = s->left;
root->val = s->val;
root->right = deleteNode(root->right, s->val); // delete successor (easy case)
}
return root;
}Challenges & pitfalls
forgetting to assign the recursive result (root->left = deleteNode(...) — calling without assigning edits nothing: the signature bug of returning-root style); successor vs predecessor (both valid — pick one, be consistent); trim's whole-subtree discard needs the BST argument said aloud; degenerate-tree O(h)=O(n) worst case → name self-balancing trees (and that std::map is a red-black tree) as the production answer, don't implement one.
5; left 3 → (2, 4); right 6 → (null, 7). Delete 3.3 < 5→ recurse left; found3. It has two children (2and4) → case 3.- Inorder successor = leftmost node of
3's right subtree =4. Copy4into the node (it becomes4), then delete the original4from the right subtree (a leaf → easy case). - Result: root
5; left4→ (2, null); right6→ (null,7). ✓ Promoting the successor keeps left-smaller / right-bigger intact.
11Serialization & Subtree Canonicalization
What it is: Encode a tree to a string and rebuild it exactly (297), or use serialization as a canonical key to find duplicate subtrees (652) — hash each subtree's serialization; same key = identical subtree. The tree-world version of the arrays-strings canonical-key pattern.
Intuition: Preorder with explicit null markers uniquely determines a binary tree — the nulls carry the structure that made preorder-alone ambiguous in Pattern 5. Deserialization is then one left-to-right pass with the same shared-cursor trick: read a token; if it's the null-marker return; else make the node and recurse left, then right. For duplicate detection, build keys bottom-up (val + "," + leftKey + "," + rightKey) so each subtree's key is O(1) to assemble from its children — and intern keys to ints (unordered_map<string,int>) to avoid O(n²) string growth on deep trees.
Approach:
void ser(TreeNode* n, string& out) {
if (!n) { out += "#,"; return; }
out += to_string(n->val) + ",";
ser(n->left, out); ser(n->right, out);
}
TreeNode* deser(istringstream& in) {
string tok; getline(in, tok, ',');
if (tok == "#") return nullptr;
TreeNode* n = new TreeNode(stoi(tok));
n->left = deser(in); n->right = deser(in); // same order as serialization — load-bearing
return n;
}Challenges & pitfalls
null markers and delimiters are both mandatory (negative values and multi-digit numbers break markerless/delimiterless schemes — "12,3" vs "1,23"); serialize and deserialize must agree on traversal order exactly; 652's key-growth trap (string keys concatenate to O(n²) total — the int-interning fix is the expected follow-up); BST-specific serialization (449) can skip null markers using bounds — a nice BST-invariant flex if asked.
1, children 2 and 3, and 3 → (4, 5).- Serialize with preorder + null markers (
#): visit root, then left subtree, then right →"1,2,#,#,3,4,#,#,5,#,#,". The#s record where subtrees end, which is what makes the string unambiguous. - Deserialize reads tokens left to right:
1(make root) → recurse left:2(make node) →#(null left) →#(null right) → back up, recurse right of1:3→ left4(#,#) → right5(#,#). Rebuilds the original tree exactly. ✓ - For Find Duplicate Subtrees (LC 652), give each subtree the key
val,leftKey,rightKeybuilt bottom-up; two subtrees with the same key are identical, so a hashmap of keys finds the duplicates in one pass.
▤Summary Table — Recognition Cheat Sheet
| # | Pattern | Direction | Trigger phrase | Contract of f(node) |
|---|---|---|---|---|
| 1 | Bottom-up aggregate | ↑ | "height", "balanced", "diameter" | subtree property (fused, sentinel for early exit) |
| 2 | Top-down path state | ↓ | "root-to-leaf", "path sum", "ancestors" | accumulate via params, evaluate at true leaves |
| 3 | Two-tree lockstep | ↑ | "same", "symmetric", "subtree of" | f(a, b); two-null idiom |
| 4 | Structural modification | ↑ | "invert", "merge", "flatten" | returns new subtree root; parent stitches |
| 5 | Build from traversals | ↓ | "construct from preorder/inorder" | one order picks root, inorder splits; shared cursor |
| 6 | BFS levels | level | "level order", "right side view", "nearest" | queue + size snapshot |
| 7 | LCA | ↑ | "lowest common ancestor" | null/one/both-found trichotomy; BST: value pruning |
| 8 | BST inorder/range | both | "validate", "k-th smallest", "iterator" | inorder = sorted; (lo, hi) ranges |
| 9 | Tree DP, global answer | ↑ + global | "max path sum", "any-to-any path" | RETURN one arm, RECORD the arch |
| 10 | BST surgery | ↑ | "insert/delete/trim BST" | return possibly-new root; 3-case delete |
| 11 | Serialization | ↑ | "serialize", "duplicate subtrees" | preorder + null markers; canonical subtree keys |
f(node) returns ⟨one sentence⟩, null returns ⟨identity⟩ — and for this problem the value I return to the parent differs from the answer I record globally, so I'll track ⟨best⟩ outside. That's one post-order pass, O(n) time, O(h) stack."