1. 程式人生 > >SKU多維屬性狀態判斷演算法

SKU多維屬性狀態判斷演算法

作者:周琪力,前端工程師,網路常用暱稱「keelii」。在過去的4年裡主要負責京東網站商品詳情頁的前端系統架構和開發,平時主要寫 JavaScript 偶爾寫點NodeJS,Python。琪力部落格: https://keelii.github.io/。

 

 

問題描述

這個問題來源於選擇商品屬性的場景。比如我們買衣服、鞋子這類物件,一般都需要我們選擇合適的顏色、尺碼等屬性

先了解一下 SKU的學術概念吧

最小庫存管理單元(Stock Keeping Unit, SKU)是一個會計學名詞,定義為庫存管理中的最小可用單元,例如紡織品中一個SKU通常表示規格、顏色、款式,而在連鎖零售門店中有時稱單品為一個SKU。最小庫存管理單元可以區分不同商品銷售的最小單元,是科學管理商品的採購、銷售、物流和財務管理以及POS和MIS系統的資料統計的需求,通常對應一個管理資訊系統的編碼。 —— form wikipedia 最小存貨單位

 

簡單的結合上面的例項來說: SKU就是你上購物網站買到的最終商品,對應的上圖中已選擇的屬性是:顏色 黑色 - 尺碼 37。

 

我先看看後端資料結構一般是這樣的,一個線性陣列,每個元素是一個描述當前SKU的Map,比如:

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]

 

前端展示的時候顯然需要 group 一下,按不同的屬性分組,目的就是讓使用者按屬性的維度去選擇,group 後的資料大概是這樣的:

{    
"顏色": ["紅", "白", "藍"],    
"尺碼": ["大", "中", "小"],    
"型號": ["A", "B", "C"] }

對應的在網頁上大概是這樣的 UI

 

這個時候,就會有一個問題,這些元子屬效能組成的集合(使用者的選擇路徑) 遠遠大於 真正可以組成的集合,比如上面的屬性集合可以組合成一個 笛卡爾積

,即。可以組合成以下序列:

[
    ["紅", "大", "A"],    // ✔
    ["紅", "大", "B"],
    ["紅", "大", "C"],
    ["紅", "中", "A"],
    ["紅", "中", "B"],
    ["紅", "中", "C"],
    ["紅", "小", "A"],
    ["紅", "小", "B"],
    ["紅", "小", "C"],
    ["白", "大", "A"],
    ["白", "大", "B"],
    ["白", "大", "C"],
    ["白", "中", "A"],
    ["白", "中", "B"],    // ✔
    ["白", "中", "C"],
    ["白", "小", "A"],
    ["白", "小", "B"],
    ["白", "小", "C"],
    ["藍", "大", "A"],
    ["藍", "大", "B"],
    ["藍", "大", "C"],
    ["藍", "中", "A"],
    ["藍", "中", "B"],
    ["藍", "中", "C"],
    ["藍", "小", "A"],
    ["藍", "小", "B"],
    ["藍", "小", "C"]     // ✔]

根據公式可以知道,一個由 3 個元素,每個元素是有 3 個元素的子集構成的集合,能組成的笛卡爾積一共有 3 的 3 次冪,也就是 27 種,然而源資料只可以形成 3 種組合。

 

這種情況下最好能提前判斷出來不可選的路徑並置灰,告訴使用者,否則會造成誤解。

 

確定規則

看下圖,如果我們定義紅色為當前選中的商品的屬性,即當前選中商品為 紅-大-A,這個時候如何確認其它非已選屬性是否可以組成可選路徑?

規則是這樣的: 假設當前使用者想選 白-大-A,剛好這個選擇路徑是不存在的,那麼我們就把 白置灰。

以此類推,如果要確認 藍 屬性是否可用,需要查詢 藍-大-A 路徑是否存在

 

解決方法

根據上面的邏輯程式碼實現思路就有了:

 

1. 遍歷所有非已選元素:”白”, “藍”, “中”, “小”, “B”, “C”

  1. 遍歷所有屬性行: ”顏色”, “尺碼”, “型號”

    1. 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素,形成一個路徑

    2. 判斷此路徑是否存在,如果不存在將當前元素置灰

 

看來問題似乎已經解決了,然而 …

 

我們忽略了一個非常重要的問題:上例中雖然 白 元素置灰,但是實際上 白 是可以被點選的!因為使用者可以選擇 白-中-B 路徑。

 

如果使用者點選了 白 情況就變得複雜了很多,我們假設使用者只選擇了一個元素 白,此時如何判斷其它未選元素是否可選?

即:如何確定 ”大”, “中”, “小”, “A”, “B”, “C” 需要置灰? 注意我們並不需要確認 “紅”,“藍”是否可選,因為屬性裡面的元素都是 單選,當前的屬性裡任何元素都可選的。

 

縮小問題規模

我們先 縮小問題範圍:當前情況下(只有一個 白 已選)如何確定尺碼 “大” 需要置灰? 你可能會想到根據我們之間的邏輯,需要分別查詢:

  • 白 - 大 - A

  • 白 - 大 - B

  • 白 - 大 - C

 

他們都不存在的時候把尺碼 大 置灰,問題似乎也可以解決。其實這樣是不對的,因為 型號 沒有被選擇過,所以只需要知道 白-大 是否可選即可。

 

同時還有一個問題,如果已選的個數不確定而且維度可以增加到不確定呢?

這種情況下如果還按之前的演算法,即使實現也非常複雜。這時候就要考慮換一種思維方式。

 

調整思路

之前我們都是反向思考,找出不可選應該置灰的元素。我們現在正向的考慮,如何確定屬性是否可選。而且多維的情況下使用者可以跳著選。比如:使用者選了兩個元素 白,B

 圖1

我們再回過頭來看下 原始存在的資料

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]


