Wiki Thuật toán Quy hoạch động (DP) Knapsack trên cây

Knapsack trên cây

huunguyen huunguyen Updated Tháng sáu 1, 2026

Knapsack trên cây (Tree Knapsack) là bài toán chọn một tập đỉnh từ cây sao cho thỏa ràng buộc cấu trúc — thường là liên thông, chứa gốc, hoặc nếu chọn con thì phải chọn cha — đồng thời tối ưu một đại lượng (tổng giá trị lớn nhất với ngân sách W, hoặc chọn đúng k đỉnh). Bằng cách gộp (merge) lời giải của các cây con như một bài knapsack, ta giải được trong O(NW) (hoặc O(NK)) thay vì xét 2N tập con — một cải thiện từ mũ xuống đa thức.

Ý tưởng / Trực giác

Điểm mấu chốt: với cây, mọi quyết định ở cây con v độc lập với phần còn lại của cây, ngoại trừ thông qua đúng một con số — đã dùng bao nhiêu trọng số (hoặc đã chọn bao nhiêu đỉnh). Vì vậy ta có thể tóm tắt toàn bộ cây con u bằng một mảng:

dp[u][j]=giá trị tốt nhất khi dùng đúng j đơn vị "ngân sách" trong cây con gốc u

Khi đứng ở đỉnh u và lần lượt xử lý các con v1,v2,, mỗi con đã có sẵn mảng dp[vi]. Gộp một con mới vào chính là phép knapsack hai mảng: nếu phần đã gộp dùng j ngân sách và con mới dùng k, kết quả dùng j+k:

dp[u][j+k]max(dp[u][j+k],dp[u][j]+dp[v][k])

Vì sao đúng? Một tập đỉnh được chọn trong cây con u phân hoạch duy nhất theo: phần thuộc các con đã xử lý trước, và phần thuộc con v đang xét. Hai phần này không chia sẻ đỉnh nào (cây con khác nhau rời nhau), nên giá trị cộng được và ngân sách cộng được — đúng cấu trúc của knapsack. Ràng buộc cấu trúc (liên thông / chọn con kéo theo cha) được mã hóa vào cách khởi tạo dp[u]cận dưới của vòng lặp chứ không phải vào phép merge.

Vì sao tổng độ phức tạp chỉ O(NW) chứ không phải O(N2W)? Đây là kết quả không hiển nhiên. Nếu trong vòng lặp merge ta giới hạn j theo kích thước cây con đã gộp (sz[u]) và k theo kích thước cây con v (sz[v]), thì mỗi cặp đỉnh (a,b) chỉ bị "ghép" với nhau đúng một lần — tại tổ tiên chung thấp nhất (LCA) của ab, đúng thời điểm hai cây con chứa ab được gộp. Số cặp đỉnh là O(N2), nhân với chi phí W trên mỗi đơn vị, ra O(NW) (khi WN thì là O(N2)). Bắt buộc phải dùng min(sz[u], W)min(sz[v], ...) làm cận, nếu không sẽ tụt xuống O(N2W).

Ví dụ chạy tay

Xét bài Dạng 2 (Dependency Knapsack): chọn con thì phải chọn cha; ngân sách W=3. Cây và dữ liệu (trọng số w, giá trị val):

            (1) w=1 val=2
           /            \
       (2) w=1         (3) w=2
        val=3           val=4
        /
    (4) w=2
     val=5

Định nghĩa dp[u][j] = giá trị lớn nhất dùng đúng j ngân sách trong cây con u, trong đó nếu chọn bất kỳ đỉnh nào của cây con thì phải chọn u. Khởi tạo: dp[u][0]=0 (không chọn gì), dp[u][wu]=valu (chỉ chọn u). Ô trống = (không đạt được).

Lá 4 (w=2,val=5):

j 0 1 2 3
dp[4] 0 · 5 ·

Lá 3 (w=2,val=4):

j 0 1 2 3
dp[3] 0 · 4 ·

Đỉnh 2 (w=1,val=3): khởi tạo dp[2][0]=0,dp[2][1]=3, rồi gộp con 4. Con 4 dùng k{0,2}. Duyệt j giảm dần:

  • j=1 (đã chọn 2): cộng dp[4][2]=5dp[2][3]=3+5=8 (chọn {2,4}).
  • j=0: chỉ giữ được dp[2][0]=0 (không chọn gì).

Sau khi gộp con 4, bảng dp[2]:

j 0 1 2 3
dp[2] 0 3 · 8

Gốc 1 (w=1,val=2): khởi tạo dp[1][0]=0,dp[1][1]=2. Cận merge ở đây là jw1=1 (muốn chọn con thì phải đã chọn 1).

Gộp con 2 (dùng k{0,1,3} với giá trị 0,3,8), duyệt j từ cao xuống, chỉ j1:

  • j=1: k=0dp[1][1]=2; k=1dp[1][2]=2+3=5; k=3 vượt (1+3=4>3), bỏ.

Sau khi gộp con 2:

j 0 1 2 3
dp[1] 0 2 5 ·

Gộp tiếp con 3 (k{0,2}, giá trị 0,4), vẫn j1:

  • j=2: k=0 giữ 5; k=22+2=4>3, bỏ.
  • j=1: k=02; k=2dp[1][3]=2+4=6.

Bảng cuối:

j 0 1 2 3
dp[1] 0 2 5 6

Đáp số =maxjdp[1][j]=6, đạt khi chọn {1,3} (trọng số 1+2=3, giá trị 2+4=6). So với {1,2} giá trị 5 hay {1,2,4} trọng số 4>W — phương án {1,3} thắng.

Cài đặt

Dạng 1 — Chọn cây con liên thông chứa gốc

Bài toán: chọn tập đỉnh liên thông chứa gốc, tổng trọng số W, giá trị lớn nhất. Trạng thái dp[u][j] = giá trị lớn nhất dùng đúng j trọng số, bắt buộc chọn u.

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
int n, W;
int w[MAXN], val[MAXN];
vector<int> adj[MAXN];
int dp[MAXN][MAXN];   // dp[u][j] = max value, dùng j trọng số trong subtree u (u BẮT BUỘC chọn)
int sz[MAXN];         // tổng trọng số subtree u đã gộp -> dùng làm cận vòng lặp

void dfs(int u, int par) {
    dp[u][w[u]] = val[u];   // trạng thái cơ sở: chỉ chọn mình u
    sz[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        // Gộp dp[v] vào dp[u]. Duyệt j GIẢM DẦN để mỗi con chỉ dùng 1 lần
        // (giống 0/1 knapsack). Cận j >= w[u] vì luôn phải có u trong tập.
        for (int j = min(sz[u], W); j >= w[u]; j--) {
            // k <= W - j để không vượt ngân sách; k <= sz[v] để giữ O(NW)
            for (int k = min(sz[v], W - j); k >= 0; k--) {
                if (dp[u][j] >= 0 && dp[v][k] >= 0)
                    dp[u][j + k] = max(dp[u][j + k], dp[u][j] + dp[v][k]);
            }
        }
        sz[u] += sz[v];   // cập nhật cận SAU khi gộp xong con v
    }
}

int main() {
    cin >> n >> W;
    for (int i = 1; i <= n; i++) cin >> w[i] >> val[i];
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    memset(dp, -1, sizeof(dp));   // -1 = trạng thái không đạt được
    dfs(1, 0);

    int ans = 0;
    for (int j = 0; j <= W; j++) ans = max(ans, dp[1][j]);
    cout << ans << "\n";
}
Dạng 2 — Dependency Knapsack (chọn con kéo theo cha)

Khác Dạng 1 ở chỗ cho phép không chọn cả cây con (dp[u][0]=0), nhưng hễ chọn đỉnh nào trong cây con thì cha của nó (và do đệ quy, cả u) phải có mặt.

void dfs(int u, int par) {
    dp[u][0] = 0;          // không chọn gì trong subtree u
    dp[u][w[u]] = val[u];  // chọn u (mở đường cho việc chọn các con)
    sz[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        for (int j = min(sz[u], W); j >= 0; j--) {
            if (dp[u][j] < 0) continue;
            for (int k = min(sz[v], W - j); k >= 0; k--) {
                if (dp[v][k] < 0) continue;
                dp[u][j + k] = max(dp[u][j + k], dp[u][j] + dp[v][k]);
            }
        }
        sz[u] += sz[v];
    }
}

Mẹo cài bài "chọn dự án phụ thuộc / chọn phòng trong tòa nhà": thêm gốc ảo 0 với w0=0,val0=0 nối tới mọi gốc thực, chạy dfs(0, -1), đáp số =maxjWdp[0][j].

Dạng 3 — Chọn đúng k đỉnh tối đa hóa tổng khoảng cách đôi — O(NK)

Bài toán: chọn đúng k đỉnh trên cây có trọng số cạnh, tối đa hóa tổng khoảng cách giữa mọi cặp. Mẹo phân rã theo cạnh: một cạnh (x,y,w) đóng góp vào khoảng cách của một cặp khi và chỉ khi cặp đó nằm hai phía của cạnh. Nếu cây con phía dưới cạnh có j đỉnh được chọn và phía trên có kj đỉnh, cạnh được đếm j·(kj) lần:

cặpdist=cạnh (x,y,w)w·j·(kj)

Trạng thái dp[u][i] = tổng khoảng cách đôi lớn nhất khi chọn đúng i đỉnh trong cây con u.

const int MAXN = 2005;
vector<pair<int,long long>> adj[MAXN];
long long dp[MAXN][MAXN];   // dp[u][i] = max tổng dist đôi, chọn i đỉnh trong subtree u
int sz[MAXN], n, k;

void dfs(int u, int par) {
    sz[u] = 1;
    fill(dp[u], dp[u] + k + 1, -1);
    dp[u][0] = dp[u][1] = 0;   // chọn 0 hoặc 1 đỉnh: chưa có cặp nào -> tổng = 0

    for (auto [v, w] : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        for (int i = min(k, sz[u] + sz[v]); i >= 0; i--) {       // tổng đỉnh sau khi gộp
            for (int j = min(i, sz[v]); j >= 0; j--) {           // j đỉnh lấy từ con v
                if (dp[u][i - j] == -1 || dp[v][j] == -1) continue;
                // cạnh (u,v,w) bị j*(k-j) cặp đi qua -> cộng w*j*(k-j)
                dp[u][i] = max(dp[u][i],
                               dp[u][i - j] + dp[v][j] + (long long)j * (k - j) * w);
            }
        }
        sz[u] += sz[v];
    }
}
// gọi dfs(1, 0); đáp số = dp[1][k]

Lưu ý hệ số (kj) dùng tổng số đỉnh được chọn trên toàn cây (k), không phải số đỉnh đã gộp — vì cạnh được đi qua bởi mọi cặp một-đầu-trong/một-đầu-ngoài, mà "ngoài" tính trên toàn bộ k đỉnh cuối cùng.

Độ phức tạp

Dạng Thời gian Bộ nhớ
Cây con liên thông / Dependency O(NW) O(NW)
Chọn k đỉnh max tổng dist O(NK) O(NK)

Thời gian: như đã lý giải ở mục Trực giác — nhờ cận min(sz[u], W)min(sz[v], …), mỗi cặp đỉnh chỉ được ghép một lần tại LCA của chúng, tổng số phép cộng là O(NW) (hoặc O(NK)). Không có cận này, độ phức tạp tụt xuống O(N2W).

Bộ nhớ: mảng dp[N][W+1] chiếm O(NW). Với N,W300~90000 ô — ổn. Với N lớn (105) mà W cũng lớn thì O(NW) bộ nhớ là không khả thi; khi đó cần ép cây về DFS order rồi knapsack 1 chiều (xem Biến thể).

