用 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,可是问题出在哪呢 ?

相关文章
相关标签/搜索