Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Giảm Thiểu Đồng Xu (CSES 1634)

Giảm Thiểu Đồng Xu (CSES 1634)
Nguồn: CSES 1634 – Minimizing Coins Độ khó: Trung bình Quy hoạch động Đệ quy có nhớ

Đề bài

Hãy xét một hệ thống tiền bao gồm $n$ đồng xu. Mỗi đồng xu có giá trị là một số nguyên dương. Nhiệm vụ của bạn là tạo ra một khoản tiền $x$ bằng cách sử dụng các đồng xu có sẵn sao cho số lượng đồng xu là tối thiểu.

Ví dụ: nếu các đồng xu là $\{1, 5, 7\}$ và tổng mong muốn là $11$, một giải pháp tối ưu là $5 + 5 + 1$, cần $3$ đồng xu.

Input

  • Dòng đầu vào đầu tiên có hai số nguyên $n$ và $x$: số lượng đồng xu và tổng số tiền mong muốn.
  • Dòng thứ hai có $n$ số nguyên phân biệt $c_1, c_2, \dots, c_n$: giá trị của mỗi đồng xu.

Output

  • In một số nguyên: số lượng đồng xu tối thiểu. Nếu không thể tạo ra tổng mong muốn, hãy in $-1$.

Constraints

  • $1 \le n \le 100$
  • $1 \le x \le 10^6$
  • $1 \le c_i \le 10^6$
Ví dụ – input
3 11
1 5 7
Ví dụ – output
3

Lời mở đầu

Đây là một bài quy hoạch động kinh điển trong bộ đề CSES. Mình sẽ đi từ cách đơn giản nhất là vét cạn bằng đệ quy, rồi cải tiến thành quy hoạch động top-down (đệ quy có nhớ), rồi bottom-up (công thức truy hồi), và cuối cùng là một cách cài đặt chạy nhanh hơn nữa. Hy vọng mang đến được giá trị cho các bạn 😇.

Dành cho tất cả mọi ngườiNộp bài trên LQDOJ

Link này công khai, bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên LQDOJ

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Tóm tắtHiểu đề cho thật đơn giả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ài nói gì?
Cho bạn 1 hệ thống tiền gồm $n$ $(1 \le n \le 100)$ đồng xu, đồng xu thứ $i$ có giá trị $c[i]$ $(1 \le c[i] \le 10^6)$, mỗi loại được dùng bao nhiêu lần cũng được. Nhiệm vụ của bạn là tạo ra số tiền $x$ $(1 \le x \le 10^6)$ sao cho số lượng đồng xu cần dùng là tối thiểu. In ra số lượng đồng xu tối thiểu đó (không tạo được thì in $-1$).

Ví dụ: $n = 3$, $x = 11$, $c = \{1, 5, 7\}$. Ta sẽ lấy 2 đồng xu $5$ và 1 đồng xu $1$. Vậy ta chỉ cần dùng 3 đồng xu, output $= 3$.

Lưu ý: tham lam "cứ lấy đồng to nhất trước" là sai ở bài này. Với ví dụ trên, lấy đồng $7$ trước thì còn $4$, phải thêm 4 đồng $1$, tổng cộng 5 đồng, nhiều hơn đáp án $3$. Vì vậy ta phải thử hết các khả năng một cách thông minh.

Cách 1Vét cạn bằng đệ quy

Cách làm đơn giản nhất của bài này chính là vét cạn, chính là ta thử mọi trường hợp có thể đó. Ta sẽ thấy rằng ở ví dụ, để tạo ra được số tiền là $11$ thì ta cần tạo ra số tiền là $10$ hoặc $6$ hoặc $4$. Tại vì 3 đồng xu có giá trị $1, 5, 7$:

  • nếu ta tạo ra được số tiền là $10$ thì lấy thêm 1 đồng xu $1$ là ta sẽ có tổng $11$;
  • nếu ta tạo ra được số tiền là $6$ thì lấy thêm 1 đồng xu $5$ là có tổng $11$;
  • nếu ta tạo ra được số tiền là $4$ thì lấy thêm 1 đồng xu $7$ là có tổng $11$.

Tương tự, để tạo ra số tiền $10$ ta cần tạo ra số tiền $9$ hoặc $5$ hoặc $3$… Từ đó ta thấy để tạo ra số tiền $x$ thì ta phải tạo được số tiền là $x - c[1]$ hoặc $x - c[2]$ hoặc … hoặc $x - c[n]$. Viết gọn lại: để tạo ra số tiền $x$ thì ta phải tạo được số tiền $x - c[i]$ $(1 \le i \le n)$.

−1−5−7 Try(11) Try(10) Try(6) Try(4) Try(9) Try(5) Try(3) Try(5) Try(1) Try(−1) Try(3) Try(−1) Try(−3)

Hai tầng đầu của cây đệ quy với $c = \{1, 5, 7\}$: Try(5) đã xuất hiện 2 lần, Try(3) cũng vậy, và càng xuống sâu càng trùng nhiều hơn.

Giờ ta cần biết số đồng xu phải dùng. Nếu ta tạo được số tiền $10$ thì số đồng xu để tạo ra $11$ $=$ (số đồng xu để tạo ra $10$) $+ 1$, ta $+1$ vì ta thêm 1 đồng xu $1$ vào. Tương tự với $6$ (thêm đồng $5$) và $4$ (thêm đồng $7$).

Vậy đến đây có bạn sẽ kết luận là số đồng xu tối thiểu để tạo ra $11$ $= \min$(số đồng xu tạo ra $10$ $+1$, số đồng xu tạo ra $6$ $+1$, số đồng xu tạo ra $4$ $+1$). Nhưng vậy là sai nha, vì để tạo ra số tiền $10$ ta có thể dùng 10 đồng $1$, hoặc 3 đồng $1$ và 1 đồng $7$, hoặc 2 đồng $5$… (rất nhiều cách). Ta đang cần số đồng xu tối thiểu để tạo ra $11$ thì ta cũng cần số đồng xu tối thiểu để tạo ra $10, 6, 4$ chứ. Rồi từ đó ta mới so sánh xem cách nào dùng ít đồng xu nhất được.

Công thức
Số đồng xu tối thiểu tạo ra $x$ $= \min\big(\text{tốithiểu}(x - c[1]) + 1,\ \text{tốithiểu}(x - c[2]) + 1,\ \dots,\ \text{tốithiểu}(x - c[n]) + 1\big)$.
Gọi biến cnt là số đồng xu tối thiểu để tạo ra $x$ thì cnt = min(cnt, tốithiểu(x - c[i]) + 1) với $1 \le i \le n$.

Để code được theo ý tưởng trên, ta sẽ xây dựng hàm đệ quy như sau:

  • Hàm Try(n, x) trả về số đồng xu tối thiểu để tạo ra số tiền $x$ với hệ thống tiền có $n$ đồng xu.
  • Điều kiện dừng 1 – x < 0: nếu $c[i] > x$ thì $x - c[i]$ sẽ âm, mà số tiền tạo ra từ các đồng xu thì không thể âm được. Nên ta trả về một giá trị tượng trưng là không hợp lệ. Ta đang tìm min nên trả về một giá trị rất lớn để không ảnh hưởng đến việc tính toán. Đồng xu bé nhất là $1$, số tiền lớn nhất là $10^6$, vậy tệ nhất cần $10^6$ đồng xu, giá trị trả về phải $> 10^6$, mình trả về $10^8$.
  • Điều kiện dừng 2 – x == 0: số tiền $0$ ta tạo được bằng cách không lấy đồng xu nào, nên trả về $0$.
  • Xử lý chạy: khởi tạo cnt = 1e8 (lúc đầu chưa biết có tạo được $x$ không nên gán giá trị không hợp lệ, cũng là giá trị rất to để cập nhật min), rồi cho $i$ chạy từ $1$ tới $n$: cnt = min(cnt, Try(n, x - c[i]) + 1).
