回溯算法:
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
回溯算法的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
result用于接收答案,当路径满足了结束条件时就把路径收入其中。路径就是之前做选择的结果,选择列表就是当前可选的“剩余的”选择,结束条件是到达决策树的底层,或者说满足了题目条件。
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
实例1:全排列(leetcode46)
怎么穷举全排列的呢?比方说给三个数[1,2,3]
,你肯定不会无规律地乱穷举,一般是这样:先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……
或者可以用图来说明:
只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不妨把这棵树称为回溯算法的「决策树」。
为啥说这是决策树呢,因为你在每个节点上其实都在做决策。比如说你站在下图的红色节点上:
你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。为啥只能在 1 和 3 之中选择呢?因为 2 这个树枝在你身后,这个选择你之前做过了,而全排列是不允许重复使用数字的。
现在可以解答开头的几个名词:[2]
就是「路径」,记录你已经做过的选择;[1,3]
就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层,在这里就是选择列表为空的时候。
如果明白了这几个名词,可以把「路径」和「选择列表」作为决策树上每个节点的属性,比如下图列出了几个节点的属性:
我们定义的backtrack
函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个全排列。所以呢,我们要做的就是从根节点往下游走,不停的更新选择列表和路径,当方向是往下的时候,选择列表变短,路径变长,往上的时候,要把一些选择重新加入到选择列表之中。
再进一步,如何遍历一棵树?这个应该不难吧。各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:
void traverse(TreeNode root) {
for (TreeNode child : root.childern)
// 前序遍历需要的操作
traverse(child);
// 后序遍历需要的操作
}
前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。!!!!!!!!!!!!!!
回想我们刚才说的,「路径」和「选择」是每个节点的属性,函数在树上游走要正确维护节点的属性,那么就要在这两个特殊时间点搞点动作:
for 选择 in 选择列表:
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
代码全部:
class Solution {
List<List<Integer>> res = new LinkedList<>();
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
// 记录「路径」
LinkedList<Integer> track = new LinkedList<>();
backtrack(nums, track);
return res;
}
// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
// 触发结束条件
if (track.size() == nums.length) {
res.add(new LinkedList(track)); //这里是把track的值赋给一个新的linkedlist并返回这个新的
//每次这样返回完,track本身会逐次removelast
return;
}
for (int i = 0; i < nums.length; i++) {
// 排除不合法的选择
if (track.contains(nums[i]))
continue;
// 做选择
track.add(nums[i]);
// 进入下一层决策树
backtrack(nums, track);
// 取消选择
track.removeLast();
}
}
}
但是必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
n皇后问题:
给你一个 N×N 的棋盘,让你放置 N 个皇后,使得它们不能互相攻击。
PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。
这是 N = 8 的一种放置方法:
这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。
函数backtrack
依然像个在决策树上游走的指针,每个节点就表示在棋盘上放置皇后,而isvalid可以将不符合条件的情况剪枝。
class Solution {
List<List<String>> res = new ArrayList<List<String>>();//用于最终接收答案
public List<List<String>> solveNQueens(int n) {
char[][] arr = new char[n][n];
//填充棋盘,每个格子默认是“.”表示没有放置皇后
for(int i=0;i<n;++i) {
Arrays.fill(arr[i],'.');
}
backtrack(arr,0,n);
return res;
}
public void backtrack(char[][] arr,int row,int n){
//这里是出错点,本来写的是row==n-1,但事实上当递归到最后一行的时候,仍然会先递归再碰到这个
//判断,所以那个时候的row==n
if(row==n){
res.add(char2list(arr,n));
return;
}
//还是回溯法的框架
for(int col=0;col<n;col++){
if(!isvalid(arr,row,col,n)){
continue;
}
arr[row][col]='Q';
backtrack(arr,row+1,n);
arr[row][col]='.';
}
}
//用于判断是否能在那一列加上皇后,要注意的是,我们的选择是一行一行进行的(递归之前做选择)
//而那个时候下面是没有值的,所以只需要判断左上,右上,和上方
public boolean isvalid(char[][] arr,int row,int col,int n){
//上方
for(int i=0;i<n;i++){
if(arr[i][col]=='Q'){
return false;
}
}
//右上
for(int i=row-1,j=col+1;i>=0&&j<n;i--,j++){
if(arr[i][j]=='Q'){
return false;
}
}
//左上
for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
if(arr[i][j]=='Q'){
return false;
}
}
return true;
}
public List<String> char2list(char[][] arr,int n){
List<String> ans = new ArrayList<String>();
for(int i=0;i<n;i++){
StringBuilder tmp = new StringBuilder();
for(int j=0;j<n;j++){
if(arr[i][j]=='Q'){
tmp.append('Q');
}else{
tmp.append('.');
}
}
ans.add(tmp.toString());
}
return ans;
}
}
在自己写回溯法的时候,可以在脑海中想象出一颗树,搞清楚每一个层级和不同树列在干什么,比如这里的全排列问题,每个层级其实代表着:排列中的第一位数字,第二位数字,第三位数字.......而不同列则表示对该位数字的不同选择;而对于n皇后问题来说,每一个层级表示不同的行row,而树列则表示在不同的行上,放置皇后的选择(也就是放在哪一个列上col)。而我们可以看到,对于树列上的遍历由for完成,每次要保证排除不合法选择;对于行的遍历,由递归完成。(草稿上不妨写出每行的代表意义以帮助思考)。
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下:
def backtrack(...):
for 选择 in 选择列表:
做选择
backtrack(...)
撤销选择
写backtrack
函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集。
其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?
某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树大幅剪枝,这就变成了动态规划。而今天的两个问题,都没有重叠子问题,也就是回溯算法问题了,复杂度非常高是不可避免的。