题目内容

百度搜索框的suggestion,比如输入北京,搜索框下面会以北京为前缀,展示“北京爱情故事”、“北京公交”、“北京医院”等等搜索词。
如何设计使得空间和时间复杂度尽量低。
 

题目分析

在字符串集合S中,找出相同前缀P的字符串。
设size(S) = N,查询次数为M。
 
 

1.朴素方案

朴素得,每次查询时,遍历整个字符串集合,比较每个字符串的前缀。
时间复杂度T(N*len(P)) = O(N),这里的len(P)是一个常数值,所以不会对大O造成影响。
那总的时间复杂度就是O(MN),不使用额外空间,则空间复杂度为O(1)。
1 for Si in S:
2 if prefix(Si, P):
3 print Si

2.离线处理方案

想一下方案1,我们大部分时间浪费在了很多不可能的比较上。
如,字符串集合中有“南京”,“上海青年”等,跟“北京”前缀毫无关系的词。
为了优化这部分计算,我们先对字符串集合进行排序,然后二分查找到前缀相关位置,接着遍历,如果遇上没有该前缀的词,就可以停止遍历。
因为后续的词不可能再有相关前缀,这一特性是字典序后的集合保证的。
1 sort(S)
2 i = lower_bound(S, P)
3 for i = i to N:
4 if not prefix(S[i], P):
5 break
6 print S[i]
排序是在第一次查询前先预处理的,后续不需再调用。

假设每次查询获得条目数为R,所以时间复杂度T(NlogN + M(logN + R))。

但我们这里定义R为常量,因为真实应用上显示条目上限是有限制的,像baidu就只有10个。
那时间复杂度就是O(NlogN + MlogN)。
集合S在这个方案中最好使用数组保存,随机读写效率高。
当然也能使用自平衡树,如std::set,虽然set排序也是O(NlogN),但由于其结构的复杂性,实际效率会有几倍的差弱于快排。
 

3.在线处理方案

3.1字典树

离线跟在线最大的区别,在于离线的字符串集合S是固定,而在线的话集合S会有增加、删除、更新操作。
所以在线处理的难度大于离线,离线问题可以认为是在线问题的子集。
假如在M次查询过程中,加入L次更新操作。在直接套用离线处理方案的情况下,时间复杂度为O(LNlogN + MlogN)。
显然这时查询不是瓶颈,反而预处理拖了后腿。
这里,我们推荐使用Trie(字典树[2]),为什么选择字典树?因为它满足这些需求:
1.更新和查找操作与字符串长度有关,时间复杂度O(1),保证了效率。
2.树的遍历顺序隐含了字典顺序,不需显式排序,跳过预处理瓶颈。
1 def find_prefix(node, deep):
2 if empty(node): #子树为空
3 return
4
5 if deep == len(P): #找到完整P
6 return all_son(node) #返回该子树所有叶节点
7
8 #递归遍历子树
9 find_prefix(son(node, P[deep]), deep+1)

Trie处理流程大致是这样的,单次查询的时间复杂度为O(1)。
在线的处理方式当然也能用在离线上,但这两者的效率谁高谁低?
从时间复杂度上,O(logN)对O(1),似乎没有啥可比性,但在实践中我们要考虑一些其他因素。 
首先,数组的下标访问速度优于树的指针访问(关于这点大家可以反汇编,不要迷信教科书上指针访问必定快于下标访问的结论,这点效率虽然有差,但现代编译器会很好的优化相关代码)。 
其次,获取结果的效率,数组的顺序访问也优于树的遍历。树有中间节点的时间消耗,且数组能比较好得被Cache到。 
再者,即使N=1G=2^30,logN=30而已,O(logN)数量级和O(1)相比其实不算多坏。 
使用数组的方案在实际情况下,往往表现优于Trie,而且程序编写难度低,调试方面也相对轻松。 
最最关键的是,Trie在工程上应用面很窄,根本不像其在理论上来的那样强大。 
朴素的Trie一般应用在英文场景,数据集庞大且重复率很高的情况下比较适用。 
原因就是Trie太费内存,不能应用于中文。基本上可以说,不改造不优化,Trie就是废材。 
但Trie多路查找的思想确实很重要,很多变种能得到很好的时空效率。 
有些程序员会迷恋甚至迷信各种数据结构在理论上带来的结果,其实我们更应该看清本质,这也是我想写有深度的分析稿子的原因。 
关于Trie的优化和改造相关内容,我会再整理一份稿子奉上的,这里先作为一个案例引用。

3.2改造方案

现在的问题是,在线处理我们需要像Trie这样的多路查找树特性,而且要能支持中文。 
这里我们可以转换下思路,可以把中文转换成拼音,这样又可以直接套用Trie,只不过多了中文转拼音一个步骤。 
转拼音其实不难,就是做个表进行映射下就好了,GBK2.0标准中也就27000+个汉字,处理详细方法在这不累述,请自行google。

