Bootstrap

递归的应用:dfs

递归

递归是什么?绝大部分人都会说:自己调用自己,刚开始我也是这样理解递归的。确实没错,递归的确是自己调用自己。递归简单的应用:编写一个能计算斐波那契数列的函数,也就是这样:

int fac(int n){
    if(n == 1 || n == 2) return 1;
    return fac(n-1) + fac(n-2);
}

相信绝大部分人都能看懂这段代码。递归除了可以用自己调用自己这样描述之外,还可以这样表示递归函数:递推式+边界处理。很显然,fac(n) = f(n-1) + f(n-2)就是这个计算斐波那契数列的递推式,而上面的if语句就是边界处理。但是,当我接触到二叉树这个数据结构时,这样的递归定义显然还不够完整,还差一点。我们先介绍一个数据结构,链表,链表是常见的基础数据结构,对于链表的实现书和网上都有讲,那么我们可不可以给链表这样一个递归定义:

链表:要么结点为空,要么由结点和子节点构成

这样定义了链表后,其实二叉树的递归定义就好理解了:

二叉树:要么为空,要么由根结点、左子树和右子树组成,而左子树和右子树分别是一棵二叉树。 摘自《算法竞赛入门经典》

链表和二叉树

看完了二叉树,就可以想到二叉树的结点是可以用类似与实现链表的方法来进行实现:

struct node{
    int value;
    node *left, *right;
};

和链表相比,二叉树只不过是多了一个指针而已。但是呢,多了一个指针有变得有点麻烦:我们在遍历链表时,只需要用一个循环就能遍历完链表中的结点。

struct node{
    int value;
    node *next;
};

void fun(node* root){  //root是链表头,即链表第一个元素的地址(指针)
    for(node *p = root; p != NULL; p=p->next){
        //操作
    }
}

而对于二叉树要写多少个循环呢,一个,两个?我们发现,二叉树并不能像链表一样简单的遍历,因为二叉树每到一个结点就有两个方向可以走,并不像一个for循环只规定一个方向。还有一个问题:当我们从根节点出发时,如果按照我们普通的方法遍历,应该是从左到右遍历,也就是先遍历左子树,遍历完后再遍历右子树。

当遍历左子树时,我们发现,这个左子树的跟结点也连接有左子树和右子树。

这时遍历过程如果用循环写就变得异常复杂,最关键的是,怎样从左子树遍历完后开始右子树的遍历。有的人说,到了树的末端就停止左子树的遍历,然后进行右子树的遍历。但是右子树又要从哪个结点开始遍历呢,所以我们还要写一个回溯的代码,而这仅仅用循环是很难实现的,下面是回溯图:

看起来非常复杂,其实我们用递归就可以解决这个问题,关键是我们对递归怎样进一步地去理解。我们再次看回斐波那契函数的代码:

int fac(int n){
    if(n == 1 || n == 2) return 1;
    return fac(n-1) + fac(n-2);
}

其中if(n == 1 || n == 2) return 1;之前被认为是边界处理,其实这里还有个操作:回溯,也就是return 1;这个语句。在递归到达边界后,就会把值返回给上一个状态。下面的return fac(n-1) + fac(n-2);中的return的作用也是回溯的操作。那么,这个递归函数还有什么值得研究的吗?之前我们说的递推式fac(n-1) + fac(n-2),在这里我们把它称为要重复做的事。所以,根据上面的解释,我们又可以这样理解递归:递归是可以帮你完成要重复做的事情,只要你规定好边界和处理好回溯的问题。那么递归相比于我们普通写的循环(递推)有什么优势呢?首先,相同点我们都知道,就是同样可以完成要重复做的事,不同在于循环一般是完成单方向的重复做的事,如果是多方向的重复做的事可能要写多重循环,甚至多重循环都不一定解决的了,代码实现相对较难。而递归呢,则单方向和多方向要完成重复做的事都可以,而且关注点只是重复做的事情,处理好边界和回溯问题就行了,减少思考的时间(这个时间因情况而定,如果你每一步递归全都要思考一遍,把过程写出来,自然是会消耗不少时间,减少时间的前提是你把要重复做的事抽象化出来,处理好边界问题后相信递归能计算出来),我先摆上遍历二叉树代码:

