Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

34 chapters
⌕
Beginner10›
B1Complexity analysisB2Arrays & stringsB3HashingB4Two pointersB5Sliding windowB6Binary searchB7Sorting algorithmsB8Stacks & queuesB9Linked listsB10Basic recursion
Intermediate12›
I1TreesI2Tree problems in depthI3Heaps & priority queuesI4Graphs: representationI5Graph problemsI6BacktrackingI7DP: 1DI8DP: 2DI9Greedy algorithmsI10IntervalsI11Bit manipulationI12Matrix problems
Advanced12›
A1Advanced DPA2Union-FindA3Advanced graph algorithmsA4Minimum Spanning TreeA5TriesA6Segment & Fenwick treesA7String algorithmsA8Monotonic stack & queueA9Design problemsA10Advanced backtrackingA11Topological patternsA12Interview strategy
/ search[ ] chaptert top

DSA in JS levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
I2

Tree problems in depth

Construction, LCA, balance and serialization — the four questions traversal alone doesn't answer.

Building a tree back from its traversals

Given pre-order and in-order sequences, you can reconstruct the exact original tree — this works because pre-order's first element is always the root, and once you know the root, in-order tells you exactly which values belong in the left subtree (everything before the root) versus the right subtree (everything after it).

pre-order: [ 3, 9, 20, 15, 7 ] — first element (3) is the root in-order: [ 9, 3, 15, 20, 7 ] — find 3, everything left of it is the left subtree left subtree in-order: [9] right subtree in-order: [15, 20, 7] recurse: next pre-order element (9) is the left subtree's root, and so on
Root from pre-order splits in-order into two halves — repeat recursively for each half.
function buildTree(preorder, inorder) {
  if (preorder.length === 0) return null;

  const rootVal = preorder[0];
  const root = { val: rootVal, left: null, right: null };

  const splitIndex = inorder.indexOf(rootVal);
  const leftInorder = inorder.slice(0, splitIndex);
  const rightInorder = inorder.slice(splitIndex + 1);

  root.left = buildTree(preorder.slice(1, 1 + leftInorder.length), leftInorder);
  root.right = buildTree(preorder.slice(1 + leftInorder.length), rightInorder);

  return root;
}
⚠ Post-order + pre-order alone isn't enough Pre-order and post-order together can't always uniquely reconstruct a binary tree — some trees produce identical pre/post pairs. You need in-order paired with either pre-order or post-order (or a fully balanced/complete tree structure) to guarantee a unique answer. Worth saying out loud if asked to justify why the combination matters.

Lowest Common Ancestor (LCA)

The LCA of two nodes is the deepest node that has both as descendants. On a plain binary tree, you find it by searching both subtrees and noticing where the paths to each target first "meet."

1 2 3 4 5 6 LCA(5, 6) = 3 — the node where both paths are still together
3 is an ancestor of both 5 and 6, and it's the deepest one that is.
function lowestCommonAncestor(root, p, q) {
  if (root === null || root === p || root === q) return root;

  const left = lowestCommonAncestor(root.left, p, q);
  const right = lowestCommonAncestor(root.right, p, q);

  if (left && right) return root; // p and q split across both sides — root is the LCA
  return left ?? right;            // both on one side — pass that answer up
}

On a BST specifically, you can skip the full search: compare both targets against the current node's value. If both are smaller, go left; if both are bigger, go right; the moment they split (or match the node), you've found the LCA in O(log n) instead of O(n) — the BST property does the pruning for you.

Checking if a tree is height-balanced

⚠ The naive version is accidentally O(n²) Calling a separate height() function at every node, inside a traversal that visits every node, recomputes height from scratch each time — O(n) work, done n times. The fix: compute height and check balance in the same pass, and short-circuit upward the moment imbalance is found anywhere below.
function isBalanced(root) {
  function check(node) {
    if (node === null) return 0; // height of an empty tree

    const leftHeight = check(node.left);
    if (leftHeight === -1) return -1; // already unbalanced below — stop early

    const rightHeight = check(node.right);
    if (rightHeight === -1) return -1;

    if (Math.abs(leftHeight - rightHeight) > 1) return -1; // -1 means "unbalanced"

    return 1 + Math.max(leftHeight, rightHeight);
  }
  return check(root) !== -1;
}

Validate BST — the trap almost everyone falls into first

