【回溯算法】回溯,从入门到入土,七道试题精选、精讲、精练

时间:2022-07-23
本文章向大家介绍【回溯算法】回溯,从入门到入土,七道试题精选、精讲、精练,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

文章目录
  • 前期准备
  • 岛屿最大面积
    • 思路
    • 代码实现
  • 八皇后问题
    • 思路
    • 代码实现
  • 括号生成
    • 思路
    • 代码实现
  • 全排列
    • 思路
    • 代码实现
    • 再说两句
    • 解回溯题的一般步骤
  • 电话号码的字母组合
    • 思路
    • 代码实现
  • 子集
    • 思路
    • 代码实现
  • 单词搜索
    • 思路
    • 代码实现

前期准备

回溯,说实话,难搞,反正我现在也在路上,还没如土。

前期准备,要玩得转回溯,递归的基础还是要有的,所以前些日子我就先把递归部分给办了。 【LeetCode】递归 原理入门+复杂度计算+练手试题

如果不是大佬,咱还是老老实实先把递归过一遍吧。 接下来直接上试题,我都亲手抄过好几遍。。。

岛屿最大面积、八皇后问题、括号生成感觉比较简单,所以思路讲的就比较简陋,适合入门练手,建议看其他题目的讲解(全排列那题)。

岛屿最大面积

给定一个包含了一些 0 和 1 的非空二维数组 grid 。

一个 岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。

找到给定的二维数组中最大的岛屿面积。(如果没有岛屿,则返回面积为 0 。)

示例 1:

[[0,0,1,0,0,0,0,1,0,0,0,0,0],
 [0,0,0,0,0,0,0,1,1,1,0,0,0],
 [0,1,1,0,1,0,0,0,0,0,0,0,0],
 [0,1,0,0,1,1,0,0,1,0,1,0,0],
 [0,1,0,0,1,1,0,0,1,1,1,0,0],
 [0,0,0,0,0,0,0,0,0,0,1,0,0],
 [0,0,0,0,0,0,0,1,1,1,0,0,0],
 [0,0,0,0,0,0,0,1,1,0,0,0,0]]

对于上面这个给定矩阵应返回 6。注意答案不应该是 11 ,因为岛屿只能包含水平或垂直的四个方向的 1 。

示例 2:

[[0,0,0,0,0,0,0,0]]

对于上面这个给定的矩阵, 返回 0。

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/max-area-of-island 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

这题其实我自己做出来了,把它放在第一题嘛,我觉得因为它是比较简单的。 从头开始遍历,遇到“1”就展开上下左右搜索,搜空了就回溯,在搜索过程中计数,并将搜索到的位置全部置0,防止二次污染。

代码实现

int checkIsland(vector<vector<int>>& grid, int x, int y) {
        int count = 0;
        if (grid[x][y] == 0)
            return count;
        else {
            grid[x][y] = 0;
            count++;
            //上
            if (x - 1 >= 0)
                count += checkIsland(grid, x - 1, y);
            //右
            if ((y + 1) < grid[0].size())
                count += checkIsland(grid, x, y + 1);
            //下
            if ((x + 1) < grid.size())
                count += checkIsland(grid, x + 1, y);
            //左
            if ((y - 1) >= 0)
                count += checkIsland(grid, x, y - 1);
        }
        return count;
    }

    int maxAreaOfIsland(vector<vector<int>>& grid) {
        if (grid.size() == 0)
            return 0;
        int max = 0;
        for (int i = 0; i < grid.size(); i++) {
            for (int j = 0; j < grid[0].size(); j++) {
                int temp = checkIsland(grid, i, j);
                if (temp > max) {
                    max = temp;
                }
            }
        }
        return max;
    }

八皇后问题

八皇后问题是一个古老的问题,于1848年由一位国际象棋棋手提出:在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,如何求解?

思路

将每一个棋子放在当前列的最前端,再放下一个棋子。 如果下一个棋子没地方放了,就回溯到前一个棋子,将其往后一个适宜位置挪动。 具体思路都在代码里了。

代码实现

#include<iostream>

using namespace std;

#define MAX_NUM 8		//皇后数量

int queen[MAX_NUM][MAX_NUM] = { 0 };

bool check(int x, int y) {	//检查一个坐标是否可以放置
	for (int i = 0; i < y; i++) {
		if (queen[x][i] == 1) {	//这一行是否可以存在
			return false;
		}
		if (x - 1 - i > 0 && queen[x - 1 - i][y - 1 - i] == 1) {	//检查左斜列
			return false;
		}
		if (x + 1 + i < MAX_NUM && queen[x + 1 + i][y + 1 + i] == 1) {	//检查右斜列
			return false;
		}
	}
	queen[x][y] = 1;
	return true;
}