[   [ "紅", "大", "A" ],   // 存在   [ "白", "中", "B" ],   // 存在   [ "藍", "小", "C" ]  // 存在
]

 

顯然:如果第一條資料 “紅”, “大”, “A” 存在,那麼下面這些子組合 肯定都存在:

  • A

  • 紅 - 大

  • 紅 - A

  • 大 - A

  • 紅 - 大 - A

 

同理:如果第二條資料 ”白”, “中”, “B” 存在,那麼下面這些子組合 肯定都存在:

  • B

  • 白 - 中

  • 白 - B

  • 中 - B

  • 白 - 中 - B

我們提前把 所有存在的路徑中的子組合 算出來,演算法上叫取集合所有子集,數學上叫 冪集, 形成一個所有存在的路徑表,演算法如下:

/**
 * 取得集合的所有子集「冪集」
 arr = [1,2,3]

   i = 0, ps = [[]]:
     j = 0; j < ps.length => j < 1:
         i=0, j=0 ps.push(ps[0].concat(arr[0])) => ps.push([].concat(1)) => [1]
              ps = [[], [1]]

   i = 1, ps = [[], [1]] :
     j = 0; j < ps.length => j < 2
         i=1, j=0 ps.push(ps[0].concat(arr[1])) => ps.push([].concat(2))  => [2]
         i=1, j=1 ps.push(ps[1].concat(arr[1])) => ps.push([1].concat(2)) => [1,2]
              ps = [[], [1], [2], [1,2]]

   i = 2, ps = [[], [1], [2], [1,2]]
     j = 0; j < ps.length => j < 4
         i=2, j=0 ps.push(ps[0].concat(arr[2])) => ps.push([3])    => [3]
         i=2, j=1 ps.push(ps[1].concat(arr[2])) => ps.push([1, 3]) => [1, 3]
         i=2, j=2 ps.push(ps[2].concat(arr[2])) => ps.push([2, 3]) => [2, 3]
         i=2, j=3 ps.push(ps[3].concat(arr[2])) => ps.push([2, 3]) => [1, 2, 3]
              ps = [[], [1], [2], [1,2], [3], [1, 3], [2, 3], [1, 2, 3]]
 */

function powerset(arr) {    
var ps = [[]];    
for (var i=0; i < arr.length; i++) {  
for (var j = 0, len = ps.length; j < len; j++) {   ps.push(ps[j].concat(arr[i])); } }
return ps; }

有了這個存在的子集集合,再回頭看 圖1 舉例:

  圖1

 

  • 如何確定 紅 可選? 只需要確定 紅-B 可選

  • 如何確定 中 可選? 需要確定 白-中-B 可選

  • 如何確定 2G 可選? 需要確定 白-B-2G 可選

 

演算法描述如下:

 

  1. 遍歷所有非已選元素

    a. 遍歷所有屬性行

    1. 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素(如果當前屬性中沒已選元素,則跳過),形成一個路徑

    2. 判斷此路徑是否存在(在所有存在的路徑表中查詢),如果不存在將當前元素置灰

 

