递归加料——回溯算法

回溯算法:从探索到回溯的艺术

算法概述

回溯算法是⼀种经典的递归算法,通常用于解决组合问题、排列问题和搜索问题等。
回溯算法的基本思想:从⼀个初始状态开始,按照⼀定的规则向前搜索,当搜索到某个状态无法前进时,回退到前⼀个状态,再按照其他的规则搜索。回溯算法在搜索过程中维护⼀个状态树,通过遍历状态树来实现对所有可能解的搜索。

回溯算法的流程:“试错”,即在搜索过程中不断地做出选择,如果选择正确,则继续向前搜索;否则,回退到上⼀个状态,重新做出选择。回溯算法通常⽤于解决具有多个解,且每个解都需要搜索才能找到的问题

  1. 选择:做出一个选择
  2. 约束:检查当前选择是否满足条件
  3. 回溯:如果当前路径无效,撤销选择,尝试其他选项

回溯算法的核心思想是:搜索状态树,通过遍历状态树来实现对所有可能解的搜索

算法框架

以下是回溯算法的通用模板:

void backtrack(vector<int>& path, vector<int>& choice, ...) 
{
    // 满⾜结束条件
    if (/* 满⾜结束条件 */) {
        // 将路径添加到结果集中
        res.push_back(path);
        return;
    }

    // 遍历所有选择
    for (int i = 0; i < choices.size(); i++) {
        // 做出选择
        path.push_back(choices[i]);
        // 做出当前选择后继续搜索
        backtrack(path, choices);
        // 撤销选择
    }
}

其中, path表示当前已经做出的选择, choices表示当前可以做的选择。在回溯算法中,我们需要做出选择,然后递归地调用回溯函数。如果满足结束条件,则将当前路径添加到结果集中;否则,我们需要撤销选择,回到上⼀个状态,然后继续搜索其他的选择。回溯算法的时间复杂度通常较高,因为它需要遍历所有可能的解。但是,回溯算法的空间复杂度较低,因为它只需要维护⼀个状态树。在实际应用中,回溯算法通常需要通过剪枝,记忆搜索方法进行优化,以减少搜索的次数,从而提高算法的效率。

递归与回溯

如果上面比较文绉绉的介绍看的一知半解,那如果从回溯算法的本质就是递归这个视角看对待问题呢?

回溯算法天然适合用递归来实现,因为:

  1. 递归的调用栈自动保存了状态
  2. 递归的返回机制天然实现了"回溯",也就是回退到上⼀个状态,也叫恢复现场

所以只要在基础递归方法上加入“恢复现场”的机制,就可以把这个递归当作回溯算法

初探回溯

题目:验证二叉搜索树
验证二叉搜索树
思路:
利用二叉搜索树的中序遍历的结果是一个有序的序列这一特性检验;使用一个全局的变量,访问一个节点时与这个全局变量比较,符合要求则更新这个变量,继续遍历。

使用一个全局变量作为前一节点的值来进行验证的这一操作就可以粗略理解为一种恢复现场

代码实现:

class Solution 
{
    long prev=LONG_MIN;//-1,int hold不住
public:
    bool isValidBST(TreeNode* root) 
    {
        if(root==nullptr)return true;
        //一旦出错,直接返回(剪枝)
        if(!isValidBST(root->left))return false;
        if(root->val<=prev)return false;
        prev=root->val;
        return isValidBST(root->right);
    }
};

其中,只要发现有一处节点不符合二叉搜索树,即可返回结果,剩余的树也不需要再遍历了,这个操作也就是所谓的剪枝

在这里插入图片描述

题目:二叉树的所有路径
二叉树的所有路径
思路:
使用深度优先遍历(DFS)求解。使用全局变量将路径以字符串形式存储,从根节点开始遍历,每次遍历时将当前节点的值加⼊到路径中,如果该节点为叶子节点,将路径存储到结果中。否则,将 “->” 加入到路径中并递归遍历该节点的左右子树。

在每一层中,当前路径是不一样的,而当遍历完左子树返回到上一层时,是需要将路径恢复的,但是路径我们通过了递归函数的参数来维护,而递归的返回机制天然实现了"回溯",不需要手动执行,这便是回溯

二叉路径

代码实现:

class Solution 
{
    vector<string> ret;
    string sep="->";
public:
    vector<string> binaryTreePaths(TreeNode* root) 
    {
        string path;
        dfs(root,path);
        return ret;
    }

    void dfs(TreeNode* root,string path)
    {
        path+=to_string(root->val);
        //递归出口
        if(root->left==nullptr&&root->right==nullptr)
        {
            ret.emplace_back(path);
            return;
        }
        path+=sep;//添加到路径
        //遍历
        if(root->left)dfs(root->left,path);
        if(root->right)dfs(root->right,path);

        
    }
};

