【算法思维优先搜索】回溯&DFS&BFS

深度优先搜索广度优先搜索是两种最常见的优先搜索方法,他们被广泛地应用在图和树等数据结构中进行搜索。

回溯算法就是一个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作。【更具体的见第二部分】

带着问题阅读 并 解决自身疑惑:

区分 回溯、广度优先搜索
区分 图结构【在深度优先搜索 中的用法】、树结构【搜索树 在回溯中的用法】两者用法的不同之处!

BFS出现的问题:

BFS实际应用解决的问题:① 最短路径or step or depth
②对应防止死循环,1)要有辅助结构:queue ; 2) 要有 辅助函数:邻域的的节点要能访问到 且 不至于死循环【除却 二叉树外,因为不会访问到父节点;其它的:数组啊, string啊 都要防止死循环】
③ 注意:邻域的 选取方式——对应的 要设置为 visited;且 未访问过等。
④ 注意:结束条件

回溯思想中需要注意的几个点:

废话不多说,直接上回溯算法框架。**解决一个回溯问题,实际上就是一个决策树的遍历过程。**你只需要思考4个问题:

1、路径:也就是已经做出的选择。

2、选择列表:也就是你当前可以做的选择。 —— 【决定了搜索空间,决定了搜索空间有哪些节点。】

3、结束条件:也就是到达决策树底层,无法再做选择的条件。——【决定了什么时候捕获有效的解,提前结束递归,开始回溯。】

4、约束,用来剪枝,避免进入无效的分支。

BFS框架中需要注意的几个点:

  1. visited——防止死循环,跟随 在入队时候 【邻接矩阵 visited,可以用 设置为0/2 防止不访问。】
  2. do visit —— 两种方式,但是注意 根据实际情况使用。且注意 第一种位置,在两边都需要visit
  3. 结束条件——如果求最短距离,注意 结束条件的变换。
  4. 邻域 与 do visit 灵活运用【return 提前快速 - 参考链接T934.】

一、深度优先搜索DFS

区别于BFS,深度优先搜索类似于 树的先序遍历。

题目1 最大岛屿面积

在这里插入图片描述
1)解法一:简单的深度优先搜索

类似于树的先序遍历

此题是十分标准的搜索题,我们可以拿来练手深度优先搜索。一般来说,深度优先搜索类型的题可以分为主函数和辅函数,主函数用于遍历所有的搜索位置,判断是否可以开始搜索,如果可以即在辅函数进行搜索。辅函数则负责深度优先搜索的递归调用。当然,我们也可以使用栈(stack)实现深度优先搜索,但因为栈与递归的调用原理相同,而递归相对便于实现,因此刷题时笔者推荐使用递归式写法,同时也方便进行回溯(见下节) 。不过在实际工程上,直接使用栈可能才是最好的选择,一是因为便于理解,二是更不易出现递归栈满的情况。我们先展示使用栈的写法。

#include <algorithm>
#include <iostream>
#include <map>
#include <unordered_map>
#include <vector>
#include <windows.h>