以最開始的後端資料為例,生成的所有可選路徑表如下: 注意路徑用分割符號「-」分開是為了查詢路徑時方便,不用遍歷

{
   "": {
           "skus": ["3158054", "3133859", "3516833"]    },
   "紅": {
           "skus": ["3158054"]    },    
   "大": {
          "skus": ["3158054"]    },    
   "紅-大": {
           "skus": ["3158054"]    },    
   "A": {
           "skus": ["3158054"]    },    
   "紅-A": {
           "skus": ["3158054"]    },    
   "大-A": {
           "skus": ["3158054"]    },    
   "紅-大-A": {
           "skus": ["3158054"]    },    
   "白": {
           "skus": ["3133859"]    },    
   "中": {
           "skus": ["3133859"]    },    
   "白-中": {
           "skus": ["3133859"]    },    
   "B": {
           "skus": ["3133859"]    },
   "白-B": {
           "skus": ["3133859"]    },
   "中-B": {
           "skus": ["3133859"]    },    
   "白-中-B": {
           "skus": ["3133859"]    },    
   "藍": {
           "skus": ["3516833"]    },    
   "小": {
           "skus": ["3516833"]    },    
   "藍-小": {
           "skus": ["3516833"]    },    
   "C": {
           "skus": ["3516833"]    },    
   "藍-C": {
           "skus": ["3516833"]    },    
   "小-C": {
           "skus": ["3516833"]    },    
   "藍-小-C": {
           "skus": ["3516833"]    } }

 

為了更清楚的說明這個演算法,再上一張圖來解釋下吧:

 

所以根據上面的邏輯得出,計算狀態後的介面應該是這樣的:

現在這種情況下如果使用者點選 尺碼 中 應該怎麼互動呢?

 

優化體驗

因為當前情況下路徑 紅-中-A 並不存在,如果點選 中,那麼除了尺碼 中 之外其它的屬性中 至少有一個 屬性和 中 的路徑搭配是不存在的。

 

互動方面需求是:如果不存在就高亮當前屬性行,使使用者必須選擇到可以和 中 組合存在的屬性。而且使用者之間選擇過的屬性要做一次快取。

 

所以當點選不存在的屬性時互動流程是這樣的:

  1. 無論當前屬性存不存在,先高亮(選中)當前屬性

  2. 清除其它所有已選屬性

  3. 更新當前狀態(只選當前屬性)下的其它屬性可選狀態

  4. 遍歷非當前屬性行的其它屬性查詢對應的在快取中的已選屬性

  5. 如果快取中對應的屬性存在(可選),則預設選中快取屬性並 再次更新 其它可選狀態。不存在,則高亮當前屬性行(深色背景)

 

這個過程的流程圖大概是這樣的,點進不存在的屬性就會進入「單選流程」

 

假設後端資料是這樣的:

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "大", "型號": "A", "skuId": "3158054" }, // 多加了一條
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]

當前選中狀態是:白-大-A

如果使用者點選 中。這個時候 白-中 是存在的,但是 中-A 並不存在,所以保留顏色 白,高亮型號屬性行:

由此可見和 白-中 能搭配存在型號只有 B,而快取的作用就是為了少讓使用者選一次顏色 白。

 

到這裡,基本上主要的功能就實現了。比如庫存邏輯處理方式也和不存屬性一樣,就不再贅述。唯一需要注意的地方是求冪集的複雜度問題。

 

演算法複雜度

冪集演算法的時間複雜度是 O(2^n),也就是說每條資料上面的屬性(維度)越多,複雜度越高。SKU資料的多少並不重要,因為是常數級的線性增長,而維度是指數級的增長。

{1}       2^1 = 2=> {},{1}
{1,2}     2^2 = 4=> {},{1},{2},{1,2}
{1,2,3}   2^3 = 8=> {},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}
...

在 chrome 裡面簡單跑了幾個用例,可見這個演算法非常低效,如果要使用這個演算法,必須控制維度在合理範圍內,而且不僅僅演算法時間複雜度很高,生成最後的路徑表也會非常大,相應的佔用記憶體也很高。

 

舉個例子:如果有一個 10 維的 sku,那麼最終生成的路徑表會有 2^10 個(1024) key/value。

 

最終 demo 可以檢視這個: SKU多維屬性狀態判斷(http://codepen.io/keelii/pen/RoOzgb)

 

相關資料: SKU組合查詢演算法探索(http://git.shepherdwind.com/sku-search-algorithm.html)