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

本篇文章你將學到

image

爲什麼要寫這篇文章

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

遞歸算法是什麼

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

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

一張有趣的圖片:

image

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

什麼時候使用遞歸?

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

滿足遞歸的條件

一個問題只要同時滿足以下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 進前端交流羣學習)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章