聊聊面試必考-遞歸思想與實戰

本篇文章你將學到

image

爲何要寫這篇文章

  1. 「遞歸」算法對於一個程序員應該算是最經典的算法之一,並且它越想越亂,不少複雜算法的實現也都用到了遞歸,例如深度優先搜索,二叉樹遍歷等。
  2. 面試中經常會問遞歸相關的內容(深拷貝,對象格式化,數組拍平,走臺階問題等)
  3. 最近項目中有一個需求,裂變分享,可是不只僅給分享人返利,還會給最終分享人返利,可是隻作到4級分銷(也用到了遞歸,文中會講解)

遞歸算法是什麼

維基百科: 遞歸是在一個函數定義的內部用到自身。有此種定義的函數叫作遞歸。聽起來好像會致使無限重複,但只要定義適當,就不會這樣。 通常來講,一個遞歸函數的定義有兩個部分。首先,至少要有一個底線,就是一個簡單的線,越過此處, 遞歸

我本身簡單地理解遞歸就是:本身調用本身,有遞有歸,注意界限值javascript

一張有趣的圖片:前端

image

遞歸算法思想講解用和注意事項

何時使用遞歸?

看一個十一假期發生的小例子,帶你走進遞歸。十一放假時去火車站排隊取票,取票排了好多人,這個時候總有一些說時間來不及要插隊取票的小夥伴,我已經排的很遙遠了,發現本身離取票口愈來愈遠了呢,我超級想知道我如今排在了第幾位(前提:前面再也不有人插隊取票了),用遞歸思想咱們應該怎麼作?java

知足遞歸的條件

一個問題只要同時知足如下3 個條件,就能夠用遞歸來解決。程序員

  1. 一個問題的解能夠分解爲幾個子問題的解。

何爲子問題 ?就是數據規模更小的問題。
好比,前面說的你想知道你排在第幾位的例子,你要知道,本身在哪一排的問題,能夠分解爲每一個人在哪一排這樣一個子問題。面試

  1. 這個問題分解以後的子問題,除了數據規模不一樣,求解思路徹底同樣

好比前面說的你想知道你排在第幾的例子,你求解本身在哪一排的思路,和前面一排人求解本身在哪一排的思路,是如出一轍的。算法

  1. 存在遞歸終止條件

好比前面說的你想知道你排在第幾的例子,第一排的人不須要再繼續詢問任何人,就知道本身在哪一排,也就是 f(1) = 1,這就是遞歸的終止條件,找到終止條件就會開始進行「歸」的過程。數據庫

如何寫遞歸代碼?(知足上面條件,確認使用遞歸後)

記住最關鍵的兩點:編程

  1. 寫出遞歸公式(注意幾分支遞歸)
  2. 找到終止條件

分析排隊取票的例子(單分支層層遞歸)

排隊取票例子的子問題已經分析出來,我想知道個人位置在哪一排,就去問前面的人,前面的人位置加一就是這我的當前隊伍的位置,你前面的人想知道繼續向前問(一層問一層,思路徹底相同,最後到第一我的終止)。遞推公式是否是想出來了。數組

f(n) = f(n-1) + 1
//f(n) 爲我所在的當前層
//f(n-1) 爲我前面的人所在的當前層
// +1 爲我前面層與我所在層

再看一個走臺階例子(多分支並列遞歸)

具體學習如何分析和寫出遞歸代碼,以最經典的走臺階例子進行講解。數據結構

:假設有n個臺階,每次你能夠跨一個臺階或者兩個臺階,請問走這n個臺階有多少種走法?用編程求解。

按照上面說的關鍵點,先找遞歸公式:根據第一步的走法可分爲兩類,第一類是第一步走了一個臺階,第二類是第一步走了兩個臺階。因此n個臺階的走法=(先走1臺階後,n-1個臺階的走法)+(先走2臺階後,n-2個臺階的走法)。寫出的遞歸公式就是:

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

有了遞推公式第,第二步有了遞推公式,遞歸代碼基本上就完成了一半。咱們再來看下終止條件。當有一個臺階時,咱們不須要再繼續遞歸,就只有一種走法。因此 f(1)=1。這個遞歸終止條件足夠嗎?咱們能夠用 n=2,n=3 這樣比較小的數試驗一下。

n=2 時,f(2)=f(1)+f(0)。若是遞歸終止條件只有一個 f(1)=1,那 f(2) 就沒法求解了。因此除了 f(1)=1 這一個遞歸終止條件外,還要有 f(0)=1,表示走 0 個臺階有一種走法,不過這樣子看起來就不符合正常的邏輯思惟了。因此,咱們能夠把 f(2)=2 做爲一種終止條件,表示走 2 個臺階,有兩種走法,一步走完或者分兩步來走。

