用100行程式碼提升10倍的效能

李熠發表於2018-11-14

提出問題

從一個我常用的面試題,也是真實需求開始聊起:

你需要在前端展示 5000 條甚至更多的資料,每一條資料的資料結構是一個物件,裡面有格式各樣的屬性。每個屬性的值又可以是基本型別,物件,甚至陣列。這裡的物件或者陣列內部的元素又可以繼續包含物件或者陣列並且允許無限巢狀下去。比如

{
  "name": {
    "firstName": "yi",
    "lastName": "li"
  },
  "age": 23,
  "roles": ['developer', 'admin'],
  "projects": [{
    "name": "demo",
    "repo": ""
  }]
}
複製程式碼

頁面上提供一個搜尋框,使用者通過輸入搜尋的內容可以找到包含這個內容的資料。注意,只要任意資料物件的任意屬性值 (比如在上面的資料結構中,只要 name, age, roles 任何一個屬性的值)包含這個關鍵詞即可。如果屬性值是陣列或者物件,那麼陣列的元素或者物件的值繼續對輸入內容進行匹配檢測,並遞迴的檢測下去,只要有命中,便算該資料匹配

如何設計這個功能,讓搜尋功能儘可能的快?

解決思路

如果你稍有程式設計師的敏感度,此時你的腦海裡應該有兩個念頭:

  • 遍歷以及深度優先遍歷是最直接的方式
  • 如果要求夠快的話遍歷我就輸了

的確,遍歷是最簡單但也是最慢的。所以通常的優化方法之一是通過空間換取時間;而另一個方法……稍後再引出。

這裡我們嘗試通過建立字典樹(Trie)來優化搜尋。

如果你還不瞭解什麼是字典樹,下面做簡單的介紹:假設我們有一個簡單的物件,鍵值的對應關係如下:

用100行程式碼提升10倍的效能

我們根據「鍵」的字母出現順次構建出一棵樹出來,葉子節點值即有可能是某個「鍵」的值

用100行程式碼提升10倍的效能

那麼此時無論使用者想訪問任何屬性的值,只要從樹的根節點出發,依據屬性字母出現的順序訪問樹的葉子節點,即可得到該屬性的值。比如當我們想訪問tea時:

用100行程式碼提升10倍的效能

但是在我們需要解決的場景中,我們不需要關心「屬性」,我們只關心「值」是否匹配上搜尋的內容。所以我們只需要對「值」建立字典樹。

假設有以下的物件值

const o = {
  message: 'ack'
  fruit: 'apple',
  unit: 'an',
  name: 'anna',
}
複製程式碼

建立的樹狀結構如下:

root--a
      |--c
         |--k
      |--p
         |--p
            |--l
               |--e    
      |--n
         |--n
            |--a
複製程式碼

當使用者搜尋 apple 時,從a開始訪問,至最後訪問到字母 e 時,若在樹中有對應的節點,表示命中;當使用者搜尋 aha 時,在訪問 h 時就已經無法在樹中找到對應的節點了,表示該物件不符合搜尋條件

但實際工作中我們會有非常多個物件值,多個物件值之間可能有重複的值,所以匹配時,我們要把所有可能的匹配結果都返回。比如

[
  {
    id: 1,
    message: 'ack'
    fruit: 'apple',
    unit: 'an',
    name: 'anna',
  },
  {
    id: 2,
    message: 'ack'
    fruit: 'banana',
    unit: 'an',
    name: 'lee',
  },
]
複製程式碼

上面兩個物件有相同的值 ackan,所以在樹上的葉子節點中我們還要新增物件的 id 辨識資訊

root--a
      |--c
         |--k (ids: [1,2])
      |--p
         |--p
            |--l
               |--e (ids: [1])
      |--n (ids: [1, 2])
         |--n
            |--a (ids: [1])
複製程式碼

這樣當使用者搜尋 an 時,我們能返回所有的匹配項

OK,有了思路之後我們開始實現程式碼。

程式碼實現

假資料

