引言
在正式谈论回溯算法以前,我们不妨以一道经典算法题作为引入。LeetCode 46. 全排列,给定一个没有重复数字的序列,返回其所有可能的全排列。 例如,输入为[1,2,3],则输出应为[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]。我们暂时不要考虑如何用编程方式去实现这一过程,现在假设是人力罗列,我们应该遵循一个什么样的列写规则?一种简单的列写规则如下图所示。
初始时,可供我们选择的数字包含在集合{1,2,3}中。第一步,我们从集合中选取1作为起始节点,然后更新集合为{2,3}。第二步,我们从集合中选取2作为第二个节点,然后更新集合为{3}。第三步,我们从集合中选取3作为第三个节点,然后更新集合为{},此时集合为空集,代表我们已经完整找到了一个可行结果[1,2,3]。回顾来看,我们每一步都在做着相同的事——从当前集合中选一个作为当前节点的数,然后更新集合。于是我们可以将问题进行抽象简化,初始时我们希望找到[1,2,3]的全排列(母问题),第一步选取1为起始节点后,我们希望找到[2,3]的全排列(子问题),因为一旦找到了[2,3]的全排列,只要在这些排列前面补上1,我们就可以得到母问题中以1为起始节点的全部结果。这就形成了用递归法求解这个问题的基本思路。
再来看图,纵观整个搜索过程,无非是一种全遍历过程。每到一个节点,如果有可选项,则生成新节点,然后更新备选集合,再进一步求解子问题;而如果无可选项,则回退到上过一个节点的状态。当然,途中我们要判断是否已经产生了解。
伪代码
反思上面的过程,我们形成一个伪代码框架,用来求解回溯问题。
整个方法其实就3大核心部件:1.解的判断以及结束判断;2.筛选满足约束的备选项;3.产生子问题并求解,求解完后恢复求解之前的状态。
public void backtrack(...){
if(是一个可行解) 将结果存入集合中;
if(无备选项或无须进一步搜索) return;
for(所有的备选项){
if(该备选项不满足约束) continue;
生成当前节点;
更新集合;
子问题backtrack();
//状态回退
删除节点;
回退集合;
}
}
例子
全排列
LeetCode 46. 全排列
我们回顾上一节给的3大核心部件。1.解的判断以及结束判断。当temp列表的大小和给定数组的长度一致时,说明形成了一个可行结果,需要存入ret中。同时两者数值大小一致,也说明再无备选项,搜索应该回溯到上一步。2.筛选满足约束的备选项。这里用到了一个布尔数组uesd,用来记录哪些数是已经被使用了的。显然我们应该选取那些未被使用过的数。布尔数组的技巧非常实用,应该记住。3.产生子问题并求解,求解完后恢复求解之前的状态。子问题求解前需要更新布尔数组used和暂存列表temp,子问题求解完以后,需要恢复used和temp之前的状态。
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> ret = new ArrayList<>();
backtrack(ret,new ArrayList<Integer>(