本文将回答以下问题:
- 如何遍历一棵有根树?
- 如何超越遍历?
如何遍历一棵有根树?
其实要说一棵树的遍历,大家其实都很清楚,无非先序、后序、层次三种(当然中序遍历只存在于二叉树中,我们后面介绍)。不过既然是函数式角度,免不了还要多bb几句。
遍历,是以一定顺序依次访问树的每个结点。所以看到顺序这个词,其实大家就很清楚这个东西并不是函数式的产物,函数式的程序设计中中我们要尽力避免这种东西的使用。在本文最后我们将会分析这个问题。
预先的一些约定
首先,我们仍然沿用上一篇文章的那棵树,也就是
(
存储方式当然也与上一次一样。
为了能让scheme这个函数式语言类似其他过程式语言一样依次做一些事情,scheme给出了这样的语法:
(
为了一个类似过程化语言的实现,我还是写了这个一个用来实现for循环的函数:
(
可以看到就是就是个将这个列表里的每个元素执行一遍函数
for
或者C++的
for
先序遍历
众所周知,先序遍历就是先访问根节点,然后依次递归访问孩子,例如C++中我们会这么写:
void
这里假设的是
template
借助前面我们定义的for-list,用scheme写的话就是:
(
其中(newline)是换行。
感兴趣的同学可以试一下,将前面的树的声明与这里的代码复制进入scheme解释器中,然后
(
看看效果。
理论上结果应该是这个顺序:
贾代善->贾赦->贾琏->贾政->贾珠->贾宝玉->贾环
后序遍历
后序遍历同理,只不过先递归子树再访问根节点,那么就是
(
不多解释了。一样用上面那棵树测试的话,结果应该是
贾琏->贾赦->贾珠->贾宝玉->贾环->贾政->贾代善
层次遍历
学过图论的同学应该知道,其实先序遍历和后序遍历本质上是深度优先搜索(DFS),而层次遍历就是宽度优先搜索(BFS)。
层次遍历会按照“辈分”来访问结点,也就是说根结点先来,然后是根节点第一层孩子(模仿数学的话应该叫一阶孩子?),接着是根节点的孩子的孩子(二阶孩子),以此类推。
在过程化语言中,我们通常采用队列来完成这个任务。例如C++中:
template
(当然这里的Queue得用std::deque<*Tree<T>>一类的方法来实现,否则存储量挺惊人的——不过这个不是本文讨论的重点啦)
首先我们补充定义一个函数
(
举个栗子,一目了然:
然后,我们只需要处理一个to-do-list即可(也就是我们前面的Queue)。代码如下:
(
这里做个备注,也就是新的to-do-list,我这里是
(
一行生成的——其实很简单,(cdr to-do-list)就是pop以后的queue,而(cdr (car to-do-list))是pop出来的那个结点的孩子列表,正好cons-list一下就行。
以前面荣国府的栗子,结果应该是:
贾代善->贾赦->贾政->贾琏->贾珠->贾宝玉->贾环
如何超越遍历?
如果只写到这里,我们只是用函数式语言中一个最不好的东西(scheme的串行begin)来实现过程语言的任务——用不合适的语言,以一个丑陋的形式去做。
但是回到函数式的起点,我们要函数式的作用,其实是为了避免思考操作的先后顺序,用函数来完成全部任务。而遍历这个事情本身,就是要给出一个操作顺序,自然是先后矛盾的。
另一个角度,其实遍历很多时候是出于一种无奈——树是分叉的,但是(冯·诺伊曼)计算机的体系结构同时只能做一件事情,于是我们不得不考虑给他们一个计算的先后顺序。比如说一棵数字树,我要算所有结点的数值之和,因为体系结构只能将两个数字加起来,所以我们必须初始化一个sum = 0,然后按照一定顺序把树的每个结点x都来个sum += x。
函数式,我们试图不考虑体系结构。正如当年mit-scheme诞生的时代——1975年,那时离第一枚多核处理器的诞生还有30年历史,离现在并行计算最常用的设备——GPU的诞生也还有24年,但是当年的先驱者就已经在scheme中引入了
我想,也许,那个时候就不再有遍历的问题了,我们也许可以同时对全部子树分别开一个线程,这样我们便无所谓顺序了。这也许正是函数式的高瞻远瞩之处。
以前面的栗子,如果我们需要把一棵树的全部结点加起来(而不是display这样的过程),我们可以完完全全用纯粹的函数式的手法去描述(对先序的程序稍加修改即可):
(
如果此时,我们能把+并行计算,比如说(+ (f a) (f b)),能开两个线程同时计算(f a)和(f b),然后再求和,那么其实就实现了我们想要的并发模式——再想想,此时这个“访问顺序”还是先序吗?