了解JavaScript的遞歸

dan-freeman-401296-unsplash.jpg

簡介

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

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

綱要
  • 什么是遞歸
  • 數字的遞歸
  • 數組的遞歸
  • 總結

什么是遞歸

函數的遞歸就是在函數中調用自身,看一個簡單的例子:

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. 調用棧可能會溢出。棧是有容量限制的,當調用層次過多,就會超出棧的容量限制,從而導致棧溢出!

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

感謝您的閱讀!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容