struct node{
    int value;
    node *left, *right;
};

void dfs(node* root){    //root为二叉树的根节点的地址(指针)
    if(root == NULL) return;  //边界处理,如果到达边界就回溯
    //重复要做的事
    dfs(root->left); 
    dfs(root->right);
    return;   //遍历完左子树和右子树后返回
}

是不是递归函数的代码很简洁?我们分析为什么遍历二叉树可以这样写:看回二叉树的递归定义:结点,左子树和右子树。所以我们在遍历时重复的操作是遍历左子树和右子树,那么怎样遍历左子树和右子树呢?首先肯定是要到左子树和右子树的根节点才能继续遍历。于是完整要做的重复事情是:到达一个节点后,遍历它的左结点和右结点。于是代码就变成这样:

void dfs(node* root){    //到达一个结点
    dfs(root->left);    //遍历左结点
    dfs(root->right);   //遍历右结点
}

这时递归函数的主要框架已经完成,也就是我们搞定了要重复做的事。接下来就要考虑边界和回溯的问题。首先考虑边界吧。当到达树的底部时:

我们怎样停止遍历,也就是判断的依据是什么?
我们可以看到上图,一个结点是边界的标志是它的左右子节点都为空,也就是:

root->left == NULL && root->right == NULL

于是原来的代码可以这样写:

void dfs(node* root){    //到达一个结点
    if(root->left == NULL && root->right == NULL) return;  //如果左右结点为空则不再遍历左结点和右结点
    dfs(root->left);    //遍历左结点
    dfs(root->right);   //遍历右结点
}

当然,也可以这样写:

void dfs(node* root){    //到达一个结点
    if(root == NULL) return; //当结点为空时就回溯
    dfs(root->left);    //遍历左结点
    dfs(root->right);   //遍历右结点
}

这样写看起来更简洁一些,为什么可行呢?当我们遍历到最后一个结点时,这个代码会继续遍历左结点,然后到了左结点这个状态。检查这个结点,发现为空,所以返回。返回后遍历右结点,发现右结点也为空,所以返回。然后遍历完左结点和右结点后返回。这里有些小伙伴可能会有些疑问?为什么遍历完左结点和右结点后会返回呢?这里没有返回代码啊!其实这里的返回只是省略不写,因为是void类型啊,执行完后就会自动返回。所以完整的遍历二叉树的代码是:

struct node{
    int value;
    node *left, *right;
};

void dfs(node* root){    
    if(root == NULL) return;  
    //其他操作可以写在这里,比如查找值等等
    dfs(root->left); 
    dfs(root->right);
}

这里有个小坑:如果你的边界处理是这样:if(root->left == NULL && root->right == NULL) return;你要执行的操作应该在这个语句前面,否则会导致最后一个点遍历不了。所以最好写成上面完整代码的形式。对于如何用递归建立二叉树,有兴趣的小伙伴可以自行百度,这里作者就不再详细说明。

dfs

终于讲到dfs,我要die了 dfs:深度优先搜索,英文全称:Depth-First-Search。刚刚遍历二叉树时,我们的函数名是不是写成了dfs?对,没错,刚刚遍历二叉树的方法就是一种dfs。那么,dfs如何实现呢?我想大家应该都猜到了:递归。所以理解递归尤为关键。在这里我就直接送上大礼包吧!

传送门

题面:

Due to recent rains, water has pooled in various places in Farmer John’s field, which is represented by a rectangle of N x M (1 <= N <= 100; 1 <= M <= 100) squares. Each square contains either water (‘W’) or dry land (’.’). Farmer John would like to figure out how many ponds have formed in his field. A pond is a connected set of squares with water in them, where a square is considered adjacent to all eight of its neighbors.

Given a diagram of Farmer John’s field, determine how many ponds he has.

Input

  • Line 1: Two space-separated integers: N and M

  • Lines 2…N+1: M characters per line representing one row of Farmer John’s field. Each character is either ‘W’ or ‘.’. The characters do not have spaces between them.

Output

  • Line 1: The number of ponds in Farmer John’s field.

Sample Input
10 12
W…WW.
.WWW…WWW
…WW…WW.
…WW.
…W…
…W…W…
.W.W…WW.
W.W.W…W.
.W.W…W.
…W…W.

