Đổi Tiền (DPRCCOI)
Đề bài
DPRCCOI – Đổi tiền
Ở AnLuuLand họ có một hệ thống tiền tệ rất kỳ lạ. Mỗi đồng tiền vàng của AnLuuLand có một số nguyên được viết trên đó. Một đồng $n$ có thể được trao đổi trong ngân hàng thành ba đồng tiền: $n \text{ div } 2$, $n \text{ div } 3$ và $n \text{ div } 4$. Với $\text{div}$ là phép chia lấy nguyên.
Bạn cũng có thể đổi tiền AnLuuLand thành đô la Mỹ. Tỷ giá hối đoái là $1:1$. Nhưng bạn không thể mua tiền AnLuuLand.
Bạn có đồng vàng ghi số $n$. Số đô la Mỹ tối đa bạn có thể đổi được là bao nhiêu?
Dữ liệu vào: sẽ chứa một số trường hợp thử nghiệm (không quá 10). Mỗi testcase là một dòng duy nhất có số $n$. Đó là số được viết trên đồng tiền của bạn.
Kết quả: Đối với mỗi trường hợp thử nghiệm xuất ra một dòng duy nhất, chứa số đô la Mỹ tối đa bạn có thể kiếm được.
Ràng buộc: $0 \le n \le 10^9$.
0
1
2
3
4
5
12
32
12345
7985
10000000000
1
2
3
4
5
13
35
21629
13137
4243218150Lờ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à một bài rất hay để thấy được sức mạnh của đệ quy có nhớ: chỉ thêm đúng 1 cái map vào code vét cạn là từ TLE thành AC luôn.
Trước tiên mình sẽ tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu như sau. Bạn đang có 1 đồng tiền vàng giá trị $n$. Bạn có thể:
- Đổi 1 đồng $n$ thành 3 đồng tiền vàng có giá trị lần lượt là
n / 2,n / 3vàn / 4(trong C++ chính là phép chia nguyên đó anh em, chia làm tròn xuống nha; ví dụ10 / 3 = 3). Các đồng mới lại có thể tiếp tục đem đi đổi như vậy. - Hoặc đổi đồng tiền vàng sang đô la Mỹ với tỷ giá $1:1$, tức đồng tiền vàng giá trị $8$ đổi được $8$ đô la; tổng quát đồng giá trị $n$ đổi được $n$ đô la.
Đầu vào có tối đa 10 đồng tiền vàng (không cho trước là bao nhiêu đồng, cứ đọc đến hết), mỗi đồng có giá trị $0 \le n \le 10^9$; với mỗi đồng hãy in ra số đô la Mỹ tối đa có thể kiếm được.
Mình giải thích vài giá trị trong ví dụ để bạn nào chưa hiểu đề sẽ hiểu rõ:
- $n = 0$: nếu đổi thành 3 đồng con thì được $0/2 = 0$, $0/3 = 0$, $0/4 = 0$. Vậy có đổi hay giữ nguyên thì cũng chỉ được 0 đô la mà thôi.
- $n = 1$: đổi thành 3 đồng con thì được 3 đồng giá trị $0$. Vậy ta không đổi mà đem luôn đồng ban đầu đi đổi đô, được 1 đô la.
- $n = 12$: đổi thành 3 đồng con là $6$, $4$ và $3$. Đem 3 đồng này đổi sang đô la thì được $6 + 4 + 3 = $ 13 đô la, lời hơn giữ nguyên ($12$).
- $n = 32$: đổi thành 3 đồng con là $16$, $10$ và $8$. Ta tiếp tục đổi đồng $16$ ra thì được thêm 3 đồng con nữa là $8$, $5$ và $4$. Tổng cộng giờ ta có các đồng $\{8, 5, 4, 10, 8\}$, đổi hết sang đô la thì được $8 + 5 + 4 + 10 + 8 = $ 35 đô la.
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 OJLộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Đệ quy vét cạn – thử mọi cách đổi
Cách làm đơn giản nhất của bài này chính là ta sẽ vét cạn nha, tức là ta sẽ thử mọi trường hợp có thể. Với 1 đồng xu có giá trị $n$ thì ta có đúng 2 lựa chọn:
- Đổi thẳng sang $n$ đô la.
- Hoặc đổi sang 3 đồng xu $n/2$, $n/3$, $n/4$. Vậy khi nào thì ta kiếm được nhiều đô la nhất từ 3 đồng xu đó? Dĩ nhiên là (số đô la tối đa kiếm được từ đồng $n/2$) + (số đô la tối đa kiếm được từ đồng $n/3$) + (số đô la tối đa kiếm được từ đồng $n/4$) đúng không? Vì 3 đồng xu này đổi độc lập với nhau, muốn tổng cả 3 được tối đa thì mỗi đồng cũng phải đổi ra được nhiều đô la nhất có thể.
Vậy số đô la tối đa đổi được từ đồng xu $n$ sẽ là $\max(n,\ \text{tối đa từ } n/2 + \text{tối đa từ } n/3 + \text{tối đa từ } n/4)$. Ta xây dựng hàm đệ quy như sau:
- Hàm:
Try(n)trả về số đô la tối đa ta có thể đổi được từ đồng xu có giá trị $n$. - Điều kiện dừng: khi
n == 0thì số đô la tối đa đổi được là $0$. Lý do ta chọn điều kiện dừng là $n = 0$ mà không phải số khác là vì một đồng xu khi tách ra sẽ bị chia cho 2, 3 hoặc 4. Cứ liên tục tách (tức là liên tục chia) thì cuối cùng chắc chắn chỉ còn đồng xu $0$. Vậy để tránh đệ quy vô hạn thì gặp đồng $0$ tareturn 0luôn, vì đồng $0$ đổi sang được $0$ đô la. - Xử lý chạy: như phân tích ở trên, ta
return max(n, Try(n / 2) + Try(n / 3) + Try(n / 4));
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll Try(ll n){
if(n == 0){
return 0;
}
return max(n, Try(n / 2) + Try(n / 3) + Try(n / 4));
}
int main(){
// freopen("dprccoi.inp", "r", stdin);
// freopen("dprccoi.out", "w", stdout);
ios_base::sync_with_stdio(0);
cin.tie(0);
ll n;
while(cin >> n){
cout << Try(n) << endl;
}
return 0;
}
import sys
def Try(n):
if n == 0:
return 0
return max(n, Try(n // 2) + Try(n // 3) + Try(n // 4))
for tok in sys.stdin.read().split():
n = int(tok)
print(Try(n))
Giải thích 1 số chỗ trong code ở trên:
while(cin >> n): vì đầu vào không cho biết trước có bao nhiêu đồng xu nên mình dùng cú pháp này để đọc tới khi hết dữ liệu thì thôi. Khi hết dữ liệu,cin >> nthất bại và vòngwhiledừng lại. Bản Python thì đọc hết bằngsys.stdin.read().split()rồi duyệt từng số.- Code gốc của mình có 2 dòng
freopen("dprccoi.inp", ...)/freopen("dprccoi.out", ...)để đọc ghi file. Khi nộp trên CLUE OJ (đọc/ghi chuẩn) thì các bạn để chúng ở dạng chú thích như trên là được; nếu đề thi yêu cầu đọc ghi file thì bỏ dấu//đi nhé.
Em bấm Tiếp trong khung chạy tay bên dưới để xem cây đệ quy được "bung" ra như thế nào. Để ý ô "Mỗi Try(x) đã bị gọi bao nhiêu lần": chỉ với $n = 32$ thôi mà nhiều hàm đã bị gọi đi gọi lại rồi.
Đánh giá độ phức tạp của cách làm này:
- Không gian: chỉ tốn bộ nhớ cho ngăn xếp đệ quy, độ sâu tối đa khoảng $\log_2 n \approx 30$ tầng, tức $O(\log n)$.
- Thời gian: mỗi lời gọi lại tách ra 3 lời gọi con, và cùng một giá trị bị tính đi tính lại rất nhiều lần. Mình đếm thử số lời gọi hàm
Try: với $n = 32$ là $130$ lần, $n = 10^6$ là khoảng $9.6 \cdot 10^6$ lần, còn $n = 10^9$ là khoảng $1.5 \cdot 10^{10}$ lần!
Cách 2Đệ quy có nhớ (quy hoạch động top-down) với map
Giờ ta sẽ đến với cách làm có thể giúp ta AC (accepted – được chấp nhận) bài này nha.
Ta sẽ nhận ra rằng bài này có các bài toán con trùng lặp. Vậy dựa vô đâu mà ta biết sẽ có bài toán con trùng lặp? Giờ ta lấy tạm $n = 1600$ đi: ta tách ra làm 3 đồng xu là $800$, $533$ và $400$. Bây giờ ta tách đồng xu $800$ ra nữa, ta sẽ được $400$, $266$, $200$. Anh em thấy trùng rồi chứ, ta đã tách được 2 đồng xu $400$ kìa.
Tổng quát hơn: đồng $n$ tách thành $n/2$, $n/3$, $n/4$. Tách đồng $n/2$ ra ta được $n/4$, $n/6$, $n/8$ – đồng $n/4$ bị trùng. Tách đồng $n/3$ thử, ta được $n/6$, $n/9$, $n/12$ – lại lần nữa đồng $n/6$ bị trùng. Thật ra chỉ cần thấy trùng 1 lần là ta đã kết luận được bài có bài toán con trùng lặp rồi.
Try(400) rồi thì ta lưu kết quả lại, lần sau gặp lại chỉ việc trả về luôn, không tính lại nữa. Cụ thể: sau điều kiện dừng, ta kiểm tra xem đã từng tính Try(n) chưa; nếu rồi thì trả về luôn, còn chưa thì tính, lưu lại rồi mới trả về. Phương pháp này được gọi là quy hoạch động top-down hay còn gọi là đệ quy có nhớ đó anh em.Minh hoạ: cây đệ quy khi không nhớ và khi có nhớ
Em nhập $n$, chọn chế độ rồi bấm Tiếp để xem từng lời gọi. Ở chế độ không nhớ, các dòng có nhãn đỏ "tính lại" là những Try(x) đã từng tính xong trước đó mà vẫn phải tính lại từ đầu. Ở chế độ có nhớ, gặp lại thì chỉ "lấy từ map" (nhãn tím) là xong, cây gọn hơn hẳn. Thử $n = 32$ ở cả 2 chế độ để so sánh nha:
Với $n = 32$: không nhớ phải gọi hàm $130$ lần, có nhớ thì chỉ còn $28$ lần. Còn với $n = 10^9$, bấm ⏭ sẽ thấy map chỉ lưu đúng $241$ giá trị khác nhau, trong khi cách 1 phải gọi hàm khoảng $1.5 \cdot 10^{10}$ lần!
Lưu kết quả vào đâu? Mảng, map hay unordered_map?
Vì $n \le 10^9$ nên ta không thể dùng mảng hay vector để lưu kết quả Try(n) được: một mảng $10^9$ phần tử sẽ rất tốn bộ nhớ. Nhưng như minh hoạ ở trên, bài này lại không có quá nhiều bài toán con khác nhau, vì ở mỗi lần gọi đệ quy thì $n$ bị chia 2, chia 3, chia 4 nên giảm xuống rất nhanh.
unordered_map cũng tốt hơn map. Dùng map tuy độ phức tạp cao hơn nhưng ta cảm thấy yên tâm hơn 🤣. Kinh nghiệm là nếu các bạn code dùng map mà bị TLE thì cứ thử thay bằng unordered_map rồi nộp lại xem: nếu được thêm test nào nữa thì tốt, còn nếu thấy 1 số test đang từ AC chuyển sang TLE thì quay xe lại map thôi 🤪. Bài này dùng cả 2 đều AC nha: chỉ việc sửa dòng khai báo thành unordered_map<ll, ll> mp; là xong.Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
map<ll, ll> mp;
ll Try(ll n){
if(n == 0){
return 0;
}
if(mp[n] != 0){
return mp[n];
}
return mp[n] = max(n, Try(n / 2) + Try(n / 3) + Try(n / 4));
}
int main(){
// freopen("dprccoi.inp", "r", stdin);
// freopen("dprccoi.out", "w", stdout);
ios_base::sync_with_stdio(0);
cin.tie(0);
ll n;
while(cin >> n){
cout << Try(n) << endl;
}
return 0;
}
import sys
mp = {}
def Try(n):
if n == 0:
return 0
if mp.get(n, 0) != 0:
return mp[n]
mp[n] = max(n, Try(n // 2) + Try(n // 3) + Try(n // 4))
return mp[n]
for tok in sys.stdin.read().split():
n = int(tok)
print(Try(n))
Giải thích 1 số chỗ trong code ở trên:
- Tại sao
map<ll, ll> mplà biến toàn cục mà không tạo một map trống mới mỗi lần lặpwhile(cin >> n)rồi truyền tham chiếu vào hàmTry()? Bởi vì nếu trước đó ta đã tính số tiền tối đa của đồng $100$ chẳng hạn, thì đến lúc gặp đồng $200$, rất nhiều bài toán con đã được tính sẵn từ đồng trước rồi. Để map toàn cục thì các đồng sau được "hưởng ké" kết quả của các đồng trước. Chẳng hạn đầu vào là 10 đồng $10^9$ thì ta chỉ phải tính đúng 1 lần thôi. if(mp[n] != 0): mặc định các giá trị trong map chưa được gán sẽ bằng $0$ (khi truy cậpmp[n]mà chưa có thì map tự tạomp[n] = 0). Với $n \ge 1$ thìTry(n) ≥ n ≥ 1, không bao giờ bằng 0. Nên nếump[n] != 0thì chứng tỏ ta đã tínhTry(n)và lưu vào rồi; còn bằng 0 thì ta đi tính để gán vào thôi.return mp[n] = max(...);câu lệnh này thực chất là gánmp[n] = max(n, Try(n / 2) + Try(n / 3) + Try(n / 4))rồi mớireturn mp[n]nha.
Trong khung chạy tay dưới đây, phần map mp sẽ hiện từng phần tử được thêm vào (ô vàng là phần tử vừa được map tự tạo với giá trị 0 hoặc vừa được gán). Bấm thử test "Hai đồng 32 và 64" để thấy đồng thứ hai được dùng lại kết quả của đồng thứ nhất:
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(K)$ với $K$ là số giá trị khác nhau xuất hiện trong cây đệ quy. Mình đếm thử: với $n = 10^9$ thì $K$ chỉ là $241$ (chưa tính đồng $0$). Thực ra mọi giá trị xuất hiện đều có dạng $\left\lfloor \dfrac{n}{2^a 3^b} \right\rfloor$ (vì chia 4 cũng là chia 2 hai lần), nên $K$ chỉ cỡ $O(\log^2 n)$.
- Thời gian: mỗi giá trị chỉ được tính đúng 1 lần, mỗi lần tốn vài thao tác trên map $O(\log K)$. Tổng cộng khoảng $O(K \log K)$ cho mỗi đồng, chạy tức thì.
- 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