前言
我学的第一个算法就是dfs. 直到一年多后的今天,为了准备比赛我重新复习算法,我发现自己第一个学的算法,dfs,居然写的如此的慢,如此纠结。虽然最后能调试成功,但是感觉费了不少精力。一开始我准备了8道dfs复习,到最后我把这8道题用dfs统统做了四遍,用着不同的写法。终于,我明白自己为什么dfs写的如此之慢了。
下面,我将我的血泪史总结出一份自己满意的答卷。
dfs原理分析
其实,dfs就是暴力枚举。因此题目中遇到需要枚举才能取得结果的题都可以用dfs做。这类典型的题有:求N阶乘,N个数全排列,求集合的所有子集,遍历树,遍历图,走迷宫,以及各种dp题…
dfs应用范围是很广的,因为通过枚举获取结果这种方法真是可以应对很多题。
而dfs,实现枚举的方法就是递归回溯,形成一棵树。如果不知道这个原理的建议先自己学学dfs再回来看,这里不赘述太多基础知识了。
首先,最原始的dfs就是穷举所有的结果,最后生成的是完整的N叉树。但是,很多时候题目是有许多条件限制的。在用dfs扩展下一层的节点时,我们往往不需要扩展每一个节点,而是先判断一下将要扩展的节点是否符合题目条件或者是否有必要扩展。经过这一层的思考给我们原始的dfs代码附上了许多if和return语句,也让最后dfs遍历出的树减少了许多节点,效率更高。这个方法就是剪枝。顾名思义,剪去枝条就是选择性扩展节点。
而dfs常常会运用到回溯,我们也会写回溯,那么我们为什么要写回溯呢?回溯是用来干嘛的呢?
可能很多人会举例迷宫,遇到无法走的路就要原路返回,同时要清理痕迹,这个清理痕迹就是回溯。
其实这种说法是对的,这么理解也行,但是我想更深入的阐述为什么会有"回溯"这个方法。
其实,dfs本身不需要运用回溯才能得到结果,也就是说回溯不是dfs得到结果的必要条件,而是用来优化dfs的。
就拿迷宫问题举例,对于每个状态(dfs树上的每个节点),都会有许多变量。比如迷宫问题我们每个状态的变量会是x,y (记录当前状态所处位置),cnt(记录当前状态已走了多少步). 而我们会利用回溯方法对已遍历过的点在全局变量二维数组map上标记。其实map本身都是要对应每个状态的。也就是说,我们走迷宫时的状态需要一整个map,x,y,cnt. 而如果给每个节点都分配一个map,对内存的消耗是巨大的。而观察dfs,本质就是递归一次生成下一层的节点,回溯一次返回到上一层。因此我们可以对map进行模拟递归回溯操作。在dfs递归进入下一个状态前,将map调整到下一个状态匹配的map;在dfs回溯回当前层时将已修改的map改回当前的状态。这样就可以通过一个全局变量作用在每个节点上了。因此我们一般将数组这种占内存空间大的状态变量采用全局变量方式模拟回溯,像cnt这种int类型可模拟也可给每个节点带上。
dfs题型举例
ok,上面对我们平时写的dfs代码进行了本质的探索。接下来剥开题目的外壳,分析题目与dfs的各种对应关系。
我们知道,dfs最后生成的是一棵树,我们根据题目的条件对这棵树进行剪枝,题目的结果就在这颗树的节点中,dfs的起点也有一个或多个(有根树,无根树)。
下面上一些具体的例子。
子集:
https://leetcode-cn.com/problems/subsets/
class Solution {
public:
vector<vector<int>> ans;
vector<int> arr;
void dfs(vector<int> &nums, int cur)
{
ans.push_back(arr); //细节1
for(int i = cur;i < nums.size();i++) //细节2
{
arr.push_back(nums[i]);
dfs(nums,i+1); //细节3
arr.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
dfs(nums,0); //细节4
return ans;
}
};
如图,原始的dfs将生成一棵完全三叉树。
分析起点:起点为空集,然后再到1,2,3,可以判断这是一棵有根树,因此细节4直接dfs(0)起始
分析过程(剪枝):因为集合中不会存在重复的元素并且和顺序无关(组合问题),因此通过细节2和细节3进行剪枝,让后一个数永远比前一个数大就可以保证不重复。为了达成这种剪枝我们的状态cur就需要定义成下标。
分析结果:剪枝后的树就是红框标记出来的树,而要求的结果就是这棵树的所有节点,因此在细节1中ans需要存每个节点的arr数组。arr数组作为全局变量,通过"回溯"方法模拟递归回溯匹配每个节点的状态
就这样,写完了一道dfs.
全排列:
https://leetcode-cn.com/problems/permutations/
class Solution {
public:
vector<vector<int>> ans;
vector<int> arr;
bool vis[100010]; //标记下标,用于去重
void dfs(vector<int>& nums, int cur)
{
if(cur >= nums.size()) //细节1
{
ans.push_back(arr);
return;
}
for(int i = 0;i < nums.size();i++)
{
if(!vis[i])
{
arr.push_back(nums[i]);
vis[i] = 1;
dfs(nums,cur+1);
arr.pop_back();
vis[i] = 0;
}
}
}
vector<vector<int>> permute(vector<int>& nums) {
dfs(nums,0); //细节2
return ans;
}
};
分析起点:由于有[1,2,3],[2,1,3],[3,1,2],我们得需要1,2,3作为起点,可以判断这是一棵无根树。我们可以在主函数for循环调用dfs生成3棵有根树也可自己建一个"空"的节点作为根。状态里的cur表示当前arr数组已有的元素个数,因此在细节2中如果用第一种方法cur应该为1,用第二种方法cur设置为0(表示arr空数组作为根)
分析过程:因为全排列不会出现重复数字,因此对每个状态还需要一个vis数组表示arr数组中已选的元素用于去重(记录下标即可)。所以需要对vis和arr进行模拟回溯。
分析结果:我们需要取剪枝后的树的最后一层节点作为结果,因此在细节1中用于捕获最后一层的节点。因为要获取最后一层,我们的状态cur定义就要和层数相关才行。因此这里的cur表示当前arr数组已有的元素个数,也表示当前所在层数。
经过上面的分析,我们可以很有条理的看透题的本质,快速搭起dfs模板。虽然上面两道题比较基础,但是非常典型,其他复杂的题需要思考的就是分析过程部分,也就是剪枝。
当然,这样工程已经完成了大部分,但是还是有一些细节需要抠,而这种细节藏得比较深,以至于一年多后我还是被这种细节所折磨。下面真就是分享我的血泪史。
dfs代码细节
在写dfs的时候,其实是有两种不同的写法。拿全排列的例题来说,arr.push_back和vis其实可以写在for循环外面的。这样写的运行逻辑就是:
- 进入当前状态,当前状态的节点全新,没有被之前任何代码所改变
- 将该节点加进数组,vis标记为已取用
- for循环扩展该节点,但不对扩展节点的状态进行任何修改
- 循环结束,该节点弹出数组,vis取消标记
而如果是写在里面,运行逻辑就是:
- 进入当前状态,当前状态的节点已经被之前的代码更新了(如打了vis标记等)
- 因为已经被更新了,所以直接for循环扩展节点,在进入扩展节点的状态前对扩展的节点的状态进行更新(加进数组,打标记)
- 递归到扩展节点,直到回溯回当前节点后,将数组和标记更新回当前状态
这两种写法我个人喜欢第二种。第二种写法在面对无根树作为起点时直接建空的根就可以开始dfs,而面对有根树则先在主函数将根初始化后再执行dfs。第一种写法在面对有根树作为起点时直接在主函数调用即可,而在面对无根树作为起点时需要在主函数进行for循环转换成n个有根树,调用n个dfs。
我建议,不要第一种方法和第二种方法混着写。混着写其实也没什么错,但是你会去思考这种调用逻辑,很浪费时间和精力。最好就是直接熟练一种方法,以后只用这种方法,更容易提升对dfs的熟练度。