Scala 語言: 使用遞歸的方式去思考

Scala 是一種有趣的語言。它一方面吸收繼承了多種語言中的優(yōu)秀特性,一方面又沒有拋棄 Java 這個(gè)強(qiáng)大的平臺(tái),它運(yùn)行在 Java 虛擬機(jī)(Java Virtual Machine)之上,輕松實(shí)現(xiàn)和豐富的 Java 類庫互聯(lián)互通。它既支持面向?qū)ο蟮木幊谭绞?,又支持函?shù)式編程。它寫出的程序像動(dòng)態(tài)語言一樣簡(jiǎn)潔,但事實(shí)上它確是嚴(yán)格意義上的靜態(tài)語言。Scala 就像一位武林中的集大成者,將過去幾十年計(jì)算機(jī)語言發(fā)展歷史中的精萃集于一身,化繁為簡(jiǎn),為程序員們提供了一種新的選擇。作者希望通過這個(gè)系列,可以為大家介紹 Scala 語言的特性,和 Scala 語言給我們帶來的關(guān)于編程思想的新的思考。

為什么遞歸會(huì)受到忽視
為了回答這一問題,必須先說到編程范式。在所有的編程范式中,面向?qū)ο缶幊蹋∣bject-Oriented Programming)無疑是最大的贏家??纯淳W(wǎng)上的招聘啟事,無一例外,會(huì)要求應(yīng)聘者熟練掌握面向?qū)ο缶幊?。但其?shí)面向?qū)ο缶幊滩⒉皇且环N嚴(yán)格意義上的編程范式,嚴(yán)格意義上的編程范式分為:命令式編程(Imperative Programming)、函數(shù)式編程(Functional Programming)和邏輯式編程(Logic Programming)。面向?qū)ο缶幊讨皇巧鲜鰩追N范式的一個(gè)交叉產(chǎn)物,更多的還是繼承了命令式編程的基因。遺憾的是,在長(zhǎng)期的教學(xué)過程中,只有命令式編程得到了強(qiáng)調(diào),那就是程序員要告訴計(jì)算機(jī)應(yīng)該怎么做,而不是告訴計(jì)算機(jī)做什么。而遞歸則通過靈巧的函數(shù)定義,告訴計(jì)算機(jī)做什么。因此在使用命令式編程思維的程序中,不得不說,這是現(xiàn)在多數(shù)程序采用的編程方式,遞歸出鏡的幾率很少,而在函數(shù)式編程中,大家可以隨處見到遞歸的方式。下面,我們就通過實(shí)例,為大家展示遞歸如何作為一種普遍方式,來解決編程問題

一組簡(jiǎn)單的例子
如何為一組整數(shù)數(shù)列求和?按照通常命令式編程的思維,我們會(huì)采用循環(huán),依次遍歷列表中的每個(gè)元素進(jìn)行累加,最終給出求和結(jié)果。這樣的程序不難寫,稍微具備一點(diǎn)編程經(jīng)驗(yàn)的人在一分鐘之內(nèi)就能寫出來。這次我們換個(gè)思維,如何用遞歸的方式求和?為此,我們不妨把問題簡(jiǎn)化一點(diǎn),假設(shè)數(shù)列包含 N 個(gè)數(shù),如果我們已經(jīng)知道了后續(xù) N – 1 個(gè)數(shù)的和,那么整個(gè)數(shù)列的和即為第一個(gè)數(shù)加上后續(xù) N – 1 個(gè)數(shù)的和,依此類推,我們可以以同樣的方式為 N – 1 個(gè)數(shù)繼續(xù)求和,直到數(shù)列為空,顯然,空數(shù)列的和為零。聽起來復(fù)雜,事實(shí)上我們可以用一句話來總結(jié):一個(gè)數(shù)列的和即為數(shù)列中的第一個(gè)數(shù)加上由后續(xù)數(shù)字組成的數(shù)列的和。現(xiàn)在,讓我們用 Scala 語言把這個(gè)想法表達(dá)出來。
清單 1. 數(shù)列求和
//xs.head 返回列表里的頭元素,即第一個(gè)元素
//xs.tail 返回除頭元素外的剩余元素組成的列表
def sum(xs: List[Int]): Int =
if (xs.isEmpty) 0 else xs.head + sum(xs.tail)

大家可以看到,我們只使用一行程序,就將上面求和的方法表達(dá)出來了,而且這一行程序看上去簡(jiǎn)單易懂。盡量少寫代碼,這也是 Scala 語言的設(shè)計(jì)哲學(xué)之一,較少的代碼量意味著寫起來更加容易,讀起來更加易懂,同時(shí)代碼出錯(cuò)的概率也會(huì)降低。同樣的程序,使用 Scala 語言寫出的代碼量通常會(huì)比 Java 少一半甚至更多。
上述這個(gè)數(shù)列求和的例子并不是特別的,它代表了遞歸對(duì)于列表的一種普遍的處理方式,即對(duì)一個(gè)列表的操作,可轉(zhuǎn)化為對(duì)第一個(gè)元素,及剩余列表的相同操作。比如我們可以用同樣的方式求一個(gè)數(shù)列中的最大值。我們假設(shè)已經(jīng)知道了除第一個(gè)元素外剩余數(shù)列的最大值,那么整個(gè)數(shù)列的最大值即為第一個(gè)元素和剩余數(shù)列最大值中的大者。這里需要注意的是對(duì)于一個(gè)空數(shù)列求最大值是沒有意義的,所以我們需要向外拋出一個(gè)異常。當(dāng)數(shù)列只包含一個(gè)元素時(shí),最大值就為這個(gè)元素本身,這種情況是我們這個(gè)遞歸的邊界條件。一個(gè)遞歸算法,必須要有這樣一個(gè)邊界條件,否則會(huì)一直遞歸下去,形成死循環(huán)。
清單 2. 求最大值
def max(xs: List[Int]): Int = {
if (xs.isEmpty)
throw new java.util.NoSuchElementException
if (xs.size == 1)
xs.head
else
if (xs.head > max(xs.tail)) xs.head else max(xs.tail)
}
同樣的方式,我們也可以求一個(gè)數(shù)列中的最小值,作為一個(gè)練習(xí),讀者可下去自行實(shí)現(xiàn)。
讓我們?cè)倏匆粋€(gè)例子:如何反轉(zhuǎn)一個(gè)字符串?比如給定一個(gè)字符串"abcd"
,經(jīng)過反轉(zhuǎn)之后變?yōu)?dcba"
。同樣的,我們可以做一個(gè)大膽的假設(shè),假設(shè)后續(xù)字符串已經(jīng)反轉(zhuǎn)過來,那么接上第一個(gè)字符,整個(gè)字符串就反轉(zhuǎn)過來了。對(duì)于一個(gè)只有一個(gè)字符的字符串,不需要反轉(zhuǎn),這是我們這個(gè)遞歸算法的邊界條件。程序?qū)崿F(xiàn)如下:
清單 3. 反轉(zhuǎn)字符串
def reverse(xs: String): String =
if (xs.length == 1) xs else reverse(xs.tail) + xs.head

最后一個(gè)例子是經(jīng)典的快速排序,讀者可能會(huì)覺得這個(gè)例子算不上簡(jiǎn)單,但是我們會(huì)看到,使用遞歸的方式,再加上 Scala 簡(jiǎn)潔的語言特性,我們只需要短短幾行程序,就可以實(shí)現(xiàn)快速排序算法。快速排序算法的核心思想是:在一個(gè)無序列表中選擇一個(gè)值,根據(jù)該值將列表分為兩部分,比該值小的那一部分排在前面,比該值大的部分排在后面。對(duì)于這兩部分各自使用同樣的方式進(jìn)行排序,直到他們?yōu)榭?,顯然,我們認(rèn)為一個(gè)空的列表即為一個(gè)排好序的列表,這就是這個(gè)算法中的邊界條件。為了方便起見,我們選擇第一個(gè)元素作為將列表分為兩部分的值。程序?qū)崿F(xiàn)如下:
清單 4. 快速排序
def quickSort(xs: List[Int]): List[Int] = {
if (xs.isEmpty) xs
else
quickSort(xs.filter(x=>x<xs.head)):::xs.head::quickSort(xs.filter(x=>x>xs.head))
}
當(dāng)然,為了使程序更加簡(jiǎn)潔,作者在這里使用了列表中的一些方法:給列表增加一個(gè)元素,連接兩個(gè)列表以及過濾一個(gè)列表,并在其中使用了 lambda 表達(dá)式。但這一切都使程序變得更符合算法的核心思想,更加易讀。

尾遞歸
從上面的例子中我們可以看到,使用遞歸方式寫出的程序通常通俗易懂,這其實(shí)代表這兩種編程范式的不同,命令式編程范式傾向于使用循環(huán),告訴計(jì)算機(jī)怎么做,而函數(shù)式編程范式則使用遞歸,告訴計(jì)算機(jī)做什么。習(xí)慣于命令式編程范式的程序員還有一個(gè)擔(dān)憂:相比循環(huán),遞歸不是存在效率問題嗎?每一次遞歸調(diào)用,都會(huì)分配一個(gè)新的函數(shù)棧,如果遞歸嵌套很深,容易出現(xiàn)棧溢出的問題。比如下面計(jì)算階乘的遞歸程序:
清單 5. 遞歸求階乘
def factorial(n: Int): Int =
if (n == 0) 1 else n * factorial(n - 1)

