回溯算法

1,463 阅读5分钟

啥是回溯?

回溯法(backtracking) 是暴力搜索法的一种。

回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足条件的解。为了有规律地枚举所有的解,我们将问题求解过程分为多个阶段。每个阶段,我们都面临一个岔路口,我们先随意选一条路走,当发现这条路得不到满足条件的解时,就回退到上一个岔路口,重新选择另一种走法。

进一步理解,回溯算法相当于一个决策过程,递归地遍历一颗决策树,穷举所有的决策,同时把满足条件的决策挑出来。

回溯的思想很简单,难就难在于代码的实现。


如何回溯?

回溯算法的关键在于如何回溯,那么程序是如何回溯的呢?

回溯法通常利用递归方式实现,我们知道系统维护一个函数栈,每一次函数调用系统都会新建一个栈帧,然后将函数的各种参数信息压入栈中。

当我们遍历某条路不满足条件时,我们就返回,这时系统栈将该函数弹出,又回到了之前的函数(回到了上一个岔路口)。

因此,程序的回溯依赖的是系统的函数栈。通过系统的函数栈自动完成回溯。


编写回溯算法

个人觉得回溯算法的关键在于有规律的、不重复、不遗漏的搜索解空间。因此我们在编写回溯算法时应该考虑的是如何搜索才能不会缺斤少两,不会重复。

其实回溯算法就是N叉树的遍历,这个N等于当前可做选择的总数。

例如leetcode70题-爬楼梯。

要找爬到第n个台阶的方法总数其实就是遍历下图这个二叉树(每层台阶要么爬一层台阶,要么爬二层台阶,两种选择,因此是N等于2),每个节点中的数字代表当前的台阶层数。

现在问题就变成了如何不遗漏、不重复遍历这个二叉树,以找出所有的解。

首先关于树的遍历,肯定用递归是最简单的。

int cnt = 0;  // 全局变量,统计共有多少种方法。

/*
    curFloor:表示当前台阶层数
    n:表示我们要到达的台阶层数
*/

// 调用方式backtrack(0, 5) 表示从0层开始要达到第5层台阶
void backtrack(int curFloor, int n){
    if(curFloor > n) return;
    if(curFloor == n) {
        cnt++;
        return;
        
    }
        
    backtrack(curFloor+1, n);  // 爬1层
    backtrack(curFloor+2, n);  // 爬2层
}

可以看出上面的代码其实就是在遍历上面的二叉树,直到找到满足条件的解。其实这道题有点特殊,因为每层台阶都只有两种相同的选择。从上面的代码和对应的解空间树图一对比可以看出就是在做二叉树前序遍历。curFloor就像是树的每个节点一样,虽然这里不是指针,我们先处理根节点,然后处理左子树,再处理右子树。

回溯算法其实就是树的遍历,同时,在前序遍历的位置作出当前选择,然后开始递归,最后在后序遍历的位置取消当前选择。

为了更好的理解这句话,我们再看一个例子(上面的例子比较特殊,它的选择过程和取消选择过程隐含在了函数调用中)。

//  怕大家不知道我在说什么,补充一下。
//  我们平常写二叉树的遍历递归实现如下
void traverse(TreeNode * root) {
    if(root == NULL) return ;
    // 如果是前序遍历,则把相应操作代码插入到这里。这里就是上面说的前序遍历的位置。
    traverse(root->left);
    // 如果是中序遍历,则把相应操作代码插入到这里。这里就是中序遍历的位置。
    traverse(root->right);
    // 如果是后序遍历,则把相应操作代码插入到这里,这里就是后序遍历的位置。
}

//  N叉树的遍历递归实现(伪代码)
void traverse(TreeNode * root) {
    if (root == NULL) return;
    for child in root.children:
        // 前序遍历代码插到这里,这里就是前序遍历的位置
        traverse(child);
        // 后序遍历代码插到这里,这里就是后序遍历的位置
}

好了,我们通过另外一个leetcode46题-全排列来加深理解。

给定一个没有重复数字的序列,返回其所有可能的全排列。

示例:

输入: [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

这道题方法就是遍历如下的树。

直接看代码

/*
    nums:表示当前可以做的选择,如【1,2,3】
    choose:表示已经做的选择。如图中的红色【】
    answer: 保存符合条件的结果
*/


void backtrack(vector<int> &nums, vector<int>& choosed, vector<vector<int>>& answer) {
    if (nums.size() == 0) {  // 选择列表已空,表示完成了一个全排列
        answer.add(choosed);
    }
    
    // 在前序遍历位置做出选择
    for (int i = 0; i < nums.size(); i++) {
        int n = nums[i];  // 保存选择,以备后面恢复选择
        choosed.push_back(n);  // 做出选择
        nums.erase(nums.begin() + i);  // 做出选择后,将被选择的移除,避免重复选择
        backtrack(nums, choosed, answer);
        nums.push_back(n);  // 恢复选择
        choosed.pop_back();  // 移除这个选择
    }
    
}

其实递归过程就是一棵树,每棵树的节点的状态相当于每个函数栈帧中的局部变量(通过形参传入)。因此,递归的过程就是在遍历一棵树。因此编写回溯算法和编写普通的递归无多大差别,关键在于在适当的位置做出选择,适当的位置恢复选择。


参考

公众号:labuladong,真的讲的很棒!(一个读者的感概)。