首先要解決的一個問題是如果快速的偽造 5000 條資料?這裡我們使用 randomuser.me/api/ 開源 API。為了簡單起見,我們讓它只返回 gender, email, phone, cell, nat基本資料型別的值,而不返回巢狀結構(物件和陣列)。注意這裡只是為了便於程式碼展示和理解,略去了複雜的結構,也就避免了複雜的程式碼。加入複雜結構之後程式碼其實也沒有大的變化,只是增加了遍歷的邏輯和遞迴邏輯而已。

請求 randomuser.me/api/?result… 結果如下:

{
  "results": [
    {
      "gender": "male",
      "email": "enzo.dumont@example.com",
      "phone": "02-65-13-26-00",
      "cell": "06-09-02-19-99",
      "nat": "FR"
    },
    {
      "gender": "male",
      "email": "gerald.omahony@example.com",
      "phone": "011-376-3811",
      "cell": "081-697-1414",
      "nat": "IE"
    }
    //...
  ]
}
複製程式碼

葉子節點資料結構

根據思路中的描述,資料結構描述如下:

class Leaf {
  constructor(id = "", value = "") {
    this.ids = id ? [id] : [];
    this.value = value;
    this.children = {};
  }
  share(id) {
    this.ids.push(id);
  }
}
複製程式碼

share方法用於向該葉子節點新增多個相同的匹配的id

幫助函式

在編碼的過程中我們需要一些幫助函式,比如:

  • isEmptyObject: 判斷是否是空物件
  • distinct: 移除一個陣列中的重複元素

這兩個函式可以借用lodash類庫實現,即使手動實現起來也很簡單,這裡就不贅述了

另一個重要的方法是normalize,我更習慣將normalize翻譯為「扁平化」(而不是「標準化」),因為這樣更形象。該方法用於將一個陣列裡的物件拆分為 id 與物件的對映關係。

比如將

[
  {
    id: 1,
    message: 'ack'
    fruit: 'apple',
    unit: 'an',
    name: 'anna',
  },
  {
    id: 2,
    message: 'ack'
    fruit: 'banana',
    unit: 'an',
    name: 'lee',
  },
]
複製程式碼

扁平化之後為

{
  '1': {
    id: 1,
    message: 'ack'
    fruit: 'apple',
    unit: 'an',
    name: 'anna',
  },
  '2': {
    id: 2,
    message: 'ack'
    fruit: 'banana',
    unit: 'an',
    name: 'lee',   
  }
}
複製程式碼

之所以要這麼做是為了當檢索結果返回一個 id 陣列時:[1, 2, 3],我們只需要遍歷一邊返回結果就能通過 id 在扁平化的 Map 裡立即找到對應的資料。否則還要不停的遍歷原始資料陣列找到對應的資料.

因為 randomuser.me 返回的資訊中不包含 id 資訊,所以我們暫時用 email 資訊作為唯一標示。normalize 的實現如下:

function normalize(identify, data) {
  const id2Value = {};
  data.forEach(item => {
    const idValue = item[identify];
    id2Value[idValue] = item;
  });
  return id2Value;
}
複製程式碼

構建一棵樹

這部分程式碼就沒有什麼祕密了,完全是按照遞演算法歸構建一顆樹了

fetch("https://randomuser.me/api/?results=5000&inc=gender,email,phone,cell,nat")
  .then(response => {
    return response.json();
  })
  .then(data => {
    const { results } = data;
    const root = new Leaf();
    const identifyKey = "email";

    results.forEach(item => {
      const identifyValue = item[identifyKey];
      Object.values(item).forEach(itemValue => {
        // 注意這裡會把 Number 和 Boolean 型別也字串化
        const stringifiedValue = String(itemValue);
        let tempRoot = root;
        const arraiedStringifiedValue = Array.from(stringifiedValue);
        arraiedStringifiedValue.forEach((character, characterIndex) => {
          const reachEnd =
            characterIndex === arraiedStringifiedValue.length - 1;
          if (!tempRoot.children[character]) {
            tempRoot.children[character] = new Leaf(
              reachEnd ? identifyValue : "",
              character
            );
            tempRoot = tempRoot.children[character];
          } else {
            if (reachEnd) {
              tempRoot.children[character].share(identifyValue);
            }
            tempRoot = tempRoot.children[character];
          }
        });
      });
    });
複製程式碼

模糊搜尋

搜尋部分程式碼也沒有什麼祕密,按圖索驥而已:

function searchBlurry(root, keyword, userMap) {
  const keywordArr = Array.from(String(keyword));
  let tempRoot = root;
  let result = [];

  for (let i = 0; i < keywordArr.length; i++) {
    const character = keywordArr[i];
    if (!tempRoot.children[character]) {
      break;
    } else {
      tempRoot = tempRoot.children[character];
    }
    if (keywordArr.length - 1 === i) {
      result = [
        ...tempRoot.ids,
        ...collectChildrenInsideIds(tempRoot.children)
      ];
    }
  }
  return distinct(result).map(id => {
    return userMap[id];
  });
}
複製程式碼

注意這裡有一個collectChildrenInsideIds方法,這個方法用於收集該葉子節點下所有的子節點的 id。這麼做是因為當前操作模糊匹配,當你搜尋a時,apple, anna, ack 都算匹配。

常規搜尋辦法以及字典樹的缺陷

為了對比效率,並且為了測試搜尋結果的正確性,我們仍然需要編寫一個常規的遍歷的搜尋方法:

function regularSearch(searchKeyword) {
  const regularSearchResults = [];
  results.forEach(item => {
    for (const key in item) {
      const value = item[key];
      if (String(value).startsWith(searchKeyword)) {
        regularSearchResults.push(item);
        break;
      }
    }
  });
  return regularSearchResults
}
複製程式碼

注意在測試物件值是否匹配搜尋詞時,我們使用了startsWith,而不是indexOf,**這是因為字典樹的缺陷在於只能匹配以搜尋詞開頭的詞!**比如當你搜尋a時,只能匹配appleanna而不能匹配banana。為了便於對比,我們不得不使用startsWith

效能的對比

效能的對比結果是很有意思的:

  • 當資料量較小時,查詢效率不會有大的差異
  • 當資料量較大時,比如 5000 條的情況下,當你的搜尋詞非常短小,比如a,那麼字典樹的查詢效率會比遍歷搜尋低,也就是反而花費的時間長;當搜尋詞變得具體時,比如ali,字典樹的查詢效率會比遍歷搜尋高

效率反而低的問題不難想到是為什麼:當你搜尋詞簡單時,訪問的葉子節點會少,所以只能掃描children收集子節點的所有的可能 id,這步操作中遍歷的過程佔用了大部分時間

但是我們仍然需要滿足這部分的查詢需求,所以我們要針對這個場景做一些優化

優化簡短搜尋的場景

我們回想一下簡單搜尋的場景,效能的瓶頸主要在於我們需要遍歷葉子節點下的所有子節點。好辦,鑑於樹構建完之後不會再發生變化,那麼我們只需要提前計算好每個葉子節點的所以子 id 就好了,這就是文章開頭說的第二類優化方案,即預計算。

我編寫了一個新的方法,用於遞迴的給每個葉子節點新增它所有子節點的 id:

function decorateWithChildrenIds(root) {
  const { children } = root;
  root.childrenIds = collectChildrenInsideIds(root.children);
  for (const character in children) {
    const characterLeaf = children[character];
    characterLeaf.childrenIds = collectChildrenInsideIds(
      characterLeaf.children
    );
    decorateWithChildrenIds(characterLeaf);
  }
}
複製程式碼

那麼在構建完樹之後,用這個方法把所有葉子節點「裝飾」一遍就好了

結論

在通過預計算之後,在 5000 條資料的情況下,無論是短搜尋還是長搜尋,字典樹的查詢效率基本是在 1ms 左右,而常規的遍歷查詢則處於 10ms 左右,的確是十倍的提升。但是這個提升的代價是建立在犧牲空間,以及提前花費了時間計算的情況下。相信如果資料結構變得更復雜,效率提升會更明顯

本文原始碼的地址是 github.com/hh54188/sea…

最後留下一個問題給大家:當需要搜尋的資料量變大時,比如 1000 時,偶爾會出現字典樹搜尋結果和遍歷搜尋結果不一致的情況,而當資料量變得更大時,比如 5000 條,那麼這個「問題」會穩定出現。這個問題算不上 bug,但是問題出在哪呢 ?


本文也同時釋出在我的知乎專欄 前端技術漫遊指南, 歡迎大家訂閱

相關文章