怎么寫遞歸


以前我很少寫遞歸,因為感覺寫遞歸需要靈感,很難復制。看了《The Little Schemer》后,我發現寫遞歸其實是有套路的。遞歸只需要想清楚 2 個問題:

  1. 什么情況不需要計算
  2. 大問題怎么變成小問題

舉例

1. 判斷數組是否包含某元素

const has = (element, arr) => {};
  • 什么情況不需要計算?
    數組為空時不需要計算,一定不包含。

    const has = (element, arr) => {
      if (arr.length === 0) return false;
    };
    
  • 怎么把大問題變成小問題?
    arr 的長度減小,向數組為空的情況逼近。
    arr 中取出第一個元素和 element 比較:

    1. 相同:返回 true
    2. 不相同:求解更小的問題。
    const has = (element, arr) => {
      if (arr.length === 0) return false;
      else if (arr[0] === element) return true;
      else return has(element, arr.slice(1));
    };
    

2. 刪除數組的某個元素

const del = (element, arr) => {};
  • 什么情況不需要計算?
    數組為空時不需要計算,返回空數組。

    const del = (element, arr) => {
      if (arr.length === 0) return [];
    };
    
  • 怎么把大問題變成小問題?
    arr 的長度減小,向空數組的情況逼近。
    arr 中取出第一個元素和 element 比較:

    1. 相同:返回數組余下元素。
    2. 不相同:留下該元素,再求解更小的問題。
    const del = (element, arr) => {
      if (arr.length === 0) return [];
      else if (arr[0] === element)
        return arr.slice(1);
      else
        return [
          arr[0],
          ...del(element, arr.slice(1))
        ];
    };
    

3. 階乘、斐波那契

階乘、斐波那契用遞歸來寫也是這個套路,代碼結構都是一樣的。

先列出不需要計算的情況,再寫大問題和小問題的轉換關系。

const factorial = n => {
  if (n === 1) return 1;
  else return n * factorial(n - 1);
};
const fibonacci = n => {
  if (n === 1) return 1;
  else if (n === 2) return 1;
  else return fibonacci(n - 1) + fibonacci(n - 2);
};

4. 小孩子的加法

小孩子用數數的方式做加法,過程是這樣的:

3 顆糖 加 2 顆糖 是幾顆糖?

小孩子會把 3 顆糖放左邊,2 顆糖放右邊。
從右邊拿 1 顆糖到左邊,數 4,
再從右邊拿 1 顆糖到左邊,數 5,
這時候右邊沒了,得出有 5 顆糖。

這也是遞歸的思路。

const add = (m, n) => {};
  • n = 0 時,不需要計算,結果就是 m

    const add = (m, n) => {
      if (n === 0) return m;
    };
    
  • 把問題向 n = 0 逼近:

    const add = (m, n) => {
      if (n === 0) return m;
      else return add(m + 1, n - 1);
    };
    

當然 m = 0 也是不需要計算的情況。
選擇 m = 0 還是 n = 0 作為不需要計算的情況 決定了 大問題轉成小問題的方向。


Continuation Passing Style

const add1 = m => m + 1;

add1 的返回結果乘 2,通常這么寫:

add1(5) * 2;

Continuation Passing Style 來實現是這樣的:

const add1 = (m, continuation) =>
  continuation(m + 1);

add1(5, x => x * 2);

add1 加一個參數 continuation,它是一個函數,表示對結果的后續操作。


我們用 Continuation Passing Style 來寫寫遞歸。

以下用

  1. CPS 代替 Continuation Passing Style
  2. cont 代替 continuation

1. 階乘

const factorial = (n, cont) => {
  if (n === 1) return cont(1);
  else return factorial(n - 1, x => cont(n * x));
};
  • 如果 n === 1,把結果 1 交給 cont
  • 如果 n > 1,計算 n - 1 的階乘,
    n - 1 階乘的結果 xn,交給 cont

這個 factorial 函數該怎么調用呢?
cont 可以傳 x => x,這個函數傳入什么就返回什么。

factorial(5, x => x);

  • 之前的寫法:

    const factorial = n => {
      if (n === 1) return 1;
      else return n * factorial(n - 1);
    };
    

    遞歸調用 factorial 不是函數的最后一步,還需要乘 n
    因此編譯器必須保留堆棧。

  • 新寫法:

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return factorial(n - 1, x => cont(n * x));
    };
    

    遞歸調用 factorial 是函數的最后一步。
    做了尾遞歸優化的編譯器將不保留堆棧,從而不怕堆棧深度的限制。

也就是說:可以通過 CPS 把遞歸變成尾遞歸。


2. 斐波那契

const fibonacci = (n, cont) => {
  if (n === 1) return cont(1);
  else if (n === 2) return cont(1);
  else
    return fibonacci(n - 1, x =>
      fibonacci(n - 2, y => cont(x + y))
    );
};
  • 如果 n === 1,把結果 1 交給 cont
  • 如果 n === 2,把結果 1 交給 cont
  • 如果 n > 2
    計算 n - 1 的結果 x
    計算 n - 2 的結果 y
    x + y 交給 cont

3. CPS 尾遞歸使用誤區

  • cont 傳入的參數不是最終結果。

    CPScont 是對結果的后續操作。
    也就是說 cont 傳入的參數需要是最終結果。
    不滿足這一點,就不能叫做 CPS

    錯誤代碼示例:

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return factorial(n - 1, x => cont(n) * x);
    };
    

    上述代碼和之前代碼的區別如下:

    x => cont(n) * x; // 現在 錯誤的寫法
    x => cont(n * x); // 之前 正確的寫法
    

    錯誤的寫法中,cont 傳入的參數不再是最終結果了,如果這么調用:

    factorial(5, console.log);
    

    期望控制台打印 120
    實際控制台打印 5

  • CPS,卻不是尾遞歸。

    const factorial = (n, cont) => {
      if (n === 1) return cont(1);
      else
        return cont(factorial(n - 1, x => n * x));
    };
    

    以上寫法稱得上是 CPS 了,但卻不是尾遞歸。

    注意這段代碼:

    cont(factorial(n - 1, x => n * x));
    

    factorial 的遞歸調用不是函數的最后一步,cont 的調用才是最后一步。


4. 驗證 CPS 尾遞歸優化

截止到 2019 年 11 月,只有 Safari 瀏覽器宣稱支持尾遞歸優化。

用從 1 加到 N 的例子試驗了一下,Safari 13.0.3:

  • 一般遞歸:堆棧溢出。

    "use strict";
    
    const sum = n => {
      if (n === 1) return 1;
      else return n + sum(n - 1);
    };
    
    sum(100000);
    
  • CPS 尾遞歸:正常算出結果。

    "use strict";
    
    const sum = (n, cont) => {
      if (n === 1) return cont(1);
      else return sum(n - 1, x => cont(n + x));
    };
    
    sum(1000000, x => x);
    

最后想說的

用以前的方式寫遞歸 還是 用 CPS 寫遞歸,只是寫法上不同,思想都是一樣的,都是要搞清:

  1. 什么情況不需要計算
  2. 大問題怎么變成小問題


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM