回溯(backtrack)描述

前言

回溯描述。


一、核心概念

  • 在深度优先搜索(DFS)的递归过程中回溯:回溯问题可抽象为树形结构,递归向下处理和获取结果,回溯向上撤销结果
  • 相对于暴力算法,是一种间接/有技巧(使用递归代替多重循环)的暴力搜索/穷举,不高效

二、典型问题

分类依据:代码随想录 (programmercarl.com)和力扣

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 图问题:图中使用深度优先遍历
  • 棋盘问题:N皇后,解数独等等

三、算法步骤

  1. 将回溯问题抽象为树形结构
  • 问题规模为树的宽度
  • 递归深度为树的深度
  1. 确定回溯函数的参数和返回值
  • 可依据逻辑需要后补充参数

可能的参数:
候选集合
问题规模为树的宽度width
递归深度为树的深度depth
当前结点问题规模的起始索引start_index
标记集合中元素是否已选取的数组
结果集合res和results

  • 返回值一般为空(void)
  1. 确定回溯函数的终止条件和获取结果逻辑
  • 在树的叶子结点获取结果
  • 在树的叶子结点结束当前层递归,并回溯撤销结果

注意:子集问题,在树的结点获取结果

  1. 确定回溯函数的遍历和处理结果逻辑
  • 横向遍历当前树层各结点的问题规模
  • 纵向递归处理结果

四、模板

// 0. 将回溯问题抽象为树形结构
// 1. 确定回溯函数的参数和返回值
void backtrack(参数) 
{
	// 2. 确定回溯函数的终止条件和获取结果逻辑
    if (终止条件) 
    {
        获取结果;
        
        return;
    }

	// 3. 确定回溯函数的遍历和处理结果逻辑
    for (当前树层,各结点的问题规模) 
    {
        处理结果;
        
        backtrack(参数); // 递归
        
        撤销结果; // 回溯
    }
}

五、模板示例

77.组合 - 力扣(LeetCode)——中等的题解:

class Solution
{
private:
    // 0. 将回溯问题抽象为树形结构
    vector<int> res;
    vector<vector<int>> results;

    // 1. 确定回溯函数的参数和返回值
    void backtrack(int width, int depth, int start_index)
    {
        // 2. 确定回溯函数的终止条件和获取结果逻辑
        if (res.size() == depth)
        {
            results.push_back(res);

            return;
        }

        // 3. 确定回溯函数的遍历和处理结果逻辑
        for (int i = start_index; i <= width; ++i)
        {
            res.push_back(i);       // 处理结果

            backtrack(width, depth, i + 1); // 递归

            res.pop_back(); // 撤销结果  回溯
        }
    }

public:
    vector<vector<int>> combine(int n, int k)
    {
        backtrack(n, k, 1);

        return results;
    }
};

六、性能分析

时间复杂度:指数级

组合问题:

  • 时间复杂度:一般为:组合规模 × 每组合判断并加入结果集的时间

如: O ( ∁ m n × n ) O(\complement^n_m × n) O(mn×n)。从m规模中取n规模,有 ∁ m n \complement^n_m mn种组合;每组合判断并加入结果集的时间需要O(n)

如: O ( 2 n × n ) O(2^n × n) O(2n×n)。从n规模中取或不取,有 2 n 2^n 2n种组合;每组合判断并加入结果集的时间需要O(n)

  • 空间复杂度:一般为:递归栈规模 + 结果集规模: O ( n ) O(n) O(n)。n为数据规模

切割和子集问题:类似组合问题

排列问题:全排列

  • 时间复杂度:一般为:全排列规模 × 每排列判断并加入结果集的时间: O ( n ! × n ) O(n!×n) O(n!×n)。n为数据规模

参见:
回溯算法入门级详解 + 练习(持续更新) - 全排列 - 力扣(LeetCode)
本周小结!(回溯算法系列三) | 代码随想录 (programmercarl.com)

  • 空间复杂度:类似组合问题

图和棋盘问题:具体问题具体分析


七、优化方法

1. 剪枝

作用:降低时间复杂度

描述:

  • 不符合条件的问题
  • 不再继续处理并获取结果

形式:

  • 横向剪枝:缩小当前树层各结点的问题规模:修改for()循环

  • 纵向剪枝:增加终止条件

模板示例:216.组合总和 III - 力扣(LeetCode)——中等的题解:

class Solution
{
private:
    // 0. 将回溯问题抽象为树形结构
    vector<int> res;
    vector<vector<int>> results;

    // 1. 确定回溯函数的参数和返回值
    // width:[1,9]
    void backtrack(int depth, int target_sum, int start_index, int sum)
    {
        // 2. 确定回溯函数的终止条件和获取结果逻辑
        if (sum > target_sum) // 纵向剪枝
        {
            return;
        }

        if (res.size() == depth)
        {
            if (sum == target_sum)
            {
                results.push_back(res);

                return;
            }

            return;
        }

        // 3. 确定回溯函数的遍历和处理结果逻辑
        for (int i = start_index; i <= 9 - (depth - res.size()) + 1; ++i) // 横向剪枝
        {
            sum += i;
            res.push_back(i); // 处理结果

            backtrack(depth, target_sum, i + 1, sum); // 递归

            sum -= i;
            res.pop_back(); // 撤销结果  回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n)
    {
        backtrack(k, n, 1, 0);

        return results;
    }
};

八、其他技巧

1. 元素不可以和可以重复选取

  • 在同一集合中,若元素不可以重复选取,则在递归时,修改当前结点问题规模的起始索引start_index

如:77.组合 - 力扣(LeetCode)——中等

  • 在同一集合中,若元素可以重复选取,则在递归时,不修改当前结点问题规模的起始索引start_index

如:39.组合总和 - 力扣(LeetCode)——中等


2. 从同一集合中取和从不同集合间取元素

  • 从同一集合中取元素,需要参数:当前结点问题规模的起始索引start_index

如:77.组合 - 力扣(LeetCode)——中等

  • 从不同集合间取元素,可能不需要参数:当前结点问题规模的起始索引start_index

如:17.电话号码的字母组合 - 力扣(LeetCode)——中等


3. 求和问题

  • 在求和问题中,可能需要先排序后剪枝

如:39.组合总和 - 力扣(LeetCode)——中等


4. 先排序后去重

排序后才可以,通过相邻元素判断是否重复选取

  • 树层去重

参见:代码随想录 (programmercarl.com)

如:40.组合总和 II - 力扣(LeetCode)——中等

如:47.全排列 II - 力扣(LeetCode)——中等

  • 树枝去重

如:47.全排列 II - 力扣(LeetCode)——中等

排列问题可以在树层去重,也可以在树枝去重;但在树层去重效率更高。参见:代码随想录 (programmercarl.com)


5. 去重的方式

去重:即标记集合中元素是否已选取

  • 数组
  • 编程语言内置的哈希表数据结构(有频繁的插入、映射、查找和删除操作,时间和空间复杂度高)。如:C++的unordered_set
  • 哈希数组(已知数据规模)

参见:代码随想录 (programmercarl.com)


6. 组合、切割、排列和子集问题的区别

  • 组合、切割和排列问题,树的叶子结点为结果(遍历符合条件的树枝)

  • 子集问题,树的每个结点为结果(遍历树);所以可以不需要确定回溯函数的终止条件,不存在剪枝的优化方法


7. 组合、切割、子集和排列问题的区别

  • 组合问题、切割和子集问题,集合是无序的,for()循环从当前结点问题规模的起始索引start_index开始;可能需要去重

  • 排列问题,集合是有序的,for()循环从0开始,必需去重


8. 二维横向遍历

  • 在二维横向遍历中
  • 进行递归和回溯

如:37.解数独 II - 力扣(LeetCode)——困难


九、力扣例题

组合问题:

  • 77.组合——中等
  • 216.组合总和 III——中等
  • 17.电话号码的字母组合——中等
  • 39.组合总和——中等
  • 40.组合总和 II——中等

切割问题:

  • 131.分割回文串——中等
  • 93.复原 IP 地址——中等

子集问题:

  • 78.子集——中等
  • 90.子集 II——中等
  • 491.递增子序列——中等

排列问题:

  • 46.全排列——中等
  • 47.全排列 II——中等

其他问题:

  • 332.重新安排行程——困难

图问题:

  • 51.N 皇后——困难
  • 37.解数独——困难

总结

回溯描述。


参考资料


作者的话

  • 感谢参考资料的作者/博主
  • 作者:夜悊
  • 版权所有,转载请注明出处,谢谢~
  • 如果文章对你有帮助,请点个赞或加个粉丝吧,你的支持就是作者的动力~
  • 文章在描述时有疑惑的地方,请留言,定会一一耐心讨论、解答
  • 文章在认识上有错误的地方, 敬请批评指正
  • 望读者们都能有所收获

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值