LeetCode組合總和I~IV和背包問題小結


一、組合總和問題

最近在看leetcode的組合問題,一共四道,總結一下共通之處與不同之處。
原題鏈接:
組合總和
組合總和II
組合總和III
組合總和IV

對比如下,為了便於對比,將原題目的敘述方式進行了修改。

問題 輸入 取值限定 解集限定 解法
I 無重復元素的數組 candidates且全為正數;目標數 target candidates元素可以無限制重復被選取 無重復集合 回溯法,對每一個候選值可以選0~n次,滿足已選之數總和小於等於target。輸入無重復+回溯本身保證結果集無重復
II 可能有重復元素的數組 candidates且全為正數;目標數 target candidates元素只能選一次 無重復集合 建立candidates元素與其個數的hashmap,基於選擇個數做回溯法
III candidates=[1,2,...,9],目標數 target,個數k candidates元素只能選一次,只能選k個 無重復集合 回溯法,按順序遍歷每個元素分別考慮選與不選。其他解法見原鏈接
IV 無重復元素的數組 candidates且全為正數;目標數 target candidates元素可以無限制重復被選取 無重復數組(順序不同認為是不同解) 轉換為背包問題的動態規划解法。先排序再用回溯法求所有無重復集合的解,最后構造結果的解法會超時。

二、背包問題

對於【組合總和IV】相關聯的背包問題,做進一步的研究。
背包可以歸為三類:0-1背包、完全背包、多重背包。

共性

  • 背包容量有限,求解能使背包中放下最大價值總和的金額。(本文不討論求得最大價值總和具體放法的方式)
  • 一共n種不同的物品,對應的體積w[1...n]和價值v[1...n]
  • 求解過程是動態規划,且dp[i][j]代表【在考慮第i件物品時(無論取不取),使用空間為j時最大的價值】。那么dp[n][1...V]中最大值即為所求的最終解。(因為可能放不滿)
  • 可以根據求解dp[i][j]的過程,進行存儲容量壓縮從而降低空間復雜度
  • 初始化dp[0][j]=0

區別

分類 輸入 取值限定 解法
0-1背包 背包容量V,n種物品其體積w[1...n]和價值v[1...n] 每個物品最多取1次 見狀態轉移方程
完全背包 背包容量V,n種物品其體積w[1...n]和價值v[1...n] 每個物品可以取無限次 見狀態轉移方程
多重背包 背包容量V,n種物品其體積w[1...n]和價值v[1...n],個數分別為k[1...n] 第i個物品可以取0至k[i]次 見狀態轉移方程

狀態轉移方程

0-1背包

  • dp[i][j] = dp[i-1][j] ,當 j-w[i]<0。表示使用容量為j時,無法放下第i件,因此選擇不放它
  • dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]),當 j-w[i]>=0。表示取放和不放第i件的兩種情況下的最大值

優化:

  1. 觀察狀態轉移方程時可以發現,每次都直接使用i-1行的結果來構造第i行的結果,那么只需要存儲一行即可。且在遍歷時,必須使用倒序遍歷j->1防止本輪的變化覆蓋到上一輪的結果上去,導致這一變化被再次取出來。
  2. 保存當前行的最大值,那么這個最大值在求解最后一行時即為所求的結果。

去掉i這一個維度可改寫為:

  • 保持不變,當 j-w[i]<0時。
  • dp[j] = max(dp[j], dp[j-w[i]] + v[i]),當 j-w[i]>=0

完全背包

在0-1背包基礎上,因為每件可以使用無限次(實際上有一個上界——不超過當前剩余容量)。公式為:

  • dp[i][j] = max(dp[i-1][j-kw[i]] + kv[i]),其中k=0,1, 2...j/w[i]取整。

但是結合0-1背包優化的過程:j倒序遍歷是為了避免重復取第i個元素造成重復更新。那么反過來利用這個特性,正好能表達每個元素取無限個的特點。
那么優化公式為:

  • dp[j] = max(dp[j], dp[j-w[i]] + v[i])。這個公式很抽象,表達為代碼為
for (int i = 0; i <= V; i++) dp[i] = 0;//初始化二維數組
//循環每個物品
for (int i = 1; i <= n; i++)
{
      for (int j = w[i]; j <= V; j++)
      {
            dp[j] = max(dp[j], dp[j -w[i]] + v[i]);
      }
}

可以看出去掉了原始公式中k的這一層循環,並且將j的下界進行了優化,減少了判斷語句。

多重背包

可以將所有類型的物品看做不同種類的,轉化為0-1背包。
也可以沿着原先完全背包的思路, dp[i][j] = max(dp[i-1][j-k*w[i]] + k*v[i]),其中k=0,1, 2...k[i]取整。
這兩種時間復雜度都是O(n^3)的。
有一種優化的方法是按2的冪將k件第i種物品拆分,如20=1+2+4+8+5,再使用0-1背包,可以降低至O(n^2logn)
還有更多的優化方法,可以參考 淺談多重背包的一些解法

背包問題延伸:先遍歷n個物品還是先遍歷背包容量V

上文所討論的三種背包問題基本場景,都是基於求結果的組合數的,即不考慮結果中元素的順序,對於V=4,[1, 3]和[3, 1]是同一個解。
如果要求排列數,又如何解呢?
從上文的討論過程可以發現,如果先按照順序取n個/種物品再遍歷背包容量V,解中第i個總在第i+1個前面,沒有考慮順序。如果先遍歷容量V,再遍歷元素,自然就形成了排序的解。還以V=4舉例,取i=1時,V-i=3;取i=3時,V-i=1,此時可以得出出[1, 3]和[3, 1]兩個不同的解。
因此,第一版的狀態轉移方程為:

  • dp[i][j] = Σdp[i-w[k]][j], 其中k=0...j-1,且使得i-w[k]>=0 。dp[i][j]代表占用容量為i、使用前j個元素時的組合數。如果不存在k,那么dp[i][j]=dp[i][j-1]。
    直觀地看,這個復雜度是O(n^3),但是因為循環的結構是這樣的
