【遞歸題】正確的打開方式,面試官聽了都說精辟

来源:https://www.cnblogs.com/MonsterJ/archive/2020/06/17/13154306.html
-Advertisement-
Play Games

前言 遞歸,是一個非常重要的概念,也是面試中非常喜歡考的。因為它不但能考察一個程式員的演算法功底,還能很好的考察對時間空間複雜度的理解和分析。 本文只講一題,也是幾乎所有演算法書講遞歸的第一題,但力爭講出花來,在這裡分享四點不一樣的角度,讓你有不同的收穫。 時空複雜度的詳細分析 識別並簡化遞歸過程中的重 ...


前言

遞歸,是一個非常重要的概念,也是面試中非常喜歡考的。因為它不但能考察一個程式員的演算法功底,還能很好的考察對時間空間複雜度的理解和分析。

本文只講一題,也是幾乎所有演算法書講遞歸的第一題,但力爭講出花來,在這裡分享四點不一樣的角度,讓你有不同的收穫。

  • 時空複雜度的詳細分析
  • 識別並簡化遞歸過程中的重覆運算
  • 披上羊皮的狼
  • 適當炫技助我拿到第一份工作

演算法思路

大家都知道,一個方法自己調用自己就是遞歸,沒錯,但這隻是理解遞歸的最表層的理解。

那麼遞歸的實質是什麼?

答:遞歸的實質是能夠把一個大問題分解成比它小點的問題,然後我們拿到了小問題的解,就可以用小問題的解去構造大問題的解。

那小問題的解是如何得到的?

答:用再小一號的問題的解構造出來的,小到不能再小的時候就是到了零號問題的時候,也就是 base case 了。

在這裡插入圖片描述

那麼總結一下遞歸的三個步驟:

Base case:就是遞歸的零號問題,也是遞歸的終點,走到最小的那個問題,能夠直接給出結果,不必再往下走了,否則,就會成死迴圈;

拆解:每一層的問題都要比上一層的小,不斷縮小問題的 size,才能從大到小到 base case;

組合:得到了小問題的解,還要知道如何才能構造出大問題的解。

所以每道遞歸題,我們按照這三個步驟來分析,把這三個問題搞清楚,代碼就很容易寫了。

斐波那契數列

這題雖是老生常談了,但相信我這裡分享的一定會讓你有其他收穫。

題目描述

斐波那契數列是一位義大利的數學家,他閑著沒事去研究兔子繁殖的過程,研究著就發現,可以寫成這麼一個序列:1,1,2,3,5,8,13,21…也就是每個數等於它前兩個數之和。那麼給你第 n 個數,問 F(n) 是多少。

解析

用數學公式表示很簡單:

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

代碼也很簡單,用我們剛總結的三步:

  • base case: f(0) = 0, f(1) = 1.
  • 分解:f(n-1), f(n-2)
  • 組合:f(n) = f(n-1) + f(n-2)

那麼寫出來就是:

class Solution {
    public int fib(int N) {
        if (N == 0) {
            return 0;
        } else if (N == 1) {
            return 1;
        }
        return fib(N-1) + fib(N-2);
    }
}

但是這種解法 Leetcode 給出的速度經驗只比 15% 的答案快,因為,它的時間複雜度實在是太高了!

在這裡插入圖片描述

過程分析

那這就是我想分享的第一點,如何去分析遞歸的過程。

首先我們把這顆 Recursion Tree 畫出來,比如我們把 F(5) 的遞歸樹畫出來:

在這裡插入圖片描述

那實際的執行路線是怎樣的?

首先是沿著最左邊這條線一路到底:F(5) → F(4) → F(3) → F(2) → F(1),好了終於有個 base case 可以返回 F(1) = 1 了,然後返回到 F(2) 這一層,再往下走,就是 F(0),又觸底反彈,回到 F(2),得到 F(2) = 1+0 =1 的結果,把這個結果返回給 F(3),然後再到 F(1),拿到結果後再返回 F(3) 得到 F(3) = 左 + 右 = 2,再把這個結果返上去...

這種方式本質上是由我們電腦的馮諾伊曼體系造就的,目前一個 CPU 一個核在某一時間只能執行一條指令,所以不能 F(3) 和 F(4) 一起進行了,一定是先執行了 F(4) (本代碼把 fib(N-1) 放在前面),再去執行 F(3).

