回溯算法模板(1)——组合问题

在进入研究具体的回溯问题之前,先来看看

回溯是什么

回溯——顾名思义,就是在处理完某一点之后返回到上一状态,再在此基础上处理另外的case。

回溯的本质就是一种枚举,只不过没有那么“暴力”,结合上记忆化搜索以及各种剪枝,回溯可以处理相当数目的中档题。同时,回溯算法因为与递归相联系,所以会隐式地生成一棵树,结合树形图,也是设计与分析回溯递归的一个好办法。

实现回溯最直接的办法就是递归,回溯与递归相辅相成。

回溯算法的使用场景,和递归算法的使用场景高度重合,包括但不限于:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等
  • 树的问题:N叉树的构造,遍历等
  • 动态规划问题
  • 其他:例如KMP算法中的next数组,虽然不是回溯,却用到了回溯的思想

回溯算法的学习与应用任重而道远,此文仅就组合问题讨论。

参考题目:
leetcode77:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(最直接的组合问题)

leetcode216:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(从子集枚举到小量组合)

leetcode17:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(回溯综合题——与哈希结合)

leetcode39:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(排序剪枝)

leetcode40:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台​​​​​(树层去重)

回溯算法模板

回溯算法在细节处理上各有不同,但是大体框架上逃不开类似递归遍历的三段式

1.确定函数头

返回值:回溯算法不同于递归,只是进行一个回溯的操作,相关的处理只在同一次搜索进行,所以不需要返回值,一般用void即可。

参数:至于参数,也不同于树的遍历,回溯算法因为其处理逻辑的不同,往往需要穿不同的参数,但是不管怎么样,进行回溯的数组肯定是需要的,可以先传入。

void backtracking(参数)

2.确定终止条件

这个就和树的遍历十分相似了,一般遍历到叶子节点就退出。

在树的遍历中,我们判断的是cur!=nullptr或者cur->left!=nullptr&&cur->right!=nullptr,这是因为树是显式的,但是回溯递归的树是隐式的,就需要特判path数组是否达到了需要的大小,或者是否满足了题设的某条件一般写作:

 

if (终止条件) {
    存放结果;
    return;
}

3.确定遍历过程

这一步事实上要比树的遍历简单很多。树的遍历中因为时常需要考虑到是否用到子树的值,因而在前中后序遍历中纠结很久,回溯法就要方便很多,只需要一次递归,递归前处理path和相关数据,递归后再次复原即可。事实上,部分情况下可以把对path和相关数据的处理也塞到递归函数里面,从而简化代码。

for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
    处理节点;
    backtracking(路径,选择列表); // 递归
    回溯,撤销处理结果
}

由以上三步,就可以实现一个大体的回溯递归函数

结合下图,不难发现:

所谓回溯,就是一边通过递归不断地深入,在每一层递归中,又用for语句遍历集合,实现对集合所有情况的枚举。

有了上述模板,解决组合问题就差不多了。但是,区分回溯和其他暴力枚举方法的一大特点,还得是回溯特有的剪枝操作

浅谈剪枝

 上述leetcode中的题目,都或多或少可以用上一些剪枝操作。

a.首先是对for循环的剪枝,以77为例:

for (int i = startIndex; i <= n; i++) {
    path.push_back(i);
    backtracking(n, k, i + 1);
    path.pop_back();
}

上述代码在i过大,大到根本不能取到k个数的时候,是始终不成立的,在这里可以直接用break跳出实现剪枝。

 for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
            path.push_back(i); // 处理节点
            backtracking(n, k, i + 1);
            path.pop_back(); // 回溯,撤销处理的节点
        }

b.同时,对于退出条件,我们也能通过越界判定来剪枝,以leetcode216为例:

当sum已经大于target时,再继续搜索是没有意义的(不存在负数元素,只会越加越大),可以通过判断sum和target关系来剪枝:

for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
    sum += i; // 处理
    path.push_back(i); // 处理
    if (sum > targetSum) { // 剪枝操作
        sum -= i; // 剪枝之前先把回溯做了
        path.pop_back(); // 剪枝之前先把回溯做了
        return;
    }
    backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
    sum -= i; // 回溯
    path.pop_back(); // 回溯
}

c.可以发现,上面的剪枝都是在单调有序的条件下进行的,所以对于无序的一个序列,搜索时可以先排序再剪枝,效果也是一样,例如39题:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

上述for循环代码在进行排序的条件下,可以剪去无效的case。

d.记忆化剪枝

这个其实源于递归,就是把经常会递归重复用到的值存在一个数组里面,需要的时候就把他直接从数组中取出,减少递归层数。

具体怎么用,可以尝试做一下洛谷P1464:P1464 Function - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

题目很简洁,重要的一个考点就是记忆化剪枝了。

除了剪枝,回溯的另一个技巧在于

设置startindex

以leetcode39为例:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum > target) {
            return;
        }
        if (sum == target) {
            result.push_back(path);
            return;
        }

        for (int i = startIndex; i < candidates.size(); i++) {
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重复读取当前的数
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

如图的代码中,我们只看backtracking(回溯)函数,在传参时,除了传入基本量,还额外传入了一个startindex。

在这个startindex在不同情况下可以发挥不同的妙用。

仅以此题为例,在递归式中给startindex传入i,可以让下一层递归的for循环略过i之前的元素,实现一个简单的去重(前提是candidates是有序的,所以要先进行排序,这不仅对剪枝有益,对回溯本身同样有益)。

接下来是有关组合问题的重头戏,

组合去重

以此为特点的一道题就是leetcode的40题:

这道题的去重主要还是在树层上操作,并且要略过对树枝的操作,具体参考下图:

都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重

为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)

强调一下,树层去重的话,需要对数组排序!

在图中去重操作下,应用到了used数组来标记相同元素是在同一树枝还是同一树层。

前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。

如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]

此时for循环里就应该做continue的操作。

我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。

而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:

去重操作相关讲解来源:代码随想录 (programmercarl.com)​​​​​​

所以单层递归的代码逻辑应该是:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
    // used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
    // used[i - 1] == false,说明同一树层candidates[i - 1]使用过
    // 要对同一树层使用过的元素进行跳过
    if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
        continue;
    }
    sum += candidates[i];
    path.push_back(candidates[i]);
    used[i] = true;
    backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1:这里是i+1,每个数字在每个组合中只能使用一次
    used[i] = false;
    sum -= candidates[i];
    path.pop_back();
}

这样就解决了组合中去重的问题。

  • 25
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Tomokochandesu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值