void showQueen() {
	for (int i = 0; i < MAX_NUM; i++) {
		for (int j = 0; j < MAX_NUM; j++) {
			cout << queen[i][j] << " ";
		}
		cout << endl;
	}
	cout << endl;
}

bool settleQueen(int x) {
	if (x == MAX_NUM) {	//遍历完毕,找到答案
		return true;
	}
	for (int i = 0; i < MAX_NUM; i++) {
		for (int y = 0; y < MAX_NUM; y++) {
			queen[y][x] = 0;	//清空当前列,省的回溯的时候被打扰
		}
		if (check(i,x)) {	//如果这行找着了
			queen[i][x] = 1;
			showQueen();	//直观测试结果
			if (settleQueen(x + 1)) {	//是时候往左了
				return true;	//一路往左
			}
		}
	}
	return false;	//如果不行,就退回来
}

括号生成

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例:

输入:n = 3
输出:[
       "((()))",
       "(()())",
       "(())()",
       "()(())",
       "()()()"
     ]

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/generate-parentheses 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

如果左括号数量不大于 nnn,我们可以放一个左括号。如果右括号数量小于左括号的数量,我们可以放一个右括号。

代码实现

	vector<string> generateParenthesis(int n) {
        vector<string> res;
        generateParenthesisDFS(n, n, "", res);
        return res;
    }
    void generateParenthesisDFS(int left, int right, string out, vector<string> &res) {
        if (left > right) return;
        if (left == 0 && right == 0) res.push_back(out);
        else {
            if (left > 0) generateParenthesisDFS(left - 1, right, out + '(', res);
            if (right > 0) generateParenthesisDFS(left, right - 1, out + ')', res);
        }
    }

全排列

给定一个 没有重复 数字的序列,返回其所有可能的全排列。

示例:

输入: [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/permutations 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

“全排列”就是一个非常经典的“回溯”算法的应用。我们知道,N 个数字的全排列一共有 N!N!N! 这么多个。

大家可以尝试一下在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。

以数组 [1, 2, 3] 的全排列为例。

我们先写以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2];
再写以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1];
最后写以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1]。

我们只需要按顺序枚举每一位可能出现的情况,已经选择的数字在接下来要确定的数字中不能出现。按照这种策略选取就能够做到不重不漏,把可能的全排列都枚举出来。

在枚举第一位的时候,有 3 种情况。
在枚举第二位的时候,前面已经出现过的数字就不能再被选取了;
在枚举第三位的时候,前面 2 个已经选择过的数字就不能再被选取了。

使用编程的方法得到全排列,就是在这样的一个树形结构中进行编程,具体来说,就是执行一次深度优先遍历,从树的根结点到叶子结点形成的路径就是一个全排列。

说明:

1、每一个结点表示了“全排列”问题求解的不同阶段,这些阶段通过变量的“不同的值”体现; 2、这些变量的不同的值,也称之为“状态”; 3、使用深度优先遍历有“回头”的过程,在“回头”以后,状态变量需要设置成为和先前一样; 4、因此在回到上一层结点的过程中,需要撤销上一次选择,这个操作也称之为“状态重置”; 5、深度优先遍历,可以直接借助系统栈空间,为我们保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此 path 变量是一个栈。 6、深度优先遍历通过“回溯”操作,实现了全局使用一份状态变量的效果。

下面我们解释如何编码:

1、首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即在已经选了一些数的前提,我们需要在剩下还没有选择的数中按照顺序依次选择一个数,这显然是一个递归结构;

2、递归的终止条件是,数已经选够了,因此我们需要一个变量来表示当前递归到第几层,我们把这个变量叫做 depth;

3、这些结点实际上表示了搜索(查找)全排列问题的不同阶段,为了区分这些不同阶段,我们就需要一些变量来记录为了得到一个全排列,程序进行到哪一步了,在这里我们需要两个变量: (1)已经选了哪些数,到叶子结点时候,这些已经选择的数就构成了一个全排列; (2)一个布尔数组 used,初始化的时候都为 false 表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true ,这样在考虑下一个位置的时候,就能够以 O(1) 的时间复杂度判断这个数是否被选择过,这是一种“以空间换时间”的思想。

我们把这两个变量称之为“状态变量”,它们表示了我们在求解一个问题的时候所处的阶段。

4、在非叶子结点处,产生不同的分支,这一操作的语义是:在还未选择的数中依次选择一个元素作为下一个位置的元素,这显然得通过一个循环实现。

5、另外,因为是执行深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做“状态重置”,即“回到过去”、“恢复现场”,我们举一个例子。

