有趣的 Scala 語言: 使用遞迴的方式去思考

發表於2013-08-18

在初學計算機程式設計時,我想大多數人的經歷會和作者一樣,學校為我們挑選一門語言,大多為 C 或 Java,先是基本的資料型別,然後是程式控制語句,條件判斷,迴圈等,書上會教我們如何定義一個函式,會說程式就是一條一條的指令,告訴計算機該如何操作。同時,我們還會看到如何定義一個遞迴函式,用來計算階乘或斐波那契數列。工作以後,其他的這些基礎還在日復一日的使用,但遞迴卻很少再被用到,以致我們很難再用遞迴的方式去解決問題了,為此,我們還有一個藉口:遞迴效能差,使用迴圈效率高。事實真是這樣的嗎?我們為自己某種能力的喪失編織了一個美麗的謊言,直到越來越多的程式語言變得流行起來,使我們有機會看到各種語言、各種風格寫出的程式,才發現自己應該重新審視遞迴這一概念了。

為什麼遞迴會受到忽視

為了回答這一問題,必須先說到程式設計正規化。在所有的程式設計正規化中,物件導向程式設計(Object-Oriented Programming)無疑是最大的贏家。看看網上的招聘啟事,無一例外,會要求應聘者熟練掌握物件導向程式設計。但其實物件導向程式設計並不是一種嚴格意義上的程式設計正規化,嚴格意義上的程式設計正規化分為:指令式程式設計(Imperative Programming)、函數語言程式設計(Functional Programming)和邏輯式程式設計(Logic Programming)。物件導向程式設計只是上述幾種正規化的一個交叉產物,更多的還是繼承了指令式程式設計的基因。遺憾的是,在長期的教學過程中,只有指令式程式設計得到了強調,那就是程式設計師要告訴計算機應該怎麼做,而不是告訴計算機做什麼。而遞迴則通過靈巧的函式定義,告訴計算機做什麼。因此在使用指令式程式設計思維的程式中,不得不說,這是現在多數程式採用的程式設計方式,遞迴出鏡的機率很少,而在函數語言程式設計中,大家可以隨處見到遞迴的方式。下面,我們就通過例項,為大家展示遞迴如何作為一種普遍方式,來解決程式設計問題的。

一組簡單的例子

如何為一組整數數列求和?按照通常指令式程式設計的思維,我們會採用迴圈,依次遍歷列表中的每個元素進行累加,最終給出求和結果。這樣的程式不難寫,稍微具備一點程式設計經驗的人在一分鐘之內就能寫出來。這次我們換個思維,如何用遞迴的方式求和?為此,我們不妨把問題簡化一點,假設數列包含 N 個數,如果我們已經知道了後續 N – 1 個數的和,那麼整個數列的和即為第一個數加上後續 N – 1 個數的和,依此類推,我們可以以同樣的方式為 N – 1 個數繼續求和,直到數列為空,顯然,空數列的和為零。聽起來複雜,事實上我們可以用一句話來總結:一個數列的和即為數列中的第一個數加上由後續數字組成的數列的和。現在,讓我們用 Scala 語言把這個想法表達出來。

清單 1. 數列求和

大家可以看到,我們只使用一行程式,就將上面求和的方法表達出來了,而且這一行程式看上去簡單易懂。儘量少寫程式碼,這也是 Scala 語言的設計哲學之一,較少的程式碼量意味著寫起來更加容易,讀起來更加易懂,同時程式碼出錯的概率也會降低。同樣的程式,使用 Scala 語言寫出的程式碼量通常會比 Java 少一半甚至更多。

上述這個數列求和的例子並不是特別的,它代表了遞迴對於列表的一種普遍的處理方式,即對一個列表的操作,可轉化為對第一個元素,及剩餘列表的相同操作。比如我們可以用同樣的方式求一個數列中的最大值。我們假設已經知道了除第一個元素外剩餘數列的最大值,那麼整個數列的最大值即為第一個元素和剩餘數列最大值中的大者。這裡需要注意的是對於一個空數列求最大值是沒有意義的,所以我們需要向外丟擲一個異常。當數列只包含一個元素時,最大值就為這個元素本身,這種情況是我們這個遞迴的邊界條件。一個遞迴演算法,必須要有這樣一個邊界條件,否則會一直遞迴下去,形成死迴圈。

清單 2. 求最大值

同樣的方式,我們也可以求一個數列中的最小值,作為一個練習,讀者可下去自行實現。

讓我們再看一個例子:如何反轉一個字串?比如給定一個字串"abcd",經過反轉之後變為 "dcba"。同樣的,我們可以做一個大膽的假設,假設後續字串已經反轉過來,那麼接上第一個字元,整個字串就反轉過來了。對於一個只有一個字元的字串,不需要反轉,這是我們這個遞迴演算法的邊界條件。程式實現如下:

清單 3. 反轉字串

