算法笔记(IV) 字典

典在计算机中指信息及其索引,也可以理解成Key-Value的关联数组或者map(本质上是key与value构成的笛卡尔积的子集,不同于数学中的映射,其可以是一对多的关系)。key可以是数字、字符串或更复杂的结构等。实现字典的常用数据结构有:hash表、字典树(trie)、二叉树、B树等,它们的优缺点各异,适用不同的场景,在一些场景中,为了节省空间开销或者是加快检索的速度,甚至可以组合适用。


    “字典”就像函数(映射map)在数学中的地位一样,在计算机中应用极其广泛,小到字符串匹配、字典压缩算法,大到编译器、数据库、文件系统、搜索引擎等,都需要建立或者维护字典。高德纳用了第三卷的下半部来讲解“查找”(并声称程序设计的每一重要方面都离不开排序和查找),就涵盖了以上的几种数据结构。另外,其上半部花了很大的精力去讲解“排序”,是因为我们的查找本身能够达到高效,需要排序处理,例如二分查找、在查找字符串时,如果字符串是经过字典序排序的,那么就极大的方便我们的查找。下面使用现实中的例子来说明使用这些数据结构和查找手段的现实原型。

     当我们打开一本书的时候,我们急需的就是浏览索引,掌握这本书的结构。索引有很多种,目录就是一种最常见的索引,一本书往往以章节展开,首先是分成各章,然后各章再细化成小节,一般的目录的层次只有章节这两层,一些内容比较多的书可能会有3-4层之多,但再多就对读者构成了太多的压力(有实验表明人类能够接受的递归层数不超过7层,盗梦空间也不过4层结构;)),也不能做到简明了。这种目录结构是B树结构的原型,B树的优点是层次递归结构,并可以将上层节点放置内存,而下层节点放在磁盘中,因相对二叉树其层数更少,可以降低磁盘IO的次数,并支持一些范围查询等功能。 

     在实际使用中,一旦知道页数,我们并不是逐页的翻找,而是先估算一下位置(如果我们不知道总页数或者书的厚度的话,翻一半位置是最保险的办法),启发式的翻到一个页面,并比对页码,依次递归下去,一般很快就可以找到对应的页码。这就是二分查找的原型。

      另外一种索引也非常的常见,比如英文词典的词是经过以字典序排过的,非常利于我们的查找,我们在检索时,顺着单词的前缀不断的定位目标。这种索引结构对应的是trie树(字典树,一种前缀树)。在中文中,我们常根据拼音去定位字,拼音的字母就利用了字典序(当然和英文字母的顺序不同),对于念相同读音的字又按照笔画数目多少进行排序方便定位。思想和英文是相通的。另外,利用trie树或者字典序,我们可以方便的查找出拥有相同前缀的字或者单词。另外后缀树和后缀数组可以实现更多的功能,对此了解的不是很多,参考[suffix tree]。

      还有一种索引,这种索引定位速度往往更快,比如在中文中,可以利用笔画数目直接定位字,对于一些场景速度更快。这就是hash表的原型:抽取对象的某一种性质,映射成一个大于0的整数,快速定位数据表。当然这种方案在一些场景并不好,例如笔画数目在30以上的字总共才在12个,但笔画数目为10的字竟然有1700多,这就是说hash表存在冲突(而且非常的不均匀)。为了处理这种情况,依然是对这些字依读音进行排序。另外,我们的字典也采用偏旁部首来对字进行编排,对那些有相同的偏旁部首的字又经过以笔画数目从小到大排序,以方便我们查找。如果以偏旁部首的笔画数目以方便减少部首,则冲突就相对小的多了,笔画数目最多的偏旁部首也不过15划,冲突最多的数目是4划的部首,但也不过52个。所以我们先使用笔画数目定位部首,再在有相同部首中再使用全字的笔画数目找到相应的字(甚至如果再发现有冲突的话,在以拼音定位),则更方便快速。另外,对于静态的字典,可以构造出最小完美hash函数,避免冲突并节省内存开销。

     从上面的讨论可以看出,合理的设置索引将方便我们的检索,而且索引要根据应用场景针对性的设置,没有任何一种数据结构适用所有的情况。而且,在实际中,往往是多种索引结构组合使用。例如上述先利用笔画数目定位部首,在在部首中再以全字笔画数定位字就是一种二层hash结构(two-level hash table,见于nosql数据库系统),当然这仍不能保证不冲突,一般我们的字典都是静态的,很少出现增添字这种事情,即使再版我们也只要重新以拼音或者笔画数再编排一下就可以了。

     在专业词典和英文词典中,需要不断的再版修订补充,周期短则一年两年,长则可能几十年甚至不会再版修订,而对于计算机维护的资源来说,更新的频率就快的多了,想想一下大型的网站,如google、amazon等,其读写更新数据的实时性要求很高,需要更快速的更新内容以及字典,因此往往采用动态的数据结构维护,二叉树就是一种优良的动态结构,可以保证动态的添加编辑数据,因此上述的场景中,需要排序的功能而又需要动态维护的功能,均可以采用二叉树进行实现。例如上述的二层hash结构仍然出现冲突的位置,可以挂接一个二叉树以某种序进行排序,即方便检索又方便动态的维护。

     另外,上述讲到的trie树在计算机实现上也有一定的考究。 例如我们发现以z开头的拼音会非常的多,而以o开头的拼音不过o、ou两个,也就是说我们的trie树的分支是非常的稀疏的(或者不平衡的),有的分支比较多,有的就比较的少,因此如果我们的空间很紧张的话,我们就不需要为o开头的拼音设置一个完整的字母表搭配了。所以一般可以使用链表实现的稀疏数组(sparse array)、二叉树等动态数据结构表示trie树的子节点,以降低空间开销。相比而言,二叉树维护序的功能要比链表更为方便。另外,还有一些优化手段,没有仔细研究,参见[double array trie]。

     在实际应用场景中,要查找的对象往往是存储在磁盘中的,索引为了更快速,一般常驻内存,因此为了节省内存并减少内存换页带来的效率问题,所以尽量使得索引的空间开销较小。考虑以上的原因,我们并不会在索引中直接存储查找对象的值,因为对象可能是一个根本就无法放进内存的文件或者大数据块。作为一个技巧,我们只会在索引中记录对象数据的指针或者说是句柄抑或是磁盘号扇区等,这样的开销就远远小于对象数据了。另外,如果内存仍然紧张,则可以将索引同样放在磁盘中,利用缓存技术将经常访问的索引加载到内存中来,并利用缓存替换算法根据使用的频繁度进行淘汰维护,如Tokyo Cabinet,网络搜索引擎也采用一些类似的缓存技术加速查询速度。另外为了克服随机写的问题,google的levelDB采用了LSM tree结构,将频繁更新的内容放在内存中,并批量的写入磁盘(细节实现不详,有待继续研究)。如果没有遇到随机写这种场景,例如日志系统或者是磁带系统,我们永远是顺序的写数据,而使用场景主要是随机读(即检索查找),一般这种系统采用hash表,又高效而简单了,如Bitcask

     但问题又来了,我们如何确定索引中放置的指针指向的对象数据就是我们要检索的目标呢?万一花了大力气从磁盘中读取出来的东西,却根本不是我们想要的呢?在数据库系统中,往往采用校验码来解决这个问题。对于一些小的数据块,我们可以使用CRC系列快速的计算出校验码(本质也是hash),和目标的校验码比较,如果相同,则很可能就是我们要查找的目标(当然也可能存在冲突),而对于大文件可以使用冲突更小的hash,如MD5等。这里简单提一提,对于数据同步的场景(例如网络传输场景,需要同步多个网关或服务器上的数据;或者客户端和服务器端的下载数据,如电驴、迅雷等),可以采用hash树进行维护,如果发现某个数据不一致,则可以递归的定位到更小的不同步数据块,以降低因不同步而传输的数据量。
   
     另外,有一些场景也对磁盘IO(或者网络传输)有严格的要求,我们最好能够预先判断数据库中是否存在此数据,以减少不必要的磁盘IO或者网络传输,此时就可以采用布隆滤波器来完成。本质上,布隆滤波器也是一种hash的改进版本。举个简单的例子,假设我想检索一堆字中有没有我想要的字,我可以预先的建立一个表,这一堆字的笔画数对应的表值均设置为1,反之为0;而这一堆字的偏旁部首的笔画数对应的表值也设置为1,反之为0。所以一个字过来,我只需预先判断这个字的笔画数和偏旁部首笔画数对应的表值是否为1,如果其中一个不为1,则我立即得出结论,这个字在这堆字中没有出现,以节省读取和比较时间。当然布隆滤波器只是一种启发式方案,会存在误判,但是不会存在漏判,这点需要在不同的工业应用仔细考虑,如安全性要求很高的场景,如入侵检测青睐于更低的漏判率,而一些场景,如垃圾邮件检测等,我们更倾向于不要误判。

     现在上述介绍的主要是正排索引(前向索引),就像是矩阵有其转置(倒排索引更像是对正排索引的转置,而不是逆矩阵,没有举函数与反函数的例子,因不太严谨)一样,有正排索引就有倒排索引(也称反文件)。假如我们利用正派索引检索某一个页面包含的关键词有那些,另一些场景则需要我们根据出现的关键词以及集合来检索页面(多见于搜索引擎)。这时就需要倒排索引来保证快速的定位了。倒排索引会记录出出现了某个关键词的页面号,如果要查找出现了某几个关键词的页面,我们可以取这几个关键词的页面号集合的并集,迅速的定位页面,并直接翻看到对应的页面。
    
    倒排索引不仅在一些场景可以加快查询,也可以与正排索引组合使用。类似的应用可见于公交路线查询和列车路线查询,使用正排索引和倒排索引可以方便的搜索换乘路线。简单的提一下,如果用0-1矩阵M表示公交路线或者列车路线中站点和路线的索引,则M*M‘表示一次正排索引和倒排索引组合查询,意味着两条路线是否有交叉点。同理M*M’*M*M'则表示再次组合查询,经过一次换乘的查询情况,如此类推。

    倒排索引结构在英文的书籍中非常的常见,一般都附在书的尾部,辅助读者快速定位内容。例如一本书的内容编排无法做到完全的线性,前面的章节可能会引入一些概念,而这些概念的讲解主要放在后面章节进行讲解。如果遇到这种情况,最好使用倒排索引,利用这些概念的相关关键字快速定位出现的页面号,就可以迅速的把握概念的来龙去脉以及被使用的关系了。

    本文简单的介绍了字典的现实世界原型以及一些理解体会,字典的设计远非这么简单,就像很少书的索引能够满足读者的需求一样,需要很多的考究。

这一周杯具的打酱油了,本来想和产品线确定需求的,结果发现需求似乎过高,迟迟不敢确定。做了几个简单的实验,发现要提高真的很难,要再想新的路子了。 
2011-11-21 0:56

最近想了一下hash表的处理,如果一个hash表一旦冲突就将原先的项替换的话,实际上这个hash表是一个缓存实现;
为什么容许这种可以替换的hash表呢?这主要是为了存储空间使用而考虑,如一些场景要求使用内存可配置(也就是可以固定使用内存量),甚至有些场景存储空间是硬性的(如CPU的cache,是硬件设计造成的,而且因在CPU晶圆上的面积限制而非常昂贵);

可替换hash表常常用于压缩算法、检索、因特网浏览器等场景,这些场景并不需要保证数据完全的被记录下来,只需要记录最近访问或者频繁访问的数据,因此hash表冲突后抛弃原数据项并不会对系统的运行构成威胁(这似乎有点像操作系统和CPU中的缓存和cache)。据我所知,这种冲突式hash表(也就是缓存)可用于web服务器的优化、数据压缩、搜索引擎、匹配和搜索算法的优化(例如正则式匹配的优化,如google的re2正则式引擎)等等,
另外,动态规划算法遇到规模庞大的问题也会用到这种会冲突抛弃的hash表作为状态缓存,以节省内存开销(见  程序设计中常用的解题策略 王建德等)。

2011-12-28 20:53

补充:字典的创建
在搜索引擎技术上,字典的创建可以分为两种:(1) 预先设置单词库+多模式匹配算法(AC自动机),例如如果我们的单词库是{“北京”,“北京人”,“人”,“在”,“纽约”},那么“北京人在纽约”,就很可能分成“北京人 | 在 | 纽约”;(2)使用n-gram方法,例如我们使用2-gram方案(2个词作为一个分词),则“北京人在纽约”,可以分成:“北京”,“京人”,“人在”,“在纽”,“纽约”;显然第一种方案的可能会遗漏一些可能匹配的结果,因为搜索的结果依赖於单词库的设计,并牵扯到词性、句法等问题,第二种方案,不会遗漏任何结果,但是会导致很多错误的匹配结果。

这里简单提一下,有时候我们并不需要去建立字典,或者显式地构建一个字典,例如正则式匹配等,我们可以即时的根据用户输入的搜索词,匹配存储的文档,当然带来计算量较大。所以一般的搜索引擎使用的是显式的构建字典(使用上述的两种方案),从而得到倒排索引以加快搜索。

在《深入理解搜索引擎》一书中,花了很大的篇幅去讲解压缩算法,以为确实“压缩算法”和“搜索引擎”在很多方面是相通的,例如滑动窗口压缩算法LZ77就采用的就是隐式的构建字典,而LZW就是显式的构建字典。另外对于“重复数据删除技术”(本质上是一种字典压缩算法)的划分数据块技术也与上述的两种分词构建字典的技术相通,请参考[http://blog.csdn.net/liuben/article/details/5829083]。

最近正在看《大规模web服务开发技术》,其中也谈到“压缩技术”是与“搜索引擎”紧密相关的。其实我不仅觉得两者的关系不仅仅是“搜索引擎”中不断使用“压缩技术”,如文本压缩、倒排索引的压缩等,而是两者在思想上也有很多的相似之处,特别是对于特殊的字典压缩算法:重复数据删除来说,其和“搜索引擎”作用和结构太相似了,所以由衷感觉在设计“重复数据删除”方案时,就像在构建一个搜索引擎一样。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章