如上图,朴素的Trie是按英文字母做边的,而拼音是声母和韵母作为单元。 
比如“好”hao,“双”shuang,Tire的做法会使树中间节点冗余,影响查找效率,最重要的是导致内存浪费。 
优化方法是将Trie对英文字母的映射改成声母和韵母的映射。 
哈,这个说起来简单,实现起来还是有要注意的地方。 
Trie对字母的映射,可以简单得开个数组,类似ptr[26],然后映射就很简单,比如ptr[ch-'a']。 
而声母和韵母的映射没这么简单,一般方法就是枚举、二分查找、map、hash,虽然集合不大,但或多或少都需要耗费些时间。 
但这个是为减少空间浪费做的一点点时间牺牲,在工程实践上是完全值得的。

有童鞋会说,既然声母和韵母还是要映射,为什么不直接映射中文? 
其实不映射中文的原因,在于中文处理本身有难度,字符集大,词组间相同前缀较短,容易给树结构的内存问题雪上加霜。 
用拼音的方法,容易合并相关前缀,比如同音不同字的情况。 
当然这些空间优势也需要付出一定的时间花费,就是在节点上保存相关词组。 
比如图例中的shuang,它可能是“双”,也可能是“爽”。这在查询前缀较短的情况下,词组候选集过大,导致额外的性能瓶颈。 
说到这里的时候,大家可能有点迷糊了,既然中文的查找树太费内存不可用,而拼音的查找树又会退化,那怎么解决才好? 
在这,我想表明我自己的一个观点,就是特定的复杂的应用应该有量身定做的算法和数据结构,教科书上不可能有现成的方案。所以一个优秀程序员的必经之路,必须要能融会贯通,然后构建出自己的解决方案。

概括下我的思路。对于有更新的在线处理,我们如果采用多路查找树的思想(我这不提Trie了,因为Trie已经被改造的面目全非),可以既照顾到数据集的更新也能兼顾查询效率,两者的时间复杂度都和操作的字符串长度有关,这已是极小的时间花费。 
从汉字转为拼音,虽然无法直接映射汉字,导致同音词查询新子问题的出现,但换来了空间可用性。 
因而打开了一种新的思路,在这里拼音做了类似一级索引的工作,同音字的筛选就能在小数据集中操作。

上图中,红色表示一级索引,绿色表示二级索引,蓝色表示数据集(蓝色是冗余数据优化),不同的图形表示不同的数据结构。这样在工程上的好处是可以结合多个不同数据结构各自的优点。 
一级索引查找方式类似Trie,二级索引可以使用set、map、hash等关联结构,数据集可以使用list、vector等顺序结构。 
使用STL的童鞋可以在资料[3]中查询各种结构的用法。

3.3改造方案优化

我们来分析下复杂度,首先分析查询时间复杂度(不算蓝色优化部分)。 
一级索引查找时间跟前缀拼音长度有关T(Len(P))。子树遍历跟其大小有关,最坏能到达O(N)。 
遍历子树是多路查找树的通病,因为它的中间节点不保存子节点信息,当然你可以选择冗余保存(就是蓝色的功能)。 
离线处理时我们说过,实践中结果集R会是一个常数值,所以别担心O(N),这里我们换成T(R)来计算。 
当一级索引节点有匹配时,进入二级索引,这里我们使用STL的set结构来分析。 
set使用iterator遍历时,它是字典序的,所以使用lower_bound + iterator就能搞定,时间复杂度是O(logN) + T(R)。 
最坏情况下,每个有效节点(除去不完整的拼音节点)只有一个词,这样需要遍历R个有效节点。 
时间复杂度为Len(P) + R*(O(logN) + R) = Len(P) + R*O(logN) + R*R,因为Len(P)和R都是常数值,所以最后查询的时间复杂度为O(logN)。 
插入操作的流程跟查询类似,时间复杂度也相同,在这就略过了。 
从这个角度讲,大家不要太过于迷信大O分析,这只是很粗略的上界,它保证时间效率上的可用性,不代表它的实际运行效率。 
所以,见到O(logN)跑的比O(N^2)都慢的程序也是很正常的,很多细节的优化,往往都是根据相关数据和特点在大O系数和常数间挣扎。

3.4自平衡树

估计很多童鞋看上字典树的处理方案已经很头大了,有没有又方便又快捷的方案? 
当然有,离线处理我们提到过自平衡树,如std::set,std::map。 
在线处理中就很好的用到了它的插入特性,时间复杂度为O(logN)。 
然后依然使用lower_bound + iterator方法查询。 
这样它的插入和查询也都是O(logN),那上面的方案跟平衡树方案效率是等同的? 
此时,我希望大家能从字典树的复杂度分析过程中找到些灵感,这里我不详述红黑树理论,可参见资料[4]

