艾舍爾的《畫手》與尾遞歸

畫手

這是一幅奇妙的圖,如你所見(jiàn),畫中的兩只手各自畫著對(duì)方,當(dāng)我們明曉這樣一種怪異的循環(huán)時(shí),一瞬間,仿佛這張靜止的畫突然流動(dòng)起來(lái),而且是一種永恒的運(yùn)動(dòng),作畫的兩只手似乎永遠(yuǎn)無(wú)法停止。正如《哥德?tīng)柊釥柊秃铡?/a>一書的作者侯世達(dá)在評(píng)價(jià)艾舍爾的這幅《畫手》時(shí)提到的:

《畫手》給我們提供了一個(gè)更緊湊的圈,這幅畫中的每一只手都在畫另一只手:這是個(gè)只包含兩個(gè)階段的怪圈。

侯世達(dá)在巴赫的音樂(lè)、艾舍爾的繪畫以及哥德?tīng)柌煌耆ɡ碇邪l(fā)現(xiàn)了“怪圈”這個(gè)概念。

所謂“怪圈”現(xiàn)象,就是當(dāng)我們向上(或向下)穿過(guò)某種層次系統(tǒng)中的(這里,系統(tǒng)是音樂(lè)的調(diào)子)一些層次時(shí),會(huì)意外地發(fā)現(xiàn)我們正好回到了我們開(kāi)始的地方。有時(shí)我用“纏結(jié)的層次結(jié)構(gòu)”這個(gè)詞來(lái)形容出現(xiàn)怪圈的系統(tǒng)。

我在閱讀《哥德?tīng)柊釥柊秃铡?/a>這本書時(shí),改不了作為程序員的積習(xí),尤其當(dāng)我看到這幅令人震撼的《畫手》時(shí),我即刻從“怪圈”想到了“遞歸(Recursion)”。因?yàn)椤斑f歸”正是這樣自身調(diào)用自身的編程技巧。當(dāng)然,一段正確的遞歸程序,必須要有一個(gè)必定能夠到達(dá)或滿足的終止條件,否則就會(huì)像《畫手》那樣永恒地循環(huán)下去。程序術(shù)語(yǔ)稱之為“死循環(huán)”。

遞歸可以讓代碼變得極為簡(jiǎn)潔,這種逐步遞減而又自我調(diào)用的方式確有一種神秘意味,然而,它卻并非是一種高效的算法實(shí)現(xiàn)。仔細(xì)琢磨遞歸的運(yùn)算軌跡,會(huì)發(fā)現(xiàn)它最終形成了“先逐步展開(kāi)而后收縮的形狀”,如下圖所示:

計(jì)算6!的線性遞歸過(guò)程,圖片來(lái)自SICP

將這種運(yùn)算過(guò)程轉(zhuǎn)換為scala代碼,則如下所示:

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

《Structure and Interpretation of Computer Programs,計(jì)算機(jī)程序的構(gòu)造和解釋》(簡(jiǎn)稱SICP)對(duì)遞歸進(jìn)行了深刻的闡釋:

這一過(guò)程構(gòu)造了一條推遲執(zhí)行的操作鏈條,收縮階段則表現(xiàn)為這些操作的實(shí)際執(zhí)行。這種過(guò)程被稱之為“遞歸過(guò)程(recursive process)”。要執(zhí)行這一過(guò)程,需要解釋器記錄后面要執(zhí)行的操作。在計(jì)算階乘n!時(shí),推遲執(zhí)行的乘法鏈條的長(zhǎng)度也就是為保存其軌跡需要保存的信息量,這個(gè)長(zhǎng)度隨著n值而線性增長(zhǎng),故而稱為“線性遞歸過(guò)程(linear recursive process)”。

正因?yàn)榇耍f歸固然讓代碼變得簡(jiǎn)潔,但由于它要保存推遲執(zhí)行的操作,鏈條越長(zhǎng),需要保存的信息越多,因而執(zhí)行的效率取決于這條“鏈條”的長(zhǎng)度。

讓我們?cè)倩剡^(guò)頭來(lái)看艾舍爾的《畫手》。在這個(gè)左手畫右手,右手畫左手無(wú)限糾纏的怪圈之外,實(shí)際上“隱藏著一直未畫出但正在畫的手,它屬于艾舍爾,左手和右手二者的創(chuàng)作者?!?/p>

“艾舍爾處于這兩只手所在的空間之外”,因此能夠做到旁觀者清。侯世達(dá)將其視為兩個(gè)不同的層次,如下圖所示:

艾舍爾《畫手》的抽象示意圖,圖片來(lái)自侯世達(dá)著作《哥德?tīng)柊釥柊秃铡?/div>

