回溯函数(算法)杂谈 -----可主动控制撤回逻辑处理的递归函数

概述

回溯,对接触了算法的人而言,并不陌生,其实严谨地说回溯函数就是递归函数,只不过在使用上,人们将它的一些细节抽离出来,进而演化出了所谓的回溯,在算法导论中,与其相关的被称为“回溯搜索算法”。

回溯本质是递归的副产物,只要有递归调用就会有回溯。

回溯法也经常和二叉树或N叉树遍历,深度优先搜索混在一起,因为这两种方式的简单实现都是用了递归(当然可以使用数据结构的栈模拟递归,来设计搜索遍历算法)。

接下来的杂谈中,大家也可以隐隐约约感觉,回溯就是在暴力搜索,只是它的优势在于,你可以在有限的函数操作里实现庞大搜索空间的操作,而不用设计冗余或重复的代码结构。

回溯和递归有个不同点就是回溯,它有一个显性逆回的步骤  (撤销本次处理的结果)

设计回溯函数的核心:1. 处理局部结果   2. 递归   3. 回溯 (撤回处理结果)

有几个要领  1. 在递归函数中对关键形参变量的调整 

                    2. 回溯对于待处理对象,大部分使用引用传参

N叉树情景

N叉树一般可以由很多问题抽象而来,比如,给定⼀个⽆重复元素的数组 candidates 和⼀个⽬标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

这里我介绍网上一个算法大咖的回溯模板, 利用循环进行递归调用很好地解决这类问题

回溯⼀般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度

for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历

根据不同的需求,可以纵横地扫描全局各结果的可能性

void backtracking(参数) {
    if (终⽌条件) {
        存放结果;
        return;    
    }
    
    //index用以控制横向的搜索空间
    for (index = 0; index < 总集合宽度 ; index++) {
        1. 处理节点; (处理外部需要收集结果的容器)
        
        2. backtracking(路径,index+1); // 递归
        

        3. 回溯
    }
}

在上面所提及的问题中,可以给出下面的解法

class Solution {
    private:
        vector<int> path;
        vector<vector<int>> result;

        void backingtrack(vector<int>& candidates, int sum, int target, int startindex){
            if(sum == target ){ 
                result.push_back(path);
                return;
            }

            if(target < sum)
                return;

            //定义startindex 可以根据 i 来 界定 回溯去重作用
            for(int i = startindex; i < candidates.size(); ++i){
                    sum += candidates[i];           //处理
                    path.push_back(candidates[i]);  //处理
                    backingtrack(candidates, sum, target, i);  //i != i+1 表示可以重复读取当前的数
                    path.pop_back();       //回溯
                    sum -= candidates[i];  //回溯
            }
        }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        path.clear();
        result.clear();

        backingtrack(candidates, 0, target, 0);
        return result;
    }
};

二叉树情景

在LeetCode上 有这样一个问题,数字为0~9之间

求根节点到叶节点数字之和,如下为两个示例及一种回溯的解法

 

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */

class Solution {
    private:
        int countSum(vector<int>& ve){
            int len = ve.size();
            int res = 0;

            for(int i = 0; i <len; i++){
                res += ve[i];
                if(i == len -1)
                    break;
                res *= 10;
            }

            //cout << res << endl;
            return res;
        }

        void Proback(TreeNode* root, vector<int>& ve, int &sum){
            if(root == nullptr)
                return;    //终止条件

            ve.push_back(root->val);  //处理结果
            if(root->left == nullptr && root->right == nullptr){
                sum += countSum(ve);  //处理结果  (可以提前终止 直接return)
                //return ;
            }

            if(root->left){
                Proback(root->left, ve, sum); //递归
                ve.pop_back();    //回溯
            }
            
            if(root->right){
                Proback(root->right, ve, sum);  //递归
                ve.pop_back();     //回溯
            }
            
            return;
        }
public:
    int sumNumbers(TreeNode* root) {
        vector<int> v;
        int sum = 0;
        Proback(root, v, sum);

        return sum;
    }
};

