A learning sheet β trees are recursion made visible. Master one question β "what does each call return to its parent?" β and most tree problems fall in three lines.
A binary tree node is { val, left, right }. Almost every solution is one recursive function over subtrees. The skill that unlocks all of them is being ruthlessly clear about the contract of your recursion. Before writing a line, answer three questions:
dfs(node) is a manager: they ask each direct report (child) for one summary number, fold those together, and report one number up to their own boss β without ever redoing a report's work. Trusting that summary blindly is the recursion leap of faith (your weak spot β lean on it). The three questions below are just that manager's job description.dfs(node) hand back to its parent? (e.g. "the height of this subtree")
(2) BASE CASE: what does dfs(null) return? (e.g. 0)
(3) COMBINE: how does the parent fold its children's returns into its own answer β and does it also record something in a global along the way?The single biggest tree confusion is mixing up what you return (one value, for the parent to keep recursing) with what you record (a global "best", which may use both children at once). Diameter is the perfect teacher.
height(node) RETURNS the node-count of the tallest path down. Base: height(null) = 0. Combine: 1 + max(height(left), height(right)).leftHeight + rightHeight. But the parent can't use a "bent" length β it needs a straight height. So dfs must RETURN the height while RECORDING left + right into a global.O(n) pass; the global holds the answer even though the function returns something else.| Problem | dfs RETURNS (for the parent) | RECORDS in a global |
|---|---|---|
| Height / Max Depth | 1 + max(L, R) | β (the return is the answer) |
| Diameter | 1 + max(L, R) (height) | max(best, L + R) |
| Balanced? | height, or -1 if a subtree is unbalanced | β (propagate -1 upward) |
| Max Path Sum | val + max(L, R, 0) (straight down) | max(best, val + max(L,0) + max(R,0)) |
| Ask | If yes β | Pattern |
|---|---|---|
| Just visit nodes in an order (level-by-level, in/pre/post)? | DFS recursion or a queue (BFS) | Traversals (Β§4.1) |
| Compute a value from subtrees (height, diameter, sum, path)? | the return-value contract | Return-value DFS (Β§4.2) |
| It's a BST (left < node < right)? | exploit the ordering β inorder is sorted | BST = ordered (Β§4.3) |
| Lowest common ancestor / a path between nodes? | postorder "bubble-up" | LCA & paths (Β§4.4) |
| Build a tree from traversals, or serialize it? | preorder gives the root; recurse | Construct (Β§4.5) |
O(1) space).left.val < node < right.val) is wrong β you must bound by ancestor ranges.| Words in the prompt | Reach for |
|---|---|
| "level order", "zigzag", "right side view", "in/pre/post-order" | Traversals Β§4.1 |
| "height/depth", "diameter", "balanced", "max path sum", "count/sum whereβ¦" | Return-value DFS Β§4.2 |
| "BST", "k-th smallest", "validate BST", "search/insert/delete", "range sum BST" | BST Β§4.3 |
| "lowest common ancestor", "path sum", "root-to-leaf", "count paths summing to k", "distance between nodes" | LCA & paths Β§4.4 |
| "construct from preorder & inorder", "serialize/deserialize", "flatten", "invert" | Construct Β§4.5 |
Node shape: class TreeNode { val: number; left: TreeNode|null; right: TreeNode|null }. Each: a one-liner, a π invariant, a template, a π€ interview lens.
βPre = me-first, In = left-me-right (sorted for a BST), Post = children-first; BFS = a queue.β
Start with the simplest thing that works β recursion. All three depth-first orders are the same code; only where you place the visit relative to the two calls changes β see it at a glance:
if (!n) return;
push(n.val);
go(n.left);
go(n.right);if (!n) return;
go(n.left);
push(n.val);
go(n.right);if (!n) return;
go(n.left);
go(n.right);
push(n.val);Same skeleton β only the highlighted visit line moves.
pre / in / post functions// Recursive DFS β identical shape; only the line that "visits" moves
function preorder(node: TreeNode | null, out: number[] = []): number[] {
if (!node) return out;
out.push(node.val); // visit FIRST β PREORDER (node, L, R)
preorder(node.left, out);
preorder(node.right, out);
return out;
}
function inorder(node: TreeNode | null, out: number[] = []): number[] {
if (!node) return out;
inorder(node.left, out);
out.push(node.val); // visit BETWEEN β INORDER (L, node, R) β sorted in a BST
inorder(node.right, out);
return out;
}
function postorder(node: TreeNode | null, out: number[] = []): number[] {
if (!node) return out;
postorder(node.left, out);
postorder(node.right, out);
out.push(node.val); // visit LAST β POSTORDER (L, R, node) β the basis of Β§4.2
return out;
}
Now all three iteratively β an explicit stack replaces the call stack, plus BFS with a queue. Postorder comes in two forms; the note after the code says when to reach for each:
// Iterative INORDER β push the left spine, pop, go right
function inorderIter(root: TreeNode | null): number[] {
const out: number[] = [], st: TreeNode[] = []; let cur = root;
while (cur || st.length) {
while (cur) { st.push(cur); cur = cur.left; } // dive left
cur = st.pop()!; out.push(cur.val); // visit
cur = cur.right; // then right
}
return out;
}
// Iterative PREORDER β stack; push right BEFORE left so left pops first
function preorderIter(root: TreeNode | null): number[] {
if (!root) return [];
const out: number[] = [], st: TreeNode[] = [root];
while (st.length) {
const n = st.pop()!; out.push(n.val); // visit on pop
if (n.right) st.push(n.right);
if (n.left) st.push(n.left);
}
return out;
}
// Iterative POSTORDER (values only) β a preorder variant (node, right, left), then reversed
function postorderIter(root: TreeNode | null): number[] {
if (!root) return [];
const out: number[] = [], st: TreeNode[] = [root];
while (st.length) {
const n = st.pop()!; out.unshift(n.val); // push to the FRONT = reverse at the end
if (n.left) st.push(n.left);
if (n.right) st.push(n.right);
}
return out;
}
// Iterative POSTORDER (genuine) β mark a node "visited" on the stack; process it on the 2nd pop
function postorderIterVisited(root: TreeNode | null): number[] {
if (!root) return [];
const out: number[] = [];
const st: [TreeNode, boolean][] = [[root, false]]; // each entry: [node, seenBefore?]
while (st.length) {
const [node, visited] = st.pop()!;
if (visited) {
out.push(node.val); // 2nd pop β children already done β visit now
} else {
st.push([node, true]); // re-push marked, to return AFTER the children
if (node.right) st.push([node.right, false]); // push right then left,
if (node.left) st.push([node.left, false]); // so left is popped (and finished) first
}
}
return out;
}
// BFS level order β a queue, one level per outer iteration
function levelOrder(root: TreeNode | null): number[][] {
if (!root) return [];
const out: number[][] = [];
let q: TreeNode[] = [root];
while (q.length) {
const level: number[] = [], next: TreeNode[] = [];
for (const x of q) {
level.push(x.val);
if (x.left) next.push(x.left);
if (x.right) next.push(x.right);
}
out.push(level);
q = next;
}
return out;
}
postorderIter (reverse a modified preorder) is the quickest to write, but it only gives you the values β it never actually stops on a node with both children done. When a problem needs you to do real work at each node in true postorder (children guaranteed finished first β evaluating an expression tree, freeing/merging subtrees, computing a value bottom-up), use postorderIterVisited: each node is pushed with a visited flag and only processed on its second pop, after its children. It's the most intuitive genuine postorder β and a bonus: the same flag template also does preorder and inorder, just by changing where you push the marked node relative to its two children.levelOrder with one tweak: right-side view keeps only the last value per level; zigzag flips a direction flag and reverses alternate levels; vertical order carries a column index with each node and groups by it (BFS makes ties come out top-down, left-to-right for free β 314 is a Meta favorite).// Right Side View (199) β level order, keep only the LAST value of each level
function rightSideView(root: TreeNode | null): number[] {
if (!root) return [];
const out: number[] = [];
const q: TreeNode[] = [root];
let front = 0;
while (front < q.length) {
const levelEnd = q.length; // this level's boundary
while (front < levelEnd) {
const node = q[front++];
if (front === levelEnd) out.push(node.val); // last node of the level
if (node.left) q.push(node.left);
if (node.right) q.push(node.right);
}
}
return out;
}
// Zigzag (103) β level order + a direction flag; reverse alternate levels
function zigzagLevelOrder(root: TreeNode | null): number[][] {
if (!root) return [];
const out: number[][] = [];
const q: TreeNode[] = [root];
let front = 0;
let leftToRight = true;
while (front < q.length) {
const level: number[] = [];
const levelEnd = q.length;
while (front < levelEnd) {
const node = q[front++];
level.push(node.val);
if (node.left) q.push(node.left);
if (node.right) q.push(node.right);
}
if (!leftToRight) level.reverse();
out.push(level);
leftToRight = !leftToRight;
}
return out;
}
// Vertical Order (314) β BFS carrying a column index; group values by column
function verticalOrder(root: TreeNode | null): number[][] {
if (!root) return [];
const cols = new Map<number, number[]>(); // column β values (BFS order = top-down, ties LβR)
const q: [TreeNode, number][] = [[root, 0]];
let front = 0;
let minCol = 0, maxCol = 0;
while (front < q.length) {
const [node, col] = q[front++];
if (!cols.has(col)) cols.set(col, []);
cols.get(col)!.push(node.val);
minCol = Math.min(minCol, col);
maxCol = Math.max(maxCol, col);
if (node.left) q.push([node.left, col - 1]);
if (node.right) q.push([node.right, col + 1]);
}
const out: number[][] = [];
for (let c = minCol; c <= maxCol; c++) out.push(cols.get(c)!);
return out;
}
O(h) memory. Morris does inorder in O(1) extra space with one trick: before descending into a left subtree, it ties a string back to itself β it points the rightmost node of that left subtree (the node you'll visit just before this one, your inorder predecessor) back at the current node. Later, when the left subtree is done, that string is how you climb back up without a stack; you untie it and visit. The only thing to remember: "thread each node to its inorder predecessor so you can return without a stack." That one sentence is the takeaway; the code below makes it concrete β it's finicky, so know the idea cold and reproduce the code only if asked for strict O(1) space.// Morris inorder β O(1) space: thread each node to its inorder predecessor, then unthread
function morrisInorder(root: TreeNode | null): number[] {
const out: number[] = [];
let cur = root;
while (cur) {
if (!cur.left) { // no left subtree β visit, then go right
out.push(cur.val);
cur = cur.right;
} else {
let pred = cur.left; // predecessor = rightmost node of the left subtree
while (pred.right && pred.right !== cur) pred = pred.right;
if (!pred.right) { // first visit β thread the breadcrumb back to cur
pred.right = cur;
cur = cur.left;
} else { // thread already set β left subtree done; unwire + visit
pred.right = null;
out.push(cur.val);
cur = cur.right;
}
}
}
return out;
}
βCompute children first, fold their returns into yours β and record a global if the answer bends.β
Setup: this is the org-chart manager made concrete. State the contract, then the body is literally three lines: (1) BASE β what does an empty subtree (null) report? (2) COMBINE β ask both children, fold their returns L and R into your answer. (3) RETURN / RECORD β hand one summary up, and if the answer can bend here, also write it to a global. Below, the same dfs three ways, each one twist harder: diameter (return-vs-record), balanced (height hijacked by a -1 "failed" sentinel), and the hardest, Max Path Sum.
dfs(node) returns, two things are already true β (1) it handed its parent a correct summary of its whole subtree, and (2) it has compared the best answer bending at this node against the global. Why that's enough: (1) lets a parent trust a child's number without re-checking it β the org-chart manager trusts each report. (2) means once the root returns, every possible bending point has been weighed, so the global is the true answer even though the function never returns it.// Diameter β RETURN height, RECORD the bending path
function diameterOfBinaryTree(root: TreeNode | null): number {
let best = 0;
function height(node: TreeNode | null): number { // CONTRACT: height of this subtree
if (!node) return 0; // base case
const L = height(node.left);
const R = height(node.right);
best = Math.max(best, L + R); // RECORD: path bending at node
return 1 + Math.max(L, R); // RETURN: straight height for parent
}
height(root);
return best;
}
// Balanced? β RETURN height, but hijack it with -1 as a "subtree already failed" sentinel
function isBalanced(root: TreeNode | null): boolean {
function height(node: TreeNode | null): number { // CONTRACT: height, or -1 if unbalanced below
if (!node) return 0;
const L = height(node.left);
if (L === -1) return -1; // a subtree already failed β propagate -1 up
const R = height(node.right);
if (R === -1) return -1;
if (Math.abs(L - R) > 1) return -1; // imbalance detected here β fail
return 1 + Math.max(L, R);
}
return height(root) !== -1;
}
// Max Path Sum β clamp negative branches; RETURN a straight path, RECORD a bent one
function maxPathSum(root: TreeNode | null): number {
let best = -Infinity;
function gain(node: TreeNode | null): number { // CONTRACT: best downward path sum from node
if (!node) return 0;
const L = Math.max(0, gain(node.left)); // ignore a negative branch
const R = Math.max(0, gain(node.right));
best = Math.max(best, node.val + L + R); // RECORD: path that bends here
return node.val + Math.max(L, R); // RETURN: straight path down
}
gain(root);
return best;
}
O(n) β but for a complete tree you can do O(logΒ² n). The insight: walk the left edge and the right edge of a subtree; if the two depths are equal, that subtree is perfect and has exactly 2^h β 1 nodes β no recursion needed. If they differ, recurse into both children β but in a complete tree one of them is always perfect, so only one branch keeps going: log n levels Γ log n edge-walk each.// Count Complete Tree Nodes (222) β spot perfect subtrees by their edge heights
function countNodes(root: TreeNode | null): number {
if (!root) return 0;
let lh = 1;
let l = root.left;
while (l) { lh++; l = l.left; } // depth hugging the LEFT edge
let rh = 1;
let r = root.right;
while (r) { rh++; r = r.right; } // depth hugging the RIGHT edge
if (lh === rh) return (1 << lh) - 1; // equal β perfect tree β 2^h β 1, done in O(1)
const L = countNodes(root.left); // not perfect β recurse; one side always IS perfect,
const R = countNodes(root.right); // so only one real branch survives per level
return 1 + L + R;
}
0 (a branch that only hurts you, you simply don't take). Nail it and they escalate: height β diameter β balanced (-1 sentinel) β max path sum β each is the same dfs with one new twist.// 1110 Delete Nodes and Return Forest β return node-or-null, parent re-links
function delNodes(root: TreeNode | null, toDelete: number[]): TreeNode[] {
const del = new Set(toDelete);
const forest: TreeNode[] = [];
function dfs(node: TreeNode | null, isRoot: boolean): TreeNode | null {
if (!node) return null;
const gone = del.has(node.val);
if (isRoot && !gone) forest.push(node); // my parent was deleted (or I'm the root) β I start a new tree
node.left = dfs(node.left, gone); // the RETURN re-links (or cuts) the child
node.right = dfs(node.right, gone);
return gone ? null : node;
}
dfs(root, true);
return forest;
}
// 979 Distribute Coins β return the net balance, record moves on each edge
function distributeCoins(root: TreeNode | null): number {
let moves = 0;
function dfs(n: TreeNode | null): number { // + = surplus to send up, - = coins needed from above
if (!n) return 0;
const l = dfs(n.left), r = dfs(n.right);
moves += Math.abs(l) + Math.abs(r); // every unit crossing an edge costs exactly one move
return n.val + l + r - 1; // keep one coin for myself
}
dfs(root);
return moves;
}
// 968 Binary Tree Cameras β greedy post-order, 3 states
function minCameraCover(root: TreeNode | null): number {
let cams = 0;
function dfs(n: TreeNode | null): 0 | 1 | 2 { // 0 = not covered Β· 1 = has camera Β· 2 = covered, no camera
if (!n) return 2; // null needs nothing β counts as covered
const l = dfs(n.left), r = dfs(n.right);
if (l === 0 || r === 0) { cams++; return 1; } // a child is uncovered β I MUST hold a camera
if (l === 1 || r === 1) return 2; // a child's camera covers me
return 0; // nothing nearby β ask my parent
}
if (dfs(root) === 0) cams++; // root ended uncovered β one more camera
return cams;
}
node.left = dfs(...) leaves dangling pointers to deleted nodes. 979: use abs β a deficit costs as much as a surplus. 968: null β 2, never 0, and never put a camera on a leaf (its parent does it).βLeft < node < right β so search is binary, and inorder is sorted.β
Setup: a plain tree can force you to check every node β O(n). A BST is binary search frozen into a data structure: because left < node < right, one comparison tells you which half to keep and which to throw away β exactly like guessing a number in 1β100 with "higher / lower" hints. So you walk a single root-to-leaf path instead of the whole tree: O(h), and h β log n when balanced. Two facts do everything: you navigate by comparison (that's the binary search), and an inorder walk emits values in sorted order.
(lo, hi) handed down by its ancestors β left child tightens hi to the parent's value, right child tightens lo. A purely local "left < node < right" check passes invalid trees (a deep-left node can still violate a far ancestor).Navigate β O(h), one comparison per step (this is the binary search).
// Search (700) β walk by comparison
function searchBST(root: TreeNode | null, val: number): TreeNode | null {
while (root) {
if (root.val === val) return root;
if (val < root.val) root = root.left; // smaller β go left
else root = root.right; // bigger β go right
}
return null;
}
// LCA in a BST (235) β the split point where the two values diverge
function lcaBST(root: TreeNode | null, p: TreeNode, q: TreeNode): TreeNode | null {
let node = root;
while (node) {
if (p.val < node.val && q.val < node.val) node = node.left; // both smaller β left
else if (p.val > node.val && q.val > node.val) node = node.right; // both bigger β right
else return node; // they split here β LCA
}
return null;
}
// Inorder successor (285) β smallest node greater than p
function inorderSuccessor(root: TreeNode | null, p: TreeNode): TreeNode | null {
let succ: TreeNode | null = null;
let cur = root;
while (cur) {
if (p.val < cur.val) {
succ = cur; // candidate; a smaller one may be further left
cur = cur.left;
} else {
cur = cur.right; // p.val >= cur.val β successor is further right
}
}
return succ;
}
Validate & k-th β both lean on "an inorder walk of a BST is sorted."
// Validate (98) β recursive: carry the allowed (lo, hi) range down
function isValidBST(node: TreeNode | null, lo = -Infinity, hi = Infinity): boolean {
if (!node) return true;
if (node.val <= lo || node.val >= hi) return false;
const L = isValidBST(node.left, lo, node.val);
const R = isValidBST(node.right, node.val, hi);
return L && R;
}
// Validate (98) β iterative: an inorder walk must be strictly increasing
function isValidBSTIter(root: TreeNode | null): boolean {
const st: TreeNode[] = []; let cur = root, prev = -Infinity;
while (cur || st.length) {
while (cur) { st.push(cur); cur = cur.left; }
cur = st.pop()!;
if (cur.val <= prev) return false; // not strictly increasing β invalid
prev = cur.val;
cur = cur.right;
}
return true;
}
// k-th smallest (230) β inorder yields sorted order; stop at the k-th
function kthSmallest(root: TreeNode | null, k: number): number {
const st: TreeNode[] = []; let cur = root;
while (cur || st.length) {
while (cur) { st.push(cur); cur = cur.left; }
cur = st.pop()!;
if (--k === 0) return cur.val;
cur = cur.right;
}
return -1;
}
Iterate on demand β kthSmallest generalized into an API. Same stack machinery, paused between calls; the classic "what if it's called repeatedly?" follow-up.
// BST Iterator (173) β controlled inorder walk; next() is amortized O(1), space O(h)
class BSTIterator {
private st: TreeNode[] = [];
constructor(root: TreeNode | null) {
this.pushLeft(root); // stage the smallest: the left spine
}
private pushLeft(node: TreeNode | null): void {
while (node) {
this.st.push(node);
node = node.left;
}
}
next(): number {
const node = this.st.pop()!; // smallest unseen value
this.pushLeft(node.right); // stage its right subtree's left spine
return node.val;
}
hasNext(): boolean {
return this.st.length > 0;
}
}
Insert β recursive and iterative both just walk to the empty slot and attach.
// Insert (701) β recursive: hang the new node where the walk falls off
function insertIntoBST(root: TreeNode | null, val: number): TreeNode | null {
if (!root) return new TreeNode(val);
if (val < root.val) root.left = insertIntoBST(root.left, val);
else root.right = insertIntoBST(root.right, val);
return root;
}
// Insert (701) β iterative: remember the parent, then attach
function insertIntoBSTIter(root: TreeNode | null, val: number): TreeNode | null {
const node = new TreeNode(val);
if (!root) return node;
let cur: TreeNode | null = root, parent: TreeNode = root;
while (cur) {
parent = cur;
if (val < cur.val) cur = cur.left;
else cur = cur.right;
}
if (val < parent.val) parent.left = node;
else parent.right = node;
return root;
}
// Delete (450) β 3 cases; two children β replace with inorder successor
function deleteNode(root: TreeNode | null, key: number): TreeNode | null {
if (!root) return null;
if (key < root.val) {
root.left = deleteNode(root.left, key);
} else if (key > root.val) {
root.right = deleteNode(root.right, key);
} else { // found the node to delete
if (!root.left) return root.right; // 0 or 1 child β splice past it
if (!root.right) return root.left;
let succ = root.right; // 2 children β inorder successor
while (succ.left) succ = succ.left; // (smallest in the right subtree)
root.val = succ.val; // copy its value up
root.right = deleteNode(root.right, succ.val); // then delete the successor
}
return root;
}
O(h) β very often followed by "now do it iteratively." Nail it and they escalate: validate β k-th smallest β BST iterator (173) β LCA-in-BST β delete β build a balanced BST from a sorted array (108) β name-drop self-balancing (AVL / red-black).βIf p and q surface from different children, you're standing on their ancestor.β
Setup: "LCA" is literally lowest common ancestor β picture two people tracing their family trees upward; the first ancestor they share is the answer. The code finds that merge point by asking each subtree "is p or q below you?": the node that hears "yes" from both sides is where their two paths first meet. Everything bubbles up from the leaves (postorder).
lca(node) returns a target node if its subtree contains one, else null. A node gets a non-null from both children only when p and q are split across them β which makes it their lowest common ancestor. If both are on one side, that side's answer bubbles straight up.// LCA in a general binary tree
function lowestCommonAncestor(root: TreeNode | null, p: TreeNode, q: TreeNode): TreeNode | null {
if (!root || root === p || root === q) return root; // found one (or hit bottom)
const L = lowestCommonAncestor(root.left, p, q);
const R = lowestCommonAncestor(root.right, p, q);
if (L && R) return root; // p and q on different sides β this is the LCA
return L ?? R; // both on one side (or neither) β pass it up
}
k, starting anywhere, going only downward." Brute force re-walks from every node β O(nΒ²). The leap is the array trick "two prefix sums that differ by k bracket a subarray summing to k" β applied to the current root-to-node path: keep a map of the running sums of all ancestors; at each node, every ancestor whose running sum equals running β k is a valid start point. One rule keeps it correct: backtrack the map when you leave a node, so cousins never see sums from a path they're not on.// Path Sum III (437) β count downward paths (any start node) summing to target
function pathSumIII(root: TreeNode | null, targetSum: number): number {
const prefix = new Map<number, number>(); // running-sum β how many ancestors have it
prefix.set(0, 1); // empty prefix = "path starts at the root"
let count = 0;
function dfs(node: TreeNode | null, running: number): void {
if (!node) return;
running += node.val;
count += prefix.get(running - targetSum) ?? 0; // each match = a valid start point
prefix.set(running, (prefix.get(running) ?? 0) + 1); // add myself for my descendants
dfs(node.left, running);
dfs(node.right, running);
prefix.set(running, prefix.get(running)! - 1); // BACKTRACK β leave the path clean
}
dfs(root, 0);
return count;
}
L && R β root bubble-up (a hit from both sides β you're the merge point), and do you notice the hidden assumption that both nodes actually exist in the tree. Nail it and they escalate: LCA β path sum (root-to-leaf target) β all root-to-leaf paths (backtracking) β distance between two nodes (LCA + depth math).βPreorder hands you the root; inorder tells you where left ends and right begins.β
Setup: building a tree is recursion in reverse β identify the root, split the remaining nodes into left/right groups, recurse. Serialization is just a preorder walk with explicit null markers.
O(n).// Build a tree from preorder + inorder
function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
const pos = new Map(inorder.map((v, i) => [v, i]));
let p = 0;
function build(lo: number, hi: number): TreeNode | null {
if (lo > hi) return null;
const node = new TreeNode(preorder[p++]); // preorder front = root
const mid = pos.get(node.val)!; // its index in inorder splits L | R
node.left = build(lo, mid - 1);
node.right = build(mid + 1, hi);
return node;
}
return build(0, inorder.length - 1);
}
// Serialize / deserialize (preorder with '#' for null)
function serialize(root: TreeNode | null): string {
const out: string[] = [];
(function dfs(n: TreeNode | null) {
if (!n) { out.push('#'); return; }
out.push(String(n.val));
dfs(n.left);
dfs(n.right);
})(root);
return out.join(',');
}
function deserialize(data: string): TreeNode | null {
const vals = data.split(','); let i = 0;
function build(): TreeNode | null {
const v = vals[i++];
if (v === '#') return null;
const node = new TreeNode(+v);
node.left = build();
node.right = build();
return node;
}
return build();
}
buildTree. But two things make a BFS version worth knowing. (1) The output string is LeetCode's own tree format: 1,2,3,#,#,#,# is exactly [1,2,3,null,null,null,null] β a free mnemonic every time you read a tree problem. (2) It's iterative, so it can't stack-overflow on a deep skewed tree (Β§6). The trick both directions share: enqueue both children β nulls included β so the queue order lines up token-for-token with the string.// Serialize β BFS: drain a queue, emit '#' for null, enqueue BOTH children (nulls too)
function serializeBFS(root: TreeNode | null): string {
if (!root) return '';
const q: (TreeNode | null)[] = [root];
const out: string[] = [];
let front = 0;
while (front < q.length) {
const node = q[front++]; // plain queue drain β no per-level bookkeeping needed
if (!node) { out.push('#'); continue; }
out.push(String(node.val));
q.push(node.left); // enqueue both children, null or not,
q.push(node.right); // so their tokens map 1:1 to the queue order
}
return out.join(',');
}
// Deserialize β BFS: each dequeued node claims the next two tokens as its children
function deserializeBFS(data: string): TreeNode | null {
if (!data) return null;
const vals = data.split(',');
const root = new TreeNode(parseInt(vals[0]));
const q: TreeNode[] = [root];
let front = 0, i = 1;
while (front < q.length && i < vals.length) {
const curr = q[front++]; // parent waiting to be wired to its two children
if (vals[i] !== '#') { // left child
const left = new TreeNode(parseInt(vals[i]));
curr.left = left;
q.push(left);
}
i++;
if (vals[i] !== '#') { // right child
const right = new TreeNode(parseInt(vals[i]));
curr.right = right;
q.push(right);
}
i++;
}
return root;
}
out.join(',') (rather than appending val + ',') avoids a trailing comma, so i < vals.length is the clean bound. If you do build the string with a trailing comma, split leaves a stray '' token at the end β then you need i < vals.length - 1, or that empty string becomes TreeNode(NaN).build(lo, hi) returns every BST over the values lo..hi. Pick each root r, then take the cross product of every left tree from build(lo, r-1) with every right tree from build(r+1, hi). Pairs with 96 (count them) β 95 is the enumeration.function generateTrees(n: number): (TreeNode | null)[] {
function build(lo: number, hi: number): (TreeNode | null)[] {
if (lo > hi) return [null]; // ONE empty tree β NOT []
const res: (TreeNode | null)[] = [];
for (let r = lo; r <= hi; r++)
for (const L of build(lo, r - 1))
for (const R of build(r + 1, hi))
res.push(new TreeNode(r, L, R));
return res;
}
return build(1, n);
}
[null], not [] β with [] the inner loops never run and every tree with an empty child disappears. Output size is Catalan(n), so time is O(4βΏ/βn) no matter what; memoizing (lo, hi) only saves constants.FAANG "easy" tree questions are pure recall β none needs a new idea, each is the Β§1 contract with a one-line combine. If the sheet is the last thing you see, these are the ones an interviewer is most likely to fire. Have every one in your fingers.
// Max Depth (104) β the height IS the answer
function maxDepth(root: TreeNode | null): number {
if (!root) return 0;
const L = maxDepth(root.left);
const R = maxDepth(root.right);
return 1 + Math.max(L, R);
}
// Invert (226) β swap each node's two children, then recurse
function invertTree(root: TreeNode | null): TreeNode | null {
if (!root) return null;
[root.left, root.right] = [root.right, root.left]; // 1) swap the children
invertTree(root.left); // 2) invert each subtree
invertTree(root.right);
return root;
}
// Same Tree (100) β same shape AND same values
function isSameTree(p: TreeNode | null, q: TreeNode | null): boolean {
if (!p && !q) return true; // both empty β identical
if (!p || !q) return false; // exactly one empty β different
if (p.val !== q.val) return false; // values differ here
const L = isSameTree(p.left, q.left);
const R = isSameTree(p.right, q.right);
return L && R;
}
// Symmetric (101) β is the tree its own mirror?
function isSymmetric(root: TreeNode | null): boolean {
function mirror(a: TreeNode | null, b: TreeNode | null): boolean {
if (!a && !b) return true; // both empty β mirrored
if (!a || !b) return false; // one empty β not mirrored
if (a.val !== b.val) return false; // values differ
const L = mirror(a.left, b.right); // outer β outer
const R = mirror(a.right, b.left); // inner β inner
return L && R;
}
if (!root) return true;
return mirror(root.left, root.right);
}
// Path Sum (112) β does ANY root-to-leaf path total `target`?
function hasPathSum(root: TreeNode | null, target: number): boolean {
if (!root) return false;
if (!root.left && !root.right) return root.val === target; // a leaf
const rest = target - root.val;
const L = hasPathSum(root.left, rest);
const R = hasPathSum(root.right, rest);
return L || R;
}
| Operation | Time | Space | Note |
|---|---|---|---|
| Any traversal / return-value DFS | O(n) | O(h) | h = height: O(log n) balanced, O(n) skewed |
| BFS level order | O(n) | O(w) | w = max width (up to n/2) |
| BST search / insert / delete / LCA-in-BST | O(h) | O(h) | O(log n) only if balanced |
| Construct / serialize | O(n) | O(n) | each node placed once (hash map for the inorder split) |
O(h) on the call stack. On a skewed tree that's O(n) β the reason deep trees want an explicit stack or Morris traversal.| Bug | The input that exposes it |
|---|---|
| Confusing RETURN with RECORD (return both branches instead of one). | A tree where the longest path/diameter doesn't pass through the root β answer comes out too small. |
| Validating a BST with a local check. | [5,1,4,null,null,3,6] β node 3 sits in the right subtree but is < 5; a local check wrongly passes it. |
| Inconsistent height base case (null β 0 vs leaf β 0). | A single node β off-by-one height/diameter. |
| Forgetting to clamp negative child gains in max path sum. | A tree with negative subtrees β they drag the sum down instead of being skipped. |
BFS with array.shift() β O(n) per dequeue. | A wide tree β quietly O(nΒ²); use a head index. |
| Recursion on a deep skewed tree. | A 10β΅-node "linked-list" tree β stack overflow; go iterative. |
Own each pattern and these are in range. Bold = Hard tier.
| Pattern | Representative LeetCode | Tier |
|---|---|---|
| Traversal | 102 Level Order Β· 199 Right Side View Β· 103 Zigzag Β· 314 Vertical Order | Med |
| Return-value (post-order) | 104 Max Depth Β· 110 Balanced Β· 543 Diameter Β· 222 Count Complete Β· 1110 Delete Nodes Β· 979 Distribute Coins Β· 124 Max Path Sum Β· 968 Binary Tree Cameras | Easy β Hard |
| BST | 98 Validate Β· 230 Kth Smallest Β· 173 Iterator Β· 235 LCA Β· 700 Search Β· 701 Insert Β· 285 Successor Β· 450 Delete | Med |
| LCA | 236 LCA of Binary Tree Β· 1644 LCA (may not exist) Β· 437 Path Sum III | Med |
| Build / serialize | 105/106 Build from Traversals Β· 95 Unique BSTs II (generate all) Β· 297 Serialize & Deserialize | Med β Hard |
| Easy bank | 104 Max Depth Β· 226 Invert Β· 100 Same Β· 101 Symmetric Β· 112 Path Sum | Easy |
Cover the right column (blurred β hover/tap to reveal). Say your answer out loud first.
| Prompt | Answer |
|---|---|
| State the 3-question contract for any tree recursion. | (1) What does dfs RETURN to the parent? (2) What's the BASE CASE return? (3) How does the parent COMBINE children's returns β and what does it RECORD globally? |
In diameter, what does dfs return vs record? | Returns the height (1 + max(L,R)) for the parent; records L + R (the bending path) into a global. |
| Why is a local "left < node < right" check wrong for BST validation? | A node must fit the range imposed by all ancestors, not just its parent β a far-ancestor bound can be violated while the local check passes. |
| What two facts make a BST powerful? | Navigate by comparison (O(h)), and an inorder traversal emits values in sorted order. |
| In general-tree LCA, when is a node the answer? | When both its left and right recursions return non-null β i.e. p and q are split across its two sides. |
| How do preorder + inorder rebuild a tree? | Preorder's front is the root; its index in inorder splits the remaining values into the left and right subtrees; recurse. |
| What's the true space cost of "O(1)" recursive tree code? | O(h) call stack β O(n) on a skewed tree. Use an explicit stack / Morris for deep trees. |
| Deleting a BST node with two children β what's the move? | Overwrite its value with its inorder successor (smallest in the right subtree), then delete that successor β which has β€1 child, an easy case. |
Why does iterative BST validation track prev? | A BST's inorder walk must be strictly increasing β each popped value must exceed the previous one; prev is that previous value. |
In inorderSuccessor, when do you go left vs right? | If p.val < cur.val, cur is a candidate β record it and go left hunting a smaller one; otherwise go right (the successor must be bigger than p). |
Why does delNodes assign node.left = dfs(node.left, gone) instead of just calling it? | The return value is how a deleted child gets cut out β it returns null and the parent overwrites its pointer. Without the assignment the parent still points at the deleted node. |
In Distribute Coins, what does dfs return, and where do the moves come from? | Returns the subtree's net balance val + L + R β 1. Moves = |L| + |R| added at each node β every unit crossing a childβparent edge costs one move. |
Binary Tree Cameras β the three states and what null returns? | 0 = uncovered, 1 = has camera, 2 = covered. null β 2. A child at 0 forces a camera here; a child at 1 covers me; otherwise return 0 and let the parent decide. |
Why is BSTIterator.next() amortized O(1)? | Each node is pushed and popped exactly once across the whole iteration β one call may push a long spine, but n calls do O(n) total work. |
pre/in/post Β· iterative pre/in/post Β· BFS levelOrdermaxDepth Β· invertTree Β· isSameTree Β· isSymmetric Β· hasPathSumdiameter + maxPathSum (say the contract first)searchBST Β· insertIntoBST (rec + iter) Β· lcaBST Β· isValidBST (range + inorder) Β· kthSmallest Β· BSTIterator Β· deleteNodelowestCommonAncestorbuildTree + serialize/deserializeFor each DFS: write the three contract lines β RETURN, BASE, RECORD β as comments before the code.