using namespace std;
class Solution
{
public:
    /**
     * @Description: 使用深度优先搜索递归调用; 相当于先序遍历 —— 先搜索当前的位置,然后再深入/递归到 循环调用自身,找到周围的岛屿 ++。
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    int maxAreaOfIsland(vector<vector<int>> &grid)
    {
        int max = 0, now = 0;
        for (int i = 0; i < grid.size(); i++)
            for (int j = 0; j < grid[0].size(); j++)
            {
                //r c 找到位置下面开始dfs
                if (grid[i][j])
                {
                    now = dfs1(grid, i, j);
                    max = max < now ? now : max;
                }
            }
        return max;
    }

protected:
    int dfs(vector<vector<int>> &grid, int r, int c)
    {
        int rel = 0;

        // 限定防止在边缘  或者 跑出递归的边界
        // 注意边缘也是可以为1的!
        if (r > -1 && r < grid.size() && c > -1 && c < grid[0].size() && grid[r][c])
        {
            int di[] = {-1, 1, 0, 0}, dj[] = {0, 0, -1, 1}; //分别对应上下左右深搜方向
            // 相当于先序遍历开始;
            grid[r][c] = 0;
            rel += 1;
            // 再进行深搜
            for (int i = 0; i < 4; i++)
            {
                rel += dfs(grid, r + di[i], c + dj[i]);
            }
        }
        return rel;
    }
    int dfs1(vector<vector<int>> &grid, int r, int c)
    {
        if (r > -1 && r < grid.size() && c > -1 && c < grid[0].size() && grid[r][c])
        {
            // 相当于先序遍历开始;
            grid[r][c] = 0;
            // 再进行深搜--- 修改直接 return
            return 1 + dfs1(grid, r-1,c) + dfs1(grid, r+1,c) + dfs1(grid, r, c-1) + dfs1(grid, r, c+1);
        }
        // 其它
        else
        {
            return 0;
        }
    }
};

int main()
{
    vector<vector<int>> arr = {{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}};
    vector<vector<int>> arr1 = {{0, 0, 0, 0, 0, 0, 0, 0}};
    vector<vector<int>> arr2 = {{1, 1, 0, 0, 0}, {1, 1, 0, 0, 0}, {0, 0, 0, 1, 1}, {0, 0, 0, 1, 1}};
    Solution so;
    // int maxArea = so.maxAreaOfIsland(arr);
    int maxArea = so.maxAreaOfIsland(arr2);
    cout << "当前最大的面积为:" << maxArea << endl;
    system("pause");
    return 0;
}

2)解法二:深搜+栈形式

栈本身与递归的调用原理相同;
区别在于 容易理解;且在这里的方法类似于树的先序遍历;而且不会发生栈溢出的现象。【一样的】

我们可以用栈来实现深度优先搜索算法。这种方法本质与方法一相同,唯一的区别是:

  • 方法一通过函数的调用来表示接下来想要遍历哪些土地,让下一层函数来访问这些土地。而方法二把接下来想要遍历的土地放在栈里,然后在取出这些土地的时候访问它们。

  • 访问每一片土地时,我们将对围绕它四个方向进行探索,找到还未访问的土地,加入到栈 stack 中;

  • 另外,只要栈 stack 不为空,就说明我们还有土地待访问,那么就从栈中取出一个元素并访问。

class Solution
{
public:
    /**
     * @Description: 使用深度优先搜索递归调用; 相当于先序遍历 —— 先搜索当前的位置,然后再深入/递归到 循环调用自身,找到周围的岛屿 ++。
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    int maxAreaOfIsland(vector<vector<int>> &grid)
    {
        int rel = 0;
        for(int i = 0; i!=grid.size() ;++i){
            for(int j = 0; j != grid[0].size() ;++j){
                if(grid[i][j]){
                    int local_area = 0;
                    stack<pair<int, int>> island;
                    island.push({i,j});
                    
                    // 开始进入深搜stack  类似递归
                    while(!island.empty()){
                        auto [row,col] = island.top();
                        island.pop();
                        if(row>-1 && row<grid.size() && col > -1 && col<grid[0].size() && grid[row][col]){
                            // 先序处理
                            grid[row][col] = 0;
                            local_area+=1;
                            // 再深入搜索 —— 即入栈
                            int di[] = {-1, 1, 0, 0}, dj[] = {0, 0, -1, 1}; //分别对应上下左右深搜方向
                            for(int k = 0; k<4 ;++k){
                                island.push({row+di[k], col+dj[k]});
                            }
                        }
                    }
                    rel = rel < local_area? local_area : rel ;
                }
            }
        }
        return rel;
    }
};

3)解法三:广度优先搜索
我们把方法二中的栈改为队列,每次从队首取出土地,并将接下来想要遍历的土地放在队尾,就实现了广度优先搜索算法

class Solution {
public:
    int maxAreaOfIsland(vector<vector<int>>& grid) {
        int ans = 0;
        for (int i = 0; i != grid.size(); ++i) {
            for (int j = 0; j != grid[0].size(); ++j) {
                int cur = 0;
                queue<int> queuei;
                queue<int> queuej;
                queuei.push(i);
                queuej.push(j);
                while (!queuei.empty()) {
                    int cur_i = queuei.front(), cur_j = queuej.front();
                    queuei.pop();
                    queuej.pop();
                    if (cur_i < 0 || cur_j < 0 || cur_i == grid.size() || cur_j == grid[0].size() || grid[cur_i][cur_j] != 1) {
                        continue;
                    }
                    ++cur;
                    grid[cur_i][cur_j] = 0;
                    int di[4] = {0, 0, 1, -1};
                    int dj[4] = {1, -1, 0, 0};
                    for (int index = 0; index != 4; ++index) {
                        int next_i = cur_i + di[index], next_j = cur_j + dj[index];
                        queuei.push(next_i);
                        queuej.push(next_j);
                    }
                }
                ans = max(ans, cur);
            }
        }
        return ans;
    }
};

题目2 朋友圈

参考1 官方多种方法。
参考2 并查集。
在这里插入图片描述

同样有三种解法:深搜、广搜、并查集。

  1. 解法一:深搜
    给定的矩阵可以看成图的邻接矩阵。这样我们的问题可以变成无向图连通块的个数。为了方便理解,考虑如下矩阵:
M= [1 1 0 0 0 0
    1 1 0 0 0 0
    0 0 1 1 1 0
    0 0 1 1 0 0
    0 0 1 0 1 0
    0 0 0 0 0 1]

如果我们把 M 看成图的邻接矩阵,则图为:

在这里插入图片描述

在这个图中,点的编号表示矩阵 M 的下标,i和 j 之间有一条边当且仅当 M[i][j]为 1。

为了找到连通块的个数,一个简单的方法就是使用深度优先搜索,从每个节点开始,我们使用一个大小为 N的 visited 数组(M大小为 N×N),这样 visited[i] 表示第 i 个元素是否被深度优先搜索访问过。

我们首先选择一个节点,访问任一相邻的节点。然后再访问这一节点的任一相邻节点。这样不断遍历到没有未访问的相邻节点时,回溯到之前的节点进行访问。

即两个关键:①考虑实际意义,每个人visit[N] 看做个体做。②看做邻接矩阵,找到置位 visited[i]= 1 即可【因为深搜,用户邻接 所有的相关邻接的圈子 都会被访问完了,不用再考虑用户i了】。

#include <iostream>
#include <cmath>
#include <algorithm>
#include <vector>


using namespace std;

//三种解法 —— 深搜、广搜、 并查集。
class Solution {
public:
    /**
     * @Description: 本质上朋友圈和岛屿面积几个 == 朋友圈的个数
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    int findCircleNum(vector<vector<int>>& M) {
        //遍历能进入几次dfs
        int count = 0;
        vector<bool> visited(M.size(), false);
        for(int i = 0; i< M.size();i++){
            if(!visited[i]){
                count++;
                dfsInFriCircle(M, i, visited);
            }
        }
        return count;
    }
    void dfsInFriCircle(vector<vector<int>> &M, int i,vector<bool> &visited){
        visited[i] = true;
        for(int j = 0;j<M.size();j++){     //【注意从0开始】
            if(!visited[j] && M[i][j]){ //相邻的朋友
                dfsInFriCircle(M, j, visited);
            }
        }
    }
};

int main()
{
    vector<vector<int>> arr = {{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}};
    vector<vector<int>> arr1 = {{0, 0, 0, 0, 0, 0, 0, 0}};
    vector<vector<int>> arr2 = {{1, 1, 0, 0, 0}, {1, 1, 0, 0, 0}, {0, 0, 0, 1, 1}, {0, 0, 0, 1, 1}};
    vector<vector<int>> arr3 = {{1,0,0,1},{0,1,1,0},{0,1,1,1},{1,0,1,1}};
    Solution so;
    int num_circle = so.findCircleNum(arr3);
    cout << "当前朋友圈数量为:" << num_circle << endl;
    system("pause");
    return 0;
}
  1. 解法二:广搜

  2. 解法三:并查集

题目3 太平洋大西洋水流问题

在这里插入图片描述
解法一: DFS逆流而上的大佬思维


class Solution
{
public:
    /**
     * @Description: 方法一:为了能够一次性访问完成—— 使用大神的逆向DFS方法。
     * @Param: 
     * @Return: 
     * @Notes: 两个数组逆向流动,从低到高 记录能够抵达太平洋和大西洋的位置。
     */
    //设置深搜四个方向
    vector<int> direction{-1, 0, 1, 0, -1};
    vector<vector<int>> pacificAtlantic(vector<vector<int>> &matrix)
    {
        vector<vector<int>> rel;
        if( matrix.empty() ) return {};
        int m = matrix.size();
        int n = matrix[0].size();
        vector<vector<bool>> can_reach_p(m, vector<bool>(n, false));
        vector<vector<bool>> can_reach_a(m, vector<bool>(n, false));
        for( int i = 0; i<m ;++i ){
            // 从边上开始 深搜 逆流而上。
            dfs(matrix, can_reach_p, i, 0);
            dfs(matrix, can_reach_a, i, n-1);
        }
        for(int j=0;j<n;++j){
            dfs(matrix, can_reach_p, 0, j);
            dfs(matrix, can_reach_a, m-1, j);
        }

        // 判断哪个位置  两个海洋都能访问到。
        for(int i = 0; i<m ;i++){
            for(int j = 0; j < n ;j++){
                if(can_reach_p[i][j] && can_reach_a[i][j])
                    rel.push_back({i, j});
            }
        }
        return rel;
    }
 	  //深搜,当前逆流而上能接触到哪些位置。
     //注意,这样子逆流而上, 同时又保证了只能访问一次【因为原地有了操作,不会再循环 重复访问了。】
    void dfs(vector<vector<int>> &matrix, vector<vector<bool>> &can_reach, int r, int c){
        // 必须保证在范围内
        int m = matrix.size(), n = matrix[0].size();

        // 处理 —— 类似于先序遍历;所以 先设置,后深搜 —— 并且在深搜之前进行 约束规范;之后就不需要开头就约束 规范了!
        if(can_reach[r][c]) return;
        can_reach[r][c] = true;
        
        // 开始深搜
        for(int i = 0; i < 4 ;++i){
            int x = r + direction[i], y = c + direction[i+1];
            if( x >=0 && x<m&& y >=0 && y < n  && matrix[x][y] >= matrix[r][c]){ // 不会越界,因为这里限制了。
                dfs(matrix, can_reach, x, y);
            }

        }

    }

    /**
     * @Description: 方法二:太平洋和大西洋图中的深搜,寻找高地。
     * @Param: 
     * @Return: 
     * @Notes: 【注意】正向DFS容易死循环——到达已经访问过的图中,所以要有个 visit或原地设个标志……未完成
     */
    vector<vector<int>> pacificAtlantic1(vector<vector<int>> &matrix)
    {
        vector<vector<int>> rel;
        // 0 表示是否能进入太平洋;1 表示是否能进入大西洋。
        for (int i = 0; i < matrix.size(); i++)
        {
            for (int j = 0; j < matrix[0].size(); j++)
            {
                //开始深搜返回 ocean
                vector<bool> ocean (2,false); 
                cout << "i am in" << endl;
                if (dfsHigh1(matrix, INT_MAX, i, j, ocean))
                {
                    rel.push_back({i, j});
                }
            }
        }
        return rel;
    }
    bool dfsHigh1(vector<vector<int>> &matrix, int bef, int r, int c, vector<bool> &ocean)
    {
        // 边界
        if (r < 0 || c < 0)
        {
            ocean[0] = true;
            return (ocean[0] && ocean[1]);
        } //可以进入太平洋
        else if (r >= matrix.size() || c >= matrix[0].size())
        {
            ocean[1] = true;
            return (ocean[0] && ocean[1]);
        } // 可以进入大西洋
        
        cout << "now matrix[r][c]: " << r << "," << c << ":" << matrix[r][c] << ";";
        // 未 越界
        if(matrix[r][c] <= bef){
            // 正经的基于当前位置进行流动
            return dfsHigh1(matrix, matrix[r][c], r-1, c, ocean) ||   
                   dfsHigh1(matrix, matrix[r][c], r+1, c, ocean)||
                   dfsHigh1(matrix, matrix[r][c], r, c-1, ocean) || 
                   dfsHigh1(matrix, matrix[r][c], r, c+1, ocean);
        }
        
    }
};

【BFS待看待删除本句话】解法二:同上面的思维但是使用BFS进行 —— 队列按层次扩展流动

// 多源点BFS逆向思考,从大西洋和太平洋分别往里面层层流动,都能流到的即为合法结果点
class Solution {
public:
	vector<vector<int>> pacificAtlantic(vector<vector<int>>& matrix) {
		if (matrix.empty() || matrix[0].empty()) return {};

		m = matrix.size(), n = matrix[0].size();
		vector<vector<bool>> pacific(m, vector<bool>(n, false));
		vector<vector<bool>> atlantic(m, vector<bool>(n, false));
		for (int i = 0; i < m; ++i) {
			bfs(matrix, pacific, i, 0); // from left Pacific
			bfs(matrix, atlantic, i, n - 1); // from right Atlantic
		}
		for (int i = 0; i < n; ++i) {
			bfs(matrix, pacific, 0, i); // from top Pacific
			bfs(matrix, atlantic, m - 1, i); // from bottom Atlantic
		}
		for (int i = 0; i < m; ++i) {
			for (int j = 0; j < n; ++j) {
				if (pacific[i][j] && atlantic[i][j]) {
					res.push_back({i, j});
				}
			}
		}
		return res;
	}

private:
	vector<vector<int>> res;
	int m, n;
	int dirs[4][2] = { {1,0},{0,1},{-1,0},{0,-1}};
	void bfs(vector<vector<int>>& matrix, vector<vector<bool>>& visited, int x, int y) {
        queue<vector<int>> q;
        q.push({x, y, INT_MIN});
        
        while (!q.empty()) {
           auto size = q.size();
           while (size--) {
               auto cur = q.front(); q.pop();
               auto i = cur[0];
               auto j = cur[1];
               auto curHeight = cur[2];
		       if (i < 0 || i >= m || j < 0 || j >= n || visited[i][j] || matrix[i][j] < curHeight) {
                  continue; // Don't use return!!!! DFS return, BFS should only contine inside loop!!!
              }

		      visited[i][j] = true;
              for (const auto& dir : dirs) {
                  auto ni = i + dir[0];
                  auto nj = j + dir[1];
                  q.push({ni, nj, matrix[i][j]});
              }
           }
        }
	}
};

作者:jyj407
链接:https://leetcode-cn.com/problems/pacific-atlantic-water-flow/solution/zhong-gui-zhong-ju-wo-de-cuo-bi-zheng-xiang-dfscha/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

二、回溯算法

2.0.1 读懂递归、回溯、DFS、DP(动态规划)区别与联系

1)递归是一种 算法结构,就是自我调用,经常作为一种编程的实现方式,比如题主问题中的DFS 、动态规划、回溯法都可以用递归来实现,当然也可以用非递归来实现。很多时候一个概念也可以用递归的方式来定义(比如gnu)。

递归是一种算法结构,递归会出现在子程序中自己调用自己或间接地自己调用自己。最直接的递归应用就是计算连续数的阶乘,计算规律:n!=(n-1)!*n。
观察阶乘计算的规律,前一个数结成的结果可以直接被应用到后一个数结成的计算中。

2)回溯是一种通用的算法,算法思想,把问题分步解决,在每一步都试验所有的可能,当发现已经找到一种方式或者目前这种方式不可能是结果的时候,退回上一步继续尝试其他可能。很多时候每一步的处理都是一致的,这时候用递归来实现就很自然。

回溯是一种 算法思想,可以用递归实现。通俗点讲回溯就是一种试探,类似于穷举,但回溯有“剪枝”功能,比如求和问题。给定7个数字,1 2 3 4 5 6 7求和等于7的组合,从小到大搜索,选择1+2+3+4 =10>7,已经超过了7,之后的5 6 7就没必要在继续了,这就是一种搜索过程的优化。如果还有不清楚的可以看一下8皇后问题。

3)当*回溯用于树的时候,就是深度优先搜索。*当然了,几乎所有可以用回溯解决的问题都可以表示为树。那么这俩在这里就几乎同义了。如果一个问题解决的时候显式地使用了树,那么我们就叫它dfs。很多时候没有用树我们也管它叫dfs严格地说是不对的,但是dfs比回溯打字的时候好输入。别的回答里提到了剪枝,实际上这二者都可以剪枝。

关于树:对于某一个搜索树来说(搜索树是起记录路径和状态判断的作用),回溯和DFS,其主要的区别是,回溯法在求解过程中不保留完整的树结构,而深度优先搜索则记下完整的搜索树。

而为了减少存储空间,在深度优先搜索中,用标志的方法记录访问过的状态,这种处理方法使得深度优先搜索法与回溯法没什么区别了。

4)至于动态规划,被题主放到这里是因为都是竞赛中经常会遇到并且学起来不容易明白吗?回溯可以用于所有用穷举法可以解决的问题,而DP只用于具有最优子结构的问题。所以不是所有问题都适合用dp来解决,比如八皇后。dp需要存贮子问题的解,回溯不需要。

2.0.2 正文思维框架

这篇文章是很久之前的一篇《回溯算法详解》的进阶版,之前那篇不够清楚,就不必看了,看这篇就行。把框架给你讲清楚,你会发现回溯算法问题都是一个套路。

废话不多说,直接上回溯算法框架。**解决一个回溯问题,实际上就是一个决策树的遍历过程。**你只需要思考4个问题:

1、路径:也就是已经做出的选择。

2、选择列表:也就是你当前可以做的选择。 —— 【决定了搜索空间,决定了搜索空间有哪些节点。】

3、结束条件:也就是到达决策树底层,无法再做选择的条件。——【决定了什么时候捕获有效的解,提前结束递归,开始回溯。】

4、约束,用来剪枝,避免进入无效的分支。

如果你不理解这三个词语的解释,没关系,我们后面会用「全排列」和「N 皇后问题」这两个经典的回溯算法问题来帮你理解这些词语是什么意思,现在你先留着印象。

代码方面,回溯算法的框架:

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。

什么叫做选择和撤销选择呢,这个框架的底层原理是什么呢?下面我们就通过「全排列」这个问题来解开之前的疑惑,详细探究一下其中的奥妙!

2.1 全排列问题

我们在高中的时候就做过排列组合的数学题,我们也知道n个不重复的数,全排列共有 n! 个。

