什麼是“遞迴”
給你講一個故事就明白了,什麼故事呢?
從前有座山,山裡有個廟,廟裡有個老和尚在給小和尚講故事,講的是從前有座山,山裡有個廟,廟裡有個老和尚在給小和尚講故事,講的是從前有座山。。。
這就是一個典型的遞迴,在不考慮歲數等自身的條件下,這將是個死遞迴,沒有終止條件。
再舉一個例子。不知道你有沒有看過一部號稱不怕劇透的電影《盜夢空間》。 小李子團隊們每次執行任務的時候,都會進入睡眠模式。如果在夢中任務還完不成的話,就再來個夢中夢,繼續去執行任務。如果還不行,就再來一層夢。同樣,如果需要回到現實的話,也必須得從最深的那層夢中醒來,然後到第一層夢,然後回到現實中。
這個過程也可以當做遞迴。層層夢是遞,層層醒是歸。遞迴本質上是將原來的問題,轉化為更小的同一問題 大白話就是 一個函式不斷的呼叫自己。
接下來看一個遞迴的經典例題,就是計算 Fibonacci 數列。
指的是這樣一個數列:1、1、2、3、5、8、13、21、34、……、x;
程式碼展示為:
function Fibonacci (n) {
if ( n <= 2 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
複製程式碼
遞迴三要素
1.一個問題的解可以分解為幾個更小的同一題的解
比如說上面的數列,如果要求解第 10 位數是多少 fn(10),可以分解為求第 9 位數 fn(9) 和第 8 位數 fn(8) 是多少,類似這樣分解。
2.分解後的子問題只存在資料不一樣的問題。
比如說上面的數列,每次分解後,所形成的子問題求解方式都一樣,只是說每次資料規模變了。
3.存在遞迴終止條件
這個是必須存在的,把問題一層一層的分解下去,但是不能無限迴圈下去了。 比如說上面的數列,當 n 小於等於 2 的時候,就會停止,此時就已經知道了第一個數和第二個數是多少了,這就是終止條件。不能像老和尚給小和尚講故事那樣,永無止境。
實現遞迴
首先來分析一個簡單的例題,用遞迴的方式來求解陣列中每個元素的和。
根據上面所講的三要素,來分解下這個問題。
求解陣列 arr 的和我們可以分解成是第一個數然後加上剩餘數的和,以此類推可以得到如下分解:
const arr = [1,2,3,4,5,6,7,...,n];
sum(arr[0]+...+arr[n]) = arr[0] + sum(arr[1]+...+arr[n]);
sum(arr[1]+...+arr[n]) = arr[1] + sum(arr[2]+...+arr[n]);
....
sum(arr[n]) = arr[n] + sum([]);
複製程式碼
然後可以推匯出一個公式:
x = 0;
sum(arr, x) = arr[x] + sum(arr,x+1); // x:表示陣列的長度
複製程式碼
再考慮一個終止條件, 當 x 增長到和陣列長度一樣的時候,就該停止了,而且此時應該返回 0。 所以綜上我們可以得出此題的解:
{
function sum(arr) {
const total = function(arr, l) {
if(l == arr.length) {
return 0;
}
return arr[l] + total(arr, l + 1);
}
return total(arr, 0);
}
sum([1,2,3,4,5,6,9,10]);
}
複製程式碼
寫遞迴程式碼的關鍵就是找到如何將原來的問題轉化為更小的同一問題,並且基於此寫出遞推公式,然後再推敲終止條件,最後將遞推公式和終止條件合成最終程式碼。
注意事項
1.遞迴容易堆疊溢位
函式呼叫會使用棧來儲存臨時變數。每呼叫一個函式,都會將臨時變數封裝為棧幀壓入記憶體棧,等函式執行完成返回時,才出棧。而遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫幀,當資料規模較大的時候很容易發生“棧溢位”錯誤(stack overflow)。
比如說一個利用遞迴求階乘的函式:
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
複製程式碼
那如何避免這種錯誤呢? 我們可以在程式碼中新增一個引數,記錄遞迴呼叫的次數。當大於一個數字的時候,手動設定報錯資訊。比如說上面的例子:
{
let count = 0;
function factorial(n) {
count ++;
if (count > 1000) {
console.error('超過了最大呼叫次數');
return;
}
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(2000)
}
複製程式碼
當然這個數字事先無法估算,只適合一些最大深度比較低的遞迴呼叫。
2.警惕遞迴中的重複計算
比如說上文提到的經典數列:
function Fibonacci (n) {
if ( n <= 2 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
複製程式碼
程式碼很簡介,但是卻包含了大量的重複計算。
假設要求計算 f(5)
f(5) = f(4) + f(3);
於是會遞迴計算 f(4) 和 f(3);
接著計算 f(4)
f(4) = f(3)+ f(2);
於是會遞迴計算f(3)和f(2);
複製程式碼
可以看到,計算 f(5) 和 f(4) 中都要計算 f(3),但這兩次 f(3) 會重複計算,這就是遞迴的最大問題,對於同一個 f(n),不能複用。
你好奇過計算一個 f(n) 到底需要有多少次遞迴呼叫呢?
我們可以在程式碼里加一個計數驗證一下。
{
let count = 0;
function Fibonacci (n) {
count ++;
if ( n <= 2 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(n); // n: 要計算的值
console.log(count);
}
複製程式碼
實驗的結果:
f(5) count = 9
f(10) count = 109
f(25) count = 150049
f(35) count = 18454929
f(40) count = 204668309
f(45) … 抱歉,我機器太慢,算不出來
複製程式碼
可以把程式碼在你的機器上試試哦,這看似簡單的兩句程式碼的時間複雜度卻達到了O的指數級。
所以優化演算法刻不容緩。為了避免重複計算,可以利用一個物件來儲存已經求解過的 f(n)。當遞迴呼叫到 f(n) 時,先判斷是否求解過了。如果是,則直接從物件中取值返回,不需計算,否則的話,再進行遞迴,這樣就能避免剛講的問題了。
所以優化後的程式碼如下:
{
function Fibonacci() {
this.obj = {};
this.count = 0;
}
Fibonacci.prototype.getF = function(n) {
this.count ++;
if ( n <= 2 ) {return 1};
if (this.obj.hasOwnProperty(n)) {
return this.obj[n];
}
const ret = this.getF(n - 1) + this.getF(n -2);
this.obj[n] = ret;
return ret;
}
var f = new Fibonacci();
f.getF(45);
}
複製程式碼
加入了快取以後,由上圖可以看出來,現在的時間複雜度只是 O(n) 的。
非遞迴實現
利用遞迴實現有缺有優,優點是短小精悍;而缺點就是空間複雜度高、有堆疊溢位的風險、存在重複計算、過多的函式呼叫會耗時較多等問題。所以,在選擇演算法時,要根據實際情況來選擇合適的方式來實現。
一般來說,遞迴可以實現的利用 for 迴圈都可以實現。比如說上文的陣列求和。
接下里我們用 for 迴圈來改寫斐波那契數列。
也比較簡單,話不多說,直接行上程式碼展示:
{
function fibonacci(n) {
if (n === 1 || n === 2) {
return 1;
}
let one = 1;
let two = 1;
let temp = null;
for(let i = 3; i <= n; i++) {
temp = one + two; // 累加前兩個數的和
one = two;
two = temp;
}
return temp;
}
console.log(fibonacci(40));
}
複製程式碼
此程式碼的時間複雜度應該一眼就能看出來了吧。
總結
剛開始接觸 js 的時候,一直都懼怕遞迴,也很少或者說幾乎就不寫遞迴的程式碼。 但其實學習了以後,發現遞迴還是挺可愛的。就像在數學找一組數字的規律一樣,可以鍛鍊我們的思維。
比如說 對於剛才用 for 迴圈改寫的斐波那契數列,還有其他解法哦,比如說用陣列。
歡迎來討論哦。
其他優化方式
可以參考阮一峰老師講的尾遞迴,連結在下方。
參考文章
有你才完美
自認很菜,建立了一個資料結構和演算法的交流群,不限開發語言,前端後端,歡迎各位大佬入駐。