搜索suggestion的更多相关文章

  1. c#面试题汇总

    下面的参考解答只是帮助大家理解,不用背,面试题.笔试题千变万化,不要梦想着把题覆盖了,下面的题是供大家查漏补缺用的,真正的把这些题搞懂了,才能“以不变应万变”.回答问题的时候能联系做过项目的例子是最好 ...

  2. .NET工程师面试宝典

    .Net工程师面试笔试宝典 传智播客.Net培训班内部资料 这套面试笔试宝典是传智播客在多年的教学和学生就业指导过程中积累下来的宝贵资料,大部分来自于学员从面试现场带过来的真实笔试面试题,覆盖了主流的 ...

  3. 传智播客DotNet面试题

    技术类面试.笔试题汇总(整理者:杨中科,部分内容从互联网中整理而来) 注:标明*的问题属于选择性掌握的内容,能掌握更好,没掌握也没关系. 下面的参考解答只是帮助大家理解,不用背,面试题.笔试题千变万化 ...

  4. Interview

    下面的题是供大家查漏补缺用的,真正的把这些题搞懂了,才能"以不变应万变". 回答问题的时候能联系做过项目的例子是最好的,有的问题后面我已经补充联系到项目中的对应的案例了. 1.简述 ...

  5. C# 面试宝典

    1.简述 private. protected. public. internal 修饰符的访问权限. private  私有成员 只有类成员才能访问 protected  保护成员 只有该类及该类的 ...

  6. 收藏所用C#技术类面试、笔试题汇总

    技术类面试.笔试题汇总 注:标明*的问题属于选择性掌握的内容,能掌握更好,没掌握也没关系. 下面的参考解答只是帮助大家理解,不用背,面试题.笔试题千变万化,不要梦想着把题覆盖了,下面的题是供大家查漏补 ...

  7. 转:.NET面试题汇总(三)

    原文地址:http://www.cnblogs.com/yuan-jun/p/6600692.html 1.简述 private. protected. public. internal 修饰符的访问 ...

  8. .net面试题[转载]

    1.简述private.protected.public.internal修饰符的访问权限. private:私有成员,在类的内部才可以访问. protected:保护成员,该类内部和继承类中可以访问 ...

  9. .Net 面试题 汇总(四)

    1.简述 private. protected. public. internal 修饰符的访问权限.private : 私有成员, 在类的内部才可以访问.protected : 保护成员,该类内部和 ...

随机推荐

  1. volatile关键字解析(转)

    volatile关键字解析 转载:http://www.cnblogs.com/dolphin0520/p/3920373.html volatile这个关键字可能很多朋友都听说过,或许也都用过.在J ...

  2. 在Window下安装Oracle

    一.Oracle下载地址 http://www.oracle.com/technetwork/database/enterprise-edition/downloads/index.html 二.解压 ...

  3. arcpy.mapping常用四大件-MapsurroundElement

    arcpy.mapping常用四大件-MapsurroundElement by 李远祥 在arcpy.mapping 中,除了数据入口MapDocument.图层Layer之外,另一重要的角色就是M ...

  4. QML Object Attributes QML对象属性

    QML Object Attributes Every QML object type has a defined set of attributes. Each instance of an obj ...

  5. bootstrap3 响应式布局栅格式布局

    抓住重点 下面开始实现Bootstrap版本3的Demo 案例 首先去官方网站  http://www.bootcss.com/ 下载 点击 进入 点击 进入 下载 把 相关的js和css 拷贝到项目 ...

  6. ubuntu下包管理器apt-get常用命令

    apt-cache search package 搜索包 apt-cache show package 获取包的相关信息,如说明.大小.版本等 sudo apt-get install package ...

  7. Javascript面对对象. 第三篇

    3.字面量 为了让属性和方法很好的体现封装的效果,并且减少不必要的输入原型的创建可以使用字面量. function Box(){} //使用字面量的方法创建原型对象,这里的{}就是对象,是Object ...

  8. 从C#到TypeScript - Generator

    总目录 从C#到TypeScript - 类型 从C#到TypeScript - 高级类型 从C#到TypeScript - 变量 从C#到TypeScript - 接口 从C#到TypeScript ...

  9. linux下常用语言的语法检查插件整理

    linux下常用语言的语法检查插件 可以结合vim语法检查插件syntastic使用,具体请参考syntastic使用说明 如php,sql,json,css,js,html,shell,c等语法插件 ...

  10. BZOJ 1877: [SDOI2009]晨跑(费用流)

    看到要求两个量就下意识的想到了费用流= =,先把一个点拆成两个点就能够解决一个的只经过一次的限制 CODE: #include<cstdio>#include<iostream> ...