遞迴優化:尾呼叫和Memoization

LumiereXyloto發表於2019-09-28

一、遞迴

1.遞迴含義:

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

2.遞迴的優點

  • 簡潔
  • 在樹的前序,中序,後序遍歷演算法中,遞迴的實現明顯要比迴圈簡單得多。

例子1

function foo(i) {
  if (i < 0)
  return;
  console.log('begin:' + i);
  foo(i - 1);
  console.log('end:' + i);
}
foo(3);

// begin:3
// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2
// end:3
複製程式碼

以函式棧的方式來理解以上程式碼就是:

  1. 第一次進入函式foo(3),此時的引數為3,假設為foo1()被推入執行棧 首先 i不小於0,輸出begin:3
  2. 進入foo(i - 1),引數為3-1 = 2,假設為foo2()被推入執行棧 i不小於0,輸出begin:2
  3. 進入foo(i - 1),引數為2-1 = 1,假設為foo3()被推入執行棧 i不小於0,輸出begin:1
  4. 進入foo(i - 1),引數為1-1 = 0,假設為foo4()被推入執行棧 i不小於0,輸出begin:0
  5. 進入foo(i - 1),引數為0-1 = -1,假設為foo5()被推入執行棧 i小於0 return
  6. 執行棧彈出當前的函式foo5(),進入到上一個函式foo4(),繼續執行未完成的程式碼 輸出end:0
  7. 執行棧彈出當前的函式foo4(),進入到上一個函式foo3(),繼續執行未完成的程式碼 輸出end:1
  8. 執行棧彈出當前的函式foo3(),進入到上一個函式foo2(),繼續執行未完成的程式碼 輸出end:2
  9. 執行棧彈出當前的函式foo2(),進入到上一個函式foo1(),繼續執行未完成的程式碼 輸出end:3
  10. 執行棧彈出當前的函式foo1(),到此執行棧全部執行完畢

例子2 階乘函式

function factorial(n) {
  // console.trace()
  if (n === 0) {
    return 1
  }

  return n * factorial(n - 1)
}

factorial(5)

// 拆分成分步的函式呼叫
// factorial(5) = factorial(4) * 5
// factorial(5) = factorial(3) * 4 * 5
// factorial(5) = factorial(2) * 3 * 4 * 5
// factorial(5) = factorial(1) * 2 * 3 * 4 * 5
// factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5
// factorial(5) = 1 * 1 * 2 * 3 * 4 * 5
複製程式碼

下面是以上函式執行的圖例

遞迴優化:尾呼叫和Memoization

如果在factorial函式中插入console.trace()來檢視每次函式執行時的呼叫棧的狀態,當遞迴到呼叫factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5時,輸出結果如下:

console.trace
factorial @ VM159:2
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
(anonymous) @ VM159:10
複製程式碼

3.遞迴的問題(缺點)

  • 效能:如以上例子所示:假設傳入的引數值特別大,那麼這個呼叫棧將會非常之大,最終可能超出呼叫棧的快取大小而崩潰導致程式執行失敗。每一次函式呼叫會在記憶體棧中分配空間,而每個程式的棧的容量是有限的,當呼叫的層次太多時,就會超出棧的容量,從而導致棧溢位。
  • 效率
    • 遞迴由於是函式呼叫自身,而函式呼叫是有時間和空間的消耗的:每一次函式呼叫,都需要在記憶體棧中分配空間以儲存引數、返回地址以及臨時變數,而往棧中壓入資料和彈出資料都需要時間。
    • 遞迴中很多計算都是重複的,由於其本質是把一個問題分解成兩個或者多個小問題,多個小問題存在相互重疊的部分,則存在重複計算,如fibonacci斐波那契數列的遞迴實現。

解決遞迴的效能問題的方法可以使用尾遞迴

二、尾遞迴

尾遞迴是一種遞迴的寫法,可以避免不斷的將函式壓棧最終導致堆疊溢位。通過設定一個累加引數,並且每一次都將當前的值累加上去,然後遞迴呼叫。通過尾遞迴,我們可以把複雜度從O(n)降低到O(1)

先說尾呼叫來理解尾遞迴

尾呼叫是指一個函式裡的最後一個動作是返回一個函式的呼叫結果的情形,即最後一步新呼叫的返回值直接被當前函式的返回結果

程式碼表現形式為:

function f(x) {
  a(x)
  b(x)
  return g(x) //函式執行的最後呼叫另一個函式
}
複製程式碼

1.尾呼叫核心理解

就是看一個函式在呼叫另一個函式得時候,本身是否可以被“釋放”

