前言
今天我继续和诸君分享我的算法心得。(dalao请轻喷)
DFS(深度优先搜索算法)可以说是最具代表性的初级算法之一。其旨在树的结构下进行试探性遍历,从而更新节点信息。个人认为,要想学好DFS,首先对代码的模拟能力一定要有一定基础,即可以根据实际问题编写出合适的代码,把自己的意图较为精确地传达给计算机。
实际上,DFS是基于树和图的数据结构上的算法,但我在最初学习DFS时并没有学过图论(所以说到底我是个蒟蒻),因此很多理解都是非官方的。本篇文章分享对象主要是启蒙一下还未经历过树图毒打的萌新们,希望读者可以借此初步入门DFS这个大坑。
首先,我们需要了解以下预备芝士:
- DFS是一种暴力解法,裸深搜在问题有一定复杂度限制的时候往往难以满足要求。即使我们可以用剪枝、预处理等方法尝试优化代码的执行,一般来说,复杂度不过关的问题你再怎么优化也AC不了(也许你把读优输优都做到极致还是会有一丝丝机会?)
- DFS一般是以递归方式呈现,我们知道,递归的质量一般来说比递推要差,这也就是DFS复杂度高的原因。
我们可以通过简单的DFS模板来分析算法的原理:
void DFS(需要传递的信息[, 搜索深度])
{
if(到达边界||满足要求)
{
进行预期操作
return;
}
//else
for(每一种可能)
{
标记, 更新信息
DFS(传递新信息[, 搜索深度 + 1]);
把本层信息恢复成更新前的状态
}
}
可以看到,深度有限搜索的核心思想就是一条路走到底,不撞南墙不回头。首先,对于当前层次的节点,只要可以,DFS会尽可能找到更深的一层进行探索;否则,就回到上一层节点进行此操作。直到整个图的所有节点都被访问过,即问题的所有可能性都被我们尝试过后,算法结束。
探索
没有理解的童鞋可以通过下面一段故事情景,与小A童鞋一起尝试领悟DFS:
- 小A今天去游乐园玩。游乐园里面的游玩设施有很多,但是由于年代较久远,任意两个游玩设施之间只有一条道路。小A看着地图一时也决定不了怎么走,于是他决定先沿着一条路走走。
- 沿着小A最初选择的道路,不一会儿,小A就游玩了摩天轮、过山车、旋转木马。可是好景不长,慢慢的,因为小A走得比较深入,他发现刚刚玩的旋转木马周围没有任何其他的游玩项目了。
- “只能往回走了。”小A想着,于是回到了刚才玩过的过山车旁,这时候他发现还有另一条路,通往的是丛林探险。于是小A高兴地走去。
- 等到实在找不到可以玩的地方,再往回走吧。小A这样想道。
- 不知不觉,小A把整个游乐园都逛了一遍。
小A今天在游乐场游玩的顺序如下:
回到家,小A总结了一下今天的游玩历程,发现自己严格贯彻沿着一条路走,直到没有路了就回退,重复操作直到遍历完整个地图这一原则,这不就是刚刚学的深度优先搜索算法吗?
小A轻松写出了以下伪代码,用来表示今天他游玩游乐场的规则:
void 游玩游乐场(游玩了xx项目)
{
if(没路可走了)
{
回上一个玩的项目周围看看吧
}
//周围还有可以游玩的项目
for(选择其中最近的一个)
{
这个项目是丛林探险
游玩游乐场(游玩丛林探险)
从丛林探险走回来
}
}
嗯嗯,确实可以按照DFS的模板写出来,那就是你没跑了!
这样想的话,原来DFS也没那么难。于是小A试着用这样的体会完成老师布置的编程作业。
怎么把数字具体化成实际问题呢?小A试着这样翻译:假设今天去游乐园玩,但是自己体力有限,最多只有N个体力值。游乐园的项目有无限多个,每个游玩项目的体力值为1~N之间的一个数字,且任意两个游玩项目之间都是联通的。由于自己比较懒,所以在玩过轻松的项目后就不想再尝试困难项目了,也就是说,当自己游玩了体力值为3的项目,就不会想去玩体力值超过3的项目,而只愿意去体验体力值小于等于3的项目了。
最后求问:小A有哪些方案可以把体力刚好消耗完?请输出具体方案。
小A大手一挥,写出了如下伪代码:
void 游玩游乐园(还有N个体力, 已经游玩的方案)
{
if(体力消耗完了)
{
输出具体的游玩方案
}
//else
for(找一找是否有满足体力要求的游戏可供选择)
{
选择这个游戏加入到游玩方案中
游玩游乐园(还有N-x个体力, 新的游玩方案)
不玩这个游戏, 把游玩方案复原
}
}
将伪代码进一步翻译成实际代码:
int n;
int a[100];
//四个参数作用:存储具体方案 保存当前剩余体力 方案长度 上一次游玩项目消耗的体力
void dfs(int a[100], int n, int len, int last)
{
//如果体力消耗完了 行程结束 输出方案
if (n <= 0)
{
if (n < 0)return;
for (int i = 0; i < len - 1; ++i)
{
cout << a[i] << "+";
}
cout << a[len - 1];
cout << endl;
}
//如果体力还有剩余 以上次消耗的体力作为基准找项目
for (int i = last; i; --i)
{
//假设小A游玩了i项目 消耗了i体力
a[len] = i;
dfs(a, n - i, len + 1, i);
//由于数组元素值的覆盖 无需撤销操作
}
}
int main()
{
cin >> n;
//小A开始形成 方案数组为空 体力为n 方案长度为0 可以选择的最大项目为n
dfs(a, n, 0, n);
return 0;
}
问题就这么完美地解决了!小A趁热打铁,找出了洛谷上的几道简单的DFS题目练练手。
洛谷P1036 选数
已知 n 个整数 1,2,⋯ ,x1,x2,⋯,xn,以及 11 个整数 k(k<n)。从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。例如当 n=4,k=3,44 个整数分别为 3,7,12,193,7,12,19 时,可得全部的组合与它们的和为:
3+7+12=22
3+7+19=29
7+12+19=38
3+12+19=34
现在,要求你计算出和为素数共有多少种。例如上例,只有一种的和为素数:93+7+19=29。
这道题和小A刚才老师布置的作业做法很相似,我们只需要在输入的 n 个整数中进行搜索 k 个数字,并检查最终得到的结果是否是素数即可。
具体代码如下:
int n, k;
int a[30];
long long ans;
//试除法 判断一个数字是否是素数
bool is_prime(int n)
{
for (int i = 2; i <= sqrt(n); i++)
{
if (n % i == 0)return 0;
}
return 1;
}
void dfs(int now, int sum, int st)
{
if (now == k)
{
if (is_prime(sum))++ans;
return;
}
for (int i = st; i < n; i++)
{
dfs(now + 1, sum + a[i], i + 1);
}
return;
}
int main()
{
cin >> n >> k;
for (int i = 0; i < n; i++)
cin >> a[i];
dfs(0, 0, 0);
cout << ans;
return 0;
}
当然,我们也可以对算法做很多优化,比如在本题中要判断很多数是否是素数,我们可以提前用素数筛法预处理,在数据比较大时,这可以省下一些时间。当然,本题只需要最朴素的方法足矣。
洛谷P1596 Lake Counting S
题目描述:由于近期的降雨,雨水汇集在农民约翰的田地不同的地方。我们用一个 N×M (1≤N≤100, 1≤M≤100) 的网格图表示。每个网格中有水(W
) 或是旱地(.
)。一个网格与其周围的八个网格相连,而一组相连的网格视为一个水坑。约翰想弄清楚他的田地已经形成了多少水坑。给出约翰田地的示意图,确定当中有多少水坑。
根据我们之前的分析,要想把DFS模板套入问题,首先要分辨出以下三个条件:
- 搜索边界,即搜索到满足什么条件时返回上一个节点。
- 遍历方法,即如何选择下一个搜索节点。
- 保存答案的方式。你可以选择全局变量、参数或者返回值的方式保存搜索得到的信息。
首先我们分析本题的要点,可以知道,任意两个 'W' 网格如果联通,只会被算作一个水坑,因此我们可以对任一个 'W' 网格进行连通性搜索,把和他联通的所有 'W' 都视为 '.' 网格,这样就可以保证不会重复计算。我们可以用二维数组模拟约翰的田地,同时对每一个数组元素为 'W' 的进行一次搜索。在搜索过程中,前往周围元素也为 'W' 的网格,继续搜索的同时将其元素更改为 '.' ,这样就能保证在后续过程中不会再次搜索到这个节点,实现了一个水坑搜索一次,那么最后的搜索总次数就是答案了。
具体代码如下:
int n, m;
//在本题中,我直接使用了数组来保存方向向量,读者可以理解并学习使用
int dx[] = { 1,0,-1,0,1,1,-1,-1 };
int dy[] = { 0,1,0,-1,1,-1,1,-1 };
int num;
//模拟田地的数组
char pool[102][102];
//边界处理 防止搜索越界造成无穷递归
bool check(int x, int y)
{
return x >= 0 && y >= 0 && x < n&& y < m;
}
//代码的内容和刚才的分析一样
void dfs(int x, int y)
{
pool[x][y] = '.';
for (int i = 0; i < 8; i++)
{
int nx = x + dx[i];
int ny = y + dy[i];
if (check(nx, ny) && pool[nx][ny] == 'W')
{
dfs(nx, ny);
}
}
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < m; j++)
{
cin >> pool[i][j];
}
}
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (pool[i][j] == 'W')
{
//每进行一次搜索就计数一次
num++;
dfs(i, j);
}
cout << num;
return 0;
}
接下来的几道比较难的题目就比较让小A摸不着头脑了,也许他还需要更多的学习和练习。于是他把题目分享给你,也许聪明的你可以尝试将它们解决?
后记
希望读者可以通过我今天的分享初步了解到DFS的原理,并能解决相关的一些简单问题。在后续过程中,我们需要广泛学习并反复练习才能熟练掌握。作为基础算法之一,DFS更多的是和其他算法结合起来形成一个复杂问题,这就需要读者自行探索了。学习编程的所有人,我们共勉。