⚠️ Lỗi thường gặp

  • Không đặt cận min(sz[u], W)min(sz[v], …) cho vòng lặp — Đây là lỗi nghiêm trọng nhất. Nếu cho j,k chạy thẳng tới W, độ phức tạp thành O(N2W) và TLE trên N vài nghìn. Luôn giới hạn theo kích thước (tổng trọng số / số đỉnh) cây con đã gộp.
  • Cập nhật sz[u] sai thời điểm — Phải cộng sz[u] += sz[v] sau khi gộp xong con v. Nếu cộng trước, cận min(sz[u], W) của vòng j sẽ bao gồm cả v, khiến một đỉnh của v bị ghép với chính nó (dùng hai lần).
  • Duyệt j tăng dần thay vì giảm dần — Như 0/1 knapsack, phải duyệt j giảm dần để mỗi cây con chỉ được tính một lần. Duyệt tăng dần sẽ "tái sử dụng" giá trị vừa cập nhật, cho kết quả vượt mức (như unbounded knapsack).
  • Tràn số — Với Dạng 3, tích j·(kj)·w có thể tới ~20002·106=4·1015, vượt int. Phải dùng long long và ép kiểu (long long)j * (k - j) * w trước khi nhân. Đề creatures ghi rõ "kết quả có thể vượt 2311".
  • Lẫn lộn trạng thái cơ sở — Dạng 1 buộc chọn gốc nên dp[u][0] để và cận jwu; Dạng 2 cho phép bỏ cả cây con nên dp[u][0]=0 và cận j0. Đặt nhầm sẽ vi phạm ràng buộc cấu trúc (cho chọn con mà không chọn cha, hoặc ngược lại).
  • Phân biệt "j đơn vị trọng số" với "j đỉnh" — Hai họ bài dùng chiều DP khác nhau (theo trọng số W hay theo số đỉnh k). Trộn lẫn (vd dùng sz là số đỉnh nhưng cận theo W) gây cận sai và kết quả sai.

Biến thể / Mở rộng

  • DFS order + knapsack 1 chiều: Làm phẳng cây theo thứ tự DFS (tin[u], tout[u]). Với dependency knapsack, "chọn u thì chọn các đỉnh trong [tin[u],tout[u]]"; có thể chuyển thành knapsack tuyến tính với bước "nếu bỏ u thì nhảy qua cả cây con". Cách này dễ debug và tiết kiệm bộ nhớ hơn bản đệ quy hai chiều.
  • Liên hệ DP trên cây tổng quát: Kỹ thuật merge-as-knapsack là một trường hợp của DP trên cây. Khi W=k nhỏ và N lớn, có thể kết hợp small-to-large / DSU on tree để giảm hằng số.
  • Group knapsack: Khi mỗi đỉnh có nhiều "phương án" (vd nhiều mức đầu tư), mỗi cây con sinh ra một nhóm món, bài trở thành knapsack theo nhóm trên cây.

Bài tập luyện

  • Tiệc trái cây (feast)(Advanced) Knapsack tuyến tính cơ bản, luyện trực giác "gộp hai trạng thái theo ngân sách" trước khi bê lên cây.
  • Sinh Vật Huyền Bí (creatures)(Expert) Tree knapsack đúng nghĩa: chọn đúng K đỉnh trên cây trọng số cạnh, tối đa tổng khoảng cách đôi — áp thẳng Dạng 3 (O(NK), phân rã theo cạnh, nhớ long long).
gnatmake 12.2.0 a68g 3.1.2 nasm 2.16.1 as_x64 2.46 awk 1.3.4 gcc 16.1.0 csc 6.12.0.200 g++ 16.1.0 g++-themis 16.1.0 g++17 16.1.0 g++20 16.1.0 g++23 16.1.0 clang++ 22.1.6 dmd 2.112.0 dart 3.12.1 gforth 0.7.3 gfortran 12.2.0 go 1.26.3 groovyc 5.0.6 javac 25.0.3 node 26.2.0 kotlinc 2.3.21 sbcl 2.2.9 lua 5.4.8 nim 2.2.10 fpc 3.2.2 fpc-themis 3.2.2 perl 5.36.0 php 8.5.6 pike 8.0 pypy3 7.3.23 python3 3.14.5 racket 8.7 ruby 4.0.5 rustc 1.96.0 csc 5.3.0 ctoj-scratch 0.0.1 sed 4.9 tclsh 8.6 bun 1.3.14 deno 2.8.1 v 0.5.1 zig 0.16.0