以階乘運(yùn)算為例。階乘本身其實(shí)不應(yīng)該陷入到自我調(diào)用的遞歸圈中,正如繪畫的艾舍爾應(yīng)該置身怪圈之外。遞歸僅應(yīng)該出現(xiàn)在遞進(jìn)的運(yùn)算中,每次遞進(jìn)的結(jié)果則作為參數(shù)傳入到遞歸的函數(shù)中。正如SICP對(duì)階乘運(yùn)算的分析。線性遞歸的算法體現(xiàn)的是階乘運(yùn)算的樸素?cái)?shù)學(xué)知識(shí),即:對(duì)于一個(gè)正整數(shù)n,n!就等于n乘以(n-1)!。

除此之外,SICP給出了另外一種觀察的視角:

我們可以將計(jì)算階乘n!的規(guī)則描述為:先乘1和2,而后將得到的結(jié)果乘以3,而后再乘以4,這樣下去直到達(dá)到n。更形式地說(shuō),我們要維持著一個(gè)變動(dòng)中的乘積product,以及一個(gè)從1到n的計(jì)數(shù)器counter。

我們需要將計(jì)數(shù)器counter與變動(dòng)的乘積product抽離出來(lái),放到如侯世達(dá)所說(shuō)的“可見(jiàn)的層次結(jié)構(gòu)”中,即單獨(dú)定義一個(gè)函數(shù)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函數(shù)內(nèi)部定義的函數(shù)factIter實(shí)現(xiàn)了遞歸,它有一個(gè)特征是方法的尾部是且只能是對(duì)函數(shù)自身的調(diào)用。

product變量就是前面我所謂的“每次遞進(jìn)的結(jié)果”,也就是SICP中提到的“變動(dòng)中的乘積”。準(zhǔn)確來(lái)講,應(yīng)該將product看作是一個(gè)accumulator。它每次存儲(chǔ)的都是每一步計(jì)算的結(jié)果,然后通過(guò)其匯集,最后收獲最終的運(yùn)算結(jié)果。至于counter與maxCount變量的引入,不過(guò)是為了完成遞進(jìn)的運(yùn)算,以及給出終止運(yùn)算的滿足條件而已。

SICP認(rèn)為:

(這種運(yùn)算過(guò)程)并沒(méi)有任何增長(zhǎng)或者收縮。對(duì)于任何一個(gè)n,在計(jì)算過(guò)程中的每一步,在我們需要保存的軌跡里,所有的東西就是變量product、counter和maxCount的當(dāng)前值。我們稱這種過(guò)程為一個(gè)迭代過(guò)程(iterative process)……在計(jì)算n!時(shí),所需的計(jì)算步驟隨著n線性增長(zhǎng),因而被稱為線性迭代過(guò)程(linear iterative process)。

由于其特性是內(nèi)部的函數(shù)總是在函數(shù)尾部被遞歸調(diào)用,故而這種調(diào)用方式又被稱之為尾遞歸(tail recursive)。它的執(zhí)行過(guò)程如下圖所示:

計(jì)算6!的線性迭代過(guò)程,圖片來(lái)自SICP

我們可以比較前面線性遞歸過(guò)程的執(zhí)行結(jié)果,很顯然,線性迭代過(guò)程的執(zhí)行步驟要遠(yuǎn)遠(yuǎn)少于前者。本質(zhì)上,雖然尾遞歸名為“遞歸”,其實(shí)執(zhí)行的是一個(gè)迭代過(guò)程。在Scala或者其他很多語(yǔ)言中,尾遞歸就是一種編譯技巧。例如當(dāng)Scala發(fā)現(xiàn)某個(gè)遞歸調(diào)用其實(shí)是一個(gè)尾遞歸時(shí),會(huì)自動(dòng)將該遞歸編譯為循環(huán)迭代,從而避免了每次進(jìn)行棧的操作(因?yàn)檫f歸需要記錄延遲運(yùn)算)。Scala還提供了@tailrec標(biāo)記來(lái)標(biāo)識(shí)尾遞歸。若你編寫的遞歸函數(shù)不是尾遞歸,只要標(biāo)記了@tailrec,編譯時(shí)會(huì)提示錯(cuò)誤。

要將一個(gè)遞歸過(guò)程編寫為迭代過(guò)程,而又要避免顯示地循環(huán)調(diào)用方式,關(guān)鍵之處就在于我們要轉(zhuǎn)換視角(跳出互相繪制的兩只手)。個(gè)人認(rèn)為,“四兩撥千斤”的著眼點(diǎn)就是尋找accumulator。

我在項(xiàng)目中希望對(duì)一個(gè)類似樹(shù)結(jié)構(gòu)的樣例類做一次“拍平”的操作。定義如下:

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

