瞭解 JavaScript 的遞迴

TONGZ發表於2018-03-28

簡介

使用遞迴可以更自然地解決一些問題。例如,像斐波那契數列:數列中的每個數字都是數列中前兩個數字的和。凡是需要您構建或遍歷樹狀資料結構的問題基本都可以通過遞迴來解決,鍛鍊自己強大的遞迴思維,你會發現解決這類問題十分容易。

在本文中,我將列舉兩個案例,讓你們瞭解遞迴函式是如何工作的。

綱要
  • 什麼是遞迴
  • 數字的遞迴
  • 陣列的遞迴
  • 總結

什麼是遞迴

函式的遞迴就是在函式中呼叫自身,看一個簡單的例子:

function doA(n) {
    ...
    doA(n-1);
}
複製程式碼

為了理解遞迴在理論上是如何工作的,我們先舉一個與程式碼無關的例子。想象一下,你是一家公司的話務員。由於這是一家業務繁忙的公司,你的座機連線多條線路,因此你可以同時處理多個電話。每條線路對應接收器上的一個按鈕,當有來電時,該按鈕將閃爍。今天當你到達公司開始工作時,發現有四條線路對應的按鈕正在閃爍,所以你需要接聽所有這些電話。

你接通線路一,並告訴他“請稍等”,然後你接通線路二,並告訴他“請稍等”,接著,你接通線路三,也告知他“請稍等”,最後,你接通線路四,並與其通話。當你結束了與線路四的通話之後,你回過頭來接通線路三,當你結束了與線路三的通話之後,你接通線路二,結束之後,你再接通線路一,當與線路一的這位客戶結束通話後,你終於可以放下電話了。

這個例子中的每一通電話就像某函式中的一個遞迴呼叫。當你接到一個電話且不能立即處理時,這個電話將被擱置;當你有一個不需要立即觸發的函式呼叫時,它將停留在呼叫棧上。當你可以接聽一個電話時,這個線路會被接通;當你的程式碼能夠觸發一個函式呼叫時,它會從呼叫棧中彈出。在你看到之後的程式碼案例有些發懵時,請回想一下這個比喻。

數字的遞迴

每個遞迴函式都需要一個終止條件,從而使其不會無休止地迴圈下去。然而,僅僅加一個終止條件,是不足以避免其無限迴圈的。該函式必須一步一步地接近終止條件。在遞迴步驟中,問題會逐步簡化為更小的問題。

假設有一個函式:從1加到n。例如,當n = 4,它實現的就是“1 + 2 + 3 + 4”。

首先,我們需要尋找終止條件。這一步可以認為是找到那個不通過遞迴就直接結束該問題的條件。當n等於0時,沒法再拆分了,所以我們的遞迴在到達0時停止。

在每一步中,你將從當前數字減去1。什麼是遞迴條件?就是用減少的數字呼叫函式sum

function sum(num){
    if (num === 0) {
        return 0;
    } else {
        return num + sum(--num)
    }
}
 
sum(4);     //10
複製程式碼

每一步過程如下:

  • 執行sum(4)。
  • 4等於0麼?否,把sum(4)保留並執行sum(3)。
  • 3等於0麼?否,把sum(3)保留並執行sum(2)。
  • 2等於0麼?否,把sum(2)保留並執行sum(1)。
  • 1等於0麼?否,把sum(1)保留並執行sum(0)。
  • 0等於0麼?是,計算sum(0)。
  • 提取sum(1)。
  • 提取sum(2)。
  • 提取sum(3)。
  • 提取sum(4)。

這是檢視函式如何處理每個呼叫的另一種方式:

sum(4)
4 + sum(3)
4 + ( 3 + sum(2) )
4 + ( 3 + ( 2 + sum(1) ))
4 + ( 3 + ( 2 + ( 1 + sum(0) )))
4 + ( 3 + ( 2 + ( 1 + 0 ) ))
4 + ( 3 + ( 2 + 1 ) )
4 + ( 3 +  3 ) 
4 + 6 
10
複製程式碼