因此,遞歸終止條件就是 f(1)=1,f(2)=2。這個時候,你能夠再拿 n=3,n=4 來驗證一下,這個終止條件是否足夠而且正確。

咱們把遞歸終止條件和剛剛推出的遞歸公式合在一塊兒就是:

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

最後根據最終的遞歸公式轉換爲代碼就是

function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    return f(n-1) + f(n-2)
}

寫遞歸代碼時注意事項

上面提到了兩個例子(去十一去車站排隊取票,走臺階問題),根據這兩個例子(選擇這兩個例子的緣由,十一去車站排隊取票問題單分支遞歸,走臺階問題多分支並列遞歸,兩個例子剛恰好),接下來咱們具體講一下遞歸的注意事項。

1. 爆棧

十一去車站排隊取票,假設這是個無敵長隊,可能以及排了1000人(嘿嘿,請注意是個假設),這個時候若是棧的大小爲1KB。
遞歸未考慮爆棧時代碼以下:

function f(n){
    if(n === 1) return 1;
    return f(n-1) + 1;
}

函數調用會使用棧來保存臨時變量。棧的數據結構是先進後出,每調用一個函數,都會將臨時變量封裝爲棧幀壓入內存棧,等函數執行完成返回時纔出棧。系統棧或者虛擬機棧空間通常都不大。若是遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險。

這麼寫代碼,對於這種假設的無敵長隊確定會出現爆棧的狀況,修改代碼

// 全局變量,表示遞歸的深度。
let depth = 0;

function f(n) {
  ++depth;
  if (depth > 1000) throw exception;
  
  if (n == 1) return 1;
  return f(n-1) + 1;
}

修改代碼後,加了防止爆棧加了遞歸次數的限制,這是防止爆棧的比較不錯的方式,可是你們請注意,遞歸次數的限制通常不會限制到1000,通常次數5次,10次還好,1000次,並不能保證其餘的的變量不會存入棧中,事先沒法計算
,也有可能出現爆棧的問題。

舒適提示:若是遞歸深度比較小,能夠考慮限制遞歸次數防止爆棧,若是出現像這種 1000的深度,仍是考慮下其餘方式實現吧。

2.重複計算

走臺階的例子,前面咱們已經推導出了遞歸公式和代碼實現。在這裏再寫一遍:

function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    return walk(n-1) + walk(n-2)
}

重複計算說的就是這種,可能這麼說你們還不明白,畫了一個重複調用函數的圖,應該就懂了。

image
看圖中的函數調用,你會發現好多函數被調用屢次,好比 f(3) ,計算 f(5) 時候需先計算 f(4)f(3),到了計算 f(4) 的時候還要計算 f(3)f(2) ,這種 f(3) 就被屢次重複計算了,解決辦法。咱們可使用一個數據結構(注:這個數據結構能夠有不少種,好比 js 中能夠用setweakMap,甚至能夠用數組。java 中也能夠好多種散列表,愛思考的童鞋能夠想一下哪種更優秀哦,後面深拷貝例子我也會具體講)來存儲求解過的 f(k),再次調用的時候,判斷數據結構中是否存在,若是有直接從散列表中取值返回,不須要重複計算,這就避免了重複計算問題。
具體代碼以下:

let mapData =new Map();
function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    // 值的判斷和存儲
    if(mapData.get(n)){
        return setDatas.get(n);
    }
    let value = walk(n-1) + walk(n-2);
    mapData.set(n,value);
    return value;
}

3.循環引用

循環引用是指遞歸的內容中出現了重複的內容,
例如給下面內容實現深拷貝:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

具體如何實現深拷貝又要避免循環引用的詳細講解在文中實戰部分,請繼續往下看,小夥伴。

遞歸算法的一點感悟