Sample Output
3

Hint

OUTPUT DETAILS:

There are three ponds: one in the upper left, one in the lower left,and one along the right side.

题面描述

农场下雨了,然后想知道农场有多少个水坑,这里用’W’表示这块地有水,判断是否为同一个水坑的标志是水连在一起,也就是:如果一个水的相邻方向(上下左右和对角)都有水的话,那么它们就属于同一个水坑。具体可以看样例。

题目分析

呃,这是一道经典的用dfs求连通块的题。什么是连通块?这里的水坑就是一个连通块。那么,我们如何用dfs,即递归的方法完成这道题呢?
我们先看看AC代码:

#include <cstring>
#include <iostream>
using namespace std;
const int maxn = 100+5;
char a[maxn][maxn];
int n, m;

bool check(int r, int c){  //越界检查
    if(r < 0 || r >= n || c < 0 || c >= m) return false;
    return true;
}

void dfs(int r, int c){
    if(a[r][c] == '#') return;  //被标记过就不用继续标记了
    a[r][c] = '#';  //标记
    int r1, c1;
    for(r1 = -1; r1 <= 1; r1++)    //遍历上下左右对角(九宫格),这里不会遍历到自己
        for(c1 = -1; c1 <= 1; c1++)
            if(check(r+r1, c+c1) && a[r+r1][c+c1] == 'W')  
                dfs(r+r1, c+c1);   //找到相邻的'W'继续进行遍历
    //遍历完相邻的'W'后回溯,这里return省略不写
}

int main(){
    memset(a, 0, sizeof(a));  //清零
    cin >> n >> m;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++)
            cin >> a[i][j];
    int cnt = 0;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++)
           if(a[i][j] == 'W') {dfs(i, j); cnt++;} //找到一个水而且没被标记过,就进行深度搜索,计数
    cout << cnt << endl;
    return 0;
}

这里涉及到一个新的方法:种子填充,我先摆在这里,后面再讲。下面是维基百科演示种子填充的gif图:


我们一步步分析代码怎么得来,先看主函数做了什么:

    for(int i = 0; i < n; i++)
        for(int j = 0; j < m; j++)
           if(a[i][j] == 'W') {dfs(i, j); cnt++;}

这里的思路就是,当我找到一个水后,把相邻的水连通起来,然后计数:

在这里插入图片描述

那么,我们“连通水”这个函数怎么写呢?这里就要用到我们之前说的方法,种子填充。其实通俗点理解就是做标记。就像这样:
在这里插入图片描述

为什么要做标记呢?如果不做标记,主函数就会继续遍历到同一个水坑。所以,我们的函数就实现两个功能:一:做标记,二:遍历相邻的水然后连通它们。而且我们也注意到,当遍历到其中一个相邻的水时,这个相邻的水又可以做为中心点去遍历其他的水,从而达到连通的目的。这提示了我们可以用递归来解决这个重复做的事,所以大概代码是这样:

void dfs(int r, int c){
    a[r][c] = '#';   //标记自己本身
    int r1, c1;
    for(r1 = -1; r1 <= 1; r1++)   //遍历相邻的水
        for(c1 = -1; c1 <= 1; c1++)
            if(a[r+r1][c+c1] == 'W')
                dfs(r+r1, c+c1);
    //遍历完后自动回溯    
}

到了这里,好像还少了些什么?没错,可能会出现数组越界访问的问题,所以只需要这样:

void dfs(int r, int c){
    a[r][c] = '#'; 
    int r1, c1;
    for(r1 = -1; r1 <= 1; r1++)   
        for(c1 = -1; c1 <= 1; c1++)
            if(r+r1 >= 0 && r+r1 < n && c+c1 >= 0 && c+c1 < m && a[r+r1][c+c1] == 'W')
                dfs(r+r1, c+c1);
}

因为我忍受不了一行这么长的代码,所以 我用了一个check()函数解决这个问题。
然后这道题就解决了。

有人会问,我的dfs函数的代码还多了个if(a[r][c] == '#') return;,这个目的是防止重复标记,不过对于数据比较少的题目这个没有多大的影响,可有可无。

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;