以上两题和平常写的递归没有什么区别,但是都含有回溯的影子,不需要我们手动去恢复现场。在使用回溯算法的题型中,只需要再基础递归中加上恢复现场的操作,那么这就是一个回溯算法

回溯算法的应用场景

  1. 组合问题:从n个元素中选k个的所有组合
  2. 排列问题:求序列的所有排列
  3. 子集问题:求集合的所有子集
  4. 棋盘问题:N皇后、数独、八皇后等
  5. 分割问题:分割回文串、IP地址划分
  6. 路径问题:在矩阵中寻找特定路径

回溯例题

题目:全排列

全排列
思路:对于穷举这种题型,推荐画决策树,这样不容易数漏,而且后续的回溯写起来很清晰。
用每一个数与所有数进行排列组合;需要注意的有两点:一:对于每一层递归,上一层使用过的元素这一层就不能使用了,所以需要进行剪枝,借助一个全局的bool check数组进行判断元素是否使用过(也就是所谓的记忆化搜索优化);二:每种排列组合使用全局的path进行记录,当返回一层递归时需要手动进行回溯,check数组同样需要在返回时手动回溯

画决策树

代码实现:

class Solution 
{
    vector<vector<int>> ret;//存储结果
    vector<int> path;//记录组合
    bool check[7];//记忆搜索
public:
    vector<vector<int>> permute(vector<int>& nums) 
    {
        dfs(nums);
        return ret;
    }

    void dfs(vector<int>& nums)
    {
        //出口
        if(path.size()==nums.size())
        {
            ret.push_back(path);
            return ;
        }


        //探索组合
        for(int i=0;i<nums.size();++i)
        {
            //检测是否已使用
            if(!check[i])//剪枝
            {
                check[i]=true;   
                path.push_back(nums[i]);
                dfs(nums);//重复子问题
                //回溯
                check[i]=false;
                path.pop_back();
            }
        }
    }
};

题目:子集

子集

思路:画决策树! 这里提供两种决策树的画法

一:对于每个元素有两种选择:1. 不进行任何操作;2. 将其添加至当前状态的集合。在递归时我们需要保证递归结束时当前的状态与进行递归操作前的状态不变,而当我们在选择进行步骤 2 进行递归时,当前状态会发生变化,因此我们需要在递归结束时撤回添加操作,即进行回溯。而且从决策树发现,这里枚举的所有结果都是需要的,也就不需要进行剪枝操作了。

决策树子集1
代码实现:

class Solution
{
    vector<vector<int>> ret;
    vector<int> path;
public:
    vector<vector<int>> subsets(vector<int>& nums)
    {
        dfs(nums, 0);
        return ret;
    }
    void dfs(vector<int>& nums, int pos)
    {
        if (pos == nums.size())
        {
            ret.push_back(path);
            return;
        }
        // 选
        path.push_back(nums[pos]);
        dfs(nums, pos + 1);
        path.pop_back(); // 恢复现场
        // 不选
        dfs(nums, pos + 1);
    }
};

二:将当前元素与后续元素进行组合,这样画出的决策树可以发现每次组合出来的树就是子集,直接保存即可;同样的,这里枚举的所有结果都是需要的,无需进行剪枝操作。

决策树子集2
代码实现:

class Solution 
{
    vector<vector<int>> ret;//存放结果
    vector<int> path;//存放路径
public:
    vector<vector<int>> subsets(vector<int>& nums) 
    {
        ret.push_back(path);//处理空集
        dfs(nums,0);
        return ret;
    }

    void dfs(vector<int>& nums,int pos)
    {
        for(int i=pos;i<nums.size();++i)
        {
            path.push_back(nums[i]);//存放结果
            ret.push_back(path);//求子集
            dfs(nums,i+1);//每一层直接得出当前的子集,
            path.pop_back();//回溯
        }
        
    }
};

对于需要穷举所有可能的结果时,建议画决策树;虽说回溯算法有所谓的模板,但是通过上述两题可发现,当你的决策树画出来时,所需要的全局变量也就出来了,是否需要进行剪枝也知道了,什么时候需要回溯也知道了,所以强烈建议画决策树

总结

回溯算法是一种强大而灵活的算法,特别适合解决需要尝试所有可能性的问题。虽然它的时间复杂度可能较高,但通过合理的剪枝和优化,可以显著提高效率。掌握回溯算法需要理解其核心的"选择-约束-回溯"模式,并通过大量练习来培养解决问题的直觉。

关键要点

  • 回溯是暴力搜索的优化版本
  • 剪枝是提高效率的关键
  • 递归实现是最自然的方式
  • 理解问题约束是设计回溯算法的第一步
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值