Cairn
algorithms · dynamic programming · O(n)

back to Dynamic Programming

Maximum Weight Independent Set on a Tree

The site's tenth dynamic programming entry, and the first whose subproblem is indexed by a node in a tree rather than a position in an array, a range, or a bitmask — every other entry on this page's own guide fills a table shaped like a line or a grid; this one fills one shaped like the tree itself, one cell per node, computed child-before-parent. The question: given a tree where every node carries a weight, pick a subset of nodes maximizing total weight, with the rule that no two directly connected nodes (an edge between them) can both be picked. Checking every subset directly is O(2ⁿ); dynamic programming answers it in a single pass, O(n), because a tree's own shape — every node has exactly one parent — already gives each subproblem a clean boundary to stop at.

Try it

A ranger network of trail junctions, each with a scenic score. A lookout tower can go up at any junction, but two towers directly connected by a single trail segment have overlapping sightlines — only one of the two may be built. Maximize total scenic score across the whole network:

circles show code (weight) — Ca=Camp(4) Ov=Overlook(10) Sp=Spring(5) Ri=Ridge(2) Fa=Falls(7) Cv=Cave(8) Me=Meadow(3)

dp[node] = (include, exclude) — best score for that node's own subtree, with/without the node itself

step 0
Press Step or Run.

Why it works

Every node u has exactly two live outcomes: it's in the chosen set, or it isn't. Two numbers per node capture everything a parent will ever need to know about its subtree:

include[u] = weight[u] + sum over children c of exclude[c]
exclude[u] = sum over children c of max(include[c], exclude[c])

If u is included, none of its children can be — they're directly connected — so each child contributes its own exclude total, not a choice. If u is excluded, each child is free to be included or not, so each contributes whichever of its own two numbers is larger. Neither formula reads anything about u's parent or siblings — only its children — which is exactly why a tree makes this cheap: a node's subtree is a self-contained problem, untouched by anything outside it. Filling every node's pair requires every child's pair already known, so nodes are processed in post-order (children before their own parent) — the leaves first (both numbers trivial: include = weight, exclude = 0), working up to the root last. The answer for the whole tree is max(include[root], exclude[root]).

Recovering which nodes were picked, not just the total, walks back down from the root top-to-bottom. At the root, compare its own two numbers and take the larger. If a node is chosen, every one of its direct children is forced excluded — but each grandchild is not: it was never directly connected to the node that got picked, so it makes its own independent choice, exactly the way exclude[u]'s own formula already allowed for. That "forced" status resets after exactly one generation; it never cascades further down than the immediate children of whichever node triggered it.

Reference implementation

function treeDP(children, weight, root) {
  const include = {}, exclude = {};
  const order = [];
  (function postOrder(u) {
    for (const c of children[u]) postOrder(c);
    order.push(u);
  })(root);

  for (const u of order) {
    include[u] = weight[u];
    exclude[u] = 0;
    for (const c of children[u]) {
      include[u] += exclude[c];
      exclude[u] += Math.max(include[c], exclude[c]);
    }
  }

  const selected = new Set();
  (function pick(u, forbidden) {
    if (forbidden) {
      for (const c of children[u]) pick(c, false); // forced status does NOT propagate further
      return;
    }
    if (include[u] >= exclude[u]) {
      selected.add(u);
      for (const c of children[u]) pick(c, true);
    } else {
      for (const c of children[u]) pick(c, false);
    }
  })(root, false);

  return { best: Math.max(include[root], exclude[root]), selected };
}

Pitfalls

The exclude recurrence's Math.max is load-bearing, not a polish pass. A node's exclude value has to consider that a child, even though it's free to be picked, might still be worth more left out — because that child's own descendants outweigh it. Replace exclude[u] += Math.max(include[c], exclude[c]) with the simpler-looking exclude[u] += include[c] (always taking the child), and the bug hides whenever a child's own weight already beats its descendants — it only surfaces once a child is worth less excluded than what lies beneath it, and that child sits at least two levels below the node whose total goes wrong. A concrete four-node chain makes it visible with real numbers: weights A=5, B=1, C=1, D=10 linked A–B–C–D. The true optimum is 15 ({A, D} — skip B and C, D's descendants comfortably outweigh either). The broken version reports 11 ({B, D}) instead, because its corrupted exclude[B] only ever considers "include C" (worth 1) and never "exclude C, take D instead" (worth 10), so node A never learns that skipping B was worth far more than the 6 the broken table credits it. A 3,000-trial sweep against a brute-force independent-set search found this exact bug wrong on 1,390 of 3,000 random trees (46%) — not a rare edge case, close to a coin flip.

The "forced excluded" status must reset after exactly one generation, not cascade down the whole subtree. A tempting reconstruction bug: once a node is forbidden, keep passing forbidden = true to every descendant recursively, on the reasoning that "this whole side is already decided." It isn't — only a node's direct children are actually constrained by it being chosen; grandchildren were never adjacent to it. On this page's own demo tree, correctly including Camp forces Overlook and Spring excluded, but leaves Ridge, Falls, Cave, and Meadow (all grandchildren of Camp) free to be included on their own merits — worth 2 + 7 + 8 + 3 = 20 on top of Camp's own 4, for the true optimum of 24. The cascading-forbidden version stops at Camp alone: 4, discarding 20 points of real, legally includable value for no reason connected to the actual rule. A 3,000-trial sweep found this version strictly worse than optimal on 2,030 of 3,000 random trees (68%) — the more levels a tree has below whichever node gets picked, the more value this bug quietly leaves on the table.

Complexity

Time: O(n) — every node is visited exactly once in the post-order pass, and every edge is read exactly once (once from the child's side, when its parent sums up include/exclude contributions). The top-down reconstruction pass is a second O(n) walk. Space: O(n) for the two per-node tables, plus O(h) recursion depth for the post-order call stack, where h is the tree's height — worst case O(n) for a tree that's really just a chain. This is the fastest entry on this page's own guide by time complexity, tied with Kadane's Algorithm's O(n) — though Kadane's still wins on space, needing O(1) against this entry's O(n) table, because a line has only one neighbor to look back at while a tree node can have any number of children to combine.

This site's guide, Choosing a Dynamic Programming Approach, compares this entry against the other ten Dynamic Programming entries side by side.