【每日算法Day 109】五大解法,帶你深入瞭解完全揹包方案數

今天這題是完全揹包問題 + 揹包問題方案數,我一共列舉了 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
      

題解

首先我們規定一些記號,用 p[i] 來表示第 i 種硬幣的面值,用 dp[i][j] 表示用前 i 種硬幣組成面值 j 的方案數。令 n 表示需要表示的面值,m 表示硬幣數。

樸素想法(錯誤)

首先我們可以想到,最樸素的方法不就是組成面值 j 的方案數等於所有組成面值 j-p[k] 的方案數之和

dp[i][j] = \sum_{k=0}^{i}{dp[i][j-p[k]]} \\

但是這樣有個很嚴重的問題,就是會產生重複計算,也就是將 6 = 1 + 56 = 5 + 1 視爲兩種情況。

動態規劃 1

樸素想法的糾正方法就是,規定拆解後的數字是有序的,這樣就不會出現重複計算了。

那麼具體怎麼實現呢?其實只需要加上一個約束,也就是強制令 p[k] 爲組成面值 j 的最大面值硬幣。那麼用掉它之後,組成面值 j-p[k] 的最大面值硬幣仍然只能是 p[k] ,這樣轉移下去就一定是有序的,不會出現面值突然增大的情況。轉移方程只需要修改一下轉移後的可用硬幣 i

dp[i][j] = \sum_{k=0}^{i}{dp[k][j-p[k]]} \\

時間複雜度 O(nm^2) ,空間複雜度 O(nm)

動態規劃 2(超時)

另一條思考路線是,我們假設第 i 個硬幣用 k 枚,然後枚舉所有的 k 就行了。轉移方程很好寫:

dp[i][j] = \sum_{k=0}^{\lfloor j/p[i] \rfloor}{dp[i-1][j-k \cdot p[i]]} \\

但是這樣時間複雜度太高了,直接超時。

時間複雜度 O(n^2m) ,空間複雜度 O(nm)

轉移方程優化

神奇的地方來了,上面兩種方法,全部可以優化爲同一個式子,仔細看好了。

動態規劃 1:

首先看第一個方法,轉移方程爲:

dp[i][j] = \sum_{k=0}^{i}{dp[k][j-p[k]]} \\

我們令 i = i - 1 ,又可以得到:

dp[i-1][j] = \sum_{k=0}^{i-1}{dp[k][j-p[k]]} \\

兩式左右兩邊相減可以得到:

dp[i][j] = dp[i-1][j] + dp[i][j-p[i]] \\

動態規劃 2:

再看第二個方法,轉移方程爲:

dp[i][j] = \sum_{k=0}^{\lfloor j/p[i] \rfloor}{dp[i-1][j-k \cdot p[i]]} \\

j = j - p[i] ,又可以得到:

dp[i][j-p[i]] = \sum_{k=1}^{\lfloor j/p[i] \rfloor}{dp[i-1][j-k \cdot p[i]]} \\

兩式左右兩邊相減可以得到:

dp[i][j] = dp[i-1][j] + dp[i][j-p[i]] \\

最終形式:

所以,最終**兩個方法消去求和之後,形式是一樣的!**都是:

dp[i][j] = dp[i-1][j] + dp[i][j-p[i]] \\

時間複雜度 O(nm) ,空間複雜度 O(nm)

空間優化

注意到,上面轉移方程每個時刻 i 其實只和 i-1 還有 i 時刻有關,所以可以把第一個維度消除掉。這樣轉移方程就變爲了:

dp[j] = dp[j] + dp[j-p[i]] \\

但是需要特別注意的是,這裏一共有三項,分別表示的是第 i 時刻、第 i-1 時刻、第 i 時刻。所以在兩層循環遍歷的時候,第一層循環必須是遍歷硬幣 i ,第二層纔是遍歷組成的面值 j ,這樣纔不會導致第 i-1 時刻的值被覆蓋掉無法訪問。

時間複雜度 O(nm) ,空間複雜度 O(n)

數學法

這個方法就只針對本題硬幣種類比較少的情況了。

假設組成面值 n 需要 i25 分, a10 分, b5 分, c1 分,那麼有:

n = 25i + 10a + 5b + c \\

這裏 i 我們是需要枚舉的,範圍是 [0, \lfloor n/25 \rfloor] ,所以我們令 r = n - 25i,那麼就得到了:

r = 10a + 5b + c \\

那麼 a 的範圍是 [0, \lfloor r/10 \rfloor] 。而 a 確定了之後, b 的範圍就是 [0, \lfloor (r-10a)/5 \rfloor] 。而 a, b 都確定了之後, c 是唯一確定了的。所以最終的方案數就是:

\begin{aligned} \sum_{a=0}^{\lfloor r/10 \rfloor}{\sum_{b=0}^{\lfloor (r-10a)/5 \rfloor}{1}} &= \sum_{a=0}^{\lfloor r/10 \rfloor}{(\lfloor (r-10a)/5 \rfloor + 1)}\\\ &= \sum_{a=0}^{\lfloor r/10 \rfloor}{(\lfloor r/5 \rfloor -2a + 1)}\\\ &= (\lfloor r/10 \rfloor + 1)(\lfloor r/5 \rfloor + 1) - (\lfloor r/10 \rfloor + 1)\lfloor r/10 \rfloor \\\ & =(\lfloor r/10 \rfloor + 1)(\lfloor r/5 \rfloor - \lfloor r/10 \rfloor + 1) \end{aligned} \\

所以最終我們遍歷 i \in [0, \lfloor n/25 \rfloor],然後令 r = n - 25i。接着令 x = \lfloor r/10 \rfloory = \lfloor r/5 \rfloor,最後對 (x+1)(y-x+1) 進行累加就行了:

\sum_{i=0}^{\lfloor n/25 \rfloor}{(x+1)(y-x+1)} \\

時間複雜度 O(n) ,空間複雜度 O(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. 硬幣: leetcode-cn.com/problem

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章