【愣錘筆記】一篇小短文完全搞明白js的遞歸和尾遞歸

「我發起狠來連本身都打」這句話,其實有那麼一丟丟遞歸的意思。好了,遞歸,什麼是遞歸?遞歸就是函數本身調用本身。本文主要分兩部分,第一部分講的遞歸經常使用場景,第二部分講遞歸帶來的問題和解決方案。那麼,👇開始直擊你靈魂深處的自虐之旅吧!javascript

1、常見的遞歸操做

遞歸的概念上面👆已經說了,就是函數本身調用本身。這句話的意思也很明白,那麼咱們就看幾個遞歸的實際例子,來探討一下javascript中好玩的遞歸操做吧。html

遞歸實現階乘函數(1*2*3*…*n的值)

先看下不經過遞歸的方式:vue

var factorial = function (n) {
  var result = 1;
  for (var i = 1; i <= n; i++) {
    result *= i;
  }
  return result;
}
console.log(factorial(5)) // 120複製代碼

經過一個循環,實現來階乘函數,老鐵,沒毛病!java

下面看一下遞歸的方式實現階乘函數:node

var factorial = function (n) {
  if (n <= 1) return 1;
  return factorial(n - 1) * n;
}
console.log(factorial(5)) // 120複製代碼

經過不停的調用自身,最終返回的是n * (n - 1)* (n - 2) …* 2 * 1就得出來咱們想要結果。es6

經典的斐波那契數列:1,1,2,3,5,8,13……即從第三項起,每一項的值是前兩項值的和。如今求第n項的值。

var fibona = function (n) {
    // 若是求第一項或者第二項,則直接返回1
    if (n === 1 || n === 2) return 1;
    // 不然的話,返回前兩項的和
    return fibona(n - 1) + fibona(n - 2);
}
console.log(fibona(4)) // 3複製代碼

根據遞歸的思想,首先設置遞歸的終止條件,就是n=1或者n=2的時候返回1。不然的話就重複調用自身,即第n項的值是第n-1和第n-2項的和,那麼同理,第n-1項的值是第n-1-2和n-1-1項的值,依次類推,經過遞歸,最終都轉化成了f(1)或f(2)的值相加。面試

Tip:像斐波契數列這類的求值函數,計算量仍是有些大的,因此咱們徹底能夠作個緩存,在屢次求相同的值的時候,咱們能夠直接從緩存取值。來吧,舉個🌰:編程

// 有緩存的斐波那契數列函數
var fibona = (function() {
    var cache = {};
    return function (n) {
      if (cache[n]) return cache[n];
      if (n === 1 || n === 2) return 1;
      return cache[n] = fibona(n - 1) + fibona(n - 2);
    }
})();
console.log(fibona(4)) // 3複製代碼

利用閉包的思想,咱們在閉包中定義一個緩存對象cache,將計算過的值存進該對象中。每次函數調用的時候,首先會從查看cache對象中是否已經存在這個值,有的話就直接返回,沒有的話則從新計算。json

遞歸實現深拷貝

對象的深拷貝但是咱們平常工做中很經常使用一個方法,幾乎到處都有它的影子。常見的深拷貝方式有兩種:數組

  1. 利用json的方法
  2. 遍歷對象屬性,而後依次賦值。若是值是引用類型,則繼續遍歷該值

// 利用json的深拷貝
var deepClone = function (obj) {
  return JSON.parse(JSON.stringify(obj))
}
// 或這簡寫一下
const deepClone = obj => JSON.parse(JSON.stringify(obj))複製代碼

這種方法很簡單,就是利用json的解析和序列化的兩個方法。然鵝!曲項向天歌,白毛浮綠水,紅掌撥清波 ! ! ! 原諒我,控制不住我本身呀~~~該方法只能對符合json格式的對象進行拷貝,並且屬性值不能是函數等一些特殊類型。我是並不推薦使用這種方法做爲項目基礎函數庫中的深拷貝方法的。👇咱們看下第二種深拷貝,也就是用遞歸來實現:

