DSA DSA In Depth
0%
design tree-dprerootingamortized-analysis

DP on Trees: Maximum Independent Set, Subtree Knapsack (O(N^2) Amortized Proof) & All-Roots Rerooting

7 min read Module 6.2: Dynamic Programming Foundations & Classical Archetypes Updated Oct 23, 2026
📄 Cheat Sheet
Rerooting DP on Trees Tutorial — All-Roots Subtree Aggregation
Prince Gupta Rerooting DP on Trees Tutorial — All-Roots Subtree Aggregation Watch on YouTube ↗

Explains bottom-up subtree aggregation, the O(N^2) tree knapsack complexity proof, and the 2-pass prefix/suffix rerooting technique.

0:00 / --:--

1. Bottom-Up Subtree DP & The $O(N^2)$ Tree Knapsack Theorem

By rooting an undirected tree arbitrarily at vertex $0$, every vertex $u$ defines a subproblem on its subtree $T_u$, solved in DFS post-order:

  • Maximum Weight Independent Set on a Tree (House Robber III): Maintain two states per node: $dp[u][0]$ (max weight in $T_u$ if $u$ is excluded) and $dp[u][1]$ (if $u$ is included): $$dp[u][0] = \sum_{v \in \text{children}(u)} \max(dp[v][0], dp[v][1]), \qquad dp[u][1] = w_u + \sum_{v \in \text{children}(u)} dp[v][0]$$
  • Subtree Dependency Knapsack (Selecting $K$ Connected Nodes): Suppose at node $u$ we merge a knapsack table of size $\text{sz}[u]$ with child $v$'s table of size $\text{sz}[v]$. A naive bound where both loops run up to $N$ gives $O(N^3)$. However, if we bound the loop ranges strictly by the current accumulated subtree size $\text{sz}[u]$ and child size $\text{sz}[v]$ before updating $\text{sz}[u] \mathrel{+}= \text{sz}[v]$, what is the true complexity?

Core Invariant — The $O(N^2)$ (and $O(N \cdot K)$) Tree Knapsack Complexity Proof

When merging child $v$ into parent $u$'s partial subtree via nested loops of lengths $\text{sz}_{\text{cur}}[u]$ and $\text{sz}[v]$, the number of inner-loop operations performed at that merge step is $\text{sz}_{\text{cur}}[u] \times \text{sz}[v]$.
Combinatorial interpretation: $\text{sz}_{\text{cur}}[u] \times \text{sz}[v]$ equals the exact number of pairs of vertices $(x, y)$ such that $x$ belongs to a previously merged branch of $u$, $y \in T_v$, and $\text{LCA}(x, y) = u$! Since every pair of vertices $(x, y)$ in the tree has a unique Lowest Common Ancestor $u$, summing over the entire tree counts each pair $(x, y)$ at most once:

$$\sum_{u} \sum_{v \in \text{children}(u)} \text{sz}_{\text{cur}}[u] \cdot \text{sz}[v] = \binom{N}{2} = O(N^2)$$

If capacities are further capped at $K$, the complexity is strictly $O(N \cdot K)$!

2. The All-Roots Rerooting Technique (Up-Down / In-Out DP)

What if we need the tree DP answer when every vertex $u \in \{0..N-1\}$ is chosen as the root (e.g., Sum of Distances in Tree, Minimum Height Trees, or Maximum Path from Every Node)? Running $N$ separate DFS traversals takes $O(N^2)$. Instead, Two-Pass Rerooting solves all $N$ roots in $O(N)$:

  1. Pass 1 (Post-Order Downward DP down[u]): Root at $0$ and compute down[u] for every subtree $T_u$.
  2. Pass 2 (Pre-Order Upward DP up[v]): As we step from parent $u$ to child $v$, the "complement subtree" $T \setminus T_v$ seen by $v$ consists of $u$'s upward contribution up[u] merged with all siblings $w \in \text{children}(u) \setminus \{v\}$. To avoid division (which fails on $\max/\min$ or $\bmod p$ zeroes), precompute prefix and suffix merges of $u$'s children!
flowchart LR subgraph Pass1["Pass 1: Bottom-Up (Post-Order)"] C1["Child v_1: down[v_1]"] --> U["Parent u: down[u]"] C2["Child v_2: down[v_2]"] --> U end subgraph Pass2["Pass 2: Top-Down Rerooting (Pre-Order)"] UpU["up[u] (Outside T_u)"] --> Combine["Combine via Prefix/Suffix
of siblings != v_2"] C1b["Sibling v_1: down[v_1]"] --> Combine Combine --> UpV2["up[v_2] passed into Child v_2"] end

Hardware Reality — Why Bound-Capped Tree Knapsack Must Update sz[u] += sz[v] AFTER the Merge Loop

A single-line bug turns an $O(N^2)$ Tree Knapsack into an $O(N^3)$ TLE: if you increment sz[u] += sz[v] before the merge loop (or loop up to the precomputed total subtree size instead of the running accumulated size), the first child merge at the root iterates $N \times \text{sz}[v_1]$ instead of $1 \times \text{sz}[v_1]$, destroying the LCA pair-counting invariant!

3. Complexity Matrix

Interval DP Wavefront (Matrix Chain) & Two-Pass Tree Rerooting DP
O(N³) Interval / O(N) Rerooting

Step through upper-triangular Interval DP diagonal wavefronts dp[i][j] by length L = 2..N, or trace Two-Pass Tree Rerooting (bottom-up subtree down[u] + top-down prefix/suffix up[u]).

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Interval DP Topological Order: Outer loop MUST iterate interval length L = 2..N so shorter sub-intervals dp[i][k] and dp[k+1][j] are already solved.
🌳 Live 2D Hierarchical Tree Topology — Interval DP Wavefront (Matrix Chain) & Two-Pass Tree Rerooting DP Visited/Active Pivot/Root Resolved
Base Dia len=1… [0] Wavefron len=2… [1] Split k* len=3… [2] Optimal len=4… [3]
[0]len=1: dp[i][i]=0Base Diagonal
[1]len=2: dp[1][2]=60, dp[2][3]=150Wavefront L=2
[2]len=3: dp[1][3]=min(k=1, k=2)=90Split k*=2
[3]len=4: dp[1][4]=130Optimal Full Chain
Outer Loop
Length L = 2 → N
Transition
min_{i≤k<j} (dp[i][k] + dp[k+1][j] + p_{i-1}p_k p_j)
Optimal Cost dp[1][4]
130 scalar multiplies
[Step 1] Swept upper-triangular diagonals L=2, 3, 4; optimal parenthesization ((A1·A2)·A3)·A4 = 130.
Tree DP Pattern Naive Approach Optimal Tree DP Time Auxiliary Space Key Mechanism
Max Independent Set / Vertex Cover O(2^N) O(N) O(N) 2-state post-order $dp[u][0/1]$
Tree Diameter / Max Path Sum O(N^2) O(N) O(N) Combine top-2 child depths at each LCA
Subtree Knapsack (Cap $K \le N$) O(N * K^2) O(N * K) O(N * K) Cap loops at $\min(K, \text{sz}_{\text{cur}}[u])$
All-Roots Queries (Rerooting DP) O(N^2) O(N) O(N) Downward post-order + Upward prefix/suffix

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <vector>

// 1. All-Roots Rerooting DP: Compute sum of distances from EVERY node u to all other nodes in O(N)
std::vector<long long> sum_of_distances_in_tree(int n, const std::vector<std::pair<int, int>>& edges) {
    std::vector<std::vector<int>> adj(n);
    for (auto [u, v] : edges) {
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    std::vector<int> sz(n, 1);
    std::vector<long long> ans(n, 0);

    // Pass 1 (Bottom-Up): Compute subtree sizes and ans[0] = sum of depths from root 0
    auto dfs_down = [&](auto& self, int u, int p) -> void {
        for (int v : adj[u]) {
            if (v == p) continue;
            self(self, v, u);
            sz[u] += sz[v];
            ans[0] += ans[v] + sz[v]; // momentarily use ans[u] to accumulate subtree dist sum
        }
        if (u != 0) {
            long long sub_sum = 0;
            for (int v : adj[u]) if (v != p) sub_sum += ans[v] + sz[v];
            ans[u] = sub_sum;
        }
    };
    dfs_down(dfs_down, 0, -1);

    // Pass 2 (Top-Down Rerooting): Shift root from u to child v in O(1):
    // Nodes in T_v (sz[v] of them) get 1 step closer; nodes outside T_v (n - sz[v]) get 1 step farther!
    auto dfs_up = [&](auto& self, int u, int p) -> void {
        for (int v : adj[u]) {
            if (v == p) continue;
            ans[v] = ans[u] - sz[v] + (n - sz[v]);
            self(self, v, u);
        }
    };
    dfs_up(dfs_up, 0, -1);
    return ans;
}

// 2. O(N * K) Tree Dependency Knapsack (Must pick parent u to pick any node in T_u)
std::vector<long long> tree_knapsack_Exact_ONK(
    int n, int k, const std::vector<int>& parent, const std::vector<long long>& profit) {
    std::vector<std::vector<int>> children(n);
    int root = 0;
    for (int i = 0; i < n; ++i) {
        if (parent[i] == -1) root = i;
        else children[parent[i]].push_back(i);
    }

    constexpr long long NEG_INF = -(1LL << 60);
    std::vector<std::vector<long long>> dp(n);
    std::vector<int> sz(n, 0);

    auto dfs = [&](auto& self, int u) -> void {
        sz[u] = 1;
        dp[u] = { 0LL, profit[u] }; // Size 0 -> 0, Size 1 (just u) -> profit[u]

        for (int v : children[u]) {
            self(self, v);
            int new_cap = std::min(k, sz[u] + sz[v]);
            std::vector<long long> next_dp(new_cap + 1, NEG_INF);
            next_dp[0] = 0;

            // CRITICAL: Bound i by current sz[u] and j by sz[v] BEFORE adding sz[v] to sz[u]!
            for (int i = 1; i <= std::min(k, sz[u]); ++i) {
                for (int j = 0; j <= std::min(k - i, sz[v]); ++j) {
                    next_dp[i + j] = std::max(next_dp[i + j], dp[u][i] + dp[v][j]);
                }
            }
            dp[u] = std::move(next_dp);
            sz[u] += sz[v]; // Update sz[u] AFTER the merge!
        }
    };
    dfs(dfs, root);
    return dp[root];
}

Production Pitfall — Using Inverse Operations (Subtraction / Division) in Rerooting When Merging $\max$ or $\min$

For additive objectives like Sum of Distances, removing child $v$'s contribution from $u$ is a simple subtraction. However, when the DP transition uses a non-invertible operator like $\max$ (e.g., Eccentricity / Longest Path from Every Node) or multiplication $\bmod M$ (where a child factor could be $0 \pmod M$), you cannot divide or subtract! Instead, for a node $u$ with $d$ children $v_0, \dots, v_{d-1}$, precompute prefix merges $P[i] = \bigoplus_{j < i} f(v_j)$ and suffix merges $S[i] = \bigoplus_{j > i} f(v_j)$ in $O(d)$ time so child $v_i$ receives $P[i] \oplus S[i] \oplus \text{up}[u]$ in $O(1)$ without inversion.

Check yourself Question 1 of 4
Concept Check The O(N^2) Tree Knapsack Complexity Proof recall

When merging subtree knapsack arrays at node $u$ with child $v$ using loops bounded by the current accumulated size `sz[u]` and `sz[v]` (updating `sz[u] += sz[v]` *after* the merge), why is the total time across the entire tree $O(N^2)$ instead of $O(N^3)$?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Suppose we want to compute the **eccentricity** (maximum distance to any node, using $\max$) for all $N$ roots using Rerooting DP. Why can't we compute `up[v]` by 'subtracting' `down[v]` from `down[u]`, and how do we achieve $O(N)$ total time even when node degrees are $O(N)$?

Key Takeaways

Rooting a tree turns it into a DAG where subtree states dp[u] are computed bottom-up in DFS post-order.
Merging subtree knapsack tables bounded by running subtree sizes sz[u] and sz[v] runs in O(N^2) (or O(N * K)) total time because every pair of nodes meets only once at their LCA.
Two-pass Rerooting DP computes all-roots answers in O(N) via a bottom-up post-order pass followed by a top-down pre-order pass.
When transitions use non-invertible operators (max, min, GCD, or multiplication mod M), use prefix and suffix child arrays during the top-down rerooting pass.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue