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.
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
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.
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 };
}
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.
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.