⚠ "Check left < node < right at every node" is NOT enough It's tempting to just compare each node against its immediate children. That misses violations further down: a right-subtree node can be smaller than a distant ancestor even while being bigger than its direct parent. The BST property is about every node in the entire left subtree, and every node in the entire right subtree — not just direct children.
5 3 8 4
4 < 8 (its parent) looks fine locally — but 4 is in 5's right subtree, so it must be > 5. It isn't. Invalid.
// pass down a valid (min, max) RANGE, tightened at every step
function isValidBST(node, min = -Infinity, max = Infinity) {
  if (node === null) return true;
  if (node.val <= min || node.val >= max) return false;

  return (
    isValidBST(node.left, min, node.val) &&   // left subtree must stay BELOW node.val
    isValidBST(node.right, node.val, max)      // right subtree must stay ABOVE node.val
  );
}

An equally valid alternative: run an in-order traversal and check the output is strictly increasing — since in-order on a real BST always produces sorted output (from the Trees chapter), any violation of that proves it isn't one. Both approaches are O(n) time, O(h) space.

Diameter of a binary tree

The diameter is the length of the longest path between any two nodes — and that path doesn't have to pass through the root. The subtlety: the longest path through a given node is leftHeight + rightHeight, but the final answer is the maximum of that value across every node, not just the root.

function diameterOfBinaryTree(root) {
  let diameter = 0;

  function height(node) {
    if (node === null) return 0;
    const leftHeight = height(node.left);
    const rightHeight = height(node.right);

    diameter = Math.max(diameter, leftHeight + rightHeight); // update global answer at EVERY node

    return 1 + Math.max(leftHeight, rightHeight); // but only return height upward
  }

  height(root);
  return diameter;
}

This is the same shape as isBalanced above — a single post-order pass that computes a per-node value (height) while side-effecting a running global answer. That combination — "return one thing up the call stack, but also track a separate best-so-far as you go" — is worth recognizing as its own recurring template; it also solves Binary Tree Maximum Path Sum with the same shape (track the best path found anywhere, but only return the best single-branch extension upward, since a path can't fork twice).

Serialization — turning a tree into a string and back

Pre-order with explicit null markers is the standard approach — it's the only single traversal that, alone, can rebuild the exact tree shape without needing a second traversal like the construction problem above.

function serialize(root) {
  if (root === null) return "null";
  return `${root.val},${serialize(root.left)},${serialize(root.right)}`;
}

function deserialize(data) {
  const values = data.split(",");
  let i = 0;

  function build() {
    if (values[i] === "null") { i++; return null; }
    const node = { val: Number(values[i++]), left: null, right: null };
    node.left = build();
    node.right = build();
    return node;
  }
  return build();
}

The null markers are what make one traversal enough — they tell the rebuilder exactly where each branch ends, instead of needing a second traversal to disambiguate the shape.

Say it like this → "I'll serialize with pre-order and explicit null markers, since that's the one traversal where the string alone — no second traversal needed — is enough to rebuild the exact original shape."

Recognizing which of these four you need

  • "Given two traversals, rebuild the tree" → construction (use pre/post-order for the root, in-order to split subtrees)
  • "Find the common ancestor" → LCA (BST property prunes it to O(log n) if it's a BST)
  • "Is this tree balanced/valid?" → compute the property bottom-up in one pass, short-circuit on failure
  • "Save this tree to a file / send over a network" → serialize with pre-order + null markers
Practice this layer

Opens in the editor — write it, run it, and check it against real tests.

Binary Tree Zigzag Level Order Traversal5 tests · intermediateBinary Tree Right Side View5 tests · intermediateDiameter of Binary Tree5 tests · intermediateBalanced Binary Tree5 tests · intermediateLowest Common Ancestor of a Binary Tree5 tests · advancedValidate Binary Search Tree5 tests · advancedKth Smallest Element in a BST5 tests · intermediateLowest Common Ancestor of a BST5 tests · intermediateConvert Sorted Array to BST5 tests · intermediateConstruct Binary Tree from Preorder and Inorder Traversal5 tests · advancedSerialize and Deserialize Binary Tree5 tests · advancedPath Sum II5 tests · intermediateBinary Tree Maximum Path Sum5 tests · advancedPopulating Next Right Pointers in Each Node5 tests · advanced
←previousTrees↑ CovernextHeaps & priority queues→