综述 这类问题一般就是说 从 序列 nums
中 以给定规则取若干元素 有如下几种变体:
元素无重不可复选 ,即 nums
中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本 的形式
以组合为例,如果输入 nums = [2,3,6,7]
,和为 7 的组合应该只有 [7]
。
元素可重不可复选 ,即 nums
中的元素可以存在重复 ,每个元素最多只能被使用一次。
以组合为例,如果输入 nums = [2,5,2,1,2]
,和为 7 的组合应该有两种 [2,2,2,1]
和 [5,2]
。
元素无重可复选 ,即 nums
中的元素都是唯一 的,每个元素可以被使用若干次 。
以组合为例,如果输入 nums = [2,3,6,7]
,和为 7 的组合应该有两种 [2,2,3]
和 [7]
。
PS: 也可以说有第四种形式,即元素可重可复选 。但既然元素可复选,那又何必存在重复元素 呢?元素去重之后就等同于形式三,所以这种情况不用考虑。
排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化。但无论形式怎么变化,其本质就是穷举所有解 ,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。
元素无重不可复选
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { LinkedList<Integer> path = new LinkedList <>(); List<List<Integer>> res = new ArrayList <>(); public List<List<Integer>> subsets (int [] nums) { int startIdx = 0 ; backtracking(nums, startIdx); return res; } private void backtracking (int [] nums, int startIdx) { res.add(new LinkedList <>(path)); for (int i = startIdx; i < nums.length; i++) { int cur = nums[i]; path.add(cur); backtracking(nums, i + 1 ); path.removeLast(); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { public List<List<Integer>> combine (int n, int k) { List<List<Integer>> paths = new ArrayList <>(); List<Integer> path = new ArrayList <>(); int idx = 1 ; backtracking(idx, paths, path, n, k); return paths; } private void backtracking (int idx, List<List<Integer>> paths, List<Integer> path, int n, int k) { if (path.size() == k) { paths.add(new ArrayList <>(path)); return ; } for (int i = idx; i <= n; i++) { path.add(i); backtracking(i + 1 , paths, path, n, k); path.remove(path.size() - 1 ); } } }
剪枝 (只需要当元素不够时剪掉即可)-> n - (k - path.size()) + 1 当所需要的元素 > 可选选项可以提供的元素时,我们就无需再往后面进行计算了,直接剪掉
优化过程如下:
已经选择的元素个数:path.size();
所需需要的元素个数为: k - path.size();
列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())
在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { public List<List<Integer>> combine (int n, int k) { List<List<Integer>> paths = new ArrayList <>(); List<Integer> path = new ArrayList <>(); int idx = 1 ; backtracking(idx, paths, path, n, k); return paths; } private void backtracking (int idx, List<List<Integer>> paths, List<Integer> path, int n, int k) { if (path.size() == k) { paths.add(new ArrayList <>(path)); return ; } for (int i = idx; i <= n - (k - path.size()) + 1 ; i++) { path.add(i); backtracking(i + 1 , paths, path, n, k); path.remove(path.size() - 1 ); } } }
[2]
就是「路径」,记录你已经做过的选择;
[1,3]
就是「选择列表」,表示你当前可以做出的选择;
「结束条件」就是遍历到树的底层叶子节点,这里也就是选择列表为空的时候 。
全排列和上边的组合和子集不同的是,我们需要左边的元素,因此不能用startIdx
来避免掉重复元素,并且我们不能 重复选择一个元素,那么我们就需要一个额外的数据结构来跟踪哪些值是被用过了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { List<List<Integer>> res = new ArrayList <>(); LinkedList<Integer> path = new LinkedList <>(); Set<Integer> visited = new HashSet <>(); public List<List<Integer>> permute (int [] nums) { backtracking(nums); return res; } private void backtracking (int [] nums) { if (path.size() == nums.length) { res.add(new ArrayList <>(path)); return ; } for (int i = 0 ; i < nums.length; i++) { if (visited.contains(nums[i])) continue ; path.add(nums[i]); visited.add(nums[i]); backtracking(nums); path.removeLast(); visited.remove(nums[i]); } } }
注意hashset在这里是有局限性的因为这道题的元素不重复因此可以用hashset,但应该用boolean visited[]
更通用一些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { List<List<Integer>> res = new LinkedList <>(); LinkedList<Integer> track = new LinkedList <>(); boolean [] used; public List<List<Integer>> permuteUnique (int [] nums) { used = new boolean [nums.length]; backtrack(nums); return res; } void backtrack (int [] nums) { if (track.size() == nums.length) { res.add(new LinkedList (track)); return ; } for (int i = 0 ; i < nums.length; i++) { if (used[i]) { continue ; } track.add(nums[i]); used[i] = true ; backtrack(nums); track.removeLast(); used[i] = false ; } } }
总结 子集 还是 组合: 要求大小为 2 的所有组合,就是所有大小为 2 的子集
所以 组合和子集是一样的:大小为 k
的组合就是大小为 k
的子集 。
比如:
LC77:给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
就是:
给你输入一个数组 nums = [1,2..,n]
和一个正整数 k
,请你生成所有大小为 k
的子集。
也就是LC78:给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
这个组合和子集的唯一区别就是我们现在只需要输出第k层的记过,而不是在前序位置每条路径都加入
全排列的index: 排列问题本身就是让你穷举元素的位置,nums[i]
之后也可以出现 nums[i]
左边的元素,所以之前的那一套使用 startIndex
的就不行了,需要额外使用 used
数组或者hashSet
来标记哪些元素还可以被选择。
如果题目不让你算全排列,而是让你算元素个数为 k
的排列,怎么算?也很简单,改下 backtrack
函数的 base case,仅收集第 k
层的节点值即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void backtrack (int [] nums, int k) { if (track.size() == k) { res.add(new LinkedList (track)); return ; } for (int i = 0 ; i < nums.length; i++) { backtrack(nums, k); } }
元素可重不可复选
如图中所示,我们可以看到的是,同一层的相邻元素如果相同,我们就不需要再处理。
为了保证相邻元素是相同的我们就需要用一个sort
综上我们有:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution { List<List<Integer>> res = new ArrayList <>(); LinkedList<Integer> path = new LinkedList <>(); public List<List<Integer>> subsetsWithDup (int [] nums) { Arrays.sort(nums); backtracking(nums, 0 ); return res; } private void backtracking (int [] nums, int startIdx) { res.add(new LinkedList <>(path)); for (int i = startIdx; i < nums.length; i++) { if (i > startIdx && nums[i] == nums[i - 1 ]) { continue ; } path.addLast(nums[i]); backtracking(nums, i + 1 ); path.removeLast(); } } }
子集问题就是组合的一种问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { List<List<Integer>> res = new ArrayList <>(); LinkedList<Integer> path = new LinkedList <>(); int curSum = 0 ; public List<List<Integer>> combinationSum2 (int [] candidates, int target) { Arrays.sort(candidates); backtracking(candidates, target, 0 ); return res; } private void backtracking (int [] candidates, int target, int startIdx) { if (curSum == target) { res.add(new LinkedList <>(path)); return ; } if (curSum > target) return ; for (int i = startIdx; i < candidates.length; i++) { if (i > startIdx && candidates[i] == candidates[i - 1 ]) continue ; curSum += candidates[i]; path.add(candidates[i]); backtracking(candidates, target, i + 1 ); path.removeLast(); curSum -= candidates[i]; } } }
用used数组来跟踪哪些数在递归(向下生长时) 被用过,从而避免重复
i > 0 && nums[i - 1] == nums[i] && !used[i - 1]
主要是用来判断当前的数和之前是否相同,如果相同且之前在同一层 时候处理过,那么我们不需要再处理一遍,跳过即可
其实核心就是 保证相同元素在排列中的相对位置保持不变 。
效果: 当出现重复元素时,比如输入 nums = [1,2,2',2'']
,2'
只有在 2
已经被使用的情况下才会被选择,同理,2''
只有在 2'
已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class Solution { LinkedList<Integer> path = new LinkedList <>(); List<List<Integer>> res = new ArrayList <>(); boolean [] used; public List<List<Integer>> permuteUnique (int [] nums) { used = new boolean [nums.length]; Arrays.sort(nums); backtracking(nums); return res; } private void backtracking (int [] nums) { if (nums.length == path.size()) { res.add(new ArrayList <>(path)); return ; } for (int i = 0 ; i < nums.length; i++) { if (used[i]) continue ; if (i > 0 && nums[i - 1 ] == nums[i] && !used[i - 1 ]) { continue ; } path.addLast(nums[i]); used[i] = true ; backtracking(nums); path.removeLast(); used[i] = false ; } } }
元素无重可复选 对于之前的解法我们是 startIdx
每一次传入i + 1
那么下一层回溯树就是从 i + 1
开始,从而保证当前元素不会被重复使用:
对于本题来说,其实就是修改了startIdx
之前是 i + 1
现在是 i
相当于给之前的回溯树添加了一条树枝,在遍历这棵树的过程中,一个元素可以被无限次使用:
⚠️ 需要注意的是,这样会导致树无限生长,因此我们还需要额外的base case 来结束递归: if (curSum > target) return;
即路径和大于 target
时就没必要再遍历下去了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { LinkedList<Integer> path = new LinkedList <>(); List<List<Integer>> res = new ArrayList <>(); int curSum = 0 ; public List<List<Integer>> combinationSum (int [] candidates, int target) { backtracking(candidates, target, 0 ); return res; } private void backtracking (int [] candidates, int target, int startIdx) { if (curSum == target) { res.add(new LinkedList <>(path)); return ; } if (curSum > target) return ; for (int i = startIdx; i < candidates.length; i++) { curSum += candidates[i]; path.addLast(candidates[i]); backtracking(candidates, target, i); curSum -= candidates[i]; path.removeLast(); } } }
排列 力扣上没有类似的题目,我们不妨先想一下,nums
数组中的元素无重复且可复选的情况下,会有哪些排列?
比如输入 nums = [1,2,3]
,那么这种条件下的全排列共有 3^3 = 27 种:
标准的全排列算法利用 used
数组进行剪枝,避免重复使用同一个元素。如果允许重复使用元素的话,直接放飞自我,去除所有 used
数组的剪枝逻辑 就行了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { List<List<Integer>> res = new LinkedList <>(); LinkedList<Integer> track = new LinkedList <>(); public List<List<Integer>> permuteRepeat (int [] nums) { backtrack(nums); return res; } void backtrack (int [] nums) { if (track.size() == nums.length) { res.add(new LinkedList (track)); return ; } for (int i = 0 ; i < nums.length; i++) { track.add(nums[i]); backtrack(nums); track.removeLast(); } } }
总结 有很多的共性,其实树都是差不多的只是多了一些剪枝操作以及base case略有不同
元素无重不可复选 即 nums
中的元素都是唯一的,每个元素最多只能被使用一次
对于组合或者子集问题:
1 2 3 4 5 6 7 8 9 10 11 12 void backtrack (int [] nums, int start) { for (int i = start; i < nums.length; i++) { track.addLast(nums[i]); backtrack(nums, i + 1 ); track.removeLast(); } }
对于排列问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void backtrack (int [] nums) { for (int i = 0 ; i < nums.length; i++) { if (used[i]) { continue ; } used[i] = true ; track.addLast(nums[i]); backtrack(nums); track.removeLast(); used[i] = false ; } }
元素可重不可复选 即 nums
中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝 ,backtrack
核心代码如下:
对于组合或者子集问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Arrays.sort(nums); void backtrack (int [] nums, int start) { for (int i = start; i < nums.length; i++) { if (i > start && nums[i] == nums[i - 1 ]) { continue ; } track.addLast(nums[i]); backtrack(nums, i + 1 ); track.removeLast(); } }
对于排列问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Arrays.sort(nums); void backtrack (int [] nums) { for (int i = 0 ; i < nums.length; i++) { if (used[i]) { continue ; } if (i > 0 && nums[i] == nums[i - 1 ] && !used[i - 1 ]) { continue ; } used[i] = true ; track.addLast(nums[i]); backtrack(nums); track.removeLast(); used[i] = false ; } }
元素无重可复选 即 nums
中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可 ,backtrack
核心代码如下:
对于组合或者子集问题:
1 2 3 4 5 6 7 8 9 10 11 12 void backtrack (int [] nums, int start) { for (int i = start; i < nums.length; i++) { track.addLast(nums[i]); backtrack(nums, i); track.removeLast(); } }
对于排列问题:
1 2 3 4 5 6 7 8 9 10 void backtrack (int [] nums) { for (int i = 0 ; i < nums.length; i++) { track.addLast(nums[i]); backtrack(nums); track.removeLast(); } }
其他例题 组合 和77题几乎一模一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { int curSum = 0 ; public List<List<Integer>> combinationSum3 (int k, int n) { List<Integer> path = new ArrayList <>(); List<List<Integer>> paths = new ArrayList <>(); backTracking(path, paths, k, n, 1 ); return paths; } private void backTracking (List<Integer> path, List<List<Integer>> paths, int k, int n, int startIdx) { if (path.size() == k) { if (curSum == n) { paths.add(new ArrayList <>(path)); } return ; } for (int i = startIdx; i <= 9 ; i++) { path.add(i); curSum += i; backTracking(path, paths, k, n, i + 1 ); path.remove(path.size() - 1 ); curSum -= i; } } }
剪枝 这道题剪枝有两个: 剩余元素的剪枝 + 求和的剪枝
剩余元素的剪枝:
即,可选列表中的元素 < 要用的元素
求和的剪枝:
即,当前和已经大于了题目要求的和
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { int curSum = 0 ; public List<List<Integer>> combinationSum3 (int k, int n) { List<Integer> path = new ArrayList <>(); List<List<Integer>> paths = new ArrayList <>(); backTracking(path, paths, k, n, 1 ); return paths; } private void backTracking (List<Integer> path, List<List<Integer>> paths, int k, int n, int startIdx) { if (path.size() == k) { if (curSum == n) { paths.add(new ArrayList <>(path)); } return ; } for (int i = startIdx; i <= 9 - (k - path.size()) + 1 ; i++) { path.add(i); curSum += i; if (curSum > n) { curSum -= i; path.remove(path.size() - 1 ); return ; } backTracking(path, paths, k, n, i + 1 ); path.remove(path.size() - 1 ); curSum -= i; } } }
组合的应用题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class Solution { Map<Character, String> phoneMap = new HashMap <>(); public List<String> letterCombinations (String digits) { phoneMap.put('2' , "abc" ); phoneMap.put('3' , "def" ); phoneMap.put('4' , "ghi" ); phoneMap.put('5' , "jkl" ); phoneMap.put('6' , "mno" ); phoneMap.put('7' , "pqrs" ); phoneMap.put('8' , "tuv" ); phoneMap.put('9' , "wxyz" ); List<String> res = new LinkedList <>(); StringBuilder cur = new StringBuilder (); int idx = 0 ; backtracking(digits, cur, res, idx); return res; } private void backtracking (String digits, StringBuilder cur, List<String> res, int idx) { if (idx > digits.length() - 1 ) { res.add(cur.toString()); return ; } char digit = digits.charAt(idx); String digit2Chars = phoneMap.get(digit); for (int i = 0 ; i < digit2Chars.length(); i++) { char target = digit2Chars.charAt(i); cur.append(target); backtracking(digits, cur, res, idx + 1 ); cur.deleteCharAt(cur.length() - 1 ); } } }
子集 首先 本题在寻找 递增子序列 因此本题相当于寻找一种特殊的子集 ,特殊的地方在于 子集中的元素是递增的
但是相较于子集问题(特指 90.子集2)本题特殊点在于我们不能再通过排序来过滤掉重复元素,因为这会破坏数组原本的顺序。
因此我们需要一个 hashset 来帮助我们寻找哪些元素在本层 中用过,注意这里只需要看本层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class Solution { LinkedList<Integer> path = new LinkedList <>(); List<List<Integer>> res = new ArrayList <>(); public List<List<Integer>> findSubsequences (int [] nums) { backtracking(nums, 0 ); return res; } private void backtracking (int [] nums, int startIdx) { if (path.size() >= 2 ) { res.add(new LinkedList <>(path)); } Set<Integer> used = new HashSet <>(); for (int i = startIdx; i < nums.length; i++) { if (!path.isEmpty() && path.getLast() > nums[i]) { continue ; } if (used.contains(nums[i])) { continue ; } path.addLast(nums[i]); used.add(nums[i]); backtracking(nums, i + 1 ); path.removeLast(); } } }
为什么回溯前 used.add(nums[i]);
回溯后没有撤销? 因为 used
是记录本层 元素是否重复使用,新的一层used都会重新定义(清空) ,所以要知道used只负责本层!