一、組合總和問題
最近在看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件的兩種情況下的最大值
優化:
- 觀察狀態轉移方程時可以發現,每次都直接使用i-1行的結果來構造第i行的結果,那么只需要存儲一行即可。且在遍歷時,必須使用倒序遍歷j->1防止本輪的變化覆蓋到上一輪的結果上去,導致這一變化被再次取出來。
- 保存當前行的最大值,那么這個最大值在求解最后一行時即為所求的結果。
去掉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];
}
}