/**
 * 判斷是否是對象(除null之外等對象類型),這裏isObject函數借鑑underscore中的實現
 * js中函數/數組/對象,都是對象類型
 */
var isObject = function (obj) {
  var type = typeof obj;
  return type === 'function' || type === 'object' && !!obj;
}
// 定義深拷貝對象
var deepClone = function (obj) {
    if (!isObject(obj)) return obj;
    var result = new obj.constructor();
    for (var i in obj) {
      if (obj.hasOwnProperty(i)) {
        result[i] = deepClone(obj[i]);
      }
    }
    return result;
}
// 打印拷貝效果
console.log(deepClone([123, {a: 1, b: {c: 2}}, 456]))
// 輸出:
[
    123,
    {
        a: 1,
        b: {
            c: 2
        }
    },
    456
]複製代碼
  • 首先說下這裏的isObject對象,它判斷一個數據是否是對象類型
  • deepClone函數接收一個待克隆對象,並返回一個克隆後的對象
  • deepClone函數首先判斷是否是原始值(也就是!isObject),若是是原始值則直接返回該原始值。若是不是原始值,則認爲它是數組或者對象(這裏忽略了函數/正則/日期等特殊數據類型,後面會介紹爲何)。而後經過for/in循環,經過遞歸調用自身進行賦值(遞歸調用的時候,若是是原始值則返回進行賦值,若是不是原始值則又進行for/in循環重複上面步驟)
  • 另外說一點,這個函數裏面,經過new obj.constructor()巧妙的避免了對當前數據是數組仍是真對象的判斷。

這個深拷貝函數是市面上很常見的深拷貝作法,基本覆蓋了絕大部分的業務場景。可是它是有bug的,好比:對於屬性值是函數/日期對象/正則/環對象(對象本身引用本身)等特殊類型,是有bug的。一樣的json的那個深拷貝方法也是如此。

可是,仍是那句話,該函數基本覆蓋了咱們平常拷貝需求,能夠放心使用。若是你須要處理上述的這些特殊類型數據的話,該方法就行不通了。關於深拷貝的話題,仔細深聊下去,東西實際上是蠻多的,徹底能夠單獨拿出來討論。本文旨在講述遞歸,不深刻討論深拷貝了。若是有興趣研究上述拷貝難題,能夠查看lodash的深拷貝原理,或者MDN的結構化拷貝(也沒有處理對函數的拷貝)。

遞歸遍歷元素的全部子節點

若是咱們想遍歷元素的全部子節點,咱們能夠經過遞歸很是方便的作到。

/**
 * 遞歸子節點,給每一個元素節點添加'data-v-123456'屬性 
 * @param {節點或元素} root 要遞歸的節點或元素
 * @param {Function} callback 每一次遍歷的回調函數
 */
