从零开始的深度优先搜索(DFS)

本文详细介绍了深度优先搜索(DFS)的概念、策略及其在搜索问题中的应用,包括树的DFS遍历(前序、中序、后序)和图的DFS。DFS适用于解决存在性问题,通过递归或栈实现,常用于解决二叉树问题、图的连通分量、环检测等。文章通过具体例子和代码解释了DFS的实现,并讨论了剪枝和回溯策略,以提高搜索效率。
摘要由CSDN通过智能技术生成

问题1: 什么是搜索?

搜索,是一个动态的,收集信息,分析信息,保存信息的循环过程。在循环的过程中,我们根据已知的信息,对探索方向进行调整。根据选择探索方向的策略,我们将搜索大致划分为“广度优先搜索”(Breadth-First Search,简称BFS)和“深度优先搜索”(Depth-First Search,简称DFS),而本文主要介绍关于深度优先搜索(DFS)的相关知识和刷题总结。

问题2:什么是深度优先搜索?

深度优先搜索,是搜索的一种策略,我们从某一个位置出发,选定一个方向,不断地前进,深入探索,直到找到答案退出搜索,或者走到了路的尽头无法前进。当遇到不能再继续前进的情况时,我们可以向后退一步,看看是否有其他方向可以继续探索,如果没有,就再退一步。。。重复这个过程,直到我们把所有的可能都探索完毕,终止。用现实生活中常见的一句话来描述深度优先搜索就是——“不撞南墙不回头”。

问题3:为什么需要深度优先搜索?

深度优先搜索,本质上是一种穷举所有可能性的过程。对于人类而言,“穷举一个复杂的问题的所有可能选项”或许是一个浩大繁琐的工程,而对于计算机而言,依靠它高速的计算能力,搭配一些优化的策略(比如记忆化,剪枝等),可以将这个过程简单化,高速化,这样我们就不用写好几十张草稿纸了。

深度优先搜索解决的是“存在问题”,即某个问题是否存在解,如果存在,有多少个解等。

问题4:如何实现深度优先搜索?

方法一:递归实现

递归,是函数自己调用自己的过程。我们编写一个DFS函数,在其内部设置好“出口”以及选择方向后调用自己的部分,就可以实现深度优先搜索了。出口的设置非常关键,如果设置的不恰当,则可能造成搜索不完全,或者重复搜索,陷入死循环的情况。

伪代码实现
function DFS(parameters){
	if(Should Exit){ //递归函数的出口,当满足退出条件时,终止这个方向的搜索
		finish some job; //收尾工作
		return result; //返回结果
	}
	for(one direction of many available directions){ //枚举每一个可选方向
		new_parameteres = calculate(parameters,direction); //依照参数和方向,计算新的参数
		DFS(new_parameteres); //递归自己,用新的参数继续搜索
	}
	return exception or result; //返回结果或者例外
}

方法二:栈实现

栈,是一种数据结构,它只能从顶部加入元素,也只能从顶部取出元素,这个特性也被称为LIFO(Last In First Out,先进后出)

递归的过程,实际上就是将当前状态保存到栈,然后继续执行新的命令,新执行完毕后,返回到之前的状态,并剩余命令的过程。因此,我们可以手动创建栈,并将当前的数据状态保存到栈中。

递归实现 vs 栈实现

假设我们想要搜索一个有100个子节点的多叉树中某一条路径。如果使用栈写法,则我们除了需要保存当前节点外,栈中还保存着之前所有节点的子节点。而如果我们使用递归实现,则栈中只会有当前路径遍历过的节点。因此,从空间意义上,递归实现比栈实现更省空间。然而,递归因为使用的系统栈,会产生一定的性能消耗,因此,当递归层数过多时,可能会导致栈溢出,程序崩溃,因此,一部分特定问题可以改用栈实现。

总结:

  • 递归的优势:编码容易,可读性强,易于维护
  • 递归的劣势:深度过深时,可能会栈溢出,导致程序崩溃
  • 栈模拟的优势:执行效率较高
  • 栈模拟的劣势:不是所有递归都能用栈模拟替换,且栈模拟的代码比较难易理解,不易维护

5. 树的深度优先搜索

我们以二叉树为例。一棵二叉树的每个节点都有不超过两个的子节点,通常习惯称为“左孩子”和“右孩子”,或者“左子节点”和“右子节点”。而子节点也有可能有它们自己的子节点,如果将根节点与子节点的联系断开,子节点和它自己的子节点也会形成一棵二叉树,因此,我们又称左右子节点为“左子树”和“右子树”。二叉树中的深度优先遍历,就是从根节点开始,依次“递归地”搜索左子树的所有节点和右子树的所有节点的过程。根据访问根节点的先后顺序,可以分为:前序遍历(根-左子树-右子树),中序遍历(左子树-根-右子树),后序遍历(左子树-右子树-根)。具体可以参考我的另一篇文章《从零开始的二叉树&堆

