{"id":13829546,"url":"https://github.com/xieyezi/sku-algorithm","last_synced_at":"2025-07-24T20:09:53.145Z","repository":{"id":41568661,"uuid":"273675903","full_name":"xieyezi/sku-algorithm","owner":"xieyezi","description":"Product multi-variant selection - SKU algorithm","archived":false,"fork":false,"pushed_at":"2024-10-29T09:12:10.000Z","size":696,"stargazers_count":164,"open_issues_count":3,"forks_count":56,"subscribers_count":5,"default_branch":"master","last_synced_at":"2025-07-19T12:02:25.973Z","etag":null,"topics":["algorithms","react","react-hook","redux","sku","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/xieyezi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2020-06-20T09:16:01.000Z","updated_at":"2025-06-25T02:46:51.000Z","dependencies_parsed_at":"2024-08-04T10:01:36.694Z","dependency_job_id":"17f4d916-57b2-4c06-9bac-d1ac38527740","html_url":"https://github.com/xieyezi/sku-algorithm","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/xieyezi/sku-algorithm","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xieyezi%2Fsku-algorithm","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xieyezi%2Fsku-algorithm/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xieyezi%2Fsku-algorithm/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xieyezi%2Fsku-algorithm/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/xieyezi","download_url":"https://codeload.github.com/xieyezi/sku-algorithm/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/xieyezi%2Fsku-algorithm/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":266898193,"owners_count":24002827,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-07-24T02:00:09.469Z","response_time":99,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["algorithms","react","react-hook","redux","sku","typescript"],"created_at":"2024-08-04T10:00:39.289Z","updated_at":"2025-07-24T20:09:53.081Z","avatar_url":"https://github.com/xieyezi.png","language":"TypeScript","readme":"\n# 商品多规格选择-前端 sku 算法\n\n![sku.png](https://i.loli.net/2020/06/21/xld2cADgjnuWF7J.png)\n\n相信大家看到这张图片就知道我们这篇文章要讲什么了，没错就是-商品多规格选择的解法。\n\n近来在掘金上面看见大家都在研究“商品多规格选择”的问题，例如`晨曦大佬`的[前端电商 sku 的全排列算法很难吗？学会这个套路，彻底掌握排列组合。](https://juejin.im/post/5ee6d9026fb9a047e60815f1) 在这篇文章里面，大佬写明了如何实现`sku`的全排列，思路非常的棒，但是并没有紧贴业务场景。真正的业务场景是，我们要根据用户每一次选择的规格，找出剩下可选的规格和不可选的规格，表现在前端页面上：就是将不可选的规格置灰，也就是如下效果（可以点击[这里](https://codesandbox.io/s/sku-algorithm-pionk?file=/src/redux/reducer/spec-reducer.ts)查看最终效果）：\n\n![sku.gif](https://i.loli.net/2020/06/21/hdQRZwjpvknKNru.gif)\n\n那么今天我们就来讲讲这个问题的一个解决方法，要讲明白很难，但是我相信你看了这篇文章之后，`sku`就再也难不倒你了。\n\n## 什么是 sku\n\n在介绍具体解法之前，我们先来介绍一下什么是`sku`? `sku`是会计学中的一个名词，被称作`库存单元`。说人话？简单来讲就是，我们上图 👆 中每一个单规格选项，例如`深空灰色`、`64G`,都是一个规格(`sku`)。商品和`sku`属于一对多的关系，也就是我们可以选择多个`sku`来确定到某个具体的商品:\n\n![商品.png](https://i.loli.net/2020/06/21/xpPuVWzkfFhD9t6.png)\n\n## 业务场景\n\n可以这么说，只要是做电商类相关的产品，比如购物 APP、购物网站等等，都会遇到这么一个场景，每个商品对应着多个规格，用户可以根据不同的规格组合，选择出自己想要的产品。我们自己在生活中也会经常用到这个功能，然而就是这样一个简单的功能，却难倒了很多小伙伴。\n\n笔者也是一样，刚开始遇到这个场景，笔者觉得应该一个下午就能搞定，完美收工，奈何还是太过于年轻，搞了差不多两天，在网上查阅了很多相关的文章和资料，但是不得其解，最后没有办法，只能硬着头皮采用暴力求解（也就是不断循环）的方法来解决的，时间复杂度贼高，达到了`O(m*n)`也就是`O(n²)`,这种实现方法其实也不是不行（~~能跑就行~~），对吧。但是后来笔者发现，当一个商品的规格非常非常多、并且用户的设备性能不是那么好的情况下，那么这种实现方式就会导致运行时间过长，表现在页面上就是：当用户点击了一个规格，会有明显的卡顿，那怎么行，客户都流失了，老板还怎么买法拉利 🤔️？所以笔者又开始了研究。\n\n## 图\n\n一个偶然的机会，笔者在逛知乎的时候，看到了有人在讨论`图`，这个`数据结构`，突然灵光一现，貌似咱们的`多规格选择`也可以用图来作求解方法，后来一尝试，还真的可行。而且时间复杂度只有`O(n)`，简直完美。所以我们下面来介绍一下`图`，什么是`图`？相信大学学过`数据结构与算法`的同学都应该知道，不过应该已经忘得一干二净了。\n\n### 什么是图\n\n`图`其实是数学的一个分支。它以图为研究对象。图论中的图是由若干给定的点及连接两点的线所构成的图形，这种图形通常用来描述某些事物之间的某种特定关系，用点代表事物，用连接两点的线表示相应两个事物间具有这种关系：\n\n![图.jpg](https://i.loli.net/2020/06/21/aK6qZJw2Odm1Q9u.jpg)\n\n`图`通常有如下分类:\n\n- 分为有向图和无向图\n\n- 分为有权图和无权图\n\n好了知道这两个概念就差不多了,当然如果想了解更多图多概念，请看[这里](https://zhuanlan.zhihu.com/p/25498681)\n\n那么我们需要用到的是无向图，什么是无向图呢，就像这样：\n\n![无向图.png](https://i.loli.net/2020/06/21/sMaedvPVSCfwFO9.png)\n\n两个顶点之间如果有连线，则表示这两个顶点是互通的。小伙伴们看到这里可能会懵逼了，说了这么多，好像跟我们要解决的问题没关系啊。小伙伴们现在想一想：用户在选择规格的时候，肯定是没有先后顺序的，假设我们现在把每种规格看作是`无向图`的一个`顶点`的话，我们可以根据这些`单项规格`的组合规格，就可以画出一个像上图一样的`无向图`。\n\n### 邻接矩阵\n\n假设我们已经画出了如上 👆 的无向图，那么我们如何将这个图用咱们的代码来表示呢？这里就用到了`邻接矩阵`\n\n`邻接矩阵`其实是《线性代数》里面的概念，相信很多小伙伴都不会陌生，我们在代码中，表示它的方法是用一个`n x n`的二维数组来抽象邻接矩阵。让我们来把上面 👆 这个无向图用邻接矩阵(二维数组)表示出来：\n\n![邻接矩阵.png](https://i.loli.net/2020/06/21/7h6IE2JgwcxqoXu.png)\n\n很显然，如果两个顶点互通（有连线），那么它们对应下标的值则为 1，否则为 0。\n\n好了，下面开始逐步都是高能，请小伙伴们认真观看。\n\n假设现在我们有如下规格列表：\n\n```js\nspecList: [\n  { title: \"颜色\", list: [\"红色\", \"紫色\"] },\n  { title: \"套餐\", list: [\"套餐一\", \"套餐二\"] },\n  { title: \"内存\", list: [\"64G\", \"128G\", \"256G\"] },\n];\n```\n\n可供选择的规格组合有：\n\n```js\nspecCombinationList: [\n    { id: \"1\", specs: [\"紫色\", \"套餐一\", \"64G\"] },\n    { id: \"2\", specs: [\"紫色\", \"套餐一\", \"128G\"] },\n    { id: \"3\", specs: [\"紫色\", \"套餐二\", \"128G\"] },\n    { id: \"4\", specs: [\"红色\", \"套餐二\", \"256G\"] }\n  ],\n```\n\n首先，我们根据`specList`知道：我们有“`颜色`”、“`套餐`”、“`内存`”三种规格类别。分别有`红色`、`紫色`、`套餐一`、`套餐二`、`64G`、`128G`、`256G`这些单项规格。每个单项规格作为一个顶点，所以就有如下顶点：\n\n![顶点.png](https://i.loli.net/2020/06/21/RhnGq17toK9xdj5.png)\n\n然后我们根据`specCombinationList`，我们可以知道，哪些规格的组合是可选的。好了我们要开始画图了。\n\n根据`{ id: \"1\", specs: [\"紫色\", \"套餐一\", \"64G\"] },`我们可以画出：\n\n![开始画图.png](https://i.loli.net/2020/06/21/V9UXJQM1jC3sbAw.png)\n\n接下来依葫芦画瓢：我们可以根据`specCombinationList`剩下的数据画出如下的图：\n\n![规格组合.png](https://i.loli.net/2020/06/21/tQcJTjf6PXGnp3S.png)\n\n好了，我们已经根据`specCombinationList`(也就是可选规格组合)将我们的规格无向图画完了。现在我们来模拟一下用户的选择:\n\n```js\nspecCombinationList: [\n    { id: \"1\", specs: [\"紫色\", \"套餐一\", \"64G\"] },\n    { id: \"2\", specs: [\"紫色\", \"套餐一\", \"128G\"] },\n    { id: \"3\", specs: [\"紫色\", \"套餐二\", \"128G\"] },\n    { id: \"4\", specs: [\"红色\", \"套餐二\", \"256G\"] }\n  ],\n```\n\n假设用户先选择了`紫色`、根据`data`,我们发现`套餐一`、`套餐二`、`64G`、`128G`是可选的，这个时候我们发现一个问题：显然跟`紫色`同级的`红色`其实也是可选的。所以这个图其实我们还没有画完。所以相同类型的规格，只要是在可选规格里面的，他们其实是应该连接起来的：\n\n![全部规格.png](https://i.loli.net/2020/06/21/2huzUjsSYfT3mbt.png)\n\n好了，无向图画好了，现在我们将它映射到`邻接矩阵`上面（这一步强烈建议小伙伴们拿出纸笔来一起画一画）：\n\n![顶点邻接矩阵.png](https://i.loli.net/2020/06/21/sz5d9k34wJnXmNM.png)\n\n到了这一步，恭喜你，你已经懂了一大半了 👏。\n\n好了，到这我们就可以公布最终结论了：\n\n- 当用户初次进入该页面时，所有的规格均可选：\n\n![都可选.png](https://i.loli.net/2020/06/21/Jgy4dzTfwDEmMlR.png)\n\n- 当用户选择了某个顶点后，当前顶点所有可选项均被找出（即是当前顶点所在列值为 1 的顶点）：\n\n![选择一项.png](https://i.loli.net/2020/06/21/Gnkr3ZSwjcIRJqW.png)\n\n- 选取多个顶点时，可选项是各个顶点邻接点的交集：（即是选中顶点所在列的交集）\n\n![多个顶点交集.png](https://i.loli.net/2020/06/21/xBTaGw9znPtVXED.png)\n\n## 代码实现\n\n说真的，我觉得小伙伴们看明白了我上面 👆 这些讲解，相信你已经完全懂了该如何实现“`多规格选择`”算法了。不过有句话叫做：光说不练假把式！那下面我们就一起来捋一捋，用代码如何实现吧，笔者这里用的前端框架是`react`，明白思路了，用什么框架都一样的哦。\n\n这里先说下思路：\n\n1、根据规格列表（`specList`）创建邻接矩阵（数组）\n\n2、根据可选规格组合(`specCombinationList`)填写顶点的值\n\n3、获得所有可选顶点，然后根据可选顶点填写同级顶点的值\n\n### 创建邻接矩阵\n\n首先，我们需要提供一个类来创建邻接矩阵。一个邻接矩阵，首先需要传入一个顶点数组：`vertex`,需要一个用来装邻接矩阵的数组：`adjoinArray`。刚刚我们上面说到了，这个类还必须提供计算`并集`和`交集`的方法：\n\n```ts\nexport type AdjoinType = Array\u003cstring\u003e;\n\nexport default class AdjoinMatrix {\n  vertex: AdjoinType; // 顶点数组\n  quantity: number; // 矩阵长度\n  adjoinArray: Array\u003cnumber\u003e; // 矩阵数组\n\n  constructor(vertx: AdjoinType) {\n    this.vertex = vertx;\n    this.quantity = this.vertex.length;\n    this.adjoinArray = [];\n    this.init();\n  }\n  // 初始化数组\n  init() {\n    this.adjoinArray = Array(this.quantity * this.quantity).fill(0);\n  }\n\n  /*\n   * @param id string\n   * @param sides Array\u003cstring\u003e\n   *  传入一个顶点，和当前顶点可达的顶点数组，将对应位置置为1\n   */\n  setAdjoinVertexs(id: string, sides: AdjoinType) {\n    const pIndex = this.vertex.indexOf(id);\n    sides.forEach((item) =\u003e {\n      const index = this.vertex.indexOf(item);\n      this.adjoinArray[pIndex * this.quantity + index] = 1;\n    });\n  }\n\n  /*\n   * @param id string\n   * 传入顶点的值，获取该顶点的列\n   */\n  getVertexCol(id: string) {\n    const index = this.vertex.indexOf(id);\n    const col: Array\u003cnumber\u003e = [];\n    this.vertex.forEach((item, pIndex) =\u003e {\n      col.push(this.adjoinArray[index + this.quantity * pIndex]);\n    });\n    return col;\n  }\n\n  /*\n   * @param params Array\u003cstring\u003e\n   * 传入一个顶点数组，求出该数组所有顶点的列的合\n   */\n  getColSum(params: AdjoinType) {\n    const paramsVertex = params.map((id) =\u003e this.getVertexCol(id));\n    const paramsVertexSum: Array\u003cnumber\u003e = [];\n    this.vertex.forEach((item, index) =\u003e {\n      const rowtotal = paramsVertex\n        .map((value) =\u003e value[index])\n        .reduce((total, current) =\u003e {\n          total += current || 0;\n          return total;\n        }, 0);\n      paramsVertexSum.push(rowtotal);\n    });\n    return paramsVertexSum;\n  }\n\n  /*\n   *  @param params Array\u003cstring\u003e\n   * 传入一个顶点数组，求出并集\n   */\n  getCollection(params: AdjoinType) {\n    const paramsColSum = this.getColSum(params);\n    let collections: AdjoinType = [];\n    paramsColSum.forEach((item, index) =\u003e {\n      if (item \u0026\u0026 this.vertex[index]) collections.push(this.vertex[index]);\n    });\n    return collections;\n  }\n\n  /*\n   *  @param params Array\u003cstring\u003e\n   * 传入一个顶点数组，求出交集\n   */\n  getUnions(params: AdjoinType) {\n    const paramsColSum = this.getColSum(params);\n    let unions: AdjoinType = [];\n    paramsColSum.forEach((item, index) =\u003e {\n      if (item \u003e= params.length \u0026\u0026 this.vertex[index]) unions.push(this.vertex[index]);\n    });\n    return unions;\n  }\n}\n```\n\n有了这个类，接下来可以创建一个专门用于生成`商品多规格选择`的类，它继承于`AdjoinMatrix`。\n\n### 创建`多规格选择`邻接矩阵\n\n我们这个多规格选择的邻接矩阵，需要提供一个查询可选顶点的方法：`getSpecscOptions`\n\n```ts\nimport AdjoinMatrix from \"./adjoin-martix\";\nimport { AdjoinType } from \"./adjoin-martix\";\nimport { SpecCategoryType, CommoditySpecsType } from \"../redux/reducer/spec-reducer\";\n\nexport default class SpecAdjoinMatrix extends AdjoinMatrix {\n  specList: Array\u003cCommoditySpecsType\u003e;\n  specCombinationList: Array\u003cSpecCategoryType\u003e;\n\n  constructor(specList: Array\u003cCommoditySpecsType\u003e, specCombinationList: Array\u003cSpecCategoryType\u003e) {\n    super(specList.reduce((total: AdjoinType, current) =\u003e [...total, ...current.list], []));\n    this.specList = specList;\n    this.specCombinationList = specCombinationList;\n    // 根据可选规格列表矩阵创建\n    this.initSpec();\n    // 同级顶点创建\n    this.initSameLevel();\n  }\n\n  /**\n   * 根据可选规格组合填写邻接矩阵的值\n   */\n  initSpec() {\n    this.specCombinationList.forEach((item) =\u003e {\n      this.fillInSpec(item.specs);\n    });\n  }\n  // 填写同级点\n  initSameLevel() {\n    // 获得初始所有可选项\n    const specsOption = this.getCollection(this.vertex);\n    this.specList.forEach((item) =\u003e {\n      const params: AdjoinType = [];\n      // 获取同级别顶点\n      item.list.forEach((value) =\u003e {\n        if (specsOption.includes(value)) params.push(value);\n      });\n      // 同级点位创建\n      this.fillInSpec(params);\n    });\n  }\n  /*\n   * 传入顶点数组，查询出可选规格\n   * @param params\n   */\n  getSpecscOptions(params: AdjoinType) {\n    let specOptionCanchoose: AdjoinType = [];\n    if (params.some(Boolean)) {\n      // 过滤一下选项\n      specOptionCanchoose = this.getUnions(params.filter(Boolean));\n    } else {\n      // 所有可选项\n      specOptionCanchoose = this.getCollection(this.vertex);\n    }\n    return specOptionCanchoose;\n  }\n\n  /**\n   *\n   * @param {*} params [key, key]\n   */\n  fillInSpec(params: AdjoinType) {\n    params.forEach((param) =\u003e {\n      this.setAdjoinVertexs(param, params);\n    });\n  }\n}\n```\n\n### 页面渲染\n\n好了到了这一步，我们已经可以在页面中使用这两个类了：\n\n```tsx\nimport React, { useState, useMemo } from \"react\";\nimport { useSelector } from \"react-redux\";\nimport { RootState } from \"../redux/reducer/root-reducer\";\nimport SpecAdjoinMatrix from \"../utils/spec-adjoin-martix\";\nimport \"./spec.css\";\nconst classNames = require(\"classnames\");\n\nconst Spec: React.FC = () =\u003e {\n  const { specList, specCombinationList } = useSelector((state: RootState) =\u003e state.spec);\n  // 已选择的规格，长度为规格列表的长度\n  const [specsS, setSpecsS] = useState(Array(specList.length).fill(\"\"));\n\n  // 创建一个规格矩阵\n  const specAdjoinMatrix = useMemo(() =\u003e new SpecAdjoinMatrix(specList, specCombinationList), [specList, specCombinationList]);\n  // 获得可选项表\n  const optionSpecs = specAdjoinMatrix.getSpecscOptions(specsS);\n\n  const handleClick = function(bool: boolean, text: string, index: number) {\n    // 排除可选规格里面没有的规格\n    if (specsS[index] !== text \u0026\u0026 !bool) return;\n    // 根据text判断是否已经被选中了\n    specsS[index] = specsS[index] === text ? \"\" : text;\n    setSpecsS(specsS.slice());\n  };\n\n  return (\n    \u003cdiv className=\"container\"\u003e\n      {specList.map(({ title, list }, index) =\u003e (\n        \u003cdiv key={index}\u003e\n          \u003cp className=\"title\"\u003e{title}\u003c/p\u003e\n          \u003cdiv className=\"specBox\"\u003e\n            {list.map((value, i) =\u003e {\n              const isOption = optionSpecs.includes(value); // 当前规格是否可选\n              const isActive = specsS.includes(value); // 当前规格是否被选\n              return (\n                \u003cspan\n                  key={i}\n                  className={classNames({\n                    specOption: isOption,\n                    specAction: isActive,\n                    specDisabled: !isOption,\n                  })}\n                  onClick={() =\u003e handleClick(isOption, value, index)}\n                \u003e\n                  {value}\n                \u003c/span\u003e\n              );\n            })}\n          \u003c/div\u003e\n        \u003c/div\u003e\n      ))}\n    \u003c/div\u003e\n  );\n};\n\nexport default Spec;\n```\n\n好了，打完收工了，如果有小伙伴想看实现效果，可以查看[这里](https://codesandbox.io/s/sku-algorithm-pionk?file=/src/redux/reducer/spec-reducer.ts)，如果有小伙伴想把代码拉到本地看看，那么请点击[这里](https://github.com/xieyezi/sku-algorithm)\n\n## 总结\n\n实践证明：大学学的东西是真的有用的。我们通过`图`，解决了`商品多规格选择`的难题。在求解可选规格的时候，时间复杂度由原来的`O(n²)`变成了`O(n)`。不过值得一提的是，采用`邻接矩阵`来存储`图`，空间复杂度就变成了`O(n²)`了，同时也存在浪费空间的问题，但是`图`肯定不止有`邻接矩阵`这一种存储方法，我们还可以用`链表`来存储`图`，小伙伴们可以自己去试一试。另外如果用`链表`来存储图，空间复杂度会变低，但是时间复杂度会变高，具体如何选择，就看小伙伴们自己权衡了。\n\n以后遇到这个需求，小伙伴们肯定是分分钟实现，提早下班。\n\n我是觉非，码字不易，如果你觉得这篇文章对你有用的话，请给个赞吧！！\n","funding_links":[],"categories":["算法"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fxieyezi%2Fsku-algorithm","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fxieyezi%2Fsku-algorithm","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fxieyezi%2Fsku-algorithm/lists"}