PS:为了简单清晰起见,我们这次讨论的全排列问题不包含重复的数字。

那么我们当时是怎么穷举全排列的呢?比方说给三个数[1,2,3],你肯定不会无规律地乱穷举,一般是这样:

先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……

其实这就是回溯算法,我们高中无师自通就会用,或者有的同学直接画出如下这棵回溯树:

图片

只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不妨把这棵树称为回溯算法的「决策树」。

为啥说这是决策树呢,因为你在每个节点上其实都在做决策。比如说你站在下图的红色节点上:

图片

你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。为啥只能在 1 和 3 之中选择呢?因为 2 这个树枝在你身后,这个选择你之前做过了,而全排列是不允许重复使用数字的。

现在可以解答开头的几个名词:[2]就是「路径」,记录你已经做过的选择;[1,3]就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层,在这里就是选择列表为空的时候。

如果明白了这几个名词,可以把「路径」和「选择列表」作为决策树上每个节点的属性,比如下图列出了几个节点的属性:

图片

我们定义的backtrack函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个全排列。

再进一步,如何遍历一棵树?这个应该不难吧。回忆一下之前 学习数据结构的框架思维 写过,各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:

void traverse(TreeNode root) {
    for (TreeNode child : root.childern)
        // 前序遍历需要的操作
        traverse(child);
        // 后序遍历需要的操作
}

而所谓的前序遍历和后序遍历,他们只是两个很有用的时间点,我给你画张图你就明白了:

图片

前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。

回想我们刚才说的,「路径」和「选择」是每个节点的属性,函数在树上游走要正确维护节点的属性,那么就要在这两个特殊时间点搞点动作:
在这里插入图片描述

现在,你是否理解了回溯算法的这段核心框架?

for 选择 in 选择列表:
    # 做选择
    将该选择从选择列表移除
    路径.add(选择)
    backtrack(路径, 选择列表)
    # 撤销选择
    路径.remove(选择)
    将该选择再加入选择列表

我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。

下面,直接看全排列代码:

List<List<Integer>> res = new LinkedList<>();

/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
    // 记录「路径」
    LinkedList<Integer> track = new LinkedList<>();
    backtrack(nums, track);
    return res;
}

// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
    // 触发结束条件
    if (track.size() == nums.length) {
        res.add(new LinkedList(track));
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        // 排除不合法的选择
        if (track.contains(nums[i]))
            continue;
        // 做选择
        track.add(nums[i]);
        // 进入下一层决策树
        backtrack(nums, track);
        // 取消选择
        track.removeLast();
    }
}

我们这里稍微做了些变通,没有显式记录「选择列表」,而是通过nums和track推导出当前的选择列表:

图片
注意:这里对应的我的 C++解法完整版

#include <algorithm>
#include <cmath>
#include <iostream>
#include <vector>

using namespace std;

class Solution {
public:
    /**
     * @Description: 全排列 —— 使用回溯 “决策树”
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        vector<int> track;
        recallTree(nums, track, ans);
        return ans;
    }
    void recallTree(vector<int> &nums, vector<int> &track, vector<vector<int>> &ans){
        if(track.size() == nums.size()){
            ans.push_back(track);
            return ;
        }

        // 选择列表的选择。
        for(int i=0; i < nums.size(); ++i ){
            if( find(track.begin(), track.end(), nums[i]) != track.end() ){
                // 找到了
                continue;
            }
            // 未找到进行添加,路径 添加
            track.push_back(nums[i]);
            recallTree(nums, track, ans);
            // 还原track
            track.pop_back();
        }


    }
};


int main(){

    vector<int> arr = {1,2,3};
    Solution so;
    auto rel = so.permute(arr);
    printf("输出结果为:/n");
    for(auto r: rel){
        cout << "[ " ;
        for(auto r2:r){
            cout << r2 << ", " ;
        }
        cout << " ]" << endl;
    }
    
    system("pause");
    return 0;
}

至此,我们就通过全排列问题详解了回溯算法的底层原理。当然,这个算法解决全排列不是很高效,因为对链表使用contains方法需要 O(N) 的时间复杂度。有更好的方法通过交换元素达到目的,但是难理解一些,这里就不写了,有兴趣可以自行搜索一下。

但是必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。

附赠 —— 组合问题求解:

class Solution {
public:
    /**
     * @Description: 46是为排列,本题为组合
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> ans;
        vector<int> comb(k,0); // 记录
        int count = 0;
        backtracking(ans, comb, count, 1, n, k);
        // recallTree(n,k,0, track, ans);
        return ans;
    
    }
    /**
     * @Description: 大佬的做法
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    void backtracking(vector<vector<int>> &ans, vector<int> &comb, int &count, int pos, int n, int k){
        if(count == k){
            ans.push_back(comb);
            return ;
        }
        // pos是下一个位置到末尾,所以不会 重复。yes 我也想到了
        // 只不过我的有剪枝
        for( int i =  pos; i <= n ; ++i){
            comb[count++] = i; //修改当前节点状态
            backtracking(ans, comb, count, i+1, n, k);
            --count;
        }
    }

    /**
     * @Description: 我的做法—— l1——n; 到  n-k+1+(l1-1)
     * @Param: 
     * @Return: 
     * @Notes: 
     */    
    void recallTreeMe(int n, int k, int level, vector<int> &track, vector<vector<int>> &ans)
    {
        if (track.size() == nums.size())
        {
            ans.push_back(track);
            return;
        }

        // 选择列表的选择。
        for (int i = 0; i < nums.size(); ++i)
        {
            if (find(track.begin(), track.end(), nums[i]) != track.end())
            {
                // 找到了
                continue;
            }
            // 未找到进行添加,路径 添加
            track.push_back(nums[i]);
            recallTree(nums, track, ans);
            // 还原track
            track.pop_back();
        }
    }
};

附赠 —— 单词搜索问题求解:

/*
 * @FilePath            : \Algorithm&Interview\AlgorithmSlay\C++\79.WordSearch.cpp
 * @Author              : AClearZhang
 * @Date                : 2020-12-21 10:13:34
 * @E-mail              : aclearzhang@qq.com
 * @Homepage            : www.aclear.top
 * @LastEditors         : AClearZhang
 * @LastEditTime        : 2020-12-21 16:24:39
 * @Version             : 1.0
 * @Description         : 79. 单词搜索
给定一个二维网格和一个单词,找出该单词是否存在于网格中。

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

 

示例:

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

给定 word = "ABCCED", 返回 true
给定 word = "SEE", 返回 true
给定 word = "ABCB", 返回 false
 * 
 */

#include <algorithm>
#include <cmath>
#include <iostream>
#include <string>
#include <vector>
#include <windows.h>

using namespace std;

