作者:Rhythm_2019
创建时间:2021.04.04
修改时间:2021.04.05
Email:rhythm_2019@163.com
导读:本文主要讲述利用回溯算法生成数组的子集、组合和排列,三者都是在数组中不断做“选择元素”、“递归”、“取消选择”三件事,根据题目的不同要求加入一些条件约束(深度限制、判重)即可解决问题。文章最后总结了自己在写代码时所犯下的错误,通过本文自己也对回溯算法有了信的理解
通过阅读本文您可以解答下面算法题:
当然啦,最重要的是你在写回溯代码的时候的那种感觉,我在写代码的时候犯了很多错误,都放在文章的最后啦
<hr/>
我们先回顾一下什么是递归,简单来说就是自己调用自己,为什么自己调用自己就能解决问题呢?是因为问题存在重复的子问题,而且规模缩小到最小时问题的解是已知的。
不必将分治、递归、回溯华清界限,就当自己在写递归即可
下面是递归的模板
private void recursion(int level, int maxLevel, int param) {
// Terminator
// Process
// Drill down
// Restore
}
回溯算法其实就是再下探Drill down
后对数据进行恢复,也就是Restore
,从而达到不断试探但效果。在平时的算法练习中我常写递归,但是需要回溯的情况比较少(可能是我写的不多),比较常见的就是走迷宫(DFS染色)、生成子集、N皇后、数独等
子集
数组[1, 2]
的子集有[]、[1]、[2]、[1, 2]
四个,大家重点留意一下回溯的解法,其他解法大家看看就好
LeetCode 78 子集
给定一个数组nums
,生成其所有子集,我想到的思路有:
- 自顶向下:已知
[0, i]
的所有子集,当前数字2
可以选择加入他们,选择不加入他们,这样即可生成所有子集,我们把这个方法称为选择加入吧 - 对于任意元素都有两种选择,出现在集合里,不出现在集合里,这样就能生成所有子集,这个方法称之为选择出现吧
- 回溯算法,分别对数组中的元素做选自、递归、取消选择
先是第一种选择加入的思路
public List<List<Integer>> subsets(int[] nums) {
if (nums.length == 0) {
return new ArrayList<>();
}
return _subsets(nums.length - 1, nums);
}
public List<List<Integer>> _subsets(int level, int[] nums) {
// Terminator
if (level < 0) {
List<List<Integer>> ans = new ArrayList<>();
ans.add(new ArrayList<>());
return ans;
}
// Process & Drill dwon
// 获得前面的所有子集
List<List<Integer>> subsets = _subsets(level - 1, nums);
int size = subsets.size();
for (int i = 0; i < size; i++) {
List<Integer> list = new ArrayList<>(subsets.get(i));
list.add(nums[level]);
subsets.add(list);
}
return subsets;
}
第二种选择出现的思路
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
if (nums.length == 0) {
return ans;
}
Arrays.sort(nums);
_subsets(0, nums, new ArrayList<>(), new HashSet<>());
return ans;
}
public void _subsets(int level, int[] nums, ArrayList<Integer> list) {
// Terminator
if (level == nums.length) {
ans.add(list);
return;
}
// Process & Drill down
ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
// 出现
list1.add(nums[level]);
_subsets(level + 1, nums, list1, res);
// 不出现
_subsets(level + 1, nums, list2, res);
}
<hr/>
最后就是回溯算法,我们使用循环分别对数组中的元素做选择,调用递归函数,取消选择
private List<List<Integer>> ans = new ArrayList<>();
private List<List<Integer>> subsets(int[] nums) {
if (nums.length == 0) {
return ans;
}
_subsets(0, nums, new ArrayList<>());
return ans;
}
private void _subsets(int level, int[] nums, ArrayList<Integer> list) {
// 每一步都要保存结果
ans.add(new ArrayList<>(list));
for (int i = level; i < nums.length; i++) {
// 【选择】
list.add(nums[i]);
// 【递归】
_subsets(i + 1, nums, list);
// 【取消选择】
list.remove(level);
}
}
将其状态树画出来,大概是这样子的。途中一行代表递归的层级,黄色表示需要被保存的结果
LeetCode 90 子集 II
我一开始以位直接加一个哈希表对重复元素进行剪枝即可,折腾了半天才发现问题
图上浅灰色方块表示使用哈希表剪枝的元素,比如最右边灰色的1
由于和最前面的1
重复了,我们就不对他进行递归了。黄色表示结果,大家可以看到深灰色的两个元素重复了,这时由于包含重复元素,很自然的[1, 2]
和[2, 1]
是重复的只能保留一个
如果我对数组进行排序,问题就会得到解决
排序能够将重复元素放在一起,后面的元素就不会生成和前面数字相关的子集
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
if (nums.length == 0) {
return ans;
}
Arrays.sort(nums);
_subsets(0, nums, new ArrayList<>(), new HashSet<>());
return ans;
}
private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
ans.add(new ArrayList<>(list));
for (int i = level; i < nums.length; i++) {
if (visited.contains(nums[i])) {
continue;
}
visited.add(nums[i]);
list.add(nums[i]);
_subsets(i + 1, nums, list, new HashSet<>());
list.remove(level);
}
}
这里介绍了回溯的方法,我们也可以使用选择出现现的思路,不过也是需要先排序,然后计算重复元素个数,如果就一个,按照以前的逻辑兵分两路,如果超过n个(n > 1),说明存在重复元素,正确的逻辑应该是让数字出现0 ~ n
次
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
if (nums.length == 0) {
return ans;
}
Arrays.sort(nums);
_subsets(0, nums, new ArrayList<>(), new HashSet<>());
return ans;
}
private void _subsets(int level, int[] nums, ArrayList<Integer> list, Set<Integer> visited) {
// Terminator
if (level >= nums.length) {
ans.add(list);
return;
}
// Process & Drill down
int count = 1, index = level + 1;
while (index < nums.length && nums[level] == nums[index]) {
index++;
count++;
}
if (count == 1) {
// 没有重复
ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
ArrayList<Integer> list2 = (ArrayList<Integer>) list.clone();
// 出现
list1.add(nums[level]);
_subsets(level + 1, nums, list1, visited);
// 不出现
_subsets(level + 1, nums, list2, visited);
} else {
// 存在重复
ArrayList<Integer> list1 = (ArrayList<Integer>) list.clone();
// 不出现
_subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
for (int i = 1; i <= count; i++) {
list1.add(nums[level]);
// 出现i次
_subsets(level + count, nums, (ArrayList<Integer>) list1.clone(), visited);
}
}
}
组合
以前看动态规划的时候一直没弄明白排列和组合的区别,那题Coin Change i/ii
记忆犹新。对于一个数组c长度为n,其组合有C(n, m)
个,而排列数则有A(n, m)
个
LeetCode 77 组合
子集包含所有组合的情况,入参k
其实表示的是递归层数,所以我们只需要在子集的基础上添加一个终止条件即可
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
if (n < 0) {
return ans;
}
_combine(0, 1, n, k, new ArrayList<>());
return ans;
}
private void _combine(int level, int start, int n, int k, List<Integer> list) {
if (level == k) {
ans.add(list);
return;
}
for (int i = start; i <= n - (k - list.size()) + 1; i++) {
list.add(i);
_combine(level + 1, i + 1, n, k, new ArrayList<>(list));
list.remove(level);
}
}
大家看图
其实和子集的一毛一样,最右边灰色的3
被剪枝的主要是因为他凑不到2
个。
数组和数字的生成方式很类似,如果数组中存在重复元素相信大家都会处理了
不那么相关的题目还有:17. 电话号码的字母组合
排列
LeetCode 46 全排列
组合是子集的一部分,但是排列就不一样了,每次选择都能选前面的元素但又不能选到自己,所以我们可以创建一个哈希表来存储选择过的元素(这里选择保存其索引)
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
if (nums == null || nums.length == 0) {
return new ArrayList<>();
}
_permute(0,nums, new ArrayList<>(), new HashSet<>());
return ans;
}
private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> visited) {
if (level == nums.length) {
ans.add(List);
return;
}
for (int i = 0; i < nums.length; i++) {
if (visited.contains(i)) {
continue;
}
visited.add(i);
List.add(nums[i]);
_permute(level + 1, nums, new ArrayList<>(List), visited);
List.remove(nums[i]);
visited.remove(i);
}
}
画一下图大概就是这样
LeetCode 47 全排列 II
现在包含重复元素,我们可以再创建一个哈希表用于记录当前层已使用过的元素,这样重复的元素就被剪枝
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
if (nums == null || nums.length == 0) {
return new ArrayList<>();
}
Arrays.sort(nums);
_permute(0,nums, new ArrayList<>(), new HashSet<>());
return ans;
}
private void _permute(int level, int[] nums, List<Integer> List, Set<Integer> indexVisited) {
if (level == nums.length) {
ans.add(List);
return;
}
Set<Integer> contentVisited = new HashSet<>();
for (int i = 0; i < nums.length; i++) {
if (indexVisited.contains(i) || contentVisited.contains(nums[i])) {
continue;
}
contentVisited.add(nums[i]);
indexVisited.add(i);
List.add(nums[i]);
_permute(level + 1, nums, new ArrayList<>(List), indexVisited);
List.remove(level);
indexVisited.remove(i);
}
}
这里涉及两个哈希表:
indexVisited
:用来记录递归时走过的元素,他保证了下一次递归不再访问某元素,作用于全局,需要被一直传递的contentVisited
:用来保存某一层已遍历的元素,这样就能剪掉重复元素,作用域某一层级,所以不用传递
总结
这篇文章我大概写了两天,感觉还有些地方没说清楚,我觉得要把算法思想正确的表达出来是一件不容易的事,还需要多加理解和练习。对于上面这几个题目其实套路都差不多
private void recursion() {
// 终止条件,一般都是限定递归层数
// 循环做选择
for (int i = start; i <= n; i++) {
// 【选择】 将结果放入数组
// 【递归】 Drill down
// 【取消选择】 将选择移出数组
}
}
总结一下需要注意的几个点:
我们写
for
循环的时候怎么写,应该从哪里开始到哪里结束:如果是子集和组合,为了不重复选择自己之前选择的元素,我们应该指定开始下标,每进入一曾递归下标往后移动。对于组合如果提米规定了输出组合的长度我们可以适当剪枝。对于排列数,每次都可以选之前的元素,所以是从0开始
什么时候需要引入
HashSet
判重:不包含重复元素:子集和组合是不需要的,因为通过开始下标我们已经排除了之前选择过的元素。而排列数则需要记录递归路上选择过的索引(元素值也可以),这样就不会重复选到之前选过的元素,这个哈希表需要全局使用,需要被传递
包含重复元素:我们需要使用
HashSet
对当前层的重复元素进行剪枝,这个哈希表只作用于当前层,不需要传递所以包含重复元素时需要使用哈希表,而排列数本来就一定需要哈希表
- 对于结果
list
的传递需不需要clome
:当然需要,我们最好使用构造方法创建新的,而且要注意创建的时机,一定是创建后马上加入结果集,中间不能有任何add
和remove
操作 最后想说的是注意【选择】和【撤销选择】的方式,我曾经是这样写的代码
private void _combine(int level, int start, int n, int k, List<Integer> list) { // ... for (int i = start; i <= n - (k - list.size()) + 1; i++) { list.add(i); _combine(level + 1, i + 1, n, k, new ArrayList<>(list)); // 注意这里 list.remove(new Integer(i)); } }
这样写在元素不重复的情况下是没什么问题的,但是如果存在重复元素的话递归时会删掉第一个重复的元素,所以我们直接这样写
list.add(i); _combine(level + 1, i + 1, n, k, new ArrayList<>(list)); // 注意这里 list.remove(level);
这样删除元素才正确
<hr/>
我个人觉得算法挺有意思的,自己也听喜欢写题,细想一下可能是因为这些题目规模较小,但非常奇妙,你的代码让计算机变得灵活,学习成本也不会太高,所以我更喜欢算法。
之前面试遇到的一个题目,如何正确计算0.3 / 0.2
,已经脱了很久了,后面会给大家带来告计算机的小数和高精算法的内容,还有非常神奇的卡特兰数,大家可以期待一下。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。