Vì sao không trả về INT_MAX?
Ở đây ta có phép cộng (Try(...) + 1). Nếu trả về INT_MAX $= 2^{31} - 1$ (giá trị lớn nhất kiểu int lưu được) thì khi cộng thêm 1 sẽ bị tràn số, rất nguy hiểm nha. Nên ở đây mình trả về (int)1e8 $= 10^8$.

Sau đây là source code của mình về ý tưởng trên cho anh em nào cần tham khảo nha:

Code vét cạn đệ quy – TLE
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
int c[105];
int Try(int n, int x) {
    if(x < 0) {
        return (int)1e8;
    }
    if(x == 0) {
        return 0;
    }
    int cnt = (int)1e8;
    for(int i = 1; i <= n; ++i) {
        cnt = min(cnt, Try(n, x - c[i]) + 1);
    }
    return cnt;
}
int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    int n, x;
    cin >> n >> x;
    for(int i = 1; i <= n; ++i) {
        cin >> c[i];
    }
    int res = Try(n, x);
    if(res == (int)1e8) {
        res = -1;
    }
    cout << res;
    return 0;
}
import sys
sys.setrecursionlimit(10000)
INF = 10**8

def Try(n, x):
    if x < 0:
        return INF
    if x == 0:
        return 0
    cnt = INF
    for i in range(1, n + 1):
        cnt = min(cnt, Try(n, x - c[i]) + 1)
    return cnt

data = sys.stdin.read().split()
n, x = int(data[0]), int(data[1])
c = [0] + [int(v) for v in data[2:2 + n]]
res = Try(n, x)
if res == INF:
    res = -1
print(res)
C++▶ Chạy tay từng dòng codeCách 1: vét cạn đệ quy, thử mọi đồng xu cuối cùng – hàm mũ.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5int c[105];
6int Try(int n, int x) {
7 if(x < 0) {
8 return (int)1e8;
9 }
10 if(x == 0) {
11 return 0;
12 }
13 int cnt = (int)1e8;
14 for(int i = 1; i <= n; ++i) {
15 cnt = min(cnt, Try(n, x - c[i]) + 1);
16 }
17 return cnt;
18}
19int main() {
20 ios_base::sync_with_stdio(0);
21 cin.tie(0);
22 int n, x;
23 cin >> n >> x;
24 for(int i = 1; i <= n; ++i) {
25 cin >> c[i];
26 }
27 int res = Try(n, x);
28 if(res == (int)1e8) {
29 res = -1;
30 }
31 cout << res;
32 return 0;
33}

Và nếu anh em không quen với điều kiện dừng if(x < 0) thì trước mỗi lần gọi đệ quy ta hãy kiểm tra đồng tiền ta đang định lấy phải $\le x$ đã. Vì để tạo ra số tiền $x$ thì đồng tiền dùng để tạo ra nó phải $\le x$ chứ. Hàm Try() sửa lại như thế này vẫn hoàn toàn đúng nha 😁:

int Try(int n, int x) {
    if(x == 0) {
        return 0;
    }
    int cnt = (int)1e8;
    for(int i = 1; i <= n; ++i) {
        if(c[i] <= x) {
            cnt = min(cnt, Try(n, x - c[i]) + 1);
        }
    }
    return cnt;
}

Giải thích một số chỗ trong code

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài cần nhập xuất nhiều thì ta nên sử dụng 2 dòng này. Trong bài này đầu vào chỉ có tầm 100 số nên các bạn không thêm vào cũng được, nhưng mình vẫn thêm vào vì đó là thói quen rồi nha. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé.
  2. int res = Try(n, x); ta tạo biến res để lưu kết quả: số đồng xu tối thiểu để tạo ra số tiền $x$ từ hệ thống tiền gồm $n$ đồng xu.
  3. if(res == (int)1e8) res = -1; vì $10^8$ tượng trưng cho việc không thể tạo ra số tiền $x$, mà đề bảo khi đó in ra $-1$. Nhiều bạn sợ nhỡ hàm Try() không trả về đúng $10^8$ thì sao? Nhìn vào biến cnt: lúc đầu bằng $10^8$, nếu không tạo được $x - c[i]$ thì Try(x - c[i]) trả về $10^8$, lúc này cnt = min(10^8, 10^8 + 1) vẫn $= 10^8$ thôi. Nếu hơi khó hiểu thì có cách khác: để tạo ra $x$ cần nhiều nhất $x$ đồng xu (đồng nhỏ nhất có thể là $1$), nên nếu res > x thì chắc chắn không hợp lệ, gán res = -1.
