点击上方
蓝字
设为星标
本文向大家介绍了回溯算法的基础知识,以帮助大家更好地理解回溯算法。
回溯搜索算法简介
维基百科中关于回溯算法的介绍是:
回溯算法(backtracking)是暴力搜索算法的一种。
这句话向我们揭示了回溯算法的用途:搜索,因此回溯算法也被称为回溯搜索算法。
但它与 “二分查找” 、 “线性查找” 等 “查找问题” 不同的是,“搜索问题” 完成一件事情有可能多种方法,而每一种方法又有多个步骤,回溯算法就是在
不断尝试
,以得到待求问题的全部的解。
正是由于回溯算法具有“强大的”暴力搜索的能力,它被应用于一些游戏问题,例如:N 皇后、解数独、祖玛游戏、24 点游戏、走迷宫、生成迷宫。
许多复杂的、规模较大的问题都可以使用回溯搜索算法得到所有可行解,进而得到最优解,因此回溯算法有“通用解题方法”的美称,回溯算法也是经典的人工智能的基础算法。
本文只涉及回溯算法的基本知识,不会涉及到很高级的应用。
从全排列问题开始
我们从一个非常经典的问题开始讲解回溯算法,这道题是「力扣」上第 46 号问题:“全排列”。
题目描述
给定一个
没有重复数字
的序列,返回其所有可能的全排列。
示例:
输入: [1 , 2 , 3 ] 输出: [ [1, 2, 3 ], [1, 3, 2 ], [2, 1, 3 ], [2, 3, 1 ], [3, 1, 2 ], [3, 2, 1 ] ]
题目解析
这道题要求我们返回一个没有重复数字的序列的所有可能的排列。
以题目示例为例,如果让我们手动去写,相信大家一定都会。
在动手尝试写出几个全排列以后,会慢慢找到规律:
1、先下以
1
开头的全排列,它们是:
[1, 2, 3], [1, 3, 2]
;
2、再写下以
2
开头的全排列,它们是:
[2, 1, 3], [2, 3, 1]
;
3、最后写下以
3
开头的全排列,它们是:
[3, 1, 2], [3, 2, 1]
。
这是一个非常典型的搜索问题,它的特点是:1、有若干个解;2、每一个解的求解过程可以分为若干个步骤,得到所有解是一个不断尝试的过程。
具体说,我们的思路是:按顺序枚举每一位可能出现的数字,之前已经出现的数字在接下来要选择的数字中不能出现。
按照这种思路就能够做到
不重不漏
,把所有的全排列都枚举出来。
这样的思路可以用一个树形结构表示。
看到这里的朋友,不妨自己先尝试画一下“全排列”问题的树形结构。
画出树形结构
使用编程的方法得到全排列,就是在这样的一个树形结构中进行搜索,从树的根结点到叶子结点的路径
path
(下文也会用到的
path
就是此处的意思,就不再重复说明) 就是题目要求的一个全排列。
我们只需要执行一次深度优先遍历(深度优先搜索),就能够得到所有的叶子结点。
相信提到深度优先搜索,不少朋友会想到树和图问题中另一个小伙伴的名字,它就是广度优先遍历(广度优先搜索)。
那么广度优先搜索是否可以应用在这道问题中呢?
既然是搜索,广度优先搜索当然可以用于搜索。这个问题大家也不妨思考一下,全排列问题,既然用广搜可以,为什么它是深搜的经典问题。
或许我们能想到一块去。
理解为什么是深度优先遍历,和回溯又有什么关系
下面我们解释一下上面的树形结构,请大家
从深搜在这棵树上走过的路径
来理解以下的几点说明:
1、
每一个结点表示了“全排列”问题求解的不同阶段,这些阶段通过变量的“不同的值”体现
,这些变量的不同的值,称之为“状态”;
2、
深度优先遍历由于有“回头”的过程
,在“回头”以后,状态变量需要设置成为和先前一样。在回到上一层结点的过程中,需要撤销上一次选择,这个操作也称之为“状态重置”,
“状态重置”就是“回溯”的本意
;
3、使用深度优先遍历编写代码,可以直接借助系统栈空间,为我们保存所需要的状态变量。在编码中需要注意:遍历到相应的结点的时候,状态变量的值是必须是正确的。此处我们来认识
path
变量作为状态变量,它在深度优先遍历中的变化:往下走一层的时候,
path
变量在尾部追加一个数字,而往回走的时候,需要撤销上一次的选择,这一操作也是在
path
的尾部去掉一个数字,因此
path
变量是一个栈。
下面我们解释如何编码:
1、首先这棵树除了叶子结点以外,每一个结点做的事情其实是一样的,即在已经选了一些数的前提下,需要在剩下还没有选择的数中按照顺序依次选择一个数,这显然是一个递归结构;
2、递归的终止条件是,数字的个数已经选够了,因此我们需要一个变量来表示当前已经选了几个数字,即当前递归到第几层,我们把这个变量叫做
depth
;
3、这些结点实际上表示了搜索全排列问题的不同阶段,为了区分这些不同阶段,我们就需要一些变量来记录为了得到一个全排列,程序进行到哪一步,在这里我们需要两个变量:
(1)已经选了哪些数,到叶子结点时候,这些已经选择的数就构成了一个全排列,
path
就是这样的变量;
(2)一个布尔数组
used
,初始化的时候都为
false
,表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为
true
,这样在考虑下一个位置的时候,就能够以
O(1)
的时间复杂度判断这个数是否被选择过,这是一种“以空间换时间”的思想。
这两个变量称之为“状态变量”,它们
表示了我们在求解一个问题的时候所处的阶段
。
4、在非叶子结点处,产生不同的分支,这一操作的语义是:在还未选择的数中依次选择一个元素作为下一个位置的元素,这显然得通过一个循环实现。
5、另外,由于执行的深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做“状态重置”,即“回到过去”、“恢复现场”,我们举一个例子:
请大家看上面的树形图想象
,代码是如何从叶子结点
[1, 2, 3]
到叶子结点
[1, 3, 2]
的。深度优先遍历是这样执行的:
从
[1, 2, 3]
回到
[1, 2]
的时候,需要撤销刚刚已经选择的数
3
;
由于在上一层只有一个数
3
能选择,我们已经尝试过了,因此程序回到再上一层,需要撤销对
2
的选择,好让后面的程序知道,选择
3
了以后还能够选择
2
。
希望大家能通过在脑子里实际地像代码一样走一遍深度优先遍历的过程,去理解代码应该怎样写。或者直接看后面的代码,然后去理解代码为什么要这样写。事实上,我是先学习,然后再理解。
参考代码 1
请读者注意:这个代码是
错误
的,有一个小坑,希望读者能自己运行一下测试用例自己发现原因,然后再阅读后面的内容。
import java.util.ArrayList;import java.util.List;public class Solution { public List> permute(int [] nums) { // 首先是特判 int len = nums.length; // 使用一个动态数组保存所有可能的全排列 List> res = new ArrayList<>(); if (len == 0 ) { return res; } boolean [] used = new boolean [len]; List path = new ArrayList<>(); dfs(nums, len, 0 , path, used, res); return res; } private void dfs (int [] nums, int len, int depth, List path, boolean [] used, List> res)
{ if (depth == len) { res.add(path); return ; } for (int i = 0 ; i if (!used[i]) { path.add(nums[i]); used[i] = true ; dfs(nums, len, depth + 1 , path, used, res); // 注意:这里是状态重置,是从深层结点回到浅层结点的过程,代码在形式上和递归之前是对称的 used[i] = false ; path.remove(depth); } } } public static void main (String[] args) { int [] nums = {1 , 2 , 3 }; Solution solution = new Solution(); List> lists = solution.permute(nums); System.out.println(lists); } }
这段代码在运行以后输出如下:
[[], [], [], [], [], []]
得到了
6
个空列表的原因出现在递归终止条件这里:
if (depth == len) { res.add(path); return ; }
解释:
path
这个变量所指向的对象在递归的过程中只有一份。在深度优先遍历完成以后,由于最后回到了根结点,
path
这个变量为空列表。
依然是去想象深度优先遍历的过程,从而理解为什么会到深搜会到原点以后为空列表,因为一开始就是空列表,深搜的过程转了一圈,在不断的选择和回溯的过程以后,回到原点,依然是空列表。
这里需要说明的一点是:
在 Java 语言中,方法传递都是值传递。对象类型的变量在传参的过程中,复制的都是变量的地址。这些地址被添加到
res
变量,但这些地址实际上指向的是同一块内存的地址,因此我们会看到
6
个空的列表对象。解决这个问题的方法很简单,在
res.add(path);
这里做一次拷贝即可。
修改的部分:
if (depth == len) { res.add(new ArrayList<>(path)); return ; }
此时再提交到「力扣」上就能得到一个 Accept 了。
复杂度分析
复杂度分析
:(可以暂时跳过,或者永远跳过,不是很重要,为了知识完整性写在这里。)
由于公众号对数学公式的排版不太友好,所以这里使用了截图的形式:(
下面我们对这一版代码做以下几个说明:
1、如果在每一个非叶子结点分支的尝试,我都创建新的变量表示状态,那么
在回到上一层结点的时候不需要“回溯”;
在递归终止的时候也不需要做拷贝。
这样的做法虽然可以得到解,但也会创建很多中间变量,这些中间变量很多时候是我们不需要的,会有一定空间和时间上的消耗。
为了验证上面的说明,我们写如下代码进行实验:
参考代码 2
import java.util.ArrayList;import java.util.List;public class Solution { public List> permute(int [] nums) { // 首先是特判 int len = nums.length; // 使用一个动态数组保存所有可能的全排列 List> res = new ArrayList<>(); if (len == 0 ) { return res; } boolean [] used = new boolean [len]; List path = new ArrayList<>(); dfs(nums, len, 0 , path, used, res); return res; } private void dfs (int [] nums, int len, int depth, List path, boolean [] used, List> res)
{ if (depth == len) { // 3、不用拷贝,因为每一层传递下来的 path 变量都是新建的 res.add(path); return ; } for (int i = 0 ; i if (!used[i]) { // 1、每一次尝试都创建新的变量表示当前的"状态" List newPath = new ArrayList<>(path); newPath.add(nums[i]); boolean [] newUsed = new boolean [len]; System.arraycopy(used, 0 , newUsed, 0 , len); newUsed[i] = true ; dfs(nums, len, depth + 1 , newPath, newUsed, res); // 2、无需回溯 } } } }
这就好比我们在实验室里做“对比实验”,只有每一个步骤的尝试都都使用同样的材料,才有可能保证“对比实验”结果的有效性。
为此我们有两种做法:
在生活中做实验对材料有破坏性,这个过程通常不可逆。
而在计算机的世界里,“恢复现场”和“回到过去”是相对容易的。
在一些字符串的“回溯”问题中,有时不需要回溯的原因是这样的:字符串变量在拼接的过程中会产生新的对象(针对 Java 和 Python 语言,其它语言我并不清楚)。
如果你使用 Python 语言,会知道有这样一种语法:
[1, 2, 3] + [4]
创建了一个新的列表对象,请看“参考代码 3”。
参考代码 3
from typing import Listclass Solution : def permute (self, nums: List[int]) -> List[List[int]]: def dfs (nums, size, depth, path, state, res) : if depth == size: res.append(path) return for i in range(size): if ((state >> i) & 1 ) == 0 : dfs(nums, size, depth + 1 , path + [nums[i]], state ^ (1 < size = len(nums) if size == 0 : return [] state = 0 res = [] dfs(nums, size, 0 , [], state, res) return res
说明:这里的整数
state
代替了布尔数组
used
的作用。布尔数组
used
在这题里的作用是判断某个位置上的元素是否已经使用过。
它有两种等价的替换方式:
(1)位掩码,即使用一个整数表示布尔数组。此时可以将空间复杂度降到
O(1)
(不包括
path
变量和
res
变量和递归栈空间消耗),本 Python 代码正好展示了这样的写法;
(2)哈希表,代码留给读者完成。
2、(只与 Java 语言相关)
ArrayList
是 Java 中的动态数组,Java 建议我们如果一开始就知道这个集合里需要保存元素的大小,可以在初始化的时候直接传入。
在这里,由于我们很清楚全排列的总是就是候选数组长度的阶乘值,因此在
res
变量初始化的时候,最好传入
len
的阶乘,让
ArrayList
在代码执行的过程中不发生扩容行为。同理,在
path
变量初始化的时候,最好传入
len
,事实这个路径变量最长也就到
len
。
3、
path
变量我们发现只是对它的末尾位置进行增加和删除的操作,显然它是一个栈,因此,使用栈语义会更清晰。
(只与 Java 语言相关)但同时
Stack
这个类的文档告诉我们,由于一些设计上的问题,建议我们使用:
Deque stack = new ArrayDeque();
这一点让我们觉得比较左右为难:
Deque
是双端队列,它提供了更灵活的接口,但同时破坏了语义,一不小心,如果用错了接口,就会导致程序错误。我采用的做法是接受官方的建议,并且(1)在程序变量命名和使用的接口时让语义清晰;(2)加上必要的注释;(3)加强测试。
这里
path
需要表示它是从根结点到叶子结点的路径,我认为这个语义更重要,因此不改名为
stack
。而在末尾添加元素和删除元素的时候,分别使用
addLast()
和
removeLast()
方法这两个最直接的方法名强调只在
path
变量的末尾操作。
到此为止,回溯搜索算法的基本思想,除了“剪枝”,我们已经介绍完了,下面做一个简单的总结。
总结
回溯算法就是在一个树形问题上做一次深度优先遍历,以达到搜索所有可能的解的效果。
为什么使用深度优先遍历?
首先是正确性,只有遍历状态空间,才能得到
所有
符合条件的解;
在深度优先遍历的时候,
不同状态之间的切换很容易
,可以再看一下上面有很多箭头的那张图,每两个状态之间的差别只有 1 处,因此回退非常方便,这样全局就使用一份状态变量完成搜索;
如果每一个状态都去创建新的变量,时间复杂度是
O(N)
,并且也有空间的消耗。
搜索问题的状态空间一般很大,在候选数比较多的时候,在非叶子结点上创建新的状态变量的性能消耗就很严重;
就本题而言,只需要叶子结点的那个状态,在叶子结点执行拷贝,时间复杂度是