例子:LeetCode1028. 从先序遍历还原二叉树

题目大意:给定一个字符串s,包含数字和“-”,每一个数字前面的“-”的个数都代表这个数字应在的深度。请根据字符串还原二叉树

思路:因为是先序遍历,每个节点有两种可能的插入位置:父节点的左子节点,以及之前某个节点的右子节点。这里我们选择优先插入左子节点,因为如果有两个处于同一深度的节点A和B(A先于B出现),而我们将A插入右节点,再将B插入左节点时,先序遍历会变成B先于A出现,导致错误。因此,如果当前位置是应插入的位置,则优先插入左节点,如果已有左节点,则插入右节点。

我们可以利用栈模拟遍历过程,栈中的元素的个数就是我们当前处于的深度。我们遍历字符串s,首先取“-”的个数,判断下一个节点应在的深度d,之后取数字,获得下一个节点的节点值val。根据栈的大小与深度d判断是否应该将元素插入其子节点。

class Solution {
public:
    TreeNode* recoverFromPreorder(string s) {
        stack<TreeNode*> st;
        int n = s.size();
        int p = 0;
        while(p<n){
            int d = 0;
            while(p<n&&s[p]=='-') p++,d++;
            int val = 0;
            while(p<n&&isdigit(s[p])) val=val*10+s[p]-'0', p++;
            TreeNode* node = new TreeNode(val);
            if(d==st.size()){ //如果当前节点的深度恰好是栈的大小,说明它正好是栈顶节点的左子节点
                if(!st.empty()){
                    st.top()->left=node;
                }
            }
            else{
                //如果当前节点的深度非栈的大小,说明它肯定是栈中某个节点的右子节点
                while(d!=st.size()) st.pop();
                st.top()->right = node;
            }
            st.push(node);
        }
        while(st.size()>1) st.pop();
        return st.top();
    }
};

6. 图的深度优先搜索

图与树的区别在于,树是无权无环图,即边与边不存在长度上的差异,只有连接点的不同,且不存在一个能回到自己的路径,但图不一定无环。在图的深度优先搜索中,如果不对环进行检测,那么我们可能会在环中一直绕圈子,导致死循环。因此,我们在搜索的过程中,需要记录已经被检查过的节点,以免节点被反复搜索。

深度优先搜索可以用于:

  1. 获取图(树)的一些属性:比如某路径节点的和,是否存在某点到某点的路径等(存在问题)
  2. 计算无向图的连通分量
  3. 检测图中是否有环
  4. 二分图检测:检测是否可以将一张图的节点分成独立的两个子集(涂色问题)
  5. 拓扑排序(虽然深度优先搜索也可以拓扑排序,但很少用,一般都是使用广度优先搜索解决拓扑问题)
  6. 回溯

例子:LeetCode695. 岛屿的最大面积

题目大意:有一个mxn的二进制矩阵,0代表海洋,1代表陆地。相邻的1可以构成同一块岛屿(此处的相邻指的是水平或者竖直四个方向上相邻),岛屿的面积就是它包含的1的个数。求矩阵中的各个岛屿里最大的岛屿面积是多少。

思路:我们首先需要找到一块陆地,检查它是否是之前我们到访过的岛屿的一部分,如果是新的岛屿,则开始探索这个岛屿,并标记岛屿的每个陆地,以及记录岛屿的面积。探索时,我们有上下左右四个方向可以探索,如果遇到已经到访过的地区,或者越界,或者遇到的是海洋,那么停止这个方向的搜索,退一步,检查其他方向。最后返回最大的岛屿面积。

代码实现:
class Solution {
public:
    vector<vector<int>> visit;

    int dx[4] = {0,0,1,-1};
    int dy[4] = {1,-1,0,0};
    
    int dfs(vector<vector<int>>& grid, int x, int y){
        if(x<0||x>=grid.size()||y<0||y>=grid[0].size()||grid[x][y]==0||visit[x][y]) return 0;
        int ans = 1;
        visit[x][y]=1;
        for(int i=0;i<4;i++){
            int nx = x+dx[i];
            int ny = y+dy[i];
            ans+=dfs(grid,nx,ny);
        }
        return ans;
    }
    int maxAreaOfIsland(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        visit.resize(m,vector<int>(n));
        int ans = 0;
        for(int i=0;i<m;i++){
            for(int j=0;j<n;j++){
                if(grid[i][j]==1&&!visit[i][j]){
                    ans = max(ans,dfs(grid,i,j));
                }
            }
        }
        return ans;
    }
};

7. 回溯

回溯,指的是将局面恢复到之前的模样,比如我们走过一条路径,触发了某些机关,可能会导致我们原先能走的路走不通了,但答案可能就在这些路径中。因此,在我们探索完当前的路径后,将迷宫重置到我们走这条路之前的模样,这样原先因为触发机关导致不能搜索的路径也就变得能够搜索了。这种“重置”的行为,就是回溯。