Không gian$O(x)$ (độ sâu đệ quy)
Thời gian$O(n^{x})$ – hàm mũ

Với cách trên chắc chắn ta sẽ bị TLE (Time Limit Exceeded – quá giới hạn thời gian): số lời gọi tăng theo hàm mũ. Tuy nhiên hãy vẫn nên cài đặt nó để sau này ta lấy nó làm công cụ kiểm tra tính chính xác cho cách làm tối ưu, hoặc biết đâu ta có thể cải tiến nó thành quy hoạch động top-down.

Cách 2Quy hoạch động top-down (đệ quy có nhớ)

Cách làm vét cạn sẽ gặp một vấn đề khiến nó bị chậm đi rất nhiều, đó chính là các bài toán con trùng lặp. Tức khi ta gặp một vấn đề ta đã tính rồi, ta lại đi tính lại nó, điều đó khiến ta tốn rất nhiều thời gian. Nhìn lại cây đệ quy ở trên: Try(4), Try(3)… bị gọi đi gọi lại rất nhiều lần.

Để giải quyết, ta dùng 1 mảng để lưu kết quả của hàm Try(n, x) lại, đây chính là phương pháp quy hoạch động top-down hay còn gọi là đệ quy có nhớ.

  • Mỗi lần gọi đệ quy thì chỉ có biến $x$ thay đổi, còn $n$ thì không. Vậy ta chỉ cần một mảng 1 chiều f[xMax] ($xMax = 10^6 + 5$), khai báo toàn cục để tránh tràn bộ nhớ stack. Phần tử f[x] lưu kết quả của Try(n, x).
  • Hàm Try() không thể trả về giá trị âm, nên ta lấp đầy mảng f = -1, tượng trưng cho việc chưa tính.
  • Sau mục điều kiện dừng, nếu f[x] != -1 (đã tính rồi) thì chỉ việc return f[x]. Ngược lại thì đi tính như cũ, lưu kết quả vào f[x] rồi trả về.
Code quy hoạch động top-down – AC
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int xMax = (int)1e6 + 5;
int c[105];
int f[xMax];
int Try(int n, int x) {
    if(x < 0) {
        return (int)1e8;
    }
    if(x == 0) {
        return 0;
    }
    if(f[x] != -1) {
        return f[x];
    }
    int cnt = (int)1e8;
    for(int i = 1; i <= n; ++i) {
        cnt = min(cnt, Try(n, x - c[i]) + 1);
    }
    return f[x] = cnt;
}
int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    int n, x;
    cin >> n >> x;
    for(int i = 1; i <= n; ++i) {
        cin >> c[i];
    }
    fill(&f[1], &f[x + 1], -1);
    int res = Try(n, x);
    if(res == (int)1e8) {
        res = -1;
    }
    cout << res;
    return 0;
}
import sys, threading
INF = 10**8

def main():
    data = sys.stdin.read().split()
    n, x = int(data[0]), int(data[1])
    c = [0] + [int(v) for v in data[2:2 + n]]
    f = [-1] * (x + 1)

    def Try(x):
        if x < 0:
            return INF
        if x == 0:
            return 0
        if f[x] != -1:
            return f[x]
        cnt = INF
        for i in range(1, n + 1):
            cnt = min(cnt, Try(x - c[i]) + 1)
        f[x] = cnt
        return cnt

    res = Try(x)
    if res == INF:
        res = -1
    print(res)