class Solution
{
public:
    vector<int> direction{-1, 0, 1, 0, -1};
    /**
     * @Description: 搜索是否粗拿在子字符串
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    bool exist(vector<vector<char>> &board, string word)
    {
        // 注意 原始边界!
        if (board.empty())
            return false;

        // vector<char> track;
        int count = 0;              // 此处不用 计数的,只需要看位置。
        bool rel = false;
        int m = board.size(), n = board[0].size();
        vector<vector<bool>>  visited(m, vector<bool>(n, false));
        for (int i = 0; i < m; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                dfsWordExit(board, i, j, rel, 0, visited, word);
            }
        }
        return rel;
    }
    /**
     * @Description: 对应辅助函数,进行深搜 并寻找单词。
     * @Param: 
     * @Return: 通过 rel 是否存在返回:true or false;
     * @Notes: 
     */
    void dfsWordExit(vector<vector<char>> &board, int r, int c, bool &rel, int count, vector<vector<bool>> &visited,string &word)
    {
        // 关键在深搜
        // 边缘结束
        if(r<0 || r>=board.size() || c<0 || c>=board[0].size()||count>word.length() || rel || visited[r][c])  return;
        if(board[r][c]!=word[count]) return ;  
        // 结束条件
        if (word.length()-1 == count)  // 因为有了上面那个条件,所以这可以用-1;  // 不减一 确实不对!!
        {
            rel = true;
            return;
        }

        //do
        if (board[r][c] == word[count])   //边缘没处理好 使得出现语法错误!不应该
        {
            // cout << "i am in and word is:" << word[count] << endl; 
            // 才开始深搜
            count++;
            visited[r][c] = true;
            for (int k = 0; k < 4; ++k)
            {
                dfsWordExit(board, r + direction[k], c + direction[k + 1], rel, count, visited, word);
            }
            // 还原错误了
            // 回溯返回状态
            count--;
            visited[r][c] = false;
        }

        return;
    }
    /**
     * @Description: 修改dfs为大佬的写法去除冗余
     * @Param: pos修改;添加 visited —— 反之返回重复;即去除已访问过的,防止回溯到。
     * @Return: 
     * @Notes: 
     */
    void dfsWordExit1(vector<vector<char>> &board, int r, int c, bool &rel, int pos, vector<vector<bool>> &visited,string &word)
    {
        // 关键在深搜
        // 边界约束
        if(r<0 || r>=board.size() || c<0 || c>=board[0].size()||pos>word.length() )  return;
        // 未找到---提前结束条件
        if(board[r][c]!=word[pos]|| rel || visited[r][c]) return ;  //【关键
        // 找到的结束条件
        if (word.length()-1 == pos)  // 因为有了上面那个条件,所以这可以用-1; //【关键
        {
            rel = true;
            return;
        }

        //do

        // cout << "i am in and word is:" << word[pos] << endl; 
        // 才开始深搜
        visited[r][c] = true;
        for (int k = 0; k < 4; ++k)
        {
            dfsWordExit1(board, r + direction[k], c + direction[k + 1], rel, pos+1, visited, word);
        }
    
        // 回溯返回状态
        visited[r][c] = false;   //回溯去除,说明当前节点不能往下了;但是可以作为最后的中间件。所以false。//【关键 增加visit
        return;
    }
};

int main()
{
    vector<vector<char>> arr = {{'A', 'B', 'C', 'E'},
        {'S', 'F', 'C', 'S'},
        {'A', 'D', 'E', 'E'}};
    // string word = "ABCCED";
    string word = "ABCB";   //会报错,注意已经访问过的  不要再重复访问了。
    // for(int i = 0;i<word.length();++i){
    //     cout<<word[i] << endl;
    // }
    Solution so;
    auto rel = so.exist(arr, word);
    cout << "是否存在当前word:" << rel << endl;
    system("pause");
    return 0;
}

【关键点—回溯】:首先遍历 board 的所有元素,先找到和 word 第一个字母相同的元素,然后进入递归流程。假设这个元素的坐标为 (i, j),进入递归流程前,先记得把该元素打上使用过的标记: visited[i][j] = true

【注意点–边界和return 检查:】
1)递归时元素的坐标是否超过边界;
2)回溯标记 mark[i][j] = 0以及 return 的时机

明白了全排列问题,就可以直接套回溯算法框架了,下面简单看看 N 皇后问题。

2.2 N 皇后问题

这个问题很经典了,简单解释一下:给你一个 N×N 的棋盘,让你放置 N 个皇后,使得它们不能互相攻击。

PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。

这是 N = 8 的一种放置方法:

图片

图片来自 LeetCode

这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。

直接套用框架:

vector<vector<string>> res;

/* 输入棋盘边长 n,返回所有合法的放置 */
vector<vector<string>> solveNQueens(int n) {
    // '.' 表示空,'Q' 表示皇后,初始化空棋盘。
    vector<string> board(n, string(n, '.'));
    backtrack(board, 0);
    return res;
}

// 路径:board 中小于 row 的那些行都已经成功放置了皇后
// 选择列表:第 row 行的所有列都是放置皇后的选择
// 结束条件:row 超过 board 的最后一行
void backtrack(vector<string>& board, int row) {
    // 触发结束条件
    if (row == board.size()) {
        res.push_back(board);
        return;
    }

    int n = board[row].size();
    for (int col = 0; col < n; col++) {
        // 排除不合法选择
        if (!isValid(board, row, col)) 
            continue;
        // 做选择
        board[row][col] = 'Q';
        // 进入下一行决策
        backtrack(board, row + 1);
        // 撤销选择
        board[row][col] = '.';
    }
}

这部分主要代码,跟全排列问题差不多。isValid函数的实现也很简单:

/* 是否可以在 board[row][col] 放置皇后? */
bool isValid(vector<string>& board, int row, int col) {
    int n = board.size();
    // 检查列是否有皇后互相冲突
    for (int i = 0; i < n; i++) {
        if (board[i][col] == 'Q')
            return false;
    }
    // 检查右上方是否有皇后互相冲突
    for (int i = row - 1, j = col + 1; 
            i >= 0 && j < n; i--, j++) {
        if (board[i][j] == 'Q')
            return false;
    }
    // 检查左上方是否有皇后互相冲突
    for (int i = row - 1, j = col - 1;
            i >= 0 && j >= 0; i--, j--) {
        if (board[i][j] == 'Q')
            return false;
    }
    return true;
}

函数backtrack依然像个在决策树上游走的指针,每个节点就表示在board[row][col]上放置皇后,通过isValid函数可以将不符合条件的情况剪枝:

图片

如果直接给你这么一大段解法代码,可能是懵逼的。但是现在明白了回溯算法的框架套路,还有啥难理解的呢?无非是改改做选择的方式,排除不合法选择的方式而已,只要框架存于心,你面对的只剩下小问题了。

当N = 8时,就是八皇后问题,数学大佬高斯穷尽一生都没有数清楚八皇后问题到底有几种可能的放置方法,但是我们的算法只需要一秒就可以算出来所有可能的结果。

不过真的不怪高斯。这个问题的复杂度确实非常高,看看我们的决策树,虽然有isValid函数剪枝,但是最坏时间复杂度仍然是 O(N^(N+1)),而且无法优化。如果N = 10的时候,计算就已经很耗时了。

有的时候,我们并不想得到所有合法的答案,只想要一个答案,怎么办呢?比如解数独的算法,找所有解法复杂度太高,只要找到一种解法就可以。

其实特别简单,只要稍微修改一下回溯算法的代码即可:

// 函数找到一个答案后就返回 true
bool backtrack(vector<string>& board, int row) {
    // 触发结束条件
    if (row == board.size()) {
        res.push_back(board);
        return true;
    }
    ...
    for (int col = 0; col < n; col++) {
        ...
        board[row][col] = 'Q';

        if (backtrack(board, row + 1))
            return true;

        board[row][col] = '.';
    }

    return false;
}

这样修改后,只要找到一个答案,for 循环的后续递归穷举都会被阻断。也许你可以在 N 皇后问题的代码框架上,稍加修改,写一个解数独的算法?
附加——我的解法:对比labuladong;在对角线上 使用了 做差的绝对值相等

