学习笔记-【回溯算法解组合问题】-回溯是递归的副产品哦

本文介绍了如何使用回溯算法解决组合问题,如从[1,n]中选择k个数的组合,通过递归和剪枝优化算法效率。详细讲解了递归的参数、返回值、终止条件以及优化方法,展示了如何通过调整for循环范围来减少不必要的搜索过程。
摘要由CSDN通过智能技术生成


理论基础

我们将回溯问题抽象为树形结构。回溯解决的问题是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成了树的深度。

回溯是递归的副产品,有递归就会有回溯。
因而回溯和递归一样,有三部曲:

参数和返回值
终止条件
递归逻辑

解决特定的问题时需要用到回溯算法:

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


例题-组合

题目描述

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
力扣 77.组合

分析

如果我们输入4,2,即在[1,4]范围内的两个数的组合,则有以下情况。
当然对应这组数据,我们用两层for循环即可得到结果,但如果是k个数的组合,那么就要k层for循环,这对于k很大的情况是不可行的。

那么我们使用回溯算法,如图所示。纵向上,每递归一次,向vector<int> path中push_back一个值,直到path.size()==k,将其push_back到vector<vector<int>> res 中。而在横向上,每一层又for循环遍历所有结果。

回溯是递归的副产品,所以回溯的过程实际上就是递归的过程。那么,我们用递归三部曲来分析一下它吧。

递归三部曲

调用参数和返回值:参数除了输入的n和k,还要再添加一个参数startIndex来记录递归的起始位置。返回值则为vector<vector<int>>类型。

终止条件:path.size()==k时终止递归。

递归逻辑:用for循环进行横向遍历,递归进行纵向遍历,递归到终止条件后将path放入res中。最后通过回溯清空path,进行下一次横向遍历。代码如下:

 for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
    path.push_back(i); // 处理节点
    backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
    path.pop_back(); // 回溯,撤销处理的节点
	}

回溯

完整代码:

#include <iostream>
#include <vector>
using namespace std;
class Solution{
private:
    vector<int> path;
    vector<vector<int>> res;
    void backtracking(int n, int k, int startIndex) {
        if (path.size() == k) {
            res.push_back(path);

            return;
        }
        for (int i = startIndex; i <= n; i++) {
            path.push_back(i);
            backtracking(n, k, i + 1);
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combine(int n,int k) {
        backtracking(n, k, 1);
        return res;
    }

};
int main(){
    Solution s;
    vector<vector<int>> res = s.combine(4, 2);
    for (auto i : res) {
        for (auto j : i) {
            cout << j << " ";
        }
        cout << endl;
    }
    return 0;
}

backtracking方法被放置在private部分是为了隐藏实现细节并且将其视为内部方法。这样做可以防止外部代码直接调用backtracking方法,以避免不正确的使用和潜在的错误。通过将backtracking方法放置在private部分,可以强调它是作为combine方法的辅助函数,而不是作为公共API的一部分。
这种做法也符合封装的原则,即将实现细节隐藏在类的内部,限制外部对内部数据和方法的直接访问,以提高类的安全性和可维护性。

优化方法

即便回溯算法是一种暴力算法,它也可以优化。
我们可以通过剪枝,删除一些不必要的过程,从而优化代码,提高效率。可以从下面的图示里看出:
回溯优化

可以看出:

第一层:从[1,4]四个数里取三个数,则 i 在[1,2]范围内
第二层:从[2,4]三个数里取两个数,则 i 在[2,3]范围内

我们可以总结出如下规律:
对于横向遍历,第startIndex层 i 范围是:[startIndex , n-(k-path.size())+1]
即:for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)

 class Solution{
//private:
//    vector<int> path;
//    vector<vector<int>> res;
//    void backtracking(int n, int k, int startIndex) {
//        if (path.size() == k) {
//            res.push_back(path);
//
//            return;
//        }
//        for (int i = startIndex; i <= n; i++) {
//            path.push_back(i);
//            backtracking(n, k, i + 1);
//            path.pop_back();
//        }
//    }
//public:
//    vector<vector<int>> combine(int n,int k) {
//        backtracking(n, k, 1);
//        return res;
//    }

    //优化
private:
    vector<int> path;
    vector<vector<int>> res;
    void backtracking(int n, int k, int startIndex) {
        if (path.size() == k) {
            res.push_back(path);

            return;
        }
        for (int i = startIndex; i <= n-k+path.size()+1; i++) {
            path.push_back(i);
            backtracking(n, k, i + 1);
            path.pop_back();//这里要pop_back,因为每一次都要回溯到上一个状态,所以要pop_back
        }
    }
public:
    vector<vector<int>> combine(int n, int k) {
        backtracking(n, k, 1);
        return res;
    }

};

总结

在特定问题时需要使用回溯算法(例如组合问题),回溯实际上是递归的副产品,因而效率并不高,需要尽可能地剪枝优化。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值