基础算法(六):回溯算法

前言

        Hello大家好,停了半个多月算法学习的荔枝又变菜了,最近决定认认真真地重新学习回溯,无意间看到Carl哥的代码随想录,感动之余也是跟着一步步走,后悔上车晚了呜呜呜~~~。之前自己摸索确实有点难受,在这篇文章中,荔枝也准备仔仔细细梳理相关的问题和知识点,大家加油💪💪💪


文章目录

前言

一、回溯算法

二、组合问题

2.1 Leecode 77——组合

2.2 Leecode 40——组合总和||

三、切割问题

Leecode 131——分割回文串

四、子集问题

4.1 Leecode 78——子集 

4.2 LeeCode 90——子集||

五、排列问题

 5.1 LeeCode 46——全排列

六、棋盘问题

6.1 LeeCode 51——N皇后

6.2 LeeCode 37——解数独

总结


一、回溯算法

        回溯算法其实是我们接触最多的算法之一,作为暴力算法的代表,回溯算法解决了K重for循环无法键入的问题。同时,回溯算法也体现在所有的递归算法逻辑中,毕竟递归其实本质上就是递推+回溯。回溯的理解其实我们可以抽象为恢复现场,也就是当你选择某种枚举或者是搜索方式来寻找路径的时候,其余的同级选择的遍历前一定会通过回溯算法来实现恢复现场。回溯算法在OJ中的经典题型主要有五类问题需要我们全部掌握:组合问题、切割问题、子集问题、排列问题和棋盘问题。

回溯算法的解题步骤:

  • 确定backtrack回溯函数的参数和返回值
  • 确定边界条件,也就是回溯的终止条件
  • 确定单层回溯函数内For循环的逻辑

解题模板: 

//解题模板--一般来说设置为无返回值void
void backtrack(确定参数){
    if(终止条件){
        ...
        return;
    }
    //确定单层for循环逻辑
    for(...){
        ...
    }
}

二、组合问题

2.1 Leecode 77——组合

 

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

        组合问题的demo跟荔枝上面的答题模板很相像是不哈哈哈,回溯算法最经典的地方就是在组合问题, 思路其实很简单:当我们顺序遍历题目中的元素,如果v容器的大小达到题目要求就会被收集到结果result中,每一次调用递归函数之后都需要进行回溯操作回退到前一层,更换不同可能的路径选择。

demo示例: 

class Solution {
private:
    vector<vector<int>> result;
    vector<int> v;
    void back(int n,int k,int index){
        if(v.size()==k){
            result.push_back(v);
            return;
        }
        for(int i=index;i<=n;i++){
            v.push_back(i);
            back(n,k,i+1);
            v.pop_back();
        }
    }
public:
    vector<vector<int>> combine(int n, int k) {
        // 一道非常经典的回溯算法题目
        back(n,k,1);
        return result;
    }
};

来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/combinations/

看起来组合问题也不是很难呐哈哈哈,接下来看一道稍微难一点的:

2.2 Leecode 40——组合总和||

题目描述:

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。 

输入示例:

candidates = [10,1,2,7,6,1,5], target = 8,

输出示例:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

        由于输入数组中有重复的元素,因此我们需要在一开始将输入的数组进行排序,排序的目的是为了更好地去重。而对于去重的规则,我们使用了一个used数组来记录元素的使用情况,对于一个重复的元素,经过排序之后我们知晓相同的元素一定相邻,当我们i-1和i是重复的时候,选择i就会产生重复的组合,这是因为在i-1树枝上我们已经将i的情况全部遍历过了,因此当used[i-1]==used[i]&&used[i-1]==0时的情况应该被去重。 其余的递归回溯过程其实都跟模板是一样的!

demo示例:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> v;
    void back(vector<int>& candidates,int target,int sum,int index,vector<bool>& used){
        // 确定终止条件
        if (sum > target) {
            return;
        }
        if(sum == target){
            result.push_back(v);
            return;
        }
        for(int i=index;i<candidates.size();i++){
            // 去重
            if(i>0 && candidates[i]==candidates[i-1] && used[i-1]==0){
                continue;
            }
            v.push_back(candidates[i]);
            used[i] = true;
            back(candidates,target,sum+candidates[i],i+1,used);
            used[i] = false;
            v.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        if(candidates.size()==0){
            return result;
        }
        // 排序
        vector<bool> used(candidates.size(), false);
        sort(candidates.begin(), candidates.end());
        back(candidates,target,0,0,used);
        return result;
    }
};

来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/combination-sum-ii/


三、切割问题

        切割问题,其实也可以看成是组合问题的另一种表述,当我们确定分割的规则,利用index来记住我们同一树层的遍历次序。