最後一個例子是經典的快速排序,讀者可能會覺得這個例子算不上簡單,但是我們會看到,使用遞迴的方式,再加上 Scala 簡潔的語言特性,我們只需要短短几行程式,就可以實現快速排序演算法。快速排序演算法的核心思想是:在一個無序列表中選擇一個值,根據該值將列表分為兩部分,比該值小的那一部分排在前面,比該值大的部分排在後面。對於這兩部分各自使用同樣的方式進行排序,直到他們為空,顯然,我們認為一個空的列表即為一個排好序的列表,這就是這個演算法中的邊界條件。為了方便起見,我們選擇第一個元素作為將列表分為兩部分的值。程式實現如下:

清單 4. 快速排序

當然,為了使程式更加簡潔,作者在這裡使用了列表中的一些方法:給列表增加一個元素,連線兩個列表以及過濾一個列表,並在其中使用了 lambda 表示式。但這一切都使程式變得更符合演算法的核心思想,更加易讀。

尾遞迴

從上面的例子中我們可以看到,使用遞迴方式寫出的程式通常通俗易懂,這其實代表這兩種程式設計正規化的不同,指令式程式設計正規化傾向於使用迴圈,告訴計算機怎麼做,而函數語言程式設計正規化則使用遞迴,告訴計算機做什麼。習慣於指令式程式設計正規化的程式設計師還有一個擔憂:相比迴圈,遞迴不是存在效率問題嗎?每一次遞迴呼叫,都會分配一個新的函式棧,如果遞迴巢狀很深,容易出現棧溢位的問題。比如下面計算階乘的遞迴程式:

清單 5. 遞迴求階乘

當遞迴呼叫 n – 1的階乘時,由於需要儲存前面的 n,必須分配一個新的函式棧,這樣當 n很大時,函式棧將很快被耗盡。然而尾遞迴能幫我們解決這個問題,所謂尾遞迴是指在函式呼叫的最後一步,只呼叫該遞迴函式本身,此時,由於無需記住其他變數,當前的函式棧可以被重複使用。上面的程式只需稍微改造一下,既可以變成尾遞迴式的程式,在效率上,和迴圈是等價的。

清單 6. 尾遞迴求階乘

在上面的程式中,我們在階乘函式內部定義了一個新的遞迴函式,該函式最後一步要麼返回結果,要麼呼叫該遞迴函式本身,所以這是一個尾遞迴函式。該函式多出一個變數 acc,每次遞迴呼叫都會更新該變數,直到遞迴邊界條件滿足時返回該值,即為最後的計算結果。這是一種通用的將非尾遞迴函式轉化為尾遞迴函式的方法,大家可多加練習,掌握這一方法。對於尾遞迴,Scala 語言特別增加了一個註釋 @tailrec,該註釋可以確保程式設計師寫出的程式是正確的尾遞迴程式,如果由於疏忽大意,寫出的不是一個尾遞迴程式,則編譯器會報告一個編譯錯誤,提醒程式設計師修改自己的程式碼。

一道面試題

也許有的讀者看了上面的例子後,還是感到不能信服:雖然使用遞迴會讓程式變得簡潔易懂,但我用迴圈也一樣可以實現,大不了多幾行程式碼而已,而且我還不用知道什麼尾遞迴,寫出的程式就是效率最高的。那我們一起來看看下面這個問題:有趣的零錢兌換問題。題目大致如下:假設某國的貨幣有若干面值,現給一張大面值的貨幣要兌換成零錢,問有多少種兌換方式。這個問題經常被各大公司作為一道面試題,不知難倒了多少同學,下面我給出該問題的遞迴解法,讀者們可以試試該問題的非遞迴解法,看看從程式的易讀性,及程式碼數量上,兩者會有多大差別。該問題的遞迴解法思路很簡單:首先確定邊界條件,如果要兌換的錢數為 0,那麼返回 1,即只有一種兌換方法:沒法兌換。這裡要注意的是該問題計算所有的兌換方法,無法兌換也算一種方法。如果零錢種類為 0 或錢數小於 0,沒有任何方式進行兌換,返回 0。我們可以把找零的方法分為兩類:使用不包含第一枚硬幣(零錢)所有的零錢進行找零,使用包含第一枚硬幣(零錢)的所有零錢進行找零,兩者之和即為所有的找零方式。第一種找零方式總共有 countChange(money, coins.tail)種,第二種找零方式等價為對於 money – conins.head進行同樣的兌換,則這種兌換方式有 countChange(money - coins.head, coins)種,兩者之和即為所有的零錢兌換方式。
清單 7. 零錢兌換問題的遞迴解法

結束語

本文通過例項,和大家一起重新審視了遞迴在程式設計中的應用,使用遞迴的方式去程式設計代表了一種程式設計思想上的轉變,程式設計師應該站在更高的抽象層次上,告訴計算機做什麼,而不是怎麼做。遞迴作為一種處理問題的普遍方式,應該得到更廣泛的應用。事實上,在 Haskell 語言中,不存在 while、for 等指令式程式設計語言中必不可少的迴圈控制語句,Haskell 強迫程式設計師使用遞迴等函數語言程式設計的思維去解決問題。作者也鼓勵大家以後碰到問題時,先考慮有沒有好的遞迴的方式實現,看看是否會為我們關於程式設計的理解帶來新的思考。

相關文章