前面提到了使用遞歸算法時知足的三個條件,肯定知足條件後,寫遞歸代碼時候的關鍵點((寫出遞歸公式,找到終止條件),這個關鍵點文中已經三次提到了哦,請記住它,最後根據遞歸公式和終止條件翻譯成代碼。

遞歸代碼,不要試圖用咱們的大腦一層一層分解遞歸的每一個步驟,這樣只會越想越煩躁,就算大神也作不到這點哦。

  • 遞歸算法優勢:代碼的表達力很強,寫起來很簡潔。
  • 遞歸算法缺點:遞歸算法有堆棧溢出(爆棧)的風險、存在重複計算,過多的函數調用會耗時較多等問題(寫遞歸算法的時候必定要考慮這幾個缺點)、歸時函數的變量的存儲須要額外的棧空間,當遞歸深度很深時,須要額外的內存佔空間就會不少,因此遞歸有很是高的空間複雜度。

遞歸算法使用場景(開篇提到的幾個面試題)

寫下面幾道應用場景實戰問題的時候,思想仍是以前說的,再重複一遍(寫出遞歸公式,找到終止條件)

1.經典走臺階問題

走臺階問題在前面已經具體講了,這裏就再也不細說,能夠看上面內容哦。

2.四級分銷-找到最佳推薦人

給定一個用戶,如何查找用戶的最終推薦 id,這裏面說了四級分銷,終止條件已經找到,只找到 四級分銷
代碼實現:

let deep = 0;
function findRootReferrerId(actorId) {
  deep++;
  let referrerId = select referrer_id from [table] where actor_id = actorId;
  if (deep === 4) return actorId; // 終止條件
  return findRootReferrerId(referrerId);
}

儘管能夠這樣完成了代碼,可是還要注意前提:

  1. 數據庫中沒有髒數據(髒數據多是測試直接手動插入數據產生的,好比A推薦了B,B又推薦了A,形成死循環,循環引用)。
  2. 確認推薦人插入表中數據的時候,必定判斷兩者以前的推薦關係是否已經存在。

3.數組拍平

let a = [1,2,3, [1,2,[1.4], [1,2,3]]]

對於數組拍平有時候也會被這樣問,這個嵌套數組的層級是多少?
具體實現代碼以下:

function flat(a=[],result=[]){
    a.forEach((item)=>{
        console.log(Object.prototype.toString.call(item))
        if(Object.prototype.toString.call(item)==='[object Array]'){
            result=result.concat(flat(item,[]));
        }else{
            result.push(item)
        }
    })
    return result;
}
console.log(flat(a)) // 輸出結果 [ 1, 2, 3, 1, 2, 1.4, 1, 2, 3 ]

4.對象格式化

對象格式化這個問題,這種通常是後臺返回給前端的數據,有時候須要格式化一下大小寫等,通常層級不會太深,不須要考慮終止條件,具體看代碼

// 格式化對象 大寫變爲小寫
let obj = {
    a: '1',
    b: {
        c: '2',
        D: {
            E: '3'
        }
    }
}
function keysLower(obj){
    let reg = new RegExp("([A-Z]+)", "g");
        for (let key in obj){
            if(Object.prototype.hasOwnProperty.call(obj,key)){
                let temp = obj[key];
                if(reg.test(key.toString())){
                    temp = obj[key.replace(reg,function(result){
                        return result.toLowerCase();
                    })]= obj[key];
                    delete obj[key];
                }
                if(Object.prototype.toString.call(temp)==='[object Object]'){
                    keysLower(temp);
                }
            }
        }
    return obj;
}
console.log(keysLower(obj));//輸出結果 { a: '1', b: { c: '2', d: { e: '3' } } }

5.實現一個深拷貝

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

代碼實現以下:

function clone(target, map = new WeakMap()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

深拷貝也是遞歸常考的例子

每次拷貝發生的事:

  • 檢查 map 中有無克隆過的對象
  • 有,直接返回
  • 沒有, 將當前對象做爲 key,克隆對象做爲 value 進行存儲
  • 繼續克隆

在這段代碼中咱們使用了 weakMap ,用來防止因循環引用而出現的爆棧。

weakMap 補充知識

都知道js中有好多種數據存儲結構,咱們爲何要用 weakMap 而不直接用 Map 進行存儲呢?

WeakMap 對象雖然也是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。

弱引用這個概念在寫 java 代碼時候用的仍是比較多的,可是到了 javascript 能使用的小夥伴並很少,網上不少深拷貝的代碼都是直接使用的 Map 存儲防止爆棧-- 弱引用,看完這篇文章能夠試着使用 WeakMap 哦。

在計算機程序設計中,弱引用與強引用相對,是指不能確保其引用的對象不會被垃圾回收器回收的引用。 一個對象若只被 弱引用 所引用,則被認爲是不可訪問(或弱可訪問)的,並所以可能在任什麼時候刻被回收。

深拷貝這裏有一個循環引用 走臺階問題是重複計算,我認爲這是兩個問題,走臺階問題是靠終止條件計算出來的。

總結

本篇文章就寫到這裏,其實還有複雜度問題想寫,可是篇幅有限,之後有時間會單獨寫複雜度的文章。本篇文章重點再重複一篇,不要嫌棄我嘮叨,什麼條件使用遞歸(想一下使用遞歸的優缺點)?遞歸代碼怎麼寫?遞歸注意事項?不要妄圖用人腦想明白複雜遞歸。以上幾點學明白了足以讓你應付大多數的面試問題了,嘿嘿,注意思想哦(還有個 weakMap 小知識你們能夠詳細去學下,也是能夠擴展爲一篇文章的)。小夥伴們有時間能夠去找幾個遞歸問題練習一下。下篇文章見!

參考文章

參考文章

加入咱們一塊兒學習吧!(加好友 coder_qi 進前端交流羣學習)

相關文章
相關標籤/搜索