class Solution {
public:
    /**
     * @Description: n皇后问题求解
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    vector<vector<string>> solveNQueens(int n) {
        if(n==0) return {{}};
        int cur = 0;  // 默认从第零行开始
        vector<int> visited(n, -1);  // 记录每一列对应设置的 行号【cur】
        vector<vector<string>> ans;
        vector<string> track(n);
        // 外面不需要---
        // for(int i =0;i<n;++i){
        dfsAllLocation(ans, track, cur, n, visited);
        // }
        return ans;
    }
    // n皇后的辅助函数
    // 进行回溯的遍历 —— 【关键是 又一次看作是决策树进行深搜。
    // 其中新增 剪枝多余的部分 ——  【遍历之外,去想怎么符合题目去做(除却 新建数组等方法的封装等。)
    void dfsAllLocation(vector<vector<string>> &ans, vector<string> &track, int cur, int n, vector<int> &visited){
        // 边界的话返回
        // if(cur < 0 || cur >= n || col < 0 || col >= n )  return ;
        // 重复访问过列 —— 剪枝时候会考虑
        // if(visited[col] >=0) return ;

        // 符合最终大小,入数组
        if(cur == n ){ // n
            ans.push_back(track);
            return ;
        }

       
        // 状态节点和 路径记录
        for(int i = 0; i < n ;++i){
            // 剪枝
            if(!isValid( cur, i, visited)){
                //如果 已经在位置上了,重复了则  剪枝
                continue;
            }
            
            // do处理
            string s(n,'.');
            s[i] = 'Q';
            track[cur] = s;
                //访问标记
            visited[i] = cur;
                //开始深搜
            dfsAllLocation(ans, track, cur+1, n, visited);
                // 返回当前状态
            visited[i] = -1;

        }
    }
    // 判断是否 有效位置
    bool isValid(int r, int c, vector<int> visited){
        //判断列是否有效
        if(visited[c] >= 0) { return false; }
        //判断斜方向是否有效
        for(int i = 0; i < visited.size() ;++i){
            if(visited[i] >=0 ){
                if(abs(c-i) == abs(r-visited[i])){
                    //存在斜方向
                    return false;
                }
            }
        }
        // 列和斜方向都没有
        return true;
    }

};

附加——大佬101的解法——对角线更好!直接标志就好!不用以上两种的遍历啦!
对角线规律

以上直接找到对角线规律,主对角线:ldiag(2n-1, false), 副对角线: rdiag(2n-1,false);
对应位置:column[i] = ldiag[row-i+n-1] = rdiag[row+i] = true; 【即可!】


class Solution1 {
public:
    /**
     * @Description: 101大佬C++ 的解法.
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    vector<vector<string>> solveNQueens(int n){
        vector<vector<string>> ans;
        if(n == 0){
            return ans;
        }
        vector<string> board(n, string(n, '.'));
        vector<bool> column(n, false), ldiag(2*n-1, false), rdiag(2*n-1,false);
        backtracking(ans, board, column, ldiag, rdiag, 0, n);
        return ans;
    }
    // 辅函数
    void backtracking(vector<vector<string>> &ans, vector<string> &board, vector<bool> &column, vector<bool> &ldiag, vector<bool> &rdiag, int row, int n){
        if(row == n){
            ans.push_back(board);
            return ;
        }
        for ( int i = 0; i < n ; i++){
            //剪枝
            // if(column[i] || ldiag[n-row+i-1] || rdiag[row+i+1]){
            if(column[i] || ldiag[row-i+n-1] || rdiag[row+i]){
                continue;
            }
            // 修改当前节点/选择边状态 进行下一个搜索
            board[row][i] = 'Q';
            // column[i] = ldiag[n-row+i-1] = rdiag[row+i+1] = true;
            column[i] = ldiag[row-i+n-1] = rdiag[row+i] = true;
            // 递归子节点
            backtracking(ans, board, column, ldiag, rdiag, row+1, n);
            // 回改当前节点状态
            board[row][i] = '.';  // 必要的。回溯之前的状态【】
            // column[i] = ldiag[n-row+i-1] = rdiag[row+i+1] = false;
            column[i] = ldiag[row-i+n-1] = rdiag[row+i] = false;
        }
    }

};

2.3 最后总结

回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下:
废话不多说,直接上回溯算法框架。**解决一个回溯问题,实际上就是一个决策树的遍历过程。**你只需要思考 4个问题:

1、路径:也就是已经做出的选择。

2、选择列表:也就是你当前可以做的选择。 —— 【决定了搜索空间,决定了搜索空间有哪些节点。】

3、结束条件:也就是到达决策树底层,无法再做选择的条件。——【决定了什么时候捕获有效的解,提前结束递归,开始回溯。】

4、约束,用来剪枝,避免进入无效的分支。

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。

小结
关键之处:回溯的三要点

  1. 选择,决定了搜索空间,决定了搜索空间有哪些节点。
  2. 约束,用来剪枝,避免进入无效的分支。
  3. 目标,决定了什么时候捕获有效的解,提前结束递归,开始回溯。

写backtrack函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集。

其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?

某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树大幅剪枝,这就变成了动态规划。而今天的两个问题,都没有重叠子问题,也就是回溯算法问题了,复杂度非常高是不可避免的。

2.4 回溯思想的实际应用见链接

三、广度优先搜索BFS

广度优先搜索是一种分层的查找过程。查找距离起点最近的点。
区别于DFS可以递归,BFS没有回退情况—— 所以需要借助辅助队列——以记忆正在访问的顶点和下一层顶点。
后台有很多人问起 BFS 和 DFS 的框架,今天就来说说吧。

首先,你要说 labuladong 没写过 BFS 框架,这话没错,今天写个框架你背住就完事儿了。但要是说没写过 DFS 框架,那你还真是说错了,其实 DFS 算法就是回溯算法,我们前文 回溯算法框架套路详解 就写过了,而且写得不是一般得好,建议好好复习。

BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用「队列」这种数据结构,每次将一个节点周围的所有节点加入队列。

BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度比 DFS 大很多,至于为什么,我们后面介绍了框架就很容易看出来了。

本文就由浅入深写两道 BFS 的典型题目,分别是「二叉树的最小高度」和「打开密码锁的最少步数」,手把手教你怎么写 BFS 算法。

BFS框架中需要注意的几个点:

  1. visited——防止死循环,跟随 在入队时候
  2. do visit —— 两种方式,但是注意 根据实际情况使用。且注意 第一种位置,在两边都需要visit
  3. 结束条件——如果求最短距离,注意 结束条件的变换。

3.1 算法框架

要说框架的话,我们先举例一下 BFS 出现的常见场景好吧, 问题的本质就是让你在一幅「图」中找到从起点start到终点target的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿。

把枯燥的本质搞清楚了,再去欣赏各种问题的包装才能胸有成竹嘛。

这个广义的描述可以有各种变体,比如走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送呢?

再比如说两个单词,要求你通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次?

再比如说连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?

再比如……

净整些花里胡哨的,这些问题都没啥奇技淫巧,本质上就是一幅「图」,让你从一个起点,走到终点,问最短路径。这就是 BFS 的本质,框架搞清楚了直接默写就好。

记住下面这个框架就 OK 了:

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    Queue<Node> q; // 核心数据结构
    Set<Node> visited; // 避免走回头路

    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0; // 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj())
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

【注意:】
队列q就不说了,BFS 的核心数据结构;cur.adj()泛指cur相邻的节点,比如说二维数组中,cur上下左右四面的位置就是相邻节点;visited的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited

再者上面的框架并不是遍历, 而是加了 q.size()方便知晓每一层的结束(结束后对应 step++),
去除上面的for(size)step++;也就是BFS中图的遍历了。

3.2 二叉树的最小高度

先来个简单的问题实践一下 BFS 框架吧,判断一棵二叉树的最小高度,这也是 LeetCode 第 111 题,看一下题目:

图片

怎么套到 BFS 的框架里呢?首先明确一下起点start和终点target是什么,怎么判断到达了终点?

显然起点就是root根节点,终点就是最靠近根节点的那个「叶子节点」嘛,叶子节点就是两个子节点都是null的节点:

if (cur.left == null && cur.right == null) 
    // 到达叶子节点

那么,按照我们上述的框架稍加改造来写解法即可:

int minDepth(TreeNode root) {
    if (root == null) return 0;
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    // root 本身就是一层,depth 初始化为 1
    int depth = 1;

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            TreeNode cur = q.poll();
            /* 判断是否到达终点 */
            if (cur.left == null && cur.right == null) 
                return depth;
            /* 将 cur 的相邻节点加入队列 */
            if (cur.left != null)
                q.offer(cur.left);
            if (cur.right != null) 
                q.offer(cur.right);
        }
        /* 这里增加步数 */
        depth++;
    }
    return depth;
}

二叉树是很简单的数据结构,我想上述代码你应该可以理解的吧,其实其他复杂问题都是这个框架的变形,在探讨复杂问题之前,我们解答两个问题:

1、为什么 BFS 可以找到最短距离,DFS 不行吗?

首先,你看 BFS 的逻辑,depth每增加一次,队列中的所有节点都向前迈一步,这保证了第一次到达终点的时候,走的步数是最少的。

DFS 不能找最短路径吗?其实也是可以的,但是时间复杂度相对高很多。

你想啊,DFS 实际上是靠递归的堆栈记录走过的路径,你要找到最短路径,肯定得把二叉树中所有树杈都探索完才能对比出最短的路径有多长对不对?

而 BFS 借助队列做到一次一步「齐头并进」,是可以在不遍历完整棵树的条件下找到最短距离的。

形象点说,DFS 是线,BFS 是面;DFS 是单打独斗,BFS 是集体行动。这个应该比较容易理解吧。

2、既然 BFS 那么好,为啥 DFS 还要存在?

BFS 可以找到最短距离,但是空间复杂度高,而 DFS 的空间复杂度较低。

还是拿刚才我们处理二叉树问题的例子,假设给你的这个二叉树是满二叉树,节点总数为N,对于 DFS 算法来说,空间复杂度无非就是递归堆栈,最坏情况下顶多就是树的高度,也就是O(logN)。

但是你想想 BFS 算法,队列中每次都会储存着二叉树一层的节点,这样的话最坏情况下空间复杂度应该是树的最底层节点的数量,也就是N/2,用 Big O 表示的话也就是O(N)。

由此观之,BFS 还是有代价的,一般来说在找最短路径的时候使用 BFS,其他时候还是 DFS 使用得多一些(主要是递归代码好写)。

好了,现在你对 BFS 了解得足够多了,下面来一道难一点的题目,深化一下框架的理解吧。

3.3 解开密码锁的最少次数

这道 LeetCode 题目是第 752 题,比较有意思:

图片

题目中描述的就是我们生活中常见的那种密码锁,若果没有任何约束,最少的拨动次数很好算,就像我们平时开密码锁那样直奔密码拨就行了。

但现在的难点就在于,不能出现deadends,应该如何计算出最少的转动次数呢?

第一步,我们不管所有的限制条件,不管deadends和target的限制,就思考一个问题:如果让你设计一个算法,穷举所有可能的密码组合,你怎么做?

穷举呗,再简单一点,如果你只转一下锁,有几种可能?总共有 4 个位置,每个位置可以向上转,也可以向下转,也就是有 8 种可能对吧。

比如说从"0000"开始,转一次,可以穷举出"1000", “9000”, “0100”, “0900”…共 8 种密码。然后,再以这 8 种密码作为基础,对每个密码再转一下,穷举出所有可能…

仔细想想,这就可以抽象成一幅图,每个节点有 8 个相邻的节点,又让你求最短距离,这不就是典型的 BFS 嘛,框架就可以派上用场了,先写出一个「简陋」的 BFS 框架代码再说别的:

// 将 s[j] 向上拨动一次
String plusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '9')
        ch[j] = '0';
    else
        ch[j] += 1;
    return new String(ch);
}
// 将 s[i] 向下拨动一次
String minusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '0')
        ch[j] = '9';
    else
        ch[j] -= 1;
    return new String(ch);
}

// BFS 框架,打印出所有可能的密码
void BFS(String target) {
    Queue<String> q = new LinkedList<>();
    q.offer("0000");

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 将当前队列中的所有节点向周围扩散 */
        for (int i = 0; i < sz; i++) {
            String cur = q.poll();
            /* 判断是否到达终点 */
            System.out.println(cur);

            /* 将一个节点的相邻节点加入队列 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                String down = minusOne(cur, j);
                q.offer(up);
                q.offer(down);
            }
        }
        /* 在这里增加步数 */
    }
    return;
}

PS:这段代码当然有很多问题,但是我们做算法题肯定不是一蹴而就的,而是从简陋到完美的。不要完美主义,咱要慢慢来,好不。

这段 BFS 代码已经能够穷举所有可能的密码组合了,但是显然不能完成题目,有如下问题需要解决:

1、会走回头路。比如说我们从"0000"拨到"1000",但是等从队列拿出"1000"时,还会拨出一个"0000",这样的话会产生死循环。

2、没有终止条件,按照题目要求,我们找到target就应该结束并返回拨动的次数。

3、没有对deadends的处理,按道理这些「死亡密码」是不能出现的,也就是说你遇到这些密码的时候需要跳过。

如果你能够看懂上面那段代码,真得给你鼓掌,只要按照 BFS 框架在对应的位置稍作修改即可修复这些问题:

int openLock(String[] deadends, String target) {
    // 记录需要跳过的死亡密码
    Set<String> deads = new HashSet<>();
    for (String s : deadends) deads.add(s);
    // 记录已经穷举过的密码,防止走回头路
    Set<String> visited = new HashSet<>();
    Queue<String> q = new LinkedList<>();
    // 从起点开始启动广度优先搜索
    int step = 0;
    q.offer("0000");
    visited.add("0000");

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 将当前队列中的所有节点向周围扩散 */
        for (int i = 0; i < sz; i++) {
            String cur = q.poll();

            /* 判断是否到达终点 */
            if (deads.contains(cur))
                continue;
            if (cur.equals(target))
                return step;

            /* 将一个节点的未遍历相邻节点加入队列 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                if (!visited.contains(up)) {
                    q.offer(up);
                    visited.add(up);
                }
                String down = minusOne(cur, j);
                if (!visited.contains(down)) {
                    q.offer(down);
                    visited.add(down);
                }
            }
        }
        /* 在这里增加步数 */
        step++;
    }
    // 如果穷举完都没找到目标密码,那就是找不到了
    return -1;
}

至此,我们就解决这道题目了。有一个比较小的优化:可以不需要dead这个哈希集合,可以直接将这些元素初始化到visited集合中,效果是一样的,可能更加优雅一些。

我的C++样式