Leecode 131——分割回文串

题目描述:

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案(回文串是正着读和反着读都一样的字符串)。

输入示例:

s = "aab"


输出示例:

[["a","a","b"],["aa","b"]]

        对于分割问题,每一个for循环就是在横向遍历同一树层的元素,递归的过程就是纵向的遍历树枝值,那么对于上一层切好的index,我们遍历i的取值来实现下一层的切割选择,所以[index,i]就是我们当次递归切割出来的子串,需要判断该子串是否是回文串,如果是就证明我们没切错。

demo示例:

class Solution {
private:
    vector<vector<string>> result;
    vector<string> v;
    void strSplit(string s,int index){
        if(index>=s.size()){
            result.push_back(v);
        }
        for(int i=index;i<s.size();i++){
            if(isTure(s,index,i)){
                string str = s.substr(index, i - index + 1);
                v.push_back(str);
            }else{
                continue;
            }
            strSplit(s,i+1);
            v.pop_back();
        }
    }
    //双指针法判断回文串
    bool isTure(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;
    }
public:
    vector<vector<string>> partition(string s) {
        if(s.length()==1){
            v.push_back(s);
            result.push_back(v);
            return result;
        }
        strSplit(s,0);
        return result;
    }
};

来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/palindrome-partitioning


四、子集问题

4.1 Leecode 78——子集 

题目描述:

给你一个整数数组 nums ,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集。你可以按任意顺序返回解集。

输入示例:

[1,2,3]

输出示例:

[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

子集问题相比于组合问题不同点在于它的递归函数收取结果的地方不止在叶子节点,而是所有符合要求的节点都属于该问题的子集,因此我们每进入递归函数的时候就开始收集结果。

class Solution {
private:
    vector<int> v;
    vector<vector<int>> result;
    void back(vector<int>& nums,int index){
        result.push_back(v);
        //边界条件
        if(index >= nums.size()) return;
        for(int i=index;i<nums.size();i++){
            v.push_back(nums[i]);
            back(nums,i+1);
            v.pop_back();
        }
    }
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        back(nums,0);
        return result;
    }
};

 题目来源:力扣(LeetCode)
链接: https://leetcode.cn/problems/subsets/

4.2 LeeCode 90——子集||

题目描述:

给你一个整数数组 nums ,其中可能包含重复元素,返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

输入示例:

[1,2,2]

输出示例:

[[],[1],[1,2],[1,2,2],[2],[2,2]] 

        子集||相比于子集在输入数组中增加了重复元素,因此我们需要使用一个数组used来标记同一层元素的使用情况,跟组合问题一样的去重规则,同样的在去重之前还是需要利用sort来使相同的元素排列相邻方便去重。

demo示例: 

class Solution {
private:
    vector<vector<int>> result;
    vector<int> v;
    void back(vector<int>& nums,int index,vector<bool>& used){
        result.push_back(v);
        for(int i=index;i<nums.size();i++){
            if(i > 0 && nums[i] == nums[i-1] && used[i-1] == false){
                continue;
            }
            v.push_back(nums[i]);
            used[i] = true;
            back(nums,i+1,used);
            used[i] = false;
            v.pop_back();
        }
    }

public:
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        if(nums.size()==0){
            return result;
        }
        vector<bool> used(nums.size(), false);
        sort(nums.begin(),nums.end());
        back(nums,0,used);
        return result;
    }
};

题目来源:力扣(LeetCode)
链接: https://leetcode.cn/problems/subsets-ii/


五、排列问题

排列问题和组合问题的最大的不同就是排列中是由顺序的,简单来说就是[1,2]和[2,1]是两种不同的结果。接下来我们来看看有重复元素和无重复元素的全排列问题的解法。

 5.1 LeeCode 46——全排列

题目描述:

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

输入示例:

[1,2,3]

输出示例:

[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

        排列问题其实跟组合问题很像,最大的不同就是排列问题不再会有index来记录遍历的元素,并且增加了一个used数组用来记录输入数组中所有元素的使用状态。在排列问题中我们会使用所有的数组元素,因此只要结果集中的数组长度等于输入数组的长度即可达到边界条件。 

demo示例:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> v;
    void backtrack(vector<int>& nums,vector<bool>& used){
        //确定回溯截止条件
        if(v.size()==nums.size()){
            result.push_back(v);
        }
        //确定单层回溯的规则
        for(int i=0;i<nums.size();i++){
            if(used[i]){
                continue;
            }
            used[i] = true;
            v.push_back(nums[i]);
            backtrack(nums,used);
            v.pop_back();
            used[i] = false;
        }

    }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<bool> used(nums.size(),false);
        backtrack(nums,used);
        return result;
    }
};