BindingElement的內(nèi)部“可能”嵌套了子的BindingElement列表。而子列表中的BindingElement也可能繼續(xù)嵌套。如此的嵌套自然就形成了一棵樹(shù),且非最簡(jiǎn)單的二叉樹(shù)。

需求要求將根節(jié)點(diǎn)BindingElement嵌套的所有子節(jié)點(diǎn)(包括間接嵌套)全部拍平,最后形成一個(gè)沒(méi)有任何子節(jié)點(diǎn)的BindingElement列表。

我在實(shí)現(xiàn)時(shí),直覺(jué)告訴我這個(gè)“拍平”的動(dòng)作其實(shí)就是對(duì)一棵樹(shù)的遍歷,完全可以用尾遞歸來(lái)完成??墒菍懥嗽S久,都未能將這個(gè)尾遞歸函數(shù)實(shí)現(xiàn)。問(wèn)題的癥結(jié)就是我沒(méi)有去尋找關(guān)鍵的accumulator。實(shí)質(zhì)上,針對(duì)這個(gè)場(chǎng)景,我們要返回的結(jié)果是List[BindingElement],在迭代過(guò)程中,就是每次疊加的值。初始值則為Nil。這里沒(méi)有階乘運(yùn)算中的counter與maxCount,而是一個(gè)作為children的List[BindingElement]。因?yàn)閰⑴c遞歸的結(jié)構(gòu)是列表中每個(gè)BindingElement中的children,對(duì)于List而言,我們可以判斷其值是否為Nil來(lái)作為終止條件。

找到了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中其實(shí)是一個(gè)不變的變量;所以通過(guò)運(yùn)算符:+添加元素項(xiàng)后得到的是另外一個(gè)List。然而,作為accumulator,其實(shí)它是以參數(shù)形式傳遞給下一個(gè)迭代,故而下一次迭代中的acc已經(jīng)變成了累加了元素后的結(jié)果,這是traverse函數(shù)之所以能夠奏效的原因。

因?yàn)槲策f歸函數(shù)tranverse遍歷的是List[BindingElement],這會(huì)導(dǎo)致返回的結(jié)果會(huì)缺失根元素自身,所以在初始化acc時(shí),將根元素(即this)賦值給了acc。

遞歸充滿了藝術(shù)的神秘美感,而尾遞歸則在藝術(shù)美與工程高效之間取得了平衡?!懂嬍帧返膬蓚€(gè)層次(可見(jiàn)的和不可見(jiàn)的)給了我們一個(gè)啟發(fā),就是在觀察事物時(shí),需要嘗試不同的視角,要學(xué)會(huì)跳出具體的實(shí)現(xiàn)細(xì)節(jié),找到職責(zé)上的抽象分解。對(duì)于程序員而言,我們還可以嘗試跳出計(jì)算機(jī)科學(xué)的范疇,從繪畫、數(shù)學(xué)、建筑或者音樂(lè)中尋找到靈感,利用跨界思想來(lái)體悟軟件設(shè)計(jì)與編程。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 編程很復(fù)雜,編程也很簡(jiǎn)單。簡(jiǎn)單的邏輯,通過(guò)代碼組織,就可以變成復(fù)雜程序或者系統(tǒng)。以前學(xué)物理的時(shí)候,老師就說(shuō)考試的物...
    人世間閱讀 3,512評(píng)論 4 15
  • 本文有七千字,閱讀大約需要占用你10分鐘時(shí)間。 好吧。。隨便寫的,我也不知道會(huì)花多久看完。因?yàn)閷懙谋容^爛,而且只是...
    鍋與盆閱讀 8,445評(píng)論 5 36
  • 函數(shù)參數(shù)的默認(rèn)值 基本用法 在ES6之前,不能直接為函數(shù)的參數(shù)指定默認(rèn)值,只能采用變通的方法。 上面代碼檢查函數(shù)l...
    呼呼哥閱讀 3,708評(píng)論 0 1
  • 1.函數(shù)參數(shù)的默認(rèn)值 (1).基本用法 在ES6之前,不能直接為函數(shù)的參數(shù)指定默認(rèn)值,只能采用變通的方法。
    趙然228閱讀 831評(píng)論 0 0
  • 61、卵男 你終究還只是個(gè)卵男 因?yàn)槟愕臋?quán)力來(lái)自那個(gè)臠 62、致為你寫詩(shī) 穿過(guò)星星的詩(shī) 照耀著路上的夢(mèng) 讓流星雨縱...
    楊岸達(dá)閱讀 194評(píng)論 0 0

友情鏈接更多精彩內(nèi)容