class Solution {
public:
    /**
     * @Description: 对应 labuladong c++实现方法
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    int openLock(vector<string>& deadends, string target) {
        if(target == "") return 0;
        unordered_set<string> stopEnds(deadends.begin(), deadends.end());
        unordered_set<string> visited;
        queue<string> q;

        int dis = 0;
        if(stopEnds.count("0000")) return -1;
        q.push("0000");
        visited.insert("0000");   // 访问过 并 添加进来了。
        while(!q.empty()){
            int sz = q.size();
            for(int i = 0; i < sz ;++i){
                // 每个当前这一层高度进行pop 和  访问处理。
                string cur = q.front();
                q.pop();
                if(cur == target){ // do
                    return dis;
                }
                
                // 邻域添加到 queue中去
                for(int j = 0; j < 4 ; ++j){
                    string plus = plusOne(cur, j);
                    string minus = minusOne(cur, j);
                    // if(!visited.count(plus) && stopEnds.find(plus) == stopEnds.end()){
                    if(!visited.count(plus) && !stopEnds.count(plus)){
                        // 不是永久暂停
                        q.push(plus);
                        visited.insert(plus);
                    }
                    // if(!visited.count(minus) &&stopEnds.find(minus) == stopEnds.end()){
                    if(!visited.count(minus) && !stopEnds.count(minus)){  // 一样的时间复杂度。
                        // 不是永久暂停
                        q.push(minus);
                        visited.insert(minus);    // 【没加这个。cpu爆炸了!】【必须放在这,因为 visited是防止 加入队列访问过死循环爆炸的】
                    }
                }

            }
            dis++;
        }

        return -1;


    }
    /**
     * @Description: 以下 两个辅助函数, 方便查找邻域的节点; —— 对应注意 防止死循环;visited[]
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    // 辅助函数,向上拨动一次
    string plusOne(string cur, int j){
        cur[j] = cur[j] == '9' ? '0' : (cur[j]+1);
        return cur;
    }
    // 辅助函数,向下拨动一次
    string minusOne(string cur, int j){
        cur[j] = cur[j]=='0'?'9':cur[j]-1;
        return cur;
    }

};

3.4 双向 BFS 优化

你以为到这里 BFS 算法就结束了?恰恰相反。BFS 算法还有一种稍微高级一点的优化思路:双向 BFS,可以进一步提高算法的效率。

篇幅所限,这里就提一下区别:传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。

为什么这样能够能够提升效率呢?其实从 Big O 表示法分析算法复杂度的话,它俩的最坏复杂度都是O(N),但是实际上双向 BFS 确实会快一些,我给你画两张图看一眼就明白了:

图片

图片

图示中的树形结构,如果终点在最底部,按照传统 BFS 算法的策略,会把整棵树的节点都搜索一遍,最后找到target;而双向 BFS 其实只遍历了半棵树就出现了交集,也就是找到了最短距离。从这个例子可以直观地感受到,双向 BFS 是要比传统 BFS 高效的。

**不过,双向 BFS 也有局限,因为你必须知道终点在哪里。**比如我们刚才讨论的二叉树最小高度的问题,你一开始根本就不知道终点在哪里,也就无法使用双向 BFS;但是第二个密码锁的问题,是可以使用双向 BFS 算法来提高效率的,代码稍加修改即可:

int openLock(String[] deadends, String target) {
    Set<String> deads = new HashSet<>();
    for (String s : deadends) deads.add(s);
    // 用集合不用队列,可以快速判断元素是否存在
    Set<String> q1 = new HashSet<>();
    Set<String> q2 = new HashSet<>();
    Set<String> visited = new HashSet<>();

    int step = 0;
    q1.add("0000");
    q2.add(target);

    while (!q1.isEmpty() && !q2.isEmpty()) {
        // 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
        Set<String> temp = new HashSet<>();

        /* 将 q1 中的所有节点向周围扩散 */
        for (String cur : q1) {
            /* 判断是否到达终点 */
            if (deads.contains(cur))
                continue;
            if (q2.contains(cur))
                return step;
            visited.add(cur);

            /* 将一个节点的未遍历相邻节点加入集合 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                if (!visited.contains(up))
                    temp.add(up);
                String down = minusOne(cur, j);
                if (!visited.contains(down))
                    temp.add(down);
            }
        }
        /* 在这里增加步数 */
        step++;
        // temp 相当于 q1
        // 这里交换 q1 q2,下一轮 while 就是扩散 q2
        q1 = q2;
        q2 = temp;
    }
    return -1;
}

双向 BFS 还是遵循 BFS 算法框架的,只是不再使用队列,而是使用 HashSet 方便快速判断两个集合是否有交集。

另外的一个技巧点就是 while 循环的最后交换q1和q2的内容,所以只要默认扩散q1就相当于轮流扩散q1和q2。

其实双向 BFS 还有一个优化,就是在 while 循环开始时做一个判断:

// ...
while (!q1.isEmpty() && !q2.isEmpty()) {
    if (q1.size() > q2.size()) {
        // 交换 q1 和 q2
        temp = q1;
        q1 = q2;
        q2 = temp;
    }
    // ...

我的 双向BFS 、 C++实现

class Solution2 {
public:
    /**
     * @Description: 对应 labuladong c++实现方法
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    int openLock(vector<string>& deadends, string target) {
        if(target == "") return 0;
        unordered_set<string> stopEnds(deadends.begin(), deadends.end());
        unordered_set<string> visited;
        unordered_set<string> q1;
        unordered_set<string> q2;


        int dis = 0;
        if(stopEnds.count("0000")) return -1;
        q1.insert("0000");
        q2.insert(target);
        // visited.insert("0000");   // 访问过 并 添加进来了。
        // visited.insert(target);   // 访问过 并 添加进来了。
        while(!q1.empty() && !q2.empty()){
            // 遍历过程中 不能修改 哈希集合
            // temp:这里新建优化交换 q1 & q2过程 —— 方便一次性 交替更新和 dis增长.
            // temp:作用2 直接添加进temp  就不用删除 q1中的元素了。
            if(q1.size() > q2.size()){   //新增 —— 瞬间时间效率和内存效率都上去了。 
                //交换q1 q2
                swap(q1,q2);
            }
            unordered_set<string> temp;
            for(string cur : q1){  // 扩散当前q1 的所有邻域—— 当前包括 set中的所有集合;所以 visited 显得格外重要。
                
                if(stopEnds.count(cur)) continue;
                if(q2.count(cur))  return dis;    // 【重点】
                visited.insert(cur);

                // 插入扩散四周
                for(int i = 0; i<4 ;++i){
                    string plus = plusOne(cur, i);
                    if(!visited.count(plus)){
                        temp.insert(plus);
                        // visited.insert(plus);
                    }
                    string minus = minusOne(cur, i);
                    if(!visited.count(minus)){
                        temp.insert(minus);
                        // visited.insert(minus);
                    }
                }
                
            }
            dis++;

            // 两者进行互换
            q1 = q2;
            q2 = temp;
        }

        return -1;


    }
    /**
     * @Description: 以下 两个辅助函数, 方便查找邻域的节点; —— 对应注意 防止死循环;visited[]
     * @Param: 
     * @Return: 
     * @Notes: 
     */
    // 辅助函数,向上拨动一次
    string plusOne(string cur, int j){
        cur[j] = cur[j] == '9' ? '0' : (cur[j]+1);
        return cur;
    }
    // 辅助函数,向下拨动一次
    string minusOne(string cur, int j){
        cur[j] = cur[j]=='0'?'9':cur[j]-1;
        return cur;
    }

};

为什么这是一个优化呢?

因为按照 BFS 的逻辑,队列(集合)中的元素越多,扩散之后新的队列(集合)中的元素就越多;在双向 BFS 算法中,如果我们每次都选择一个较小的集合进行扩散,那么占用的空间增长速度就会慢一些,效率就会高一些。

不过话说回来,无论传统 BFS 还是双向 BFS,无论做不做优化,从 Big O 衡量标准来看,空间复杂度都是一样的,只能说双向 BFS 是一种 trick 吧,掌握不掌握其实都无所谓。最关键的是把 BFS 通用框架记下来,反正所有 BFS 算法都可以用它套出解法。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值