Knapsack trên cây
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 , hoặc chọn đúng đỉ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 (hoặc ) thay vì xét 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 độ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 bằng một mảng:
Khi đứng ở đỉnh và lần lượt xử lý các con , mỗi con đã có sẵn mảng . 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 ngân sách và con mới dùng , kết quả dùng :
Vì sao đúng? Một tập đỉnh được chọn trong cây con 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 đ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 và 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ỉ chứ không phải ? Đây là kết quả không hiển nhiên. Nếu trong vòng lặp merge ta giới hạn theo kích thước cây con đã gộp () và theo kích thước cây con (), thì mỗi cặp đỉnh 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 và , đúng thời điểm hai cây con chứa và được gộp. Số cặp đỉnh là , nhân với chi phí trên mỗi đơn vị, ra (khi thì là ). Bắt buộc phải dùng min(sz[u], W) và min(sz[v], ...) làm cận, nếu không sẽ tụt xuống .
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 . Cây và dữ liệu (trọng số , giá trị ):
(1) w=1 val=2
/ \
(2) w=1 (3) w=2
val=3 val=4
/
(4) w=2
val=5
Định nghĩa = giá trị lớn nhất dùng đúng ngân sách trong cây con , trong đó nếu chọn bất kỳ đỉnh nào của cây con thì phải chọn . Khởi tạo: (không chọn gì), (chỉ chọn ). Ô trống = (không đạt được).
Lá 4 ():
| j | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dp[4] | 0 | · | 5 | · |
Lá 3 ():
| j | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dp[3] | 0 | · | 4 | · |
Đỉnh 2 (): khởi tạo , rồi gộp con 4. Con 4 dùng . Duyệt giảm dần:
- (đã chọn 2): cộng (chọn ).
- : chỉ giữ được (không chọn gì).
Sau khi gộp con 4, bảng :
| j | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dp[2] | 0 | 3 | · | 8 |
Gốc 1 (): khởi tạo . Cận merge ở đây là (muốn chọn con thì phải đã chọn 1).
Gộp con 2 (dùng với giá trị ), duyệt từ cao xuống, chỉ :
- : ; ; vượt (), bỏ.
Sau khi gộp con 2:
| j | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dp[1] | 0 | 2 | 5 | · |
Gộp tiếp con 3 (, giá trị ), vẫn :
- : giữ ; → , bỏ.
- : ; .
Bảng cuối:
| j | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dp[1] | 0 | 2 | 5 | 6 |
Đáp số , đạt khi chọn (trọng số , giá trị ). So với giá trị hay trọng số — phương án 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ố , giá trị lớn nhất. Trạng thái = giá trị lớn nhất dùng đúng trọng số, bắt buộc chọn .
#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 (), nhưng hễ chọn đỉnh nào trong cây con thì cha của nó (và do đệ quy, cả ) 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 nối tới mọi gốc thực, chạy dfs(0, -1), đáp số .
Dạng 3 — Chọn đúng đỉnh tối đa hóa tổng khoảng cách đôi —
Bài toán: chọn đúng đỉ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 đó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ó đỉnh được chọn và phía trên có đỉnh, cạnh được đếm lần:
Trạng thái = tổng khoảng cách đôi lớn nhất khi chọn đúng đỉnh trong cây con .
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ố dùng tổng số đỉnh được chọn trên toàn cây (), 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ộ đỉnh cuối cùng.
Độ phức tạp
| Dạng | Thời gian | Bộ nhớ |
|---|---|---|
| Cây con liên thông / Dependency | ||
| Chọn đỉnh max tổng dist |
Thời gian: như đã lý giải ở mục Trực giác — nhờ cận min(sz[u], W) và 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à (hoặc ). Không có cận này, độ phức tạp tụt xuống .
Bộ nhớ: mảng dp[N][W+1] chiếm . Với là ô — ổn. Với lớn () mà cũng lớn thì 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)vàmin(sz[v], …)cho vòng lặp — Đây là lỗi nghiêm trọng nhất. Nếu cho chạy thẳng tới , độ phức tạp thành và TLE trê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ộngsz[u] += sz[v]sau khi gộp xong con . Nếu cộng trước, cậnmin(sz[u], W)của vòng sẽ bao gồm cả , khiến một đỉnh của bị ghép với chính nó (dùng hai lần). - Duyệt tăng dần thay vì giảm dần — Như 0/1 knapsack, phải duyệt 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 có thể tới , vượt
int. Phải dùnglong longvà ép kiểu(long long)j * (k - j) * wtrước khi nhân. Đềcreaturesghi rõ "kết quả có thể vượt ". - Lẫn lộn trạng thái cơ sở — Dạng 1 buộc chọn gốc nên để và cận ; Dạng 2 cho phép bỏ cả cây con nên và cận . Đặ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 " đơn vị trọng số" với " đỉnh" — Hai họ bài dùng chiều DP khác nhau (theo trọng số hay theo số đỉnh ). Trộn lẫn (vd dùng là số đỉnh nhưng cận theo ) 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 thì chọn các đỉnh trong "; có thể chuyển thành knapsack tuyến tính với bước "nếu bỏ 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 nhỏ và 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 đỉ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 (, phân rã theo cạnh, nhớ
long long).