编者按:2007 年夏天,MIT 开设了一门以侯世达(Douglas Hofstadter)名著《哥德尔、埃舍尔、巴赫》为读本的研讨课。本次第二讲的原定讲者贾斯汀·柯里缺席,由柯伦·凯莱赫(Curran Kelleher)代课,他带着自己用 Groovy/Java 写的一组程序,从阶乘一路讲到分形蕨、曼德博集合与巴赫的和声迷宫。课堂上有学生反复追问,从「把科赫雪花剪断能拉多长」到「代码里没有递归函数,怎么知道输出是递归的」,把讲者一步步逼到递归定义的边界上。本文依据现场录音编译整理,只删去口语枝节、寒暄与重复,论证、例子与现场问答均予保留。
凯莱赫: 我叫柯伦·凯莱赫,今天由我来讲递归和分形。贾斯汀今天不在,所以我替他上这一课。我准备了一批例子,都是递归的计算机程序,有的不画图,有的画图,凡是画图的,画出来的都是分形。所谓分形(fractal),就是在不同尺度上自相似的东西,你可以一直放大下去。我们四点休息十分钟,然后我会给你们看一批分形的例子,再放几段巴赫。
凯莱赫: 先看一个递归的数学函数:阶乘。三的阶乘是 3×2×1,四的阶乘是 4×3×2×1,就是那个感叹号。它的定义本身其实就是递归的:任意一个 n 的阶乘,等于 n 乘以 (n-1) 的阶乘。比如 n 是四,n 减一就是三,四的阶乘就是四乘以三的阶乘。这是一个递归定义。
凯莱赫: 讲义第三页上有我写的一个小程序,就是做阶乘的。给不太熟悉编程的同学解释一下:def 的意思是「定义」,所以我们在定义一个叫 factorial 的函数,它接受一个参数 n。你可以调用这个函数,传给它一个数,在函数内部,这个数就叫 n。函数体是这样的:如果 n 大于 1,就返回 n 乘以 factorial(n-1);否则,返回 1。
凯莱赫: 让这个函数成为递归函数的,恰恰是它调用了它自己。阶乘被定义成 n 乘以另一个东西的阶乘。所以,递归函数就是调用自身的函数。举个例子,假设 n 是五,我们调用 factorial(5),函数拿到 n 等于五。五大于一,于是返回五乘以 factorial(4)。四也大于一,于是返回四乘以 factorial(3),也就是五乘四乘以三的阶乘。它就这样一层层调用自己,一直降到 1 为止。这就是递归。
凯莱赫: 另一个跟阶乘很像的简单例子,是斐波那契数(Fibonacci numbers)。数列是这样的:1,1,然后把前两项加起来得到下一项,于是 1,1,2;1 加 2 是 3;2 加 3 是 5;再来是 8,如此往下。这些就是斐波那契数。
凯莱赫: 这同样是一个递归定义。这里的编号只是元素的下标。斐波那契的第二项等于第零项加第一项,第五项等于第三项加第四项。一般地说,fib(n) 等于 fib(n-1) 加 fib(n-2)。所以我们把刚才那段代码里的 factorial 换成 fib 就行了,你会发现两者几乎一模一样:如果 n 大于 1,就返回 fib(n-1) 加 fib(n-2)。这样就得到了斐波那契数列。讲义里两个程序的样例输出都有,跑出来就是这个结果。
凯莱赫: 今天谁带了《哥德尔、埃舍尔、巴赫》?请翻到第 132 页。那里有两张图,是递归转移网络(recursive transition network)。它们定义了一套语法,有点像英语。它当然不是完整的英语,只是一个简化版,但作者要传达的语法本质,也就是递归语法的意思,已经完整地在里面了。
凯莱赫: 你会看到,「花哨名词」(fancy noun)这个节点里又调用了「花哨名词」自己,箭头绕回到了自身。递归就在这里。我做的事情,是把这张图改写成一个小程序:凡是遇到岔路,也就是有多条转移可选的地方,程序就随机挑一条走。这个程序在我讲义的第五页。可惜投影仪不能用,不然我就直接放给你们看了。
凯莱赫: 我们照着图看。「花哨名词」一开始就调用「普通名词」(ordinate noun)。再看讲义里的程序,大概在中间靠下的位置,写着「fancy noun 的 RTN」,然后是 fancy noun 等于某个函数调用。先说明一下,在这门语言里,给一段东西套上花括号,它基本上就成了一个函数。所以 fancy noun 等于什么呢?基本上是从图里照抄下来的:先调用 ordinate noun,然后从「介词」、「关系代词」、「什么都不接」这几项里随机挑一个。你去看书上那张图,从 ordinate noun 出来的箭头,正好指向关系代词、空、结束和介词这几处。你完全可以把它写成一个计算机程序,我做的就是这件事。
凯莱赫: 你把我的程序多看一会儿就会发现,图里所有的箭头都对应着程序里的函数调用,而它们之所以是递归的,是因为兜兜转转最后会绕回自身。侯世达在这里要说明的是:语言本身就是由递归语法定义的。这正是我们能把句子套在句子里的原因,它是递归的。递归带来嵌套,有时候是无穷的嵌套,而这正是分形的来处。
凯莱赫: 程序后面就是样例输出,你们读一读,挺好玩的。比如「奇怪的母牛里面的小小百吉饼」,作为一个英文句子居然说得通,而它是这个程序生成的。我觉得这件事本身就非常迷人。当然也有完全不知所云的,比如「跑着大小大号角的大小百吉饼」,这句就毫无意义。
凯莱赫: 下一个例子在第五页,我们要用递归函数画一棵树。我在黑板上写一段伪代码,不是真正的代码,只是把这个程序在做什么讲清楚。
凯莱赫: 我们有一个「长树」的函数,从一根枝条开始。这里是起点,是入口。程序一启动,就会执行主类里那句 tree()。第一次调用 growTree,参数是 0.5、0、树干高度等等,画出来的就是最下面这一根。这一步启动了整个过程。函数里做的事情,基本上就是:只要深度大于零,就往下做。
凯莱赫: 这个函数会调用自己两次,一次一条分枝。程序第一次调用它,就画出这一根,直接画在屏幕上。你注意看代码里那句 add new JV line X1 X2,那一句才是真正往屏幕上添一条线。我真希望能把程序跑起来给你们看。depth 是它要分叉的次数,这里 depth 是 11,所以会长成一棵很大的树。
凯莱赫: 接下来它生成两条子枝,两次 growTree 调用分别对应这一条和那一条。看讲义里的代码:growTree(X2, Y2, ...),X2、Y2 就是上一根枝条的末端;rootLength 乘以 sizeFactor,sizeFactor 是缩放系数,这里是 0.58,所以每一级大约缩小到一半;再看角度,一个是 rootAngle 加 angleFactor,一个是 rootAngle 减 angleFactor。angleFactor 是 π/4,正好 45 度。所以每分一次叉,两条枝就按这个角度岔开。
凯莱赫: 假设第一次调用时 depth 是四,进到函数里传给下一层的是 depth 减一,于是里面这一层的 depth 是三,再往下是二、一,一直减到零为止。左右两边都这样做,就这么一直长下去。这就是一个分形。你可以设想,如果不写 depth 等于 10 或者 11,而是写 depth 等于无穷,理论上这个形状就可以无限放大下去,永远放大下去,而且放大之后看上去和大尺度上一模一样。这就是分形的概念。树的部分有问题吗?
凯莱赫: 接下来是科赫曲线(Koch curve)。它和树很像,只是规则不同。先讲概念上它是什么:你从一条线段开始,有时候是从一个三角形开始(那样能做出整个雪花),把线段三等分,用中间那一段作边长,向外做一个等边三角形,也就是三边等长,然后把中间那条原线段去掉。这就是规则:从一条线变成这个折线形状。然后把同一条规则施加到新生成的每一小段上,再往下,继续对更小的段做同样的事。
凯莱赫: 一条简单的规则被反复施加,而每次施加的对象都是上一次施加规则的结果,这就是递归。你可以一直画下去。我们看代码,在第七页,函数叫 createCurve。它做的事情本质上就是调用自己四次,这四次分别对应折线的这一段、这一段、这一段和这一段。
凯莱赫: 看代码中间那四个 createCurve 调用,参数是 X1、Y1、AX、AY、CX、CY、BY 等等。对照那张红色小图:这个点是 X1、Y1,其他点在图里都标好了。第一个调用的意思是,把规则施加到这一段上;第二个从 A 点开始,也就是这里,把规则施加到那一段;第三个从 C 开始,C 是顶点;第四个施加到最后一段。这又是一个漂亮的递归分形。
凯莱赫: 如果把它推广一下,加进随机性,会发生什么呢?这些点的名字无所谓,关键是,不要让它们落在精确的位置上,而是让每个点上下随机偏移一点点,然后连线,在那里做出三角形,这里也偏一点。每一步都掺一点随机,你画出来的线条就和真实的海岸线一模一样,就是陆地边缘的那种轮廓。如果再把它推广到三维,同样加随机,生成出来的就是三维山脉,虚拟的三维曲面,看上去和真山没有分别。所以有点奇怪:这些递归结构确实存在于自然界。
学生: 第六页上写着,科赫雪花的面积是有限的,但周长是无限的,这是什么意思?
凯莱赫: 对,我刚才忘了讲,这一点特别有意思。科赫雪花是从一个三角形出发,对三角形的每一条边施加那条规则,三条边上都长出这样的曲线。数学上已经证明,或者说已经推演出来:如果你把这条规则施加无穷多次(在数学里可以这么做,因为它是纯理论的),这个图形内部的面积是有限的,是一个确定的数值。但它的表面积,不对,是周长,周长是无限的。原因在于,你每施加一次规则,周长都会变长。原来这一段有一个长度,施加规则以后,新的折线总长比原来更长。你可以想见,如果无限地做下去,它就会无限长。这真是让人头晕。
学生: 可是每迭代一次,新加的那些段不是越来越小吗?
凯莱赫: 确实越来越小。但如果你在理论上做无穷多次,它依然存在。你看第六页标题「科赫雪花」旁边那条曲线,看到了吗?即使做无穷多次,大致就是那个样子。
学生: 那假如我把这样一片雪花剪开呢?比如我把它剪成两半,再把它拉开,它会一直延伸下去吗?
凯莱赫: 你是说从中间剪断?
学生: 不是剪成两半,就是在某一处剪开。我不是要让各段互相跑到别处去,只是像剪一根围成圈的绳子那样剪一刀。
凯莱赫: 我不太确定我理解了,你要不上来画一下。
学生: 比如说我这里有一个圈,我在这个位置剪一刀。你刚才说它面积是有限的,那我这样剪开再拉直,它就能一直延伸下去,可这说不通啊。
凯莱赫: 我明白了,你说的是它同时又有有限的面积。就是说,假如这条科赫曲线是一根绳子,你把它剪断,绳子就散开了,然后你去拉它,它会一直延伸下去。是的,确实如此。这就是「周长无限」的意思,所以它才这么迷人,简直是疯了。
凯莱赫: 有人真的试过去量不列颠的海岸线。英国的海岸线有多长?你们听过这个问题吗?如果你从很大的尺度上看,比如整个国家的卫星照片,你可以沿着轮廓画一圈线,然后说:好,就这么长,这就是英国海岸线的长度。但你一放大就会发现,刚才画的那些大线段其实错得离谱,根本对不上真实的边界。于是你按照新的放大倍率把它画得更精细,结果长度变长了。
学生: 可是新加进去的那些不是应该越来越小吗,你只是补上一些小碎块而已。
凯莱赫: 对,只是补上一些小碎块。假设真实的海岸线是这个样子,你从极远处看,会说:好,大概就是这条直线。但你看得更仔细、把它细化以后就会说:不对,不是那条直线,大概是这几条折线。可是这些新折线加起来,总长度比原来更长。你再精细一点,再精细一点,它就越来越长、越来越长。所以从来没有人真正算出英国海岸线有多长。它是一个分形。
学生: 可是在这个现实世界里,真的可能存在无限精细的东西吗?那不是得有无穷小的尺度才行?
凯莱赫: 他问的是,在这个世界上有没有可能真的存在那样的无限。答案是没有,因为地球上的空间是有限的。
学生: 但就算在有限的范围内,你刚才不也说,就算面积或者体积是有限的,周长还是可以无限吗?
凯莱赫: 是的,科赫曲线在数学的语言里,如果你严格按数学去做,它的周长无限而面积有限。但要记住,这是一个理论上的造物,它只活在数学世界里,在宇宙中无法真正存在。不过自然界里有很多东西非常接近它。英国的海岸线并不是真的无限长,但它确实非常像这一类形状。科赫曲线还有别的问题吗?
凯莱赫: 下一个例子在第八页,谢尔宾斯基三角形(Sierpinski triangle),非常有意思。你取一个大三角形,在里面画一个倒着的小三角形,这就是它的规则。这么一画,你就得到了三个新的三角形。然后你对这三个新三角形再施加同一条规则。这就是递归:你对一个已经施加过规则的东西再施加一次规则,得到更小的三角形,如果一直做下去,它就无限地小下去。
凯莱赫: 这是计算它的一种方式,一种看待这条规则的方式。但我在讲义里用的是另一种,我在那里谈到了迭代函数系统(iterated function system)。我直接做给你们看,做完你们就明白它是什么意思了。
凯莱赫: 假设我们从一个点开始,比如这个点,任意一个点都行。我们有三种选择,这个玩法叫「混沌游戏」(chaos game):把这个点移到它与顶点甲连线的中点,或者移到它与顶点乙连线的中点,或者与顶点丙连线的中点。假设我们选甲,那就走到这里,正好在中间。而在迭代函数系统里,我们把这件事一遍一遍地做下去,每次随机挑三个顶点中的一个,朝它走一半路。
凯莱赫: 那么,接下来我们走向这一个,到这里;再走向这一个的一半;再走这一个;再这一个;再这一个;再这一个。就这样一直随机地走下去,把每一步落下的点都画出来。我写的那个程序(讲义里有)做的就是这件事,它一直跑,一直画点。最后你得到的,就是谢尔宾斯基三角形。太疯狂了。图就在第八页。
学生: 也就是说,随机怎么选都无所谓?不管你怎么选,最后画出来的总是同一个样子?
凯莱赫: 是的。
学生: 那如果我把程序重新跑一遍,因为它是随机的,选的顺序就会不一样。
凯莱赫: 对,正是这样。重跑一次,它的选择会不同,也许第一次不是走向这个顶点,而是走向那个顶点,也许它连着朝同一个顶点走了十次、一百次。程序是随机挑的。他问的是:既然是随机的,是不是还会生成同样的图?答案是会。这真是让人叫绝。等我们做完下一个例子,也就是分形蕨,你们会理解得更透彻。
凯莱赫: 我们先快速看一眼代码,然后想一个问题:谢尔宾斯基三角形的这段代码,是递归的吗?代码里 def drawSierpinski 就是画三角形的那部分。A、B、C 是三个顶点,也就是这三个点。points 等于由 A、B、C 组成的列表。currentPoint 就随便设成 A 好了。while true 的意思是,这段代码反复执行,一直做下去,做无穷多次,直到你把程序停掉为止。里面写着 nextPoint 等于 pickFrom(points),pickFrom 是上面定义的函数,作用就是从你给它的列表里随机挑一个。所以 pickFrom 正是那个随机决定走向哪个顶点的东西。
凯莱赫: 挑到点以后,就是 currentPoint.X 等于 (currentPoint.X + nextPoint.X) 除以二。你在做的其实是把当前点和目标点的 X 坐标取平均,X 和 Y 都这么做,得到的就是当前点与目标点的中点。这一句就干这件事。然后 image.fillPixel 把这个点画到屏幕上。第八页那张图,就是这个程序生成的。它就这样一直跑,一直画。
凯莱赫: 所以问题来了:这个东西是递归的吗?我们说过,递归函数是调用自身的函数。这个答案我们留到下一个例子之后再说。到这里有问题吗?
学生: 能再说一遍递归的定义吗?
凯莱赫: 一般来说,递归就是用自身来定义自身的东西,是绕回到自身的东西。
学生: 那如果你反复施加那条递归规则,它不就会变回它一开始的样子吗?
凯莱赫: 不一定,因为每一次施加规则,都有一点微小的变化。以阶乘为例,它说的不是「n 的阶乘等于 n 的阶乘」,而是「n 的阶乘等于 n 乘以 (n-1) 的阶乘」。阶乘那一部分是递归的,但它并没有原封不动地绕回自身,中间有变化。
凯莱赫: 至少对递归函数来说是这样:函数被定义出来,然后调用自己。我不确定这能不能回答你的问题,但你设想一下,如果我们把这几行判断去掉,函数只剩下一句「def fib(n) 返回 fib(n-1) 加 fib(n-2)」,会发生什么?它会一直调用自己,无穷无尽地调用下去。这是个问题。所以,所有递归函数要想真正做成什么事,就必须在某一处触底。它们必须停下来,而停下来的机关就是这个条件:只有当 n 大于 1 时才继续;如果 n 小于或等于 1,就直接返回。这就把它止住了。这样回答你的问题了吗?你要是觉得没答上,可以再问一遍。
学生: 也就是说,只有计算机里的递归函数才需要触底?
凯莱赫: 是的,需要触底的是那些递归的计算机函数。递归还有别的种类。不过,自然界的递归其实最终也是要触底的。拿自然界的树来说,它分叉,分叉,再分叉,但最后总会走到一片叶子上,就不再分叉了。这棵树里面运行着某种递归的程序,而当枝条细到某个尺寸时,程序会收到某种信号,我想大概是这样,于是它转而长出一片叶子。所以这类递归过程也是会触底的。
凯莱赫: 但刚才那个谢尔宾斯基的程序确实是递归的,我们过一会儿会看到它为什么是递归的,而它并不触底,因为它就这样永远跑下去。
学生: 也就是说,如果你想让递归真正给出一个可见的结果,它就必须触底,否则你拿不到返回值。但你完全可以设想一个不触底的理论世界,让它一直延续下去。
凯莱赫: 对,完全正确,你概括得非常好。他的意思是:要得到递归的任何实际显现,它就必须触底,这样才能把结果交还给你。但你确实可以设想不触底的理论世界,让它一路延伸下去。侯世达在这一章前面的对话里就这么干了:一个精灵得去问元精灵,元精灵又得去问元元精灵。有人读过那一段吗?
学生: 读过,而且那一段里时间是越来越短的。
凯莱赫: 对,时间越来越短,所以最后你在无穷小的时间里重复了无穷多次。
学生: 是啊,太疯狂了。
凯莱赫: 真的很值得琢磨。虽然是纯理论的,但想一想非常有意思。
凯莱赫: 下一个是分形蕨(fractal fern),第九页,特别酷,也特别漂亮。
凯莱赫: 我要先讲一个概念:坐标变换(coordinate transformation)。假设在这个大矩形和那个小矩形之间有一个坐标变换。它是一个作用在点上的函数,作用在二维点上。比如你取这里这个点,对它施加这个变换,得到的就是那边那个点;再取这里的另一个点,施加变换,得到那边对应的点。小矩形里的图案,就是大矩形里的一个缩小的副本。
凯莱赫: 这就是「函数」,也就是迭代函数系统里「函数」的那一部分。「迭代」指的是你一遍遍地做下去,「系统」指的是这样的函数不止一个,而是有一批。在谢尔宾斯基三角形里是三个。
凯莱赫: 分形蕨是一个有四个函数的迭代函数系统。谢尔宾斯基那个有三个,那三个点每一个就是一个函数,「朝某个点走一半」就是一个函数。而蕨这个有四个函数,每一个都是一个坐标变换。比如这一个,是从外面这个大矩形到里面这个小矩形的坐标变换。如果我们取大矩形里的这个点,施加这个变换,就得到小矩形里的这个点。
凯莱赫: 假设我们取大矩形的中心点,施加一次变换,得到的是小矩形的中心点,大概在这里。但接下来我们要说:好,这个新点现在被看作是大矩形里的点,我们对它再施加一次变换。于是大矩形里的这个点,映到小矩形里大概这个位置。然后再说:好,这个点现在又是大矩形里的点,再施加一次。于是得到这里,这里,这里,一连串越来越小的点。它们最终会越缩越小,因为这两个矩形有一个角点是重合的,我想是这样,应该没错,是同一个点。
凯莱赫: 我们再看另一个变换。这个变换的目标是一条线段,你就把它当成一个压扁的矩形来想,它同样是一个坐标变换。我们把大矩形里的任意一点,映到这条线段上的某一点。大矩形里这个点会映到线段的顶端,这个点会映到线段的底端,中心点会映到线段的中点。
凯莱赫: 现在想象一个只有这两个函数的迭代函数系统。假设两个函数各有大约一半的机会被随机选中。不,我们换个设定:假设有百分之九十的时间施加第一个映射。那么从这个点出发,它会映到这里,再映到这里,只要一直施加这个映射,它就一路往上爬。但假设它爬了一百次以后,有一次选中了另一个映射,落到了下面。也就是说,它本来在最上面,一映射就回到这里重新开始,然后又往上,往上,往上。再假设走到某处时,计算机决定施加第二个映射,而它此刻大约在四分之三高的位置,那它就会落到线段四分之三高的地方。正因为是随机的,只要一直做下去,最终它几乎会把这条线段上的每一个点都填满。这一点很值得琢磨。到这里有问题吗?
凯莱赫: 现在回到蕨。四个变换全都是从外面这个大矩形出发,映到里面某一个较小的区域。比如取上面这个点施加这个变换,它会落到这里;取那个点,会落到那里。变换的走向大致就是这样。
凯莱赫: 假设我们的迭代函数系统就有这么几个。我们从大约一半高的位置出发,选中了这个映射,它落到这里,落到这块区域的中间。然后我们连续施加这个映射很多次,它一路往上爬。再施加一次那个映射时,它这次的位置更靠近顶端了,所以落点也更靠外、更靠近这个矩形的顶端。然后一次又一次地施加。也许有时候它只往上爬了一格,那落点就在下面这里。最终,它会用这个图案把整个矩形填满,长成这个样子。而由于这里面几乎每一个点都会被那个映射反复作用很多次,我们得到的就是这样一个蕨的结构。是不是特别酷?
学生: 你的意思是说,小的那一块里的所有点,会被映射到大的那一块的不同部位去?
凯莱赫: 你说的是谢尔宾斯基三角形还是蕨?
学生: 蕨。就是说小的那一块里有一堆点,那些点是不是分别对应着蕨的某一部分?看上去它们是被重复了,只是变小了,而且每一份里的点数量还一样多。
凯莱赫: 完全正确。
学生: 可是如果越缩越小,怎么可能塞进同样多的东西?
凯莱赫: 说得对,实际上塞不进。我举个例子。假设我们不是只对一个点施加变换,而是对一堆点施加,比如这条线上的所有点,或者接近所有的点。我们把这个映射施加若干次,得到这一批、这一批、这一批点。现在我们对所有这些点施加另一个映射,它们全都会映到这里来。然后我们再做一次,再做一次。这样一来,所有那些点最终都会被映到这一块里,于是你在这里就得到了整个结构的一个小副本,你有了这些小分枝。接着这一整块又被映到上面这里,于是又有了分枝、分枝、分枝。而由于这里已经有了更小的分枝,你就有了往下两层的结构。这整块东西连同新长出来的分枝,又会被映回这里去,随着迭代进行不断填充进来。
凯莱赫: 但它不会一味地往上爬。假设我们百分之百地施加那个往上的映射,它就只会一路爬上去,哪儿也不去。可如果我们只有百分之七的时间施加另外的映射,它就会爬到各种不同的高度,然后被映回下面,再往上爬,再被映回去。再加上从这一块到那一块的变换,我们得到的就是这个东西。它是递归的。它之所以递归,是因为这些映射最终映到了自身之上。
凯莱赫: 我们看看代码,它和谢尔宾斯基三角形的代码非常像。代码本身不是递归的,但映射是递归的,正是这一点让整件事成为递归。
凯莱赫: class fern,drawFern,while true,意思是这段一直执行,反反复复地做。然后从这一列变换里随机挑一个。第一个写着「映到茎」,意思是从整体映到这里,映到茎上。第二个「映到左枝」,从整体映到这一块。第三个「映到右枝」,映到下面这里。第四个则是从这里映到这里,就是往上那么一走。
凯莱赫: 紧接着,代码里写的是:从一个函数列表里挑,同时给出一个概率列表:0.01、0.07、0.07、0.85。这就是这四个映射被选中的概率。如果你让它们等概率,那么「往上」这个映射连续发生超过五次左右的机会就非常小,于是点只会爬一点点就被映回去,得到的分形会非常稀疏,绝大多数点都堆在下面这里,看上去很难看。我试过。真希望能当场写给你们看。所以,0.01 表示映到茎的情况占百分之一,映到左右两条分枝各占百分之七,而往上盘旋的那个映射占百分之八十五。
凯莱赫: 现在休息十分钟。还有问题吗?
凯莱赫: 我们继续。在离开迭代函数系统之前,我想指出一点:从映射的角度看,谢尔宾斯基三角形和蕨其实是同一回事。谢尔宾斯基三角形有三个映射,一个把大三角形映到这个小三角形,一个映到那个小三角形,还有一个映到第三个小三角形,三者被选中的概率相等。
凯莱赫: 你想一想就明白了:从任意一点朝某个顶点走一半,本质上就是把它映到某个小三角形里去。假设你有这么一条线上的所有点,对它们施加这个函数,结果就是把它们整体映到那个小三角形里去。所以这些三角形是递归地映到自身之上的。这里确实有递归在发生,只不过递归不在代码里。代码本身只是在重复,但它重复的东西,正是这些自我映射的递归映射。
学生: 这么说,同一个算法总有不止一种表达方式?
凯莱赫: 同一个算法总有不止一种表达方式。是的,非常有意思。尤其是在分形这类东西上,好像总还有另一条路可以走,得到同样的结果。谢尔宾斯基三角形在这一点上尤其惊人。
学生: 那如果代码里根本没有递归函数呢?在不运行代码的情况下,你怎么判断它的输出会是递归的?
凯莱赫: 你是说,你手上有代码,但它里面没有递归函数?
学生: 对,代码里没有递归函数。如果你不能运行它,你怎么在不跑一遍的情况下发现输出会是递归的?
凯莱赫: 他问的是:既然代码本身不是递归的,你怎么知道输出会是递归的,哪怕里面一个递归函数都没有。我的看法是这样:当你写代码的时候,如果你意识到代码做的事情是在施加这些二维映射,你就可以在脑子里推演:这些映射会以某个概率被反复施加,那么结果会是什么。你只要在头脑里做这个外推,其实就是在跳出系统(stepping out of the system),你在问:这东西究竟在干什么?
学生: 可是非得那样不可吗?就不能待在系统里面判断?
凯莱赫: 待在系统里面?在这里,待在系统里面就意味着真的去运行代码,像计算机执行程序那样一步步走。而跳出系统,我指的是从更高的层面去看正在发生的事。
学生: 也就是说,当你把那些细节抽象掉,把做着同一件事的东西归到一起,你就得到了一个更高的层面,而递归其实是在那个层面上发生的。
凯莱赫: 完全正确,就是这样。他说的是:当你把这些函数具体在做什么的细节抽象掉,你就开始察觉到一个更高层面的东西,而那个东西本身就是递归。所以,只要你的思考抽象到足够的程度,你就能靠逻辑判断出它会生成一个递归的形状。
学生: 可是并没有什么简单的算法或者程序,能替你判断一段代码是不是递归的吧?
凯莱赫: 你是说某种检测程序?
学生: 对,某种检测。
凯莱赫: 没有,你只能靠想。这正是人能做而机器做不了的事。没有哪种严格的检测可以施加到一段计算机程序上,然后告诉你它会不会产生递归的结果。因为在这个例子里,代码里根本没有递归函数,所以计算机无从知道,它没法问自己「我能不能抽象一层看看」。只有人能做这件事,至少目前是这样。而且这段程序也不是以映射系统的形式写出来的,它就是一些普通的函数而已。
凯莱赫: 所以你只要顺着映射、映射、再映射地想下去:你把这里的东西映到那边,原先在这里、可以无限往下延展的全部结构,现在就整个装进了那个小三角形的一个小角落里。这就是一个递归结构。
凯莱赫: 我们进入下一个例子,曼德博集合,非常有意思。既然设备现在通了,我想先把讲义里的程序跑一下,让你们感觉一下。先跑递归转移网络这个,看看会怎么样。它会生成一百个句子,因为代码里有一句打印语句执行一百次。第五页上写着 (0..100).each { println fancyNoun() },这只是 Groovy 语言里一种花哨的写法。糟糕,跑不起来。算了,我一边讲一边试着修。这台机器我折腾了一整天了。
凯莱赫: 下一个也是最后一个例子,是曼德博集合(Mandelbrot set)。你们大概都听说过它,非常有名,也许是所有分形里最有名的一个。它叫这个名字,是因为曼德博这个人极其热爱分形,努力把分形的概念传达给全世界,而这确实是一个了不起的分形。接下来会稍微数学一点,不过没关系。
凯莱赫: 它活在复平面上。也就是说,我们有这样一个平面,横轴是实数,纵轴是虚数,我想是这么摆的。曼德博集合由这个函数定义:Z = Z² + C。先说清楚,一个复数就是这个平面上的一个点。假设这个点,纵坐标是 B,横坐标是 A,那么这个点就写作 A + Bi,而 i 是 -1 的平方根,这在现实中并不存在,所以它才叫「虚」数。两个复数相乘并不那么简单,你得老老实实把乘法展开,得到 AC 加上一堆项。我就不全展开了,会花不少时间。总之你得到的是一种并不线性的映射,它会怎么走并不容易预料。
凯莱赫: 算法是这样运作的:你在复平面上取一个点,C 就取这个点的值。而 Z 从零开始,就是零加零 i。然后你反复施加这个函数。第一次施加:Z = Z² + C,0 的平方是零,加上 C,所以第一次迭代之后,Z 就等于 C,Z 落在这里。然后再施加一次,这次的 Z 已经是刚才得到的新 Z 了。Z = Z² + C,你先做平方,再加上 C 的实部和虚部,于是这个点就被映到了别的地方,比如这里。然后你一次又一次地施加:这里,这里,这里。我不知道我讲得是不是太快了。总之施加一次得到这个点,它成了新的 Z,然后这个新的 Z 又绕回去,我们再算 Z² + C。C 永远是最初那个点,变的只是 Z。它映到这里,新的点又成了 Z,再算一次 Z² + C,又映到那边去。我写了一个小程序,可以把这个映射过程和它的形状演示出来。
学生: C 可以取在任何地方吗?还是说 Z 可以?就是起点。
凯莱赫: 起点可以取在任何地方。但真正运行算法时,我们只在 -2 到 2 这个范围里取,因为只有在这个区域里,这个分形才有有趣的事情发生。
凯莱赫: 算法我还没讲完。假设有一个点在这里,非常靠近原点,那么随着迭代,它往往会向内盘旋。而外面这个点,尤其是落在半径二之外的点,施加这个函数以后,往往会被甩到这里,再甩到那里,越跑越远。所以,我们渲染曼德博集合的做法是:施加函数若干次,如果这个点最终跑出了以原点为心、半径为二的圆,就停止迭代,因为我们知道,一旦它出去了,就会一路跑到无穷远。它逃逸了。这就叫逃逸迭代次数(escape iterations),也就是跑出这个圆所需要的迭代次数。
凯莱赫: 然后我们根据逃逸迭代次数给这个点上色。另一半规则是:如果它永远不逃逸,我们就把它涂成黑色。而要真正判断这一点,我们的办法是固定迭代若干次,比如五百次,或者七十次,如果迭代这么多次之后它还没有逃逸,我们就姑且认为它永远不会逃逸了。这当然不严格。所以我们画出来的只是真实集合的一个近似,但要渲染出图来,就只能这么做。这类点涂黑。
凯莱赫: 真正渲染的时候,我们遍历屏幕上的每一个像素,对每个像素执行这套算法。这个点就是 C,我们迭代,检验,迭代,检验,迭代,检验。检验的内容就是它是否跑到了圆外。一旦跑到圆外,就按照所用的迭代次数给它分配一个颜色。每一个像素都这么办。比如这一个点,它会向内盘旋,其实它不是一条连续的线,只是不断施加函数得到的一串点,向内盘旋,永远不会逃逸,所以我们把它涂成黑色。
凯莱赫: 这个小程序把这一切演示得非常好。我用 Java 实现了这套算法,它会把每一步的点输出出来,并在相邻的点之间连线。比如从这里出发,画到这里,这里,这里。
凯莱赫: 结果就是我们看到的这幅图。我把鼠标移来移去,它就在做这些检验。这里只需要一次迭代,一次迭代对应浅蓝色。这里是两次,上面会列出来,一、二。所以凡是这个颜色的地方,点在跑出圆外之前只经过了两次迭代。往里走一点,就需要三次,四次,五次,六次,七次,越往里迭代次数越多。所有有颜色的点最终都会逃逸,而所有黑色的点不会。里面那些图案特别有意思。你看中间这些,就是一路向内盘旋。这边这些呢,有一种循环的模式,看到了吗?函数绕回到了自身,所以它有点像是递归的。我也说不太清它究竟怎么个递归法,但它确实有那个味道。再往外走到第二个瘤状凸起上,形状就更漂亮了。
凯莱赫: 我们还可以放大。它会重新计算,此刻它正在为每一个点重算这套算法,速度非常快。于是我们能看到这些生成分形的极漂亮的形状,简直疯了。
凯莱赫: 我们在这里确实能看到递归,看上去肯定有某种递归的东西在运作。而递归的部分在于,Z 被施加到了自身上:第 n 个 Z 等于前一个 Z 的平方加 C,然后这个 Z 又被重新当作旧的 Z。这种重新框定、重新施加函数的动作,正是它的递归所在。所以这说得通。黑色区域里的点,就是无限地递归下去。代码本身不是递归的,但映射函数还是那个递归的东西。真正的曼德博集合其实只有那些黑点,其余的点我们上色,只是为了好看。
凯莱赫: 我们快速看一下代码。def drawMandelbrot,看到了吗?window.set(-2, 2),这是把绘图范围设定在这个区间。然后对窗口宽度上的每一个 X 像素、高度上的每一个 Y 像素,C.real 和 C.imaginary 取屏幕上对应的 X、Y 值。这一步的意思是,让像素对应到我们所处理的那个空间,也就是复平面上的实际点。接着 def iterations 等于 calculateIterations(C),我们把 C 传进去。记住,这就是 C,起点就是 C。然后 def color 等于 calculateColor(iterations),我们根据逃逸所用的迭代次数算出颜色。最后把这个像素填成那个颜色,画到屏幕上。
凯莱赫: 再看 calculateIterations 函数。int calculateIterations,这个写法表示它会返回一个整数,参数是一个复数 C。Z.real 和 Z.imaginary 都从零开始,就从那里起步。iterations 也从零开始,我们要数一数用了多少次迭代。然后:只要圆还包含着 Z(这是另一个函数,专门检验点是否在圆内),并且迭代次数小于最大迭代次数,就继续。最大迭代次数就是那个界限,超过它我们就认定这个点会一直向内盘旋、老老实实待在里面。
凯莱赫: 只要这些条件成立,就执行 Z = Z*Z + C,也就是 Z 的平方加 C。之所以能这么写,是因为我为复数重载了乘法和加法运算符,所以它的行为是正确的,会去做那套古怪的复数乘法。做完之后 iterations++,也就是迭代计数加一。而 calculateColor 就是根据迭代次数算出颜色。
凯莱赫: 我们还剩二十分钟。我想用音乐和分形把你们震一震。我要放三段巴赫,它们都太棒了。第一首其实不是约翰·塞巴斯蒂安·巴赫写的,它是《小和声迷宫》(Little Harmonic Labyrinth),书里那篇对话正是以这首曲子为蓝本的,作者是卡尔·菲利普·埃马努埃尔·巴赫。
凯莱赫: 英语有一套语法,我们可以把句子嵌进句子里,再往里嵌更多的句子,我们还可以再嵌,而刚才这句话本身就是一个嵌套的句子。音乐也一样,它也有自己的语法。那些和弦进行、旋律,尤其是和声,构成了一种语言,可以在不同的调之间移动。这就是巴赫把递归嵌进音乐的一种方式:他有这些嵌套的和声结构,嵌套的和弦进行。书的第五章里谈过这件事,而那篇对话正是照着我们刚听的《小和声迷宫》写的。
凯莱赫: 旋律同样可以带有递归,因为它可以有模式。比如这个主题,巴赫经常这么干:他拿一个主题,配上不同的和声重述一遍,而且他会把它嵌进大大小小不同的尺度里。举个例子,我不确定他在这一首里是不是这么处理的,但在更大的尺度上,整体的和声走向本身可能也遵循着同一个主题的形状:在一段很长的时间里停在这个调上,又在一段很长的时间里停在那个调上;而当他身处那个调里的时候,他弹的又是这个主题。这就是嵌套的例子。
凯莱赫: 所以严格说来,这里并没有递归本身,有的是递归的结果。这就是递归过程和递归结构的区别。递归结构基本上就是任何带有嵌套的结构。英语是一种递归结构,因为它出自一套递归语法,它有嵌套。音乐也是递归结构,而不是递归过程。
学生: 那么递归过程差不多就是一个函数,它规定了你怎样得到那个递归结构?
凯莱赫: 正是如此。他说,递归过程有点像一个函数,规定了你如何最终得到一个递归结构。完全正确。
学生: 所以他弹的是这样:整体上有一个大主题,他从这个主题里挑出一些片段来弹,弹着弹着离开那一段,转到另一段,而那一段同样属于这个主题。
凯莱赫: 对,正是这样。不过这一点很难听出来。
学生: 是很难听出来。
凯莱赫: 要听出这个,需要一副相当精细的音乐耳朵。
凯莱赫: 看看还剩多少时间,三分钟。我们来说说栈的压入和弹出。假设有一个函数叫 foo,它内部调用了函数 bar,这里是 bar 的定义,bar 做了一些事,又调用了 G,做了另一些事,这里是 G 的定义,G 也许只做一件事就结束了。于是你就有了一个调用栈(call stack)。其实任何具有这种递归式流程的语法里都会出现这种情况。所以,在计算机程序里,在英语里,都有「栈」这个概念。你在这里做一些事,做一些事。
凯莱赫: 当你调用 bar 时,你的注意力其实转移到了那边,转到了 bar 上。但你在心里,或者说在计算机的内存里,保留着你刚才在哪儿、从哪儿离开的记录。
学生: 这是不是就像你想做成某件事的时候,比如你手上有好几首曲子之类的?
凯莱赫: 你是说调用?
学生: 是目标。你有目标。
凯莱赫: 对,正是这样。这也正是编程之所以难的原因:你有一个高层次的目标,但要达成它,就像你说的,你得先达成另外一些目标,而那些目标每一个又需要更下层的目标。所以它其实是递归的,很疯狂。
凯莱赫: 那么,这个程序执行的时候,它先做完这里的事,把当前位置(就叫位置 A 吧)压到栈上。这就是栈,上面现在有 A。然后它进入 bar,做完那一堆事,接着调用 G。为了记住这次是从哪儿走的,我们把这个位置叫做 B,把 B 压到栈上,然后进入 G,做完 G 里的事。做完之后 G 返回。返回的时候它要问:我上一次是在哪儿?栈就是干这个用的。我上一次在哪儿?在 B。于是我们弹出。把东西放到栈上叫压入(push),从栈上取下来叫弹出(pop)。我们弹出,说:好,这是 B,B 就是我们刚才所在的地方。于是回到那里,把 bar 剩下的部分做完。做完之后再问:我们上一次在哪儿?取出 A,回到 A,把 foo 做完。
学生: 所以你也可以让那些东西对应到别的事物上,对吧?比如乐曲的不同段落。而那个东西又在某种意义上反映着它自己。
凯莱赫: 对,当然可以,正是如此。所以,与《小和声迷宫》相对应的那篇对话就具有这样的结构:进入一层,再进入另一层,然后退出来,再退回上一层。
凯莱赫: 五点了,今天就到这里。
代课讲师 Curran Kelleher 通过一系列递归程序(阶乘、斐波那契、递归转移网络、分形树、Koch 曲线、Sierpinski 三角形、分形蕨、Mandelbrot 集)说明递归如何产生嵌套与分形,并区分"递归过程"与"递归结构",最后引申到语言、巴赫音乐和调用栈。
讲者的初始定义是「递归函数就是调用自身的函数」。以阶乘为例,n! 被定义为 n × (n−1)!,程序中 factorial(n) 内部调用 factorial(n−1),当 n 大于 1 时不断向下调用,直到 n 等于 1 返回 1,再逐层相乘得到结果。这一定义出现在开场的阶乘与斐波那契部分,是全讲的出发点;后面讲者会用谢尔宾斯基三角形和蕨类的例子修正它。
规则是把一条线段三等分,在中段上向外做一个等边三角形,并去掉原来的中段,于是一条线段变成四条较短线段。程序 create curve 调用自身四次,正是因为每次应用规则后产生四段,每一段都要再次应用同样的规则。讲者在科赫曲线一节中把代码里四个函数调用与图上的四段一一对应,说明程序结构直接映射几何规则。
四个映射分别对应:映射到茎(概率 0.01)、映射到左分枝(0.07)、映射到右分枝(0.07)、向上盘旋的主体映射(0.85)。讲者在蕨类一节解释,这些概率只影响点的分布密度和图像饱满度,不改变最终形状;他试过等概率,结果上部极其稀疏,「看起来很不好」。这正是 Barnsley 蕨的标准参数。
因为递归函数每次都调用自身,如果没有终止条件(如 n ≤ 1 时返回 1,或 depth 为 0 时停止),它会无限调用下去,永远返回不了结果。在学生提问环节,讲者以去掉判断条件的 Fibonacci 为例说明会无限循环。他同时指出自然界的递归(如树的分枝)也会在枝条达到某个尺寸时转为生叶而停止,只有理论世界(如 GEB 里精灵问元精灵的对话)才能设想不触底的递归。
每次应用规则周长都变为原来的 4/3,无限次后周长发散到无穷;而面积增量越来越小且总和收敛,整个图形始终装在一个有限的框内。学生质疑新增的碎片越来越小、总长应该收敛,讲者的回答是:对分形而言,每层新增的长度并不趋于零,所以总和不断变大,「越精确越长」。他借英国海岸线的例子说明这是同一现象:测量尺度越小,海岸线越长,因此从来没人能定出确切数值。
因为三个「向顶点移动一半距离」的映射构成一个迭代函数系统,它们有唯一的吸引子——谢尔宾斯基三角形。无论初始点在哪、随机序列如何,点经过多次映射后都会落在吸引子上并逐渐填满它。讲者在回答学生「重新运行会不会不同」时明确说答案是「会生成同样的图」,并在蕨类例子之后进一步解释:每个映射把整体缩到局部,整体又包含所有局部,所以结构被无限复制,与具体随机路径无关。
它把递归从「函数调用自身」这一代码层面的特征,扩展为「结构把整体映射到自身的一部分」这一更抽象的属性。谢尔宾斯基三角形和蕨类的程序只是 while true 循环加随机选择,没有任何自调用,但三角形被映射到自身的三个子三角形、蕨叶被映射到自身的分枝上,这种「映射到自身」才是递归所在。这一转折为后面讨论英语和音乐做铺垫:它们是递归结构,未必由递归过程直接生成。
讲者的回答是必须「跳出系统」:不能只在代码内部按步骤执行(那就是当一台计算机),而要从更高层次抽象地思考这些映射整体上在做什么,才能预见输出会是递归结构。这直接对应 GEB 中「跳出系统」(jumping out of the system)的概念。讲者进一步断言,没有严格的机械测试能判定一个程序会否产生递归输出,「目前只有人类能做到」,这与程序高层性质不可判定的思想相通。
数学上可证明,一旦迭代点的模超过 2,后续必然发散到无穷,所以半径 2 的圆是判定逃逸的边界,有趣的结构也只出现在这个范围内。算法对每个像素取 C,从 Z=0 开始反复计算 Z=Z²+C,记录跑出圆所需的迭代次数并据此上色;若达到最大迭代次数仍未逃逸,就假定它永不逃逸并涂黑。讲者强调这是近似:真正的集合只有黑色部分,彩色只是为了好看,而最大迭代次数是不得已的阈值。
讲者在课堂上已经预先回应了这一点。他承认数学上的科赫曲线是理论构造,宇宙中不存在真正无限精细的东西(地球空间有限)。但他同时指出自然界「相当接近」它:随机化的科赫规则生成的曲线与真实海岸线几乎一样,推广到三维就是逼真的山脉;树的分枝是一种运行中的递归程序;英国海岸线的长度随测量尺度增长且无法定值。因此他的立场是:分形是理想模型,其价值在于揭示自然结构在很宽尺度范围内的自相似规律,而非声称自然是无限的。
成立,而且讲者和学生在课堂末尾已经把它推广到了目标层面。调用栈的逻辑是:进入子任务前把当前位置压入栈,完成后弹出并回到原处;巴赫的《小和声迷宫》从主调进入某调、再进入更远的调,然后逐层返回,结构相同。学生提出「有目标时也是这样」,讲者认同:高层目标需要子目标,子目标又需要子子目标,这就是编程困难的根源。类比的限度在于:栈要求严格的后进先出,而人类的目标追求常常并行、中途放弃或忘记返回——这正是 GEB 所说的「层数太深会忘了自己在哪」。