用 100 行代碼提高 10 倍的性能

需求是這樣的:前端

你須要在前端展現 5000 條甚至更多的數據,每一條數據的數據結構是一個對象,裏面有格式各樣的屬性。每一個屬性的值又能夠是基本類型,對象,甚至數組。這裏的對象或者數組內部的元素又能夠繼續包含對象或者數組而且容許無限嵌套下去。好比git

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

頁面上提供一個搜索框,用戶經過輸入搜索的內容能夠找到包含這個內容的數據。注意,只要任意數據對象的任意屬性值 (好比在上面的數據結構中,只要 nameageroles 任何一個屬性的值)包含這個關鍵詞便可。若是屬性值是數組或者對象,那麼數組的元素或者對象的值繼續對輸入內容進行匹配檢測,並遞歸的檢測下去,只要有命中,便算該數據匹配程序員

如何設計這個功能,讓搜索功能儘量的快?github

解決思路

若是你稍有程序員的敏感度,此時你的腦海裏應該有兩個念頭:算法

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

的確,遍歷是最簡單但也是最慢的。因此一般的優化方法之一是經過空間換取時間;而另外一個方法……稍後再引出。json

這裏咱們嘗試經過創建字典樹(Trie)來優化搜索。api

若是你還不瞭解什麼是字典樹,下面作簡單的介紹:假設咱們有一個簡單的對象,鍵值的對應關係以下:數組

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

那麼此時不管用戶想訪問任何屬性的值,只要從樹的根節點出發,依據屬性字母出現的順序訪問樹的葉子節點,便可獲得該屬性的值。好比當咱們想訪問tea時:app

可是在咱們須要解決的場景中,咱們不須要關心「屬性」,咱們只關心「值」是否匹配上搜索的內容。因此咱們只須要對「值」創建字典樹。

假設有如下的對象值

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',
  },
]

上面兩個對象有相同的值 ack 和 an,因此在樹上的葉子節點中咱們還要添加對象的 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 條數據?這裏咱們使用 https://randomuser.me/api/開源 API。爲了簡單起見,咱們讓它只返回 genderemailphonecellnat基本數據類型的值,而不返回嵌套結構(對象和數組)。注意這裏只是爲了便於代碼展現和理解,略去了複雜的結構,也就避免了複雜的代碼。加入複雜結構以後代碼其實也沒有大的變化,只是增長了遍歷的邏輯和遞歸邏輯而已。

請求 https://randomuser.me/api/?results=5000&inc=gender,email,phone,cell,nat 結果以下:

 

{
  "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時,appleannaack 都算匹配。

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

爲了對比效率,而且爲了測試搜索結果的正確性,咱們仍然須要編寫一個常規的遍歷的搜索方法:

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 左右,的確是十倍的提高。可是這個提高的代價是創建在犧牲空間,以及提早花費了時間計算的狀況下。相信若是數據結構變得更復雜,效率提高會更明顯

本文源代碼的地址是 (https://github.com/hh54188/search-trie-tree)[https://github.com/hh54188/search-trie-tree]

最後留下一個問題給你們:當須要搜尋的數據量變大時,好比 1000 時,偶爾會出現字典樹搜索結果和遍歷搜索結果不一致的狀況,而當數據量變得更大時,好比 5000 條,那麼這個「問題」會穩定出現。這個問題算不上 bug,可是問題出在哪呢 ?

相關文章
相關標籤/搜索