Leetcode組合總和系列——回溯(剪枝優化)+動態規劃
組合總和 I
給定一個無重複元素的陣列 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。
candidates 中的數字可以無限制重複被選取。
說明:
所有數字(包括 target)都是正整數。
解集不能包含重複的組合。示例 1:
輸入:candidates = [2,3,6,7], target = 7,
所求解集為:
[
[7],
[2,2,3]
]來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/combination-sum
此題要求解出所有可能的解,則需要用回溯法去回溯嘗試求解,我們可以畫一棵解空間樹:
圖中綠色節點表示找到了一種可行解,而紅色的節點表示到這個節點的時候組合總和的值已經大於target了,無需繼續向下嘗試,直接返回即可。
因為題目要求解集無重複,即2,2,3
和3,2,2
應該算作同一種解,所以我們在回溯的時候應該先對candidates
陣列排序,然後每次只向下回溯大於等於自己的節點。
觀察解空間樹我們發現:當某一層中第一次出現紅色節點或綠色節點後,後面的節點將全變為紅色,因為陣列是經過排序的,任意節點後面的節點都是大於此節點的(candidates
陣列無重複元素),所以當出現一個紅/綠色節點後,後面的節點不必再繼續檢查,直接剪枝
即可。
剪枝後的解空間樹如下:
這樣看整棵解空間樹就小多了,下面直接上程式碼:
Java版本的回溯解法程式碼
class Solution {
List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates);
dfs(candidates,target,0,new ArrayList());
return result;
}
public void dfs (int[] candidates, int target, int currSum, List<Integer> res) {
if (currSum == target) {
result.add (new ArrayList(res));
return;
}
for (int i = 0; i < candidates.length; i++) {
if (currSum + candidates[i] > target) {
return;
}
int size = res.size();
if (size==0 || candidates[i] >= res.get(size-1)) {
res.add(candidates[i]);
dfs(candidates, target, currSum+candidates[i],res);
res.remove(size);
}
}
}
}
Go版本的回溯解法程式碼
func combinationSum(candidates []int, target int) (result [][]int) {
sort.Ints(candidates)
var dfs func(res []int, currSum int)
dfs = func(res []int, currSum int) {
if currSum == target {
result = append(result, append([]int(nil), res...))
return
}
for i := 0; i < len(candidates); i++ {
if currSum + candidates[i] > target {
return
}
if len(res) == 0 || candidates[i] >= res[len(res)-1] {
length := len(res)
res = append(res, candidates[i])
dfs(res, currSum+candidates[i])
res = res[:length]
}
}
}
var res []int
dfs(res, 0)
return
}
組合總和 II
給定一個陣列 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。
candidates 中的每個數字在每個組合中只能使用一次。
說明:
所有數字(包括目標數)都是正整數。
解集不能包含重複的組合。示例 1:
輸入: candidates = [2,5,2,1,2], target = 5, 所求解集為: [ [1,2,2], [5] ]
來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/combination-sum-ii
和組合總和I不同的是這個題目中的candidates
陣列中出現了重複數字,而且每個數字只能使用一次,我們對這個陣列進行排序,每次回溯進下一層的時候都從上一層訪問的節點的下一個開始訪問。畫出的解空間樹如下:
觀察解空間樹發現還是有重複的解出現,比如1,2,2
出現了兩次,這種問題我們可以通過兩種方法來解決
-
每次當找到一個可行解後,判斷看是否此解已經存在於之前發現的解中了,如果存在就丟棄
-
剪枝,同一層中同樣的節點只能出現一次,這樣不但整個解空間樹會小很多,而且避免了判斷時候的開銷,下面是剪枝後的解空間樹
具體剪枝的方法我們可以通過增加一個visit集合,記錄同一層是否出現過相同節點,如果出現過就不再次訪問此節點。
我對兩種解法做了對比,執行的時間效率對比如下:第一種對應上面的結果,第二種解法對應下面的結果
下面貼出第二種解法的程式碼:
Java版本的回溯解法程式碼
class Solution {
public static void trace(List<List<Integer>> result, List<Integer> res, int[] candidates, int target, int curr, int index) {
if (curr == target) {
//得到預期目標
result.add(new ArrayList<>(res));
}
Set<Integer> visit = new HashSet<>();
for (int j = index+1; j < candidates.length; j++) {
if (visit.contains(candidates[j])) {
continue;
} else {
visit.add(candidates[j]);
}
if (curr + candidates[j] > target){
//此路不通,後路肯定也不通
break;
} else {
//繼續試
res.add(candidates[j]);
int len = res.size();
trace(result, res,candidates,target,curr+candidates[j],j);
res.remove(len-1);
}
}
}
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<Integer> res = new ArrayList<>();
List<List<Integer>> result = new ArrayList<List<Integer>>();
int curr = 0;
Arrays.sort(candidates);
trace(result, res,candidates,target,curr,-1);
return result;
}
}
Go版本的回溯解法程式碼
func combinationSum2(candidates []int, target int) (result [][]int) {
sort.Ints(candidates)
var dfs func(res []int, currSum, index int)
dfs = func(res []int, currSum, index int) {
if currSum == target {
result = append(result, append([]int(nil), res...))
return
}
var set []int
for i := index+1; i < len(candidates); i++ {
if isExist(set, candidates[i]) {
continue
} else {
set = append(set, candidates[i])
}
if currSum + candidates[i] > target { //遇到紅色節點,直接跳出迴圈,後面也無需嘗試
break
} else {
res = append(res, candidates[i])
dfs(res, currSum+candidates[i], i)
res = res[:len(res)-1]
}
}
}
var res []int
dfs(res, 0, -1)
return
}
func isExist(set []int, x int) bool {
for _, v := range set {
if v == x {
return true
}
}
return false
}
組合總和 III
找出所有相加之和為 n 的 k 個數的組合。組合中只允許含有 1 - 9 的正整數,並且每種組合中不存在重複的數字。
說明:
所有數字都是正整數。
解集不能包含重複的組合。示例 1:
輸入: k = 3, n = 7
輸出: [[1,2,4]]示例 2:
輸入: k = 3, n = 9
輸出: [[1,2,6], [1,3,5], [2,3,4]]來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/combination-sum-iii
此題的candidates陣列不再由題目給出,而是由[1,9]
區間裡的陣列成,且每種組合不存在重複的數,則每種數字只能用一次,我們還是繼續採用回溯法,不同的是限制瞭解集中數字的個數。而且每層的回溯都從上一層訪問的節點的下一個節點開始。
如果使用暴力法去回溯,將得到下面這樣的一棵解空間樹(由於樹過大,所以右邊被省略)
因為題目中規定了樹的深度必須是k,紅色表示不可能的解,綠色表示可行解,紫色表示到了規定的層數k,但總和小於n的情況。
觀察上述的解空間樹我們發現了剪枝的方法:
- 對於紅色節點之後的節點直接裁剪掉
- 但需要注意紫色的雖然不符合題意,但由於後面可能出現正確解,所以不能剪掉
- 根據樹的深度來剪,上面兩個題中都沒有規定深度,此題還可以根據深度來剪,如果超過規定深度就不繼續向下探索
畫出剪枝後的解空間樹(同樣省略了右邊的樹結構):
Java版本的回溯解法程式碼
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
List<Integer> res = new ArrayList<>();
List<List<Integer>> result = new ArrayList<List<Integer>>();
trace(result,res,0,k,n);
return result;
}
public void trace (List<List<Integer>> result, List<Integer> res, int curr, int k, int n) {
if (res.size() == k && curr == n) {
result.add(new ArrayList<>(res));
return;
} else if (res.size() < k && curr < n) {
for (int i = 1; i < 10; i++) {
int len = res.size();
if (len == 0 || i > res.get(len - 1)) {
res.add(i);
trace(result,res,curr+i,k,n);
res.remove(len);
}
}
} else { //樹的深度已經大於規定的k
return;
}
}
}
Go版本的回溯解法程式碼
func combinationSum3(k int, n int) (result [][]int) {
var dfs func(res []int, currSum int)
dfs = func(res []int, currSum int) {
if len(res) == k && currSum == n {
result = append(result, append([]int(nil), res...))
return
} else if len(res) < k && currSum < n {
i := 1
if len(res) > 0 {
i = res[len(res)-1]+1
}
for ; i < 10; i++ {
res = append(res, i)
dfs(res, currSum+i)
res = res[:len(res)-1]
}
} else { //搜尋的深度已經超過了k
return
}
}
var res []int
dfs(res, 0)
return
}
組合總和 IV
給你一個由 不同 整陣列成的陣列 nums ,和一個目標整數 target 。請你從 nums 中找出並返回總和為 target 的元素組合的個數。
題目資料保證答案符合 32 位整數範圍。
示例 1:
輸入:nums = [1,2,3], target = 4
輸出:7
解釋:
所有可能的組合為:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
請注意,順序不同的序列被視作不同的組合。示例 2:
輸入:nums = [9], target = 3
輸出:0來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/combination-sum-iv
這個道題目並沒有像上面一樣要求我們找出所有的解集,而是隻要求解解的個數,這時如果我們再採用回溯法去求解無疑是造成了很大的浪費,所以考慮使用動態規劃,只求解個數而不關注所有解的具體內容。
題目允許數字的重複,且對順序敏感(即不同順序視做不同解),這樣我們可以通過讓每一個nums
陣列中數num
做解集的最後一個數,這樣當x作為解集的最後一個數,解集就為num1,num2,num3......x
如果dp陣列的dp[x]
表示target為x時候的解集個數,那麼我們只需要最後求解dp[target]
即可。
那麼當最後一個數為x時對應的解集個數就為dp[target-x]
個,讓nums
中的每一個數做一次最後一個數,將結果相加就是dp[target]
的值,不過需要注意的是dp[0] = 1
表示target為0時只有一種解法(即一個數都不要),dp的下標必須為非負數。
下面是狀態轉移方程(n為nums最後一個元素的下標):
Java版本的動態規劃解法程式碼
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0] = 1;
for (int i = 1; i <= target; i++) {
for (int num:nums) {
int tmp = i - num;
if (tmp >= 0) {
dp[i] += dp[tmp];
}
}
}
return dp[target];
}
}
Go版本的動態規劃解法程式碼
func combinationSum4(nums []int, target int) int {
dp := make([]int, target+1)
dp[0] = 1
for i := 1; i <= target; i++ {
for _, v := range nums {
tmp := i - v
if tmp >= 0 {
dp[i] += dp[tmp]
}
}
}
return dp[target]
}