速度提高几百倍,記一次資料結構在實際工作中的運用

蔣鵬飛發表於2020-11-24

這段時間寫了一堆原始碼解析,這篇文章想換換口味,跟大家分享一個我工作中遇到的案例。畢竟作為一個打工人,上班除了摸魚看原始碼外,磚還是要搬的。本文會分享一個使用恰當的資料結構來進行效能優化,從而大幅提高響應速度的故事,提高有幾百倍那麼多。

事情是這樣的,我現在供職一家外企,我們有一個給外國人用的線下賣貨的APP,賣的商品有衣服,鞋子,可樂什麼的。某天,產品經理找到我,提了一個需求:需要支援三層的產品選項。聽到這個需求,我第一反應是我好像沒有見到過三層的產品選項,畢竟我也是一個十來年的資深剁手黨,一般的產品選項好像最多兩層,比如下面是某電商APP一個典型的鞋子的選項:

image-20201116172602218

這個鞋子就是兩層產品選項,一個是顏色,一個是尺碼,顏色總共有11種,尺碼總共也是11種。為了驗證我的直覺,我把我手機上所有的購物APP,啥淘寶,京東,拼多多,蘇寧易購全部開啟看了一遍。在我看過的商品中,沒有發現一個商品有三層選項的,最多也就兩層

本文可執行的示例程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DataStructureAndAlgorithm/OptimizeVariations

為什麼沒人做三層選項?

一兩家不做這個,可能是各家的需求不一樣,但是大家都不做,感覺事情不對頭。經過仔細分析後,我覺得不做三層選項可能有以下兩個原因:

1. 這可能是個偽需求

上面這個鞋子有11種顏色,11種尺碼,意味著這些選項後面對應的是11 * 11,總共121個商品。如果再來個第三層選項,假設第三層也有11個選項,那對應的商品總共就是11 * 11 * 11,也就是1331個商品,好多店鋪總共可能都沒有1331個商品。也就是說,第三層選項可能是個偽需求,使用者並沒有那麼多選項放在第三層,還是以上面的鞋子為例,除了顏色,尺碼外,非要再添一個層級,那隻能是性別了,也就是男鞋和女鞋。對於男鞋和女鞋來說,版型,尺碼這些很不一樣,一般都不會放到一個商品下面,更常用的做法是分成兩個商品,各自有自己的顏色和尺碼。

2. 有效能問題

僅僅是加上第三層選項這個功能並沒有什麼難的,也就是多展示幾個可以點選的按鈕而已,點選邏輯跟兩層選項並沒有太大區別。但是細想下去,我發現了他有潛在的效能問題。以上面這雙鞋子為例,我從後端API拿到的資料是這樣的:

const merchandise = {
  // variations存放的是所有選項
  variations: [
    {
      name: '顏色',
      values: [
        { name: '限量版574海軍藍' },
        { name: '限量版574白粉' },
        // 下面還有9個
      ]
    },
    {
      name: '尺碼',
      values: [
        { name: '38' },
        { name: '39' },
        // 下面還有9個
      ]
    },
  ],
  // products陣列存放的是所有商品
  products: [
    {
      id: 1,
      price: 208,
      // 與上面variations的對應關係在每個商品的variationMappings裡面
      variationMappings: [
        { name: '顏色', value: '限量版574白粉' },
        { name: '尺碼', value: '38'},
      ]
    },
    // 下面還有一百多個產品
  ]
}

上面這個結構本身還是挺清晰的,merchandise.variations是一個陣列,有幾層選項,這個陣列就有幾個物件,每個物件的name就是當前層級的名字,values就是當前層級包含的選項,所以merchandise.variations可以直接拿來顯示在UI上,將他們按照層級渲染成按鈕就行。

上面圖片中,使用者選擇了第一層的限量版574白粉,第二層的4041等不存在的商品就自動灰掉了。用上面的資料結構可以做到這個功能,當使用者選擇限量版574白粉的時候,我們就去遍歷merchandise.products這個陣列,這個陣列的一個項就是一個商品,這個商品上的variationMappings會有當前商品的顏色尺碼資訊。對於我當前的專案來說,如果這個商品可以賣,他就會在merchandise.products這個陣列裡面,如果不可以賣,這個陣列裡面壓根就不會有這個商品。比如上圖的限量版574白粉40碼的組合就不會出現在merchandise.products裡面,查詢的時候找不到這個組合,那就會將它變為灰色,不可以點。

所以對於限量版574白粉40這個鞋子來說,為了知道它需不需要灰掉,我需要整個遍歷merchandise.products這個陣列。按照前面說的11個顏色,11個尺碼來說,最多會有121個商品,也就是最多查詢121次。同樣的要知道限量版574白粉41這個商品可以不可以賣,又要整個遍歷商品陣列,11個尺碼就需要將商品陣列整個遍歷11次。對於兩層選項來說,11 * 11已經算比較多的了,每個尺碼百來次運算可能還不會有嚴重的效能問題。但是如果再加一層選項,新加這層假如也有11個可選項,這複雜度瞬間就增加了一個指數,從\(O(n^2)\)變成\(O(n^3)\)!現在我們的商品總數是11 * 11 * 11,也就是1331個商品,假如第三層是性別,現在為了知道限量版574白粉40男性這個商品可不可以賣,我需要遍歷1331個商品,如果遍歷121個商品需要20ms,還比較流暢,那遍歷1331個商品就需要220ms,這明顯可以感覺到卡頓了,在某些硬體較差的裝置上,這種卡頓會更嚴重,變得不可接受了。而且我們APP使用的技術是React Native,本身效能就比原生差,這樣一來,使用者可能就怒而解除安裝了!

我拿著上述對需求的疑問,和對效能的擔心找到了產品經理,發生瞭如下對話:

我:大佬,我發現市面上好像沒有APP支援三層選項的,這個需求是不是有問題哦,而且三層選項相較於兩層選項來說,複雜度是指數增長,可能會有效能問題,使用者用起來會卡的。

產品經理:兄弟,你看的都是國內的APP,但是我們這個是給外國人用的,人家外國人就是習慣這麼用,我們要想賣的出去就得滿足他們的需求。太卡了肯定不行,效能問題,想辦法解決嘛,這就是在UI上再加幾個按鈕,設計圖都跟以前是一樣的,給你兩天時間夠了吧~

我:啊!?額。。。哦。。。

我們也不認識幾個外國人,我們也不敢再問,都說了是使用者需求,我們必須滿足了產品才賣的出去,產品賣出去了我們才有飯吃,想辦法解決吧!

解決方案

看來這個需求是必須要做了,這個功能並不複雜,因為三層選項可以沿用兩層選項的方案,繼續去遍歷商品陣列,但是這個複雜度增長是指數級的,即從\(O(n^2)\)變成\(O(n^3)\),使用者用起來會卡。現在,我需要思考一下,有沒有其他方案可以提高效能。經過仔細思考,我發現,這種指數級的複雜度增長是來自於我們整個陣列的遍歷,如果我能夠找到一個方法不去遍歷這個陣列,立即就能找到限量版574白粉40男性對應的商品存不存在就好了。

這個具體的問題轉換一下,其實就是:在一個陣列中,通過特定的過濾條件,查詢符合條件的一個項。嗯,查詢,聽起來蠻耳熟的,現在我之所以需要去遍歷這個陣列,是因為這些查詢條件跟商品間沒有一個直接的對應關係,如果我能建立一個直接的對應關係,不就可以一下就找到了嗎?我想到了:查詢樹假如我重組這些層級關係,將它們組織為一顆樹,每個商品都對應樹上的一個葉子節點,我可以將三層選項的查詢複雜度從\(O(n^3)\)降到\(O(1)\)

兩層查詢樹

為了說明白這個演算法,我先簡化這個問題,假設我們現在有兩層選項,顏色尺碼,每層選項有兩個可選項:

  1. 顏色:白色,紅色
  2. 尺碼:39,40

我們現在對應有4個商品:

  1. 一號商品:productId為1,白色,39碼
  2. 二號商品:productId為2,白色,40碼
  3. 三號商品:productId為3,紅色,39碼
  4. 四號商品:productId為4,紅色,40碼

如果按照最簡單的做法,為了查詢紅色39碼鞋子存不存在,我們需要遍歷所有的這四個商品,這時候的時間複雜度為\(O(n^2)\)。但是如果我們構建像下面這樣一顆樹,可以將時間複雜度降到\(O(1)\)

image-20201117160534500

上面這顆樹,我們忽略root節點,在本例中他並沒有什麼用,僅僅是一個樹的入口,這棵樹的第一層淡黃色節點是我們第一層選項顏色,第二層淡藍色節點是我們的第二層選項尺碼,只是每個顏色節點都會對應所有的尺碼,這樣我們最後第二層的葉子節點其實就對應了具體的商品。現在我們要查詢紅色39碼鞋子,只需要看圖中紅色箭頭指向的節點上有沒有商品就行了。

那這種資料結構在JS中該怎麼表示呢?其實很簡單,一個物件就行了,像這樣:

const tree = {
  "顏色:白色": {
    "尺碼:39": { productId: 1 },
    "尺碼:40": { productId: 2 }
  },
  "顏色:紅色": {
    "尺碼:39": { productId: 3 },
    "尺碼:40": { productId: 4 }
  }
}

有了上面這個資料結構,我們要查詢紅色39碼直接取值tree["顏色:紅色"]["尺碼:39"]就行了,這個複雜度瞬間就變為\(O(1)\)了。

三層查詢樹

理解了上面的兩層查詢樹,要將它擴充套件到三層就簡單了,直接再加一層就行了。假如我們現在第三層選項是性別,有兩個可選項,那我們的查詢樹就是這樣子的:

image-20201118133333379

對應的JS物件:

const tree = {
  "顏色:白色": {
    "尺碼:39": { 
    	"性別:男": { productId: 1 },
      "性別:女": { productId: 2 },
    },
    "尺碼:40": { 
    	"性別:男": { productId: 3 },
      "性別:女": { productId: 4 },
    }
  },
  "顏色:紅色": {
    "尺碼:39": { 
    	"性別:男": { productId: 5 },
      "性別:女": { productId: 6 },
    },
    "尺碼:40": { 
    	"性別:男": { productId: 7 },
      "性別:女": { productId: 8 },
    }
  }
}

同樣的,假如我們要查詢一個白色的,39碼的鞋子,直接tree["顏色:白色"]["尺碼:39"]["性別:男"]就行了,這個時間複雜度也是\(O(1)\)

寫程式碼

上面演算法都弄明白了,剩下的就是寫程式碼了,我們主要需要寫的程式碼就是用API返回的資料構建一個上面的tree這種結構就行了,一次遍歷就可以做到。比如上面這個三層查詢樹對應的API返回的結構是這樣的:

const merchandise = {
  variations: [
    {
      name: '顏色',
      values: [
        { name: '白色' },
        { name: '紅色' },
      ]
    },
    {
      name: '尺碼',
      values: [
        { name: '39' },
        { name: '40' },
      ]
    },
    {
      name: '性別',
      values: [
        { name: '男' },
        { name: '女' },
      ]
    },
  ],
  products: [
    {
      id: 1,
      variationMappings: [
        { name: '顏色', value: '白色' },
        { name: '尺碼', value: '39' },
        { name: '性別', value: '男' }
      ]
    }
    // 下面還有7個商品,我就不重複了
  ]
}

為了將API返回的資料轉換為我們的樹形結構資料我們寫一個方法:

function buildTree(apiData) {
  const tree = {};
  const { variations, products } = apiData;

  // 先用variations將樹形結構構建出來,葉子節點預設值為null
  addNode(tree, 0);
  function addNode(root, deep) {
    const variationName = variations[deep].name;
    const variationValues = variations[deep].values;

    for (let i = 0; i < variationValues.length; i++) {
      const nodeName = `${variationName}:${variationValues[i].name}`;
      if (deep === 2) {
        root[nodeName] = null
      } else {
        root[nodeName] = {};
        addNode(root[nodeName], deep + 1);
      }
    }
  }

  // 然後遍歷一次products給樹的葉子節點填上值
  for (let i = 0; i < products.length; i++) {
    const product = products[i];
    const { variationMappings } = product;
    const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`;
    const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`;
    const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`;
    tree[level1Name][level2Name][level3Name] = product;
  }

  // 最後返回構建好的樹
  return tree;
}

然後用上面的API測試資料執行下看下效果,發現構建出來的樹完全符合我們的預期:

image-20201117173553941

這就好了嗎?

現在我們有了一顆查詢樹,當使用者選擇紅色40碼後,為了知道對應的可不可以點,我們不需要去遍歷所有的商品了,而是可以直接從這個結構上取值。但是這就大功告成了嗎?並沒有!再仔細看下我們構建出來的資料結構,層級關係是固定的,第一層是顏色,第二層是尺碼,第三層是性別,而對應的商品是放在第三層性別上的。也就是說使用這個結構,使用者必須嚴格按照,先選顏色,再選尺碼,然後我們看看性別這裡哪個該灰掉。如果他不按照這個順序,比如他先選了性別,然後選尺碼40,這時候我們應該計算最後一個層級顏色哪些該灰掉。但是使用上面這個結構我們是算不出來的,因為我們並沒有tree["性別:男"]["尺碼:40"]這個物件。

這怎麼辦呢?我們沒有性別-尺碼-顏色這種順序的樹,那我們就建一顆唄!這當然是個方法,但是使用者還可能有其他的操作順序呀,如果我們要覆蓋使用者所有可能的操作順序,總共需要多少樹呢?這其實是性別尺碼顏色這三個變數的一個全排列,也就是\(A_3^3\),總共6顆樹。像我這樣的懶人,讓我建6棵樹,我實在懶得幹。如果不建這麼多樹,需求又覆蓋不了,怎麼辦呢,有沒有偷懶的辦法呢?如果我能在需求上動點手腳,是不是可以規避這個問題?帶著這個思路,我想到了兩點:

1. 給一個預設值。

使用者開啟商品詳情頁的時候,預設選中第一個可售商品。這樣就相當於我們一開始就幫使用者按照顏色-尺碼-性別這個順序選中了一個值,給了他一個預設的操作順序。

2. 不提供取消功能,只能切換選項

如果提供取消功能,他將我們提供的顏色-尺碼-性別預設選項取消掉,又可以選成性別-尺碼-顏色了。不提供取消功能,只能通過選擇其他選項來切換,只能從紅色換成白色,而不能取消紅色,其他的一樣。這樣我們就能永遠保證顏色-尺碼-性別這個順序,使用者操作只是只是每個層級選中的值不一樣,層級順序並不會變化,我們的查詢樹就一直有效了。而且我發現某些購物網站也不能取消選項,不知道他們是不是也遇到了類似的問題。

對需求做這兩點修改並不會對使用者體驗造成多大影響,跟產品經理商量後,她也同意了。這樣我就從需求上幹掉了另外5棵樹,偷懶成功!

下面是三層選項跑起來的樣子:

Nov-18-2020 17-42-28

還有一件事

前面的方案我們解決了查詢的效能問題,但是引入了一個新問題,那就是需要建立這顆查詢樹。建立這顆查詢樹還是需要對商品列表進行一次遍歷,這是不可避免的,為了更順滑的使用者體驗,我們應該儘量將這個建立過程隱藏在使用者感知不到的地方。我這裡是將它整合到了商品詳情頁的載入狀態中,使用者點選進入商品詳情頁,我們要去API取資料,不可避免的會有一個載入狀態,會轉個圈什麼的。我將這個遍歷過程也做到了這個轉圈中,當API資料返回,並且查詢樹建立完成後,轉圈才會結束。這在理論上會延長轉圈的時間,但是本地的遍歷再慢也會比網路請求快點,所以使用者感知並不明顯。當轉圈結束後,所有資料都準備就緒了,使用者操作都是\(O(1)\)的複雜度,做到了真正的絲般順滑~

為什麼不讓後端建立這棵樹?

上面的方案都是在前端建立這顆樹,那有沒有可能後端一開始返回的資料就是這樣的,我直接拿來用就行,這樣我又可以偷懶了~我還真去找過後端,可他給我說:“我也想偷懶!”開個玩笑,真是情況是,這個商品API是另一個團隊維護的微服務,他們提供的資料不僅僅給我這一個終端APP使用,也給公司其他產品使用,所以要改返回結構涉及面太大,根本改不動。

封裝程式碼

其實我們這個方案實現本身是比較獨立的,其他人要是用的話,他也不關心你裡面是棵樹還是顆草,只要傳入選擇條件,能夠返回正確的商品就行,所以我們可以將它封裝成一個類。

class VariationSearchMap {
    constructor(apiData) {
        this.tree = this.buildTree(apiData);
    }

  	// 這就是前面那個構造樹的方法
    buildTree(apiData) {
        const tree = {};
        const { variations, products } = apiData;

        // 先用variations將樹形結構構建出來,葉子節點預設值為null
        addNode(tree, 0);
        function addNode(root, deep) {
            const variationName = variations[deep].name;
            const variationValues = variations[deep].values;

            for (let i = 0; i < variationValues.length; i++) {
                const nodeName = `${variationName}:${variationValues[i].name}`;
                if (deep === variations.length - 1) {
                    root[nodeName] = null;
                } else {
                    root[nodeName] = {};
                    addNode(root[nodeName], deep + 1);
                }
            }
        }

        // 然後遍歷一次products給樹的葉子節點填上值
        for (let i = 0; i < products.length; i++) {
            const product = products[i];
            const { variationMappings } = product;
            const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`;
            const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`;
            const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`;
            tree[level1Name][level2Name][level3Name] = product;
        }

        // 最後返回構建好的樹
        return tree;
    }

    // 新增一個方法來搜尋商品,引數結構和API資料的variationMappings一樣
    findProductByVariationMappings(variationMappings) {
        const level1Name = `${variationMappings[0].name}:${variationMappings[0].value}`;
        const level2Name = `${variationMappings[1].name}:${variationMappings[1].value}`;
        const level3Name = `${variationMappings[2].name}:${variationMappings[2].value}`;

        const product = this.tree[level1Name][level2Name][level3Name];

        return product;
    }
}

然後使用的時候直接new一下就行:

const variationSearchMap = new VariationSearchMap(apiData);    // new一個例項出來

// 然後就可以用這個例項進行搜尋了
const searchCriteria = [
    { name: '顏色', value: '紅色' },
    { name: '尺碼', value: '40' },
    { name: '性別', value: '女' }
];
const matchedProduct = variationSearchMap.findProductByVariationMappings(searchCriteria);
console.log('matchedProduct', matchedProduct);    // { productId: 8 }

總結

本文講述了一個我工作中實際遇到的需求,分享了我的實現和優化思路,供大家參考。我的實現方案不一定完美,如果大家有更好的方案,歡迎在評論區討論~

本文可執行的示例程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DataStructureAndAlgorithm/OptimizeVariations

下面再來回顧下本文的要點:

  1. 本文要實現的需求是一個商品的三層選項。
  2. 當使用者選擇了兩層後,第三層選項應該自動計算出哪些能賣,哪些不能賣。
  3. 鑑於後端API返回選項和商品間沒有直接的對應關係,為了找出能賣還是不能賣,我們需要遍歷所有商品。
  4. 當總商品數量不多的時候,所有商品遍歷可能不會產生明顯的效能問題。
  5. 但是當選項增加到三層,商品數量的增加是指數級的,效能問題就會顯現出來。
  6. 對於\(O(n^3)\)這種寫程式碼時就能預見的效能問題,我們不用等著報BUG了才處理,而是開發時直接就解決了。
  7. 本例要解決的是一個查詢問題,所以我想到了建一顆樹,直接將\(O(n^3)\)的複雜度降到了\(O(1)\)
  8. 但是一顆樹並不能覆蓋所有的使用者操作,要覆蓋所有的使用者操作需要6棵樹。
  9. 出於偷懶的目的,我跟產品經理商量,調整了需求和互動砍掉了5顆樹。真實原因是樹太多了,會佔用更多的記憶體空間,也不好維護。有時候適當的調整需求和互動也可以達到優化效能的效果,效能優化可以將互動和技術結合起來思考。
  10. 這個樹的搜尋模組可以單獨封裝成一個類,外部使用者,不需要知道細節,直接呼叫介面查詢就行。
  11. 前端會點資料結構還是有用的,本文這種場景下還很有必要。

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

相關文章