const getChildNodes = (root, callback) => {
  // 判斷是存在子元素
  if (root && root.children && root.children.length) {
    // 將子元素轉換成可遍歷的數組
    Array.from(root.children).forEach(node => {
      callback && typeof callback === 'function' && callback(node);
      // 遞歸子元素,重複上述操做
      getChildNodes(node, callback);
    });
}
// 例如,咱們想像vue的scoped那樣,爲每個html標籤添加data-v-123456屬性
const root = document.getElementById('app');
getChildNodes(root, function (node) {
  // 爲每一個子元素添加屬性
  node.setAttribute('data-v-123456', '');
});
// 輸出結果以下圖複製代碼


  • 二分法快速排序中的遞歸運用

二分法快排,或許是面試中常問到的數組排序方法。核心思想就是,從待排序數組中取出最中間的拿個值(注意,只是下標是中間的那個,並非值是中間的那個),而後遍歷剩餘數組項,將比中間的值小的放在一個數組拼接在左邊,比這個中間值大的所有放在一個數組而後拼接在右邊。利用遞歸,知道每一次的數組個數只剩一項的時候,中止。如此,最終拼接出來的數組就是排序後的數組。

/**
 * 利用二分法快速排序
 */
var quickSort = function (arr) {
  if (arr.length <= 1) return arr;
  var left = [],
  right = [],
  middle = arr.splice(Math.floor(arr.length / 2), 1)[0],
  i = 0,
  item;
  for (; item = arr[i++];) {
    item < middle ? left[left.length] = item : right[right.length] = item;
  }
  return quickSort(left).concat([middle], quickSort(right));
}
// 輸出: [2, 3, 5]
console.log(quickSort([3, 2, 5]))複製代碼

樹結構中可使用遞歸

樹結構就是有個根結點,根結點底下能夠有多個子節點,每一個子節點又能夠有子節點。常見的樹結構數據以下:

var tree = {
  name: '電腦',
  children: [
    {
      name: 'F盤',
      children: [
        {
          name: '照片',
          children: []
        },
        {
          name: '文件',
          children: [
            {
              name: '工做文件',
              children: [
                {
                  name: '報告',
                  children: []
                }
              ]
            }
          ]
        }
      ]      
    },
    {
      name: 'E盤',
      children: [
        {
          name: '視頻',
          children: [
            {
              name: 'js教程',
              children: []
            }
          ]
        }
      ]
    }
  ]
}複製代碼

遍歷樹結構,有深度優先的原則,也有廣度優先的原則。能夠經過循環,也能夠經過遞歸。接下來咱們演示深度優先遍歷。

所謂深度優先的原則:就是順着一個節點延伸下去,先遍歷它的第一個子節點,而後是第一個孫節點,而後重孫節點,直到沒有子節點爲止。即先縱深遍歷完以後在遍歷同級的其餘節點。

// 深度優先的非遞歸遍歷
function deepTraversal (root, cb) {
  if (!root) return;
  cb && typeof cb === 'function' && cb(root);
  while (root.children && root.children.length) {
    var node = root.children.shift();
    cb && typeof cb === 'function' && cb(node);
    while (node && node.children && node.children.length) {
      root.children.unshift(node.children.pop());
    }
  }
}
// 調用,輸出每一項的name值
deepTraversal(tree, function (node) {
  console.log(node.name);
});
// 輸出:
// 電腦
// F盤
// 照片
// 文件
// 工做文件
// 報告
// E盤
// 視頻
// js教程複製代碼

下面看下用遞歸如何來處理深度優先的遍歷:

// 深度優先的遞歸遍歷
function deepTraversal (root, cb) {
  if (!root) return;
  cb && typeof cb === 'function' && cb(root);
  if (root.children && root.children.length) {
    var i = 0, node;
    for (; node = root.children[i++];) {
      deepTraversal(node, cb);
    }
  }
}
// 輸出結果同上
deepTraversal(tree, function (node) {
  console.log(node.name);
});複製代碼

經過上面的例子,雖然循環和遞歸均可以實現深度優先原則的遍歷。可是使用循環的方式進行遍歷,其實性能是更好的。

經典樓梯問題:一共10級樓梯,每次能夠走一步或兩步,求一共多少種走法

這個問題,猛一看可能沒有任何頭緒。可是細細一想,要找出其中的規律。下面說下解題思路:若要到達最後一級樓梯(N=10)能夠有兩種走法:
  1. 站在(N=8)的位置,而後邁兩步上去,這是一種到達最頂的走法
  2. 站在(N=9)的位置,而後邁一步上去,這是另外一種到達最頂的走法
以此類推:
  1. 若要到達(N=9)的位置,也有兩種走法,即站在(N=8邁一步或者N=7邁兩步)的位置上;
  2. 若要到達(N=8)的位置,也有兩種走法,即站在(N=7邁一步或者N=6邁兩步)的位置上;
因此,能夠理解爲,若要到達第N級的走法有:到達第N-2的全部走法+到達N-1點全部走法。而到達N-1的走法有:到達N-1-2的全部走法+到達N-1-1的全部走法。結論來了,到達n級的全部走法:

fn = fn(n - 1) + f(n - 2) // 僞代碼複製代碼

照着這個思路,要到達某一級的全部走法等於到達前一級的全部走法加上到達前兩級的全部走法之和。那總過有個下限吧。哎,你想對了,這個下限就是:

  1. 若是你要到達第一級,那麼走法只有一種
  2. 若是你要到達第二級,走法有兩種
  3. 若是你要到達第三級,則又能夠表示爲到達第一級和到達第二級的全部走法之和了

因此,這個下限就是:

if (n === 1) return 1;
if (n === 2) return 2;複製代碼

這樣,咱們的求走法的函數也就順着這個思路出來了:

var getRoutes = function (n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return fibona(n - 1) + fibona(n - 2);
}
console.log(getRoutes(10)) // 89級複製代碼

這個核心思想就是,到達某一級的走法永遠等於到達前一級和前兩級的全部走法之和。所以很適合用遞歸來處理。

細胞分裂問題:1個細胞,一個小時分裂一次,生命週期是3小時,求n小時後容器內,有多少細胞。

這個題目的解題思路就是要分清細胞的狀態,以及細胞的計算方式。粗略的說,細胞只有死亡和活着的狀態,而最終求細胞個數也是指的求最後還活着的細胞。

因爲細胞能夠分裂,所以細胞能夠細分爲四種狀態:

  1. 剛分裂的狀態----前一次(1/2/3狀態)的新細胞(也包含最初的母細胞)才能夠分裂成此狀態
  2. 分裂一小時的狀態----只有前一次分裂的1狀態的細胞分裂後自身會成爲此狀態
  3. 分裂兩小時的狀態----只有前一次分裂的2狀態的細胞再分裂後自身會成爲此狀態
  4. 分裂三小時的狀態(也是細胞的死亡狀態)

所以,計算n小時後的細胞數,就是計算n小時後細胞狀態爲1/2/3的細胞總和。如今咱們假設,求1狀態的細胞總數的函數爲funA,求2狀態的爲funB,求3狀態的爲funC。

最終咱們的計算函數就是:

//獲取n小時後的細胞總數
var calcCells = function (n) {
    return funA(n) + funB(n) + funC(n)
}複製代碼

老鐵,這樣應該沒毛病吧!

下面,重點在各個狀態細胞的計算函數。

先來看1狀態的細胞----剛分裂的細胞。前一次的1狀態,前一次的2狀態和前一次的3狀態均可以分裂新細胞。而前一次的4狀態則不能夠。有人問,爲啥?死了呀!難道還要詐屍呀~~還有一點,0小時的時候,1狀態的細胞數量是1,就是這個母細胞。

由此:

// 獲取1狀態的細胞數量
var funA = function (n) {
    if (n === 0) return 1;
    return funA(n - 1) + funB(n - 1) + funC(n - 1);
}複製代碼

再看2狀態的細胞----分裂一小時的。2狀態的細胞,只能是剛分裂狀態的細胞,在一小時後變成此狀態(也就是前一次分裂狀態爲1的細胞)。可是,在0小時的時候,是沒有此狀態的細胞。因此:

// 獲取2狀態的細胞數量
var funB = function (n) {
    if (n === 0) return 0;
    return funA(n - 1);
}複製代碼

同理,3狀態的細胞,則是由2狀態的細胞在一小時變成的,so:

// 獲取3狀態的細胞數量
var funC = function (n) {
    if (n === 0 || n === 1) return 0;
    return funB(n - 1);
}複製代碼

這樣,咱們利用遞歸便實現該方法:

console.log(fibo(1), fibo(2), fibo(3), fibo(4), fibo(5)) // 2 4 7 13 24複製代碼


2、遞歸的問題和優化

前面講了這麼多有趣的遞歸,然而,遞歸併不是完美的!不只如此,還會有性能和內存問題。最經典的莫過於堆棧溢出。在講遞歸的問題以前,咱們先了解幾個概念:

  1. 堆棧:後進先出的原則。想象一下桌子上放一堆書,先放的在最底下,後放的在最頂部,拿書的時候,後放的被先拿走。即後進入堆棧的先出棧。
  2. 函數的堆棧概念:js中,每次函數調用會在內存造成一個「調用記錄」, 保存着調用位置和內部變量等信息。若是函數中調用了其餘函數,則js引擎將其運行過程暫停,去執行新調用的函數,新調用函數成功後繼續返回當前函數的執行位置,繼續日後執行。執行新調用的函數過程,也是將其推入到堆棧的最頂部併爲其開闢一塊內容。新函數執行完畢後將其推出堆棧,並回收內存。由此便造成了函數的堆棧。

瞭解了這些概念以後,咱們再來看這個階乘函數。

// 經典的階乘函數
var factorial = function (n) {
    if (n <= 1) return 1;
    return factorial(n - 1) * n
}
console.log(factorial(5)) // 120
console.log(factorial(6594)) // 6594爆棧複製代碼

輸出結果咱們看到,在遞歸近7000次的時候,堆棧溢出了(注意:這個數字毫無心義,不是w3c規範規定的,而是js的解釋器定的,根據不一樣的平臺/不一樣的瀏覽器/不一樣的版本可能都會不同)。錯誤結果以下圖所示,之因此瀏覽器會如此蠻橫加個溢出,強制終止掉你的遞歸,是爲了包含你的系統由於不當的程序而被耗盡內存。


爲何會堆棧溢出呢?從上面的概念咱們理解到,每次函數調用,都會爲其開闢一小塊內存,並把函數推入堆棧,執行完畢後纔會釋放。而咱們的階乘函數,在最後一句return factorial(n - 1) * n 包含了一個對自身的調用 * n,這就使得該函數必需要等待新的函數調用執行完畢後再乘以n以後纔算執行完畢返回,一樣的新的函數調用在最後的時候又要等待內部的新的函數嗲調用執行完畢後進行計算再返回。如此一來,就比如如,a內有個b,b有個c,c內有個d……而a要等b執行完才釋放,b要等c,c要等d……這樣在堆棧內便存放了n多個函數的「調用記錄」,而每個「調用記錄」是開闢了一塊內存的,因此,便超出了瀏覽器的限制,溢出了。

知道了問題,那解決辦法呢?辦法就是尾調用優化

尾調用就是:在函數執行的最後一步返回一個一個函數調用。這個概念很簡單,咱們看下幾個例子:

/**
 * 函數最後一行雖然是一個函數調用,而後並未返回
 * funA函數會等funB執行完畢後纔算執行完畢,才能被推出棧。
 * 因此不是尾調用
 */
function funA () {
  funB();
}

/**
 * 函數執行到最後一行,須要等到funB執行完畢的結果,而後funA再計算後才返回結果
 */
function funA () {
  var x = 10;
  return x + funB();
}

/**
 * 在funB執行完畢後還有賦值操做,所以也不是尾調用
 * 本質由於要等funB執行完畢後funA才能執行完畢
 */
function funA () {
  var x = funB();
  return x;
}複製代碼

以上這些都不是尾調用,緣由都寫在註釋了。下面再看下是尾調用的幾種狀況:

// 函數最後的一行返回了一個函數調用
function func () {
    // 省略函數的邏輯
    // ……
    return funA()
}
// 函數經過判斷,最後仍是返回的函數調用
function func () {
    // 省略函數的邏輯
    // ……
    var x = 0;
    if (x >= 0) return funB()
    return funA()
}
// 雖然最後一行是一個三元運算符,可是最終返回的也是一個函數調用
function func () {
    // 省略函數的邏輯
    // ……
    return  0 > 1 ? funA() : funB();
}複製代碼

尾調用的核心就是:在函數執行的最後一步返回一個函數調用。注意哦,是最後一步,而沒必要須是最後一行代碼。

知道了尾調用的核心思想,咱們回過頭再來看一下咱們的階乘函數,若要達到最後一步只返回一個函數調用,那咱們就要想辦法去掉函數返回中的*n這塊。

由此,咱們能夠在函數的參數中,攜帶一個sum參數來存放每一次遞歸後的值,最後在遞歸到1的時候,將這參數返回便可!ok,下面咱們來實現:

// 使用了一個參數sum來保存階乘後的值
// 函數執行到n==1的時候,返回sum的值
var factorial = function (n, sum) {
    if (n <= 1) return sum;
    return factorial(n - 1, sum * n)
}
console.log(factorial(5, 1)) // 120
console.log(factorial(12040, 1)) // 12000左右依然爆棧了,可是比以前的爆棧上限提高了很多複製代碼

咱們每次遞歸調用的時候,把當前的計算結果存在函數的第二個參數sum中,並傳遞給下一次的遞歸調用,知道最後n===1的時候,返回最終的結果。

可是,如今的這種調用方法,咱們每次都須要加一個默認的參數1,感受好麻煩哦!內心好不爽!怎麼辦?固然是盤他啊,給他幹掉。

  • 能夠直接寫個新的接口,返回factorial函數的調用,把1這個參數給他帶上

var newFactorial = function(n) {
   return factorial(n, 1)
};複製代碼
  • 函數攜帶默認參數的作法,最簡單的莫過於es6自己支持的特性----函數參數設置默認值:
var factorial = function (n, sum = 1) {
    if (n <= 1) return sum;
    sum *= n;
    return factorial(n - 1, sum)
}複製代碼
  • 最後,我我的更推薦的是經過向右柯里化的方式,這是函數式編程的一個很重要的概念。

// 經過寫一個向右柯里化函數來封裝新的階乘函數
var curry = function (fn, sum) {
    return function (n) {
      return fn.call(fn, n, sum);
    }
}
var curryFactorial = curry(factorial, 1);
console.log(curryFactoial(5)) // 120複製代碼

經過curry函數,對階乘函數進行來封裝,返回一個新的帶默認參數sum爲1的新函數。關於柯里化函數,有興趣的小夥伴能夠去研究研究,也頗有意思的呦。

注意:咱們雖然經過了尾調用優化了咱們的遞歸函數(這裏是尾遞歸,尾調用自身即尾遞歸),可是上面的操做,在達到某個值的時候依然會爆棧。這是爲何呢?究其緣由:

  • 尾調用只在嚴格模式下生效,正常模式下是沒有效果的
  • es6明確表示,只要實現了尾調用,就不會棧溢出。而後js解釋器在實現的時候並未遵照這一規範。v8曾經實現過,後來又刪除了調用優化,由於進行函數尾遞歸優化以後,會破壞函數的調用棧記錄。w3c也在致力於尋找新的語法來指明函數的尾調用。


最後,還要再說明幾點:

  • 遞歸本質就是本身調用本身,在終止條件前會一直遞歸。在遞歸層級到必定程度時,會出現棧溢出而中止遞歸。
  • 因爲遞歸存在一些性能和內存的問題,咱們要在使用遞歸時須要更加慎重。但並非不能使用遞歸,遞歸仍是有不少其適宜的使用場景。
  • 一般咱們在客戶端的編程,也基本不會涉及到須要遞歸成千上萬的層級,因此在確保不會觸碰到這些閥值前,仍是能夠安心使用的。
  • 在客戶端編程的時候,所須要考慮的性能問題,更多的不在語言層面,所以,咱們有些是更須要關注寫出來的代碼的可讀性和可維護性。


相關文章
相關標籤/搜索