Deck 1 gave you the four traversals; this deck turns them into answers. Almost every problem here is one traversal that returns a value up the tree (a height, a diameter, a path sum), while a single global variable watches for the best. The rest are views (what the tree looks like from the top, side, or straightened into levels) and paths & ancestors (root-to-leaf sums, lowest common ancestor, nodes at distance K). Learn the two shapes, bottom-up postorder and level-batched BFS, and eighteen problems collapse into a handful of patterns.
This is not a list of problems. It is 17 learning units, and every one runs the same four beats. Go in order and the problems stop feeling random.
ASSUMEDA queue for every level-based unit. Stacks & Queues is step 09 and not built yet, so the batching trick is derived here.
18 PROBLEMS · EVERY ONE LINKS TO LEETCODE TO SOLVE
Click any problem to jump straight to it. Solved ones turn green. Press I from anywhere to come back here. The number is the sheet's row, not your position — the deck teaches in the order that builds, so you will meet them slightly out of numerical sequence.
Eighteen problems, a handful of shapes. The cards are the phrase that selects the pattern: “height/diameter” → bottom-up postorder, “by level” → batched BFS, “path sum” → carry-the-path, “distance K” → tree-as-graph.
one postorder that RETURNS a value up the tree, a global tracks the best
BOTTOM-UP POSTORDER + GLOBALO(n)recurse on two nodes in lockstep, comparing structure and values
PAIRED RECURSIONO(n)BFS with size-batches; one batch is one level, read left→right or reversed
LEVEL-BATCHED BFSO(n)BFS carrying a horizontal column index; bucket nodes by column
BFS + COLUMN INDEXO(n log n)carry the path (or running sum) down; test or record it at a leaf
DFS CARRYING THE PATHO(n) · O(n²) to printLCA is one bottom-up pass; distance-K turns the tree into a graph + BFS
ANCESTOR / TREE-AS-GRAPHO(n)Every problem here is one O(n) traversal. The bottom-up family returns a value up the tree while a global watches; the level family is BFS batched by queue.size(). The number to fear is index overflow (width) and recursion depth.
TREE PROBLEM ⇒ ONE O(n) PASS · BOTTOM-UP POSTORDER OR LEVEL-BATCHED BFS
Seventeen units. The depth family (one postorder, one global) opens; then structure comparisons, the views (BFS by level and by column), paths (carry-it-down and prefix-sum), and ancestors (LCA and tree-as-graph). Four are concept units, top/bottom view, boundary, burn, taught but not on LeetCode.
For every node you need a fact that depends on its subtrees. How tall they are, say. In what order must you visit nodes so that fact is always ready when you need it?
Children first, then the node. If a node's answer is built out of its subtrees' answers, it simply cannot be computed until both subtrees have reported, so the visit order is forced, not chosen. That bottom-up order is postorder, and it is why the first four units of this deck are the same function wearing different hats: each node returns one value to its parent, and a running best is updated on the way up.
You are running BFS with a queue, popping a node and pushing its children. At the instant just before you start on level k, what is sitting in the queue?
Exactly level k, and nothing else. Level k−1 has all been popped, and the only things pushed while popping it were its children, which are precisely level k. So the queue's size at that moment is the level's node count: snapshot it, pop that many, and you have isolated one level as a group. Reverse the group for zig-zag, take its last for right view, measure it for width. units 07, 08 and 13 are all that one observation.
The maximum depth (height) is the archetype of the whole bottom-up family. A node's height is 1 + the larger of its two children's heights, with an empty subtree contributing 0. That's a two-line postorder: ask each child for its height, combine, return. Every harder problem in this group, balanced, diameter, max path sum. Is this same recursion with an extra thing tracked on the side.
HOW TALL IS THE TREE, AND WHY IS IT A POSTORDER?
Why is computing a node's height a postorder (children-first) operation?
The parent's answer needs the children's answers. 1 + max(leftH, rightH) can't be evaluated until both recursive calls return, which is the definition of postorder. Every 'combine your children's results at the node' computation is a postorder, the shape you'll reuse for the next three problems.
A root with a left child that is a single leaf, and a right child that has one child (a chain of 2). What's the max depth (number of nodes on the longest root-to-leaf path)?
3. Left path: root + leaf = 2 nodes. Right path: root + child + grandchild = 3 nodes. Depth is the max, so 3. (LeetCode counts nodes; some texts count edges, which would be 2. Always check which the problem wants.)
Postorder is left → right → root: a node is recorded only once both its subtrees are complete, so the root is always last. Badge #7 here. Same walk again, with the record line moved after both calls. This children-before-parent order is what you need whenever a node's answer is built out of its children's answers: deleting a tree, heights, sizes, any bottom-up tree DP. Half of deck 2's problems are a postorder in disguise.
“Maximum depth / height of the tree.” The base case of the whole bottom-up family.
A node's depth is 1 plus the deeper of its two children. Recurse; an empty subtree is 0.
// Height = 1 + the taller subtree; empty = 0. int maxDepth(TreeNode* root) { if (root == nullptr) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); }
// Height = 1 + the taller subtree; empty = 0. public int maxDepth(TreeNode root) { if (root == null) return 0; return 1 + Math.max(maxDepth(root.left), maxDepth(root.right)); }
# Height = 1 + the taller subtree; empty = 0. def maxDepth(root): if root is None: return 0 return 1 + max(maxDepth(root.left), maxDepth(root.right))
Off-by-one between nodes and edges. LeetCode counts nodes on the longest path (empty tree = 0, single node = 1). If a variant asks for height in edges, it's one less. Decide which the problem wants before you code the base case.
A tree is height-balanced if, at every node, its two subtrees' heights differ by at most 1. The naive check computes height at each node. O(n²). The trick: make the height helper do double duty. It returns the height as usual, but returns a sentinel −1 the moment it discovers any imbalance below, and every caller short-circuits on −1. One postorder, O(n).
HOW DO YOU CHECK BALANCE IN ONE PASS, NOT O(n²)?
What makes the O(n) balanced check faster than the naive one?
One combined pass instead of a height() call per node. Folding the balance test into the height recursion, and returning −1 to abort as soon as any subtree is unbalanced. Visits each node a single time. The −1 sentinel doubles as both 'unbalanced' and a value the parent can cheaply test.
Why return −1 specifically as the 'unbalanced' signal, rather than a separate boolean?
−1 is impossible as a height, so it's a safe out-of-band signal. Real heights are non-negative, so −1 unambiguously means 'unbalanced somewhere below' and rides the existing integer return, no extra boolean, no global. Each caller checks for it first and short-circuits, giving the single-pass O(n).
This is the shape behind most of deck 2, and it is worth meeting once slowly. Asking “how tall is this subtree?” is a question a node cannot answer on the way down. It has to wait for both children first. That dependency is what forces postorder; it is not a stylistic choice. Watch the leaves get their h1 badges before any parent gets anything, and the root answer last because it had to. The balance test then rides along free: since every node already knows both child heights, checking |L − R| ≤ 1 costs nothing extra, which is how an O(n²) check-height-at-every-node solution collapses to O(n).
“Is the tree height-balanced?” (every node's subtrees differ in height by ≤ 1). A height recursion with an early-exit sentinel.
Compute height as usual, but return −1 the instant any subtree is unbalanced; propagate −1 up so the whole check is one O(n) pass.
// Height helper returns -1 as an 'unbalanced below' sentinel. int height(TreeNode* node) { if (node == nullptr) return 0; int l = height(node->left); if (l == -1) return -1; int r = height(node->right); if (r == -1) return -1; if (abs(l - r) > 1) return -1; // unbalanced here return 1 + max(l, r); } bool isBalanced(TreeNode* root) { return height(root) != -1; }
// Height helper returns -1 as an 'unbalanced below' sentinel. int height(TreeNode node) { if (node == null) return 0; int l = height(node.left); if (l == -1) return -1; // bail out early int r = height(node.right); if (r == -1) return -1; if (Math.abs(l - r) > 1) return -1; // -1 can never be a real height return 1 + Math.max(l, r); } public boolean isBalanced(TreeNode root) { return height(root) != -1; // one postorder pass, O(n) }
# Height helper returns -1 as an 'unbalanced below' sentinel. def isBalanced(root): def height(node): if node is None: return 0 l = height(node.left) if l == -1: return -1 r = height(node.right) if r == -1: return -1 if abs(l - r) > 1: # unbalanced here return -1 return 1 + max(l, r) return height(root) != -1
Calling a separate height() at every node. O(n²). The naive check recomputes heights repeatedly; fold the balance test into one height pass with the −1 sentinel for O(n). Also propagate −1 immediately, don't compute the right subtree if the left already failed.
The diameter is the longest path between any two nodes (measured in edges), and it need not pass through the root. The insight: the longest path that bends at a node is leftHeight + rightHeight. So run the height recursion, and at every node update a global maximum with leftH + rightH. Crucially, the node still returns its height (1 + max), not its diameter. That return/track split is the whole lesson.
HOW DO YOU FIND THE LONGEST PATH WHEN IT NEED NOT TOUCH THE ROOT?
At each node, what value do you RETURN to the parent, and what value do you use to UPDATE the answer?
Return height, update diameter. The parent needs this node's height; the longest path through this node is leftH + rightH. Confusing the two, returning the diameter. Breaks the parent's height math and is the single most common diameter bug.
On the perfect 7-node tree (root 1; 2,3; 4,5,6,7), what is the diameter in edges?
4 edges. The deepest bending path runs from a bottom-level leaf up to the root and back down to another bottom leaf: e.g. 4–2–1–3–6, four edges. At the root, leftHeight + rightHeight = 2 + 2 = 4, which is where the global max lands.
Diameter is the first problem where those two numbers come apart, and mixing them up is the classic bug. The longest path through a node uses both sides, L + R, but the value handed to the parent can only use one, because a path cannot fork. So the node updates a running best with L + R, then returns 1 + max(L, R). Same walk as the height above, same badges; only the extra bookkeeping line differs. Return L + R by mistake and you get a number that is too big and not a path at all.
“Diameter, longest path between any two nodes.” The path need not pass the root, so track a global while returning heights.
Run the height recursion; at each node the longest bending path is leftH + rightH. Update a global max with it. Return the node's height (1 + max), not its diameter.
// Return height; update the global diameter with leftH + rightH. int best = 0; int height(TreeNode* node) { if (node == nullptr) return 0; int l = height(node->left); int r = height(node->right); best = max(best, l + r); // longest path bending at node (edges) return 1 + max(l, r); // RETURN height, not diameter } int diameterOfBinaryTree(TreeNode* root) { best = 0; height(root); return best; }
// Return height; update the global diameter with leftH + rightH. int best = 0; int height(TreeNode node) { if (node == null) return 0; int l = height(node.left); int r = height(node.right); best = Math.max(best, l + r); // longest path BENDING here return 1 + Math.max(l, r); // what the parent needs } public int diameterOfBinaryTree(TreeNode root) { height(root); return best; }
# Return height; update the global diameter with leftH + rightH. def diameterOfBinaryTree(root): best = 0 def height(node): nonlocal best if node is None: return 0 l = height(node.left) r = height(node.right) best = max(best, l + r) # longest path bending at node (edges) return 1 + max(l, r) # RETURN height, not diameter height(root) return best
Returning the diameter instead of the height. The parent needs this node's height to compute its own; the diameter (leftH + rightH) is a side-effect tracked globally. Return the wrong one and every ancestor's math breaks. LeetCode measures the diameter in edges, which is exactly leftH + rightH.
Maximum path sum is the diameter idea with values and one extra move. The best path bending at a node is node->val + max(0, gainLeft) + max(0, gainRight). You clamp negative gains to 0 because a negative-sum branch is better skipped. Track that in a global. What a node returns upward is node->val + max(0, max(gainLeft, gainRight)). A path can only continue up through one child.
HOW DO YOU FIND THE BEST-SUM PATH, WITH NEGATIVES IN PLAY?
Why do you clamp each child's returned gain with max(0, gain)?
A negative branch is worse than no branch. If extending into a child would lower the sum, you take 0 instead, effectively not going that way. Without the clamp, a strongly negative subtree corrupts every ancestor's best-sum, and the solution fails on any tree with negative node values (which the problem explicitly allows).
Why does the RETURNED value use max(leftGain, rightGain) while the GLOBAL update uses leftGain + rightGain?
Bend here (both children) vs continue upward (one child). The answer candidate at a node bends there and uses both branches: val + L + R. But what you hand your parent must be a path that can extend further up, and a valid path can't fork, so it takes only the better child: val + max(L, R). Same return/track split as diameter, now with values.
Max path sum is diameter with weights, and it adds one more idea: a branch that contributes a negative total is simply not taken, so each child's gain is clamped with max(0, …). As before, the answer that may be recorded at a node (val + L + R. The path turning here) is different from what the node returns (val + max(L, R), a path that must keep going up). Follow the ↑ badges: node 3 records 16 but only returns 10, and the winning path bends at the root for 18.
“Maximum path sum, path may start and end anywhere.” Diameter with values, negatives allowed. Clamp and track a global.
Each node's best downward gain is val + max(0, better child). The best path bending at the node is val + max(0,left) + max(0,right). Track that globally. Return the gain (one child only).
// Clamp child gains to 0; track val+L+R, return val+max(L,R). int best = INT_MIN; int gain(TreeNode* node) { if (node == nullptr) return 0; int l = max(0, gain(node->left)); // drop a negative branch int r = max(0, gain(node->right)); best = max(best, node->val + l + r); // best path bending at this node return node->val + max(l, r); // a path continues up via ONE child } int maxPathSum(TreeNode* root) { best = INT_MIN; gain(root); return best; }
// Clamp child gains to 0; track val+L+R, return val+max(L,R). int best = Integer.MIN_VALUE; int gain(TreeNode node) { if (node == null) return 0; int l = Math.max(0, gain(node.left)); // drop a negative branch int r = Math.max(0, gain(node.right)); best = Math.max(best, node.val + l + r); // a path PEAKING here return node.val + Math.max(l, r); // upward: only one side } public int maxPathSum(TreeNode root) { gain(root); return best; }
# Clamp child gains to 0; track val+L+R, return val+max(L,R). def maxPathSum(root): best = float('-inf') def gain(node): nonlocal best if node is None: return 0 l = max(0, gain(node.left)) # drop a negative branch r = max(0, gain(node.right)) best = max(best, node.val + l + r) # best path bending here return node.val + max(l, r) # continue up via ONE child gain(root) return best
Not clamping negative gains, or initialising best to 0. A negative child should contribute 0 (drop it), not its negative value. And best must start at −∞, not 0. A tree of all-negative values has a negative answer (the least-negative single node), which a 0 floor would wrongly beat.
Two trees are the same if they have identical structure and values. Recurse on the two roots in lockstep: if both are null, they match; if exactly one is null, or their values differ, they don't; otherwise recurse on (p.left, q.left) and (p.right, q.right). This paired recursion is the template for symmetric and invert too.
HOW DO YOU COMPARE TWO TREES NODE-BY-NODE?
What are the three base/short-circuit cases when comparing two nodes p and q?
Both-null true, one-null false, value-mismatch false. These three guards handle every way two trees can align or diverge at a position; passing all three means the current nodes agree and you descend. Getting the null cases right (both vs exactly-one) is what makes it correct on differently-shaped trees.
Same Tree, Symmetric and Invert are all variations of one idea. What is it?
Paired / mirrored recursion. Same tree walks the two trees in the same orientation; symmetric walks one tree against itself in mirror orientation (left vs right); invert physically swaps each node's children. Seeing them as one template with different child-pairings is the point of grouping them.
The general form of the pair walk that unit 06 uses mirrored. The drawing is p and the badge on each node is what q holds at the same position, so a disagreement is visible where it happens, and && means nothing below it is ever visited. The line that matters most is the first one: null against non-null is already a difference, before any value is compared.
“Are two trees identical?” Structure and values must match, a paired recursion.
Compare the two roots together: both null is a match; exactly one null or unequal values is a mismatch; otherwise recurse on the two left children and the two right children.
// Compare two trees in lockstep. bool isSameTree(TreeNode* p, TreeNode* q) { if (!p && !q) return true; // both empty -> match if (!p || !q || p->val != q->val) return false; return isSameTree(p->left, q->left) && isSameTree(p->right, q->right); }
// Compare two trees in lockstep. public boolean isSameTree(TreeNode p, TreeNode q) { if (p == null && q == null) return true; // both empty -> match if (p == null || q == null || p.val != q.val) return false; return isSameTree(p.left, q.left) && isSameTree(p.right, q.right); }
# Compare two trees in lockstep. def isSameTree(p, q): if not p and not q: # both empty -> match return True if not p or not q or p.val != q.val: return False return isSameTree(p.left, q.left) and isSameTree(p.right, q.right)
Mishandling the null cases. 'Both null' is a match (you reached the end together); 'exactly one null' is a mismatch (shapes differ). Collapsing these, e.g. returning false whenever either is null. Breaks on trees that legitimately end at the same place.
A tree is symmetric if its left and right subtrees are mirror images. Compare them with a paired recursion, but mirrored: match left.left against right.right, and left.right against right.left. Invert is the active cousin: at every node, swap its two children and recurse, producing the mirror tree that symmetric merely checks for.
HOW DO YOU CHECK MIRROR SYMMETRY, AND PRODUCE THE MIRROR?
In the symmetric check, which children do you compare across the two subtrees?
Cross-pair: outer with outer, inner with inner. A mirror reflects left↔right, so the left subtree's left edge corresponds to the right subtree's right edge. Comparing same-side children instead tests whether the two subtrees are identical (Same Tree), which is a different question and passes only coincidentally symmetric trees.
Invert Binary Tree in one line of logic per node. What is it?
Swap children, recurse. swap(node->left, node->right) at every node flips the tree horizontally. It's the constructive version of the symmetric check: invert one half and it should equal the other. Famous for a reason. It's three lines and a favourite warm-up.
Every recursion so far carried one pointer. Symmetry cannot: asking “is this node mirrored?” is meaningless on its own, so the recursion carries two and compares them. The pairing is the whole problem, a mirror matches outside with outside: (a.left, b.right) and (a.right, b.left). Pair left with left instead and the check silently becomes “are these two subtrees identical”, which is Same Tree, a different question that happens to return true on symmetric-looking inputs. Invert the tree and you get the third member of the family: same two-pointer walk, but swapping instead of comparing.
“Is the tree a mirror of itself?” Compare the two halves with a MIRRORED paired recursion.
Symmetry means the left subtree mirrors the right. Compare left.left with right.right and left.right with right.left, the cross-pairing. Base cases as in same-tree.
// Mirror check: cross-pair the children. bool isMirror(TreeNode* a, TreeNode* b) { if (!a && !b) return true; if (!a || !b || a->val != b->val) return false; return isMirror(a->left, b->right) // OUTER pair && isMirror(a->right, b->left); // INNER pair } bool isSymmetric(TreeNode* root) { return !root || isMirror(root->left, root->right); }
// Mirror check: cross-pair the children. boolean isMirror(TreeNode a, TreeNode b) { if (a == null && b == null) return true; if (a == null || b == null || a.val != b.val) return false; return isMirror(a.left, b.right) // OUTER pair && isMirror(a.right, b.left); // INNER pair } public boolean isSymmetric(TreeNode root) { return root == null || isMirror(root.left, root.right); }
# Mirror check: cross-pair the children. def isSymmetric(root): def isMirror(a, b): if not a and not b: return True if not a or not b or a.val != b.val: return False return isMirror(a.left, b.right) and isMirror(a.right, b.left) return not root or isMirror(root.left, root.right)
Comparing same-side children (left.left with right.left). That checks whether the two subtrees are identical, not mirror images. Symmetry reflects across the centre, so it must be left.left ↔ right.right and left.right ↔ right.left. Same-side pairing passes only trees that happen to be symmetric AND identical-sided.
“Invert / mirror the tree.” Swap every node's two children.
At each node swap its left and right pointers, then recurse into both (order doesn't matter). The result is the mirror image, the very tree Symmetric tests against.
// Swap children at every node. TreeNode* invertTree(TreeNode* root) { if (root == nullptr) return nullptr; swap(root->left, root->right); invertTree(root->left); invertTree(root->right); return root; }
// Swap children at every node. public TreeNode invertTree(TreeNode root) { if (root == null) return null; TreeNode t = root.left; root.left = root.right; root.right = t; invertTree(root.left); invertTree(root.right); return root; }
# Swap children at every node. def invertTree(root): if root is None: return None root.left, root.right = root.right, root.left invertTree(root.left) invertTree(root.right) return root
Swapping AFTER recursing without care, or forgetting to return the root. Swap then recurse, or recurse then swap. Both work as long as you swap each node exactly once. Just return the (unchanged) root pointer; the tree is mutated in place. It's the constructive twin of the Symmetric check.
Zig-zag (spiral) traversal is a level-order walk where the direction alternates each level: left-to-right, then right-to-left, and so on. Run the standard size-batched BFS, but keep a leftToRight flag; for a right-to-left level, place values into the level list from the back (or just reverse the level before appending). Everything else is ordinary level order.
HOW DO YOU READ THE TREE IN A BOUSTROPHEDON (ALTERNATING) ORDER?
For zig-zag, does the way you push children onto the queue change between levels?
Push order stays left-then-right; only the output flips. The queue must keep producing correct level-by-level order, so children always go on left-then-right. The zig-zag is purely a presentation choice on each level's collected values, reverse alternate levels. Fiddling with push order corrupts the levels below.
On the 7-node tree (root 1; 2,3; 4,5,6,7), what is the zig-zag order?
1 · 3,2 · 4,5,6,7. Level 0 has just the root (1). Level 1 is read right-to-left, so [3, 2] instead of [2, 3]. Level 2 flips back to left-to-right: [4, 5, 6, 7]. Concatenated: 1, 3, 2, 4, 5, 6, 7. The mechanism slide runs the plain level order; zig-zag just reverses every other captured level.
Level order is breadth-first: top to bottom, left to right, using a queue instead of recursion. Pop the front, record it, push its children to the back. Because a queue is FIFO, everything on one level is processed before anything on the next. watch the queue hold exactly the current frontier and nothing else. Read the queue's size at the start of a round and you have one level isolated, which is what zig-zag, right-side view and almost every 'by level' problem in deck 2 are built on.
“Zig-zag / spiral level order.” Level order with the direction flipped each level.
Standard size-batched BFS, plus a leftToRight flag. On a right-to-left level, place values from the back of the level list (or reverse it before appending). Children still enqueue left-first.
// Level order; reverse the placement on alternate levels. vector<vector<int>> zigzagLevelOrder(TreeNode* root) { vector<vector<int>> res; if (!root) return res; queue<TreeNode*> q; q.push(root); bool leftToRight = true; while (!q.empty()) { int n = q.size(); vector<int> level(n); for (int i = 0; i < n; i++) { TreeNode* node = q.front(); q.pop(); int idx = leftToRight ? i : n - 1 - i; // place, don't reorder queue level[idx] = node->val; if (node->left) q.push(node->left); if (node->right) q.push(node->right); } leftToRight = !leftToRight; res.push_back(level); } return res; }
// Level order; reverse the placement on alternate levels. public List<List<Integer>> zigzagLevelOrder(TreeNode root) { List<List<Integer>> res = new ArrayList<>(); if (root == null) return res; Queue<TreeNode> q = new ArrayDeque<>(); q.add(root); boolean leftToRight = true; while (!q.isEmpty()) { int n = q.size(); // exactly this level Integer[] level = new Integer[n]; for (int i = 0; i < n; i++) { TreeNode node = q.poll(); level[leftToRight ? i : n - 1 - i] = node.val; // place, do not push if (node.left != null) q.add(node.left); // ALWAYS left first if (node.right != null) q.add(node.right); } res.add(Arrays.asList(level)); leftToRight = !leftToRight; } return res; }
# Level order; reverse the placement on alternate levels. from collections import deque def zigzagLevelOrder(root): res = [] if not root: return res q = deque([root]) left_to_right = True while q: n = len(q) level = [0] * n for i in range(n): node = q.popleft() idx = i if left_to_right else n - 1 - i # place, don't reorder queue level[idx] = node.val if node.left: q.append(node.left) if node.right: q.append(node.right) left_to_right = not left_to_right res.append(level) return res
Alternating the push order instead of the output order. The queue must keep producing correct levels, so always enqueue children left-then-right. Only flip how you record each level (reverse the collected values). Reversing the queue logic scrambles every level below.
The right side view is what you see standing to the right of the tree: the last node of each level. Size-batched BFS gives it directly, the final node popped in each level round is that level's rightmost. (A DFS that visits right before left and records the first node seen at each new depth works too.) Populating next-right pointers is the same 'per level' idea made into links: connect each node to its right neighbour on the same level.
HOW DO YOU GET THE RIGHTMOST NODE PER LEVEL, AND LINK NEIGHBOURS?
Using BFS, how do you capture the right-side view?
The last-popped node of each level. Since BFS drains a level left-to-right, the final pop in that level's batch is its rightmost node, exactly what's visible from the right. (Recording the first per level gives the left view instead.) A right-first DFS recording the first node at each new depth is the tidy recursive alternative.
Populating next-right pointers on a PERFECT binary tree can be done in O(1) extra space. What's the key relation?
Wire the next level using the current level's next chain. For a node, its left child's neighbour is its right child; its right child's neighbour is the left child of the node's own next (if any). Walking each level via the next pointers you already set lets you connect the level below with no queue. O(1) space. (The BFS solution is O(n) space but works on any tree.)
Right view, top view, bottom view, vertical order and maximum width are the same level-order walk. Each node gets a key, and the only thing that changes between the five problems is which node keeps a key when several compete for it. Here the key is the level and the rule is last wins, so every node overwrites its level's entry and the survivor is the rightmost. Flip that one word to first wins and, with no other change, you would be looking at the left view.
“Right side view, the rightmost node of each level.” Size-batched BFS, take the last.
BFS by level; the last node popped in each level is its rightmost. Record it. (Equivalently, a right-first DFS recording the first node seen at each new depth.)
// The last node popped in each BFS level is the rightmost. vector<int> rightSideView(TreeNode* root) { vector<int> res; if (!root) return res; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int n = q.size(); for (int i = 0; i < n; i++) { TreeNode* node = q.front(); q.pop(); if (i == n - 1) res.push_back(node->val); // rightmost of level if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } return res; }
// The last node popped in each BFS level is the rightmost. public List<Integer> rightSideView(TreeNode root) { List<Integer> res = new ArrayList<>(); if (root == null) return res; Queue<TreeNode> q = new ArrayDeque<>(); q.add(root); while (!q.isEmpty()) { int n = q.size(); for (int i = 0; i < n; i++) { TreeNode node = q.poll(); if (i == n - 1) res.add(node.val); // the LAST of this level if (node.left != null) q.add(node.left); if (node.right != null) q.add(node.right); } } return res; }
# The last node popped in each BFS level is the rightmost. from collections import deque def rightSideView(root): res = [] if not root: return res q = deque([root]) while q: n = len(q) for i in range(n): node = q.popleft() if i == n - 1: # rightmost of level res.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) return res
Assuming the right view is just the right spine. If the right subtree is shorter, a node from the LEFT subtree can be the rightmost at a deep level, so you can't simply follow right pointers. Take the last node of each BFS level (or, in right-first DFS, the first node at each new depth).
“Connect each node to its right neighbour on the same level.” A perfect tree admits an O(1)-space wiring.
For a perfect tree, use the current level's next links to wire the level below: a node's left.next = its right; its right.next = node.next ? node.next.left : null. Walk level by level via next pointers, no queue.
// Perfect tree: wire level L+1 using level L's next chain. O(1) space. Node* connect(Node* root) { Node* leftmost = root; while (leftmost && leftmost->left) { Node* node = leftmost; while (node) { node->left->next = node->right; node->right->next = node->next ? node->next->left : nullptr; node = node->next; // walk the current level } leftmost = leftmost->left; // drop to the next level } return root; }
// Perfect tree: wire level L+1 using level L's next chain. O(1) space. public Node connect(Node root) { Node leftmost = root; while (leftmost != null && leftmost.left != null) { Node node = leftmost; while (node != null) { node.left.next = node.right; // within this parent if (node.next != null) node.right.next = node.next.left; // across to the neighbour node = node.next; // walk level L via next } leftmost = leftmost.left; // drop to level L+1 } return root; }
# Perfect tree: wire level L+1 using level L's next chain. O(1) space. def connect(root): leftmost = root while leftmost and leftmost.left: node = leftmost while node: node.left.next = node.right node.right.next = node.next.left if node.next else None node = node.next # walk the current level leftmost = leftmost.left # drop to the next level return root
Reaching for a queue (O(n) space) when O(1) is expected. The BFS solution is correct and works on any tree, but the interview point of LC 116 (perfect tree) is the constant-space wiring: connect the next level using the next pointers you already set on the current one. For a non-perfect tree (LC 117) the same idea needs a dummy head per level.
The top view is the set of nodes visible from directly above, the first node encountered at each horizontal column. Give the root column 0, a left child col − 1, a right child col + 1. Run a BFS (level order) carrying each node's column; the first time a column appears, that node is on the top view. BFS (not DFS) is essential so the topmost node wins ties. It's a concept unit, the pattern, not a LeetCode row.
WHAT DOES THE TREE LOOK LIKE FROM STRAIGHT ABOVE?
Why must the top view use BFS rather than DFS?
BFS guarantees the topmost node arrives first at each column. Top view wants the highest node per column; BFS visits by increasing depth, so the first node it sees in a column is the shallowest. DFS could descend into a deep node in that column before a shallower sibling, recording the wrong one. (Bottom view flips this: keep the LAST node per column.)
How is bottom view different from top view in the same framework?
Keep the last node per column, not the first. Top view records a column only if unseen (first wins); bottom view updates the column every time (last wins), so the deepest node in each column survives. Same BFS, same column indexing, one flips 'first' to 'last'.
Key each node by horizontal distance, root 0, left child −1, right child +1, and keep only the first node seen at each distance. The reason that works is worth pausing on. BFS visits shallower nodes before deeper ones, so first at this distance is automatically highest at this distance, which is the definition of the top view. Watch node 5 arrive at hd 0 and get turned away. The root already owns that column. Do this with a DFS instead and the guarantee evaporates.
The bottom view is the mirror of the top view: the last (lowest) node at each horizontal column, seen from below. Same column indexing (root 0, left −1, right +1) and the same BFS carrying the column, but instead of keeping the first node per column, you overwrite the column with every node, so the deepest one wins. Output the columns left to right. Also a concept unit.
WHAT DOES THE TREE LOOK LIKE FROM STRAIGHT BELOW?
Top view keeps the first node per column; bottom view keeps the last. Why does BFS make 'last' mean the correct node for bottom view?
Later in BFS = deeper. Because BFS advances level by level, every subsequent node in a given column is at least as deep as the previous. Overwriting therefore leaves the deepest node, the one you'd see looking up at the tree. Same machinery as top view, 'first' swapped for 'last'.
What decides the left-to-right ordering of the two views' output?
Order the output by column index. Each column contributes one node (first for top, last for bottom); presenting them left to right means iterating columns from the minimum to the maximum index. An ordered map keyed by column does this for free. Depth selects the node; column places it.
Bottom view is top view with the guard deleted. Same queue, same horizontal-distance key, but the entry is overwritten every time, so the last node to arrive at each distance is the one left standing, and the last to arrive is the deepest. The two problems are if (!seen.count(hd)) apart. Compare this run against the top view slide: identical badges, identical queue, and hd 0 resolves to 6 here where it resolved to 1 there.
Vertical order generalises the views: output every node, grouped by column, and within a column ordered by row (depth), breaking ties by value (LeetCode's rule). Run a BFS carrying (row, col); bucket nodes into a map keyed by column, and within each column sort by (row, value). Then read columns left to right. It's the full picture the top and bottom views each take one slice of.
HOW DO YOU LIST EVERY NODE GROUPED BY VERTICAL COLUMN?
Two nodes land in the same column at the same row (depth). How does LeetCode's vertical order break the tie?
Same column, same row ⇒ smaller value first. The ordering key inside a column is (row, value): deeper rows come after shallower, and ties at the same row are resolved by ascending value. This exact tie-break is what distinguishes LeetCode's 'Vertical Order Traversal' from the simpler top/bottom views (and it's why a plain BFS insertion order isn't quite enough).
How do you compute a node's column, and why does it place vertical lines correctly?
Left is −1, right is +1 from the parent's column. A vertical line through the tree is a constant horizontal offset from the root; each left step decreases it and each right step increases it. Nodes sharing a column value lie on the same vertical line. The definition of the traversal. Top/bottom view are just the first/last node of each such column.
Vertical order drops the competition entirely. Every node is kept, grouped by horizontal distance, so the answer is a list of columns rather than one node per key. Top and bottom view are then just this result with the first or last element of each column taken. The detail that bites in the real problem is tie-breaking: two nodes can share a column and a level, and LeetCode wants those ordered by value. BFS gives you the row order for free, but not that final tie-break.
“Vertical order traversal.” Group nodes by column; within a column order by (row, value).
BFS carrying (row, col): root (0,0), left (row+1, col−1), right (row+1, col+1). Bucket into an ordered map keyed by column; within each column sort by (row, value). Read columns left to right.
// BFS carrying (row, col); bucket by column, sort by (row, val). vector<vector<int>> verticalTraversal(TreeNode* root) { map<int, vector<pair<int,int>>> cols; // col -> (row, val) queue<tuple<TreeNode*,int,int>> q; q.push({root, 0, 0}); while (!q.empty()) { auto [node, row, col] = q.front(); q.pop(); cols[col].push_back({row, node->val}); if (node->left) q.push({node->left, row + 1, col - 1}); if (node->right) q.push({node->right, row + 1, col + 1}); } vector<vector<int>> res; for (auto& [col, vec] : cols) { sort(vec.begin(), vec.end()); // by row, then value vector<int> colVals; for (auto& [row, val] : vec) colVals.push_back(val); res.push_back(colVals); } return res; }
// BFS carrying (row, col); bucket by column, sort by (row, val). public List<List<Integer>> verticalTraversal(TreeNode root) { // TreeMap keeps the columns in left-to-right order for free TreeMap<Integer, List<int[]>> cols = new TreeMap<>(); Queue<Object[]> q = new ArrayDeque<>(); if (root != null) q.add(new Object[]{root, 0, 0}); while (!q.isEmpty()) { Object[] cur = q.poll(); TreeNode node = (TreeNode) cur[0]; int row = (int) cur[1], col = (int) cur[2]; cols.computeIfAbsent(col, k -> new ArrayList<>()).add(new int[]{row, node.val}); if (node.left != null) q.add(new Object[]{node.left, row + 1, col - 1}); if (node.right != null) q.add(new Object[]{node.right, row + 1, col + 1}); } List<List<Integer>> res = new ArrayList<>(); for (List<int[]> bucket : cols.values()) { // same column: by row, then by VALUE - that tie-break is the spec bucket.sort((x, y) -> x[0] != y[0] ? x[0] - y[0] : x[1] - y[1]); List<Integer> out = new ArrayList<>(); for (int[] e : bucket) out.add(e[1]); res.add(out); } return res; }
# BFS carrying (row, col); bucket by column, sort by (row, val). from collections import deque, defaultdict def verticalTraversal(root): cols = defaultdict(list) # col -> list of (row, val) q = deque([(root, 0, 0)]) while q: node, row, col = q.popleft() cols[col].append((row, node.val)) if node.left: q.append((node.left, row + 1, col - 1)) if node.right: q.append((node.right, row + 1, col + 1)) res = [] for col in sorted(cols): res.append([val for row, val in sorted(cols[col])]) # by row, then value return res
Forgetting the (row, value) tie-break. LeetCode's vertical order is stricter than a plain top-down bucket: two nodes in the same column AND same row must be ordered by ascending value. Sort each column by (row, value), not just insertion order. The usual reason a 'works on simple trees' solution fails.
The boundary traversal prints the tree's outline anti-clockwise: the left boundary (top-down, excluding leaves), then all leaves left-to-right, then the right boundary (bottom-up, excluding leaves). Each part is a small dedicated walk, and the care is in not double-counting the corners (root, and leaves that are also on a boundary). A concept unit. The technique matters more than any single judge problem.
HOW DO YOU PRINT THE OUTLINE OF THE TREE ANTI-CLOCKWISE?
Why are leaves deliberately excluded from the left- and right-boundary walks?
To avoid double-counting corner leaves. The three passes overlap at the corners: a leaf can sit at the end of the left boundary and also in the leaves list. By having the boundary walks skip leaves and letting the middle pass own all leaves, every node is emitted once. Off-by-one at these corners is the whole difficulty of boundary traversal.
Why is the right boundary collected bottom-up (or reversed) rather than top-down?
Anti-clockwise means the right side goes upward. After the leaves (bottom, left-to-right), the outline climbs the right edge back to the root, so the right boundary must be emitted bottom-up. Collect it top-down and reverse, or recurse to the bottom and add on the way back. The direction is what makes it a continuous outline.
The anti-clockwise boundary is not one traversal. It is three stitched together: the left edge top-down, then every leaf left to right, then the right edge bottom-up, so it is collected normally and then reversed. Each pass is easy. What makes this a hard problem is the joins: a leaf sitting on the left or right edge belongs to the leaf pass, so phases 1 and 3 must exclude leaves or that node is emitted twice. The leaf pass is also a full traversal of the tree, not a walk along the bottom. The leaves of the middle subtrees are on the boundary too.
Maximum width is the largest number of nodes between the leftmost and rightmost node of any level, counting the nulls in between. The trick is to index nodes as in a heap: root gets index i, its children 2i and 2i+1. A level's width is lastIndex − firstIndex + 1. Run a BFS carrying indices; per level, width is the last minus first index. Normalise each level's indices (subtract the first) so they don't overflow.
HOW WIDE IS THE TREE, COUNTING THE GAPS?
Why index nodes as 2i and 2i+1 instead of just counting nodes per level?
Heap indices number the empty positions too. Width includes the gaps, so you need positional indices, not a node tally. Assigning 2i/2i+1 gives each slot the number it would have in a complete tree; the leftmost and rightmost present nodes then bracket the full span including any nulls between them.
Why subtract the first index of each level from all indices on that level?
Re-basing prevents index overflow without changing the width. Since the width is last − first + 1, only the differences matter. Subtracting each level's first index resets it to 0 and keeps children small, avoiding the exponential blow-up (and negative wrap-around) that 2i+1 causes at depth ~30. A classic silent overflow bug.
Width has to count the missing nodes between two present ones, so counting what is in the queue is wrong. Give each node the index a heap would: children of i are 2i and 2i+1. A level's width is then last − first + 1, gaps included, with no null bookkeeping at all. The trap is that those indices double every level and overflow a 32-bit int on a deep skewed tree, so the code subtracts the level's first index as it goes, keeping the numbers small.
“Maximum width, counting null gaps.” Heap-index the nodes; width = last − first + 1.
BFS carrying a heap index (children 2i, 2i+1). Per level, width is the last index minus the first plus 1. Normalise each level's indices by subtracting the first to avoid overflow.
// Heap indices; normalise per level to avoid overflow. int widthOfBinaryTree(TreeNode* root) { if (!root) return 0; int best = 0; queue<pair<TreeNode*, unsigned long long>> q; q.push({root, 0}); while (!q.empty()) { int n = q.size(); unsigned long long first = q.front().second, last = first; for (int i = 0; i < n; i++) { auto [node, idx] = q.front(); q.pop(); idx -= first; // re-base to avoid overflow last = idx; if (node->left) q.push({node->left, 2 * idx}); if (node->right) q.push({node->right, 2 * idx + 1}); } best = max(best, (int)(last + 1)); } return best; }
// Heap indices; normalise per level to avoid overflow. public int widthOfBinaryTree(TreeNode root) { if (root == null) return 0; int best = 0; Queue<Object[]> q = new ArrayDeque<>(); q.add(new Object[]{root, 0}); while (!q.isEmpty()) { int n = q.size(); int first = 0, last = 0; long base = (long) q.peek()[1]; // re-base: indices double per level for (int i = 0; i < n; i++) { Object[] cur = q.poll(); TreeNode node = (TreeNode) cur[0]; int idx = (int) ((long) cur[1] - base); if (i == 0) first = idx; if (i == n - 1) last = idx; if (node.left != null) q.add(new Object[]{node.left, 2L * idx}); if (node.right != null) q.add(new Object[]{node.right, 2L * idx + 1}); } best = Math.max(best, last - first + 1); // counts the null GAPS too } return best; }
# Heap indices; normalise per level to avoid overflow. from collections import deque def widthOfBinaryTree(root): if not root: return 0 best = 0 q = deque([(root, 0)]) while q: n = len(q) first = q[0][1] last = first for _ in range(n): node, idx = q.popleft() idx -= first # re-base to avoid overflow last = idx if node.left: q.append((node.left, 2 * idx)) if node.right: q.append((node.right, 2 * idx + 1)) best = max(best, last + 1) return best
Index overflow. Raw 2i+1 indexing doubles each level and blows past a 32-bit int around depth 30, giving negative widths. Re-base every level by subtracting its first index (Python ints are unbounded, but C++/Java need the normalisation or an unsigned 64-bit type).
The path family all carry the path (or a running sum) down as you recurse and test or record it, usually at a leaf. Binary Tree Paths collects every root-to-leaf path string. Path Sum asks if any root-to-leaf sum equals a target. Path Sum II collects all such paths (backtracking). Path Sum III is different: count paths between any ancestor and descendant summing to a target, solved with a prefix-sum hashmap, the same trick as subarray-sum-equals-K on a tree.
HOW DO YOU TEST, PRINT, OR COUNT PATHS THROUGH THE TREE?
In Path Sum (root-to-leaf equals target), what is the base case, and how do you avoid a subtle bug at a node with one child?
Test the sum only at a genuine leaf. A root-to-leaf path ends at a leaf, so you compare the remaining target to node->val when both children are null. Returning at null instead would let a node with one child falsely 'complete' a path through its missing side. Subtract as you go, check at leaves.
Path Sum III counts paths that go downward between ANY two nodes. Why does a prefix-sum hashmap solve it in O(n)?
Prefix sums along the current root-to-node path. A downward path summing to target and ending at the current node corresponds to an earlier ancestor whose prefix sum is current − target. A hashmap of prefix→count (added on the way down, removed on the way back, backtracking the map) counts them in O(1) per node. It's the tree version of subarray-sum-equals-K.
Everything before this asked a node for a number. This family needs the route, so the path is carried down as a shared buffer, appended on the way in and emitted whole at every leaf. Because that buffer is shared, entering a node and failing to remove it on the way out leaks it into the sibling branch: leaf 5 would report 1>2>4>5 instead of 1>2>5. That single pop_back() is backtracking, and it is the whole difference between this and a wrong answer. Watch the PATH panel shrink every time the walk leaves a node.
“Print all root-to-leaf paths.” Carry the path down; record it at each leaf.
DFS carrying the path so far. At a leaf, record the completed path. Backtrack the path as you return so siblings start clean.
// Carry the path down; emit it at each leaf. void dfs(TreeNode* node, string path, vector<string>& res) { if (!node) return; path += to_string(node->val); if (!node->left && !node->right) { res.push_back(path); return; } path += "->"; dfs(node->left, path, res); dfs(node->right, path, res); } vector<string> binaryTreePaths(TreeNode* root) { vector<string> res; dfs(root, "", res); return res; }
// Carry the path down; emit it at each leaf. void dfs(TreeNode node, String path, List<String> res) { if (node == null) return; path += node.val; if (node.left == null && node.right == null) { res.add(path); return; } path += "->"; dfs(node.left, path, res); // String is immutable, so each dfs(node.right, path, res); // branch gets its own copy free }
# Carry the path down; emit it at each leaf. def binaryTreePaths(root): res = [] def dfs(node, path): if not node: return path = path + str(node.val) if not node.left and not node.right: res.append(path); return path += "->" dfs(node.left, path) dfs(node.right, path) dfs(root, "") return res
Recording at null or at one-child nodes instead of leaves. A root-to-leaf path ends at a genuine leaf (both children null). Passing the path by value auto-backtracks in the snippet above; if you share a mutable path, you must pop after recursing or paths bleed into siblings.
“Does a root-to-leaf path sum to the target?” Subtract on the way down; test at a leaf.
Recurse subtracting the node's value from the remaining target. At a leaf, success is remaining == node value. Return true if either subtree succeeds.
// Root-to-leaf sum == target: subtract down, check at leaves. bool hasPathSum(TreeNode* node, int target) { if (!node) return false; if (!node->left && !node->right) return target == node->val; return hasPathSum(node->left, target - node->val) || hasPathSum(node->right, target - node->val); }
// Root-to-leaf sum == target: subtract down, check at leaves. public boolean hasPathSum(TreeNode node, int target) { if (node == null) return false; if (node.left == null && node.right == null) return target == node.val; return hasPathSum(node.left, target - node.val) || hasPathSum(node.right, target - node.val); }
# Root-to-leaf sum == target: subtract down, check at leaves. def hasPathSum(node, target): if not node: return False if not node.left and not node.right: return target == node.val return (hasPathSum(node.left, target - node.val) or hasPathSum(node.right, target - node.val))
Returning true when reaching null, or testing at non-leaves. An empty child isn't a completed path, only test at a real leaf. Reaching null should return false, or a node with a single child would falsely 'complete' a path through its missing side (e.g. target reached at a node that still has one child).
“Collect all root-to-leaf paths summing to the target.” DFS with backtracking.
Carry the current path and remaining target. At a leaf that hits 0, record a copy of the path. Push before recursing and pop after. The backtracking that lets siblings reuse the path buffer.
// Backtracking: push, recurse, pop; record a copy at a matching leaf. void dfs(TreeNode* node, int target, vector<int>& path, vector<vector<int>>& res) { if (!node) return; path.push_back(node->val); target -= node->val; if (!node->left && !node->right && target == 0) res.push_back(path); // a copy of the current path else { dfs(node->left, target, path, res); dfs(node->right, target, path, res); } path.pop_back(); // backtrack } vector<vector<int>> pathSum(TreeNode* root, int target) { vector<vector<int>> res; vector<int> path; dfs(root, target, path, res); return res; }
// Backtracking: push, recurse, pop; record a copy at a matching leaf. void dfs(TreeNode node, int target, List<Integer> path, List<List<Integer>> res) { if (node == null) return; path.add(node.val); target -= node.val; if (node.left == null && node.right == null && target == 0) res.add(new ArrayList<>(path)); // a COPY: path keeps mutating dfs(node.left, target, path, res); dfs(node.right, target, path, res); path.remove(path.size() - 1); // undo before the sibling }
# Backtracking: push, recurse, pop; record a copy at a matching leaf. def pathSum(root, target): res, path = [], [] def dfs(node, target): if not node: return path.append(node.val) target -= node.val if not node.left and not node.right and target == 0: res.append(path[:]) # a COPY of the current path else: dfs(node.left, target) dfs(node.right, target) path.pop() # backtrack dfs(root, target) return res
Storing the path by reference, or forgetting to pop. Record a copy (path[:]). The shared buffer keeps mutating. And every push must be matched by a pop on the way out, or the path leaks into sibling branches. This is the canonical tree backtracking shape.
“Count paths (any node down to any descendant) summing to target.” Prefix-sum + hashmap.
Keep the running prefix sum from root to the current node and a map of prefix→count. Paths ending at the current node with sum = target correspond to an earlier prefix of (current − target). Add on the way down, remove on the way back.
// Prefix-sum count: paths ending here with sum target. int total = 0; void dfs(TreeNode* node, long running, long target, unordered_map<long,int>& seen) { if (!node) return; running += node->val; total += seen.count(running - target) ? seen[running - target] : 0; seen[running]++; dfs(node->left, running, target, seen); dfs(node->right, running, target, seen); seen[running]--; // backtrack the map } int pathSum(TreeNode* root, int target) { total = 0; unordered_map<long,int> seen{{0,1}}; dfs(root, 0, target, seen); return total; }
// Prefix-sum count: paths ending here with sum target. int total = 0; void dfs(TreeNode node, long running, long target, Map<Long,Integer> seen) { if (node == null) return; running += node.val; total += seen.getOrDefault(running - target, 0); // starts that qualify seen.merge(running, 1, Integer::sum); dfs(node.left, running, target, seen); dfs(node.right, running, target, seen); seen.merge(running, -1, Integer::sum); // leave the path as you found it } public int pathSum(TreeNode root, int target) { Map<Long,Integer> seen = new HashMap<>(); seen.put(0L, 1); // the empty prefix dfs(root, 0, target, seen); return total; }
# Prefix-sum count: paths ending here with sum target. from collections import defaultdict def pathSum(root, target): seen = defaultdict(int); seen[0] = 1 total = 0 def dfs(node, running): nonlocal total if not node: return running += node.val total += seen[running - target] seen[running] += 1 dfs(node.left, running) dfs(node.right, running) seen[running] -= 1 # backtrack the map dfs(root, 0) return total
Not backtracking the prefix map, or seeding it wrong. After processing a node's subtrees you must decrement its prefix count, or sums from one branch pollute a sibling. Seed the map with {0: 1} so a path starting at the root counts. Use 64-bit sums. Values can be large and negative.
The lowest common ancestor of two nodes p and q in a general binary tree is found in one bottom-up pass. Base case: a null, or a node that is p or q, returns itself. Otherwise recurse into both children. If both return non-null, the two targets lie in different subtrees, so this node is the LCA; if only one side is non-null, propagate it up. No parent pointers, no second pass, O(n).
HOW DO YOU FIND THE SPLIT-POINT ANCESTOR OF TWO NODES IN ONE PASS?
When does a node conclude that IT is the lowest common ancestor?
Both subtrees report a find ⇒ this is the split point. If p is somewhere on the left and q somewhere on the right (or vice versa), the current node is the deepest node that has both in its subtree. the LCA. If both were on one side, that side's recursion would return the answer and this node just forwards it. (The value-between-p-and-q rule is the BST version, a different problem.)
Why does returning the node itself when node == p || node == q correctly handle the case where one target is an ancestor of the other?
Finding p first short-circuits correctly. When one target is above the other, the higher one (say p) is encountered first and returned; its subtree isn't explored further, but that's fine, p is an ancestor of q and thus their lowest common ancestor. The elegant part is that no special case is needed; the base case handles it.
The brute-force LCA finds both root-to-node paths and compares them. The elegant version never builds a path at all: search for either target, and let each call return what it found. A node that gets a hit back from its left side and its right side is, by definition, the deepest node containing both, so it returns itself. A node with only one hit just forwards it upward. The subtlety worth naming: this returns the first such node found on the way up, and going up means going from deepest to shallowest, which is exactly why it lands on the lowest common ancestor rather than the root.
“Lowest common ancestor in a binary tree” (not a BST). One bottom-up pass.
Return the node if it's null or equals p or q. Recurse both sides; if both return non-null this node is the LCA, else propagate whichever side is non-null.
// Both subtrees report a target -> this node is the LCA. TreeNode* lowestCommonAncestor(TreeNode* node, TreeNode* p, TreeNode* q) { if (!node || node == p || node == q) return node; TreeNode* left = lowestCommonAncestor(node->left, p, q); TreeNode* right = lowestCommonAncestor(node->right, p, q); if (left && right) return node; // p and q on different sides return left ? left : right; // both on one side (or neither) }
// Both subtrees report a target -> this node is the LCA. public TreeNode lowestCommonAncestor(TreeNode node, TreeNode p, TreeNode q) { if (node == null || node == p || node == q) return node; TreeNode left = lowestCommonAncestor(node.left, p, q); TreeNode right = lowestCommonAncestor(node.right, p, q); if (left != null && right != null) return node; // one each side return (left != null) ? left : right; // propagate the finding }
# Both subtrees report a target -> this node is the LCA. def lowestCommonAncestor(node, p, q): if not node or node == p or node == q: return node left = lowestCommonAncestor(node.left, p, q) right = lowestCommonAncestor(node.right, p, q) if left and right: # p and q on different sides return node return left if left else right
Using the BST rule (compare values) on a general tree. A plain binary tree isn't ordered, so you can't navigate by value. You must search both subtrees. The 'both sides non-null ⇒ this is the LCA' logic also silently handles the case where one target is an ancestor of the other (it's found first and returned).
All nodes at distance K from a target needs movement upward as well as down, but tree pointers only go down. So first turn the tree into a graph: one pass records each node's parent in a map. Now every node has three neighbours (left, right, parent), and the answer is a plain BFS from the target, stopping after K layers. The nodes on the K-th layer are exactly distance K away.
HOW DO YOU REACH NODES K STEPS AWAY, INCLUDING UPWARD?
Why can't you solve 'all nodes at distance K' with a plain downward DFS from the target?
Distance-K nodes can lie above and across, not just below. From the target you must move to its parent (and then into the parent's other subtree) to reach some distance-K nodes. Tree links are one-directional (downward), so you first build a parent map, making every edge traversable both ways, then it's an ordinary unweighted-graph BFS.
Once you have the parent map and BFS from the target, why is a visited set essential?
Undirected edges mean you can revisit. The visited set prevents it. With parent links added, moving target→parent→target is possible; without a visited set the BFS bounces between neighbours and mis-measures distances. Marking nodes visited as they're enqueued keeps the layer count equal to the true distance, so the K-th layer is exactly the answer.
This is the moment binary trees stop being trees. “All nodes at distance K” needs to travel upward as well as downward, and a tree node has no pointer to its parent. So the first pass records one, and the tree becomes an undirected graph. After which the problem is nothing but a BFS from the target, layer by layer, stopping at layer K. Watch the spread reach node 2 from node 5: that step is impossible in any traversal in this deck, and it is the only new idea here. Distance-K and Burn the Tree are the same code with a different stopping condition.
“All nodes at distance K from a target.” Turn the tree into a graph, then BFS.
Child pointers only go down, but distance-K nodes can be above and across. Build a parent map, then BFS from the target over neighbours {left, right, parent}, K layers deep; the K-th layer is the answer.
// Parent map turns the tree into a graph; BFS K layers from target. vector<int> distanceK(TreeNode* root, TreeNode* target, int K) { unordered_map<TreeNode*, TreeNode*> parent; function<void(TreeNode*, TreeNode*)> mark = [&](TreeNode* n, TreeNode* p) { if (!n) return; parent[n] = p; mark(n->left, n); mark(n->right, n); }; mark(root, nullptr); queue<TreeNode*> q; q.push(target); unordered_set<TreeNode*> seen{target}; int dist = 0; while (!q.empty()) { if (dist == K) { vector<int> res; while (!q.empty()) { res.push_back(q.front()->val); q.pop(); } return res; } int n = q.size(); for (int i = 0; i < n; i++) { TreeNode* node = q.front(); q.pop(); for (TreeNode* nb : {node->left, node->right, parent[node]}) if (nb && !seen.count(nb)) { seen.insert(nb); q.push(nb); } } dist++; } return {}; }
// Parent map turns the tree into a graph; BFS K layers from target. public List<Integer> distanceK(TreeNode root, TreeNode target, int K) { Map<TreeNode, TreeNode> parent = new HashMap<>(); mark(root, null, parent); Queue<TreeNode> q = new ArrayDeque<>(); Set<TreeNode> seen = new HashSet<>(); // the graph is UNDIRECTED now q.add(target); seen.add(target); int dist = 0; while (!q.isEmpty()) { if (dist == K) { List<Integer> res = new ArrayList<>(); for (TreeNode n : q) res.add(n.val); return res; } int n = q.size(); for (int i = 0; i < n; i++) { TreeNode cur = q.poll(); for (TreeNode nx : new TreeNode[]{cur.left, cur.right, parent.get(cur)}) if (nx != null && seen.add(nx)) q.add(nx); // add() reports new } dist++; } return new ArrayList<>(); } private void mark(TreeNode n, TreeNode p, Map<TreeNode, TreeNode> parent) { if (n == null) return; parent.put(n, p); mark(n.left, n, parent); mark(n.right, n, parent); }
# Parent map turns the tree into a graph; BFS K layers from target. from collections import deque def distanceK(root, target, K): parent = {} def mark(n, p): if not n: return parent[n] = p; mark(n.left, n); mark(n.right, n) mark(root, None) q = deque([target]); seen = {target}; dist = 0 while q: if dist == K: return [node.val for node in q] for _ in range(len(q)): node = q.popleft() for nb in (node.left, node.right, parent[node]): if nb and nb not in seen: seen.add(nb); q.append(nb) dist += 1 return []
Forgetting to go upward, or omitting the visited set. Distance-K nodes lie above and across, so you need the parent map, a downward-only DFS misses half of them. And once edges are bidirectional, BFS will bounce back the way it came unless a visited set blocks it, corrupting the distances.
Burn the tree (minimum time to burn everything, fire spreading to adjacent nodes each second, starting from a given node) is the distance-K idea taken to its limit: the answer is the maximum distance from the start node to any other node. Build the same parent map, then BFS outward from the start, counting the number of layers until the queue empties. That layer count is the burn time. A concept unit; the pattern is 'tree as graph + BFS'.
HOW LONG DOES FIRE TAKE TO CONSUME THE WHOLE TREE FROM A NODE?
Why is the minimum burn time equal to the maximum distance from the start node?
The last node to burn is the farthest one. Fire is a breadth-first wave: everything at distance d ignites at second d. The tree is fully consumed exactly when the most distant node ignites, so the answer is the maximum over all nodes of their distance from the start, computed by a BFS that counts layers. It's the same tree-as-graph machinery as distance-K.
What is the shared engine behind 'nodes at distance K' and 'burn the tree'?
Parent map + BFS; one reads a layer, the other counts layers. Both problems need upward movement, so both build a parent map and BFS from a start node. Distance-K collects the nodes on the K-th layer; burn time is simply the index of the final layer. Recognising them as the same 'tree as graph' pattern is the payoff of grouping them.
Burn the Tree is Distance-K with the question inverted. Fire spreads to a node's parent and both children each minute, the identical undirected-graph BFS, but instead of stopping at layer K and reporting who is there, you run until the queue empties and report how many layers it took. The answer is the eccentricity of the start node: the distance to the furthest node from it. Note the count starts at -1, because the layer containing the start node itself costs no time. An off-by-one that silently adds a minute if you miss it.
Diameter of a binary tree: why can't you just return the diameter recursively. Why does it need a global (or a returned pair)?
A node returns its height but records a diameter. Its parent needs the node's height to compute its own; but the longest path bending at this node is leftHeight + rightHeight. Those are different numbers, so you return one (height) and stash the other in a global max. This 'return X, track Y' split is the crux of the whole bottom-up family.
Lowest Common Ancestor in a (non-BST) binary tree returns the node where the two targets split. What does the recursion return, and how does it detect the LCA?
Both sides non-null ⇒ this node is the split point. Base case: a null, or a node equal to p or q, returns itself. Each node collects its children's results, if the left subtree found one target and the right found the other, the current node is their lowest common ancestor; if only one side found anything, that result bubbles up. One O(n) pass, no parent pointers.
Tree bugs pass the sample and fail the judge: returning the diameter not the height, an unclamped negative gain, same-side symmetry, an overflowing width index, a level size read too late. Each returns a believable answer.
In the bottom-up family a node must return its height to its parent, while the answer (diameter, path sum) is tracked in a global. Return the answer by mistake and the parent's arithmetic is wrong, a classic and plausible bug that passes small symmetric trees.
Computing height separately at every node to get its diameter re-walks subtrees repeatedly, O(n²). Fold the diameter update INTO the single height recursion so each node is visited once.
A child that contributes a negative sum should be dropped, not added. Take max(0, gain(child)). Forgetting the clamp makes a strongly negative subtree drag the answer down, and it fails on trees with negative values.
Symmetry mirrors the two halves, so you compare the left subtree's LEFT with the right subtree's RIGHT, and left's right with right's left. Comparing same-side children checks 'same tree', not 'mirror', and passes only accidentally symmetric inputs.
Indexing nodes as 2i+1/2i+2 makes indices double each level and overflow for deep trees. Normalise each level by subtracting the first index (or use unsigned/long), or the width comes out negative.
The level count must be snapshotted before the inner loop; reading it live (as children are pushed) merges levels. This one silently corrupts zig-zag, right view, width, everything level-based.
Twelve patterns, one page. The right column is the phrase that should trigger each, the night-before surface for the property/view/path toolkit.
Two shapes did most of the work: a bottom-up postorder that returns a value while a global watches (depth, diameter, path sum), and a level-batched BFS (views, width). Paths carry state down; LCA and distance-K reason about ancestors. Deck 3 finishes the topic, construction, serialization, Morris and tree DP.
Deck 2 of 3. Uses L14-L28, L30-L31. Top view, bottom view, boundary and burn tree are concept units (Striver lectures, no clean LC). Added at the user's request: Invert, Path Sum I/II/III, Populating Next Right. Children Sum Property (L29) dropped as niche. Deck 3 next.
Not a preference — an arithmetic one. Every slide is a fixed 1280 × 720 stage: a graph animating beside the code that drives it, with the problems laid out two columns wide. It scales as a single piece, so on a screen this size the body text comes out around 4px tall.
Shrinking it further would not help, and rebuilding it to reflow would mean losing the thing that makes it worth reading.