艾舍尔的《画手》与尾递归

画手

这是一幅奇妙的图,如你所见,画中的两只手各自画着对方,当我们明晓这样一种怪异的循环时,一瞬间,仿佛这张静止的画突然流动起来,而且是一种永恒的运动,作画的两只手似乎永远无法停止。正如《哥德尔艾舍尔巴赫》一书的作者侯世达在评价艾舍尔的这幅《画手》时提到的:

《画手》给我们提供了一个更紧凑的圈,这幅画中的每一只手都在画另一只手:这是个只包含两个阶段的怪圈。

侯世达在巴赫的音乐、艾舍尔的绘画以及哥德尔不完全定理中发现了“怪圈”这个概念。

所谓“怪圈”现象,就是当我们向上(或向下)穿过某种层次系统中的(这里,系统是音乐的调子)一些层次时,会意外地发现我们正好回到了我们开始的地方。有时我用“缠结的层次结构”这个词来形容出现怪圈的系统。

我在阅读《哥德尔艾舍尔巴赫》这本书时,改不了作为程序员的积习,尤其当我看到这幅令人震撼的《画手》时,我即刻从“怪圈”想到了“递归(Recursion)”。因为“递归”正是这样自身调用自身的编程技巧。当然,一段正确的递归程序,必须要有一个必定能够到达或满足的终止条件,否则就会像《画手》那样永恒地循环下去。程序术语称之为“死循环”。

递归可以让代码变得极为简洁,这种逐步递减而又自我调用的方式确有一种神秘意味,然而,它却并非是一种高效的算法实现。仔细琢磨递归的运算轨迹,会发现它最终形成了“先逐步展开而后收缩的形状”,如下图所示:

计算6!的线性递归过程,图片来自SICP

将这种运算过程转换为scala代码,则如下所示:

def factorial(n: Int): Long = 
  if (n == 1) 1 else n * factorial(n - 1)

《Structure and Interpretation of Computer Programs,计算机程序的构造和解释》(简称SICP)对递归进行了深刻的阐释:

这一过程构造了一条推迟执行的操作链条,收缩阶段则表现为这些操作的实际执行。这种过程被称之为“递归过程(recursive process)”。要执行这一过程,需要解释器记录后面要执行的操作。在计算阶乘n!时,推迟执行的乘法链条的长度也就是为保存其轨迹需要保存的信息量,这个长度随着n值而线性增长,故而称为“线性递归过程(linear recursive process)”。

正因为此,递归固然让代码变得简洁,但由于它要保存推迟执行的操作,链条越长,需要保存的信息越多,因而执行的效率取决于这条“链条”的长度。

让我们再回过头来看艾舍尔的《画手》。在这个左手画右手,右手画左手无限纠缠的怪圈之外,实际上“隐藏着一直未画出但正在画的手,它属于艾舍尔,左手和右手二者的创作者。”

“艾舍尔处于这两只手所在的空间之外”,因此能够做到旁观者清。侯世达将其视为两个不同的层次,如下图所示:

艾舍尔《画手》的抽象示意图,图片来自侯世达著作《哥德尔艾舍尔巴赫》

以阶乘运算为例。阶乘本身其实不应该陷入到自我调用的递归圈中,正如绘画的艾舍尔应该置身怪圈之外。递归仅应该出现在递进的运算中,每次递进的结果则作为参数传入到递归的函数中。正如SICP对阶乘运算的分析。线性递归的算法体现的是阶乘运算的朴素数学知识,即:对于一个正整数n,n!就等于n乘以(n-1)!。

除此之外,SICP给出了另外一种观察的视角:

我们可以将计算阶乘n!的规则描述为:先乘1和2,而后将得到的结果乘以3,而后再乘以4,这样下去直到达到n。更形式地说,我们要维持着一个变动中的乘积product,以及一个从1到n的计数器counter。

我们需要将计数器counter与变动的乘积product抽离出来,放到如侯世达所说的“可见的层次结构”中,即单独定义一个函数factIter:

def factorial(n: Int): Long = {
  def factIter(product: Long, counter: Int, maxCount:Int): Long =
    if (counter > maxCount) product
    else factIter(product * counter, counter+1, maxCount)
  factIter(1, 1, n)
}

在factorial函数内部定义的函数factIter实现了递归,它有一个特征是方法的尾部是且只能是对函数自身的调用。

product变量就是前面我所谓的“每次递进的结果”,也就是SICP中提到的“变动中的乘积”。准确来讲,应该将product看作是一个accumulator。它每次存储的都是每一步计算的结果,然后通过其汇集,最后收获最终的运算结果。至于counter与maxCount变量的引入,不过是为了完成递进的运算,以及给出终止运算的满足条件而已。

SICP认为:

(这种运算过程)并没有任何增长或者收缩。对于任何一个n,在计算过程中的每一步,在我们需要保存的轨迹里,所有的东西就是变量product、counter和maxCount的当前值。我们称这种过程为一个迭代过程(iterative process)……在计算n!时,所需的计算步骤随着n线性增长,因而被称为线性迭代过程(linear iterative process)。