# đệ quy sâu tới x tầng nên phải tăng giới hạn đệ quy và kích thước stack
sys.setrecursionlimit(1 << 21)
threading.stack_size(1 << 27)
t = threading.Thread(target=main)
t.start()
t.join()
C++▶ Chạy tay từng dòng codeCách 2: đệ quy có nhớ, mỗi f[x] chỉ tính 1 lần – O(n·x).
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const int xMax = (int)1e6 + 5;
6int c[105];
7int f[xMax];
8int Try(int n, int x) {
9 if(x < 0) {
10 return (int)1e8;
11 }
12 if(x == 0) {
13 return 0;
14 }
15 if(f[x] != -1) {
16 return f[x];
17 }
18 int cnt = (int)1e8;
19 for(int i = 1; i <= n; ++i) {
20 cnt = min(cnt, Try(n, x - c[i]) + 1);
21 }
22 return f[x] = cnt;
23}
24int main() {
25 ios_base::sync_with_stdio(0);
26 cin.tie(0);
27 int n, x;
28 cin >> n >> x;
29 for(int i = 1; i <= n; ++i) {
30 cin >> c[i];
31 }
32 fill(&f[1], &f[x + 1], -1);
33 int res = Try(n, x);
34 if(res == (int)1e8) {
35 res = -1;
36 }
37 cout << res;
38 return 0;
39}

Giải thích một số chỗ trong code

  1. fill(&f[1], &f[x + 1], -1); câu lệnh này chính là lấp đầy mảng f bằng $-1$, từ f[1] tới f[x].
  2. return f[x] = cnt; vừa gán f[x] = cnt vừa trả về giá trị đó.
Không gian$O(x_{max})$
Thời gian$O(n \cdot x)$

Trong trường hợp xấu nhất ta phải lấp đầy mảng f, với mỗi phần tử lại chạy một vòng for từ $1$ tới $n$, vậy cần $x \cdot n$ phép tính. Tệ nhất $x = 10^6$, $n = 100$ cần khoảng $10^8$ phép tính, với code trên là ta đã AC rồi nha.

Cách 3Quy hoạch động bottom-up (công thức truy hồi)

Ta đã có cách quy hoạch động top-down rồi thì ta cũng sẽ có cách quy hoạch động bottom-up, chính là anh em sẽ tạo ra công thức truy hồi đó. Cho anh em nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước như sau:

Bước 1 – Bài toán con
Gọi $F[i]$ là số đồng xu tối thiểu để tạo ra số tiền $i$ (từ hệ thống gồm $n$ đồng xu).
Bước 2 – Bài toán cơ sở
Như ở phần điều kiện dừng: khi $i = 0$ thì không cần đồng xu nào, tức $F[0] = 0$. Lúc đầu với số tiền $i$ bất kỳ ta chưa biết có tạo ra được không, nên khởi tạo mọi phần tử (trừ phần tử $0$) bằng giá trị không hợp lệ $10^8$: fill(&f[1], &f[x + 1], (int)1e8);
Bước 3 – Đáp án
Ta cần số đồng xu tối thiểu để tạo ra $x$, nên hiển nhiên đáp án là $F[x]$ (nếu vẫn bằng $10^8$ thì in $-1$).
Bước 4 – Công thức truy hồi
Như đã phân tích ở phần vét cạn: $F[i] = \min\big(F[i - c[1]] + 1, F[i - c[2]] + 1, \dots, F[i - c[n]] + 1\big)$, viết gọn:
$$F[i] = \min\big(F[i],\ F[i - c[j]] + 1\big) \quad (1 \le j \le n,\ c[j] \le i)$$ Phải kiểm tra $c[j] \le i$ vì nhỡ $i - c[j]$ âm thì sao.

Để áp dụng công thức trên ta làm một vòng for(i) chạy từ $1$ tới $x$. Khi chạy như vậy ta luôn đảm bảo rằng $F[i - c[j]]$ (với $c[j] \le i$) đã được tính rồi, vì vòng for(i) đang chạy tăng dần mà.

Em nhập các đồng xu và số tiền $x$ rồi bấm Tiếp để xem bảng $F$ được lấp đầy từng ô:

Ví dụ nhanh:
Code quy hoạch động bottom-up – AC
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int xMax = (int)1e6 + 5;
int c[105], f[xMax];
int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    int n, x;
    cin >> n >> x;
    for(int i = 1; i <= n; ++i) {
        cin >> c[i];
    }
    f[0] = 0;
    fill(&f[1], &f[x + 1], (int)1e8);
    for(int i = 1; i <= x; ++i) {
        for(int j = 1; j <= n; ++j) {
            if(c[j] <= i) {
                f[i] = min(f[i], f[i - c[j]] + 1);
            }
        }
    }
    if(f[x] == (int)1e8) {
        f[x] = -1;
    }
    cout << f[x];
    return 0;
}
import sys
INF = 10**8
data = sys.stdin.read().split()
n, x = int(data[0]), int(data[1])
c = [int(v) for v in data[2:2 + n]]
f = [INF] * (x + 1)
f[0] = 0
for i in range(1, x + 1):
    best = f[i]
    for cj in c:
        if cj <= i and f[i - cj] + 1 < best:
            best = f[i - cj] + 1
    f[i] = best
print(-1 if f[x] == INF else f[x])
C++▶ Chạy tay từng dòng codeCách 3: bottom-up, tính f[1], f[2], …, f[x] theo công thức truy hồi.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const int xMax = (int)1e6 + 5;
6int c[105], f[xMax];
7int main() {
8 ios_base::sync_with_stdio(0);
9 cin.tie(0);
10 int n, x;
11 cin >> n >> x;
12 for(int i = 1; i <= n; ++i) {
13 cin >> c[i];
14 }
15 f[0] = 0;
16 fill(&f[1], &f[x + 1], (int)1e8);
17 for(int i = 1; i <= x; ++i) {
18 for(int j = 1; j <= n; ++j) {
19 if(c[j] <= i) {
20 f[i] = min(f[i], f[i - c[j]] + 1);
21 }
22 }
23 }
24 if(f[x] == (int)1e8) {
25 f[x] = -1;
26 }
27 cout << f[x];
28 return 0;
29}

Giải thích một số chỗ trong code

  1. if(f[x] == (int)1e8) f[x] = -1; nếu $F[x] = 10^8$ chứng tỏ không thể tạo ra số tiền $x$, mà đề bảo khi đó in ra $-1$. Hoặc các bạn có thể viết: nếu f[x] == (int)1e8 thì in $-1$, ngược lại in f[x].
Không gian$O(x_{max})$
Thời gian$O(n \cdot x)$

Với cách trên thì code của ta sẽ ăn ít bộ nhớ hơn và chạy nhanh hơn vì không phải tốn bộ nhớ và thời gian cho việc gọi đệ quy nữa. Nhưng cách trên vẫn chưa phải là cách nhanh nhất, vì vòng for của ta vẫn chạy khá nhiều. Giờ ta sẽ đến với một cách cải tiến hơn.

Cách 4Đảo vòng lặp: xét từng đồng xu

Ta vẫn dùng quy hoạch động bottom-up thôi. $F[j]$ vẫn là số xu tối thiểu để tạo ra số tiền $j$, $F[x]$ vẫn là kết quả, khởi tạo (trường hợp cơ sở) vẫn như vậy. Khác ở chỗ: với $i$ đồng xu đầu tiên thì để tạo ra số tiền $j$ ta cần tối thiểu bao nhiêu đồng xu.

  • Vòng for(i) ở ngoài chạy từ $1$ tới $n$ (tượng trưng cho việc ta đang xét $i$ đồng xu đầu tiên).
  • Với đồng xu $i$ thì số tiền nhỏ nhất nó có thể góp vào là $c[i]$, vậy vòng for(j) bắt đầu từ j = c[i] và chạy đến $x$.
  • Nếu có cách tạo ra số tiền $j - c[i]$ thì chắc chắn ta tạo được số tiền $j$ (thêm đồng $c[i]$ vào là được mà). Vậy ta kiểm tra if(f[j - c[i]] != (int)1e8) rồi áp dụng công thức truy hồi vẫn như trên: f[j] = min(f[j], f[j - c[i]] + 1).