从 [1, 2, 3] 到 [1, 3, 2] ,深度优先遍历是这样做的,从 [1, 2, 3] 回到 [1, 2] 的时候,需要撤销刚刚已经选择的数 3,因为在这一层只有一个数 3 我们已经尝试过了,因此程序回到上一层,需要撤销对 2 的选择,好让后面的程序知道,选择 3 了以后还能够选择 2。

这种在遍历的过程中,从深层结点回到浅层结点的过程中所做的操作就叫“回溯”。

作者:liweiwei1419 链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

代码实现

	vector<vector<int>> permute(vector<int>& nums) {
        if(nums.empty()) return {};
        // 存放最后结果
        vector<vector<int>> ans;
        // 存放某一个排列
        vector<int> temp;
        // 判断该数字是否被使用过
        vector<bool> used(nums.size(),false);
        // 进行递归求解
        dfs(ans,temp,used,nums);

        return ans;
    }

    void dfs(vector<vector<int>>& ans,vector<int>& temp,vector<bool>& used,
    const vector<int>& nums)
    {
        // 如果当前排列数组的长度等于输入数组的长度
        // 该排列已完成
        // 将该排列的加入结果中,返回
        if(temp.size()==nums.size())
        {
            ans.push_back(temp);
            return;
        }

        // 循环的进行枚举所有状态
        for(int i=0;i<nums.size();++i)
        {
            // 该数字已经选择过,跳过
            if(used.at(i)) continue;

            // 选择当前数字
            temp.push_back(nums.at(i));
            // 记录该数字已被选择
            used.at(i)=true;
            // 递归选择下一个数字
            dfs(ans,temp,used,nums);
            // 回溯,撤销当前选择
            used.at(i)=false;
            temp.pop_back();
        }
    }

再说两句

1、为什么使用深度优先遍历?

(1)首先是正确性,只有遍历状态空间,才能得到所有符合条件的解;

(2)在深度优先遍历的时候,不同状态之间的切换很容易,可以再看一下上面有很多箭头的那张图,每两个状态之间的差别只有 1 处,因此回退非常方便,这样全局才能使用一份状态变量完成搜索;

(3)如果使用广度优先遍历,从浅层转到深层,状态的变化就很大,此时我们不得不在每一个状态都新建变量去保存它,从性能来说是不划算的;

(4)如果使用广度优先遍历就得使用队列,然后编写结点类。使用深度优先遍历,我们是直接使用了系统栈,系统栈帮助我们保存了每一个结点的状态信息。于是我们不用编写结点类,不必手动编写栈完成深度优先遍历。大家可以尝试使用广度优先遍历实现一下,就能体会到这一点。

2、不回溯可不可以?

可以。搜索问题的状态空间一般很大,如果每一个状态都去创建新的变量,时间复杂度是 O(N)。在候选数比较多的时候,在非叶子结点上创建新的状态变量的性能消耗就很严重。

就本题而言,只需要叶子结点的那个状态,在叶子结点执行拷贝,时间复杂度是 O(N)。路径变量在深度优先遍历的时候,结点之间的转换只需要 O(1)。

最后,由于回溯算法的时间复杂度很高,因此,如果在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,就可以提前结束,这一步操作称之为剪枝。

回溯算法会大量应用“剪枝”技巧达到以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但一般而且能够剪枝节约的时间更多。还有正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。否则时间消耗又上去了。

作者:liweiwei1419 链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

解回溯题的一般步骤

第 1 步都是先画图,画图是非常重要的,只有画图才能帮助我们想清楚递归结构,想清楚如何剪枝。就拿题目中的示例,想一想人手动操作是怎么做的,一般这样下来,这棵递归树都不难画出。

即在画图的过程中思考清楚: 1、分支如何产生; 2、题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径? 3、哪些搜索是会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?

作者:liweiwei1419 链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].

说明: 尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/letter-combinations-of-a-phone-number 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

在前面的一些步骤做出一些修改,并重新尝试找到可行解。

给出如下回溯函数 backtrack(combination, next_digits) ,它将一个目前已经产生的组合 combination 和接下来准备要输入的数字 next_digits 作为参数。

如果没有更多的数字需要被输入,那意味着当前的组合已经产生好了。 如果还有数字需要被输入: 遍历下一个数字所对应的所有映射的字母。 将当前的字母添加到组合最后,也就是 combination = combination + letter 。 重复这个过程,输入剩下的数字: backtrack(combination + letter, next_digits[1:]) 。

代码实现

