Succinct简洁数据结构是一种来自生物信息学的研究成果,根据Wiki百科的定义是在数据压缩存储达到接近信息熵下界时仍然保持高效的查询性能的一类数据结构。听起来有些拗口,通俗点说就是既能压缩存储还能高速检索。Succinct数据结构有很多,小波树(wavelettree)是其中最常见有效的之一。小波(wavelet)跟图像里的小波变换没什么关系,为什么起了这么迷惑的名字很难知晓,笔者猜测大概从结构上看起来有些像图像处理里小波变换吧。
小波树总体上是针对一个字符串构造的一种数据结构,用来回答Rank和Select这样的查询。Rank操作代表这样的含义:对于一个{0,1}构造的位图向量,Rank(position,1)的含义是位图中position位置之前1的数量。那么对于一个字符串来说,Rank(position,alpha)代表字符串中position位置之前字符alpha的数量,例如下图的字符串中,Rank(5,e)=2。
Select是Rank的反向操作:对于一个{0,1}构造的位图向量,Select(frequency,1)代表第frequency次出现{1}的位置。例如在下面的位图向量中,Select(4,1)=7。
能够有效支持Rank/Select操作的位图向量是许多Succinct数据结构构造的基石,也包括小波树。假如位图向量可以正好放入一个word中,比如64bit,那么Rank其实就是一次popcnt操作,Intel的CPU可以采用SSE指令在几个周期内完成该操作。当位图扩大之后,为了能够支持高速的Rank操作,就需要设计内存布局,使得最终的操作都将转化为单个word之上的popcnt,因此Rank性能的瓶颈将取决于cachemiss的次数——一次cachemiss将导致最长ns的延迟,相比之下几个指令周期的popcnt可以忽略不计。在Succinct数据结构刚刚出现的时候,早期的位图向量做一次Rank操作需要至少5,6次cachemiss,后来日本人Takeshi发明了3次cachemiss的位图向量,而我们此前团队的August进一步改进,做到了仅需1次cachemiss,这是目前最优的位图向量布局。相比Rank操作,Select要昂贵得多,可以类比数学中的积分对比求导的性能差异。
下边我们来看一下最常见的二叉小波树是如何构造的。二叉小波树构造过程就是把字符串转化为一颗平衡二叉树位图的过程,0代表一半的符号,1代表另一半符号。在树的每一层,字符表都要重新编码,直到最底层没有任何歧义。递归的构造过程如下:
取字符串的字母表,将前半部分编码为0,后半部分编码为1,例如{a,b,c,d}就变成了{0,0,1,1}。这个时候编码是有歧义的,比如你不能根据0就猜测该字符是a还是b。
把0表示的字符{a,b}分组做为一个子树;把1表示的字符{c,d}分组做为另一颗一个子树。
在每一颗上都重复如上步骤直到子树只包含1个或者2个字符,这样0或者1就可以明确表示而没有任何歧义了。
例如对于字符串PeterPiperpickedapeckofpickledpeppers,构造出的二叉小波树如图所示,这里,空格和字符串终止符我们分别特殊符号来表示,比如_和$,那么整个串的字符表包含{$,P,_,a,c,d,e,f,i,k,l,o,p,r,s,t},首先它们会被编码映射成{0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1},左边的子树基于编码为0的字符集创建,包含{$,P,_,a,c,d,e,f}然后该子树重新编码为{0,0,0,0,1,1,1,1},再重复子树创建过程。
可以看到整个二叉树是平衡的,因此,我们可以把每层所有的位图连接在一起合并成一个大的位图,这样树的每一层都是一个等长的位图,而树的高度则是字符集尺寸的对数O(log_2{A})。在一个小波树构造好之后,一个字符串上的rank操作需要从树的最顶端位图开始操作,直到最底层的位图,因此一共需要N次位图上的rank操作,这里N等于小波树的高度。例如查询Rank(5,e)的过程可以由图看出来:首先在最上层,{e}我们编码为0,因此在这一层执行rank(5,0)的操作,我们可以得到0的数量是4。
这个结果可以引导我们到下一层从那个位置开始执行rank操作——在{0}表示的子树中,第4个位置,由于在该层{e}已经编码为1,因此我们需要执行rank(4,1),重复该步骤直到最底层。
除了二叉小波树之外,还有霍夫曼小波树,针对文本型序列可以提供更高的压缩比,以及更加快速的小波矩阵,本文的题图采纳了Matrix电影的片头,就是类比小波矩阵看起来成片的位图向量。
那么拥有可以提供Rank/Select能力的小波树,我们都可以做一些什么工作呢?这里有一些基于Rank/Select的扩展型查询:
Lookup(T,p):在一个字符串序列T,返回位置在p的序列项。
Quantile(T,p,sp,ep):在一个字符串序列T,返回位置在sp和ep之间的第p个最大值。
FreqList(T,k,sp,ep):在一个字符串序列T,返回位置在sp和ep之间的最频繁的k个值。
RangeList(T,sp,ep,min,max):在一个字符串序列T,返回位置在sp和ep之间,取值范围在min和max之间的所有项。
RangeList(T,sp,ep,min,max):在一个字符串序列T,返回位置在sp和ep之间,取值范围在min和max之间的所有项的频率。
这些扩展型查询基本都是围绕一个字符串序列某个区间之内的统计信息来做,聪明的读者一定可以想到可以拿它来做数据分析。它们大部分的复杂度跟Rank/Select相差不大,都是正比于小波树的高度。因此可以看到,如果拿小波树去表示一个序列,不论该序列有多长,查询性能都不受影响,因为它只受限于序列字符集的大小。举例来说,如果小波树表示的是中文文本,那么任何操作的时间只跟log_2()这个值有关,这意味着小波树的高度最多只有16层,这是效率多么惊人的数据结构!当然,在实际工程实现中,还会受到其他的限制,比如cachemiss,内存分配,等等。做为一个压缩型的数据结构,表征全部文本,所消耗的空间最多只有16个位图向量。如果嫌上边的叙述仍然过于抽象,我们接下来可以举一些更加实际的例子。第一个例子是全文搜索。全文搜索的意思是,给定一段文本,我们可以快速的查询任意子串是否在该文本中出现,并且统计它的频率和出现的位置。常规的做法是构造后缀树或者后缀数组,Ferragina和Manzini在前人工作的基础之上把经过BWT变换后的后缀数组放到了小波树上,这样使得查询复杂度仅跟小波树的高度有关,而跟文本的尺寸无关,在巨大的文本序列基础上,这是多么大的性能提升!这就是著名的以他们名字首字母命名的FM-Index,是已知小波树最成功的用途之一。第二个例子是倒排索引。假定目前我们针对某文档集合需要构建索引,同时还需要提供词在每个文档中的位置信息,如果按照倒排索引的做法,我们需要同时存储文本信息和倒排索引本身,而如果把文档表示为一个巨大的字符串插入到小波树之中,我们可以仅仅使用一个压缩数据结构小波树而无需任何其他开销。在该小波树中,为了能够提取任何原始文本,我们只需要采用Lookup操作;为了访问某个词C对应的倒排链的第i个位置,我们只需要调用Select(i,C)操作,进一步的,Rank操作可以继续扩展为针对序列多个区间之间求交,所以我们甚至可以直接把构建好的倒排索引插入到小波树中,这甚至能够提供比原始倒排表还快的检索性能——因为小波树的求交复杂度跟文档数量无关!第三个例子是图。一个图常见的表示形式是邻接列表。给定这样一种数据结构,我们可以列出任何一个图节点的相邻节点。如果我们把这样的邻接列表放到小波树中会如何呢?我们可以以常数时间获取到任何一个图节点的某一个相邻节点,以常数时间获取到某几个图节点共同的相邻节点——看到这里聪明的读者想到了什么?微博的共同北京严重白癜风怎么办北京白癜风医院哪家治疗的好