回溯算法从递归到组合问题的浅析

本文详细介绍了回溯算法的关键要素,包括递归函数的参数与返回值、确定终止条件和单层递归逻辑,并通过实际的编程示例,如链表反转、组合问题和电话号码字母组合,展示了如何运用回溯法解决问题。
摘要由CSDN通过智能技术生成

回溯算法

前言

每当我们学习递归相关的题目的时候,我们会很难以理解整个递归的过程,以及在实现递归代码的时候,我们会出现无限递归的问题,或者递归一步也没有进行的操作,这些问题都是在递归学习中很常见的问题。笔者在做回溯的题目时候,也经常陷入这些问题。这些问题都是因为我们没有养成一个解决递归问题时候的一个思维方式。接下来我根据我对于代码随想录的内容进行一个对于回溯算法的自己的一个看法。

递归三部曲

三部曲主要包括:

1.递归函数的参数和返回值

2.确定终止条件

3.单层递归的逻辑

递归三部曲是代码随想录指出的一种思考递归代码的一个思路,我们要依靠这种三部曲来解决递归的相关问题。我们先来逐条分析一下这三个步骤的含义。我们在写这种递归题目的时候,一定要做到一个事情就是,从这个递归最简单的情况开始思考这三部曲。我们把一个困难的问题简单成最小问题的过程是非常重要的,这有利于我们更好的思考这三部曲。

递归函数的参数和返回值

这一条要求我们在做题的时候,先要考虑清楚每一次递归的时候函数需要调用的参数,以及这个函数应该返回什么值。返回值很好理解,也就是题目需要什么我们一般返回什么类型的值,而上面的参数该怎么去判断呢?

笔者这里认为,我们在写递归的题目时候,我们要通过考虑他的终止条件是什么情况,来决定我们这个递归函数需要的参数是什么,一个二叉树的题目,我们自然就要传入他的根节点,因为我们要判断这个节点的位置,来决定我们什么时候返回。我们要先明白逻辑在填入我们需要的参数。

确定终止条件

这一条可是说是决定我们回溯算法的核心,我们写出不同的终止条件,会带来截然不同的一个效果,就好比我们在写二叉树题目时候,我们是直到这个节点为NULL的时候在返回,还是在该节点成为叶子节点时候再返回,这两种不同的思路会让这个代码的形式截然不同。我们从一个问题的最小问题出发,得到这个最小问题的终止条件,也就是我们整个复杂问题的最小问题的终止条件。

单层递归的逻辑

我们单层递归的逻辑就是怎么把这个大问题变成一个小问题的过程,就是把复杂问题简单化的一个过程,我们要明白变化的的过程,以及代码上是如何展现这个过程的。

对于递归函数的理解

我们在做递归的题目时候,永远发现递归函数的每一步解决问题的过程是非常难以理解的,无法理解每一个过程,是我们学习递归以及回溯算法的难点所在。这也就是很多人抵触递归的原因,因为他没有像迭代一样把每一个过程清晰的展示在我们的面前,而是展示他的一个宏观过程,我们在做递归的题目时候,我们一定要把每一步理解成宏观意义上的。我拿206. 反转链表这道题目来举一个例子,并且在下面贴出相关的代码。

{0567691B-EB1D-4eb5-A3E0-82BC83F7F425}

struct ListNode* reverseList(struct ListNode* head) {
    if (head == NULL || head->next == NULL) {
        return head;
    }
    struct ListNode* cur = reverseList(head->next);
    head->next->next = head;
    head->next = NULL;
    return cur;
}

我们根据上面的三部曲来理解这道题目

  • 第一步:我们不难看出,我们要返回反转后头节点以及我们传入的参数是反转前的头结点。
  • 第二步:我们知道把这个链表简化成一个最小的链表,就是空链表或者是一个只有一个节点的链表时候我们不需要反转链表,这样我们就可以推出整个链表的终止条件head == NULL || head->next == NULL而且在这种情况下面,我们也可以知道,我们此时传入未反转头结点也是我们已经反转的头结点所以我们的返回值也就是一个head
  • 第三步:我们要知道如何翻转链表,我们如果将这个大问题变成小问题,head->next这个不断向后去遍历链表的操作就是将大问题变小,我们先把整个大链表中最后面的小链表反转了,然后不就实现了反转吗?这时候就可以写出这条代码reverseList(head->next);这段代码是在不断地向后寻找最短小的链表的过程。然后我们考虑只有两个节点时候我们的情况,通过这个小问题推出大问题,两个节点时候,我们分析这时候的head在倒数第二个位置,我们要怎么寻找到他的下一个节点呢?head->next也就是我们寻找的下一个节点,我们怎么反转也显而易见:head->next->next = head;这句话就实现了一个反转,与此同时,我们发现了这个节点还没和下一个节点断开连接,这时候我们进行一个断链表的操作head->next = NULL;其实这一步也实现了我们让开头节点指向NULL的过程。然后我们思考一下这时候应该返回那个节点,也就是我们原先的head->next但是反转后我们已经找不到他了,我们就应该调用一个临时变量来记录这头节点struct ListNode* cur = reverseList(head->next);这一步就调用了一个临时变量

通过这道题目的分析,我们不难发现我们模拟一下最简单子问题的过程,也就可以大致模拟出递归第三步的一些操作。我们一定要把递归函数当成一个宏观的函数,不能把他理解成每一步是如何操作的(否则我们就会一入递归深似海)。

回溯

说会回溯算法,我们先要明白如何理解递归,我们才可以理解回溯。

  • 回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
  • 回溯是递归的副产品,只要有递归就会有回溯

回溯法解决的问题

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

笔者目前只学习了如何解决组合问题,能力有限也仅仅只介绍一下组合问题的解决,后续还会继续学习这方面的内容。

回溯模板

这个模版其实和我们之前的三部曲内容大差不差,这个模版也只是有利于我们思考这类题目,根据模版思考也可以帮助初学者快速上手回溯法

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

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

组合问题

这里给出一道例题,我们通过这道题目去理解内容

77. 组合

{2326D59E-D291-4669-84EA-913BBBEB9C0B}

直接的解法当然是使用for循环,例如示例中k为2,很容易想到 用两个for循环,这样就可以输出 和示例中一样的结果。

int n = 4;
for (int i = 1; i <= n; i++) {
    for (int j = i + 1; j <= n; j++) {
        cout << i << " " << j << endl;
    }
}

输入:n = 100, k = 3 那么就三层for循环,代码如下:

int n = 100;
for (int i = 1; i <= n; i++) {
    for (int j = i + 1; j <= n; j++) {
        for (int u = j + 1; u <= n; n++) {
            cout << i << " " << j << " " << u << endl;
        }
    }
}

这时候我们就要想到用搜索解决这个问题了。

上面我们说了要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题

递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了

笔者这里借用代码随想录的图和话来帮助分析。

77.组合

确定参数

这样就清晰的表现了整个过程。我们就可以确定我们的变量的数量。

  • 数里一定有两个参数,既然是集合n里面取k个数,那么n和k是两个int型的参数。
  • 然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历
终止条件

path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。这时候保存我们的这个路径的组合

if (path.size() == k) {
     result.push_back(path); //满足条件就放入数组中 
     return;
}
每一步递归的逻辑

根据图我们可以看出我们最后的返回是由我们这一次递归的i决定的,我们永远要记得就是我们每一次递归都要进行一次回溯

for (int i = startIndex; i <= n; i++) {
        path.push_back(i);
        dfs(n, k, i + 1); //后面传入的是下一个开始的坐标
        path.pop_back(); //回溯
}

接下来我们进行一个完整的代码的给出。