a) 什么时候需要回溯?:

  1. 全局只有一份的变量,在回到上一层递归时需要重置(比如字符或数字的使用记录,地图的障碍位置等)
  2. 对于基本类型变量,我们再向下传递时,系统都进行了复制,而非直接操作数据本身,因此无需重置
  3. 对于字符串,我们每次拼接的时候,都会生成一个新的对象,在返回上一层时,这个对象会自动消失,因此无需重置
  4. 对于广度优先搜索,因为其保存了每一层的所有状态,因此在状态空间很大的时候很难回溯

b) 经典问题:N皇后

题目大意:有一个nxn的棋盘,我们需要将n个皇后放在棋盘上,同时保证任意两个皇后不能在同一行,同一列,同一斜线。求摆放的方案。

思路:因为任意两个皇后不能在同一行,所以每一行至多有1个皇后。由此,我们逐行尝试,每到一行,我们就开始检查每一列,是否存在可以放皇后的位置,如果有,则放一个皇后,再去检查下一行,如果下一行没有可以摆放的位置或者所有皇后已经全都摆完了,那就回到上一层,将我们之前放的皇后拿起来,继续尝试剩下的列能否放置皇后。这个“拿起来”的动作,就是回溯。

代码实现:
class Solution {
public:
    vector<vector<string>> ans;
    vector<int> record;

    bool check(vector<string>& board, int row, int col){ //检查当前位置是否可以摆放皇后
        for(int i=0;i<row;i++){
            if(record[i]==col) return false; //共列
            if(abs(record[i]-col)==abs(row-i)) return false; //共斜线
        }
        return true;
    }

    void dfs(vector<string>& board, int row, int n){
        if(row==n){
            ans.push_back(board);
            return;
        }
        for(int i=0;i<n;i++){
            if(check(board,row,i)){ //如果可以摆放
                board[row][i]='Q'; //摆放
                record.push_back(i); //将摆放的位置记录下来
                dfs(board,row+1,n); //继续检查下一行
                record.pop_back(); 
                board[row][i]='.'; //将这一行的皇后拿起来
            }
        }
    }

    vector<vector<string>> solveNQueens(int n) {
        vector<string> board(n,string(n,'.'));
        dfs(board,0,n);
        return ans;
    }
};

8. 剪枝

所谓“剪枝”,就是排除一些搜索的方向。如果在搜索的过程中,知道某一条分支一定不存在需要的结果,那么我们就可以跳过这个分支的搜索,避免浪费时间。

例子: LeetCode473. 火柴拼正方形

题目大意:有n根火柴,想知道能否用这些火柴拼出一个正方形。要求必须使用上所有的火柴,不能有剩余,不能折断火柴。

思路:

首先,我们需要判断是否有至少4根火柴,如果少于4根,则无论如何都不可能拼出一个正方形(第一处剪枝,我们减去了少于4根火柴的探索);

然后,我们计算所有火柴的长度和,检查是否为4的倍数,如果不是4的倍数,必然会有火柴剩下(第二处剪枝,我们减去了和不等于4的火柴探索)

在开始探索之前,我们还需要做一件事,那就是对火柴数组进行降序排序,这样我们可以优先选择那些较长的火柴,较短的火柴用于弥补之后可能存在的空隙。此时,我们检查最长的火柴是否超过了周长的四分之一,如果超过了,则不可能拼成正方形(第三处剪枝,我们减去了某条边大于周长四分之一的探索)

最后,我们开始搜索,我们设立一个长度为4的数组,用于记录正方形四条边的拼凑情况,如果有一条边的长度超过了总边长的4分之一,那么必然无法拼成正方形,因此我们可以直接返回(第四处剪枝,我们减去了某一条边大于四分之一周长的探索分支)

class Solution {
public:
    bool dfs(vector<int>& nums, vector<int>& sums, int idx, int& target){
        if(*max_element(sums.begin(),sums.end())>target) return false; //剪枝4
        if(idx==nums.size()){
            return sums[0]==sums[1]&&sums[1]==sums[2]&&sums[2]==sums[3];
        }
        for(int i=0;i<4;i++){
            sums[i]+=nums[idx];
            if(dfs(nums,sums,idx+1,target)) return true;
            sums[i]-=nums[idx];
        }
        return false;
    }

    bool makesquare(vector<int>& nums) {
        int n = nums.size();
        if(n<4) return false; //剪枝1
        int total =0;
        for(auto& d:nums) total+=d;
        if(total%4!=0) return false; //剪枝2
        int target = total/4;
        sort(nums.begin(),nums.end(),greater<int>());
        if(nums[n-1]>target) return false; //剪枝3
        vector<int> sums(4,0);
        return dfs(nums,sums,0,target);
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值