我們在 IDE 里 debug 就可以看到棧裡面的情況:這裡確實是先走的最左邊這條線路,一共有 5 層,然後再一層層往上返回。

在這裡插入圖片描述

時間複雜度分析

如何評價一個演算法的好壞?

很多問題都有多種解法,畢竟條條大路通羅馬。但如何評價每種方法的優劣,我們一般是用大 O 表達式來衡量時間和空間複雜度。

時間複雜度:隨著自變數的增長,所需時間的增長情況。

這裡大 O 表示的是一個演算法在 worst case 的表現情況,這就是我們最關心的,不然春運搶車票的時候系統 hold 不住了,你跟我說這個演算法很優秀?

當然還有其他衡量時間和空間的方式,比如

Theta: 描述的是 tight bound Omega(n):
這個描述的是 best case,最好的情況,沒啥意義

這也給我們了些許啟發,不要說你平時表現有多好,沒有意義;面試衡量的是你在 worst case 的水平;不要說面試沒有發揮出你的真實水平,扎心的是那就是我們的真實水平。

那對於這個題來說,時間複雜度是多少呢?

答:因為我們每個節點都走了一遍,所以是把所有節點的時間加起來就是總的時間。

在這裡,我們在每個節點上做的事情就是相加求和,是 O(1) 的操作,且每個節點的時間都是一樣的,所以:

總時間 = 節點個數 * 每個節點的時間

那就變成了求節點個數的數學題:

在 N = 5 時,
在這裡插入圖片描述

最上面一層有1個節點,
第二層 2 個,
第三層 4 個,
第四層 8 個,
第五層 16 個,如果填滿的話,想象成一顆很大的樹:)

這裡就不要在意這個沒填滿的地方了,肯定是會有差這麼幾個 node,但是大 O 表達的時間複雜度我們剛說過了,求的是 worst case.

那麼總的節點數就是:
1 + 2 + 4 + 8 + 16

這就是一個等比數列求和了,當然你可以用數學公式來算,但還有個小技巧可以幫助你快速計算:

其實前面每一層的節點相加起來的個數都不會超過最後一層的節點的個數,總的節點數最多也就是最後一層節點數 * 2,然後在大 O 的時間複雜度裡面常數項也是無所謂的,所以這個總的時間複雜度就是:

最後一層節點的個數:2^n

空間複雜度分析

一般書上寫的空間複雜度是指:

演算法運行期間所需占用的所有記憶體空間

但是在公司里大家常用的,也是面試時問的指的是
Auxiliary space complexity:

運行演算法時所需占用的額外空間。

舉例說明區別:比如結果讓你輸出一個長度為 n 的數組,那麼這 O(n) 的空間是不算在演算法的空間複雜度里的,因為這個空間是跑不掉的,不是取決於你的演算法的。

那空間複雜度怎麼分析呢?

我們剛剛說到了馮諾伊曼體系,從圖中也很容易看出來,是最左邊這條路線占用 stack 的空間最多,一直不斷的壓棧,也就是從 5 到 4 到 3 到 2 一直壓到 1,才到 base case 返回,每個節點占用的空間複雜度是 O(1),所以加起來總的空間複雜度就是 O(n).

優化演算法

那我們就想了,為什麼這麼一個簡簡單單的運算竟然要指數級的時間複雜度?到底是為什麼讓時間如此之大。

那也不難看出來,在這棵 Recursion Tree 里,有太多的重覆計算了。

比如一個 F(2) 在這裡都被計算了 3 次,F(3) 被計算了 2 次,每次還都要再重新算,這不就是狗熊掰棒子嗎,真的是一把辛酸淚。

那找到了原因之後,為瞭解決這種重覆計算,電腦採用的方法其實和我們人類是一樣的:記筆記。

對很多職業來說,比如醫生、律師、以及我們工程師,為什麼越老經驗值錢?因為我們見得多積累的多,下次再遇到類似的問題時,能夠很快的給出解決方案,哪怕一時解決不了,也避免了一些盲目的試錯,我們會站在過去的高度不斷進步,而不是每次都從零開始。

回到優化演算法上來,那電腦如何記筆記呢?

我們要想求 F(n),無非也就是要
記錄 F(0) ~ F(n-1) 的值,
那選取一個合適的數據結構來存儲就好了。

