搜索关键词智能提示suggestion

题目详情

百度搜索框中,输入“北京”,搜索框下面会以北京为前缀,展示“北京爱情故事”、“北京公交”、“北京医院”等等搜索词,输入“结构之”,会提示“结构之法”,“结构之法 算法之道”等搜索词。
请问,如何设计此系统,使得空间和时间复杂度尽量低。

搜索关键词智能提示suggestion - 图1

分析与解法

本题来源于去年2012年百度的一套实习生笔试题中的系统设计题(为尊重原题,本章主要使用百度搜索引擎展开论述,而不是google等其它搜索引擎,但原理不会差太多。然脱离本题,平时搜的时候,鼓励用…),题目比较开放,考察的目的在于看应聘者解决问题的思路是否清晰明确,其次便是看能考虑到多少细节。

我去年整理此题的时候,曾简单解析过,提出的方法是:

  • 直接上Trie树「Trie树的介绍见:从Trie树(字典树)谈到后缀树」 + TOP K「hashmap+堆,hashmap+堆 统计出如10个近似的热词,也就是说,只存与关键词近似的比如10个热词」

方法就是这样子的:Trie树+TOP K算法,但在实际中,真的只要Trie树 + TOP K算法就够了么,有什么需要考虑的细节?OK,请看下文娓娓道来。

解法一、Trie树 + TOP K

步骤一、trie树存储前缀后缀

若看过博客内这篇介绍Trie树和后缀树的文章的话,应该就能对trie树有个大致的了解,为示本文完整性,引用下原文内容,如下:

1.1、什么是Trie树

Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率往往比哈希表高。

Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

它有3个基本性质:

  1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  3. 每个节点的所有子节点包含的字符都不相同。

1.2、树的构建

举个在网上流传颇广的例子,如下:

题目:给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。

分析:这题当然可以用hash来解决,但是本文重点介绍的是trie树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用trie还是很简单。

现在回到例子中,如果我们用最傻的方法,对于每一个单词,我们都要去查找它前面的单词中是否有它。那么这个算法的复杂度就是O(n^2)。(字符串比较的复杂度是否以strcmp次数计算更妥当,当此处为了讨论简化了细节)。显然对于100000的范围难以接受。现在我们换个思路想。假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。

好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:

搜索关键词智能提示suggestion - 图2

当时第一次看到这幅图的时候,便立马感到此树之不凡构造了。单单从上幅图便可窥知一二,好比大海搜人,立马就能确定东南西北中的到底哪个方位,如此迅速缩小查找的范围和提高查找的针对性,不失为一创举。

ok,如上图所示,对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。

那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。

借用上面的图,当用户输入前缀a的时候,搜索框可能会展示以a为前缀的“abcd”,“abd”等关键词,再当用户输入前缀b的时候,搜索框下面可能会提示以b为前缀的“bcd”等关键词,如此,实现搜索引擎智能提示suggestion的第一个步骤便清晰了,即用trie树存储大量字符串,当前缀固定时,存储相对来说比较热的后缀。那又如何统计热词呢?请看下文步骤二、TOP K算法统计热词。

步骤二、TOP K算法统计热词

当每个搜索引擎输入一个前缀时,下面它只会展示0~10个候选词,但若是碰到那种候选词很多的时候,如何取舍,哪些展示在前面,哪些展示在后面?这就是一个搜索热度的问题。

如本题描述所说,在去年的这个时候,当我在搜索框内搜索“北京”时,它下面会提示以“北京”为前缀的诸如“北京爱情故事”,“北京公交”,“北京医院”,且“ 北京爱情故事”展示在第一个:

搜索关键词智能提示suggestion - 图3

为何输入“北京”,会首先提示“北京爱情故事”呢?因为去年的这个时候,正是《北京爱情故事》这部电视剧上映正火的时候(其上映日期为2012年1月8日,火了至少一年),那个时候大家都一个劲的搜索这部电视剧的相关信息,当10个人中输入“北京”后,其中有8个人会继续敲入“爱情故事”(连起来就是“北京爱情故事”)的时候,搜索引擎对此当然不会无动于衷。

也就是说,搜索引擎知道了这个时间段,大家都在疯狂查找北京爱情故事,故当用户输入以“北京”为前缀的时候,搜索引擎猜测用户有80%的机率是要查找“北京爱情故事”,故把“北京爱情故事”在下面提示出来,并放在第一个位置上。

但为何今年这个时候再次搜索“北京”的时候,它展示出来的词不同了呢?

原因在于随着时间变化,人们对《北京爱情故事》这部电视剧的关注度逐渐下降,与此同时,又出现了新的热词,或新的电影,故现在虽然同样是输入“北京”,后面提示的词也相应跟着起了变化。那解决这个问题的办法是什么呢?如开头所说:定期分析某段时间内的人们搜索的关键词,统计出搜索次数比较多的热词,继而当用户输入某个前缀时,优先展示热词。

故说白了,这个问题的第二个步骤便是统计热词,我们把统计热词的方法称为TOP K算法,此算法的应用场景便是此文中的第2个问题,再次原文引用:

