本篇文章你將學到
爲什麼要寫這篇文章
- “遞歸”算法對於一個程序員應該算是最經典的算法之一,而且它越想越亂,很多複雜算法的實現也都用到了遞歸,例如深度優先搜索,二叉樹遍歷等。
- 面試中常常會問遞歸相關的內容(深拷貝,對象格式化,數組拍平,走臺階問題等)
- 最近項目中有一個需求,裂變分享,但是不僅僅給分享人返利,還會給最終分享人返利,但是隻做到4級分銷(也用到了遞歸,文中會講解)
遞歸算法是什麼
維基百科
:遞歸
是在一個函數定義的內部用到自身。有此種定義的函數叫做遞歸。聽起來好像會導致無限重複,但只要定義適當,就不會這樣。 一般來說,一個遞歸函數的定義有兩個部分。首先,至少要有一個底線,就是一個簡單的線,越過此處,遞歸
。
我自己簡單地理解遞歸就是:自己調用自己,有遞有歸,注意界限值
。
一張有趣的圖片:
遞歸算法思想講解用和注意事項
什麼時候使用遞歸?
看一個十一假期發生的小例子,帶你走進遞歸。十一放假時去火車站排隊取票,取票排了好多人,這個時候總有一些說時間來不及要插隊取票的小夥伴,我已經排的很遙遠了,發現自己離取票口越來越遠了呢,我超級想知道我現在排在了第幾位(前提:前面不再有人插隊取票了),用遞歸思想我們應該怎麼做?
滿足遞歸的條件
一個問題只要同時滿足以下3 個條件,就可以用遞歸來解決。
- 一個問題的解可以分解爲幾個子問題的解。
何爲子問題 ?就是數據規模更小的問題。
比如,前面說的你想知道你排在第幾位的例子,你要知道,自己在哪一排的問題,可以分解爲每個人在哪一排這樣一個子問題。
- 這個問題分解之後的子問題,除了數據規模不同,求解思路完全一樣
比如前面說的你想知道你排在第幾的例子,你求解自己在哪一排的思路,和前面一排人求解自己在哪一排的思路,是一模一樣的。
- 存在遞歸終止條件
比如前面說的你想知道你排在第幾的例子,第一排的人不需要再繼續詢問任何人,就知道自己在哪一排,也就是 f(1) = 1
,這就是遞歸的終止條件,找到終止條件就會開始進行“歸”的過程。
如何寫遞歸代碼?(滿足上面條件,確認使用遞歸後)
記住最關鍵的兩點:
- 寫出遞歸公式(注意幾分支遞歸)
- 找到終止條件
分析排隊取票的例子(單分支層層遞歸
)
排隊取票例子的子問題已經分析出來,我想知道我的位置在哪一排,就去問前面的人,前面的人位置加一就是這個人當前隊伍的位置,你前面的人想知道繼續向前問(一層問一層,思路完全相同,最後到第一個人終止)。遞推公式是不是想出來了。
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)
}
重複計算說的就是這種,可能這麼說大家還不明白,畫了一個重複調用函數的圖,應該就懂了。
看圖中的函數調用,你會發現好多函數被調用多次,比如 f(3)
,計算 f(5)
時候需先計算 f(4)
和 f(3)
,到了計算 f(4)
的時候還要計算 f(3)
和 f(2)
,這種 f(3)
就被多次重複計算了,解決辦法。我們可以使用一個數據結構(注:這個數據結構可以有很多種,比如 js 中可以用set
,weakMap
,甚至可以用數組。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);
}
儘管可以這樣完成了代碼,但是還要注意前提:
- 數據庫中沒有
髒數據
(髒數據可能是測試直接手動插入數據產生的,比如A推薦了B,B又推薦了A,造成死循環,循環引用)。 - 確認推薦人插入表中數據的時候,一定判斷二者之前的推薦關係是否已經存在。
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
小知識大家可以詳細去學下,也是可以擴展爲一篇文章的)。小夥伴們有時間可以去找幾個遞歸問題練習一下。下篇文章見!
參考文章
參考文章
- 天明夜盡:https://juejin.im/post/5d1dab...
- code祕密花園:https://juejin.im/post/5d6aa4...
- 極客時間的遞歸學習
加入我們一起學習吧!(加好友 coder_qi 進前端交流羣學習)