速度提升幾百倍,記一次數據結構在實際工做中的運用

這段時間寫了一堆源碼解析,這篇文章想換換口味,跟你們分享一個我工做中遇到的案例。畢竟做爲一個打工人,上班除了摸魚看源碼外,磚仍是要搬的。本文會分享一個使用恰當的數據結構來進行性能優化,從而大幅提升響應速度的故事,提升有幾百倍那麼多。javascript

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

image.png

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

本文可運行的示例代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DataStructureAndAlgorithm/OptimizeVariationsnode

爲何沒人作三層選項?

一兩家不作這個,多是各家的需求不同,可是你們都不作,感受事情不對頭。通過仔細分析後,我以爲不作三層選項可能有如下兩個緣由:git

1. 這多是個僞需求

上面這個鞋子有11種顏色,11種尺碼,意味着這些選項後面對應的是11 * 11,總共121個商品。若是再來個第三層選項,假設第三層也有11個選項,那對應的商品總共就是11 * 11 * 11,也就是1331個商品,好多店鋪總共可能都沒有1331個商品。也就是說,第三層選項多是個僞需求,用戶並無那麼多選項放在第三層,仍是以上面的鞋子爲例,除了顏色,尺碼外,非要再添一個層級,那隻能是性別了,也就是男鞋和女鞋。對於男鞋和女鞋來講,版型,尺碼這些很不同,通常都不會放到一個商品下面,更經常使用的作法是分紅兩個商品,各自有本身的顏色和尺碼。github

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裏面,查找的時候找不到這個組合,那就會將它變爲灰色,不能夠點。api

因此對於限量版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

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

相關文章
相關標籤/搜索