降维打击!为何我认为数据结构与算法对前端开发很重要

 

事情要从 GitHub 上的一个 issue 谈起:https://github.com/LeuisKen/leuisken.github.io/issues/2,需求里面的我指代为 issue 里面的我

从一个需求谈起

在我以前的项目中,曾经遇到过这样一个需求,编写一个级联选择器,大概是这样:javascript

 

11前端

 

图中的示例使用的是 Ant-Design 的 Cascader 组件。java

要实现这一功能,我须要相似这样的数据结构:git

var data = [{
  "value": "浙江",
  "children": [{
    "value": "杭州",
    "children": [{
      "value": "西湖"
    }]
  }]
}, {
  "value": "四川",
  "children": [{
    "value": "成都",
    "children": [{
      "value": "锦里"
    }, {
      "value": "方所"
    }]
  }, {
    "value": "阿坝",
    "children": [{
      "value": "九寨沟"
    }]
  }]
}]

一个具备层级结构的数据,实现这个功能很是容易,由于这个结构和组件的结构是一致的,递归遍历就能够了。程序员

可是,因为后端一般采用的是关系型数据库,因此返回的数据一般会是这个样子:github

var data = [{
  "province": "浙江",
  "city": "杭州",
  "name": "西湖"
}, {
  "province": "四川",
  "city": "成都",
  "name": "锦里"
}, {
  "province": "四川",
  "city": "成都",
  "name": "方所"
}, {
  "province": "四川",
  "city": "阿坝",
  "name": "九寨沟"
}]

前端这边想要将数据转换一下其实也不难,由于要合并重复项,能够参考数据去重的方法来作,因而我写了这样一个版本。数据库

'use strict'

/**
 * 将一个没有层级的扁平对象,转换为树形结构({value, children})结构的对象
 * @param {array} tableData - 一个由对象构成的数组,里面的对象都是扁平的
 * @param {array} route - 一个由字符串构成的数组,字符串为前一数组中对象的key,最终
 * 输出的对象层级顺序为keys中字符串key的顺序
 * @return {array} 保存具备树形结构的对象
 */

var transObject = function(tableData, keys) {
  let hashTable = {}, res = []
  for( let i = 0; i < tableData.length; i++ ) {
    if(!hashTable[tableData[i][keys[0]]]) {
      let len = res.push({
        value: tableData[i][keys[0]],
        children: []
      })
      // 在这里要保存key对应的数组序号,否则还要涉及到查找
      hashTable[tableData[i][keys[0]]] = { $$pos: len - 1 }
    }
    if(!hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]]) {
      let len = res[hashTable[tableData[i][keys[0]]].$$pos].children.push({
        value: tableData[i][keys[1]],
        children: []
      })
      hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]] = { $$pos: len - 1 }
    }
    res[hashTable[tableData[i][keys[0]]].$$pos].children[hashTable[tableData[i][keys[0]]][tableData[i][keys[1]]].$$pos].children.push({
      value: tableData[i][keys[2]]
    })
  }
  return res
}

var data = [{
  "province": "浙江",
  "city": "杭州",
  "name": "西湖"
}, {
  "province": "四川",
  "city": "成都",
  "name": "锦里"
}, {
  "province": "四川",
  "city": "成都",
  "name": "方所"
}, {
  "province": "四川",
  "city": "阿坝",
  "name": "九寨沟"
}]

var keys = ['province', 'city', 'name']

console.log(transObject(data, keys))

还好 keys 的长度只有 3 ,这种东西长了根本没办法写,很明显能够看出来这里面有重复的部分,能够经过循环搞定,可是想了好久都没有思路,就搁置了。后端

后来,有一天晚饭后不是很忙,就跟旁边作数据的同事聊了一下这个需求,请教一下该怎么用循环来处理。他看了一下,就问我:“你知道 trie 树吗?”。我头一次听到这个概念,他简单的给我讲了一下,而后说感受处理的问题有些相似,让我能够研究一下 trie 树的原理并试着优化一下。数组