当然也可以使用二维数组 收集路径的总集,一维数组收集路径

class Solution {
    private:
        vector<vector<int>> ve;
        vector<int> path;

        void preorder(TreeNode* root){
            if(root == nullptr)
                return;

            path.push_back(root->val);  //处理
            if(root->left == nullptr && root->right == nullptr)
                ve.push_back(path);     //处理

            if(root->left){
                preorder(root->left);
                path.pop_back();   //回溯
            }

            if(root->right){
                preorder(root->right);
                path.pop_back();   //回溯
            }
        }
public:
    int sumNumbers(TreeNode* root) {
        preorder(root);

        int res = 0;

        for(int i=0; i < ve.size(); i++){
            int len = ve[i].size();       //path数组的长度
            int sum = 0;

            //计数
            for(int j = 0; j < len; j++){
                int tmp = ve[i][j] * pow(10, len-j-1);
                sum += tmp;
            }

            res += sum;
        }

        return res;
    }
};

回溯的使用也存在一些技巧和花样,比如下面两种实现方式,本质都是一样的,一个是通过主动调用而进行回溯操作(显式),一个是通过调整形参进而进行自动回溯(隐式)。

显式回溯

递归函数里主动地回溯

class Solution {
    private:
        string s;
        vector<string> res;
        unordered_map<int, string> index2string = {
            {0, ""}, {1, ""}, {2, "abc"}, {3, "def"}, {4, "ghi"}, {5, "jkl"}, {6, "mno"},
            {7, "pqrs"}, {8, "tuv"}, {9, "wxyz"}
        }; 

        void backtracking(string digits, int index){
            if(index == digits.size()){
                res.push_back(s);
                return;
            }

            int digit = (int)(digits[index] - '0');    // - '0' ASCII 差值 获得数值
            string letters = index2string[digit];
            
            for(int i = 0; i < letters.size(); ++i){
                s.push_back(letters[i]);              //处理
                backtracking(digits, index+1);        //递归
                s.pop_back();                         //回溯
            }
        }
public:
    vector<string> letterCombinations(string digits) {
        res.clear();
        s.clear();

        if(digits.size() == 0)
            return res;

        backtracking(digits, 0);
        return res;
    }
};

隐式回溯

递归函数里被动地回溯

把回溯隐藏在递归参数里,把参数写进递归函数 == 自动回溯。下面举个抽象的例子

(开过车的都知道,手动挡的车,比起自动挡而言,在切换变速杆时,多了离合器的控制,而这些其实在自动挡车,有汽车控制器帮你完成了)

class Solution {
    private:
        string s;
        vector<string> res;
        unordered_map<int, string> index2string = {
            {0, ""}, {1, ""}, {2, "abc"}, {3, "def"}, {4, "ghi"}, {5, "jkl"}, {6, "mno"},
            {7, "pqrs"}, {8, "tuv"}, {9, "wxyz"}
        }; 

        void backtracking(string digits, int index, const string& s){
            if(index == digits.size()){
                res.push_back(s);
                return;
            }

            int digit = (int)(digits[index] - '0');    // - '0' ASCII 差值 获得数值
            string letters = index2string[digit];
            
            for(int i = 0; i < letters.size(); ++i){
                
                backtracking(digits, index+1, s+letters[i]);        //处理+递归+回溯
                
            }
        }
public:
    vector<string> letterCombinations(string digits) {
        res.clear();
        s.clear();

        if(digits.size() == 0)
            return res;

        backtracking(digits, 0, "");
        return res;
    }
};

应用场景

这里以算法题中几个经典问题来谈谈其巧妙的应用,尽量以显式回溯来阐述。

排列组合问题

这里比较取巧地使用了交换的思想来处理排列,排列是有序的,使用N叉树思想,在循环中嵌套递归,并且每层都是从0开始搜索而不是startIndex。

