前段時間在掘金看到一個熱帖 今天又懶得加班了,能寫出這兩個算法嗎?帶你去電商公司寫商品中心,裏面提到了一個比較有意思故事,大意就是一個看似比較簡單的電商 sku 的全排列組合算法,可是卻有好多人沒能順利寫出來。有一個畢業生小夥子在面試的時候給出了思路,可是進去之後仍是沒寫出來,羞愧跑路~javascript
其實排列組合是一個很經典的算法,也是對遞歸回溯法的一個實踐運用,本篇文章就以帶你學習一個標準「排列組合求解模板」,耐心看完,你會有更多收穫。前端
需求描述起來很簡單,有這樣三個數組:java
let names = ["iPhone X", "iPhone XS"]
let colors = ["黑色", "白色"]
let storages = ["64g", "256g"]
複製代碼
須要把他們的全部組合窮舉出來,最終獲得這樣一個數組:git
[
["iPhone X", "黑色", "64g"],
["iPhone X", "黑色", "256g"],
["iPhone X", "白色", "64g"],
["iPhone X", "白色", "256g"],
["iPhone XS", "黑色", "64g"],
["iPhone XS", "黑色", "256g"],
["iPhone XS", "白色", "64g"],
["iPhone XS", "白色", "256g"],
]
複製代碼
因爲這些屬性數組是不定項的,因此不能簡單的用三重的暴力循環來求解了。github
若是咱們選用遞歸回溯法來解決這個問題,那麼最重要的問題就是設計咱們的遞歸函數。面試
以上文所舉的例子來講,好比咱們目前的屬性數組就是:names
、colors
、storages
,首先咱們會處理 names
數組,很顯然對於每一個屬性數組,都須要去遍歷它,而後一個一個選擇後再去和 下一個數組的每一項進行組合。算法
咱們設計的遞歸函數接受兩個參數:數組
index
對應當前正在處理的下標,是 names
仍是 colors
或是 storage
。prev
上一次遞歸已經拼接成的結果,好比 ['iPhone X', '黑色']
。進入遞歸函數:bash
處理屬性數組的下標0
:假設咱們在第一次循環中選擇了 iPhone XS
,那此時咱們有一個未完成的結果狀態,假設咱們叫它 prev
,此時 prev = ['iPhone XS']
。函數
處理屬性數組的下標1
:那麼就處理到 colors
數組的了,而且咱們擁有 prev
,在遍歷 colors
的時候繼續遞歸的去把 prev
拼接成 prev.concat(color)
,也就是 ['iPhone XS', '黑色']
這樣繼續把這個 prev
交給下一次遞歸。
處理屬性數組的下標2
:那麼就處理到 storages
數組的了,而且咱們擁有了 name + color
的 prev
,在遍歷 storages
的時候繼續遞歸的去把 prev
拼接成 prev.concat(storage)
,也就是 ['iPhone XS', '黑色', '64g']
,而且此時咱們發現處理的屬性數組下標已經到達了末尾,那麼就放入全局的結果變量 res
中,做爲一個結果。
let names = ["iPhone X", "iPhone XS"]
let colors = ["黑色", "白色"]
let storages = ["64g", "256g"]
let combine = function (...chunks) {
let res = []
let helper = function (chunkIndex, prev) {
let chunk = chunks[chunkIndex]
let isLast = chunkIndex === chunks.length - 1
for (let val of chunk) {
let cur = prev.concat(val)
if (isLast) {
// 若是已經處理到數組的最後一項了 則把拼接的結果放入返回值中
res.push(cur)
} else {
helper(chunkIndex + 1, cur)
}
}
}
// 從屬性數組下標爲 0 開始處理
// 而且此時的 prev 是個空數組
helper(0, [])
return res
}
console.log(combine(names, colors, storages))
複製代碼
畫出以 iPhone X
這一項爲起點的遞歸樹圖,固然這個問題是一個多個根節點的樹,請自行腦補 iPhone XS
爲起點的樹,子結構是如出一轍的。
爲何說這種接法是排列組合的「萬能模板呢」?來看一下 LeetCode 上的真題。
77. 組合 這是一道難度爲 medium
的問題,其實算是比較有難度的問題了:
給定兩個整數 n 和 k,返回 1 ... n 中全部可能的 k 個數的組合。
示例:
輸入: n = 4, k = 2
輸出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
複製代碼
let combine = function (n, k) {
let ret = []
let helper = (start, prev) => {
let len = prev.length
if (len === k) {
ret.push(prev)
return
}
for (let i = start; i <= n; i++) {
helper(i + 1, prev.concat(i))
}
}
helper(1, [])
return ret
}
複製代碼
能夠看出這題和咱們求解電商排列組合的代碼居然如此類似。只須要設計一個接受 start
排列起始位置、prev
上一次拼接結果爲參數的遞歸 helper
函數,
而後對於每個起點下標 start
,先拼接上 start
位置對應的值,再不斷的再以其餘剩餘的下標做爲起點去作下一次拼接。當 prev
這個中間狀態的拼接數組到達題目的要求長度 k
後,就放入結果數組中。
在這個解法中,有一些遞歸分支是明顯不可能獲取到結果的,咱們每次遞歸都會循環嘗試 <= n
的全部項去做爲start
,假設咱們要求的數組長度 k = 3
,最大值 n = 4
。
而咱們以 prev = [1]
,再去以 n = 4
爲 start
做爲遞歸的起點,那麼顯然是不可能獲得結果的,由於 n = 4
的話就只剩下 4
這一項能夠拼接了,最多也就拼接成 [1, 4]
,不可能知足 k = 3
的條件。
因此在進入遞歸以前,就果斷的把這些「廢枝」給減掉。這就叫作「剪枝」
let combine = function (n, k) {
let ret = []
let helper = (start, prev) => {
let len = prev.length
if (len === k) {
ret.push(prev)
return
}
// 還有 rest 個位置待填補
let rest = k - prev.length
for (let i = start; i <= n; i++) {
if (n - i + 1 < rest) {
continue
}
helper(i + 1, prev.concat(i))
}
}
helper(1, [])
return ret
}
複製代碼
固然,力扣中能夠套用這個模板的類似題型還有不少,並且大多數難度都是 medium
的,好比快手的面試題子集 II-90,能夠看出排列組合的遞歸解法仍是有必定的難度的。
我在維護的 LeetCode 題解倉庫 中已經按標籤篩選好 「遞歸與回溯」類型的幾道題目和解答了,感興趣的小夥伴也能夠一塊兒攻破它們。
排列組合問題並非空中樓閣,在實際工做中也會常常遇到這種場景,掌握了遞歸回溯的標準模板固然不是爲了讓你死記硬背套公式,而是真正的理解它。遇到須要遞歸解決的問題。
但願閱讀完本篇文章的你,能對遞歸和排列組合問題有進一步的理解和收穫。
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。