递归循环一个无限极数组_理解递归、尾调用优化和蹦床函数优化
想要理解遞歸,您必須先理解遞歸。開個玩笑罷了, 遞歸 是一種編程技巧,它可以讓函數在不使用 for 或 while 的情況下,使用一個調用自身的函數來實現循環(huán)。
例子 1:整數總和
例如,假設我們想要求從 1 到 i 的整數的和,目標是得到以下結果:
sumIntegers(1); // 1sumIntegers(3); // 1 + 2 + 3 = 6sumIntegers(5); // 1 + 2 + 3 + 4 + 5 = 15復制代碼這是不用遞歸來實現的代碼:
// 循環(huán)const sumIntegers = i => { let sum = 0; // 初始化 do { // 重復 sum += i; // 操作 i --; // 下一步 } while(i > 0); // 循環(huán)停止的條件 return sum;}復制代碼用遞歸來實現的代碼如下:
// 循環(huán)const sumIntegers = (i, sum = 0) => { // 初始化 if (i === 0) { // return sum; // 結果 } return sumIntegers( // 重復 i - 1, // 下一步 sum + i // 操作 );}// 甚至實現得更簡單const sumIntegers = i => { if (i === 0) { return i; } return i + sumIntegers(i - 1);}復制代碼這就是遞歸的基礎。
注意,遞歸版本中是沒有 中間變量 的。它不使用 for 或者 do...while 。由此可見,它是 聲明式 的。
我還可以告訴您的是,事實上遞歸版本比循環(huán)版本 慢 —— 至少在 JavaScript 中是這樣。但是遞歸解決的不是性能問題,而是可表達性的問題。
例子 2:數組元素之和
讓我們嘗試一個稍微復雜一點的例子,一個將數組中的所有數字相加的函數。
sumArrayItems([]); // 0sumArrayItems([1, 1, 1]); // 1 + 1 + 1 = 3sumArrayItems([3, 6, 1]); // 3 + 6 + 1 = 10// 循環(huán)const sumArrayItems = list => { let result = 0; for (var i = 0; i++; i <= list.length) { result += list[i]; } return result;}復制代碼正如您所看到的,循環(huán)版本是命令性的:您需要確切地告訴程序要 做什么 才能得到所有數字的和。下面是遞歸的版本:
// 遞歸const sumArrayItems = list => { switch(list.length) { case 0: return 0; // 空數組的和為 0 case 1: return list[0]; // 一個元素的數組之和,就是這個唯一的元素。#顯而易見 default: return list[0] + sumArrayItems(list.slice(1)); // 否則,數組的和就是數組的第一個元素 + 其余元素的和。 }}復制代碼遞歸版本中,我們并沒有告訴程序要 做什么 ,而是引入了簡單的規(guī)則來 定義 數組中所有數字的和是多少。這可比循環(huán)版本有意思多了。
如果您是函數式編程的愛好者,您可能更喜歡 Array.reduce() 版本:
// reduce 版本const sumArrayItems = list => list.reduce((sum, item) => sum + item, 0);復制代碼這種寫法更短,而且更直觀。但這是另一篇文章的主題了。
例子 3:快速排序
現在,我們來看另一個例子。這次的更復雜一點: 快速排序 。快速排序是對數組排序最快的算法之一。
快速排序的排序過程:獲取數組的第一個元素,然后將其余的元素分成比第一個元素小的數組和比第一個元素大的數組。然后,再將獲取的第一個元素放置在這兩個數組之間,并且對每一個分隔的數組重復這個操作。
要用遞歸實現它,我們只需要遵循這個定義:
const quickSort = array => { if (array.length <= 1) { return array; // 一個或更少元素的數組是已經排好序的 } const [first, ...rest] = array; // 然后把所有比第一個元素大和比第一個元素小的元素分開 const smaller = [], bigger = []; for (var i = 0; i < rest.length; i++) { const value = rest[i]; if (value < first) { // 小的 smaller.push(value); } else { // 大的 bigger.push(value); } } // 排序后的數組為 return [ ...quickSort(smaller), // 所有小于等于第一個的元素的排序數組 first, // 第一個元素 ...quickSort(bigger), // 所有大于第一個的元素的排序數組 ];};復制代碼簡單,優(yōu)雅和聲明式,通過閱讀代碼,我們可以讀懂快速排序的定義。
現在想象一下用循環(huán)來實現它。我先讓您想一想,您可以在本文的最后找到答案。
例子 4:取得一棵樹的葉節(jié)點
當我們需要處理 遞歸數據結構 (如樹)時,遞歸真的很有用。樹是具有某些值和 孩子 屬性的對象;孩子們又包含著其他的樹或葉子(葉子指的是沒有孩子的對象)。例如:
const tree = { name: 'root', children: [ { name: 'subtree1', children: [ { name: 'child1' }, { name: 'child2' }, ], }, { name: 'child3' }, { name: 'subtree2', children: [ { name: 'child1', children: [ { name: 'child4' }, { name: 'child5' }, ], }, { name: 'child6' } ] } ]};復制代碼假設我需要一個函數,該函數接受一棵樹,返回一個葉子(沒有孩子節(jié)點的對象)數組。預期結果是:
getLeaves(tree);/*[ { name: 'child1' }, { name: 'child2' }, { name: 'child3' }, { name: 'child4' }, { name: 'child5' }, { name: 'child6' },]*/復制代碼我們先用老方法試試,不用遞歸。
// 對于沒有嵌套的樹來說,這是小菜一碟const getChildren = tree => tree.children;// 對于一層的遞歸來說,它會變成:const getChildren = tree => { const { children } = tree; let result = []; for (var i = 0; i++; i < children.length - 1) { const child = children[i]; if (child.children) { for (var j = 0; j++; j < child.children.length - 1) { const grandChild = child.children[j]; result.push(grandChild); } } else { result.push(child); } } return result;}// 對于兩層:const getChildren = tree => { const { children } = tree; let result = []; for (var i = 0; i++; i < children.length - 1) { const child = children[i]; if (child.children) { for (var j = 0; j++; j < child.children.length - 1) { const grandChild = child.children[j]; if (grandChild.children) { for (var k = 0; k++; j < grandChild.children.length - 1) { const grandGrandChild = grandChild.children[j]; result.push(grandGrandChild); } } else { result.push(grandChild); } } } else { result.push(child); } } return result;}復制代碼呃,這已經很令人頭疼了,而且這只是兩層遞歸。您想想看如果遞歸到第三層、第四層、第十層會有多糟糕。
而且這僅僅是求一些葉子;如果您想要將樹轉換為一個數組并返回,又該怎么辦?更麻煩的是,如果您想使用這個循環(huán)版本,您必須確定您想要支持的最大深度。
現在看看遞歸版本:
const getLeaves = tree => { if (!tree.children) { // 如果一棵樹沒有孩子,它的葉子就是樹本身。 return tree; } return tree.children // 否則它的葉子就是所有子節(jié)點的葉子。 .map(getLeaves) // 在這一步,我們可以嵌套數組 ([child1, [grandChild1, grandChild2], ...]) .reduce((acc, item) => acc.concat(item), []); // 所以我們用 concat 來連接鋪平數組 [1,2,3].concat(4) => [1,2,3,4] 以及 [1,2,3].concat([4]) => [1,2,3,4]}復制代碼僅此而已,而且它適用于任何層級的遞歸。
JavaScript 中遞歸的缺點
遺憾的是,遞歸函數有一個很大的缺點:該死的越界錯誤。
Uncaught RangeError: Maximum call stack size exceeded復制代碼與許多語言一樣,JavaScript 會跟蹤 堆棧 中的所有函數調用。這個堆棧大小有一個最大值,一旦超過這個最大值,就會導致 RangeError 。在循環(huán)嵌套調用中,一旦根函數完成,堆棧就會被清除。但是在使用遞歸時,在所有其他的調用都被解析之前,第一個函數的調用不會結束。所以如果我們調用太多,就會得到這個錯誤。
為了解決堆棧大小問題,您可以嘗試確保計算不會接近堆棧大小限制。這個限制取決于平臺,這個值似乎都在 10,000 左右。所以,我們仍然可以在 JavaScript 中使用遞歸,只是需要小心謹慎。
如果您不能限制遞歸的大小,這里有兩個解決方案:尾調用優(yōu)化和蹦床函數優(yōu)化。
尾調用優(yōu)化
所有嚴重依賴遞歸的語言都會使用這種優(yōu)化,比如 Haskell。JavaScript 的尾調用優(yōu)化的支持是在 Node.js v6 中實現的。
尾調用 是指一個函數的最后一條語句是對另一個函數的調用。優(yōu)化是在于讓尾部調用函數替換堆棧中的父函數。這樣的話,遞歸函數就不會增加堆棧。注意,要使其工作,遞歸調用必須是遞歸函數的 最后一條語句 。所以 return loop(..); 是一次有效的尾調用優(yōu)化,但是 return loop() + v; 不是。
讓我們把求和的例子用尾調用優(yōu)化一下:
const sum = (array, result = 0) => { if (!array.length) { return result; } const [first, ...rest] = array; return sum(rest, first + result);}復制代碼這使運行時引擎可以避免調用堆棧錯誤。但是不幸的是,它在 Node.js 中已經不再有效,因為 在 Node 8 中已經刪除了對尾調用優(yōu)化的支持 。也許將來它會支持,但到目前為止,是不存在的。
蹦床函數優(yōu)化
另一種解決方法叫做 蹦床函數 。其思想是使用延遲計算稍后執(zhí)行遞歸調用,每次執(zhí)行一個遞歸。我們來看一個例子:
const sum = (array) => { const loop = (array, result = 0) => () => { // 代碼不是立即執(zhí)行的,而是返回一個稍后執(zhí)行的函數:它是惰性的 if (!array.length) { return result; } const [first, ...rest] = array; return loop(rest, first + result); }; // 當我們執(zhí)行這個循環(huán)時,我們得到的只是一個執(zhí)行第一步的函數,所以沒有遞歸。 let recursion = loop(array); // 只要我們得到另一個函數,遞歸過程中就還有其他步驟 while (typeof recursion === 'function') { recursion = recursion(); // 我們執(zhí)行現在這一步,然后重新得到下一個 } // 一旦執(zhí)行完畢,返回最后一個遞歸的結果 return recursion;}復制代碼這是可行的,但是這種方法也有一個很大的缺點:它很 慢 。在每次遞歸時,都會創(chuàng)建一個新函數,在大型遞歸時,就會產生大量的函數。這就很令人心煩。的確,我們不會得到一個錯誤,但這會減慢(甚至凍結)函數運行。
從遞歸到迭代
如果最終出現性能或者最大調用堆棧大小超出的問題,您仍然可以將遞歸版本轉換為迭代版本。但不幸的是,正如您將看到的,迭代版本通常更復雜。
讓我們以 getLeaves 的實現為例,并將遞歸邏輯轉換為迭代。我知道結果,我以前試過,很糟糕。現在我們再試一次,但這次是遞歸的。
// 遞歸版本const getLeaves = tree => { if (!tree.children) { // 如果一棵樹沒有孩子,它的葉子就是樹本身。 return tree; } return tree.children // 否則它的葉子就是所有子節(jié)點的葉子。 .map(getLeaves) // 在這一步,我們可以嵌套數組 ([child1, [grandChild1, grandChild2], ...]) .reduce((acc, item) => acc.concat(item), []); // 所以我們用 concat 來連接鋪平數組 [1,2,3].concat(4) => [1,2,3,4] 以及 [1,2,3].concat([4]) => [1,2,3,4]}復制代碼首先,我們需要重構遞歸函數以獲取累加器參數,該參數將用于構造結果。它寫起來甚至會更短:
const getLeaves = (tree, result = []) => { if (!tree.children) { return [...result, tree]; } return tree.children .reduce((acc, subTree) => getLeaves(subTree, acc), result);}復制代碼然后,這里技巧就是將遞歸調用展開到剩余計算的堆棧中。 在遞歸外部 初始化結果累加器,并將進入遞歸函數的參數推入堆棧。最后,將堆疊的運算解堆疊,得到最后的結果:
const getLeaves = tree => { const stack = [tree]; // 將初始樹添加到堆棧中 const result = []; // 初始化結果累加器 while (stack.length) { // 只要堆棧中有一個項 const currentTree = stack.pop(); // 得到堆棧中的第一項 if (!currentTree.children) { // 如果一棵樹沒有孩子,它的葉子就是樹本身。 result.unshift(currentTree); // 所以把它加到結果里 continue; } stack.push(...currentTree.children);// 否則,將所有子元素添加到堆棧中,以便在下一次迭代中處理 } return result;}復制代碼這好像有點難,所以讓我們用 quickSort 再次做一次。這是遞歸版本:
const quickSort = array => { if (array.length <= 1) { return array; // 一個或更少元素的數組是已經排好序的 } const [first, ...rest] = array; // 然后把所有比第一個元素大和比第一個元素小的元素分開 const smaller = [], bigger = []; for (var i = 0; i < rest.length; i++) { const value = rest[i]; if (value < first) { // 小的 smaller.push(value); } else { // 大的 bigger.push(value); } } // 排序后的數組為 return [ ...quickSort(smaller), // 所有小于等于第一個的元素的排序數組 first, // 第一個元素 ...quickSort(bigger), // 所有大于第一個的元素的排序數組 ];};復制代碼const quickSort = (array, result = []) => { if (array.length <= 1) { return result.concat(array); // 一個或更少元素的數組是已經排好序的 } const [first, ...rest] = array; // 然后把所有比第一個元素大和比第一個元素小的元素分開 const smaller = [], bigger = []; for (var i = 0; i < rest.length; i++) { const value = rest[i]; if (value < first) { // 小的 smaller.push(value); } else { // 大的 bigger.push(value); } } // 排序后的數組為 return [ ...quickSort(smaller, result), // 所有小于等于第一個的元素的排序數組 first, // 第一個元素 ...quickSort(bigger, result), // 所有大于第一個的元素的排序數組 ];};復制代碼然后使用堆棧來存儲數組進行排序,在每個循環(huán)中應用前面的遞歸邏輯將其解堆棧。
const quickSort = (array) => { const stack = [array]; // 我們創(chuàng)建一個數組堆棧進行排序 const sorted = []; //我們遍歷堆棧直到它被清空 while (stack.length) { const currentArray = stack.pop(); // 我們取堆棧中的最后一個數組 if (currentArray.length == 1) { // 如果只有一個元素,那么我們把它加到排序中 sorted.push(currentArray[0]); continue; } const [first, ...rest] = currentArray; // 否則我們取數組中的第一個元素 //然后把所有比第一個元素大和比第一個元素小的元素分開 const smaller = [], bigger = []; for (var i = 0; i < rest.length; i++) { const value = rest[i]; if (value < first) { // 小的 smaller.push(value); } else { // 大的 bigger.push(value); } } if (bigger.length) { stack.push(bigger); // 我們先向堆棧中添加更大的元素來排序 } stack.push([first]); // 我們在堆棧中添加 first 元素,當它被解堆時,更大的元素就已經被排序了 if (smaller.length) { stack.push(smaller); // 最后,我們將更小的元素添加到堆棧中來排序 } } return sorted;}復制代碼瞧!我們就這樣有了快速排序的迭代版本。但是記住,這只是一個優(yōu)化,
不成熟的優(yōu)化是萬惡之源 —— 唐納德·高德納
因此,僅在您需要時再這樣做。
結論
我喜歡遞歸。它比迭代版本更具聲明式,并且通常情況下代碼也更短。遞歸可以輕松地實現復雜的邏輯。盡管存在堆棧溢出問題,但在不濫用的前提下,在 JavaScript 中使用它是沒問題的。并且如果有需要,可以將遞歸函數重構為迭代版本。
總結
以上是生活随笔為你收集整理的递归循环一个无限极数组_理解递归、尾调用优化和蹦床函数优化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 刺激战场有内涵的名字
- 下一篇: 走线画直线_画画教程,只用1支铅笔,教你