那這裡很明顯了,用一個數組來存:

Index 0 1 2 3 4 5
F(n) 0 1 1 2 3 5

那有了這個 cheat sheet,我們就可以從前到後得到結果了,這樣每一個點就只算了一遍,用一個 for loop 就可以寫出來,代碼也非常簡單。

class Solution {
    public int fib(int N) {
        if (N == 0) {
            return 0;
        }
        if (N== 1) {
            return 1;
        }
        int[] notes = new int[N+1];
        notes[0] = 0;
        notes[1] = 1;
        for(int i = 2; i <= N; i++) {
            notes[i] = notes[i-1] + notes[i-2];
        }
        return notes[N];
    }
}

這個速度就是 100% 了~

在這裡插入圖片描述

但是我們可以看到,空間應該還有優化的餘地。

那仔細想想,其實我們記筆記的時候需要記錄這麼多嗎?需要從幼兒園到小學到初中到高中的筆記都留著嗎?

那其實每項的計算只取決於它前面的兩項,所以只用保留這兩個就好了。

那我們可以用一個長度為 2 的數組來計算,或者就用 2 個變數。

更新代碼:

class Solution {
    public int fib(int N) {
        int a = 0;
        int b = 1;
        if(N == 0) {
            return a;
        }
        if(N == 1) {
            return b;
        }
        for(int i = 2; i <= N; i++) {
            int tmp = a + b;
            a = b;
            b = tmp;
        }
        return b;
    }
}

這樣我們就把空間複雜度優化到了 O(1),時間複雜度和用數組記錄一樣都是 O(n).

這種方法其實就是動態規劃 Dynamic Programming,寫出來的代碼非常簡單。

那我們比較一下 Recursion 和 DP:

Recursion 是從大到小,層層分解,直到 base case 分解不了了再組合返回上去;
DP 是從小到大,記好筆記,不斷進步。
也就是 Recursion + Cache = DP

如何記錄這個筆記,如何高效的記筆記,這是 DP 的難點。

有人說 DP 是拿空間換時間,但我不這麼認為,這道題就是一個很好的例證。

在用遞歸解題時,我們可以看到,空間是 O(n) 在棧上的,但是用 DP 我們可以把空間優化到 O(1),DP 可以做到時間空間的雙重優化。

其實呢,斐波那契數列在現實生活中也有很多應用。

比如在我司以及很多大公司里,每個任務要給分值,1分表示大概需要花1天時間完成,然後分值只有>1,2,3,5,8這5種,(如果有大於8分的任務,就需要把它 break down 成8分以內的,以便大家在>兩周內能完成。)
因為任務是永遠做不完的而每個人的時間是有限的,所以每次小組會開會,挑出最重要的任務讓大家來>做,然後每個人根據自己的 available 的天數去 pick up 相應的任務。

那有同學可能會想,這題這麼簡單,這都 2020 年了,面試還會考麽?

答:真的會

只是不能以這麼直白的方式給你了。

比如很有名的爬樓梯問題:

一個 N 階的樓梯,每次能走一層或者兩層,問一共有多少種走法。

這個題這麼想:

站在當前位置,只能是從前一層,或者前兩層上來的,所以 f(n) = f(n-1) + f(n-2).

這題是我當年面試時真實被問的,那時我還在寫 python,為了炫技,還用了lambda function:

f = lambda n: 1 if n in (1, 2) else f(n-1) + f(n-2)

遞歸的寫法時間複雜度太高,所以又寫了一個 for loop 的版本

def fib(n)
  a, b = 1, 1
  for i in range(n-1):
    a, b = b, a+b
  return a 

然後還寫了個 caching 的方法:

def cache(f):
    memo = {}
    def helper(x):
        if x not in memo:
            memo[x] = f(x)
        return memo[x]
    return helper
@cache
def fibR(n):
    if n==1 or n==2: return 1
    return fibR(n-1) + fibR(n-2)

還順便和麵試官聊了下 tail recursion:

tail recursion 尾遞歸:就是遞歸的這句話是整個方法的最後一句話。

那這個有什麼特別之處呢?

尾遞歸的特點就是我們可以很容易的把它轉成 iterative 的寫法,當然有些智能的編譯器會自動幫我們做了(不是說顯性的轉化,而是在運行時按照 iterative 的方式去運行,實際消耗的空間是O(1))

