CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/35 | Trie树:如何实现搜索引擎的搜索关键词提示功能?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

205 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="35 | Trie树如何实现搜索引擎的搜索关键词提示功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/69/90c5ea1dc435a6d92c213756ef194969.mp3"></audio>
搜索引擎的搜索关键词提示功能,我想你应该不陌生吧?为了方便快速输入,当你在搜索引擎的搜索框中,输入要搜索的文字的某一部分的时候,搜索引擎就会自动弹出下拉框,里面是各种关键词提示。你可以直接从下拉框中选择你要搜索的东西,而不用把所有内容都输入进去,一定程度上节省了我们的搜索时间。
<img src="https://static001.geekbang.org/resource/image/ce/9e/ceb8738453401d5fc067acd513b57a9e.png" alt="">
尽管这个功能我们几乎天天在用,作为一名工程师,你是否思考过,它是怎么实现的呢?它底层使用的是哪种数据结构和算法呢?
像Google、百度这样的搜索引擎它们的关键词提示功能非常全面和精准肯定做了很多优化但万变不离其宗底层最基本的原理就是今天要讲的这种数据结构Trie树。
## 什么是“Trie树”
Trie树也叫“字典树”。顾名思义它是一个树形结构。它是一种专门处理字符串匹配的数据结构用来解决在一组字符串集合中快速查找某个字符串的问题。
当然这样一个问题可以有多种解决方法比如散列表、红黑树或者我们前面几节讲到的一些字符串匹配算法但是Trie树在这个问题的解决上有它特有的优点。不仅如此Trie树能解决的问题也不限于此我们一会儿慢慢分析。
现在我们先来看下Trie树到底长什么样子。
我举个简单的例子来说明一下。我们有6个字符串它们分别是howhiherhellososee。我们希望在里面多次查找某个字符串是否存在。如果每次查找都是拿要查找的字符串跟这6个字符串依次进行字符串匹配那效率就比较低有没有更高效的方法呢
这个时候我们就可以先对这6个字符串做一下预处理组织成Trie树的结构之后每次查找都是在Trie树中进行匹配查找。**Trie树的本质就是利用字符串之间的公共前缀将重复的前缀合并在一起**。最后构造出来的就是下面这个图中的样子。
<img src="https://static001.geekbang.org/resource/image/28/32/280fbc0bfdef8380fcb632af39e84b32.jpg" alt="">
其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。
为了让你更容易理解Trie树是怎么构造出来的我画了一个Trie树构造的分解过程。构造过程的每一步都相当于往Trie树中插入一个字符串。当所有字符串都插入完成之后Trie树就构造好了。
<img src="https://static001.geekbang.org/resource/image/f8/6c/f848a7d8bda3d4f8bb4a7cbfaabab66c.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/06/b6/06b45fde2ca8077465e0c557bc749ab6.jpg" alt="">
当我们在Trie树中查找一个字符串的时候比如查找字符串“her”那我们将要查找的字符串分割成单个的字符her然后从Trie树的根节点开始匹配。如图所示绿色的路径就是在Trie树中匹配的路径。
<img src="https://static001.geekbang.org/resource/image/6d/b9/6dbed0579a60c6d170bd8fde5990bfb9.jpg" alt="">
如果我们要查找的是字符串“he”呢我们还用上面同样的方法从根节点开始沿着某条路径来匹配如图所示绿色的路径是字符串“he”匹配的路径。但是路径的最后一个节点“e”并不是红色的。也就是说“he”是某个字符串的前缀子串但并不能完全匹配任何字符串。
<img src="https://static001.geekbang.org/resource/image/05/f9/05c3c5d534921f00a9ae33e7e65b1bf9.jpg" alt="">
## 如何实现一棵Trie树
知道了Trie树长什么样子我们现在来看下如何用代码来实现一个Trie树。
从刚刚Trie树的介绍来看Trie树主要有两个操作**一个是将字符串集合构造成Trie树**。这个过程分解开来的话就是一个将字符串插入到Trie树的过程。**另一个是在Trie树中查询一个字符串**。
了解了Trie树的两个主要操作之后我们再来看下**如何存储一个Trie树**
从前面的图中我们可以看出Trie树是一个多叉树。我们知道二叉树中一个节点的左右子节点是通过两个指针来存储的如下所示Java代码。那对于多叉树来说我们怎么存储一个节点的所有子节点的指针呢
```
class BinaryTreeNode {
char data;
BinaryTreeNode left;
BinaryTreeNode right;
}
```
我先介绍其中一种存储方式,也是经典的存储方式,大部分数据结构和算法书籍中都是这么讲的。还记得我们前面讲到的散列表吗?借助散列表的思想,我们通过一个下标与字符一一映射的数组,来存储子节点的指针。这句话稍微有点抽象,不怎么好懂,我画了一张图你可以看看。
<img src="https://static001.geekbang.org/resource/image/f5/35/f5a4a9cb7f0fe9dcfbf29eb1e5da6d35.jpg" alt="">
假设我们的字符串中只有从a到z这26个小写字母我们在数组中下标为0的位置存储指向子节点a的指针下标为1的位置存储指向子节点b的指针以此类推下标为25的位置存储的是指向的子节点z的指针。如果某个字符的子节点不存在我们就在对应的下标的位置存储null。
```
class TrieNode {
char data;
TrieNode children[26];
}
```
当我们在Trie树中查找字符串的时候我们就可以通过字符的ASCII码减去“a”的ASCII码迅速找到匹配的子节点的指针。比如d的ASCII码减去a的ASCII码就是3那子节点d的指针就存储在数组中下标为3的位置中。
描述了这么多,有可能你还是有点懵,我把上面的描述翻译成了代码,你可以结合着一块看下,应该有助于你理解。
```
public class Trie {
private TrieNode root = new TrieNode('/'); // 存储无意义字符
// 往Trie树中插入一个字符串
public void insert(char[] text) {
TrieNode p = root;
for (int i = 0; i &lt; text.length; ++i) {
int index = text[i] - 'a';
if (p.children[index] == null) {
TrieNode newNode = new TrieNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
}
// 在Trie树中查找一个字符串
public boolean find(char[] pattern) {
TrieNode p = root;
for (int i = 0; i &lt; pattern.length; ++i) {
int index = pattern[i] - 'a';
if (p.children[index] == null) {
return false; // 不存在pattern
}
p = p.children[index];
}
if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
else return true; // 找到pattern
}
public class TrieNode {
public char data;
public TrieNode[] children = new TrieNode[26];
public boolean isEndingChar = false;
public TrieNode(char data) {
this.data = data;
}
}
}
```
Trie树的实现你现在应该搞懂了。现在我们来看下**在Trie树中查找某个字符串的时间复杂度是多少**
如果要在一组字符串中频繁地查询某些字符串用Trie树会非常高效。构建Trie树的过程需要扫描所有的字符串时间复杂度是O(n)n表示所有字符串的长度和。但是一旦构建成功之后后续的查询操作会非常高效。
每次查询时如果要查询的字符串长度是k那我们只需要比对大约k个节点就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说构建好Trie树后在其中查找字符串的时间复杂度是O(k)k表示要查找的字符串的长度。
## Trie树真的很耗内存吗
前面我们讲了Trie树的实现也分析了时间复杂度。现在你应该知道Trie树是一种非常独特的、高效的字符串匹配方法。但是关于Trie树你有没有听过这样一种说法“Trie树是非常耗内存的用的是一种空间换时间的思路”。这是什么原因呢
刚刚我们在讲Trie树的实现的时候讲到用数组来存储一个节点的子节点的指针。如果字符串中包含从a到z这26个字符那每个节点都要存储一个长度为26的数组并且每个数组元素要存储一个8字节指针或者是4字节这个大小跟CPU、操作系统、编译器等有关。而且即便一个节点只有很少的子节点远小于26个比如3、4个我们也要维护一个长度为26的数组。
我们前面讲过Trie树的本质是避免重复存储一组字符串的相同前缀子串但是现在每个字符对应一个节点的存储远远大于1个字节。按照我们上面举的例子数组长度为26每个元素是8字节那每个节点就会额外需要26*8=208个字节。而且这还是只包含26个字符的情况。
如果字符串中不仅包含小写字母还包含大写字母、数字、甚至是中文那需要的存储空间就更多了。所以也就是说在某些情况下Trie树不一定会节省存储空间。在重复的前缀并不多的情况下Trie树不但不能节省内存还有可能会浪费更多的内存。
当然我们不可否认Trie树尽管有可能很浪费内存但是确实非常高效。那为了解决这个内存问题我们是否有其他办法呢
我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。用哪种数据结构呢?我们的选择其实有很多,比如有序数组、跳表、散列表、红黑树等。
假设我们用有序数组数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候我们可以通过二分查找的方法快速查找到某个字符应该匹配的子节点的指针。但是在往Trie树中插入一个字符串的时候我们为了维护数组中数据的有序性就会稍微慢了点。
替换成其他数据结构的思路是类似的,这里我就不一一分析了,你可以结合前面学过的内容,自己分析一下。
实际上Trie树的变体有很多都可以在一定程度上解决内存消耗的问题。比如**缩点优化**,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。这里我就不展开详细讲解了,你如果感兴趣,可以自行研究下。
<img src="https://static001.geekbang.org/resource/image/87/11/874d6870e365ec78f57cd1b9d9fbed11.jpg" alt="">
## Trie树与散列表、红黑树的比较
实际上字符串的匹配问题笼统上讲其实就是数据的查找问题。对于支持动态数据高效操作的数据结构我们前面已经讲过好多了比如散列表、红黑树、跳表等等。实际上这些数据结构也可以实现在一组字符串中查找字符串的功能。我们选了两种数据结构散列表和红黑树跟Trie树比较一下看看它们各自的优缺点和应用场景。
在刚刚讲的这个场景在一组字符串中查找字符串Trie树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。
第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
第三如果要用Trie树解决问题那我们就要自己从零开始实现一个Trie树还要保证没有bug这个在工程上是将简单问题复杂化除非必须一般不建议这样做。
第四我们知道通过指针串起来的数据块是不连续的而Trie树中用到了指针所以对缓存并不友好性能上会打个折扣。
综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
讲到这里你可能要疑惑了讲了半天我对Trie树一通否定还让你用红黑树或者散列表那Trie树是不是就没用了呢是不是今天的内容就白学了呢
实际上Trie树只是不适合精确匹配查找这种问题更适合用散列表或者红黑树来解决。Trie树比较适合的是查找前缀匹配的字符串也就是类似开篇问题的那种场景。
## 解答开篇
Trie树就讲完了我们来看下开篇提到的问题如何利用Trie树实现搜索关键词的提示功能
我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个Trie树。当用户输入其中某个单词的时候把这个词作为一个前缀子串在Trie树中匹配。为了讲解方便我们假设词库里只有hello、her、hi、how、so、see这6个关键词。当用户输入了字母h的时候我们就把以h为前缀的hello、her、hi、how展示在搜索提示框内。当用户继续键入字母e的时候我们就把以he为前缀的hello、her展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。
<img src="https://static001.geekbang.org/resource/image/4c/0d/4ca9d9f78f2206cad93836a2b1d6d80d.jpg" alt="">
不过,我讲的只是最基本的实现原理,实际上,搜索引擎的搜索关键词提示功能远非我讲的这么简单。如果再稍微深入一点,你就会想到,上面的解决办法遇到下面几个问题:
<li>
我刚讲的思路是针对英文的搜索关键词提示对于更加复杂的中文来说词库中的数据又该如何构建成Trie树呢
</li>
<li>
如果词库中有很多关键词在搜索提示的时候用户输入关键词作为前缀在Trie树中可以匹配的关键词也有很多如何选择展示哪些内容呢
</li>
<li>
像Google这样的搜索引擎用户单词拼写错误的情况下Google还是可以使用正确的拼写来做关键词提示这个又是怎么做到的呢
</li>
你可以先思考一下如何来解决,如果不会也没关系,这些问题,我们会在实战篇里具体来讲解。
实际上Trie树的这个应用可以扩展到更加广泛的一个应用上就是自动输入补全比如输入法自动补全功能、IDE代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。
## 内容小结
今天我们讲了一种特殊的树Trie树。Trie树是一种解决字符串快速匹配问题的数据结构。如果用来构建Trie树的这一组字符串中前缀重复的情况不是很多那Trie树这种数据结构总体上来讲是比较费内存的是一种空间换时间的解决问题思路。
尽管比较耗费内存但是对内存不敏感或者内存消耗在接受范围内的情况下在Trie树中做字符串匹配还是非常高效的时间复杂度是O(k)k表示要匹配的字符串的长度。
但是Trie树的优势并不在于用它来做动态集合数据的查找因为这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie树最有优势的是查找前缀匹配的字符串比如搜索引擎中的关键词提示功能这个场景就比较适合用它来解决也是Trie树比较经典的应用场景。
## 课后思考
我们今天有讲到Trie树应用场合对数据要求比较苛刻比如字符串的字符集不能太大前缀重合比较多等。如果现在给你一个很大的字符串集合比如包含1万条记录如何通过编程量化分析这组字符串集合是否比较适合用Trie树解决呢也就是如何统计字符串的字符集大小以及前缀重合的程度呢
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。