其实代码里的备注还更加全全面

 //1. 用map记录每个数字按键对应的所有字母
    map<char, string> M = {
        {'2', "abc"}, {'3', "def"}, {'4', "ghi"}, {'5', "jkl"}, {'6', "mno"},
        {'7', "pqrs"}, {'8', "tuv"}, {'9', "wxyz"}
    };
    //2. 存储最终结果和临时结果的变量
    vector<string> ans;
    string current;

    //3. DFS函数,index是生成临时结果字串的下标,
    //每一个digits[index]数字对应临时结果current[index]的一位字母
    void DFS(int index, string digits) {
        //出口
        if(index == digits.size()) {
            ans.push_back(current);
            return;
        }
        //递归调用
        //对于当前输入的第index号数字(digits[index]),
        //枚举其对应的所有字母(M[digits[index]][i])
        for(int i = 0; i < M[digits[index]].size(); i++) {
            current.push_back(M[digits[index]][i]);     //临时结果压入一个字母
            DFS(index + 1, digits);         //以在当前位置压入该字母这一“情况”为前提,构造此“分支”的后续结果
            current.pop_back();             //状态还原,例如临时结果从 "ab" -> "a",下一次循环尝试"ac" 
        }
    }

    vector<string> letterCombinations(string digits) {
        if(digits.size() == 0) {
            return ans;
        }
        DFS(0, digits);
        return ans;
    }

子集

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集。

示例:

输入: nums = [1,2,3]
输出:
[
  [3],
  [1],
  [2],
  [1,2,3],
  [1,3],
  [2,3],
  [1,2],
  []
]

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/subsets 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

定义一个回溯方法 backtrack(first, curr),第一个参数为索引 first,第二个参数为当前子集 curr。

如果当前子集构造完成,将它添加到输出集合中。

否则,从 first 到 n 遍历索引 i。

    将整数 nums[i] 添加到当前子集 curr。

    继续向子集中添加整数:backtrack(i + 1, curr)。

    从 curr 中删除 nums[i] 进行回溯。

代码实现

class Solution {
private:
    vector<vector<int>> ret;
    vector<int> temp;
public:
   
    void DFS(vector<int>& nums,int pre,int level) {
        //退出条件:前面插入的是数组中最后一个元素
        if (pre == nums.back()) {
            temp.clear();
            return;
        }
            
        for (int i = 0; i < nums.size(); i++) {
            if (nums[i] <= pre)
                continue;
            temp.push_back(nums[i]);
            ret.push_back(temp);
            DFS(nums, nums[i],i+1);

            if (level)
                return;
        }
    }

    vector<vector<int>> subsets(vector<int>& nums) {
        ret.push_back({});
        if (nums.empty())
            return ret;
        DFS(nums, INT_MIN,0);
        return ret;
    }
};

单词搜索

给定一个二维网格和一个单词,找出该单词是否存在于网格中。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

示例:

board =
[
  ['A','B','C','E'],
  ['S','F','C','S'],
  ['A','D','E','E']
]

给定 word = “ABCCED”, 返回 true 给定 word = “SEE”, 返回 true 给定 word = “ABCB”, 返回 false

提示:

board 和 word 中只包含大写和小写英文字母。
1 <= board.length <= 200
1 <= board[i].length <= 200
1 <= word.length <= 10^3

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/word-search 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路

绕了一圈,又绕回“岛屿最大面积”这种二维铺开搜索的题目了。

作者:liweiwei1419 链接:https://leetcode-cn.com/problems/word-search/solution/zai-er-wei-ping-mian-shang-shi-yong-hui-su-fa-pyth/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

代码实现

	int dir[4][4]={{-1,0},{1,0},{0,-1},{0,1}};
    
    bool exist(vector<vector<char>>& board, string word) {
        int m=board.size();
        int n=board[0].size();
        vector<vector<bool>> visited(m,vector<bool>(n));
        
        
        for(int i=0;i<m;i++){
            for(int j=0;j<n;j++){
                if(dfs(i,j,0,board,word,visited))
                    return true;
            }
        }
        return false;
    }
    
    bool dfs(int x,int y,int index,vector<vector<char>>& board,string &word,vector<vector<bool>>& visited){
        if(index==word.size()-1){
            return word[index]==board[x][y];
        }
        
        if(word[index]==board[x][y]){
            visited[x][y]=true;
            for(int i=0;i<4;i++){
                int new_x=x+dir[i][0];
                int new_y=y+dir[i][1];
                
                if(new_x>=0&&new_x<board.size()&&new_y>=0&&new_y<board[0].size()&&!visited[new_x][new_y])
                    if(dfs(new_x,new_y,index+1,board,word,visited))
                        return true;
            }
            visited[x][y]=false;
            
        }
        return false;
    }

等着二刷吧,太难了。。。