算法笔记 DFS的千层套路 HERODING的算法之路
Posted HERODING23
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法笔记 DFS的千层套路 HERODING的算法之路相关的知识,希望对你有一定的参考价值。
前言
深度优先搜索DFS应该是各家大厂和高校机试的香饽饽,因为这类题型牵扯到图的遍历,略显复杂,但是用递归实现起来相对代码量少,还比较容易找到规律,所以在LeetCode中相当于中等题的难度,所以几乎每两题就会有一题能够使用DFS去解决,如果掌握了DFS的技巧,相信你一定能够在各大机试中锋芒毕露,斩获offer!
1. 理解与审题
什么情况下需要使用深度优先搜索?首先最重要的是有深度,没有深度根本无法使用DFS,那么这就要求至少二维数组,或者树的形式。从逻辑上看,DFS一般是从初始位置出发,不断深入,直到无法再往“深”的方向发展进行。FS一般做的是统计的工作,比如满足条件的线路有多少条,连通分量的个数,满足条件的组合有多少个等等这样的问题,下面我将从树和图两个角度分析DFS的使用。
1.1 树的DFS
树的DFS很常见,比如树的前序、中序、后序遍历都可以用DFS实现,所使用的的思想也就是深度优先遍历的思想,实现起来往往特别简单,往往三四行代码就可以了,如下所示:
void DFSTree(Tree * root) {
if(root != nullptr) {
visit(root); //1
DFSTree(root -> left); //2
DFSTree(root -> right); //3
}
}
这是最基本的前序遍历的形式,调换1,2,3的位置就可以变换成中序和后序,这里就不过多赘述了。
树的DFS应用常常出现在搜索二叉树(中序遍历为从小到大的排序),查找目标节点是否存在等等,所以在遇到提到这些的题目时候需要警觉,在下面的解法部分我会举例阐述。
1.2 一维数据结构的DFS
一维数据结构的DFS经常给定一维数组或者字符串,求解符合题意的组合数目,这也衍生出一个特例,就是回溯的方法,每遍历过一种情况就回溯回去,防止影响其他情况的判断。但是注意在组合个树中,DFS经常会因为时间复杂度爆炸(2^n指数型爆炸增长)而无法使用,得换成动态规划解题。
1.3 图的DFS
图的DFS经常是给定二维矩阵,寻找矩阵中符合题意路径的个数(是否存在),所以审题时一旦注意到题目提供数据结构是矩阵,要求的上述提到的路径个数解的个数,那么就要往DFS的方向去思考。
2. 解法与优化
最一般的DFS的模板通常是定义访问数组,二重for循环遍历矩阵(防止多连通子图导致访问不到),每遍历一个节点,就往深的方向继续遍历,直到所有节点都访问过,这是我做题总结出的规律,但是很多都会根据题目的要求而做出不同的变种,下面我将从树和图的DFS的角度举例阐述。
2.1 树的DFS
最基础的情况是求解树的节点个数,这个DFS、BFS都能够实现,但是代码量就完全不同了,BFS的话需要定义队列,然后for循环,各种入队出队操作,一波下来没个二十行解决不了问题,而DFS就简单了,往往只需要三四行就可以,比如完全二叉树的节点个数,在这道题中,如果用BFS,代码如下:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
int countNodes(TreeNode* root) {
if(!root){
return 0;
}
queue<TreeNode*> q;
int sum = 0;
q.push(root);
while(!q.empty()){
for(int i = 0; i < q.size(); i ++){
TreeNode* demo = q.front();
q.pop();
sum ++;
if(demo -> left){
q.push(demo -> left);
}
if(demo -> right){
q.push(demo -> right);
}
}
}
return sum;
}
};
那我们再看看DFS的代码:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
int countNodes(TreeNode* root) {
if(!root){
return 0;
}
return countNodes(root -> left) + countNodes(root -> right) + 1;
}
};
除去定义的树,总共就四行实现目标,只不过这种方法确实难以想到,观察最后一行,左子树个数+右子树个数+当前节点个数(1),这么一看顿时豁然开朗,如果没有这么想,按照模板来的话会是这样的实现:
/**
* 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 {
public:
int countNodes(TreeNode* root) {
if(root == nullptr) {
return 0;
}
int count = 0;
dfs(root, count);
return count;
}
void dfs(TreeNode* root, int& count) {
if(root == nullptr) {
return;
}
count ++;
dfs(root -> left, count);
dfs(root -> right, count);
}
};
代码量也不是很大,也是很好理解的。遇到节点count+ +,深搜左右子树,没有return,只不过是额外定义了一个dfs和一个count,实现过程和四行的DFS是一样的。
树中一个重要的DFS题型是二叉搜索树的应用,因为它有个特性,就是中序遍历是从小到大的排序,在二叉搜索树的最小绝对差这道题中,就完全契合我们的目标,换成排序后,前后两个数差值比较就能得出最小差值了,代码如下:
class Solution {
public:
void dfs(TreeNode* root, int& pre, int& ans) {
if (root == nullptr) {
return;
}
dfs(root->left, pre, ans);
if (pre == -1) {
pre = root->val;
} else {
ans = min(ans, root->val - pre);
pre = root->val;
}
dfs(root->right, pre, ans);
}
int getMinimumDifference(TreeNode* root) {
int ans = INT_MAX, pre = -1;
dfs(root, pre, ans);
return ans;
}
};
以上是树中的DFS详解,可以看出树使用DFS可以大大缩减代码量,只不过在构建的过程需要思考操作、左右子树深搜三者之间的位置关系,很多时候看到代码恍然大悟,自己敲的时候无从下手。
2.2 一维数据结构的DFS
一维数据结构的DFS常常涉及到选择问题,选择当前节点,还是不选择,最后得到的组合是否满足题意,解题模板也是定义一个访问数组,判断当前位置是否被访问,但是每遍历一遍后要记得回溯,就是回退访问节点的状态,否则之后的组合无法选择,这样的选择问题一定要是在n足够小的时候使用,很多情况不能超过10,因为时间复杂度为O(2^n),n一旦过大,爆炸性增长必将超时。
这里举的例子是字符串的排列,在这道题目中限制n <= 8,所以完全可以使用DFS,该题的思路是先把所有的元素提取出来,然后一个一个填回去,统计不同组合的个数,这里用到的是DFS中的回溯。
- 定义访问数组,给字符串排序,防止重复填充;
- 在回溯函数中,for循环遍历每一个字符,分别填充进入当前的位置,标记该字符,然后递归下一个位置;
- 每次递归完成,要进行回溯,
visited[j] = false;
这里就是我说的回退节点状态; - 所有DFS过程结束,返回统计的结果。
代码如下:
class Solution {
private:
vector<bool> visited;
vector<string> res;
public:
void backtrack(string s, int i, int len, string& seq) {
// 填充到最后一位
if(i == len) {
res.push_back(seq);
}
for(int j = 0; j < len; j ++) {
// 如果访问过,或者填充重复了
if(visited[j] || (j > 0 && !visited[j - 1] && s[j - 1] == s[j])) {
continue;
}
visited[j] = true;
seq.push_back(s[j]);
backtrack(s, i + 1, len, seq);
// 回溯
visited[j] = false;
seq.pop_back();
}
}
vector<string> permutation(string s) {
int len = s.size();
visited.resize(len);
// 排序一下可以让相同的字符连续,便于避免重复
sort(s.begin(), s.end());
string seq;
backtrack(s, 0, len, seq);
return res;
}
};
一道题目可能不够过瘾,那就再来一道!LeetCode 216 组合总和III,这道题目的优点在于使用了回溯剪枝,可以有效提高效率,与字符串的排列相同(字符的组合),这里是数字的组合,只不过多了规定固定的k个数以及和为固定的n,照样是:
- 定义访问数组,进行深度优先;
- 在回溯函数中,提前剪枝,
n < 0 || k < 0
意思是超过了个数或者和超过了就没必要继续下去了; - 然后遍历每一个数,从index到9(防止重复的组合),每加入一个数,就对该数再进行深搜;
- 深搜完成后回溯,
visited[i] = false;res.pop_back();
,恢复各个参数的状态。
好了,以上就是一维数据结构的深度优先,包括回溯以及一些优化方法如回溯,可以有效提高程序运行效率。
2.3 图的DFS
图的DFS其实从本质上来说比一维数据结构的DFS要来的简单,因为二维数组的移动更为直观,甚至可以在二维矩阵中规划出一条路线, 图的DFS的模板也更固定,定义访问数组,二重for循环遍历矩阵(防止多连通子图导致访问不到),每遍历一个节点,就往深的方向继续遍历,直到所有节点都访问过,经典题型如岛屿数量,N皇后问题,这里我也拿这些题目进行举例。
LeetCode 100 岛屿数量,该题的本质是查找连通分量的个数,那么DFS恰好是解决这样问题的好手,每一次的深度优先结束,就是一个连通分量的结束,遍历所有的点(可能第一个点深搜就把所有点都访问过了,这个时候连通分量就是1),统计连通分量个数,代码如下:
class Solution {
private:
// 定义方向和网络的长宽
int direction[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int m, n;
public:
int numIslands(vector<vector<char>>& grid) {
// 如果大小为0
if(grid.size() == 0) {
return 0;
}
// 计算长宽
m = grid.size();
n = grid[0].size();
// 统计数量
int count = 0;
for(int i = 0; i < m; i ++) {
for(int j = 0; j < n; j ++) {
if(grid[i][j] == '0') {
continue;
} else {
count ++;
dfs(grid, i, j);
}
}
}
return count;
}
void dfs(vector<vector<char>>& grid, int i, int j) {
// 如果越界或者是水的情况
if(i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') {
return;
}
grid[i][j] = '0';
// 遍历四个方向深搜
for(auto& d : direction) {
dfs(grid, i + d[0], j + d[1]);
}
}
};
/*作者:heroding
链接:https://leetcode-cn.com/problems/number-of-islands/solution/c-dfs-by-heroding-4jlj/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/
下面也是经典的DFS问题,N皇后问题,这道问题的难度明显和岛屿数量不是一个等级的,但是核心思想是一样的。N皇后问题要求任何两个皇后都不能处于同一条横行、纵行或斜线上,那么定义行,列,两个对角线共四个访问数组,从第1行开始,找位置,标记并继续下一行,递归结束要回溯,包括访问数组和矩阵,这题需要技巧的地方在行列与对角线数组位置的转变,row[u] = col[i] = dg[u - i + n - 1] = udg[i + u]
,他们有着这样的转换关系,代码如下:
class Solution {
public:
static const int M = 20;
int row[M], col[M], dg[M], udg[M];
vector<string> a;
vector<vector<string>> res;//返回的结果
vector<vector<string>> solveNQueens(int n) {
a.resize(n, string(n, '.'));//把a设置成n*n的.
dfs(0, n);
return res;
}
void dfs(int u, int n) {
if (u == n) {
res.push_back(a);//一种方案完成
}
for (int i = 0; i < n; i ++) {
if (!row[u] && !col[i] && !dg[u - i + n - 1] && !udg[i + u]) {//如果行列对角线都无
a[u][i] = 'Q';
row[u] = col[i] = dg[u - i + n - 1] = udg[i + u] = 1;//标记已经占用过
dfs(u + 1, n);
a[u][i] = '.';//一个方案结束后要复原
row[u] = col[i] = dg[u - i + n - 1] = udg[i + u] = 0;
}
}
}
};
观察代码,除去技巧的部分,是不是就是之前所述的模板内容?可见DFS这类题型的套路还是很好把握的。
3. 总结
其实DFS也就是那么回事,所谓的千层套路还不是万变不离其宗,底层的模板是一样的,没有任何变化,不同的DFS题目只不过在模板的基础上花里胡哨加了一大堆其他技巧罢了,只要把握住DFS的核心,相信任何DFS的题目都不会难倒大家,希望读者读到这可以有所收获。当然实践是检验真知的唯一手段,还是需要大家勤练习,才能真正掌握DFS的精妙之处,从而以不变应万变。
以上是关于算法笔记 DFS的千层套路 HERODING的算法之路的主要内容,如果未能解决你的问题,请参考以下文章
算法笔记 排序算法完整介绍及C++代码实现 HERODING的算法之路
算法笔记 万物皆可DP——动态规划常见类型 HERODING的算法之路
算法笔记 揭开广度优先遍历BFS的神秘面纱 HERODING的算法之路