寻找热门查询,300万个查询字符串中统计最热门的10个查询

原题:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门),请你统计最热门的10个查询串,要求使用的内存不能超过1G。

解答:由上面第1题,我们知道,数据大则划为小的,如一亿个Ip求Top 10,可先将Ip地址使用Ip2long函数(各种语言或库皆提供本函数)转为长整型Ipld后,再按Ipld%1000将ip分到1000个小文件中去,并保证一种ip只出现在一个文件中,再对每个小文件中的ip进行hashmap计数统计并按数量排序,最后归并或者最小堆依次处理每个小文件的top10以得到最后的结果。(小提示:常用的Ip hash还有一种基于bit的方法,聪明的你一定想到了吧^_^)

但如果数据规模本身就比较小,能一次性装入内存呢?比如这第2题,虽然有一千万个Query,但是由于重复度比较高,因此事实上只有300万的Query,每个Query255Byte,因此我们可以考虑把他们都放进内存中去(300万个字符串假设没有重复,都是最大长度,那么最多占用内存3M*1K/4=0.75G(实际占用会略大一点,因具体实现而异)。所以可以将所有字符串都存放在内存中进行处理),而现在只是需要一个合适的数据结构,在这里,HashTable绝对是我们优先的选择。

所以对数据量较少(可以全部放入内存的时候)我们放弃分而治之+hash映射的步骤,直接上hash统计,然后排序。针对此类典型的TOP K问题,采取的对策往往是:hashmap + 堆。如下所示:

  1. hashmap统计:先对这批海量数据预处理。具体方法是:维护一个Key为Query字串,Value为该Query出现次数的HashTable,即hash_map(Query,Value),每次读取一个Query,如果该字串不在Table中,那么加入该字串,并且将Value值设为1;如果该字串在Table中,那么将该字串的计数加一即可。最终我们在O(N)的时间复杂度内用Hash表完成了统计;
  2. 堆排序:第二步、借助堆这个数据结构,找出Top K,时间复杂度为N‘logK。即借助堆结构,我们可以在对数量级的时间内查找和调整。因此,维护一个K(该题目中是10)大小的小根堆,然后遍历300万的Query对应的计数,分别和根元素进行对比。所以,我们最终的时间复杂度是:O(N) + N’ * O(logK),(N为1000万,N’为300万)。

别忘了这篇文章中所述的堆排序思路:‘维护k个元素的最小堆,即用容量为k的最小堆存储最先遍历到的k个数,并假设它们即是最大的k个数,建堆费时O(k),并调整堆(费时O(logk))后,有k1>k2>…kmin(kmin设为小顶堆中最小元素)。继续遍历数列,每次遍历一个元素x,与堆顶元素比较,若x>kmin,则更新堆(x入堆,用时logk),否则不更新堆。这样下来,总费时O(k*logk+(n-k)*logk)=O(n*logk)。此方法得益于在堆中,查找等各项操作时间复杂度均为logk。’—第三章续、Top K算法问题的实现。

当然,你也可以采用trie树,关键字域存该查询串出现的次数,没有出现为0。最后用10个元素的最小推来对出现频率进行排序。

相信,如此,也就不难理解开头所提出的方法了:Trie树+ TOP K「hashmap+堆,hashmap+堆 统计出如10个近似的热词,也就是说,只存与关键词近似的比如10个热词」。

而且你以后就可以告诉你身边的伙伴们,为何输入“结构之”,会提示出来一堆以“结构之”为前缀的词了:

搜索关键词智能提示suggestion - 图4

方法貌似成型了,但有哪些需要注意的细节呢?如@江申_Johnson所说:“实际工作里,比如当前缀很短的时候,候选词很多的时候,查询和排序性能可能有问题,也许可以加一层索引trie(这层索引可以只索引频率高于某一个阈值的词,很短的时候查这个就可以了。数量不够的话再去查索引了全部词的trie树);而且有时候不能根据query频率来排,而要引导用户输入信息量更全面的query,或者或不仅仅是前缀匹配这么简单。”

当然,在实际的工程中,排序的依据还有很多,例如对于突发的热点问题,虽然之前用户没有输入过,但是却能排在最前面,是因为在系统内部有一些相应的模块进行了所谓的“调权”。此外,上面我们谈到trie的时候,都采取了简化的模型,即trie通常只是针对英语单词而言的,如果是中文,会有一个严重的问题:),你想到了吗?(小提示:英语和中文的基本字符,差别是不是很大?)当然,如果考虑到我们喜欢用拼音来表述中文,这个问题不是没有解决方法的^_^。

此外,在工程实现的时候,前端往往采用了ajax技术来保障速度上的优势,不然等用户输入完毕了咱的推荐词还在慢悠悠的传输中,那也就没有多少意义了。由于前端讨论和咱们这里的讨论关系不大,感兴趣的朋友可以参考相关资料。

扩展阅读

除了上文提到的trie树,三叉树或许也是一个不错的解决方案:点击此处。此外,StackOverflow上也有两个讨论帖子,大家可以看看:帖子1帖子2