Mình bổ sung: vì j chạy tăng dần nên lúc tính f[j] thì f[j - c[i]] có thể đã dùng chính đồng $c[i]$ rồi, nhờ vậy mỗi loại đồng xu được dùng bao nhiêu lần cũng được, đúng như đề bài. Đồng xu nào có $c[i] > x$ thì vòng for(j) không chạy lần nào, tự động bị bỏ qua.
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int xMax = (int)1e6 + 5;
int c[105], f[xMax];
int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    int n, x;
    cin >> n >> x;
    for(int i = 1; i <= n; ++i) {
        cin >> c[i];
    }
    f[0] = 0;
    fill(&f[1], &f[x + 1], (int)1e8);
    for(int i = 1; i <= n; ++i) {
        for(int j = c[i]; j <= x; ++j) {
            if(f[j - c[i]] != (int)1e8) {
                f[j] = min(f[j], f[j - c[i]] + 1);
            }
        }
    }
    if(f[x] == (int)1e8) {
        f[x] = -1;
    }
    cout << f[x];
    return 0;
}
import sys
INF = 10**8
data = sys.stdin.read().split()
n, x = int(data[0]), int(data[1])
c = [int(v) for v in data[2:2 + n]]
f = [INF] * (x + 1)
f[0] = 0
for ci in c:
    for j in range(ci, x + 1):
        if f[j - ci] != INF and f[j - ci] + 1 < f[j]:
            f[j] = f[j - ci] + 1
print(-1 if f[x] == INF else f[x])
C++▶ Chạy tay từng dòng codeCách 4: vòng ngoài là đồng xu, vòng trong là số tiền từ c[i] tới x.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const int xMax = (int)1e6 + 5;
6int c[105], f[xMax];
7int main() {
8 ios_base::sync_with_stdio(0);
9 cin.tie(0);
10 int n, x;
11 cin >> n >> x;
12 for(int i = 1; i <= n; ++i) {
13 cin >> c[i];
14 }
15 f[0] = 0;
16 fill(&f[1], &f[x + 1], (int)1e8);
17 for(int i = 1; i <= n; ++i) {
18 for(int j = c[i]; j <= x; ++j) {
19 if(f[j - c[i]] != (int)1e8) {
20 f[j] = min(f[j], f[j - c[i]] + 1);
21 }
22 }
23 }
24 if(f[x] == (int)1e8) {
25 f[x] = -1;
26 }
27 cout << f[x];
28 return 0;
29}
Không gian$O(x_{max})$
Thời gian$O(n \cdot x)$, chạy nhanh hơn thực tế

Với cách trên thì code của ta sẽ chạy nhanh hơn nữa nha. Tuy về mặt lý thuyết độ phức tạp thời gian vẫn là $O(n \cdot x)$, nhưng trong thực tế sẽ có nhiều test ta chạy nhanh hơn: vòng trong bắt đầu từ $c[i]$ chứ không phải từ $1$, không còn câu lệnh if(c[j] <= i) bị kiểm tra vô ích, và vòng j duyệt mảng f liên tục nên truy cập bộ nhớ rất "thân thiện" với cache.

Ghi chú kiểm tra của mình: 4 code C++ ở trên là của mình trong bài viết gốc, các bản Python là mình viết thêm. Tất cả 8 chương trình đã được kiểm tra với 250 bộ test ngẫu nhiên ($n \le 5$, $x \le 22$, có cả trường hợp in $-1$) so với chương trình vét cạn BFS theo số đồng xu, đều khớp. Với test lớn nhất $n = 100$, $x = 10^6$ (có đồng $1$ nên đệ quy sâu $10^6$ tầng): Cách 2 chạy khoảng 0.7–1.7 giây trên máy mình (đệ quy sâu khá tốn), Cách 3 khoảng 0.2–0.6 giây, Cách 4 khoảng 0.13–0.4 giây. Các bản Python đúng nhưng mất 15–25 giây với test lớn nhất ($10^8$ phép tính là quá sức Python), nên chỉ để tham khảo cách cài đặt; nộp thì nên dùng C++.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇

Nhận xét