2.尾呼叫好處

以下面函式呼叫棧和呼叫幀為例

function f(x) {
  res = g(x)
  return res+1
}

function g(x) {
  res = r(x)
  return res + 1
}

function r(x) {
  res = x + 1
  return res + 1
}
複製程式碼

遞迴優化:尾呼叫和Memoization
如圖,普通呼叫過程中,假如函式的呼叫層數非常多時,呼叫棧會消耗大量記憶體,甚至棧溢位,造成程式嚴重卡頓或意外崩潰。

用尾呼叫解決棧溢位風險

function f() {
  m = 10
  n = 20
  return g(m + n)
}
f()

// 等同於
function f() {
  return g(30)
}
f()

// 等同於
g(30)
複製程式碼

上述程式碼,我們可以看到,我們呼叫g之後,和f就沒有任何關係了,函式f就結束了,所以執行到最後一步,完全可以刪除 f() 的呼叫記錄,只保留 g(30) 的呼叫記錄。

尾呼叫的意義 如果將函式優化為尾呼叫,那麼完全可以做到每次執行時,呼叫幀為一,這將大大節省記憶體,提高能效。

3.尾遞迴 = 尾呼叫 + 遞迴

function factorial(n, total = 1) {
  // console.trace()
  if (n === 0) {
    return total
  }

  return factorial(n - 1, n * total)
}
複製程式碼

呼叫factorial(3)函式執行步驟如下:

factorial(3, 1) 
factorial(2, 3) 
factorial(1, 6) 
factorial(0, 6) // n = 0; return 6
複製程式碼

呼叫棧不再需要多次對factorial進行壓棧處理,因為每一個遞迴呼叫都不在依賴於上一個遞迴呼叫的值。因此,空間的複雜度為o(1)而不是0(n)。檢視控制檯,發現第三次列印的結果如下:

console.trace
factorial @ VM362:2
factorial @ VM362:7
factorial @ VM362:7
factorial @ VM362:7
(anonymous) @ VM362:9
複製程式碼

既然說了呼叫棧不再需要多次對factorial進行壓棧處理,那為什麼結果還是不會在每次呼叫的時候壓棧,只有一個factorial呢?

正確的使用方式應該是

'use strict';

function factorial(n, total = 1) {
  // console.trace()
  if (n === 0) {
    return total
  }

  return factorial(n - 1, n * total)
}

// 注意,雖然說這裡啟用了嚴格模式,但是經測試,在Chrome和Firefox下,還是會報棧溢位錯誤,並沒有進行尾呼叫優化
// Safari瀏覽器進行了尾呼叫優化,factorial(500000, 1)結果為Infinity,因為結果超出了JS可表示的數字範圍
// 如果在node v6版本下執行,需要加--harmony_tailcalls引數,node --harmony_tailcalls test.js
// 但是node最新版本已經移除了--harmony_tailcalls功能
複製程式碼

三、Memoization

memoization最初是用來優化計算機程式使之計算的更快的技術,是通過儲存呼叫函式的結果並且在同樣引數傳進來的時候返回結果。大部分應該是在遞迴函式中使用。memoization 是一種優化技術,避免一些不必要的重複計算,可以提高計算速度。

同樣以階乘函式為例:

1.不使用memoization

const factorial = n => {
  if (n === 1) {
    return 1
  } else {
    return factorial(n - 1) * n
  }
}
複製程式碼

2.使用memoization

const cache = [] // 定義一個空的存放快取的陣列
const factorial = n => {
  if (n === 1) {
    return 1
  } else if (cache[n - 1]) { // 先從cache陣列裡查詢結果,如果沒找到的話再計算
    return cache[n - 1]
  } else {
    let result = factorial(n - 1) * n
    cache[n - 1] = result
    return result
  }
}
複製程式碼

3.搭配閉包使用memoization

const factorialMemo = () => {
  const cache = []
  const factorial = n => {
    if (n === 1) {
      return 1
    } else if (cache[n - 1]) {
      console.log(`get factorial(${n}) from cache...`)
      return cache[n - 1]
    } else {
      let result = factorial(n - 1) * n
      cache[n - 1] = result
      return result
    }
  }
  return factorial
}

const factorial = factorialMemo()
複製程式碼

4.總結

memorization 可以把函式每次的返回值存在一個陣列或者物件中,在接下來的計算中可以直接讀取已經計算過並且返回的資料,不用重複多次相同的計算。是一個空間換時間的方式,這種方法可用於部分遞迴中以提高遞迴的效率。

相關文章