这一节我们来谈「剪枝」这个话题。在上一节我们分析了,「回溯算法」的时间复杂度一般是指数级别的,这个算法的时间复杂度随着数据的增加增长是很快的。
由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,这一分支就可以跳过,这一步操作就是在一棵树上剪去一个枝叶,被人们很形象地称之为剪枝。
回溯算法会大量应用「剪枝」技巧达到以加快搜索速度。这里有几点提示:
1、有时,需要做一些预处理工作(例如排序)才能达到剪枝的目的。虽然预处理工作虽然也消耗时间,但和剪枝能够节约的时间相比是微不足道的。因此,能预处理的话,就尽量预处理;
2、正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。
对于一些特定的问题也必须通过「剪枝」才能搜索到精准的答案。
下面我们来看一下,「力扣」第 47 号问题,全排列问题的第 2 道问题,这道题与第 46 号问题的区别在于:输入数组有重复的元素,因此搜索出来的结果也一定会有重复的列表。
如何删除这些重复的列表呢?一种想法是按照第 46 题的解法,直接在搜索结果当中进行删除,但是我们只要具体操作一下就会发现这样的操作:是不方便实现的。连放进哈希表里去重都是不方便实现的。
- 列表元素的去重一个比较容易想到的方案是:对列表元素进行排序然后再逐个比对;
- 而另一种更具操作性的方案是:我们先对输入数组进行排序,在搜索的过程当中我们就能够发现能够产生重复问题的分支,我们来看下这个问题的树形结构。
我们先按照第 46 题的做法,把树形图画全,然后去发现产生重复分支的原因:在搜索起点第 2
个 1
进行遍历的时候,我们会发现由于第 1
个 1
已经取消了选择,那么接下来在搜索的过程当中,第 1
个 1
还会再次出现,因此在这一个分子所产生的结果就肯定会是重复的。
于是我们就需要捕捉到,即将要产生重复分支的这个瞬间,然后将它跳过。跳过的语句经常是 continue
或者是 break
这样的循环控制的语句。
对于这道问题,就是「当搜索起点去它前一个分支的搜索起点一样,并且前一个分支刚刚好被取消了选择的时候」,在这里「刚刚好被取消了选择」大家对比一下这个分支,搜索起点 1 和上一轮的搜索起点 1 是一样的,但是上一轮的 1 是刚刚被选择了。
依然是从「深度优先遍历」的角度去考虑这个「回溯算法」。
下面我们来看一下代码,我们可以在「力扣」第 46 题的基础上稍作修改。
- 首先对输入数组进行排序,这是剪枝的前提;
- 然后我们需要加上这一段代码:
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
Java 代码:
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
public class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
int len = nums.length;
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
// 排序(升序或者降序都可以),排序是剪枝的前提
Arrays.sort(nums);
boolean[] used = new boolean[len];
// 使用 Deque 是 Java 官方 Stack 类的建议
Deque<Integer> path = new ArrayDeque<>(len);
dfs(nums, len, 0, used, path, res);
return res;
}
private void dfs(int[] nums, int len, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; ++i) {
if (used[i]) {
continue;
}
// 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
// 写 !used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
path.addLast(nums[i]);
used[i] = true;
dfs(nums, len, depth + 1, used, path, res);
// 回溯部分的代码,和 dfs 之前的代码是对称的
used[i] = false;
path.removeLast();
}
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums = {1, 1, 2};
List<List<Integer>> res = solution.permuteUnique(nums);
System.out.println(res);
}
}
这就是这一节的内容,大家也发现,其实我们在讲解的时候,一直都在和大家用图形说话,的确,回溯算法的问题很多时候就是和图形相关的,并且很多时候回溯算法就是在一个树形问题上进行遍历。
因此,做出回溯算法的第 1 步,是:
把题目抽象成为树形问题,然后思考程序如何在这个树形问题上进行遍历,在适当的时候,需要删除一些枝叶,以加快搜索结果。
在草稿纸上画出树形图是非常关键的一步。
画出图形,就能够帮助我们更加深刻地理解题目的条件,然后用一两个具体的例子代入,就能够发现剪枝的条件。
其实不管是多么高深的算法,其实都是人写的,程序只是帮助人们实现算法的思想。因此首先我们先去想人是怎么做这个问题的时候,往往就能打开思路。
下面提供几个相关的练习,这些练习都是「力扣」上非常经典的使用「回溯」算法解决的问题。
练习
1、「力扣」第 39 题、第 40 题:组合问题;
2、「力扣」第 78 题、第 90 题:子集问题;
3、「力扣」第 77 题:组合问题。
做这些问题的前提,依然是画出这些问题的「树形结构」,以方便我们打开思路。
下一节,我们来看一些一种特殊的回溯问题,这些问题看起来没有回溯,但实际上是回溯的问题。这就是字符串上的回溯问题。