算法笔记 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中的回溯。

  1. 定义访问数组,给字符串排序,防止重复填充;
  2. 在回溯函数中,for循环遍历每一个字符,分别填充进入当前的位置,标记该字符,然后递归下一个位置;
  3. 每次递归完成,要进行回溯,visited[j] = false;这里就是我说的回退节点状态;
  4. 所有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,照样是:

  1. 定义访问数组,进行深度优先;
  2. 在回溯函数中,提前剪枝,n < 0 || k < 0 意思是超过了个数或者和超过了就没必要继续下去了;
  3. 然后遍历每一个数,从index到9(防止重复的组合),每加入一个数,就对该数再进行深搜;
  4. 深搜完成后回溯,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的算法之路的主要内容,如果未能解决你的问题,请参考以下文章

算法笔记 KMP算法 HERODING的算法之路

算法笔记 排序算法完整介绍及C++代码实现 HERODING的算法之路

算法笔记 万物皆可DP——动态规划常见类型 HERODING的算法之路

算法笔记 揭开广度优先遍历BFS的神秘面纱 HERODING的算法之路

算法笔记 C++中const和auto的那些事 HERODING的算法之路

算法笔记 揭开scanf(“%d“, &a)!=EOF的神秘面纱 HERODING的算法之路