dfs求连通块

递归

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

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

相信绝大部分人都能看懂这段代码。递归除了可以用自己调用自己这样描述之外,还可以这样表示递归函数:递推式+边界处理。很显然,fb(n) = fb(n-1) + fb(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 fb(int n){
    if(n == 1 || n == 2) return 1;
    return fb(n-1) + fb(n-2);
}

其中if(n == 1 || n == 2) return 1;之前被认为是边界处理,其实这里还有个操作:回溯,也就是return 1;这个语句。在递归到达边界后,就会把值返回给上一个状态。下面的return fb(n-1) + fb(n-2);中的return的作用也是回溯的操作。那么,这个递归函数还有什么值得研究的吗?之前我们说的递推式fb(n-1) + fb(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如何实现呢?我想大家应该都猜到了:递归。所以理解递归尤为关键。这里给一道dfs的经典例题:https://www.cnblogs.com/happy-MEdge/p/10544533.html

posted @ 2019-01-29 16:58  MrEdge  阅读(1158)  评论(0编辑  收藏  举报