由于其特性是内部的函数总是在函数尾部被递归调用,故而这种调用方式又被称之为尾递归(tail recursive)。它的执行过程如下图所示:

计算6!的线性迭代过程,图片来自SICP

我们可以比较前面线性递归过程的执行结果,很显然,线性迭代过程的执行步骤要远远少于前者。本质上,虽然尾递归名为“递归”,其实执行的是一个迭代过程。在Scala或者其他很多语言中,尾递归就是一种编译技巧。例如当Scala发现某个递归调用其实是一个尾递归时,会自动将该递归编译为循环迭代,从而避免了每次进行栈的操作(因为递归需要记录延迟运算)。Scala还提供了@tailrec标记来标识尾递归。若你编写的递归函数不是尾递归,只要标记了@tailrec,编译时会提示错误。

要将一个递归过程编写为迭代过程,而又要避免显示地循环调用方式,关键之处就在于我们要转换视角(跳出互相绘制的两只手)。个人认为,“四两拨千斤”的着眼点就是寻找accumulator

我在项目中希望对一个类似树结构的样例类做一次“拍平”的操作。定义如下:

case class BindingElement(itemType: BindingItemType, fieldId: ID, children: Option[List[BindingElement]])

BindingElement的内部“可能”嵌套了子的BindingElement列表。而子列表中的BindingElement也可能继续嵌套。如此的嵌套自然就形成了一棵树,且非最简单的二叉树。

需求要求将根节点BindingElement嵌套的所有子节点(包括间接嵌套)全部拍平,最后形成一个没有任何子节点的BindingElement列表。

我在实现时,直觉告诉我这个“拍平”的动作其实就是对一棵树的遍历,完全可以用尾递归来完成。可是写了许久,都未能将这个尾递归函数实现。问题的症结就是我没有去寻找关键的accumulator。实质上,针对这个场景,我们要返回的结果是List[BindingElement],在迭代过程中,就是每次叠加的值。初始值则为Nil。这里没有阶乘运算中的counter与maxCount,而是一个作为children的List[BindingElement]。因为参与递归的结构是列表中每个BindingElement中的children,对于List而言,我们可以判断其值是否为Nil来作为终止条件。

找到了accumulator,尾递归的迭代算法自然就水到渠成了。代码如下所示:

case class BindingElement(itemType: BindingItemType, fieldId: ID, children: Option[List[BindingElement]]) { 
  def flatten:List[BindingElement] = { 
    def cloneToLeaf(item: BindingElement) = BindingElement(item.itemType, item.fieldId, None) 

    @tailrec 
    def traverse(items: List[BindingElement], acc: List[BindingElement]): List[BindingElement] = 
      items match { 
        case Nil => acc 
        case head :: tail => traverse(head.children.getOrElse(Nil) ::: tail, acc :+ cloneToLeaf(head)) 
      }

    traverse(children.getOrElse(Nil), List[BindingElement](cloneToLeaf(this))) 
  } 
}

注意,这里的acc(即accumulator)在Scala中其实是一个不变的变量;所以通过运算符:+添加元素项后得到的是另外一个List。然而,作为accumulator,其实它是以参数形式传递给下一个迭代,故而下一次迭代中的acc已经变成了累加了元素后的结果,这是traverse函数之所以能够奏效的原因。

因为尾递归函数tranverse遍历的是List[BindingElement],这会导致返回的结果会缺失根元素自身,所以在初始化acc时,将根元素(即this)赋值给了acc。

递归充满了艺术的神秘美感,而尾递归则在艺术美与工程高效之间取得了平衡。《画手》的两个层次(可见的和不可见的)给了我们一个启发,就是在观察事物时,需要尝试不同的视角,要学会跳出具体的实现细节,找到职责上的抽象分解。对于程序员而言,我们还可以尝试跳出计算机科学的范畴,从绘画、数学、建筑或者音乐中寻找到灵感,利用跨界思想来体悟软件设计与编程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容

  • 编程很复杂,编程也很简单。简单的逻辑,通过代码组织,就可以变成复杂程序或者系统。以前学物理的时候,老师就说考试的物...
    人世间阅读 3,372评论 4 15
  • 本文有七千字,阅读大约需要占用你10分钟时间。 好吧。。随便写的,我也不知道会花多久看完。因为写的比较烂,而且只是...
    锅与盆阅读 8,034评论 5 36
  • 函数参数的默认值 基本用法 在ES6之前,不能直接为函数的参数指定默认值,只能采用变通的方法。 上面代码检查函数l...
    呼呼哥阅读 3,351评论 0 1
  • 1.函数参数的默认值 (1).基本用法 在ES6之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
    赵然228阅读 683评论 0 0
  • 61、卵男 你终究还只是个卵男 因为你的权力来自那个脔 62、致为你写诗 穿过星星的诗 照耀着路上的梦 让流星雨纵...
    杨岸达阅读 172评论 0 0