Cái Túi 2 (BTKCATU2)
Đề bài
BTKCATU2 – CÁI TÚI 2
Trong siêu thị tại gian hàng Black Friday có $n$ đồ vật, đồ vật thứ $i$ có trọng lượng là $W[i]$ và giá trị $V[i]$. Bạn được một phiếu mua hàng đặc biệt khi vào mua hàng tại gian hàng Black Friday ở siêu thị này. Khi sử dụng phiếu mua hàng này bạn được phép chọn một số đồ vật bất kỳ trên gian hàng miễn sao trọng lượng tối đa là $M$. Hỏi bạn có thể lấy đi những mặt hàng nào để giá trị bạn nhận được là tối đa.
Bạn hãy lập trình giải quyết bài toán trên với dữ liệu cho là danh sách $n$ mặt hàng tại gian hàng mà bạn có thể chọn. Bạn cần liệt kê được danh sách các mặt hàng mà mình lấy được.
Dữ liệu nhập:
- Dòng đầu tiên là hai số nguyên dương $n$ và $M$ $(1 \le n \le 24;\ 1 \le M \le 10^{11})$.
- Dòng thứ $i$ trong $n$ dòng tiếp theo chứa hai số nguyên $w[i]$ và $v[i]$ là trọng lượng và giá trị của đồ vật thứ $i$ $(1 \le w[i], v[i] \le 10^9)$.
Kết quả:
- Dòng 1 in một số nguyên $x$ là số đồ vật bạn lấy được.
- Dòng 2 bạn in số thứ tự của các vật phẩm được lấy đi (số thứ tự của các đồ vật theo thứ tự trong đầu vào).
Nếu có nhiều đáp án tối ưu, bạn có thể in đáp án tối ưu bất kỳ.
Giới hạn thời gian: 1 giây · Giới hạn bộ nhớ: 128 MB.
5 20
7 1
5 8
1 1
7 8
9 74
1 2 3 4Lời mở đầu
Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇.
Đây là phần 2 của bài cái túi nha. Phần 1 (bài "Cái túi 1 ver 2" trên blog – cùng đề nhưng chỉ cần in ra tổng giá trị lớn nhất) mình đã giải thích kỹ hàm đệ quy quay lui rồi, nên ở đây những chỗ code giống phần 1 mình sẽ không giải thích lại, chỉ tập trung vào phần mới: làm sao biết được mình đã lấy những đồ vật nào.
Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJVới các hệ thống web chấm bài giới hạn thời gian chạy tối đa chỉ 1 giây thì các bạn rất dễ rơi vào lỗi TLE (Time Limit Exceeded – chạy quá thời gian cho phép). Như mình đã nói rất nhiều lần: muốn an toàn thì số phép toán trên toàn bài chỉ nên ở ngưỡng $(3 \text{ đến } 5) \cdot 10^7$; một số hệ thống server mạnh hơn có thể nâng lên $10^8$ nhưng cũng hên xui nhé. Thực ra ta không cần đếm chi tiết từng phép toán, cứ canh theo số lần vòng lặp (hay số lần gọi đệ quy) chạy là được. Ngưỡng này là cho C/C++; Python, Java, C# chạy chậm hơn nên ngưỡng phải thấp hơn. Đề cho 2 giây thì nhân ngưỡng lên 2, 3 giây thì nhân 3, 100 ms thì chia 10, cứ thế thôi.
Tóm tắt đề cho bạn nào chưa hiểu: có $n \le 24$ đồ vật, đồ vật $i$ nặng $w[i]$ và có giá trị $v[i]$; túi chứa được tối đa $M$ (tới $10^{11}$). Chọn các đồ vật sao cho tổng trọng lượng $\le M$ và tổng giá trị lớn nhất, rồi in ra số lượng đồ vật đã chọn và số thứ tự của chúng.
Ví dụ: $n = 5$, $M = 20$, các đồ vật (trọng lượng, giá trị) là $(7, 1), (5, 8), (1, 1), (7, 8), (9, 7)$. Cách chọn tối ưu là lấy đồ vật 1, 2, 3 và 4: khối lượng $7 + 5 + 1 + 7 = 20 \le 20$, giá trị $1 + 8 + 1 + 8 = 18$ là lớn nhất. Vậy in ra $4$ (số đồ lấy) và dòng sau là $1\ 2\ 3\ 4$.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Quay lui, dùng vector lưu các đồ vật đã chọn – O(2ⁿ)
Bước 1: viết lại hàm đệ quy thành hàm không trả về
Ở phần 1 mình dùng hàm đệ quy có trả về để tính giá trị lớn nhất. Ở bài này, để tiện in ra các đồ vật đã lấy, mình viết lại thành hàm không trả về, chỉ cần thay đổi một chút:
- Kết quả lưu vào một biến toàn cục
res, lúc đầures = 0. - Hàm
Try(n, M, val)thêm tham sốvallà tổng giá trị các món đồ đã lấy: đang xét các đồ vật $1..n$, túi còn chứa thêm được $M$. - Điều kiện dừng:
n == 0(không còn gì để lấy) hoặcM == 0(túi không chứa thêm được) thì cập nhậtresbằng cách so vớival, rồireturnđể không gọi đệ quy nữa. - Trong mọi trường hợp ta đều có thể không lấy món thứ $n$, nên gọi luôn
Try(n - 1, M, val): túi vẫn còn $M$, giá trị không đổi. - Nếu
w[n] <= Mthì có thể lấy món thứ $n$: gọiTry(n - 1, M - w[n], val + v[n]).
Lúc đầu chưa lấy món gì nên gọi Try(n, M, 0). Đến đây là AC được phần 1 rồi.
Bước 2: thêm một vector lưu danh sách đồ vật đang chọn
Để làm phần 2, ta dùng thêm một tham số nữa trong hàm Try() để lưu danh sách các đồ vật đã chọn – có thể dùng vector, stack hay queue, nhưng vector là nhanh nhất nên mình dùng vector. Thêm một biến toàn cục vector<int> idRes có tác dụng giống res, chỉ khác là nó lưu vị trí các đồ vật của cách chọn tốt nhất.
- Tham trị
Try(..., vector<int> id): mỗi lần gọi, chương trình ngầm tạo ra một vectoridmới và copy lại toàn bộ vector bên ngoài. Tiện ở chỗ trong hàm thay đổi thì bên ngoài không sao cả (2 biến khác nhau), nhưng copy liên tục như vậy rất tốn thời gian ⇒ dễ TLE. - Tham chiếu
Try(..., vector<int> &id):idtrong hàm chính làidbên ngoài, không tốn thời gian copy. Bù lại phải cẩn thận vì trong thay đổi thì ngoài cũng thay đổi, nhất là khi đang đệ quy.
push_back() trước khi gọi đệ quy thì phải có pop_back() ngay sau khi gọi xong, để trả vector về đúng trạng thái cũ cho các nhánh sau.Vậy hàm mới là Try(ll n, ll M, ll val, vector<int> &id):
- Ở điều kiện dừng: nếu
val > res(tìm được cách tốt hơn) thì gánres = valvàidRes = id(chỉ copy khi có cách tốt hơn), rồireturnnhư thường. - Ở nhánh có lấy món thứ $n$:
id.push_back(n), gọi đệ quy, rồiid.pop_back(). - Vì ta đi từ đồ vật thứ $n$ ngược về đồ vật đầu tiên nên danh sách số thứ tự bị lưu ngược; lúc in thì in từ cuối vector về đầu là được thứ tự tăng dần.
Minh hoạ cây quay lui
Em nhập $M$ và các đồ vật (mỗi đồ vật là một cặp w v, tối đa 5 đồ vật) rồi bấm Tiếp. Mỗi nút trên cây là một lần gọi Try: số trong nút là val, dòng nhỏ bên dưới là sức chứa còn lại M. Nhánh trái = không lấy món $n$, nhánh phải (xanh) = lấy món $n$; dấu ✗ đỏ là nhánh bị chặn vì w[n] > M. Để ý vector id được push_back rồi pop_back như thế nào nhé.
Sau đây là source code của mình cho ý tưởng trên:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll w[30], v[30];
ll res = 0;
vector<int> idRes;
void Try(ll n, ll M, ll val, vector<int> &id) {
if(n == 0 || M == 0) {
if(val > res) {
res = val;
idRes = id;
}
return;
}
Try(n - 1, M, val, id);
if(w[n] <= M) {
id.push_back(n);
Try(n - 1, M - w[n], val + v[n], id);
id.pop_back();
}
}
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
ll n, M;
cin >> n >> M;
for(int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
vector<int> id;
Try(n, M, 0, id);
cout << (int)idRes.size() << endl;
for(int i = (int)idRes.size() - 1; i >= 0; --i) {
cout << idRes[i] << " ";
}
return 0;
}
import sys
sys.setrecursionlimit(10000)
n, M = map(int, input().split())
w = [0] * (n + 1)
v = [0] * (n + 1)
for i in range(1, n + 1):
w[i], v[i] = map(int, input().split())
res = 0
idRes = []
def Try(n, M, val, id):
global res, idRes
if n == 0 or M == 0:
if val > res:
res = val
idRes = id[:] # sao chép danh sách hiện tại
return
Try(n - 1, M, val, id)
if w[n] <= M:
id.append(n)
Try(n - 1, M - w[n], val + v[n], id)
id.pop()
Try(n, M, 0, [])
print(len(idRes))
print(*idRes[::-1])
Giải thích một số chỗ trong code:
vector<int> id;trongmain: lúc đầu tạo vectoridrỗng vì chưa chọn phần tử nào, từ đó mới gọiTry(n, M, 0, id).- Vòng
forcuối inidRestừ cuối về đầu, vì các đồ vật được thêm vào theo thứ tự $n, n - 1, \dots, 1$. - Bản Python dùng list (giống vector) với
append/pop; khi cập nhật thì phải sao chépid[:], nếu gánidRes = idthì 2 biến trỏ chung một list, các lầnpopsau sẽ làm hỏng kết quả.
Dành cho bạn nào muốn biết hàm Try() mà vector để tham trị thì như thế này nha – viết dễ hơn (chỉ việc thêm vào, không cần xoá ra vì mỗi lần gọi đệ quy vector đã được copy sang biến khác) nhưng chạy sẽ bị TLE đó:
void Try(ll n, ll M, ll val, vector<int> id) {
if(n == 0 || M == 0) {
if(val > res) {
res = val;
idRes = id;
}
return;
}
Try(n - 1, M, val, id);
if(w[n] <= M) {
id.push_back(n);
Try(n - 1, M - w[n], val + v[n], id);
}
}
Đánh giá độ phức tạp của cách làm này:
- Không gian: độ sâu đệ quy tối đa $n$, vector
idvàidRestối đa $n$ phần tử ⇒ $O(n)$. - Thời gian: mỗi đồ vật có 2 lựa chọn lấy / không lấy nên tệ nhất có $2^n$ lá, tổng số lần gọi khoảng $2^{n+1}$. Với $n = 24$ là khoảng $3.3 \cdot 10^7$ lần gọi, vừa đúng ngưỡng an toàn. Mình đo bản C++ với test nặng nhất ($n = 24$, túi chứa được hết) chạy khoảng 0.1 giây.
Cách 2Đọc thêm: QHĐ bottom-up + truy vết (khi n, M ≤ 1000)
Giờ mình giả sử đề đổi thành $1 \le n, M, w[i] \le 1000$. Với $n = 1000$ thì cách quay lui ở trên chắc chắn TLE ($2^{1000}$!), còn chậm hơn cả phần 1 vì tốn thêm thời gian cho vector lưu vị trí. Lúc này phải dùng quy hoạch động.
Cả QHĐ top-down hay bottom-up đều tạo ra mảng 2 chiều f[n][M] và độ phức tạp trên lý thuyết bằng nhau. Top-down tốn thêm chút bộ nhớ và thời gian cho đệ quy, nhưng nhiều trường hợp không phải lấp đầy toàn bộ mảng (không tin thì anh em lấy code top-down của phần 1, in mảng f với test mẫu ra xem). Có điều chính vì không lấp đầy nên truy vết bằng top-down khá rắc rối, vì vậy ở đây ta dùng bottom-up: lấp đầy cả bảng rồi nhìn vào bảng tìm ra quy luật truy vết. Mình khuyên các bạn nên in bảng f ra sau khi chạy xong, nhìn vào đó là thấy quy luật ngay.
f[i][j] = giá trị lớn nhất lấy được khi xét $i$ đồ vật đầu tiên và túi chứa được tối đa $j$.Nếu $w[i] \gt j$:
f[i][j] = f[i-1][j]; ngược lại: f[i][j] = max(v[i] + f[i-1][j-w[i]], f[i-1][j]).Truy vết: bắt đầu từ f[n][M], tức i = n, j = M:
- Nếu
f[i][j] == f[i-1][j]: với $i - 1$ đồ vật đầu ta cũng đã đạt được giá trị lớn nhất đó, nên không cần lấy đồ vật $i$ (có thể vẫn có cách khác có lấy $i$ cũng tối ưu, nhưng không lấy cũng được). Xét tiếpf[i-1][j]: túi vẫn chứa được $j$. - Ngược lại
f[i][j] != f[i-1][j], mà theo công thức thìf[i][j] ≥ f[i-1][j], nên ở đây chính là lớn hơn. Ví dụ với $i - 1$ món đầu chỉ lấy được tối đa 8, còn với $i$ món lại lấy được tới 10 – thì chắc chắn ta phải lấy món thứ $i$. Thêm $i$ vào danh sách rồi xét tiếpf[i-1][j - w[i]]: túi chỉ còn chứa thêm được $j - w[i]$. - Lặp đến khi $i = 0$ (hết đồ vật) hoặc $j = 0$ (túi hết chỗ).
Source code cho ý tưởng trên cho anh em tham khảo nha:
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll w[1005], v[1005];
ll f[1005][1005];
void solve(ll n, ll M) {
for(int i = 1; i <= n; ++i) {
for(int j = 1; j <= M; ++j) {
if(w[i] > j) {
f[i][j] = f[i - 1][j];
}
else {
f[i][j] = max(v[i] + f[i - 1][j - w[i]], f[i - 1][j]);
}
}
}
}
int main() {
ios_base::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
ll n, M;
cin >> n >> M;
for(int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
solve(n, M);
int i = n, j = M;
vector<int> idRes;
while(i && j) {
if(f[i][j] == f[i - 1][j]) {
i--;
}
else {
idRes.push_back(i);
j -= w[i];
i--;
}
}
cout << (int)idRes.size() << endl;
for(int i = (int)idRes.size() - 1; i >= 0; --i) {
cout << idRes[i] << " ";
}
return 0;
}
n, M = map(int, input().split())
w = [0] * (n + 1)
v = [0] * (n + 1)
for i in range(1, n + 1):
w[i], v[i] = map(int, input().split())
# f[i][j] = giá trị lớn nhất khi xét i đồ vật đầu, túi chứa tối đa j
f = [[0] * (M + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, M + 1):
if w[i] > j:
f[i][j] = f[i - 1][j]
else:
f[i][j] = max(v[i] + f[i - 1][j - w[i]], f[i - 1][j])
i, j = n, M
idRes = []
while i and j:
if f[i][j] == f[i - 1][j]:
i -= 1
else:
idRes.append(i)
j -= w[i]
i -= 1
print(len(idRes))
print(*idRes[::-1])
Giải thích một số chỗ trong cách làm trên:
while(i && j)chính làwhile(i != 0 && j != 0); viết không thì trình biên dịch tự hiểu ngầm là!= 0.- Vì ta vẫn đi ngược từ dưới lên nên lúc in ra vẫn phải in ngược.
j -= w[i] trước rồi mới i--. Code mình đăng hồi đó lỡ viết i--; trước rồi j -= w[i];, lúc này w[i] đã là trọng lượng của đồ vật $i - 1$ nên truy vết bị lệch – mình thử 500 bộ test ngẫu nhiên thì có tới 257 bộ in sai. Trong bài mình đã sửa lại thứ tự cho đúng nhé.Đánh giá độ phức tạp của cách làm này:
- Không gian: bảng
fcó $(n + 1)(M + 1)$ ô ⇒ $O(n \cdot M)$. - Thời gian: lấp bảng $O(n \cdot M)$, truy vết chỉ $O(n)$ vì mỗi bước $i$ giảm 1 ⇒ $O(n \cdot M)$. Với $n = M = 1000$ chỉ khoảng $10^6$ phép tính.
f[n][M] được, anh em chỉ quay lui như cách 1 thôi nha. Cách 2 là để các bạn biết thêm kỹ thuật truy vết QHĐ, rất hay dùng trong các bài cái túi có $M$ nhỏ.- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét