回忆往前挪十年,那一年的数学正好学到排列组合,那一天最火的电影是《那些年一起追过的女孩》。课间广播台播放的是《那些年一起追过的女孩》主题曲,其中有一句:黑板上排列组合你舍得解开吗?巧的是黑板上的题目确实也是排列组合。其中一个同学说:哎,现在黑板上不正好是排列组合吗?但是没人理他,不过这个场景我永远的记住了。
扯远了。
在leetcode上有一堆排列、组合、子集的问题,所有这些排列组合的题目的解法都是同一个算法框架——回溯。看完这篇文章,下面这些排列组合的题目就全部可以AC了。
- 全排列:
https://leetcode-cn.com/problems/permutations/
- 全排列2:
https://leetcode-cn.com/problems/permutations-ii/
- 字符串的排列:
https://leetcode-cn.com/problems/zi-fu-chuan-de-pai-lie-lcof/
- 组合:
https://leetcode-cn.com/problems/combinations/
- 组合总和:
https://leetcode-cn.com/problems/combination-sum/
- 组合总和2:
https://leetcode-cn.com/problems/combination-sum-ii/
- 组合总和3:
https://leetcode-cn.com/problems/combination-sum-iii/
- 组合总和4:
https://leetcode-cn.com/problems/combination-sum-iv/
- 子集:
https://leetcode-cn.com/problems/subsets/
- 子集2:
https://leetcode-cn.com/problems/subsets-ii/
0.回溯框架前情提要
回溯说起来可能比较高大上。但是如果说深度优先遍历,你肯定会说,这个我知道这个我知道,我我我我(见卡姆表情包)。
对,回溯只是一种变了形的深度优先遍历,深度优先遍历在遍历的时候直接无差别对各种情况开枝散叶,直到所有情况都遍历完成。而回溯是更有章法一点的深度优先遍历,他使用同一个列表存储选择路径,当完成选择之后还会把这个选取去掉换成另一个选择,使用这样方式放所有可能性遍历完成。
总结一下,他们的区别主要就两个。
- 深度优先遍历是直接对各种情况开枝散叶遍历,回溯是通过选择和回退选择遍历所有情况。
- 深度优先遍历使用的是不同列表,回溯使用的是同一个列表。
下面有9道题目,咱们先用第一道题全排列讲一下普通深度优先遍历和回溯的区别,并引出回溯框架。后面就调几道套用回溯框架来展示结果了。
1.全排列
题意
Given an array nums of distinct integers, return all the possible permutations. You can return the answer in any order.
- permutations 美[ˌpɜːrmjʊ'teɪʃn] 英[pɜːmjʊ'teɪʃ(ə)n] n. 变化组合 / 排列 / 置换 / <英>挑选
翻译:给定一个不同数字的列表,返回所有可能的排列。你可以以任何顺序返回。
示例:
输入: [1,2,3]
输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
思路推演
这种题目我们在上中学的时候就会在演算本上画出来,第一位选了1之后,我们第二位可以选择2或者3,第二位选择了2之后,第三位只能选择3,第二位选择了3之后,第三位只能选择2。
根据上面的思路我们可以写出深度优先遍历的代码,一次选择一个数字通过递归往深处选择,直到选择的数量等于三为止,还需要考虑不能有重复的数字。
我们看一下代码是这样写的:
class Solution {
public List<List<Integer>> permute(int[] nums) {
//1.使用空列表开始深度优先遍历
dfs(nums, new ArrayList<>());
return res;
}
List<List<Integer>> res = new ArrayList<>();
private void dfs(int[] nums, List<Integer> list) {
//2.终止条件:如果列表选择的数字超过了数组数量,则终止选择。
if(list.size() >= nums.length) {
res.add(list);
return;
}
//3.遍历所有可能的选择
for(int i = 0; i < nums.length; i++) {
//4.过滤条件:如果之前的选择包含了这个数字则不选择了。
if(!list.contains(nums[i])) {
//5.下面三行代码则是做出当前的选择,继续往深处遍历,直到终止条件为止。
List<Integer> curr = new ArrayList<>(list);
curr.add(nums[i]);
dfs(nums, curr);
}
}
}
}
这样一个熟悉的深度优先遍历就完成了。
看完代码我们可以思考,为什么不直接使用深度优先遍历解题而进化出一个回溯的概念呢?
可以看到在注释5那里我们每一次做出选择都需要创建一个新的列表进入下一层,这样对空间的消耗不是很好。
知道了普通深度优先遍历的缺点,现在我们引申出回溯的概念,在注释5那里我们略作改动,使用同一个列表先做选择,等选择完成之后再回退该选择,达到只使用一个列表的目的。见如下代码,只有注释2和注释5存在改动。
class Solution {
public List<List<Integer>> permute(int[] nums) {
//1.使用空列表开始深度优先遍历
dfs(nums, new ArrayList<>());
return res;
}
List<List<Integer>> res = new ArrayList<>();
private void dfs(int[] nums, List<Integer> list) {
//2.终止条件:如果列表选择的数字超过了数组数量,则终止选择。
if(list.size() >= nums.length) {
res.add(new ArrayList<>(list));
return;
}
//3.遍历所有可能的选择
for(int i = 0; i < nums.length; i++) {
//4.过滤条件:如果之前的选择包含了这个数字则不选择了。
if(list.contains(nums[i])) {
continue;
}
//5.做出当前的选择
list.add(nums[i]);
//6.进入下一层计算
dfs(nums, list);
//7.下一层所有选择都计算完之后,之后回退该选择。
list.remove(list.size() - 1);
}
}
}
上述代码就是最基本的回溯框架代码了,关键点在于2终止条件,3遍历所有可能,4过滤条件也就是剪枝,剪去一些没有必要的操作降低耗时,5做出选择,6进入下一层遍历,7在这一层回退该选择做出其他选择。
好的,回溯分类下最最经典的题目全排列就打完收工了。
2. 组合总和3
做完了最经典的全排列,我们选一个组合的题目组合总和3。
题意
Find all valid combinations of k numbers that sum up to n such that the following conditions are true:
Only numbers 1 through 9 are used. Each number is used at most once. Return a list of all possible valid combinations. The list must not contain the same combination twice, and the combinations may be returned in any order.
- combinations 组合
- conditions 情况
翻译:寻找总和为n的k个数字的所有有效组合,并需要满足下列条件:
- 只能使用1-9的数字。
- 每个数字最多只能被使用1次。
- 返回所有有可能的组合。
- 不能返回相同的组合两次,一个组合可以返回任意排序。
思路推演
这次直接把上面的框架拿过来,直接对号入座就好了。
- 终止条件:数字个数等于或者大于k了,总和等于或者大于n了。
- 需要遍历的所有可能:1-9。
- 过滤条件:因为每个数字最多遍历一次,所以新一次的遍历要在之前的下标之后开始遍历。
- 选择与回退:首先是列表的选择与回退。其次因为这里需要计算总和,所以需要实时计算总和和回退总和,避免每次当前选择的路径计算总和浪费时间复杂度。
然后把上面说的这些套到框架上,代码如下,具体见注释。
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
dfs(k, n, new ArrayList<>(), 1);
return res;
}
int sum = 0;
List<List<Integer>> res = new ArrayList<>();
private void dfs(int k, int n, List<Integer> path, int start) {
// 终止条件:数字个数等于或者大于k了,总和等于或者大于n了。
if(sum == n && path.size() == k && !res.contains(path)) {
res.add(new ArrayList<>(path));
return;
}
if(path.size() >= k || sum > n) {
return;
}
//需要遍历的所有可能:1-9。
//过滤条件:因为每个数字最多遍历一次,所以新一次的遍历要在之前的下标之后开始遍历。
for(int i = start; i < 10; i++) {
//选择与回退:首先是列表的选择与回退。其次因为这里需要计算总和,所以需要实时计算总和和回退总和,避免每次当前选择的路径计算总和浪费时间复杂度。
path.add(i);
sum += i;
dfs(k, n, path, i + 1);
path.remove(path.size() - 1);
sum -= i;
}
}
}
打完收工~
总结
把模板中的几个问题(终止条件,所有可能性,过滤条件,选择与回退)思考清楚,往代码里一套基本问题就解决了。
最开始陈列的十道题目,按照这个套路刷下来,几分钟一道不成问题,就不一一分析了。(主要是懒)
如果喜欢请关注——理解并背诵君(公众号同名)