今天這題是完全揹包問題 + 揹包問題方案數,我一共列舉了 5 種解法,層層遞進優化。並且從兩個角度殊途同歸,最終優化到同一個式子。強烈建議掌握,對理解揹包問題有很大幫助。
題目鏈接
LeetCode 面試題 08.11. 硬幣[1]
題目描述
給定數量不限的硬幣,幣值爲 25
分、10
分、5
分和 1
分,編寫代碼計算 n
分有幾種表示法。(結果可能會很大,你需要將結果模上 1000000007
)
說明:
-
0 <= n (總金額) <= 1000000
示例1
輸入:
n = 5
輸出:
2
解釋:
有兩種方式可以湊成總金額:
5=5
5=1+1+1+1+1
示例2
輸入:
n = 10
輸出:
4
解釋:
有四種方式可以湊成總金額:
10=10
10=5+5
10=5+1+1+1+1+1
10=1+1+1+1+1+1+1+1+1+1
題解
首先我們規定一些記號,用 來表示第 種硬幣的面值,用 表示用前 種硬幣組成面值 的方案數。令 表示需要表示的面值, 表示硬幣數。
樸素想法(錯誤)
首先我們可以想到,最樸素的方法不就是組成面值 的方案數等於所有組成面值 的方案數之和:
但是這樣有個很嚴重的問題,就是會產生重複計算,也就是將 6 = 1 + 5
和 6 = 5 + 1
視爲兩種情況。
動態規劃 1
樸素想法的糾正方法就是,規定拆解後的數字是有序的,這樣就不會出現重複計算了。
那麼具體怎麼實現呢?其實只需要加上一個約束,也就是強制令 爲組成面值 的最大面值硬幣。那麼用掉它之後,組成面值 的最大面值硬幣仍然只能是 ,這樣轉移下去就一定是有序的,不會出現面值突然增大的情況。轉移方程只需要修改一下轉移後的可用硬幣 :
時間複雜度 ,空間複雜度 。
動態規劃 2(超時)
另一條思考路線是,我們假設第 個硬幣用 枚,然後枚舉所有的 就行了。轉移方程很好寫:
但是這樣時間複雜度太高了,直接超時。
時間複雜度 ,空間複雜度 。
轉移方程優化
神奇的地方來了,上面兩種方法,全部可以優化爲同一個式子,仔細看好了。
動態規劃 1:
首先看第一個方法,轉移方程爲:
我們令 ,又可以得到:
兩式左右兩邊相減可以得到:
動態規劃 2:
再看第二個方法,轉移方程爲:
令 ,又可以得到:
兩式左右兩邊相減可以得到:
最終形式:
所以,最終**兩個方法消去求和之後,形式是一樣的!**都是:
時間複雜度 ,空間複雜度 。
空間優化
注意到,上面轉移方程每個時刻 其實只和 還有 時刻有關,所以可以把第一個維度消除掉。這樣轉移方程就變爲了:
但是需要特別注意的是,這裏一共有三項,分別表示的是第 時刻、第 時刻、第 時刻。所以在兩層循環遍歷的時候,第一層循環必須是遍歷硬幣 ,第二層纔是遍歷組成的面值 ,這樣纔不會導致第 時刻的值被覆蓋掉無法訪問。
時間複雜度 ,空間複雜度 。
數學法
這個方法就只針對本題硬幣種類比較少的情況了。
假設組成面值 需要 枚 25
分, 枚 10
分, 枚 5
分, 枚 1
分,那麼有:
這裏 我們是需要枚舉的,範圍是 ,所以我們令 ,那麼就得到了:
那麼 的範圍是 。而 確定了之後, 的範圍就是 。而 都確定了之後, 是唯一確定了的。所以最終的方案數就是:
所以最終我們遍歷 ,然後令 。接着令 ,,最後對 進行累加就行了:
時間複雜度 ,空間複雜度 。
代碼
動態規劃 1(c++)
class Solution {
public:
typedef long long ll;
static const ll mod = 1e9 + 7;
static const int N = 1000010;
static const int M = 4;
ll dp[M][N];
int p[M] = {1, 5, 10, 25};
int waysToChange(int n) {
memset(dp, 0, sizeof dp);
for (int i = 0; i < M; ++i) dp[i][0] = 1;
for (int i = 0; i < M; ++i) {
for (int j = 1; j <= n; ++j) {
for (int k = 0; k <= i; ++k) {
if (j >= p[k]) {
(dp[i][j] += dp[k][j-p[k]]) %= mod;
}
}
}
}
return dp[M-1][n];
}
};
動態規劃 2(超時)(c++)
class Solution {
public:
typedef long long ll;
static const ll mod = 1e9 + 7;
static const int N = 1000010;
static const int M = 4;
ll dp[M][N];
int p[M] = {1, 5, 10, 25};
int waysToChange(int n) {
memset(dp, 0, sizeof dp);
for (int i = 0; i < M; ++i) dp[i][0] = 1;
for (int i = 0; i <= n/p[0]; ++i) dp[0][i*p[0]] = 1;
for (int i = 1; i < M; ++i) {
for (int j = 1; j <= n; ++j) {
for (int k = 0; k <= j/p[i]; ++k) {
(dp[i][j] += dp[i-1][j-k*p[i]]) %= mod;
}
}
}
return dp[M-1][n];
}
};
轉移方程優化(c++)
class Solution {
public:
typedef long long ll;
static const ll mod = 1e9 + 7;
static const int N = 1000010;
static const int M = 4;
ll dp[M][N];
int p[M] = {1, 5, 10, 25};
int waysToChange(int n) {
memset(dp, 0, sizeof dp);
for (int i = 0; i < M; ++i) dp[i][0] = 1;
for (int i = 0; i <= n/p[0]; ++i) dp[0][i*p[0]] = 1;
for (int i = 1; i < M; ++i) {
for (int j = 1; j <= n; ++j) {
dp[i][j] = dp[i-1][j];
if (j >= p[i]) (dp[i][j] += dp[i][j-p[i]]) %= mod;
}
}
return dp[M-1][n];
}
};
空間優化(c++)
class Solution {
public:
typedef long long ll;
static const ll mod = 1e9 + 7;
static const int N = 1000010;
static const int M = 4;
ll dp[N];
int p[M] = {1, 5, 10, 25};
int waysToChange(int n) {
memset(dp, 0, sizeof dp);
dp[0] = 1;
for (int j = 0; j < M; ++j) {
for (int i = 1; i <= n; ++i) {
if (i >= p[j]) {
(dp[i] += dp[i-p[j]]) %= mod;
}
}
}
return dp[n];
}
};
數學法(c++)
class Solution {
public:
typedef long long ll;
static const ll mod = 1e9 + 7;
int waysToChange(int n) {
ll res = 0;
for (int i = 0; i <= n/25; ++i) {
ll r = n - 25 * i;
ll x = r / 10, y = r / 5;
(res += (x + 1) * (y - x + 1)) %= mod;
}
return res;
}
};
參考資料
[1]
LeetCode 面試題 08.11. 硬幣: https://leetcode-cn.com/problems/coin-lcci/