讲道理, trie 树这个数据结构网上确实有不少资料,但不多有使用 JavaScript 实现的,不过原理却是不难。尝试以后,我就将 transObject 的代码优化成了这样。(关于 trie 树,还请读者本身阅读相关材料)数据结构

var transObject = function(tableData, keys) {
  let hashTable = {}, res = []
  for (let i = 0; i < tableData.length; i++) {
    let arr = res, cur = hashTable
    for (let j = 0; j < keys.length; j++) {
      let key = keys[j], filed = tableData[i][key]
      if (!cur[filed]) {
        let pusher = {
          value: filed
        }, tmp
        if (j !== (keys.length - 1)) {
          tmp = []
          pusher.children = tmp
        }
        cur[filed] = { $$pos: arr.push(pusher) - 1 }
        cur = cur[filed]
        arr = tmp
      } else {
        cur = cur[filed]
        arr = arr[cur.$$pos].children
      }
    }
  }
  return res
}

这样,解决方案就和 keys 的长短无关了。

这种解决方案正如《三体》里面使用「二向箔」对宇宙文明进行降维打击通常干净利落!

若是你对「Trie」树的相关概念不了解的话,能够继续往下查看进行阅读学习。

这是以前的写的一篇旧文,小吴这里进行了必定的修改和排版

Trie树

Trie 这个名字取自“retrieval”,检索,由于 Trie 能够只用一个前缀即可以在一部字典中找到想要的单词。
虽然发音与「Tree」一致,但为了将这种 字典树 与 普通二叉树 以示区别,程序员小吴通常读「Trie」尾部会重读一声,能够理解为读「TreeE」。

Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

此外 Trie 树也称前缀树(由于某节点的后代存在共同的前缀,好比 pan 是 panda 的前缀)。

它的key都为字符串,能作到高效查询和插入,时间复杂度为 O(k),k 为字符串长度,缺点是若是大量字符串没有共同前缀时很耗内存。

它的核心思想就是经过最大限度地减小无谓的字符串比较,使得查询高效率,即「用空间换时间」,再利用共同前缀来提升查询效率。

Trie树的特色

假设有 5 个字符串,它们分别是:code,cook,five,file,fat。如今须要在里面屡次查找某个字符串是否存在。若是每次查找,都是拿要查找的字符串跟这 5 个字符串依次进行字符串匹配,那效率就比较低,有没有更高效的方法呢?

若是将这 5 个字符串组织成下图的结构,从肉眼上扫描过去感官上是否是比查找起来会更加迅速。

Trie树样子Trie树样子

经过上图,能够发现 Trie树 的三个特色:

  • 根节点不包含字符,除根节点外每个节点都只包含一个字符
  • 从根节点到某一节点,路径上通过的字符链接起来,为该节点对应的字符串
  • 每一个节点的全部子节点包含的字符都不相同

经过动画理解 Trie 树构造的过程。在构造过程当中的每一步,都至关于往 Trie 树中插入一个字符串。当全部字符串都插入完成以后,Trie 树就构造好了。

 Trie 树构造 Trie 树构造

Trie树的插入操做

Trie树的插入操做Trie树的插入操做

Trie树的插入操做很简单,其实就是将单词的每一个字母逐一插入 Trie树。插入前先看字母对应的节点是否存在,存在则共享该节点,不存在则建立对应的节点。好比要插入新单词cook,就有下面几步:

  • 插入第一个字母 c,发现 root 节点下方存在子节点 c,则共享节点 c
  • 插入第二个字母 o,发现 c 节点下方存在子节点 o,则共享节点 o
  • 插入第三个字母 o,发现 o 节点下方不存在子节点 o,则建立子节点 o
  • 插入第三个字母 k,发现 o 节点下方不存在子节点 k,则建立子节点 k
  • 至此,单词 cook 中全部字母已被插入 Trie树 中,而后设置节点 k 中的标志位,标记路径 root-&gt;c-&gt;o-&gt;o-&gt;k这条路径上全部节点的字符能够组成一个单词cook

Trie树的查询操做

