這個遞歸不太難

来源:https://www.cnblogs.com/wisewrong/archive/2020/07/03/13223224.html
-Advertisement-
Play Games

這個遞歸不太難 相信大家都知道什麼是遞歸,但在實際開發的時候用過多少次遞歸呢? 程式的世界有句話叫“人用迴圈,神用遞歸”,很多情況下我們都會優先使用迴圈而不是遞歸。我和幾個朋友聊過,他們的看法是:“相比迴圈而言,遞歸性能更差,而且更不可控,容易出問題。” 捕獲關鍵詞“問題”,啟動“解決”模式... ...


這個遞歸不太難

相信大家都知道什麼是遞歸,但在實際開發的時候用過多少次遞歸呢?

程式的世界有句話叫“人用迴圈,神用遞歸”,很多情況下我們都會優先使用迴圈而不是遞歸。我和幾個朋友聊過,他們的看法是:“相比迴圈而言,遞歸性能更差,而且更不可控,容易出問題。”

捕獲關鍵詞“問題”,啟動“解決”模式...


一、先熱個身

數學家高斯的在念小學的時候,他的數學老師出了一道題:對自然數1到100求和。高斯用首尾相加的辦法很快的算出了答案,不過我們這次要扮演高斯的同學,老老實實的從1加到100。

首先試一下迴圈的思路:

function sum(n) {
    let count = 0;
    for (let i = 1; i <= n; i++) {
    	count += i;
    }
    return count;
}
sum(100); // 5050

可以看到迴圈體只有一行代碼

count += i;

如果把 count 當成是一個函數的返回值,一個基本的遞歸邏輯就成型了:

function sum(n) {
    return n + sum(n-1);
}
// 這裡的 sum(n-1) 不能寫成 sum(n--)

但僅僅這樣是不夠的,還差一個關鍵代碼塊——開關

遞歸本身是一個無限迴圈,需要添加控制條件,讓程式在合適的時候退出迴圈

function sum(n) {
		if (n === 1) {
			return n;
		}
    return n + sum(n-1);
}
sum(100); // 5050

試試 sum(20000) 的結果是多少?


二、三大要素

上面的例子已經完成了一個簡單的遞歸,回頭總結一下,我們主要做了兩件事:

  1. 遞歸的拆解 —— 提取重覆的邏輯,縮小問題規模
  2. 遞歸的出口 —— 明確遞歸的結束條件

其實在這之前,我們還做了一件事,這件事很重要,但常常會被我們忽略掉:

  1. 遞歸的定義 —— 明確函數的功能

這三大要素是寫遞歸的必要條件,而其中的第三點,是寫好一個遞歸的必要條件。


以經典的樹組件作為案例,來印證一下這三要素。

樹組件的主要功能,就是將一個規範的具有層級的數組,渲染成樹列表

由此我們能明確這個函數的主要功能:接收一個數組入參,返回一個完整的樹組件

好像大概可能應該也許有點問題?

還是先來觀察數組吧。每個元素的 titlekey 是固定的,只是非葉子節點有 children。而 children 內部的結構也是 titlekey,加一個可能有的 children

這樣一來就能很容易的提取出重覆的邏輯:渲染樹節點,以 children 作為遞歸結束的判斷條件

為了更好的 UI 展示,還需要記錄樹節點的層級來計算當前節點的縮進。

我們只是在渲染樹節點,而不是渲染整個樹!

之所以能渲染出整個樹,是因為在函數執行的過程中,產生了很多的樹節點,這些樹節點組成了一個樹。

所以我們這個函數功能應該是:接收一個數組作為必要參數,和一個數值作為可選參數,並返回一個樹節點

重新捋一下思路,這個渲染樹組件的函數就清晰多了:

renderTree = (list, level = 1) => {
  return list.map(x => {
    const { children, id } = x || {};
    if (children) { // 遞歸的結束條件
      return (
        <TreeNode key={`${id}`} level={level}>
          {/* 調用自身,形成遞歸 */}
          {renderTreeNodes(children, level + 1)}
        </TreeNode>
      );
    }
    // 遞歸的出口
    return <TreeNode key={`${id}`} level={level}></TreeNode>
  })
}

三、遞歸優化 - 手動緩存

當我們去分析一個迴圈的時候,能清晰的看出這個函數的內部邏輯和執行次數。

而遞歸則不然,它的結構更加簡潔,但也增加了理解成本。比如下麵這個遞歸,你能一眼看出它的執行次數麽?

function Fibonacci (n) {
  return n <= 2 ? 1 : Fibonacci(n - 1) + Fibonacci(n - 2);
}

這就是著名的 Fibonacci 數列,我儘力避免拿它舉例,後來發現這個例子最為簡單直觀。

Fibonacci 數列:1, 1, 2, 3, 5, 8, 13, 21...

f(n) = f(n-1) + f(n-2)

我們試著執行一下 Fibonacci(10),並記錄該函數的調用次數

居然執行了 109 次?

其實回頭分析一下 Fibonacci 這個函數就能發現,執行的時候存在很多的重覆計算,比如計算 Fibonacci(5)

-- f(5)
| -- f(4)
| | -- f(3)
| | | -- f(2)
| | | -- f(1)
| | -- f(2)
| -- f(3)
| | -- f(2)
| | -- f(1)

葉子節點會被重覆計算,層次越深,計算的次數就越多

這裡有兩個優化思路,第一種是從當前的邏輯上,添加一層緩存,如果當前入參已經計算過,就直接返回結果。

// 緩存函數
function memozi(fn){
	const obj = {};
  return function(n){
    obj[n] = obj[n] || fn(n);
    return obj[n];
  }
}

const Fibonacci = memozi(function(n) {
  return n <= 2 ? 1 : Fibonacci(n - 1) + Fibonacci(n - 2);
})

只執行了10次!這已經達到了迴圈的執行次數。

這是一種空間換時間的思想,增加了額外的變數來記錄狀態,不過函數的實際調用次數並沒有減少,只是在 memozi 函數中做了判斷。

怎麼才能真正實現 O(n) 的時間複雜度呢?


四、遞歸優化 - 自下而上

上面所有的遞歸都是自上而下的遞歸,從 n 開始,一直計算到最小值。但在 Fibonacci 的例子中,如果需要計算 f(n),就需要先計算 f(n-1),所以一定會存在重覆計算的情況。

能不能從最小值開始計算呢?

在明確了 f(n) = f(n-1) + f(n-2) 規則的前提下,同時又知道 f(1) = 1, f(2) = 1,那就能推斷出 f(3) = 2,乃至 f(4), f(5)...

從而得到一個基本邏輯:

function foo(x = 1, y = 1) {
  return foo(y, x + y);
}

這裡的 xy 就是對應 n=1n=2 的時候的值,然後逐步計算出 n=3, n=4... 的值。

然後加入 n <= 2 的邊界,得到最終的遞歸函數:

function Fibonacci(n, x = 1, y = 1) {
  return n <= 2 ? y : Fibonacci(n - 1, y, x + y);
}

我們僅僅是稍微調整了函數的邏輯,就達到了 O(n) 的時間複雜度。這種自下而上的思想,其實是動態規劃的體現。


動態規劃是一種尋求最優解的數學方法,它經常會被當做一種演算法,但它其實並不像“二分查找”、“冒泡排序”一樣有著固定的範式。實際上動態規劃是一種方法論,它提供的是一種解決問題的思路。

簡單來說,動態規劃將一個複雜的問題分解成若幹個子問題,通過綜合子問題的最優解來得到原問題的最優解。而且動態規劃是自下而上求解,先計運算元問題,再由這些子問題計算父問題,直至求解出原問題的解,將時間複雜度優化為 O(n)

動態規劃有三個重要概念:

  1. 最優子結構
  2. 邊界

光看名詞就覺得有點似曾相識。沒錯,這就是前文提到的遞歸三要素中的“縮小問題規模”和“結束條件”。

而動態規劃的第三個概念,才是其核心所在:

  1. 狀態轉移方程

所謂狀態轉移方程,就是子問題與父問題之間的關係,或者說:如何用子問題推導出父問題

通常我們用遞歸都是自上而下,是先遇到了父問題,再去解決子問題。而動態規劃是先解決子問題,再通過狀態轉移方程求解出父問題,也就是自下而上。這種自下而上的遞歸也被稱為“遞推”


動態規劃的適用範圍,也是自下而上的適用範圍:

  1. 存在最優子結構

    作為整個過程的最優策略,應當具有這樣的特質:無論過去的狀態和決策如何,相對於前面的決策所形成的狀態而言,餘下的決策序列必然構成最優子策略。

    也就是說,一個最優策略的子策略也是最優的。

  2. 無後效性

    如果某階段狀態給定後,則在這個階段以後過程的發展不受這個階段以前各段狀態的影響。

    也就是說,計算f(i),不需要f(i+1)...f(n)的值,也不會修改f(1)...f(i-1)的值(1 < i < n)。

只要滿足這兩點,就可以用自下而上的思路來優化。

不過上面自下而上求解 Fibonacci 數列的函數,除了動態規劃之外,還使用了尾調用


五、遞歸優化 - 尾調用

函數在調用的時候,會在調用棧 (call stack) 中存有記錄,每一條記錄叫做一個調用幀 (call frame)。每調用一個函數,就向棧中 push 一條記錄,函數執行結束後依次向外彈出,直到清空調用棧。

function foo () { console.log('wise'); }
function bar () { foo(); }
function baz () { bar(); }

baz();

造成這種結果是因為每個函數在調用另一個函數的時候,並沒有 return 該調用,所以 JS 引擎會認為你還沒有執行完,會保留你的調用幀。

如果對上面的例子做如下修改:

function foo () { console.log('wise'); }
function bar () { return foo(); }
function baz () { return bar(); }

baz();

上面的改動其實是函數式編程中的一個重要概念,當一個函數執行時的最後一個步驟是返回另一個函數的調用,這就叫做尾調用(PTC)如果是在遞歸裡面使用,即在函數的末尾調用自身,就是尾遞歸


回頭來看最開始的求 1~n 之和的例子:

function sum(n) {
		if (n === 1) {
			return n;
		}
    return n + sum(n-1);
}
sum(100); // 5050

如果執行 sum(20000)棧溢出(爆棧):

Uncaught RangeError: Maximum call stack size exceeded

將這個遞歸升級為尾遞歸:

function sum(n, count = 0) {
		if (n === 1) {
			return count + n;
		}
    return sum(n-1, count+n);
}

現在調用棧中的調用幀始終只有一條,相對節省記憶體,這樣的遞歸就靠譜了許多


尾調用對遞歸的意義重大,但在實際運用的時候卻備受阻礙。

首先需要使用嚴格模式"use strict",其次主流瀏覽器只有 Safari 支持尾調用(上面的截圖就是在 Safari 截的),ChromeFirefox 甚至 node 都不支持尾調用優化。

Chrome V8 團隊給出的解釋是:

  1. 由於尾調用消除調用幀是隱式的,這意味著開發者可能很難發現一些無限迴圈的遞歸,如果它們恰好出現在末尾,因為這些遞歸的堆棧將不再溢出。
  2. 尾調用會丟失堆棧信息,這將導致執行流中的堆棧信息丟失,這將影響程式調試和錯誤收集。

不過即使如此,ChromeMozilla 依然認可尾調用優化所帶來的的性能提升,只是在引擎層面還沒有找到一個很安全可靠的方案來支持尾調用優化。微軟曾經提議從語法上來指定尾調用(類似於 return continue 這樣的特殊語句),不過最終方案仍在討論中。

雖然大部分的瀏覽器還不支持尾遞歸,但我們在開發的時候依然可以優先使用尾調用,畢竟運行的效果是一樣的,而一旦程式在支持尾遞歸的環境下運行,就會有更快的運行速度。更重要的是,當我們嘗試使用尾遞歸的時候,通常會自然而然的用到自下而上的思想。


六、小結

我們一般認為遞歸會比迴圈的性能要差,是因為函數調用本身是有開銷的。

但如果能實現尾遞歸,那麼遞歸的效率應該至少和迴圈一樣好。

對於不能使用尾調用的遞歸,即使寫成了迴圈的形式,也只是拿一個棧來模擬遞歸的過程。會帶來一定的效率提升,但也會造成代碼的冗餘。

關於迴圈和(優化之後的)遞歸之間的取捨,我覺得可以從以下幾個方面判斷:

  1. 遞歸的優點是代碼簡潔,邏輯清晰。缺點是調用幀導致的執行效率過低,而且不如迴圈容易理解;
  2. 遞歸和迴圈完全可以互換,但遞歸可以處理的問題,如果通過迴圈去解決,通常需要額外的低效處理;
  3. 如果邏輯相對簡單,使用迴圈也很簡潔,可以優先考慮迴圈;
  4. 在無法使用尾遞歸的環境,迴圈永遠是優先考慮的解決方案,但如果能接受遞歸的性能開銷,建議使用遞歸。

我認為遞歸其實是一種思維方式。所謂的“遞歸比迴圈慢”,指的是遞歸的各種實現。

掌握遞歸的意義不在於編碼本身,而在於知道如何編碼。

Premature optimization is the root of all evil.

過早優化是萬惡之源。

—— 《電腦編程藝術》Donald Knuth


參考資料:

《遞歸優化:尾調用和Memoization》—— LumiereXyloto

《尾調用優化》—— 阮一峰

《【譯】V8 團隊眼中的 ES6、ES7及未來》—— 奇舞團

《什麼是動態規劃(Dynamic Programming)?動態規劃的意義是什麼?》—— 苗華棟



您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 現在,Safari(技術預覽版106)和Firefox(版本78)的預覽版均支持新的CSS :is() 和 :where() 偽類。 Chrome的實施仍然落後。 使用 :is() 減少重覆 你可以使用 :is() 偽類來刪除選擇器列表中的重覆項。 /* BEFORE */ .embed .save ...
  • 接下來就好好介紹一下這七個開發調試工具,用起來是真的爽啊。建議收藏使用哦! Web瀏覽器中的開發工具 任何現代的 Web 瀏覽器都配有功能強大的工具來調試應用程式。 如使用控制台語句cconsole.log(),使用alert()的彈出視窗,還可以使用debugger語句暫停代碼執行,這些對於我們的 ...
  • 一、Why choose front-end 2012.07畢業後,進了一家游戲公司做運營策劃,寫過營銷方案、做過內容編輯、知道廣告投放和換量,還得兼職產品經理畫原型。 每天9.30-23.00以後,周末經常加班,像無頭蒼蠅一樣碰撞一年後,我沒有任何成就感,我開始思考自己每天做的是什麼,將來會做什麼 ...
  • 下麵是總結的css技巧,建議大家收藏,以後用的時候就不用到處查資料了。當然這些也不是所有的,大家如果有什麼好的css有趣樣式技巧也可以發出來哦 三角形 最常見的一種形狀了。切圖,不存在的。 /** 正三角 */ .triangle { width: 0; height: 0; border-styl ...
  • Levy曲線是將一條線段不停地分形成兩條長度相等且相互垂直的線段而生成的。Levy分形的最後很像一個英文字母C,所以也稱它為C曲線。 Levy曲線的生成示意如圖1所示。 圖1 Levy曲線的生成 1.Levy曲線 Levy曲線採用遞歸過程易於實現,編寫如下的HTML代碼。 <!DOCTYPE htm ...
  • 說一下我個人理解跟建議,僅供參考 第一步,先看一本前端入門的書+《Javascript權威指南》:前端入門的書隨便哪本都行,主要是瞭解一下前端HTML + CSS + Javascript大致是怎麼回事,有個概念,腦海中留個大致輪廓就好,非要推薦的話,可以看看《HTML5權威指南》,Apress的書 ...
  • 1.內嵌標簽: iframe: src:要顯示的網路資源路徑 可以是本地資源(相對路徑)也可以是網路資源(url) 註:預設當前頁面打開及載入src指向的資源 width:設置顯示區域寬度 height:設置顯示區域高度 name:設置內嵌區域的名字,結合超鏈接標簽的target屬性使用 註:在當前 ...
  • 開發環境配置 一般情況下開發環境是會跨域的,所以我們只需要在跨域的位置配置即可。進入config/index.js,在proxyTable對象裡面添加代碼,如下 '/api': { target: 'http://localhost:8082', //開發環境,設置調用介面功能變數名稱和埠號別忘了加htt ...
一周排行
    -Advertisement-
    Play Games
  • GoF之工廠模式 @目錄GoF之工廠模式每博一文案1. 簡單說明“23種設計模式”1.2 介紹工廠模式的三種形態1.3 簡單工廠模式(靜態工廠模式)1.3.1 簡單工廠模式的優缺點:1.4 工廠方法模式1.4.1 工廠方法模式的優缺點:1.5 抽象工廠模式1.6 抽象工廠模式的優缺點:2. 總結:3 ...
  • 新改進提供的Taurus Rpc 功能,可以簡化微服務間的調用,同時可以不用再手動輸出模塊名稱,或調用路徑,包括負載均衡,這一切,由框架實現並提供了。新的Taurus Rpc 功能,將使得服務間的調用,更加輕鬆、簡約、高效。 ...
  • 本章將和大家分享ES的數據同步方案和ES集群相關知識。廢話不多說,下麵我們直接進入主題。 一、ES數據同步 1、數據同步問題 Elasticsearch中的酒店數據來自於mysql資料庫,因此mysql數據發生改變時,Elasticsearch也必須跟著改變,這個就是Elasticsearch與my ...
  • 引言 在我們之前的文章中介紹過使用Bogus生成模擬測試數據,今天來講解一下功能更加強大自動生成測試數據的工具的庫"AutoFixture"。 什麼是AutoFixture? AutoFixture 是一個針對 .NET 的開源庫,旨在最大程度地減少單元測試中的“安排(Arrange)”階段,以提高 ...
  • 經過前面幾個部分學習,相信學過的同學已經能夠掌握 .NET Emit 這種中間語言,並能使得它來編寫一些應用,以提高程式的性能。隨著 IL 指令篇的結束,本系列也已經接近尾聲,在這接近結束的最後,會提供幾個可供直接使用的示例,以供大伙分析或使用在項目中。 ...
  • 當從不同來源導入Excel數據時,可能存在重覆的記錄。為了確保數據的準確性,通常需要刪除這些重覆的行。手動查找並刪除可能會非常耗費時間,而通過編程腳本則可以實現在短時間內處理大量數據。本文將提供一個使用C# 快速查找並刪除Excel重覆項的免費解決方案。 以下是實現步驟: 1. 首先安裝免費.NET ...
  • C++ 異常處理 C++ 異常處理機制允許程式在運行時處理錯誤或意外情況。它提供了捕獲和處理錯誤的一種結構化方式,使程式更加健壯和可靠。 異常處理的基本概念: 異常: 程式在運行時發生的錯誤或意外情況。 拋出異常: 使用 throw 關鍵字將異常傳遞給調用堆棧。 捕獲異常: 使用 try-catch ...
  • 優秀且經驗豐富的Java開發人員的特征之一是對API的廣泛瞭解,包括JDK和第三方庫。 我花了很多時間來學習API,尤其是在閱讀了Effective Java 3rd Edition之後 ,Joshua Bloch建議在Java 3rd Edition中使用現有的API進行開發,而不是為常見的東西編 ...
  • 框架 · 使用laravel框架,原因:tp的框架路由和orm沒有laravel好用 · 使用強制路由,方便介面多時,分多版本,分文件夾等操作 介面 · 介面開發註意欄位類型,欄位是int,查詢成功失敗都要返回int(對接java等強類型語言方便) · 查詢介面用GET、其他用POST 代碼 · 所 ...
  • 正文 下午找企業的人去鎮上做貸後。 車上聽同事跟那個司機對罵,火星子都快出來了。司機跟那同事更熟一些,連我在內一共就三個人,同事那一手指桑罵槐給我都聽愣了。司機也是老社會人了,馬上聽出來了,為那個無辜的企業經辦人辯護,實際上是為自己辯護。 “這個事情你不能怪企業。”“但他們總不能讓銀行的人全權負責, ...