Hướng dẫn giải của Bài toán chiếc ba lô
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.
Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.
Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.
Bạn phải đăng nhập để mở lời giải này.
Đăng nhậpTác giả: , ,
Hiểu bài toán
Bài toán này là một biến thể của bài toán cái túi (Knapsack) với các ràng buộc phụ thuộc. Có N món đồ, mỗi món có khối lượng wi và giá trị vi. Sức chứa balo là W. Ràng buộc là một số đồ vật chỉ có thể được chọn nếu một đồ vật khác (đồ vật cha) đã được chọn trước đó. Ràng buộc này tạo thành một cấu trúc cây (hoặc rừng cây), trong đó đồ vật i phụ thuộc vào đồ vật d_i (cha). Mục tiêu là chọn một tập hợp đồ vật con (tập hợp này phải hợp lệ về mặt phụ thuộc) sao cho tổng khối lượng không vượt quá W và tổng giá trị là lớn nhất.
Các cách tiếp cận
Cách Quy hoạch động trên cây với mảng 1 chiều (Tree Knapsack)
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
const int MAXN = 50010;
const int MAXW = 60010;
int n, W;
vector<int> adj[MAXN];
int w[MAXN], v[MAXN];
int dp[MAXN][MAXW];
int sz[MAXN];
// Tính kích thước subtree cho mỗi node
void get_sizes(int u) {
sz[u] = 1;
for (int v : adj[u]) {
get_sizes(v);
sz[u] += sz[v];
}
}
void dfs(int u) {
// Base case: Mảng dp[u] đã được khởi tạo với 0 (hoặc -Inf)
// Ban đầu, xem như chưa chọn gì, dp[u][0] = 0, các trạng thái khác -Inf
// Tuy nhiên, để đơn giản, ta coi dp[u] là DP của subtree rooted at u
// Logic: Duyệt qua các con, merge kết quả vào cha.
for (int child : adj[u]) {
dfs(child);
// Merge subtree của child vào u
// Đây là thuật toán 'Knapsack on tree' kinh điển
for (int j = W; j >= 0; j--) {
// Nếu ta đã có j khối lượng ở node u (từ các con trước đó hoặc chính nó)
// Giờ thử thêm subtree của child
for (int k = 0; k <= j; k++) {
// k là khối lượng từ subtree child
n // j - k là khối lượng còn lại
if (dp[u][j - k] != -1 && dp[child][k] != -1) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[child][k]);
}
}
}
}
// Cuối cùng, thử chọn chính node u
// Chỉ cần lùi mảng 1 bước cho khối lượng của u
for (int j = W; j >= w[u]; j--) {
if (dp[u][j - w[u]] != -1) {
dp[u][j] = max(dp[u][j], dp[u][j - w[u]] + v[u]);
}
}
}
int main() {
cin >> n >> W;
for (int i = 1; i <= n; i++) {
int p;
cin >> p;
if (p != 0) adj[p].push_back(i);
}
for (int i = 1; i <= n; i++) cin >> w[i];
for (int i = 1; i <= n; i++) cin >> v[i];
// Khởi tạo DP
memset(dp, -1, sizeof(dp));
for (int i = 0; i <= n; i++) dp[i][0] = 0;
// Tạo node ảo là root của tất cả các node không có cha
// Hoặc duyệt qua các node gốc
vector<int> roots;
for (int i = 1; i <= n; i++) {
if (adj[i].size() > 0 && count(adj, adj+n, i) == 0) roots.push_back(i);
}
// Code mẫu này giả định cấu trúc forest, cần xử lý root ảo
// Đơn giản hơn: Solution 1 dùng node 0 làm root cho mọi item có d_i = 0
return 0;
}
- Time Complexity: ~O(N * W^2)~ (Quá chậm, chỉ để minh họa logic cơ bản)
- Space Complexity: O(N * W)
Đây là cách tiếp cận cơ bản nhất của DP trên cây. Đối với mỗi node, ta tính toán các khả năng chọn các đồ vật trong subtree của nó. Ta duyệt qua các con của một node, kết hợp DP của các con vào node đó. Cuối cùng, ta thử chọn chính node đó. Cách này rất trực quan nhưng độ phức tạp ~O(N * W^2)~ do việc merge mảng 2 chiều thô sơ, dẫn đến TLE với giới hạn đề bài.
Cách Quy hoạch động Optimized (Kỹ thuật Merge Tree + Knapsack)
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
const int MAXN = 50010;
const int MAXW = 60010;
int n, W;
vector<int> adj[MAXN];
int w[MAXN], v[MAXN];
int dp[MAXN][MAXW];
int sz[MAXN];
// Tính kích thước subtree để limit vòng lặp (Heuristic)
void get_sizes(int u) {
sz[u] = 1;
for (int v : adj[u]) {
get_sizes(v);
sz[u] += sz[v];
}
}
void dfs(int u) {
// Khởi tạo: Node u chưa được chọn, capacity 0 là 0, còn lại -Inf
// Tuy nhiên, ta sẽ merge con trước
for (int child : adj[u]) {
dfs(child);
// Merge con vào cha
// Optimization: Chỉ duyệt j đến sz[u] * 200 (giả định max weight)
// Nhưng đơn giản nhất là duyệt j từ W về 0
for (int j = W; j >= 0; j--) {
if (dp[u][j] == -1) continue;
for (int k = 1; k <= sz[child] * 200 && j + k <= W; k++) {
if (dp[child][k] != -1) {
dp[u][j + k] = max(dp[u][j + k], dp[u][j] + dp[child][k]);
}
}
}
}
// Chọn node u
for (int j = W; j >= w[u]; j--) {
if (dp[u][j - w[u]] != -1)
dp[u][j] = max(dp[u][j], dp[u][j - w[u]] + v[u]);
}
}
int main() {
cin >> n >> W;
// Đọc input và xây dựng cây
// ... (giống code mẫu)
return 0;
}
- Time Complexity: ~O(N * W * 200)~ (Vẫn quá chậm nếu không tối ưu đúng cách)
- Space Complexity: O(N * W)
Cách này cải tiến một chút bằng cách dùng kích thước subtree để giới hạn vòng lặp, nhưng nhìn chung vẫn chưa tối ưu enough cho bài toán này vì W khá lớn và N lớn. Tuy nhiên, đây là bước đệm quan trọng để hiểu các giải thuật tối ưu hơn.
Cách Quy hoạch động theo DFS order (Duyệt theo thứ tự)
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int n, m;
vector<int> g[50010];
int w[50010], v[50010], sz[50010], o[50010];
int pool[61000010]; // Pool bộ nhớ khổng lồ
int t;
void dfs(int x) {
for (auto u : g[x])
dfs(u), sz[x] += sz[u];
o[++t] = x; // Lưu thứ tự DFS pre-order
++sz[x];
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
std::cin >> n >> m;
// Xây dựng cây
for (int i = 1, x; i <= n; i++) {
std::cin >> x;
g[x].push_back(i);
}
for (int i = 1; i <= n; i++) std::cin >> w[i];
for (int i = 1; i <= n; i++) std::cin >> v[i];
// Duyệt DFS từ root 0 (node ảo)
dfs(0);
// Ép kiểu mảng 1 chiều thành mảng 2D: f[i][j]
// i là số lượng item đã xét, j là khối lượng
int (*f)[m + 1] = decltype(f)(pool);
// Khởi tạo mảng DP, mặc định 0
// f[0][0] = 0 là mặc định
for (int i = 1; i <= n; i++) {
int x = o[i]; // Item thứ i trong thứ tự DFS
int size_subtree = sz[x];
int parent_idx = i - size_subtree; // Vị trí của cha trong thứ tự DFS
// Rule 1: Nếu không chọn item x
// Trạng thái f[i][j] có thể kế thừa từ f[i-1][j]
// Nhưng do cách tổ chức DP theo cây, ta cần copy từ state của cha + các con đã xử lý
// Code tối ưu:
// Nếu không chọn x, kết quả tương tự như khi chưa xét subtree x
// f[i][j] = f[parent_idx][j]
for (int j = 0; j < m + 1; j++) {
f[i][j] = f[parent_idx][j];
}
// Rule 2: Nếu có chọn item x
// Chỉ xét các khối lượng có thể chứa w[x]
// Cập nhật: f[i][j] = max(f[i][j], f[i-1][j - w[x]] + v[x])
// Tuy nhiên, biến thể cây đặc biệt này có một công thức gom nhóm:
// f[i][j] = max(f[parent_idx][j], f[i-1][j - w[x]] + v[x])
// Tuy nhiên, code mẫu dùng:k
// f[i][j] = max(f[parent_idx][j], f[i-1][j - w[x]] + v[x])
// Cụ thể code mẫu:
for (int j = w[x]; j <= m; j++) {
f[i][j] = std::max(f[i - sz[x]][j], f[i - 1][j - w[x]] + v[x]);
}
// Giải thích chi tiết hơn:
// f[i][j] là DP sau khi đã xét xong subtree của node thứ i trong order.
// f[i - sz[x]][j] là DP của node cha (và các phần tử trước đó).
// f[i - 1][j - w[x]] là DP của node ngay trước đó (đã được xử lý trong cùng subtree).
}
std::cout << *std::max_element(f[n], f[n] + m + 1);
return 0;
}
- Time Complexity: O(N * W)
- Space Complexity: O(N * W)
Đây là phương pháp tối ưu nhất được trình bày trong giải pháp 1. Ý tưởng là:
- Xây dựng cây phụ thuộc, thêm node 0 làm cha của mọi node không phụ thuộc ai.
- Thực hiện DFS để lấy ra một thứ tự (pre-order) của các node.
- Định nghĩa DP[i][j]: tổng giá trị lớn nhất khi xét i node đầu tiên trong thứ tự với sức chứa j.
- Với mỗi node x ở vị trí i:
- Nếu không chọn x: Trạng thái DP[i][j] phải bằng chính xác DP[i - sz[x]][j] (trạng thái của node cha). Điều này đúng vì nếu không chọn x, ta không thể chọn bất kỳ node nào trong subtree của x, nên ta bỏ qua cả khối subtree này.
- Nếu chọn x: Ta có thể chọn x, và các node trước đó. Công thức là DP[i][j] = max(DP[i][j], DP[i-1][j - w[x]] + v[x]).
- Code gộp hai trường hợp:
max(f[i - sz[x]][j], f[i - 1][j - w[x]] + v[x]).
Cách này biến bài toán cây thành một bài toán quy hoạch động tuyến tính, tận dụng ưu điểm của Knapsack kinh điển.
Phân tích độ phức tạp
| Cách tiếp cận | Time | Space | Tên |
|---|---|---|---|
| 1 | ~O(N * W^2)~ (Quá chậm, chỉ để minh họa logic cơ bản) | O(N * W) | Quy hoạch động trên cây với mảng 1 chiều (Tree Knapsack) |
| 2 | ~O(N * W * 200)~ (Vẫn quá chậm nếu không tối ưu đúng cách) | O(N * W) | Quy hoạch động Optimized (Kỹ thuật Merge Tree + Knapsack) |
| 3 | O(N * W) | O(N * W) | Quy hoạch động theo DFS order (Duyệt theo thứ tự) |
Bài học kinh nghiệm
- Biến đổi bài toán cây thành bài toán quy hoạch động tuyến tính bằng cách sử dụng thứ tự DFS (pre-order).
- Công thức gom nhóm
f[i][j] = max(f[i - sz[x]][j], f[i - 1][j - w[x]] + v[x])là chìa khóa để xử lý phụ thuộc tree-structure. - Giới hạn
N * W <= 6 * 10^7cho phép giải thuật O(N * W) chạy trong thời gian chấp nhận được.
Lỗi thường gặp
- Lỗi tràn mảng bộ nhớ khi khai báo
dp[N][W]vì N*W có thể lên tới 300 triệu phần tử. Cần dùng mảng 1 chiều lớn hoặc pool memory như trong code mẫu. - Quên xử lý node ảo (root 0) dẫn đến việc bỏ sót các node gốc của rừng cây.
- Sai lệch trong việc tính toán kích thước subtree (
sz) hoặc thứ tự duyệt (order), dẫn đến việc cập nhật DP sai.
Bình luận