for(int i=0;i<=target;i++) {
  for(int j=0;j<nums.length;j++) {
    // 對k做一次循環,計算dp
  ...
  }
}

假如把dp[i]看做每一步的累加結果,即dp[i]的含義是n種物品在容量i時的擺放方式數目,這時的轉移公式為:

  • dp[i] += dp[i-nums[j]],其中i-nums[j]>=0。

當然,此時的dp[i],與dp[i][j]已經不是一個含義了,dp[i]是j取最大時的dp[i][j],它的變化過程中體現了dp[i][j]。可以看出【組合問題】和原始的完全背包問題已經顯現出差異。

習題求解

組合問題

377. 組合總和 Ⅳ

class Solution {
    public int combinationSum4(int[] nums, int target) {
        if(nums==null || nums.length ==0) {
            return 0;
        }
        int dp[] = new int[target+1];
        for(int j=0;j<nums.length;j++) {
            dp[0] = 1;
        }
        for(int i=1;i<=target;i++) {
            for(int j=0;j<nums.length;j++) {
                if(i-nums[j] >= 0) {
                    dp[i] += dp[i-nums[j]];
                }
        }
        return dp[target];
    }
}

494. 目標和

可以看做元素是取正還是取反的背包問題。注意這一題進行坐標平移(+1000)和使用遞推式替代狀態轉移方程,復雜度會更低。后者即
將 dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]] 改寫為

  • dp[i][j + nums[i]] += dp[i - 1][j]
  • dp[i][j - nums[i]] += dp[i - 1][j]
    可以理解為通過上一層的基准值構造下一層的值。
    由於直接原地保存dp結果會造成干擾,優化解需要一個臨時數組。

518.零錢兌換 II

典型的完全背包問題,典型的優化方式。

class Solution {
    public int change(int amount, int[] coins) {
        if(amount == 0) {
            return 1;
        }
        if(amount<0) {
            return 0;
        }
        if(coins == null || coins.length == 0) {
            return 0;
        }

        int dp[] = new int[amount+1];
        dp[0] = 1;
        for(int i=0;i<coins.length;i++) {
            for (int j=coins[i]; j<=amount;j++) {
                dp[j] += dp[j-coins[i]];
            }
        }
        return dp[amount];
    }
}

true-false問題

416. 分割等和子集

0-1背包。變化點是求固定的dp[V]是否存在(true or false)。

class Solution {
    public boolean canPartition(int[] nums) {
        if(nums==null || nums.length == 0) {
            return false;
        }
        int sum = 0;
        for(int i=0;i<nums.length;i++) {
            sum+=nums[i];
        }
        if((sum & 1) == 1) {
            return false;
        }
        int half = sum>>1;

        // 0-1背包
        // 第i個數字, 和為j
        boolean dp[] = new boolean[half+1];
        dp[0] = true;
        for(int i=0;i<nums.length;i++) {
            for(int j=half;j>=0;j--) {
                if(j>=nums[i]) {
                    dp[j] = (dp[j] || dp[j-nums[i]]);
                }
                if(j==half && dp[j]) {
                    return true;
                }
            }
        }
        return false;
    }
}

139. 單詞拆分

直接套用參考文檔希望用一種規律搞定背包問題
中true-false * 完全背包 問題的公式:

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        if(s==null || s.isEmpty()) {
            return true;
        }
        if(wordDict == null ||wordDict.size() ==0) {
            return false;
        }

        boolean dp[] = new boolean[s.length()+1];
        dp[0] = true;
        for(int i=0;i<=s.length();i++) {
            for(int j=0;j<wordDict.size();j++) {
                String wj = wordDict.get(j);
                if(wj.length() <= i ) {
                    dp[i] = dp[i] || (dp[i-wj.length()] && wj.equals(s.subSequence(i-wj.length(),i)));
                }
            }
        }
        return dp[s.length()];
    }
}

最大最小問題

  • dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)

474. 一和零

二維的背包問題,限制了兩個維度,因此是O(mnl)的時間復雜度。因為第一遍沒想清楚,解法不粘貼了,請參考官方解。
為什么狀態轉移方程里有一個+1?因為取了一個新的元素,元素個數+1。

322. 零錢兌換

官方解的初始化方式理解起來不太直觀,因此我直接用Integer.MAX_VALUE來標識。

class Solution {
    public int coinChange(int[] coins, int amount) {
        if(amount==0) {
            return 0;
        }
        if(amount<0 || coins==null || coins.length == 0) {
            return -1;
        }
        int dp[] = new int[amount+1];
        dp[0] = 0;
        for(int i=1;i<=amount;i++) {
            dp[i] = Integer.MAX_VALUE;
        }
        for(int i=0;i<coins.length;i++) {
            for (int j=coins[i];j<=amount;j++) {
                if(dp[j-coins[i]] < Integer.MAX_VALUE) {
                    dp[j] = Math.min(dp[j], dp[j-coins[i]] + 1);
                }
            }
        }
        return dp[amount] == Integer.MAX_VALUE? -1 : dp[amount];
    }
}

參考文檔

希望用一種規律搞定背包問題
【算法總結】動態規划-背包問題


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM