Rút Tiền ATM
Đề bài
Bài 2: Rút tiền ATM
Một máy ATM hiện có $n$ $(n \le 20)$ tờ tiền có giá trị $t_1, t_2, \ldots, t_n$. Hãy đưa ra một cách trả với số tiền đúng bằng $S$.
Input
- Dòng 1: 2 số $n$ và $S$.
- Dòng 2: gồm $n$ số $t_1, t_2, \ldots, t_n$.
Output: Nếu có thể trả đúng thì đưa ra cách trả, nếu không ghi $-1$.
10 390
200 10 20 20 50 50 50 50 100 10020 20 50 50 50 100 100Lờ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 😇.
Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:
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 OJTrước khi vào bài, các bạn nhớ giúp mình ngưỡng quen thuộc: với giới hạn 1 giây thì toàn bài chỉ nên lặp khoảng $(3 \div 5) \cdot 10^7$ lần (C/C++) là an toàn, $10^8$ thì hên xui. Đề này tuy không ghi rõ nhưng có vẻ được lấy từ một kỳ thi học sinh giỏi cấp trường/tỉnh, mà các kỳ thi này thường quy định chung 1 giây cho mọi bài, nên các bạn cứ canh theo ngưỡng đó.
Giải thích ví dụ: máy có 10 tờ tiền $200, 10, 20, 20, 50, 50, 50, 50, 100, 100$ và cần trả đúng $S = 390$. Đáp án mẫu chọn 7 tờ $20 + 20 + 50 + 50 + 50 + 100 + 100 = 390$. Nhưng đề chỉ yêu cầu đưa ra một cách trả, nên cách khác như $200 + 20 + 20 + 50 + 100 = 390$ cũng đúng. Code trong bài sẽ in ra cách này – khác output mẫu nhưng vẫn hợp lệ nhé. Nếu không có cách chọn nào ra đúng $S$ thì in $-1$.
Tuy nhiên bài này mình sẽ không hướng dẫn cách quy hoạch động, vì nó đã quá phổ biến rồi. Thay vào đó mình hướng dẫn cách tệ nhất: vét cạn (hay còn gọi là duyệt trâu 😁). Tại sao? Theo quan điểm của mình, nếu cách làm tệ nhất mà còn chưa làm được thì đừng vội tìm hiểu cách tốt hơn, vì sẽ chưa đủ "nội công" để hiểu đâu. Hiểu được cách vét cạn rồi thì các bạn sẽ thấy được chỗ chưa tốt của nó, từ đó tối ưu dần lên và lĩnh ngộ những cách tốt hơn. Thêm nữa, lúc nào mình cũng khuyến khích: ngoài cách tối ưu, hãy viết thêm một bản vét cạn để làm công cụ kiểm tra tính CHÍNH XÁC của cách tối ưu – vì vét cạn (cài đặt đúng) thì không thể sai được.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Vét cạn tất cả tập con bằng hàng đợi – O(2^n · n)
Vét cạn cho bài này nghĩa là ta đi xét hết tất cả các tập con của danh sách tờ tiền. Xét đến khi nào gặp tập con có tổng bằng $S$ thì dừng lại in ra; xét đến tận cùng vẫn không có thì in $-1$. Đơn giản là vậy thôi.
Ví dụ danh sách có 5 phần tử đánh số từ 1 đến 5 thì các tập con (theo số thứ tự) không trùng nhau là:
| Số phần tử | Các tập con |
|---|---|
| 1 | 1 · 2 · 3 · 4 · 5 |
| 2 | 12 · 13 · 14 · 15 · 23 · 24 · 25 · 34 · 35 · 45 |
| 3 | 123 · 124 · 125 · 134 · 135 · 145 · 234 · 235 · 245 · 345 |
| 4 | 1234 · 1235 · 1245 · 1345 · 2345 |
| 5 | 12345 |
Với mỗi tập con, ta cộng các tờ tiền ở các vị trí tương ứng. Như ví dụ của đề, khi xét đến tập con 5 phần tử $\{1, 3, 4, 5, 9\}$, tức các tờ $200, 20, 20, 50, 100$, ta thấy tổng bằng 390 nên dừng luôn và in ra. Ta xét từ tập con ít phần tử nhất trở lên, trong mỗi tập thì vị trí tăng dần; gặp tập đầu tiên thỏa mãn là dừng, tội gì mất công xét tiếp 😄.
Để sinh ra mọi tập con, người ta thường dùng đệ quy quay lui (Backtracking) – cài đặt ngắn gọn (xem Cách 2), nhưng tư tưởng không dễ hiểu với bạn mới bắt đầu. Nên ở đây mình hướng dẫn cách sinh tập con không dùng đệ quy, nhìn thấy rõ quy trình hơn:
- Hàng đợi (Queue) chứa các tập con, mỗi phần tử là một mảng 1 chiều. Ban đầu cho vào hàng đợi các tập 1 phần tử: $\{1\}, \{2\}, \ldots, \{n\}$.
- Lấy lần lượt từng mảng ra khỏi hàng đợi – đó chính là tập con cần xét. Gọi $x$ là phần tử cuối của mảng.
- Nếu $x \lt n$ thì thêm vào hàng đợi $n - x$ mảng mới: giữ nguyên mảng vừa lấy ra, gắn thêm vào cuối một phần tử lần lượt là $x + 1, x + 2, \ldots, n$.
- Lặp lại đến khi hàng đợi rỗng.
Mô phỏng với $n = 5$: ban đầu hàng đợi là $\{1\}, \{2\}, \{3\}, \{4\}, \{5\}$.
- Lấy ra $\{1\}$: phần tử cuối là 1 < 5 nên thêm $\{1,2\}, \{1,3\}, \{1,4\}, \{1,5\}$.
- Lấy ra $\{2\}$: thêm $\{2,3\}, \{2,4\}, \{2,5\}$. Lấy ra $\{3\}$: thêm $\{3,4\}, \{3,5\}$. Lấy ra $\{4\}$: thêm $\{4,5\}$.
- Lấy ra $\{5\}$: phần tử cuối là 5, không thỏa $\lt n$ nên không thêm được gì.
- Lấy ra $\{1,2\}$: thêm $\{1,2,3\}, \{1,2,4\}, \{1,2,5\}$. Lấy ra $\{1,3\}$: thêm $\{1,3,4\}, \{1,3,5\}$. ... và cứ thế.
- Cuối cùng lấy ra $\{1,2,3,4,5\}$, không thêm được gì, hàng đợi rỗng ⇒ dừng.
Toàn bộ những lần lấy ra ở trên chính là toàn bộ các tập con, đúng thứ tự như bảng phía trên: số phần tử tăng dần từ 1 đến $n$, trong từng nhóm thì vị trí tăng dần. Các bạn tự nhập dữ liệu vào khung dưới đây rồi bấm Tiếp để xem từng lần lấy ra / thêm vào nhé (ô cam: các tờ tiền của tập đang xét; chip xanh: các mảng vừa được thêm vào hàng đợi):
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
int main(){
queue<vector<int>> q;
int n;
long long s;
cin >> n >> s;
long long a[n + 1];
for(int i = 1; i <= n; ++i){
cin >> a[i];
vector<int> b;
b.push_back(i);
q.push(b);
}
while(!q.empty()){
vector<int> temp = q.front();
q.pop();
long long sum = 0;
for(int i = 0; i < (int)temp.size(); ++i){
sum += a[temp[i]];
}
if(sum == s){
for(int i = 0; i < (int)temp.size(); ++i){
cout << a[temp[i]] << " ";
}
return 0;
}
int x = temp[(int)temp.size() - 1];
while(x < n){
temp.push_back(++x);
q.push(temp);
temp.pop_back();
}
}
cout << -1;
return 0;
}
import sys
from collections import deque
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
a = [0] + [int(x) for x in data[2:2 + n]]
q = deque()
for i in range(1, n + 1):
q.append([i])
found = False
while q:
temp = q.popleft()
total = 0
for p in temp:
total += a[p]
if total == s:
print(*[a[p] for p in temp])
found = True
break
x = temp[-1]
while x < n:
x += 1
q.append(temp + [x])
if not found:
print(-1)
Một số giải thích cho code ở trên:
- Vòng lặp
forđầu tiên trong hàmmainđể đọc mảng từ input, đồng thời mình tận dụng nó để đưa luôn vào hàng đợi $n$ mảng 1 chiều $\{1\}, \{2\}, \ldots, \{n\}$. - Mình dùng
queuecó sẵn trong STL C++ cho nhanh gọn (đi thi thực tế cũng không ai tự cài đặt lại, tự cài lỡ xui sai thì toang 😅), và mảng 1 chiều thì dùngvector– mảng động với nhiều hàm hỗ trợ sẵn rất tiện. - Mỗi
templấy ra chứa các vị trí của tập con, ví dụtemp = {1, 2, 3}nghĩa là đang xét các tờ ở vị trí 1, 2, 3 (tính từ 1, nên mảngakhai báo $n + 1$ phần tử và bỏ qua index 0). Tờ tiền tương ứng làa[temp[i]], cộng hết vàosum, nếu bằngsthì in cáca[temp[i]]ra. - Bước sinh mảng mới:
xlà phần tử cuối củatemp; trong khix < nthìtemp.push_back(++x)(tăngxtrước rồi mới đưa vào cuối mảng), đẩytempvào hàng đợi, rồitemp.pop_back()bỏ phần tử vừa thêm để lặp tiếp vớixkế tiếp.
s, a[], sum là long long cho chắc ăn (bản gốc của mình dùng int) – tổng 20 tờ tiền lớn vẫn không lo tràn số.Khung chạy tay (để dễ theo dõi, giới hạn $n \le 6$):
Đánh giá độ phức tạp của cách làm này:
- Thời gian: trường hợp xấu nhất (không có cách trả) phải xét toàn bộ $2^n - 1$ tập con. Với $n = 20$ là $1\,048\,575$ – cứ coi như hơn $10^6$ tập, mỗi tập tối đa 20 phần tử ⇒ khoảng $20 \cdot 10^6 = 2 \cdot 10^7$ phép cộng, vẫn thỏa ngưỡng 1 giây, không sợ TLE. Phần đọc $n$ tờ tiền thì chẳng đáng bao nhiêu.
- Không gian: hàng đợi lúc đông nhất chứa khoảng hai "lớp" tập con liền nhau, cỡ $\binom{20}{10} + \binom{20}{11} \approx 3.5 \cdot 10^5$ mảng, mỗi mảng tối đa 20 số – vẫn nằm trong vài chục MB.
Cách 2Vét cạn bằng đệ quy quay lui – O(2^n)
Như mình đã nói ở trên, đây là cách thường dùng để duyệt tập con và cài đặt rất ngắn gọn (từ khoá: "Liệt kê các tập con của dãy N phần tử bằng đệ quy quay lui"). Hiểu được cách hàng đợi rồi thì xem đoạn code đệ quy cũng sẽ dễ hiểu thôi.
Ý tưởng: lần lượt quyết định cho từng tờ tiền $i = 1, 2, \ldots, n$: chọn tờ đó (thêm vào danh sách chon, cộng vào tổng) hoặc không chọn. Hàm thu(i, sum) trả về true nếu từ trạng thái hiện tại có thể đi tiếp ra một cách trả đúng $S$:
- Nếu tổng hiện tại đã bằng $S$ (và đã chọn ít nhất 1 tờ) ⇒ tìm được rồi, trả về
truengay – danh sáchchonchính là đáp án. - Nếu đã quyết định hết $n$ tờ ($i \gt n$) mà chưa đủ ⇒ nhánh này thất bại.
- Thử chọn tờ $i$:
chon.push_back(i)rồi gọithu(i + 1, sum + a[i]); nếu thất bại thì quay lui:chon.pop_back()và thử nhánh không chọnthu(i + 1, sum).
Cách này mỗi tập con chỉ "đi qua" một lần và tổng được cộng dần theo đường đi, không phải cộng lại từ đầu như Cách 1, nên nhanh hơn hẳn. Đáp án tìm được có thể khác Cách 1 (với ví dụ của đề code in ra $200\ 20\ 20\ 50\ 50\ 50$), nhưng đề chấp nhận cách trả bất kỳ nên vẫn đúng.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
int n;
long long s, a[25];
vector<int> chon;
bool thu(int i, long long sum){
if(sum == s && !chon.empty()) return true;
if(i > n) return false;
chon.push_back(i);
if(thu(i + 1, sum + a[i])) return true;
chon.pop_back();
return thu(i + 1, sum);
}
int main(){
cin >> n >> s;
for(int i = 1; i <= n; ++i) cin >> a[i];
if(thu(1, 0)){
for(int i = 0; i < (int)chon.size(); ++i){
cout << a[chon[i]] << " ";
}
}
else cout << -1;
return 0;
}
import sys
sys.setrecursionlimit(10000)
data = sys.stdin.read().split()
n, s = int(data[0]), int(data[1])
a = [0] + [int(x) for x in data[2:2 + n]]
chon = []
def thu(i, total):
if total == s and chon:
return True
if i > n:
return False
chon.append(i)
if thu(i + 1, total + a[i]):
return True
chon.pop()
return thu(i + 1, total)
if thu(1, 0):
print(*[a[p] for p in chon])
else:
print(-1)
Đánh giá độ phức tạp của cách làm này:
- Thời gian: cây đệ quy có tối đa $2^{n+1}$ lời gọi, mỗi lời gọi $O(1)$ ⇒ $O(2^n)$; với $n = 20$ chỉ khoảng 2 triệu lời gọi, C++ chạy chừng 0.02 giây.
- Không gian: độ sâu đệ quy và mảng
chontối đa $n$ ⇒ $O(n)$.
- 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