遞歸優化:尾調用和Memoization

1、遞歸

1.遞歸含義:

一個過程或函數在其定義或說明中有直接或間接調用自身的一種方法,它一般把一個大型複雜的問題層層轉化爲一個與原問題類似的規模較小的問題來求解,遞歸策略只需少許的程序就可描述出解題過程所須要的屢次重複計算,大大地減小了程序的代碼量。javascript

2.遞歸的優勢

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

例子1java

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
複製代碼

以函數棧的方式來理解以上代碼就是:node

  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
複製代碼

下面是以上函數運行的圖例數組

若是在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斐波那契數列的遞歸實現。

解決遞歸的性能問題的方法可使用尾遞歸緩存

2、尾遞歸

尾遞歸是一種遞歸的寫法,能夠避免不斷的將函數壓棧最終致使堆棧溢出。經過設置一個累加參數,而且每一次都將當前的值累加上去,而後遞歸調用。經過尾遞歸,咱們能夠把複雜度從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
}
複製代碼

如圖,普通調用過程當中,假如函數的調用層數很是多時,調用棧會消耗大量內存,甚至棧溢出,形成程序嚴重卡頓或意外崩潰。

用尾調用解決棧溢出風險

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功能
複製代碼

3、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 能夠把函數每次的返回值存在一個數組或者對象中,在接下來的計算中能夠直接讀取已經計算過而且返回的數據,不用重複屢次相同的計算。是一個空間換時間的方式,這種方法可用於部分遞歸中以提升遞歸的效率。

相關文章
相關標籤/搜索