在 Trie 树中查找一个字符串的时候,好比查找字符串 code,能够将要查找的字符串分割成单个的字符 c,o,d,e,而后从 Trie 树的根节点开始匹配。如图所示,绿色的路径就是在 Trie 树中匹配的路径。

code的匹配路径code的匹配路径

若是要查找的是字符串cod(鳕鱼)呢?仍是能够用上面一样的方法,从根节点开始,沿着某条路径来匹配,如图所示,绿色的路径,是字符串cod匹配的路径。可是,路径的最后一个节点「d」并非橙色的,并非单词标志位,因此cod字符串不存在。也就是说,cod是某个字符串的前缀子串,但并不能彻底匹配任何字符串。

cod的匹配路径cod的匹配路径

Trie树的删除操做

Trie树的删除操做与二叉树的删除操做有相似的地方,须要考虑删除的节点所处的位置,这里分三种状况进行分析:

删除整个单词(好比 hi )

删除整个单词删除整个单词

  • 从根节点开始查找第一个字符h
  • 找到h子节点后,继续查找h的下一个子节点i
  • i是单词hi的标志位,将该标志位去掉
  • i节点是hi的叶子节点,将其删除
  • 删除后发现h节点为叶子节点,而且不是单词标志位,也将其删除
  • 这样就完成了hi单词的删除操做

删除前缀单词(好比 cod )

删除前缀单词删除前缀单词
这种方式删除比较简单。
只须要将cod单词整个字符串查找完后,d节点由于不是叶子节点,只需将其单词标志去掉便可。

 

删除分支单词(好比 cook )

删除分支单词删除分支单词
删除整个单词 状况相似,区别点在于删除到 cook 的第一个 o 时,该节点为非叶子节点,中止删除,这样就完成cook字符串的删除操做。

 

Trie树的应用

事实上 Trie树 在平常生活中的使用随处可见,好比这个:

具体来讲就是常常用于统计和排序大量的字符串(但不只限于字符串),因此常常被搜索引擎系统用于文本词频统计。它的优势是:最大限度地减小无谓的字符串比较,查询效率比哈希表高。

1. 前缀匹配

例如:找出一个字符串集合中全部以 五分钟 开头的字符串。咱们只须要用全部字符串构造一个 trie树,而后输出以 五−>分−>钟 开头的路径上的关键字便可。

trie树前缀匹配经常使用于搜索提示。如当输入一个网址,能够自动搜索出可能的选择。当没有彻底匹配的搜索结果,能够返回前缀最类似的可能

google搜索google搜索

2. 字符串检索

给出 N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,按最先出现的顺序写出全部不在熟词表中的生词。

检索/查询功能是Trie树最原始的功能。给定一组字符串,查找某个字符串是否出现过,思路就是从根节点开始一个一个字符进行比较:

  • 若是沿路比较,发现不一样的字符,则表示该字符串在集合中不存在。
  • 若是全部的字符所有比较完而且所有相同,还需判断最后一个节点的标志位(标记该节点是否表明一个关键字)。

Trie树的局限性

如前文所讲,Trie 的核心思想是空间换时间,利用字符串的公共前缀来下降查询时间的开销以达到提升效率的目的。

假设字符的种数有m个,有若干个长度为n的字符串构成了一个 Trie树 ,则每一个节点的出度为 m(即每一个节点的可能子节点数量为m),Trie树 的高度为n。很明显咱们浪费了大量的空间来存储字符,此时Trie树的最坏空间复杂度为O(m^n)。也正因为每一个节点的出度为m,因此咱们可以沿着树的一个个分支高效的向下逐个字符的查询,而不是遍历全部的字符串来查询,此时Trie树的最坏时间复杂度为O(n)

这正是空间换时间的体现,也是利用公共前缀下降查询时间开销的体现。

LeetCode 第 208 号问题就是 实现 Trie (前缀树),感兴趣的小伙伴能够去实操一下。

但愿今天的这篇文章能帮你们认识到掌握好了数据结构能够在工做中带来多大的帮助,你们加油:)

相关文章
相关标签/搜索