目录
简介
深度优先搜索(DFS,Depth First Search)属于递归算法的一种。其过程简要来说是对问题划分为子问题,再继续划分直到不能划分为止,带求解问题即为递归树的根节点。文中叶子节点即最小子问题。
可视化
方向问题
对于递归树来说,一个问题的解决需要子问题的答案,那么就需要从叶子节点(可解子问题)返回结果,那就是自底向上。否则,就是自顶向下。
自顶向下
思路
当前节点存在未访问子节点
访问子节点
不存在(达到叶子节点或所有子节点访问完毕)
返回上一层
模板
遍历图上所有节点
Dfs (V) {
if( V是旧点 )
return;
//如果v已经走过,那马上返回
将V标记为旧点 ;
循环邻接表/矩阵 {
if判断是否相邻,相邻的点为U
Dfs (U);//包含他的父节点和子节点都要涉及到
}
}
int main() {
将所有点 都标记为新;//一般使用memset,0表示新(未访问)
while( 在图中能找到新点 k)
Dfs (k);
//这样避免了有多个连通图的情况
}
判断从某点出发是否能走到终点(记录路径)
Node path[MAX_LEN];//MAX_LEN取节点总数即可
//path,记录路径。node,可以是int(节点标号),也可以是结构体类型
int depth;//深度
int main()
{
将所有点标记为新点;//一般memset全变成0表示新点,1表示旧点
depth = 0;
if(Dfs(起点))
{
for(int i=0;i<=depth;++i)//输出经过的每个点的路径
cout<<path[i]<<endl;
}
}
bool Dfs(V){
if(V为终点)
path[depth]=V;
return true;
}
if(V为旧点)
return false;
将V标记为旧点;
path[depth]=V;
++depth;
对和V相邻的每个节点U{
if(Dfs(U)==true)
//如果返回true,那么说明,从v开始能走到终点
}
--depth;
//现在v是放到path里的,但是返回false,说明从v出发走不到终点,
//v不应出现在路径里,所以深度减一。
return false;
}
举例
图的深度优先搜索就是,以某个节点为递归树的根节点,递归树就是图的一个连通分支,由于只需要访问,不需要从子节点得到什么东西,就选择自顶向下。
无向图-基本操作函数(建立,广度遍历,深度遍历,邻接矩阵表示)
自底向上
思路
能够找到可解叶子节点的答案并且找到非叶子节点由子节点构成的解。
模板
当前节点是可解叶子节点
返回结果
返回由子问题构成的当前解
举例
斐波那契数列指的是这样一个数列:0,1,1,2,3,5...
这个数列从第3项开始,每一项都等于前两项之和。
F(1)=1,F(2)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 3,n ∈ N*)
/*
Project:Fibonacci
Date: 2020/09/22
Author: Frank Yu
*/
#include<iostream>
using namespace std;
int dfs(int n)
{
if (1 == n || 2 == n)return 1;
return dfs(n - 1) + dfs(n - 2);
}
//主函数
int main()
{
int n;
cout << "请输入想得到斐波那契数列第几项(>0):" << endl;
cin >> n;
cout <<"斐波那契数列第"<<n<<"项:"<<dfs(n)<<endl;
return 0;
}
回溯
回溯其实单独算一种算法,这里当做一种思想融合到本篇文章。这种思想就是忘记做过的事情,比如vis[]数组标记清除,仿佛回到了过去的时间点,有种撤销Ctrl+Z的感觉。
思路
针对自顶向下问题,返回上一层时,忘记自己曾经从上一层走下来。
模板
当前节点存在未访问子节点
访问子节点
不存在(达到叶子节点或所有子节点访问完毕)
do something
进入下一层
撤销
举例
思路
第一层随便选,第二层时选过的不能再选,第三层时第一、二层选过的不能再选。
bool vis[]数组记录每一层选择了哪个
/*
Project: full permutation(全排列)
Date: 2020/09/22
Author: Frank Yu
*/
#include<iostream>
#include<iomanip>
#define Max 10
using namespace std;
int N; //层数
int vis[Max];
int nums[Max];
void print()
{
for (int i = 1; i <= N; ++i)
{
cout << setw(5)<< nums[i];
}
cout << endl;
}
void dfs(int n)
{
if (n > N) print();
else
{
//选择数字
for (int i = 1; i <= N; ++i)
{
if (!vis[i])
{
nums[n] = i;
vis[i] = 1;
dfs(n + 1);
// 回溯 忘记曾达到n+1层,操作撤销 nums会覆盖,不必撤销
vis[i] = 0;
}
}
}
}
int main()
{
cin >> N;
// 从第1层开始搜索
dfs(1);
return 0;
}
这是很经典的一道使用回溯的题
每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。
思路
我们每次遍历行,选好后标记列和对角线,图中红色右下线和蓝色左下线代表所在对角线已经有棋子了,不能再放了。
对于列很简单,左下和右下需要我们去找出规律。左斜线用行列表示为r+c,右斜线用行列表示为r-c。行列下标均为0到n-1,
对于第1行,左斜线(1~6),右斜线(-5~1),选择的皇后为(1,3),左斜线为1+3=4,右斜线为1-3=-2。
/*
Project: n_empress
Date: 2020/09/22
Author: Frank Yu
*/
#include<iostream>
using namespace std;
#define Max 13+1
int N;//棋盘大小
int ans[Max];//保存结果,每一行皇后所在的列
int cnt = 0; //统计所有情况
int flag_col[Max];//标记列
int flag_left[2*Max];//标记左下
int flag_right[2*Max];//标记右下
void print()
{
if (cnt < 3)
{
for (int i = 1; i <=N; ++i)
{
cout << ans[i] << " ";
}
cout << endl;
}
}
void dfs(int row)
{
if (row > N) { print(); cnt++; return; }
for (int col = 1; col <=N; ++col)
{
//遍历列 判断列 左下 右下,由于row-col可能为负值,导致下标越界,故+N
if (!flag_col[col]&&!flag_left[row-col+N]&&!flag_right[row+col])
{
flag_col[col] = 1;
flag_left[row - col + N] = 1;
flag_right[row + col] = 1;
ans[row] = col;
dfs(row + 1);//递归下一行
//回溯,标志清除 ans覆盖,不必修改
flag_col[col] = 0;
flag_left[row - col + N] = 0;
flag_right[row + col] = 0;
}
}
}
int main()
{
cin >> N;
dfs(1);
cout << cnt << endl;
return 0;
}
剪枝
递归:我简单吗?内存空间换来的。
由于递归简单易懂,但是可能递归层数太多导致爆栈。针对自顶向下递归,一些子树没必要展开,子节点不可达,或者可达但对结果没有影响,那么我们可以剪掉这部分子树,来减少运行的时间和所需空间。参考本专栏下面文章:
记忆化
针对自底向上,一些递归树包含过多的重复子问题,我们可以对子问题保存结果,如果已经计算过了直接拿来用就好。
举例
自底向上中斐波那契数列在dfs(5)的时候就有一些重复子问题的计算dfs(1):2次、dfs(2):3次、dfs(3):2次,如果是计算dfs(40)、dfs(400)呢?可想而知,做了太多已经做过的子问题。
我们修改一下自底向上中斐波那契数列的代码,加大一点。
/*
Project:Fibonacci
Date: 2020/09/22
Author: Frank Yu
*/
#include<iostream>
#include<chrono>
using namespace std;
unsigned long int dfs(int n)
{
if (1 == n || 2 == n)return 1;
return dfs(n - 1) + dfs(n - 2);
}
//主函数
int main()
{
int n;
cout << "请输入想得到斐波那契数列第几项(>0):" << endl;
cin >> n;
const auto t1 = chrono::system_clock::now();
unsigned long int ans = dfs(n);
const auto t2 = chrono::system_clock::now();
const auto duration = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
cout <<"斐波那契数列第"<<n<<"项:"<< ans << ",耗时:" << duration * 1e-3 << "ms" << endl;
return 0;
}
我们使用数组保存子问题结果。
/*
Project:Fibonacci
Date: 2020/09/22
Author: Frank Yu
*/
#include<iostream>
#include<chrono>
using namespace std;
#define N 1000
unsigned long int Fibonacci[N] = {0,1,1};
unsigned long int dfs(int n)
{
if (Fibonacci[n] != 0)return Fibonacci[n];
Fibonacci[n - 2] = dfs(n - 2);
Fibonacci[n - 1] = dfs(n - 1);
return dfs(n - 1) + dfs(n - 2);
}
//主函数
int main()
{
int n;
cout << "请输入想得到斐波那契数列第几项(>0):" << endl;
cin >> n;
const auto t1 = chrono::system_clock::now();
unsigned long int ans = dfs(n);
const auto t2 = chrono::system_clock::now();
const auto duration = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
cout << "斐波那契数列第" << n << "项:" <<ans<<",耗时:"<< duration * 1e-3 <<"ms"<< endl;
for (int i = 0; i < N; ++i)
{
cout << Fibonacci[i] << " ";
}
return 0;
}
可以看到,添加记忆化的话,速度快了很多。
递归与栈
当程序执行到某个函数时,将这个函数进行入栈操作,在入栈之前,通常需要完成三件事。
1、将所有的实参、返回地址等信息传递给被调函数保存。
2、为被调函数的局部变量分配存储区。
3、将控制转移到北调函数入口。
当一个函数完成之后会进行出栈操作,出栈之前同样要完成三件事。
1、保存被调函数的计算结果。
2、释放被调函数的数据区。
3、依照被调函数保存的返回地址将控制转移到调用函数。
上述操作必须通过栈来实现,即将整个程序的运行空间安排在一个栈中。每当运行一个函数时,就在栈顶分配空间,函数退出后,释放这块空间。所以当前运行的函数一定在栈顶。
我修改了一下斐波那锲数列的函数,来展示函数入出栈顺序。
unsigned long int dfs(int n)
{
cout << "dfs(" << n << ")入函数栈" << endl;
if (1 == n || 2 == n)
{
cout << "dfs(" << n << ")出函数栈" << endl;
return 1;
}
unsigned long int ans = dfs(n - 1) + dfs(n - 2);
cout << "dfs(" << n << ")出函数栈" << endl;
return ans;
}
注意:这是部分函数栈状态,绿色代表运行结束,该出栈了;先入栈的dfs(3)是由dfs(4)调用的,因为我写的是dfs(n-1)+dfs(n-2),如果写成dfs(n-2)+dfs(n-1)的话,应该是dfs(5)入栈,然后是dfs(3)入栈。
总结
自顶向下:不需要子节点的结果,遇到泛型做参数时也需要回溯(OJ-leetcode-113. 路径总和 II)。
自底向上:需要子节点的结果。找到叶子节点的结果,非叶子节点与自身子节点的关系是难点。
回溯:针对自顶向下,有时必选,看递归时之前做的操作是否有影响(一般是标记位)。
剪枝:针对自顶向下,有时必选,看情况。
记忆化:针对自底向上,可选,看问题规模。
更多数据结构与算法实现:数据结构(严蔚敏版)与算法的实现(含全部代码)
本人b站账号:lady_killer9
有问题请下方评论,转载请注明出处,并附有原文链接,谢谢!如有侵权,请及时联系。如果您感觉有所收获,自愿打赏,可选择支付宝18833895206(小于),您的支持是我不断更新的动力。