瞭解遞歸的幾種姿式 - 函數式編程

什麼是遞歸

遞歸和迭代是一枚硬幣的兩面,在不可變的條件下,遞歸提供了一種更具表現力、強大且優秀的迭代替代方法編程

遞歸函數由如下兩個主要部分組成:數組

  • 基準
  • 遞歸條件

遞歸主要的核心思想是將問題分解爲較小的問題,逐個解決後再組合,構建出整個問題的答案。瀏覽器

具體概念不詳述,可谷歌百度自行搜索。遞歸適合解決相似XML解析、語法樹構建,深度遍歷等問題。編程語言

而在Haskell這種純函數編程語言裏,本來是沒有循環結構的,遞歸是自然代替循環的,好比求和函數(固然,Haskell有原生的sum方法支持)實現,以下所示:ide

sum' :: Num a => [a] -> a
sum' []  = 0
sum' (x:xs) = x + sum' xs
複製代碼

再看階乘函數的Haskell實現,以下所示:函數式編程

factorial :: (Integral a) => a -> a  
factorial 0 = 1  
factorial n = n * factorial (n - 1)  
複製代碼

你會發現函數的聲明基本表達了上述所說的遞歸兩個主要部分。不得不認可,很優雅!函數

遞歸適當時候能夠優雅的解決迭代不適合處理的問題。掌握遞歸思考的方式是一個長期訓練的過程。工具

下文將帶你們學習幾個遞歸的姿式,因爲篇幅有限,不詳述原理。性能

(同窗們莫慌,下文將用JavaScript舉例,畢竟它纔是我目前的恰飯工具哈哈)學習

求和的幾種姿式

考慮給一個數組求和:

const nums = [1, 2, 3, 4, 5];
複製代碼

命令式

命令式的開發思惟,會很天然寫出如下代碼:

let total = 0;
for(let i = 0; i < nums.length; i++) {
  total += nums[i];
}

console.log(total); // 15
複製代碼

聲明式

更進一步,學了點函數式編程的同窗會寫出如下代碼:

const add = (x, y) => x + y;
const sum = (...nums) => nums.reduce(add, 0);

console.log(sum(...nums)); // 15
複製代碼

遞歸

瞭解遞歸的同窗,寫出來如下代碼:

function getTotal(sum, num, ...nums) {
  if (nums.length === 0) {
    return sum + num;
  } else {
    return sum + getTotal(num, ...nums);
  }
}

console.log(getTotal(...nums)); // 15
複製代碼

可是,目之所及,遞歸仍是不多用的,不只僅常見的缺少遞歸思惟問題,也是有性能問題的考慮,你們會發現寫遞歸存在棧溢出的問題:

因而我寫了個函數,測試一下Chrome瀏覽器支持遞歸的深度是多少?

function getMaximumCallStack(getTotal) {
  const f = n => getTotal(...'1'.repeat(n).split('').map(Number));
  let i = 1;

  while(true) {
    try {
      const res = f(i);
      console.log(`Stack size: ${i}, f(${i})=${res}`);
      i++;
    } catch(e) {
      console.info(`Maximum call stack size: ${i}`);
      break;
    }
  }
}

getMaximumCallStack(getTotal);
複製代碼

測試了上述寫的getTotal遞歸,

Chrome寶寶居然只是到了484層棧就跪了,實在不敢相信!

------------瀏覽器三八分割線------------

Safari寶寶表現如何呢?

貌似比Chrome好一丟丟,不過也沒什麼很大的區別...

那這樣讓咱們如何愉快的使用遞歸呀?

遞歸的幾種優化方式

如上文所述,遞歸雖然優雅,可是經常會遇到棧溢出的狀況,那麼這種問題怎麼優化呢?如下三種優化方式:

PTC(Proper Tail Calls)

PTC必定要運行在嚴格模式下,文件開始聲明"use strict";

function getTotal_PTC(sum, num, ...nums) {
  sum += num;
  if (nums.length === 0) {
    return sum;
  } else {
    return getTotal_PTC(sum, ...nums);
  }
}

console.log(getTotal_PTC(...nums)); // 15
複製代碼

PTC版的遞歸其實和上文寫的遞歸只有些微寫法上的區別:

// 正常遞歸
return sum + getTotal(num, ...nums);
// PTC版的遞歸
return getTotal_PTC(sum, ...nums);
複製代碼

改爲PTC寫法以後,支持支持PTC優化的瀏覽器,能夠不斷重複利用原有的棧,從而避免了棧溢出的問題。(原理大體上是因爲瀏覽器不用保留記住每一次遞歸中的值,在這個函數裏特指 sum + getTotal(num, ...nums) 中的sum變量值,從而新棧替換舊棧。

支持PTC優化的瀏覽器很少,目前可能只有Safari支持,仍然爲了眼見爲實,在Chrome和Safari兩個瀏覽器進行了測試。

運行上述工具方法測試:getMaximumCallStack(getTotal_PTC)

Chrome寶寶很惋惜的偷懶了,木有支持~(殘念),見下圖:

Safari寶寶果真優秀,對其有所支持!跑了一段時間,未見溢出,見下圖:

CPS(Continuation Passing Style)

const getTotal_CPS = (function() {
  return function(...nums) {
    return recur(nums, v => v);
  };

  function recur([sum, ...nums], identity) {
    if (nums.length === 0) {
      return identity(sum);
    } else {
      return recur(nums, v => identity(sum + v));
    }
  }
})();

console.log(getTotal_CPS(...nums)); // 15
複製代碼

這種優化技巧經過建立額外的包裹函數:

  1. 將值的計算延遲
  2. 避免調用棧的堆積

可是不可避免的消耗了更多的內存用來存放這些多餘的包裹函數。 (關於具體原理比較複雜,有空單獨寫篇文章論述)

Chrome瀏覽器測試以下圖:

仍然棧溢出,可是棧的深度多了不少~

Trampoline

function getTotal_f(sum, num, ...nums) {
  sum += num;
  if (nums.length === 0) {
    return sum;
  } else {
    return () => getTotal_f(sum, ...nums);
  }
}

function trampoline(f) {
  return function trampolined(...args) {
    let result = f(...args);
    while (typeof result == "function") {
      result = result();
    }
    return result;
  };
}

const getTotal_trampoline = trampoline(getTotal_f);

console.log(getTotal_trampoline(...nums)); // 15
複製代碼

這種思惟技巧將遞歸巧妙的轉換爲了迭代! 寫法保持了遞歸的思惟,可是通過trampoline工具函數的處理,實際上交給瀏覽器執行的時候變成了迭代。

Chrome測試以下:

速度飛快!絲滑流暢~

考慮到內存堆棧問題,trampoline仍是蠻適合做爲折中的方案的。

小結

謹記:遞歸的目標是寫出更具備可讀性的代碼。因此運用遞歸時考慮如下兩點:

  • 編寫迭代循環以前,反思是否是能夠用遞歸更好的表述!
  • 編寫遞歸以前,反思是否是沒有必要使用遞歸?
相關文章
相關標籤/搜索