當(dāng)遞歸調(diào)用 n – 1
的階乘時(shí),由于需要保存前面的 n
,必須分配一個(gè)新的函數(shù)棧,這樣當(dāng) n
很大時(shí),函數(shù)棧將很快被耗盡。然而尾遞歸能幫我們解決這個(gè)問題,所謂尾遞歸是指在函數(shù)調(diào)用的最后一步,只調(diào)用該遞歸函數(shù)本身,此時(shí),由于無需記住其他變量,當(dāng)前的函數(shù)??梢员恢貜?fù)使用。上面的程序只需稍微改造一下,既可以變成尾遞歸式的程序,在效率上,和循環(huán)是等價(jià)的。
清單 6. 尾遞歸求階乘
def factorial(n: Int): Int = {
@tailrec
def loop(acc: Int, n: Int): Int =
if (n == 0) acc else loop(n * acc, n - 1)

loop(1, n) 

}

在上面的程序中,我們?cè)陔A乘函數(shù)內(nèi)部定義了一個(gè)新的遞歸函數(shù),該函數(shù)最后一步要么返回結(jié)果,要么調(diào)用該遞歸函數(shù)本身,所以這是一個(gè)尾遞歸函數(shù)。該函數(shù)多出一個(gè)變量 acc
,每次遞歸調(diào)用都會(huì)更新該變量,直到遞歸邊界條件滿足時(shí)返回該值,即為最后的計(jì)算結(jié)果。這是一種通用的將非尾遞歸函數(shù)轉(zhuǎn)化為尾遞歸函數(shù)的方法,大家可多加練習(xí),掌握這一方法。對(duì)于尾遞歸,Scala 語言特別增加了一個(gè)注釋 @tailrec
,該注釋可以確保程序員寫出的程序是正確的尾遞歸程序,如果由于疏忽大意,寫出的不是一個(gè)尾遞歸程序,則編譯器會(huì)報(bào)告一個(gè)編譯錯(cuò)誤,提醒程序員修改自己的代碼。

一道面試題
也許有的讀者看了上面的例子后,還是感到不能信服:雖然使用遞歸會(huì)讓程序變得簡(jiǎn)潔易懂,但我用循環(huán)也一樣可以實(shí)現(xiàn),大不了多幾行代碼而已,而且我還不用知道什么尾遞歸,寫出的程序就是效率最高的。那我們一起來看看下面這個(gè)問題:有趣的零錢兌換問題。題目大致如下:假設(shè)某國的貨幣有若干面值,現(xiàn)給一張大面值的貨幣要兌換成零錢,問有多少種兌換方式。這個(gè)問題經(jīng)常被各大公司作為一道面試題,不知難倒了多少同學(xué),下面我給出該問題的遞歸解法,讀者們可以試試該問題的非遞歸解法,看看從程序的易讀性,及代碼數(shù)量上,兩者會(huì)有多大差別。該問題的遞歸解法思路很簡(jiǎn)單:首先確定邊界條件,如果要兌換的錢數(shù)為 0,那么返回 1,即只有一種兌換方法:沒法兌換。這里要注意的是該問題計(jì)算所有的兌換方法,無法兌換也算一種方法。如果零錢種類為 0 或錢數(shù)小于 0,沒有任何方式進(jìn)行兌換,返回 0。我們可以把找零的方法分為兩類:使用不包含第一枚硬幣(零錢)所有的零錢進(jìn)行找零,使用包含第一枚硬幣(零錢)的所有零錢進(jìn)行找零,兩者之和即為所有的找零方式。第一種找零方式總共有countChange(money, coins.tail)
種,第二種找零方式等價(jià)為對(duì)于 money – conins.head
進(jìn)行同樣的兌換,則這種兌換方式有 countChange(money - coins.head, coins)
種,兩者之和即為所有的零錢兌換方式。
清單 7. 零錢兌換問題的遞歸解法
def countChange(money: Int, coins: List[Int]): Int = {
if (money == 0)
1
else if (coins.size == 0 || money < 0)
0
else
countChange(money, coins.tail) + countChange(money - coins.head, coins)
}

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,732評(píng)論 6 539
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,214評(píng)論 3 426
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,781評(píng)論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,588評(píng)論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,315評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,699評(píng)論 1 327
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,698評(píng)論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,882評(píng)論 0 289
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,441評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,189評(píng)論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,388評(píng)論 1 372
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,933評(píng)論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,613評(píng)論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,023評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,310評(píng)論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,112評(píng)論 3 398
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,334評(píng)論 2 377

推薦閱讀更多精彩內(nèi)容