前端電商 sku 的全排列算法很難嗎?學會這個套路,完全掌握排列組合。

前言

前段時間在掘金看到一個熱帖 今天又懶得加班了,能寫出這兩個算法嗎?帶你去電商公司寫商品中心,裏面提到了一個比較有意思故事,大意就是一個看似比較簡單的電商 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

思路

若是咱們選用遞歸回溯法來解決這個問題,那麼最重要的問題就是設計咱們的遞歸函數。面試

思路分解

以上文所舉的例子來講,好比咱們目前的屬性數組就是:namescolorsstorages,首先咱們會處理 names 數組,很顯然對於每一個屬性數組,都須要去遍歷它,而後一個一個選擇後再去和 下一個數組的每一項進行組合。算法

咱們設計的遞歸函數接受兩個參數:數組

  • index 對應當前正在處理的下標,是 names 仍是 colors 或是 storage
  • prev 上一次遞歸已經拼接成的結果,好比 ['iPhone X', '黑色']

進入遞歸函數:bash

  1. 處理屬性數組的下標0:假設咱們在第一次循環中選擇了 iPhone XS,那此時咱們有一個未完成的結果狀態,假設咱們叫它 prev,此時 prev = ['iPhone XS']函數

  2. 處理屬性數組的下標1:那麼就處理到 colors 數組的了,而且咱們擁有 prev,在遍歷 colors 的時候繼續遞歸的去把 prev 拼接成 prev.concat(color),也就是 ['iPhone XS', '黑色'] 這樣繼續把這個 prev 交給下一次遞歸。

  3. 處理屬性數組的下標2:那麼就處理到 storages 數組的了,而且咱們擁有了 name + colorprev,在遍歷 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

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 = 4start 做爲遞歸的起點,那麼顯然是不可能獲得結果的,由於 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. 對於不可能達成條件的分支遞歸,進行合理的「剪枝」。

但願閱讀完本篇文章的你,能對遞歸和排列組合問題有進一步的理解和收穫。

❤️ 感謝你們

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索