我們可以發現,遞迴條件中的引數不斷改變,並逐漸接近並最終符合終止條件。在上面的案例中,我們在遞迴條件中的每一步都將引數減1,最後在終止條件中測試引數是否等於0。

任務
  1. 使用常規迴圈方法而不是遞迴來寫一個數字求和的sum函式。
  2. 寫一個遞迴函式來實現兩數相乘。例如:multiply(2,4) 將返回8,寫出multiply(2,4)的每一步發生的情況。

陣列的遞迴

陣列的遞迴和數字的遞迴相似,類似於數字的遞減,我們在每一步遞減陣列中的元素個數,直到獲得一個空陣列。

考慮使用陣列作為求和函式的引數,並返回陣列中所有元素的總和。求和函式如下:

function sum(arr) {
    var len = arr.length;
    if (len == 0) {
        return 0;
    } else {
        return arr[0] + sum(arr.slice(1));
    }
}
複製程式碼

如果陣列長度等於0,則返回0,arr[0]表示陣列的第一位,arr.slice(1)表示從第一位開始擷取arr陣列,並返回擷取之後的陣列。例如var arr = [1,2,3,4];arr[0]為1,arr.slice(1)[2,3,4]。當我們執行sum([1,2,3,4])時,都發生了一些什麼?

sum([1,2,3,4])
1 + sum([2,3,4])
1 + ( 2 + sum([3,4]) )
1 + ( 2 + ( 3 + sum([4]) ))
1 + ( 2 + ( 3 + ( 4 + sum([]) )))
1 + ( 2 + ( 3 + ( 4 + 0 ) ))
1 + ( 2 + ( 3 + 4 ) )
1 + ( 2 + 7 ) 
1 + 9
10
複製程式碼

每一次執行都檢查陣列是否為空,否則,對元素數量逐漸遞減的該陣列執行遞迴。

任務
  1. 使用常規迴圈方法而不是遞迴來寫一個陣列求和的sum函式。
  2. 定義一個length()函式,陣列作為引數,返回陣列長度(不可以使用Javascript Array物件內建的length屬性)。例如:length(['a', 'b', 'c', 'd']),並寫出每一步發生的事情。

總結

一個過程或函式在其定義或說明中有直接或間接呼叫自身的一種方法,它通常把一個大型複雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞迴策略只需少量的程式就可描述出解題過程所需要的多次重複計算,大大地減少了程式的程式碼量。

本文只列舉兩個小案例,只為說明遞迴是怎麼回事,上述兩個案例的公式都是變數+函式的形式,當然也有很多函式+函式的形式的案例,例如文章開頭提到的著名的斐波那契數列,程式碼如下:

function func( n ) { 
    if (n == 0 || n == 1) {
        return 1;
    }
        return func(n-1) + func(n-2);
}
    
複製程式碼

下面來說一下使用遞迴的步驟及優缺點。

步驟
  1. 找規律,將這個規律轉換成一個公式return出來。
  2. 找出口,出口即終止條件,它一定是一個已知的條件。
優點
  1. 程式碼異常簡潔。
  2. 符合人類思維。
缺點
  1. 由於遞迴是呼叫函式自身,而函式呼叫需要消耗時間和空間:每次呼叫,都要在記憶體棧中分配空間以儲存引數、臨時變數、返回地址等,往棧中壓入和彈出資料都需要消耗時間。這勢必導致執行效率大打折扣。
  2. 遞迴中的計算大都是重複的,其本質是把一個問題拆解成多個小問題,小問題之間存在互相重疊的部分,這樣的重複計算也會導致效率的低下。
  3. 呼叫棧可能會溢位。棧是有容量限制的,當呼叫層次過多,就會超出棧的容量限制,從而導致棧溢位!

可見遞迴的缺點還是很明顯的,在實際開發中,在可控的情況下,合理使用。

感謝您的閱讀!

相關文章