1. 程式人生 > 其它 >尾呼叫及遞迴優化

尾呼叫及遞迴優化

參考文章

  1. 尾呼叫優化 - 阮一峰

基本概念

一、尾呼叫

  • 一個函式的最後一步是呼叫另一個函式,並返回。注意點是,返回的是一個函式的呼叫(執行)。
// 最簡形式
function f(x){ return g(x); }

// 變種
function f(x) {
  if (x > 0) return m(x);
  return n(x);
}

// 不屬於的情況
// 情況一
function f(x){
  let y = g(x);
  return y;
}

// 情況二
function f(x){ return g(x) + 1; }

 二、尾呼叫優化

  尾呼叫優化是將原本的函式進行一定的改造,改造成尾呼叫的形式,這樣能節省一定的記憶體空間,是空間複雜度優化的常用手段之一。

  我們知道,函式呼叫會在記憶體形成一個"呼叫記錄",又稱"呼叫幀"(call frame),儲存呼叫位置和內部變數等資訊。如果在函式A的內部呼叫函式B,那麼在A的呼叫記錄上方,還會形成一個B的呼叫記錄。等到B執行結束,將結果返回到A,B的呼叫記錄才會消失。如果函式B內部還呼叫函式C,那就還有一個C的呼叫記錄棧,以此類推。所有的呼叫記錄,就形成一個"呼叫棧"(call stack)。

demo:

// 原始函式:
function f() {
  let m = 1;
  let n = 2;
  let result = g(m + n);

  return result;
}

// 優化
function f() { let m = 1; let n = 2; return g(m + n); } // 執行 f();
  • 在原始函式中,當執行到  let result = g(m + n); 時候, 會進入到 g 函式的作用域去執行相關的邏輯,但是此時 f 函式尚未執行完成,那麼就會把 f 函式壓入呼叫棧,於是就需要記憶體空間儲存相關資料,直至 g 函式執行完畢,才接著執行 f 函式,最終一一釋放記憶體空間,順序為 f 函式執行 -> g 函式執行 -> ... -> g 函式結束 -> f 函式結束
  • 而在優化的函式中,f 函式返回的是一個函式,於是就相當於 f 函式執行並返回 g 函式,接著繼續呼叫 g 函式,其順序為 f 函式執行 -> f 函式結束
    -> g 函式執行 -> g 函式結束 -> ...

  這就叫做"尾呼叫優化"(Tail call optimization),即只保留內層函式的呼叫記錄。如果所有函式都是尾呼叫,那麼完全可以做到每次執行時,呼叫記錄只有一項,這將大大節省記憶體。這就是"尾呼叫優化"的意義。

將尾呼叫用於遞迴優化

  函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。

  遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫記錄,很容易發生"棧溢位"錯誤(stack overflow)。但對於尾遞迴來說,由於只存在一個呼叫記錄,所以永遠不會發生"棧溢位"錯誤。如:

// 階乘計算
function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

  在最後一步要計算  n * factorial(n - 1) , n 在當前作用域可以獲得,但是  factorial(n - 1) 則需要進入下層作用域計算,所以只能將本層壓入呼叫棧,繼續申請記憶體進行下層運算,因此這種遞迴方式很佔用記憶體。

  進行尾呼叫優化:

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

  在最後一步,無論 n - 1  還是  n * total 都可以在本級運算出結果,所以對於本層而言,沒有需要記錄的資料,因此在將這兩個結果運算出來後作為引數傳遞給下級函式,並返回,對於本級而言,沒有需要記錄或者儲存的,可以將本層的記憶體空間釋放出來,相較於普通的形式,永遠只需要一塊函式的執行空間,這就是優化的意義所在(對於程式碼閱讀也十分友好)。

  再看一個對斐波拉契數列的遞迴優化:

// 普通遞迴
function factorial(num) {
  if (num === 0 || num === 1) {
    return num;
  }
  // console.trace();
  // console.log(factorial(num - 1) + factorial(num - 2));
  return factorial(num - 1) + factorial(num - 2);
}

// 尾遞迴優化
function factorial(num, num1 = 0, num2 = 1) {
  if (num === 0) {
    return num1;
  }
  // console.trace();
  return factorial(num - 1, num2, num1 + num2);
}

總結

  尾遞迴的實現,往往需要改寫遞迴函式,確保最後一步只調用自身。做到這一點的方法,就是把所有用到的內部變數改寫成函式的引數。

  遞迴本質上是一種迴圈操作。純粹的函數語言程式設計語言沒有迴圈操作命令,所有的迴圈都用遞迴實現,這就是為什麼尾遞迴對這些語言極其重要。對於其他支援"尾呼叫優化"的語言(比如Lua,ES6),只需要知道迴圈可以用遞迴代替,而一旦使用遞迴,就最好使用尾遞迴。