關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。
譯者團隊(排名不分先後):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿蔔、vavd317、vivaxy、萌萌、zhouyao
第 9 章:遞迴(上)
在下一頁,我們將進入到遞迴的論題。
(本頁剩餘部分故意留白)
我們來談談遞迴吧。在我們入坑之前,請查閱上一頁的正式定義。
我知道,這個笑話弱爆了 :)
大部分的開發人員都承認遞迴是一門非常強大的程式設計技術,但他們並不喜歡去使用它。在這個意義上,我把它放在與正規表示式相同的類別中。遞迴技術強大但又令人困惑,因此被視為 不值得我們投入努力。
我是遞迴程式設計的超級粉絲,你,也可以的!在這一章節中我的目標就是說服你:遞迴是一個重要的工具,你應該將它用在你的函數語言程式設計中。當你正確使用時,遞迴程式設計可以輕鬆地描述複雜問題。
定義
所謂遞迴,是當一個函式呼叫自身,並且該呼叫做了同樣的事情,這個迴圈持續到基本條件滿足時,呼叫迴圈返回。
警告: 如果你不能確保基本條件是遞迴的 終結者,遞迴將會一直執行下去,並且會把你的專案損壞或鎖死;恰當的基本條件十分重要!
但是... 這個定義的書面形式太讓人疑惑了。我們可以做的更好些。思考下這個遞迴函式:
function foo(x) {
if (x < 5) return x;
return foo( x / 2 );
}
複製程式碼
設想一下,如果我們呼叫 foo(16)
將會發生什麼:
在 step 2 中, x / 2
的結果是 8
, 這個結果以引數的形式傳遞到 foo(..)
並執行。同樣的,在 step 3 中, x / 2
的結果是 4
,這個結果以引數的形式傳遞到另一個 foo(..)
並執行。但願我解釋得足夠直白。
但是一些人經常會在 step 4 中卡殼。一旦我們滿足了基本條件 x
(值為4) < 5
,我們將不再呼叫遞迴函式,只是(有效地)執行了 return 4
。
特別是圖中返回 4
的虛線那塊,它簡化了那裡的過程,因此我們來深入瞭解最後一步,並把它折分為三個子步驟:
該次的返回值會回過頭來觸發呼叫棧中所有的函式呼叫(並且它們都執行 return
)。
另外一個遞迴例項:
function isPrime(num,divisor = 2){
if (num < 2 || (num > 2 && num % divisor == 0)) {
return false;
}
if (divisor <= Math.sqrt( num )) {
return isPrime( num, divisor + 1 );
}
return true;
}
複製程式碼
這個質數的判斷主要是通過驗證,從2到 num
的平方根之間的每個整數,看是否存在某一整數可以整除 num
(%
求餘結果為 0
)。如果存在這樣的整數,那麼 num
不是質數。反之,是質數。divisor + 1
使用遞迴來遍歷每個可能的 divisor
值。
遞迴的最著名的例子之一是計算斐波那契數,該數列定義如下:
fib( 0 ): 0
fib( 1 ): 1
fib( n ):
fib( n - 2 ) + fib( n - 1 )
複製程式碼
注意: 數列的前幾個數值是: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ... 每一個數字都是數列中前兩個數字之和。
直接用程式碼來定義斐波那契:
function fib(n) {
if (n <= 1) return n;
return fib( n - 2 ) + fib( n - 1 );
}
複製程式碼
函式 fib(..)
對自身進行了兩次遞迴呼叫,這通常叫作二分遞迴查詢。後面我們將會更多地討論二分遞迴查詢。
在整個章節中,我們將會用不同形式的 fib(..)
來說明關於遞迴的想法,但不太好的地方就是,這種特殊的方式會造成很多重複性的工作。 fib(n-1)
和 fib(n-2)
執行時候兩者之間並沒有任何的共享,但做的事情幾乎又完全相同,這種情況一直持續到整個整數空間(譯者注:形參 n
)降到 0
。
在第五章的效能優化方面我們簡單的談到了記憶儲存技術。本章中,記憶儲存技術使得任意一個傳入到 fib(..)
的數值只會被計算一次而不是多次。雖然我們不會在這裡過多地討論這個技術話題,但不論是遞迴或其它任何演算法,我們都要謹記,效能優化是非常重要的。
相互遞迴
當一個函式呼叫自身時,很明顯,這叫作直接遞迴。比如前面部分我們談到的 foo(..)
,isPrime(..)
以及 fib(..)
。如果在一個遞迴迴圈中,出現兩個及以上的函式相互呼叫,則稱之為相互遞迴。
這兩個函式就是相互遞迴:
function isOdd(v) {
if (v === 0) return false;
return isEven( Math.abs( v ) - 1 );
}
function isEven(v) {
if (v === 0) return true;
return isOdd( Math.abs( v ) - 1 );
}
複製程式碼
是的,這個奇偶數的判斷笨笨的。但也給我們提供了一些思路:某些演算法可以根據相互遞迴來定義。
回顧下上節中的二分遞迴法 fib(..)
;我們可以換成相互遞迴來表示:
function fib_(n) {
if (n == 1) return 1;
else return fib( n - 2 );
}
function fib(n) {
if (n == 0) return 0;
else return fib( n - 1 ) + fib_( n );
}
複製程式碼
注意: fib(..)
相互遞迴的實現方式改編自 “用相互遞迴來實現斐波納契數列” 研究報告(www.researchgate.net/publication…) 。
雖然這些相互遞迴的示例有點不切實際,但是在更復雜的使用場景下,相互遞迴是非常有用的。
為什麼選擇遞迴?
現在我們已經給出了遞迴的定義和說明,下面來看下,為什麼說遞迴是有用的。
遞迴深諳函數語言程式設計之精髓,最被廣泛引證的原因是,在呼叫棧中,遞迴把(大部分)顯式狀態跟蹤換為了隱式狀態。通常,當問題需要條件分支和回溯計算時,遞迴非常有用,此外在純迭代環境中管理這種狀態,是相當棘手的;最起碼,這些程式碼是不可或缺且晦澀難懂。但是在堆疊上呼叫每一級的分支作為其自己的作用域,很明顯,這通常會影響到程式碼的可讀性。
簡單的迭代演算法可以用遞迴來表達:
function sum(total,...nums) {
for (let i = 0; i < nums.length; i++) {
total = total + nums[i];
}
return total;
}
// vs
function sum(num1,...nums) {
if (nums.length == 0) return num1;
return num1 + sum( ...nums );
}
複製程式碼
我們不僅用呼叫棧代替了 for
迴圈,而且用 return
s 的形式在回撥棧中隱式地跟蹤增量的求和( total
的間歇狀態),而非在每次迭代中重新分配 total
。通常,FPer 傾向於儘可能地避免重新分配區域性變數。
像我們總結的那樣,在基本演算法裡,這些差異是微乎其微的。但是,隨著演算法複雜度的提升,你將更加能體會到遞迴帶來的收益,而不是這些命令式狀態跟蹤。
宣告式遞迴
數學家使用 Σ 符號來表示一列數字的總和。主要原因是,如果他們使用更復雜的公式而且不得不手動書寫求和的話,會造成更多麻煩(而且會降低閱讀性!),比如
1 + 3 + 5 + 7 + 9 + ..
。符號是數學的宣告式語言!
正如 Σ 是為運算而宣告,遞迴是為演算法而宣告。遞迴說明:一個問題存在解決方案,但並不一定要求閱讀程式碼的人瞭解該解決方案的工作原理。我們來思考下找出入參最大偶數值的兩種方法:
function maxEven(...nums) {
var num = -Infinity;
for (let i = 0; i < nums.length; i++) {
if (nums[i] % 2 == 0 && nums[i] > num) {
num = nums[i];
}
}
if (num !== -Infinity) {
return num;
}
}
複製程式碼
這種實現方式不是特別難處理,但它的一些細微的問題也不容忽視。很明顯,執行 maxEven()
,maxEven(1)
和 maxEven(1,13)
都將會返回 undefined
?最終的 if
語句是必需的嗎?
我們試著換一個遞迴的方法來對比下。我們用下面的符號來表示遞迴:
maxEven( nums ):
maxEven( nums.0, maxEven( ...nums.1 ) )
複製程式碼
換句話說,我們可以將數字列表的 max-even 定義為其餘數字的 max-even 與第一個數字的 max-even 的結果。例如:
maxEven( 1, 10, 3, 2 ):
maxEven( 1, maxEven( 10, maxEven( 3, maxEven( 2 ) ) )
複製程式碼
在 JS 中實現這個遞迴定義的方法之一是:
function maxEven(num1,...restNums) {
var maxRest = restNums.length > 0 ?
maxEven( ...restNums ) :
undefined;
return (num1 % 2 != 0 || num1 < maxRest) ?
maxRest :
num1;
}
複製程式碼
那麼這個方法有什麼優點嗎?
首先,引數與之前不一樣了。我專門把第一個引數叫作 num1
,剩餘的其它引數放在一起叫作 restNums
。我們本可以把所有引數都放在 nums
陣列中,並從 nums[0]
獲取第一個引數。這是為什麼呢?
函式的引數是專門為遞迴定義的。它看起來像這樣:
maxEven( num1, ...restNums ):
maxEven( num1, maxEven( ...restNums ) )
複製程式碼
你有發現引數和遞迴之間的相似性嗎?
當我們在函式體簽名中進一步提升遞迴的定義,函式的宣告也會得到提升。如果我們能夠把遞迴的定義從引數反映到函式體中,那就更棒了。
但我想說最明顯的改進是,for
迴圈造成的錯亂感沒有了。所有迴圈邏輯都被抽象為遞迴回撥棧,所以這些東西不會造成程式碼混亂。我們可以輕鬆的把精力集中在一次比較兩個數字來找到最大偶數值的邏輯中 —— 不管怎麼說,這都是很重要的部分!
從思想上來講,這如同一位數學家在更龐大的方程中使用 Σ 求和一樣。我們說,“數列中剩餘值的最大偶數是通過 maxEven(...restNums)
計算出來的,所以我們只需要繼續推斷這一部分。”
另外,我們用 restNums.length > 0
保證推斷更加合理,因為當沒有引數的情況下,返回的 maxRest
結果肯定是 undefined
。我們不需要對這部分的推理投入額外的精力。這個基本條件(沒有引數情況下)顯而易見。
接下來,我們把精力放在對比 num1
和 maxRest
上 —— 演算法的主要邏輯是如何確定兩個數字中的哪一個(如果有的話)是最大偶數。如果 num1
不是偶數(num1 % 2 != 0
),或著它小於 maxRest
,那麼,即使 maxRest
的值是 undefined
,maxRest
會 return
掉。否則,返回結果會是 num1
。
在閱讀整個實現過程中,與命令式的方法相比,我所做這個例子的推理過程更加直接,核心點更加突出,少做無用功;比 for
迴圈中引用 無窮數值
這一方法 更具有宣告性。
小貼士: 我們應該指出,除了手動迭代或遞迴之外,另一種(可能更好的)建模的方法是我們在在第7章中討論的列表操作。我們先把數列中的偶數用 filter(..)
過濾出來,然後通過遞迴 reduce(..)
函式(對比兩個數值並返回其中較大的數值)來找到最大值。在這裡,我們只是使用這個例子來說明在手動迭代中遞迴的宣告性更強。
還有一個遞迴的例子:計算二叉樹的深度。二叉樹的深度是指通過樹的節點向下(左或右)的最長路徑。還有另一種通過遞迴來定義的方式:任何樹節點的深度為1(當前節點)加上來自其左側或右側子樹的深度的最大值:
depth( node ):
1 + max( depth( node.left ), depth( node.right ) )
複製程式碼
直接轉換為二分法遞迴函式:
function depth(node) {
if (node) {
let depthLeft = depth( node.left );
let depthRight = depth( node.right );
return 1 + max( depthLeft, depthRight );
}
return 0;
}
複製程式碼
我不打算列出這個演算法的命令式形式,但請相信我,它太麻煩、過於命令式了。這種遞迴方法很不錯,宣告也很優雅。它遵循遞迴的定義,與遞迴定義的演算法非常接近,省心。
並不是所有的問題都是完全可遞迴的。它不是你可以廣泛應用的靈丹妙藥。但是遞迴可以非常有效地將問題的表達,從更具必要性轉變為更有宣告性。
未完待續......【下一章】第 9 章:遞迴(下)
** 【上一章】翻譯連載 | JavaScript輕量級函數語言程式設計-第 8 章:列表操作 |《你不知道的JS》姊妹篇 **
** 【下一章】翻譯連載 | 第 9 章:遞迴(下)-《JavaScript輕量級函數語言程式設計》 |《你不知道的JS》姊妹篇 **
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。
iKcamp官網:www.ikcamp.com
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!