Bài 27.5: Bài tập tổng hợp về các biến thể Knapsack

Bài 27.5: Bài tập tổng hợp về các biến thể Knapsack
Chào mừng các bạn đã quay trở lại với hành trình chinh phục Cấu trúc dữ liệu và Giải thuật! Sau khi đã làm quen với bài toán Knapsack 0/1 kinh điển - bài toán đặt nền móng cho rất nhiều kỹ thuật Lập trình động (Dynamic Programming - DP), hôm nay chúng ta sẽ cùng nhau mở rộng kiến thức bằng cách khám phá những biến thể thú vị của nó.
Bài toán Knapsack, hay còn gọi là bài toán Cái Túi, mô tả tình huống đơn giản nhưng mạnh mẽ: làm sao để đóng gói các vật phẩm vào một chiếc túi có sức chứa giới hạn sao cho đạt được mục tiêu nào đó (thường là tối đa hóa giá trị). Sự đơn giản này làm cho Knapsack trở thành một mô hình tuyệt vời để giải quyết vô số vấn đề trong thực tế, từ tối ưu hóa tài nguyên, phân bổ ngân sách cho đến cắt vật liệu công nghiệp.
Hiểu sâu về Knapsack và các biến thể của nó không chỉ giúp bạn giải quyết trực tiếp các bài toán dạng này mà còn trang bị cho bạn tư duy Lập trình động mạnh mẽ để nhận diện và xử lý các vấn đề khác có cấu trúc tương tự. Hãy cùng đi sâu vào thế giới đa dạng của Knapsack!
1. Knapsack 0/1 - Nền Tảng Vững Chắc (Ôn Lại)
Trước khi khám phá các biến thể, chúng ta hãy nhanh chóng ôn lại bài toán gốc: Knapsack 0/1.
Bài toán: Cho N
vật phẩm, mỗi vật phẩm i
có trọng lượng w[i]
và giá trị v[i]
. Chúng ta có một chiếc túi có sức chứa tối đa là W
. Nhiệm vụ là chọn một tập hợp các vật phẩm sao cho tổng trọng lượng không vượt quá W
và tổng giá trị là lớn nhất. Với mỗi vật phẩm, chúng ta chỉ có hai lựa chọn: hoặc chọn (1), hoặc không chọn (0).
Kỹ thuật DP: Chúng ta xây dựng một bảng DP để lưu trữ kết quả của các bài toán con.
- Trạng thái DP:
dp[i][j]
là giá trị lớn nhất có thể đạt được khi chỉ xét đếni
vật phẩm đầu tiên và túi có sức chứaj
. - Công thức truy hồi: Khi xét vật phẩm thứ
i
:- Nếu không chọn vật phẩm
i
: Giá trị lớn nhất vẫn là giá trị đạt được khi chỉ xéti-1
vật phẩm với sức chứaj
, tức làdp[i-1][j]
. - Nếu chọn vật phẩm
i
(chỉ khiw[i] <= j
): Giá trị lớn nhất là giá trị đạt được khi xéti-1
vật phẩm với sức chứa còn lạij - w[i]
cộng thêm giá trị của vật phẩmi
, tức làdp[i-1][j - w[i]] + v[i]
. - Vậy,
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])
nếuw[i] <= j
, ngược lạidp[i][j] = dp[i-1][j]
.
- Nếu không chọn vật phẩm
Tối ưu không gian O(W): Quan sát công thức truy hồi, để tính dp[i][j]
, ta chỉ cần các giá trị từ hàng i-1
. Điều này cho phép chúng ta giảm không gian lưu trữ từ O(N*W) xuống O(W) bằng cách chỉ sử dụng một mảng 1D dp[j]
, đại diện cho giá trị lớn nhất với sức chứa j
.
Khi cập nhật mảng dp
1D, điều quan trọng cần nhớ là vòng lặp cho sức chứa j
phải đi từ W
xuống w[i]
. Điều này đảm bảo rằng khi tính dp[j]
, giá trị dp[j - w[i]]
mà ta sử dụng vẫn là giá trị từ "bước trước" (tức là không bao gồm vật phẩm i
hiện tại), mô phỏng việc chỉ sử dụng mỗi vật phẩm một lần.
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
// Ví dụ Knapsack 0/1
std::vector<int> weights = {10, 20, 30}; // Trọng lượng vật phẩm
std::vector<int> values = {60, 100, 120}; // Giá trị vật phẩm
int capacity = 50; // Sức chứa tối đa của túi
int n = weights.size(); // Số lượng vật phẩm
// Mảng DP với kích thước (capacity + 1), khởi tạo 0
// dp[j] sẽ lưu giá trị lớn nhất có thể đạt được với sức chứa j
std::vector<int> dp(capacity + 1, 0);
// Duyệt qua từng vật phẩm
for (int i = 0; i < n; ++i) {
// Duyệt qua sức chứa từ lớn nhất xuống đến trọng lượng của vật phẩm hiện tại
// Điều này đảm bảo mỗi vật phẩm chỉ được xét "đến" một lần cho mỗi sức chứa j
for (int j = capacity; j >= weights[i]; --j) {
// Công thức truy hồi:
// dp[j] hiện tại là giá trị tốt nhất khi KHÔNG chọn vật phẩm i
// dp[j - weights[i]] + values[i] là giá trị khi CHỌN vật phẩm i
// (Lưu ý: dp[j - weights[i]] vẫn là giá trị tốt nhất từ các vật phẩm 0..i-1
// do vòng lặp j đang giảm dần)
dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
}
}
// Giá trị lớn nhất đạt được với sức chứa capacity
std::cout << "Knapsack 0/1 Max Value: " << dp[capacity] << std::endl; // Output: 220 (Chọn vật 20kg (100) và 30kg (120))
return 0;
}
Giải thích code:
- Mảng
dp
có kích thướccapacity + 1
.dp[j]
lưu trữ giá trị tối đa có thể đạt được với sức chứa chính xác (hoặc nhỏ hơn, nhưng trong trường hợp tối ưu này thì thường là chính xác)j
, chỉ sử dụng các vật phẩm đã được xử lý cho đến bước hiện tại. - Vòng lặp ngoài duyệt qua từng vật phẩm (
i
). - Vòng lặp trong duyệt qua sức chứa (
j
) từcapacity
xuốngweights[i]
. Việc duyệt ngược này là bí quyết của tối ưu không gian O(W) cho 0/1 Knapsack. Nó đảm bảo rằng khi bạn tínhdp[j]
, giá trịdp[j - weights[i]]
mà bạn lấy để cộng thêmvalues[i]
vẫn là giá trị trước khi xem xét vật phẩmi
cho sức chứaj
này. dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
so sánh hai trường hợp: không chọn vật phẩmi
(giá trị vẫn làdp[j]
trước khi cập nhật) và chọn vật phẩmi
(giá trị làdp[j - weights[i]]
cộng thêm giá trị của vật phẩmi
).
2. Knapsack Không Giới Hạn (Unbounded Knapsack) - Sức Mạnh Của Sự Lặp Lại
Đây là biến thể đầu tiên và phổ biến của Knapsack 0/1.
Bài toán: Giống Knapsack 0/1, nhưng mỗi vật phẩm có thể được chọn nhiều lần (không giới hạn số lượng).
Điểm khác biệt chính: Vì mỗi vật phẩm có thể dùng nhiều lần, khi chúng ta quyết định chọn vật phẩm i
, phần sức chứa còn lại j - w[i]
vẫn có thể sử dụng lại chính vật phẩm i
đó.
Kỹ thuật DP:
- Trạng thái DP: Tương tự, dùng mảng 1D
dp[j]
là giá trị lớn nhất có thể đạt được với sức chứaj
. - Công thức truy hồi: Khi xét vật phẩm thứ
i
cho sức chứaj
:- Nếu chọn vật phẩm
i
: Giá trị làdp[j - w[i]] + v[i]
. Nhưng khác với 0/1 Knapsack,dp[j - w[i]]
ở đây có thể đã bao gồm vật phẩmi
rồi! Điều này là hợp lệ vì ta có thể chọn vật phẩmi
nhiều lần. dp[j] = max(dp[j], dp[j - w[i]] + v[i])
nếuw[i] <= j
.
- Nếu chọn vật phẩm
Tối ưu không gian O(W): Vẫn sử dụng mảng 1D dp
kích thước O(W). Tuy nhiên, vòng lặp cho sức chứa j
bây giờ phải đi từ weights[i]
lên đến capacity
.
Tại sao lại ngược lại so với 0/1? Khi duyệt j
tăng dần, khi ta tính dp[j]
, giá trị dp[j - weights[i]]
mà ta tham chiếu đã có thể được cập nhật bằng cách sử dụng vật phẩm i
(nếu j - weights[i] >= weights[i]
). Điều này chính xác là hành vi ta muốn: cho phép vật phẩm i
đóng góp vào dp[j - weights[i]]
và sau đó lại được sử dụng một lần nữa để đạt được dp[j]
.
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
// Ví dụ Knapsack Không Giới Hạn
std::vector<int> weights = {10, 20, 30}; // Trọng lượng vật phẩm
std::vector<int> values = {60, 100, 120}; // Giá trị vật phẩm
int capacity = 50; // Sức chứa tối đa của túi
int n = weights.size(); // Số lượng vật phẩm
// Mảng DP với kích thước (capacity + 1), khởi tạo 0
// dp[j] sẽ lưu giá trị lớn nhất có thể đạt được với sức chứa j
std::vector<int> dp(capacity + 1, 0);
// Duyệt qua từng vật phẩm
for (int i = 0; i < n; ++i) {
// Duyệt qua sức chứa từ trọng lượng của vật phẩm hiện tại lên đến lớn nhất
// Điều này đảm bảo mỗi vật phẩm CÓ THỂ được sử dụng nhiều lần cho một sức chứa j
for (int j = weights[i]; j <= capacity; ++j) {
// Công thức truy hồi:
// dp[j] hiện tại là giá trị tốt nhất ĐẾN thời điểm này cho sức chứa j
// dp[j - weights[i]] + values[i] là giá trị khi CHỌN vật phẩm i.
// dp[j - weights[i]] ở đây CÓ THỂ đã bao gồm vật phẩm i (nếu j-weights[i] đủ lớn),
// cho phép chọn vật phẩm i nhiều lần.
dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
}
}
// Giá trị lớn nhất đạt được với sức chứa capacity
std::cout << "Unbounded Knapsack Max Value: " << dp[capacity] << std::endl; // Output: 300 (Chọn 5 lần vật 10kg (60*5=300))
return 0;
}
Giải thích code:
- Cấu trúc tương tự như 0/1 Knapsack với tối ưu không gian O(W).
- Điểm khác biệt duy nhất (nhưng quan trọng) nằm ở vòng lặp trong duyệt sức chứa
j
. Nó đi từweights[i]
lên đếncapacity
. Điều này tạo ra sự khác biệt cơ bản trong công thức truy hồi:dp[j - weights[i]]
giờ đây có thể tham chiếu đến một giá trị đã được cập nhật bằng cách sử dụng vật phẩmi
, cho phép vật phẩmi
được sử dụng lại để đạt được sức chứaj
.
3. Bài Toán Tổng Con (Subset Sum) - Tìm Kiếm Sự Kết Hợp
Bài toán Subset Sum không hẳn là một "Knapsack" theo nghĩa truyền thống (tối đa hóa giá trị), nhưng nó có cấu trúc DP rất giống với Knapsack 0/1 và thường được xem như một biến thể hoặc bài toán liên quan chặt chẽ.
Bài toán: Cho một tập hợp các số nguyên dương nums
và một số nguyên dương target
. Xác định xem có tồn tại một tập con (subset) của nums
mà các phần tử trong tập con đó có tổng bằng target
hay không.
Liên hệ với Knapsack 0/1:
- Tập hợp các số
nums
là các "vật phẩm". - Giá trị (value) và trọng lượng (weight) của mỗi "vật phẩm" đều chính là giá trị của số đó (
nums[i]
). - Sức chứa (capacity) của túi là
target
. - Mục tiêu là xác định xem có thể đạt được tổng giá trị (cũng là tổng trọng lượng) chính xác bằng
target
hay không, sử dụng mỗi số tối đa một lần (vì là tập con).
Kỹ thuật DP:
- Trạng thái DP: Sử dụng mảng boolean 1D
dp[j]
, có nghĩa là "có thể đạt được tổngj
hay không?". - Khởi tạo:
dp[0]
làtrue
(tổng 0 luôn có thể đạt được bằng cách không chọn số nào). Cácdp[j]
khác (j > 0) ban đầu làfalse
. - Công thức truy hồi: Khi xét số
num
hiện tại: Với mỗi tổngj
mà ta đang xem xét (từtarget
xuốngnum
), nếu ta đã có thể đạt được tổngj - num
(tức làdp[j - num]
làtrue
), thì ta có thể đạt được tổngj
bằng cách thêm sốnum
.dp[j] = dp[j] || dp[j - num]
nếunum <= j
.
Tối ưu không gian O(Target): Sử dụng mảng boolean 1D dp
kích thước O(Target). Giống như 0/1 Knapsack, vòng lặp cho tổng j
phải đi từ target
xuống num
để đảm bảo mỗi số chỉ được sử dụng một lần cho mỗi tổng j
.
#include <iostream>
#include <vector>
#include <numeric> // For std::accumulate
int main() {
// Ví dụ Subset Sum
std::vector<int> nums = {3, 34, 4, 12, 5, 2}; // Tập hợp các số
int target = 9; // Tổng cần đạt
// Mảng DP boolean, kích thước (target + 1)
// dp[j] = true nếu có thể tạo ra tổng j, false nếu ngược lại
std::vector<bool> dp(target + 1, false);
dp[0] = true; // Có thể tạo ra tổng 0 (bằng cách không chọn số nào)
// Duyệt qua từng số trong tập nums
for (int num : nums) {
// Duyệt qua tổng j từ target xuống đến num
// Giống 0/1 Knapsack, duyệt ngược để đảm bảo mỗi số chỉ dùng 1 lần cho mỗi tổng j
for (int j = target; j >= num; --j) {
// Nếu có thể tạo ra tổng j - num (dp[j - num] là true)
// Thì ta có thể tạo ra tổng j bằng cách thêm số num vào tập con tạo ra j - num
dp[j] = dp[j] || dp[j - num];
}
}
// Kết quả là dp[target]
if (dp[target]) {
std::cout << "Subset Sum: Possible to achieve sum " << target << std::endl; // Output: Possible... (3 + 4 + 2 = 9 hoặc 5 + 4 = 9)
} else {
std::cout << "Subset Sum: Not possible to achieve sum " << target << std::endl;
}
// Ví dụ khác: target = 30
target = 30;
std::vector<bool> dp2(target + 1, false);
dp2[0] = true;
for (int num : nums) {
for (int j = target; j >= num; --j) {
dp2[j] = dp2[j] || dp2[j - num];
}
}
if (dp2[target]) {
std::cout << "Subset Sum: Possible to achieve sum " << target << std::endl; // Output: Not possible...
} else {
std::cout << "Subset Sum: Not possible to achieve sum " << target << std::endl;
}
return 0;
}
Giải thích code:
- Mảng boolean
dp
lưu trữ khả năng đạt được từng tổng từ 0 đếntarget
. dp[0]
được khởi tạo làtrue
.- Vòng lặp ngoài duyệt qua từng số trong
nums
. - Vòng lặp trong duyệt
j
ngược từtarget
xuốngnum
. Lệnhdp[j] = dp[j] || dp[j - num];
có nghĩa là: "Tổngj
có thể đạt được nếu nó đã đạt được trước khi xét sốnum
(dp[j]
ban đầu) HOẶC nó có thể đạt được bằng cách lấy một tập con đạt tổngj - num
và thêm sốnum
vào (dp[j - num]
)." Vòng lặp ngược đảm bảo rằng khi ta dùngdp[j - num]
, nó phản ánh khả năng đạt tổngj - num
chỉ bằng các số trước sốnum
hiện tại.
Bài toán Subset Sum cũng có thể được biến thể thành bài toán đếm số lượng tập con có tổng bằng target
, hoặc tìm một tập con cụ thể nếu có. Kỹ thuật DP vẫn tương tự, chỉ thay đổi ý nghĩa của dp[j]
(là số lượng cách thay vì boolean).
4. Bài Toán Đổi Tiền (Coin Change) - Đếm Hoặc Tối Ưu Số Lượng
Bài toán Coin Change cũng là một "họ hàng" thân cận của Knapsack, đặc biệt là Unbounded Knapsack. Nó có hai biến thể chính: đếm số cách đổi tiền và tìm số đồng xu ít nhất để đổi tiền. Chúng ta sẽ xem xét biến thể tìm số đồng xu ít nhất, vì nó liên quan đến việc tối ưu (minimizing) tương tự như Knapsack tối ưu (maximizing).
Bài toán: Cho một tập hợp các mệnh giá đồng xu coins
và một số tiền amount
. Tìm số đồng xu ít nhất cần thiết để tạo ra tổng số tiền amount
. Giả sử mỗi mệnh giá có sẵn không giới hạn số lượng. Nếu không thể tạo ra tổng amount
, trả về -1 (hoặc một giá trị đặc biệt như vô cực).
Liên hệ với Unbounded Knapsack:
- Các mệnh giá đồng xu
coins
là các "vật phẩm". - Trọng lượng (weight) của mỗi vật phẩm là mệnh giá đồng xu (
coins[i]
). - Giá trị (value) của mỗi vật phẩm là 1 (vì ta muốn tối thiểu hóa số lượng đồng xu, tương đương với tối đa hóa một giá trị âm -1, hoặc đơn giản là đếm số vật phẩm).
- Sức chứa (capacity) của túi là
amount
. - Mục tiêu là tìm số lượng "vật phẩm" (đồng xu) ít nhất để đạt được "sức chứa" (tổng tiền) chính xác bằng
amount
. Mỗi đồng xu có thể sử dụng nhiều lần.
Kỹ thuật DP:
- Trạng thái DP: Mảng 1D
dp[j]
là số đồng xu ít nhất cần thiết để tạo ra tổng tiềnj
. - Khởi tạo:
dp[0] = 0
(cần 0 đồng xu để tạo ra tổng 0). Tất cả cácdp[j]
khác (j > 0) được khởi tạo với một giá trị "vô cực" (một số rất lớn) để biểu thị rằng tổng đó ban đầu là không thể đạt được. - Công thức truy hồi: Khi xét đồng xu
coin
hiện tại: Với mỗi tổngj
từcoin
lên đếnamount
, nếu tổngj - coin
là có thể đạt được (tức làdp[j - coin]
không phải vô cực), thì ta có thể đạt được tổngj
bằng cách thêm đồng xucoin
. Số đồng xu cần thiết sẽ làdp[j - coin] + 1
. Ta muốn tìm số đồng xu ít nhất, nên ta lấy giá trị nhỏ nhất giữadp[j]
hiện tại vàdp[j - coin] + 1
.dp[j] = min(dp[j], dp[j - coin] + 1)
nếucoin <= j
vàdp[j - coin]
không phải vô cực.
Tối ưu không gian O(Amount): Sử dụng mảng 1D dp
kích thước O(Amount). Giống như Unbounded Knapsack, vòng lặp cho tổng j
phải đi từ coin
lên đến amount
. Điều này cho phép sử dụng lại cùng một mệnh giá đồng xu nhiều lần để đạt được tổng j
.
#include <iostream>
#include <vector>
#include <algorithm>
// Định nghĩa một giá trị vô cực lớn
const int INF = 1e9; // Sử dụng 1e9 hoặc std::numeric_limits<int>::max()
int main() {
// Ví dụ Coin Change (Minimum Coins)
std::vector<int> coins = {1, 2, 5}; // Các mệnh giá đồng xu
int amount = 11; // Tổng tiền cần đổi
// Mảng DP, kích thước (amount + 1)
// dp[j] = số đồng xu ít nhất để tạo ra tổng j
std::vector<int> dp(amount + 1, INF); // Khởi tạo với vô cực
dp[0] = 0; // Cần 0 đồng xu để tạo ra tổng 0
// Duyệt qua từng mệnh giá đồng xu
for (int coin : coins) {
// Duyệt qua tổng tiền j từ coin lên đến amount
// Giống Unbounded Knapsack, duyệt xuôi để cho phép dùng đồng xu coin nhiều lần
for (int j = coin; j <= amount; ++j) {
// Nếu tổng j - coin là có thể đạt được (dp[j - coin] không phải vô cực)
if (dp[j - coin] != INF) {
// Thì ta có thể đạt tổng j bằng cách thêm đồng xu coin.
// Số đồng xu mới cần là dp[j - coin] + 1.
// Cập nhật dp[j] với giá trị nhỏ nhất.
dp[j] = std::min(dp[j], dp[j - coin] + 1);
}
}
}
// Kết quả là dp[amount]. Nếu vẫn là vô cực, nghĩa là không thể đổi được.
if (dp[amount] == INF) {
std::cout << "Coin Change (Min Coins): Cannot make amount " << amount << std::endl;
} else {
std::cout << "Coin Change (Min Coins): Minimum coins for " << amount << " is " << dp[amount] << std::endl; // Output: 3 (5 + 5 + 1)
}
// Ví dụ khác: amount = 7
amount = 7;
std::vector<int> dp2(amount + 1, INF);
dp2[0] = 0;
for (int coin : coins) {
for (int j = coin; j <= amount; ++j) {
if (dp2[j - coin] != INF) {
dp2[j] = std::min(dp2[j], dp2[j - coin] + 1);
}
}
}
if (dp2[amount] == INF) {
std::cout << "Coin Change (Min Coins): Cannot make amount " << amount << std::endl;
} else {
std::cout << "Coin Change (Min Coins): Minimum coins for " << amount << " is " << dp2[amount] << std::endl; // Output: 2 (5 + 2)
}
return 0;
}
Giải thích code:
- Mảng
dp
lưu trữ số đồng xu ít nhất. Khởi tạo vớiINF
(vô cực) trừdp[0]=0
. - Vòng lặp ngoài duyệt qua từng mệnh giá đồng xu.
- Vòng lặp trong duyệt tổng tiền
j
tăng từcoin
lênamount
. Điều này cho phép chúng ta sử dụng đồng xucoin
hiện tại để đóng góp vàodp[j - coin]
(nếuj - coin
đủ lớn để chứa một đồng xucoin
khác), và sau đó lại dùng thêm một đồng xucoin
để đạt đếnj
. dp[j] = std::min(dp[j], dp[j - coin] + 1);
cập nhật số đồng xu ít nhất cho tổngj
. Ta so sánh giá trị hiện tại củadp[j]
với giá trị có được bằng cách lấy số đồng xu ít nhất cho tổngj - coin
và thêm một đồng xucoin
.- Kiểm tra
dp[j - coin] != INF
trước khi cập nhật để tránh tràn số hoặc sử dụng kết quả từ tổng không thể đạt được.
Bài toán Coin Change cũng có biến thể đếm số cách đổi tiền. Trong trường hợp đó, công thức truy hồi sẽ là dp[j] = dp[j] + dp[j - coin]
, và mảng DP sẽ lưu trữ số cách (kiểu int
hoặc long long
). Vòng lặp duyệt tổng tiền j
vẫn đi tăng dần để cho phép các tổ hợp sử dụng lại đồng xu.
Kết nối các biến thể
Như bạn thấy, mặc dù có tên gọi và ngữ cảnh khác nhau, các bài toán Knapsack 0/1, Knapsack Không Giới Hạn, Subset Sum, và Coin Change (biến thể tối thiểu hóa) đều chia sẻ một lõi chung dựa trên Lập trình động trên một chiều "sức chứa" hoặc "tổng".
Sự khác biệt chính trong cách triển khai DP 1D nằm ở hướng duyệt vòng lặp cho chiều "sức chứa/tổng":
- Duyệt ngược (từ lớn xuống bé): Dành cho các bài toán mà mỗi "vật phẩm/số" chỉ được sử dụng tối đa một lần (như 0/1 Knapsack, Subset Sum).
- Duyệt xuôi (từ bé lên lớn): Dành cho các bài toán mà mỗi "vật phẩm/đồng xu" có thể sử dụng không giới hạn số lần (như Unbounded Knapsack, Coin Change).
Bài tập ví dụ:
Nhảy cóc trên lưới
Trong một chuyến tham quan bảo tàng, FullHouse Dev được giới thiệu một trò chơi cổ đại thú vị. Đó là một trò chơi nhảy cóc trên một tấm lưới, nơi người chơi phải tìm ra tất cả các cách có thể để di chuyển từ điểm xuất phát đến đích.
Bài toán
Bạn được cho một lưới kích thước \(n \times m\), trong đó mỗi ô chứa giá trị 0 hoặc 1. Một ô bị chặn nếu giá trị của nó là 1. Khi đứng tại một ô, bạn có thể thực hiện các bước di chuyển sau:
- Di chuyển sang phải đến ô không bị chặn gần nhất
- Di chuyển xuống dưới đến ô không bị chặn gần nhất
Xuất phát từ ô \((1,1)\), hãy xác định số cách có thể để đến được ô \((n,m)\).
Do kết quả có thể rất lớn, hãy in ra kết quả theo modulo \(10^9 + 7\).
INPUT FORMAT:
- Dòng đầu tiên chứa hai số nguyên \(n\) và \(m\) - số hàng và số cột của lưới.
- \(n\) dòng tiếp theo, mỗi dòng chứa một chuỗi độ dài \(m\) gồm các ký tự '0' và '1'.
OUTPUT FORMAT:
- In ra một số nguyên duy nhất là số cách để đi từ ô \((1,1)\) đến ô \((n,m)\) theo modulo \(10^9 + 7\).
Ràng buộc:
- \(1 \leq n, m \leq 1000\)
- Mỗi ô chỉ chứa ký tự '0' hoặc '1'
Ví dụ
INPUT
3 3
000
011
000
OUTPUT
3
Giải thích
Có 3 cách để đi từ ô (1,1) đến ô (3,3):
- (1,1) -> (2,1) -> (3,1) -> (3,2) -> (3,3)
- (1,1) -> (1,2) -> (3,2) -> (3,3)
- (1,1) -> (1,2) -> (1,3) -> (3,3) Tuyệt vời, đây là một bài toán điển hình về Quy hoạch động (Dynamic Programming - DP) trên lưới. Điểm đặc biệt ở đây là luật di chuyển "nhảy cóc đến ô không bị chặn gần nhất" thay vì chỉ di chuyển sang ô kề cạnh.
Dưới đây là hướng giải ngắn gọn bằng C++:
Xác định trạng thái DP:
- Gọi
dp[r][c]
là số cách để đi từ ô(1,1)
(hoặc(0,0)
nếu dùng chỉ mục 0) đến ô(r,c)
.
- Gọi
Trường hợp cơ sở:
dp[1][1]
(hoặcdp[0][0]
) = 1 nếu ô(1,1)
không bị chặn ('0').- Nếu ô
(1,1)
bị chặn ('1'), thì không có cách nào để bắt đầu, kết quả là 0.
Công thức truy hồi:
- Để tính
dp[r][c]
, ta cần xem xét tất cả các ô(r_prev, c)
vớir_prev < r
sao cho một bước nhảy xuống từ(r_prev, c)
sẽ đến đúng(r,c)
. - Tương tự, xem xét tất cả các ô
(r, c_prev)
vớic_prev < c
sao cho một bước nhảy sang phải từ(r, c_prev)
sẽ đến đúng(r,c)
. dp[r][c]
là tổng số cách đến tất cả các ô(r_prev, c)
và(r, c_prev)
như vậy (tất cả tính theo modulo10^9 + 7
).- Nếu ô
(r,c)
bị chặn ('1'), thìdp[r][c] = 0
.
- Để tính
Cách xác định ô nhảy đến:
- Từ ô
(r_prev, c)
(không bị chặn), bước nhảy xuống dưới sẽ đến ô(r', c)
không bị chặn đầu tiên sao chor' > r_prev
. - Từ ô
(r, c_prev)
(không bị chặn), bước nhảy sang phải sẽ đến ô(r, c')
không bị chặn đầu tiên sao choc' > c_prev
. - Để tính
dp[r][c]
, ta cần tìm ngược lại: những ô(r_prev, c)
nào mà(r,c)
là ô không bị chặn đầu tiên dưới chúng? Những ô(r, c_prev)
nào mà(r,c)
là ô không bị chặn đầu tiên bên phải chúng?
- Từ ô
Tối ưu tính toán công thức truy hồi:
- Việc trực tiếp tìm tất cả các ô
(r_prev, c)
và(r, c_prev)
như ở bước 3 cho mỗi ô(r,c)
sẽ tốn thời gian. - Nhận xét: Nếu ô
(r, c)
không bị chặn, thì nó có thể là đích nhảy xuống của nhiều ô(r_prev, c)
phía trên nó, và đích nhảy phải của nhiều ô(r, c_prev)
bên trái nó. - Cụ thể: ô
(r, c)
là đích nhảy xuống từ(r_prev, c)
nếugrid[r_prev][c] == '0'
và tất cả các ôgrid[r_prev+1][c], ..., grid[r-1][c]
đều là '1'. - Ô
(r, c)
là đích nhảy phải từ(r, c_prev)
nếugrid[r][c_prev] == '0'
và tất cả các ôgrid[r][c_prev+1], ..., grid[r][c-1]
đều là '1'. - Công thức truy hồi có thể được tính bằng cách cộng dồn (cumulative sum). Khi tính
dp[r][c]
:- Tổng các cách đến
(r,c)
từ phía trên: là tổng củadp[k][c]
cho cáck < r
thỏa mãn điều kiện nhảy xuống. - Tổng các cách đến
(r,c)
từ phía trái: là tổng củadp[r][k]
cho cáck < c
thỏa mãn điều kiện nhảy phải.
- Tổng các cách đến
- Có thể duy trì hai tổng tích lũy khi duyệt qua lưới: một tổng cho các bước nhảy từ trên xuống (theo cột) và một tổng cho các bước nhảy từ trái sang (theo hàng).
- Khi tính
dp[r][c]
(nếugrid[r][c] == '0'
):dp[r][c]
nhận đóng góp từ những ô(k,c)
phía trên(k<r)
mà(r,c)
là ô không bị chặn đầu tiên dưới chúng. Tổng này có thể được tính dựa vào tổng tích lũy cácdp
từ các ô phía trên trong cùng cộtc
mà đang chờ ô không bị chặn tiếp theo để nhảy xuống. Nếu ôgrid[r-1][c] == '1'
, thì tổng chờ từ(r-1,c)
trở lên vẫn tiếp tục chờ và nhảy đến(r,c)
. Nếu ôgrid[r-1][c] == '0'
, thì chỉ có chính(r-1,c)
(nếu thỏa điều kiện nhảy) đóng góp vào tổng chờ cho(r,c)
và các ô dưới nữa.- Tương tự cho đóng góp từ những ô
(r,k)
phía trái(k<c)
.
- Cần duy trì hai mảng phụ, ví dụ
sum_v[r][c]
vàsum_h[r][c]
, để lưu trữ tổng số cách đến các ô ở trên/trái mà có thể nhảy đến(r,c)
.sum_v[r][c]
= tổngdp[k][c]
cho cáck < r
sao chogrid[j][c] == '1'
với mọik < j < r
.sum_h[r][c]
= tổngdp[r][k]
cho cáck < c
sao chogrid[r][j] == '1'
với mọik < j < c
.- Nếu
grid[r][c] == '0'
, thìdp[r][c] = (sum_v[r][c] + sum_h[r][c]) % MOD
.
- Recurrence cho
sum_v
vàsum_h
:- Nếu
grid[r][c] == '1'
:sum_v[r][c] = 0
,sum_h[r][c] = 0
. - Nếu
grid[r][c] == '0'
:sum_v[r][c]
= (sum_v[r-1][c]
nếur>0
vàgrid[r-1][c] == '1'
) + (dp[r-1][c]
nếur>0
vàgrid[r-1][c] == '0'
). Tổng này cần tính modulo.sum_h[r][c]
= (sum_h[r][c-1]
nếuc>0
vàgrid[r][c-1] == '1'
) + (dp[r][c-1]
nếuc>0
vàgrid[r][c-1] == '0'
). Tổng này cần tính modulo.
- Nếu
- Base case cho
sum_v
,sum_h
:sum_v[0][c] = 0
cho mọic
,sum_h[r][0] = 0
cho mọir
.
- Việc trực tiếp tìm tất cả các ô
Thứ tự tính DP:
- Duyệt qua lưới từ
(1,1)
đến(n,m)
(hoặc(0,0)
đến(n-1, m-1)
) theo thứ tự hàng rồi cột. - Khi tính
dp[r][c]
, ta đã có sẵn các giá trịdp
của các ô ở hàngr
các cộtc_prev < c
và các ô ở cộtc
các hàngr_prev < r
.
- Duyệt qua lưới từ
Modulo:
- Thực hiện phép toán modulo
10^9 + 7
sau mỗi lần cộng để tránh tràn số.
- Thực hiện phép toán modulo
Tóm lại, hướng giải:
- Sử dụng mảng DP
dp[n][m]
. - Sử dụng hai mảng phụ
sum_v[n][m]
vàsum_h[n][m]
để hỗ trợ tính tổng đóng góp từ các ô phía trên và bên trái. - Khởi tạo
dp[0][0] = 1
nếugrid[0][0] == '0'
, ngược lại là 0. Các mảng phụ khởi tạo 0. - Duyệt qua lưới từ
(0,0)
đến(n-1,m-1)
:- Nếu ô
(r,c)
bị chặn ('1'):dp[r][c] = 0
,sum_v[r][c] = 0
,sum_h[r][c] = 0
. - Nếu ô
(r,c)
không bị chặn ('0'):- Tính
sum_v[r][c]
dựa vàosum_v[r-1][c]
vàdp[r-1][c]
(nếur>0
) theo công thức truy hồi đã nêu. - Tính
sum_h[r][c]
dựa vàosum_h[r][c-1]
vàdp[r][c-1]
(nếuc>0
) theo công thức truy hồi đã nêu. dp[r][c] = (sum_v[r][c] + sum_h[r][c]) % MOD
. (Lưu ý trường hợp(0,0)
đã khởi tạo).
- Tính
- Nếu ô
- Kết quả là
dp[n-1][m-1]
.
Độ phức tạp thời gian: O(nm) do mỗi ô được xử lý một lần. Độ phức tạp không gian: O(nm) cho các mảng DP và phụ.
Comments