圖解Leetcode組合總和系列——回溯(剪枝優化)+動態規劃

頭髮是我最後的倔強發表於2021-04-29

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,33,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出現了兩次,這種問題我們可以通過兩種方法來解決

  1. 每次當找到一個可行解後,判斷看是否此解已經存在於之前發現的解中了,如果存在就丟棄

  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的情況。

觀察上述的解空間樹我們發現了剪枝的方法:

  1. 對於紅色節點之後的節點直接裁剪掉
  2. 但需要注意紫色的雖然不符合題意,但由於後面可能出現正確解,所以不能剪掉
  3. 根據樹的深度來剪,上面兩個題中都沒有規定深度,此題還可以根據深度來剪,如果超過規定深度就不繼續向下探索

畫出剪枝後的解空間樹(同樣省略了右邊的樹結構):

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最後一個元素的下標):

\[dp[i]= \begin{cases} 1& \text{i=0}\\ \sum_{j=0}^n\ dp[target-nums[j]& \text{i!=0 && target-nums[j] > 0} \end{cases} \]

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]
}

相關文章