class Solution {
private: 
    vector<vector<int>> result;
    vector<int> path;
    void dfs(int n, int k, int startIndex) {
        if (path.size() == k) {
            result.push_back(path); //满足条件就放入数组中 
            return;
        }
        for (int i = startIndex; i <= n; i++) {
            path.push_back(i);
            dfs(n, k, i + 1); //后面传入的是下一个开始的坐标 (根据宏观可以看出,我们这个函数求了从(i + 1)到 n 这个范围的一个情况)
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combine(int n, int k) {
        dfs(n, k, 1); //从一开始进行搜索
        return result;
    }
};

组合总和 |||

{7B369D5F-F642-424d-B916-BD5CE91ED6B7}

这道题目是上面那道题目的升级,我们需要重新考虑。

  • 确定参数 :我们从上面那道题目不难发现参数没有很大的差异,只不过我们需要一个sum来记录每一次的和是多少
  • 终止条件:我们需要sum和path的个数都符合条件才可以将该元素传入我们的数组
  • 每一步递归的逻辑 :我们需要对sum和path进行回溯和增加
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void huisu(int k, int n, int startIndex, int sum) {
        if (path.size() == k) {
            if (sum == n) { //符合条件就加入
                result.push_back(path);
            }
            return;
        }
        for (int i = startIndex; i <= 9; i++) {
            path.push_back(i);
            sum += i;
            huisu(k, n, i + 1, sum); //递归
            sum -= i; //回溯
            path.pop_back(); //回溯
        }
    }
public:
    vector<vector<int>> combinationSum3(int k, int n) {
        huisu(k, n, 1, 0);
        return result;
    }
};

电话号码的字母组合

{F1F795A4-B7B7-4a1c-9168-B6C89A1E666F}

  • 确定参数 :我们需要传入一个string字符串来记录我们的号码,const vector<string> vec = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; 建立一个字符串数组来存放数字与字母的对应关系,这个输入字符串我们同时也要传入。
  • 终止条件:我们需要path的个数都符合条件才可以将该元素传入我们的数组
  • 每一步递归的逻辑 :我们需要对path进行回溯和增加
class Solution {
public:
    const vector<string> vec = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; //建立一个字符串数组来存放数字与字母的对应关系
    vector<string> result;
    string path;
    void huisu(int startIndex, int k, const string& digits) {
        if (path.size() == k) {
            result.push_back(path);
            return;
        }
        int tmp = digits[startIndex] - '0'; //求出这个位置的下标
        for (int i = 0; i < vec[tmp].size(); i++) { //在每个数组下进行一个查找的操作
            path.push_back(vec[tmp][i]);
            huisu(startIndex + 1, k, digits);  
            path.pop_back(); //回溯操作
        }
    }
    vector<string> letterCombinations(string digits) {
        if (digits.size() == 0) {
            return result;
        }
        huisu(0, digits.size(), digits);
        return result;
    }
};

组合总和

{C1D447FA-E932-495e-8FE0-ADEB4335E0EF}

  • 确定参数 :我们从上面那道题目不难发现参数没有很大的差异,只不过我们需要一个sum来记录每一次的和是多少
  • 终止条件:我们需要sum和path的个数都符合条件才可以将该元素传入我们的数组
  • 每一步递归的逻辑 :我们需要对sum和path进行回溯和增加

这道题目主要的不同是我们可以重复选择。

class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    
    void huisu(int target, int sum, vector<int>& candidates, int startIndex) {
        if (sum > target) { //大于直接return
            return;
        } else if (sum == target) {
            result.push_back(path);
        }
        for (int i = startIndex; i < candidates.size(); i++) {
            path.push_back(candidates[i]);
            sum += candidates[i];
            huisu(target, sum, candidates, i); //回溯包括i的位置,意思就是可以多次选择
            sum -= candidates[i]; //回溯的步骤
            path.pop_back();
        }
    }
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        huisu(target, 0, candidates, 0);
        return result;
    }
};

小结

这就是笔者对于回溯算法的一个简单学习,之后还会继续补充后面的分割问题。笔者能力有限,如有错误,请不吝赐教。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值