题目来源:力扣(LeetCode)
链接: https://leetcode.cn/problems/permutations/

 那如果输入的数组中有重复元素,我们应该如何处理呢?

我们来看看Leecode中的全排列||

跟组合问题一样,我们在最开始对输入数组中的元素进行排序,并利用used数组来帮助我们去重,去重的规则和组合问题相比也是一样的。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> v;
    void backtrack(vector<int>& nums,vector<bool>& used){
        //确定回溯截止条件
        if(v.size()==nums.size()){
            result.push_back(v);
        }
        //确定单层回溯的规则
        for(int i=0;i<nums.size();i++){
            if(used[i] || (i-1>=0 && nums[i-1] == nums[i] && used[i-1]==false)){
                continue;
            }
            used[i] = true;
            v.push_back(nums[i]);
            backtrack(nums,used);
            v.pop_back();
            used[i] = false;
        }

    }
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<bool> used(nums.size(),false);
        sort(nums.begin(),nums.end());
        backtrack(nums,used);
        return result;
    }
};

题目来源:力扣(LeetCode)
链接: https://leetcode.cn/problems/permutations-ii/


六、棋盘问题

        棋盘问题算是回溯算法种难度比较高的问题了,这里的难度其实并不是难在过程有多么复杂,主要是当我们用回溯算法模板刷爆前面的组合、切割、子集和排列问题后我们已经习惯处理一维容器的问题了,在棋盘问题中我们首先来看看最经典的N皇后问题:

6.1 LeeCode 51——N皇后

题目描述

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

输入示例:

4

输出示例:

[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]

        我们仔细来看看题目,题目对于皇后的放置是由要求的,也就是同一行、同一列、同一45度或者是135度的直线上不能同时存在两个及以上的皇后。 空的格子使用‘.’来表示,放置皇后的格子用‘Q’来表示。那么对于一个棋盘来说,我们的递归对象就是对棋盘上的每一行,for循环遍历对象是每一行上的元素,我们在下面封装了一个校验皇后放置的规则的函数isVaild(),当规则允许的时候我们就放置皇后并递推和回溯,直到触发边界条件:递归到了棋盘的边界。而关于判断皇后位置正误的逻辑其实很简单,手动画一个棋盘推一下就好了哈哈哈,这里荔枝给出一个图来辅助理解吧:

 

demo示例:

class Solution {
private:
    vector<vector<string>> result;
    void backtrack(vector<string>& chessboard,int n,int row){
        if(row==n){
            result.push_back(chessboard);
        }
        for(int i=0;i<n;i++){
            if(isVaild(row,i,chessboard,n)){
                chessboard[row][i] = 'Q';
                backtrack(chessboard,n,row+1);
                chessboard[row][i] = '.';
            }
        }
    }
    bool isVaild(int row,int col,vector<string>& chessboard,int n){
        // 对列上的皇后情况进行去除
        for(int i=0;i<row;i++){
            if(chessboard[i][col]=='Q'){
                return false;
            }
        }
        // 对左上方也就是135度方向进行皇后去除
        for(int i=row-1,j=col-1;i>=0 && j>=0;i--,j--){
            if(chessboard[i][j]=='Q'){
                return false;
            }
        }
        // 对右上方也就是45度方向进行皇后去除
        for(int i=row-1,j=col+1;i>=0 && j<n;i--,j++){
            if(chessboard[i][j]=='Q'){
                return false;
            }
        }
        return true;
    }
public:
    vector<vector<string>> solveNQueens(int n) {
        vector<string> chessboard(n,string(n,'.'));
        backtrack(chessboard,n,0);
        return result;
    }
};

看完这份代码其实大家可以看到其实N皇后问题也是用到了荔枝上面使用的题解模板,所以有了答题模板后的我们,确实强大了不少哈哈哈哈。

题目来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/n-queens

6.2 LeeCode 37——解数独

题目描述:

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则:

  • 数字 1-9 在每一行只能出现一次。
  • 数字 1-9 在每一列只能出现一次。
  • 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

输入示例:

[["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]

 

输出示例:

[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]

        这道题目相较于前面的N皇后问题最大的不同点就在于它是采用三层for来实现调用递归函数的过程的,由于题目的特殊要求,我们需要遍历数独矩阵中的每一个位进行查询改为上有没有数字,同时遍历数字1-9来看看同行、同列以及同一个九宫格中有没有重复的元素。整段代码的结构和之前相比其实没有什么大的区别。

demo示例:

class Solution {
private:
    bool backtrack(vector<vector<char>>& board){
        for(int i=0;i<board.size();i++){
            for(int j=0;j<board[0].size();j++){
                if(board[i][j]=='.'){
                    for(char k='1';k<='9';k++){
                        if(isValid(i,j,k,board)){
                            board[i][j] = k;
                            if(backtrack(board)) return true;
                            board[i][j] = '.';
                        }
                    }
                    return false;
                }
            }
        }
        return true;
    }
    bool isValid(int row,int col,char str,vector<vector<char>>& board){
        // 判断行里有没有重复的元素
        for(int i = 0;i<9;i++){
            if(board[row][i] == str){
                return false;
            }
        }
        // 判断列里面有没有重复元素
        for(int j = 0;j<9;j++){
            if(board[j][col]==str){
                return false;
            }
        }
        // 判断一个9方格种有没有重复的元素
        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(board[i][j]==str){
                    return false;
                }
            }
        }
        return true;
    }
public:
    void solveSudoku(vector<vector<char>>& board) {
        backtrack(board);
    }
};

题目来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/sudoku-solver


总结

        在这篇文章中,荔枝主要通过回溯算法的模板题解来类比回溯算法五大问题:组合、分割、子集、排列和棋盘问题,通过上述的代码我们发现其实一个模板就可以将几乎所有的回溯算法的问题都解决了。总算找到适合自己的刷题方法了,跟着Carl哥给的顺序刷这样收获真的很大哈哈哈哈。希望我的分享也能帮到一起学习的小伙伴嘻嘻嘻嘻。

今朝已然成为过去,明日依然向往未来!我是小荔枝,在技术成长的路上与你相伴,码文不易,麻烦举起小爪爪点个赞吧哈哈哈~~~ 比心心♥~~~

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
逻辑结构:描述数据元素之间的逻辑关系,如线性结构(如数组、链表)、树形结构(如二叉树、堆、B树)、图结构(有向图、无向图等)以及集合和队列等抽象数据类型。 存储结构(物理结构):描述数据在计算机中如何具体存储。例如,数组的连续存储,链表的动态分配节点,树和图的邻接矩阵或邻接表表示等。 基本操作:针对每种数据结构,定义了一系列基本的操作,包括但不限于插入、删除、查找、更新、遍历等,并分析这些操作的时间复杂度和空间复杂度。 算法算法设计:研究如何将解决问题的步骤形式化为一系列指令,使得计算机可以执行以求解问题。 算法特性:包括输入、输出、有穷性、确定性和可行性。即一个有效的算法必须能在有限步骤内结束,并且对于给定的输入产生唯一的确定输出。 算法分类:排序算法(如冒泡排序、快速排序、归并排序),查找算法(如顺序查找、二分查找、哈希查找),图论算法(如Dijkstra最短路径算法、Floyd-Warshall算法、Prim最小生成树算法),动态规划,贪心算法回溯法,分支限界法等。 算法分析:通过数学方法分析算法的时间复杂度(运行时间随数据规模增长的速度)和空间复杂度(所需内存大小)来评估其效率。 学习算法数据结构不仅有助于理解程序的内部工作原理,更能帮助开发人员编写出高效、稳定和易于维护的软件系统。
算法数据结构它们分别涵盖了以下主要内容: 数据结构(Data Structures): 逻辑结构:描述数据元素之间的逻辑关系,如线性结构(如数组、链表)、树形结构(如二叉树、堆、B树)、图结构(有向图、无向图等)以及集合和队列等抽象数据类型。 存储结构(物理结构):描述数据在计算机中如何具体存储。例如,数组的连续存储,链表的动态分配节点,树和图的邻接矩阵或邻接表表示等。 基本操作:针对每种数据结构,定义了一系列基本的操作,包括但不限于插入、删除、查找、更新、遍历等,并分析这些操作的时间复杂度和空间复杂度。 算法算法设计:研究如何将解决问题的步骤形式化为一系列指令,使得计算机可以执行以求解问题。 算法特性:包括输入、输出、有穷性、确定性和可行性。即一个有效的算法必须能在有限步骤内结束,并且对于给定的输入产生唯一的确定输出。 算法分类:排序算法(如冒泡排序、快速排序、归并排序),查找算法(如顺序查找、二分查找、哈希查找),图论算法(如Dijkstra最短路径算法、Floyd-Warshall算法、Prim最小生成树算法),动态规划,贪心算法回溯法,分支限界法等。 算法分析:通过数学方法分析算法的时间复杂度(运行时间随数据规模增长的速度)和空间复杂度(所需内存大小)来评估其效率。 学习算法数据结构不仅有助于理解程序的内部工作原理,更能帮助开发人员编写出高效、稳定和易于维护的软件系统。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值