class Solution {
    private:
        vector<vector<int>> res;
        void backTrack(vector<vector<int>>& res, vector<int>& out, int cur, int len){
            if(cur == len){
                res.emplace_back(out);
                return;
            }

            for(int i = cur; i < out.size(); ++i){
                swap(out[cur], out[i]);            //处理
                backTrack(res, out, cur+1, len);   //递归
                swap(out[cur], out[i]);            //回溯
            }
        }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        backTrack(res, nums, 0, nums.size()-1);

        return res;
    }
};

不同于排列问题,组合是无序的,仍然是利用N叉树思想,用for循环嵌套递归来控制搜索的数量,遍历所有结果。

class Solution {
    private:
        vector<int> path;
        vector<vector<int>> result;
        void Backtracking(int n, int k, int startindex){
            if(path.size() == k){
                result.push_back(path);
                return;     //终止条件
            }

            for(int i = startindex; i <= n; i++){   // 每次从startindex开始
                path.push_back(i);          //处理结果
                Backtracking(n, k, i+1);    //递归
                path.pop_back();            //回溯
            }
    }
    public:
        vector<vector<int>> combine(int n, int k) {

            /*第一层中 [1, n-k+1]   进入下一层;
             右边界扩充  [startindex, n-k+1 + path.size()]
            */

            path.clear();
            result.clear();

            Backtracking(n, k, 1);
            return result;
        }
};

切割子集问题

类似于排列组合问题,但是在处理结果上进行了一些调整,在处理中引入了逻辑判断条件,后续的数独问题也是引入了逻辑判断条件,但是更加复杂。

切割要点:1.模拟切割线  2.递归终止 3.在递归循环中截取子串   4.判断回文

class Solution {
    private:
        bool ispartition(const string& s, int start, int end){
            for(int i = start, j = end; i < j; i++, j--){
                if(s[i] != s[j])
                    return false;
            }

            return true;
        }

        vector<string> path;
        vector<vector<string>> result;

        /*
            startindex 是搜索的起始位置
            for 循环是重新一条N叉树的分支, 递归是深度搜索
        */ 
        void backtracing(string& s, int startindex){
            if(startindex >= s.size()){
                result.push_back(path);
                return;     //终止条件
            }

            for(int i = startindex; i < s.size(); ++i){
                if(ispartition(s, startindex, i)){             //处理
                    string str = s.substr(startindex, i-startindex+1);
                    path.push_back(str);
                }else{
                    continue;
                }

                backtracing(s, i+1);   //递归 ;index+1 表示不重复搜索
                path.pop_back();       //回溯

            }
        }

public:
    vector<vector<string>> partition(string s) {
        path.clear();
        result.clear();

        backtracing(s, 0);

        return result;
    }
};

子集的要点:

子集问题是在N叉树形结构中要收集所有节点的结果,而组合问题是收集叶子节点的结果

就是边递归边存值的问题,for循环表示横向,每一层都递增一个可能的结果。

class Solution {
    private:
        vector<int> path;
        vector<vector<int>> result;

        void backtracing(vector<int>& nums, int startindex){
            if(startindex > nums.size())
                return;   //终止条件

            for(int i = startindex; i < nums.size(); ++i){
                path.push_back(nums[i]);     //处理
                result.push_back(path);      //每次都要存结果,每一层的不同结果
                backtracing(nums, i+1);      //递归;index去重
                path.pop_back();             //回溯

            }

        }
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        result.clear();
        path.clear();

        backtracing(nums, 0);
        result.push_back({});  //单独处理空集,题目有要求空集也算上
        return result;
    }
};

棋盘数独问题

这类问题,就是叠加了多层循环嵌套的递归了,可能嵌套在判断条件,可能嵌套在搜索条件,不再像前面两种应用问题里的,单独一个循环可以考虑充分。可以想象为,每个节点都是一个棋盘或一局数独矩阵,而在前面两类问题上,每一个节点,仅仅只是一个数组

棋盘,抽象成二维矩阵,矩阵中的长既是N叉树的树形结构中每一个节点的棋盘长度,又是N叉树的高度;矩阵中的宽就是N叉树的树形结构中每一个节点的棋盘宽度。