那為什麼呢?

因為回來的時候不需要 backtrack,遞歸這裡就是最後一步了,不需要再往上一層返值。

def fib(n, a=0, b=1):
    if n==0: return a
      if n==1: return b
    return fib(n-1, b, a+b)

最終,拿出了我的殺手鐧:lambda and reduce

fibRe = lambda n: reduce(lambda x, n: [x[1], x[0]+x[1]], range(n), [0, 1])

看到面試官滿意的表情後,就開始繼續深入的聊了...

所以說,不要以為它簡單,同一道題可以用七八種方法來解,分析好每個方法的優缺點,引申到你可以引申的地方,展示自己扎實的基本功,這場面試其實就是你 show off 的機會,這樣才能騙過面試官啊~lol

這就是本文的所有內容了,不知道大家看完感受如何?留言告訴我你的感受吧~

點擊在看,鼓勵下我啊!

瞎寫評論,顯得我很紅啊!

轉發轉發轉發,愛她,就送給她!

還想跟我看更多數據結構和演算法題的小伙伴們,記得關註我,我是程式零世界,演算法就這麼回事。

作者:小齊本齊
file


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

-Advertisement-
Play Games
更多相關文章
  • 最近也是在後臺收到很多小伙伴私信問我線程和線程池這一塊的問題,說自己在面試的時候老是被問到這一塊的問題,被問的很頭疼。前幾天看到後幫幾個小伙伴解決了問題,但是問的人有點多我一個個回答也回答不過來,乾脆花了一個上午時間寫了這篇文章分享給大家。話不多說,滿滿的乾貨都在下麵了! ...
  • 一、背景 隨著時間和業務的發展,資料庫中的數據量增長是不可控的,庫和表中的數據會越來越大,隨之帶來的是更高的磁碟、IO、系統開銷,甚至性能上的瓶頸,而一臺服務的資源終究是有限的,因此需要對資料庫和表進行拆分,從而更好的提供數據服務。 當用戶表達到千萬級別,在做很多操作的時候都會很吃力,所以當數據增長 ...
  • 1.數組基礎 數組的定義: 數組是相同類型數據的有序集合。數組描述的是相同類型的若幹數據,按照一定的先後次序排列組合而成的。 其中每一個數據成為元素,每個元素可以通過索引來訪問他們。 數組的三個基本特點: 1.長度確定,數組一旦被創建,它的大小就是不可以改變的。 2.其元素必須是相同類型,不允許出現 ...
  • 前言 本文的文字及圖片來源於網路,僅供學習、交流使用,不具有任何商業用途,版權歸原作者所有,如有問題請及時聯繫我們以作處理。 Matplotlib 是一個 Python 的 2D繪圖庫(當然也可以畫三維形式的圖形哦),它以各種硬拷貝格式和跨平臺的互動式環境生成出版質量級別的圖形 。通過 Matplo ...
  • Collection是一種關於集合的類 在Collection類中共有的方法有: add(E e):添加 remove(E e):指定元素刪除 contains(E e):指定元素是否存在 isEmpty():判斷是否為空 size():返回元素個數 to Array():元素變成數組 clean( ...
  • 案例故事: 大部分帶彩色屏幕的終端設備,不管是手機,車機,電視等等,都需要涉及圖片的顯示, 作為一名專業的多媒體測試人員,我們需要一堆的規範化標準的的圖片測試文件, 但是發現圖片資源名字命名的很隨意比如:IMG_20200325_161111.jpg, 以上命名不能看出圖片文件的具體圖片編碼格式,分 ...
  • app拿soul為例子 一.環境配置 #模擬器的frida服務為86 #frida-server-12.9.8-android-x86 adb push frida-server-12.9.8-android-x86 /data/local/tmp/ adb shell ./frida-server ...
  • 1.多態(polymorphism) 多態指的是同一個方法調用,由於對象不用可能會有不用的行為。現實生活中,同一個方法,具體實現會完全不同。 比如: 動物會叫,狗就是汪汪汪,貓就是喵喵喵 多態的要點: 1.多態是方法的多態,不是屬性的多態(多態與屬性無關) 2.多態的存在要有三個必要條件:繼承,方法 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...