鏈接:https://www.jianshu.com/p/7a17b4179225
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
問題描述
這個問題來源於選擇商品屬性的場景。比如我們買衣服、鞋子這類物件,一般都需要我們選擇合適的顏色、尺碼等屬性

先了解一下 SKU 的學術概念吧
簡單的結合上面的實例來說: 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
路徑是否存在
...
解決方法
根據上面的邏輯代碼實現思路就有了:
- 遍歷所有非已選元素:
"白", "藍", "中", "小", "B", "C"
- 遍歷所有屬性行:
"顏色", "尺碼", "型號"
- 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素,形成一個路徑
- 判斷此路徑是否存在,如果不存在將當前元素置灰
- 遍歷所有屬性行:
看來問題似乎已經解決了,然而 ...
我們忽略了一個非常重要的問題:上例中雖然 白
元素置灰,但是實際上 白
是可以被點擊的!因為用戶可以選擇 白-中-B
路徑
如果用戶點擊了 白
情況就變得復雜了很多,我們假設用戶 只選擇了一個元素 白
,此時如何判斷其它未選元素是否可選?

即:如何確定 "大", "中", "小", "A", "B", "C"
需要置灰? 注意我們並不需要確認 "紅","藍"
是否可選,因為屬性里面的元素都是 單選,當前的屬性里任何元素都可選的
縮小問題規模
我們先 縮小問題范圍:當前情況下(只有一個 白
已選)如何確定尺碼 "大"
需要置灰? 你可能會想到根據我們之間的邏輯,需要分別查找:
- 白 - 大 - A
- 白 - 大 - B
- 白 - 大 - C
他們都不存在的時候把尺碼 大
置灰,問題似乎也可以解決。其實這樣是不對的,因為 型號沒有被選擇過,所以只需要知道 白-大
是否可選即可
同時還有一個問題,如果已選的個數不確定而且維度可以增加到不確定呢?

這種情況下如果還按之前的算法,即使實現也非常復雜。這時候就要考慮換一種思維方式
調整思路
之前我們都是反向思考,找出不可選應該置灰的元素。我們現在正向的考慮,如何確定屬性是否可選。而且多維的情況下用戶可以跳着選。比如:用戶選了兩個元素 白,B

我們再回過頭來看下 原始存在的數據
[ { "顏色": "紅", "尺碼": "大", "型號": "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 舉例:

- 如何確定
紅
可選? 只需要確定紅-B
可選 - 如何確定
中
可選? 需要確定白-中-B
可選 - 如何確定
2G
可選? 需要確定白-B-2G
可選
算法描述如下:
- 遍歷所有非已選元素
- 遍歷所有屬性行
- 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素(如果當前屬性中沒已選元素,則跳過),形成一個路徑
- 判斷此路徑是否存在(在所有存在的路徑表中查詢),如果不存在將當前元素置灰
- 遍歷所有屬性行
以最開始的后端數據為例,生成的所有可選路徑表如下:
注意路徑用分割符號「-」分開是為了查找路徑時方便,不用遍歷
{ "": { "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
並不存在,如果點擊 中
,那么除了尺碼 中
之外其它的屬性中 至少有一個 屬性和 中
的路徑搭配是不存在的
交互方面需求是:如果不存在就高亮當前屬性行,使用戶必須選擇到可以和 中
組合存在的屬性。而且用戶之間選擇過的屬性要做一次緩存
所以當點擊不存在的屬性時交互流程是這樣的:
- 無論當前屬性存不存在,先高亮(選中)當前屬性
- 清除其它所有已選屬性
- 更新當前狀態(只選當前屬性)下的其它屬性可選狀態
- 遍歷非當前屬性行的其它屬性查找對應的在緩存中的已選屬性
- 如果緩存中對應的屬性存在(可選),則默認選中緩存屬性並 再次更新 其它可選狀態。不存在,則高亮當前屬性行(深色背景)
這個過程的流程圖大概是這樣的,點進不存在的屬性就會進入「單選流程」

假設后端數據是這樣的:
[ { "顏色": "紅", "尺碼": "大", "型號": "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 多維屬性狀態判斷
相關資料:
SKU組合查詢算法探索
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Sku 多維屬性狀態判斷</title> <script src="http://misc.360buyimg.com/jdf/lib/jquery-1.6.4.js"></script> <style> body { font-size: 12px; } dt { width: 100px; text-align: right; } dl { clear: both; overflow:hidden; } dl.hl { background:#ddd; } dt, dd { float:left; height: 40px; line-height: 40px; margin-left: 10px; } button { font-size: 14px; font-weight: bold; padding: 4px 4px; } .disabled { color:#999; border: 1px dashed #666; } .active { color: red; } </style> </head> <body> <p> <textarea id="data_area" cols="100" rows="10"> [ { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158055" }, { "顏色": "白", "尺碼": "大", "型號": "A", "skuId": "3158054" }, { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" }, { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" } ] </textarea> </p> <p> <input onclick="updateData()" type="button" value="更新數據"> </p> <hr> <div id="app"></div> <hr> <div id="msg"></div> <script> var data = JSON.parse($('#data_area').val()) var res = {} var spliter = '\u2299' var r = {} var keys = [] var selectedCache = [] /** * 計算組合數據 */ function combineAttr(data, keys) { var allKeys = [] var result = {} for (var i = 0; i < data.length; i++) { var item = data[i] var values = [] for (var j = 0; j < keys.length; j++) { var key = keys[j] if (!result[key]) result[key] = [] if (result[key].indexOf(item[key]) < 0) result[key].push(item[key]) values.push(item[key]) } allKeys.push({ path: values.join(spliter), sku: item['skuId'] }) } return { result: result, items: allKeys } } /** * 渲染 DOM 結構 */ function render(data) { var output = '' for (var i = 0; i < keys.length; i++) { var key = keys[i]; var items = data[key] output += '<dl data-type="'+ key +'" data-idx="'+ i +'">' output += '<dt>'+ key +':</dt>' output += '<dd>' for (var j = 0; j < items.length; j++) { var item = items[j] var cName = j == 0 ? 'active' : '' if (j == 0) { selectedCache.push(item) } output += '<button data-title="'+ item +'" class="'+ cName +'" value="'+ item +'">'+ item +'</button> ' } output += '</dd>' output += '</dl>' } output += '</dl>' $('#app').html(output) } function getAllKeys(arr) { var result = [] for (var i = 0; i < arr.length; i++) { result.push(arr[i].path) } return result } /** * 取得集合的所有子集「冪集」 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; } /** * 生成所有子集是否可選、庫存狀態 map */ function buildResult(items) { var allKeys = getAllKeys(items) for (var i = 0; i < allKeys.length; i++) { var curr = allKeys[i] var sku = items[i].sku var values = curr.split(spliter) // var allSets = getAllSets(values) var allSets = powerset(values) // 每個組合的子集 for (var j = 0; j < allSets.length; j++) { var set = allSets[j] var key = set.join(spliter) if (res[key]) { res[key].skus.push(sku) } else { res[key] = { skus: [sku] } } } } } function trimSpliter(str, spliter) { // ⊙abc⊙ => abc // ⊙a⊙⊙b⊙c⊙ => a⊙b⊙c var reLeft = new RegExp('^' + spliter + '+', 'g'); var reRight = new RegExp(spliter + '+$', 'g'); var reSpliter = new RegExp(spliter + '+', 'g'); return str.replace(reLeft, '') .replace(reRight, '') .replace(reSpliter, spliter) } /** * 獲取當前選中的屬性 */ function getSelectedItem() { var result = [] $('dl[data-type]').each(function () { var $selected = $(this).find('.active') if ($selected.length) { result.push($selected.val()) } else { result.push('') } }) return result } /** * 更新所有屬性狀態 */ function updateStatus(selected) { for (var i = 0; i < keys.length; i++) { var key = keys[i]; var data = r.result[key] var hasActive = !!selected[i] var copy = selected.slice() for (var j = 0; j < data.length; j++) { var item = data[j] if (selected[i] == item) continue copy[i] = item var curr = trimSpliter(copy.join(spliter), spliter) var $item = $('dl').filter('[data-type="'+ key +'"]').find('[value="'+ item +'"]') var titleStr = '['+ copy.join('-') +']' if (res[curr]) { $item.removeClass('disabled') setTitle($item.get(0)) } else { $item.addClass('disabled').attr('title', titleStr + ' 無此屬性搭配') } } } } /** * 正常屬性點擊 */ function handleNormalClick($this) { $this.siblings().removeClass('active') $this.addClass('active') } /** * 無效屬性點擊 */ function handleDisableClick($this) { var $currAttr = $this.parents('dl').eq(0) var idx = $currAttr.data('idx') var type = $currAttr.data('type') var value = $this.val() $this.removeClass('disabled') selectedCache[idx] = value console.log(selectedCache) // 清空高亮行的已選屬性狀態(因為更新的時候默認會跳過已選狀態) $('dl').not($currAttr).find('button').removeClass('active') updateStatus(getSelectedItem()) /** * 恢復原來已選屬性 * 遍歷所有非當前屬性行 * 1. 與 selectedCache 對比 * 2. 如果要恢復的屬性存在(非 disable)且 和當前*未高亮行*已選擇屬性的*可組合*),高亮原來已選擇的屬性且更新 * 3. 否則什么也不做 */ for (var i = 0; i < keys.length; i++) { var item = keys[i] var $curr = $('dl[data-type="'+ item +'"]') if (item == type) continue var $lastSelected = $curr.find('button[value="'+ selectedCache[i] +'"]') // 緩存的已選屬性沒有 disabled (可以被選擇) if (!$lastSelected.hasClass('disabled')) { $lastSelected.addClass('active') updateStatus(getSelectedItem()) } } } /** * 高亮當前屬性區 */ function highLighAttr() { for (var i = 0; i < keys.length; i++) { var key = keys[i] var $curr = $('dl[data-type="'+ key +'"]') if ($curr.find('.active').length < 1) { $curr.addClass('hl') } else { $curr.removeClass('hl') } } } function bindEvent() { $('#app').undelegate().delegate('button', 'click', function (e) { var $this = $(this) var isActive = $this.hasClass('.active') var isDisable = $this.hasClass('disabled') if (!isActive) { handleNormalClick($this) if (isDisable) { handleDisableClick($this) } else { selectedCache[$this.parents('dl').eq(0).data('idx')] = $this.val() } updateStatus(getSelectedItem()) highLighAttr() showResult() } }) $('button').each(function () { var value = $(this).val() if (!res[value] && !$(this).hasClass('active')) { $(this).addClass('disabled') } }) } function showResult() { var result = getSelectedItem() var s = [] for (var i = 0; i < result.length; i++) { var item = result[i]; if (!!item) { s.push(item) } } if (s.length == keys.length) { var curr = res[s.join(spliter)] if (curr) { s = s.concat(curr.skus) } $('#msg').html('已選擇:' + s.join('\u3000-\u3000')) } } function updateData() { data = JSON.parse($('#data_area').val()) init(data) } function setTitle(el) { var title = $(el).data('title'); if (title) $(el).attr('title', title); } function setAllTitle() { $('#app').find('button').each(setTitle) } function init(data) { res = {} r = {} keys = [] selectedCache = [] for (var attr_key in data[0]) { if (!data[0].hasOwnProperty(attr_key)) continue; if (attr_key != 'skuId') keys.push(attr_key) } setAllTitle(); r = combineAttr(data, keys) render(r.result) buildResult(r.items) updateStatus(getSelectedItem()) showResult() bindEvent() } init(data) </script> </body> </html>