以N皇后为例,这是嵌套在判断条件里的。

棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,只要搜索到了树的叶子节点,说明就找到了皇后们的位置了,然后收集合理的结果。

class Solution {
    private:
        vector<vector<string>> result; //3 dim
        vector<string> path;
        

        bool isvalid(int row, int col, vector<string>& path, int n){

            //col
            for(int i = 0; i < row; ++i){
                if(path[i][col] == 'Q') return false;
            }

            //45
            for(int i = row-1, j =col -1; i >=0 && j >= 0; i--, j--){
                if(path[i][j] == 'Q') return false;
            }

            //135
            for(int i = row -1, j = col + 1; i>= 0 && j < n; i--, j++){
                if(path[i][j] == 'Q') return false;
            }

            return true;

        }

        void backtracing(int n, int row){
            if(row == n){
                result.push_back(path);
                return;
            }

            // 取数 ; for 循环的 下一次循环
            for(int col = 0; col < n; col++){
                if(isvalid(row, col, path, n)){  //嵌套判断条件
                    path[row][col] = 'Q';     //处理
                    backtracing(n, row+1);    //递归
                    path[row][col] = '.';     //回溯
                }
            }
        }
        

public:
    vector<vector<string>> solveNQueens(int n) {
        result.clear();
        path = vector<string>(n , string(n, '.'));

        backtracing(n, 0);

        return result;
    }
};

数独,不同于N皇后,每一行每一列都只有一个有效位置的逻辑(局部有效进而全局有效),在递归过程(N皇后问题是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,把逻辑判断嵌套在判断条件里,然后一行一列确定皇后的唯一位置),数独中棋盘的每一个位置都要放一个数字(多重局部有效进而全局有效),并检查数字是否合法,解数独的树形结构要比N皇后更杂,更宽,更深,不再是简单的一行或列出现一个。

而且在棋盘的基础上,抽象了N叉树节点的棋盘大小就是9*9的,因此需要进一步拓展思维,用常量来设置嵌套在判断条件,并同时用常量设置嵌套在搜索条件,因为此刻棋盘固定,最终为三维递归

class Solution {
    private:
        
        bool isvalid(int row, int col, char val, vector<vector<char>>& result){

            //row
            for(int i =0; i < 9; ++i){
                if(result[row][i] == val)
                    return false;
            }

            //col
            for(int i = 0; i < 9; ++i){
                if(result[i][col] == val)
                    return false;
            }

            //3*3
            int startrow = (row / 3) * 3;
            int startcol = (col / 3) * 3;
            for(int i = startrow; i < startrow + 3; ++i){
                for(int j = startcol; j < startcol + 3; ++j){
                    if(result[i][j] == val)
                        return false;
                }
            }

            return true;
        }

        // 搜索单条路径
        // 两层for循环刻画棋盘
        bool backtracing(vector<vector<char>>& result){
            for(int i = 0; i < result.size(); ++i){
                for(int j = 0; j < result[0].size(); ++j){
                    if(result[i][j] == '.'){
                        // for as char
                        for(char k = '1'; k <= '9'; ++k){
                            if(isvalid(i, j, k, result)){              //嵌套判断条件
                                result[i][j] = k;                      //处理
                                if(backtracing(result)) return true;   //递归
                                result[i][j] = '.';                    //回溯
                            }
                        }

                        return false;
                    }
                }
            }

            return true;
        }
public:
    void solveSudoku(vector<vector<char>>& board) {

        backtracing(board);

    }
};

拓展应用

其实回溯的花样远不止本文所总结的这些,它可以和很多问题进行杂糅,并且在处理上有更灵活的做法,比如经典的迷宫游戏问题,本文杂谈就简单地归纳和个人理解的可能有误,如果大家发现问题还请指正下。

算法的种类和应用千千万,需要自行沉淀去悟出深意,感兴趣的读者可以去搜索了解哈。

完结撒花!!!!!!!!!

  • 21
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值