后缀数组倍增构造算法说解

后缀数组,作为一种高效的字符串处理数据结构,没有哈希算法的随机性,也没有 KMP 算法的侷限性,是作为一种较为全面而编程思想不甚复杂的数据结构加以应用。后缀数组应用最为广泛的一种构建方法是倍增构造,时间复杂度为 O(nlogn),其中 n 为待处理字符串的长度。

我们不妨设待处理的字符串储存在 s[1 .. n] 中,则在一个后缀数组 sa[1 .. n] 中,sa[i] 表示该字符串的所有后缀集合 {s[1 .. n], s[2 .. n], .. , s[n .. n]} 中字典序为 i 的后缀为 s[sa[i] .. n]。与之相对应的后缀名次数组 rank[1 .. n] 中,rank[i] 表示后缀 s[i .. n] 在该字符串的所有后缀集合 {s[1 .. n], s[2 .. n], .. , s[n .. n]} 中的字典序排名为 rank[i]。显然,rank[sa[i]] = sa[rank[i]] = i。

另外,为了效率更高地解决字符串问题,后缀数组通常有一个辅助数组 height[1 .. n],height[i] 表示后缀 s[sa[i-1] .. n] 和后缀 s[sa[i] .. n] 的最长公共前缀的长度。由于存在性质:height[rank[i]] ≥ height[rank[i – 1]] – 1,故在求出 sa[1 .. n] 后 height[1 .. n] 可以在线性时间复杂度内求出。

倍增算法究其本质,类似于归并排序的过程。当第 i 轮倍增结束后,算法保证原字符串的所有后缀已经按前 2i 位字典序有序排列。我们设 rank[i][1 .. n] 表示第 i 轮倍增后的后缀名次数组,则先预处理出 rank[0][1 .. n] 的值(元素相同时排名相同)。假设我们已经处理完 rank[i][1 .. n] 的值,此时若想处理出 rank[i + 1][1 .. n] 的值,对于第 j 个元素,我们只需以 rank[i][j] 为第一关键字,以 rank [i][j + 2i ] 为第二关键字排序,并重新编号名次即可。

对于以上过程成立的原因,我们可以如此考虑。rank[i][j] 表示 s[j .. j + 2i – 1] 的排名,rank [i][j + 2i ] 表示 s[j + 2i .. j + 2i+1 – 1] 的排名,排序过程相当于以 s[j .. j + 2i – 1] 为第一关键字,以 s[j + 2i .. j + 2i+1 – 1] 为第二关键字排序,即以 s[j .. j + 2i+1 – 1] 按字典序排序,重编号后的结果就是 rank[i+1][j] 的值。

在每一轮倍增过程的排序中,若使用快排,则每轮倍增的时间复杂度为 O(nlogn),整个倍增过程的时间复杂度为 O( nlog2n )。考虑到 rank[1 .. n] 中的元素不超过 n,我们可以使用基数排序来降低时间复杂度,使得每轮倍增的时间复杂度为 O(n),从而使整个倍增过程的时间复杂度为 O(nlogn)。

每一轮基数排序的过程中,都会进行两次计数排序。第一次计数排序,以第二关键字对当前名次数组排序,将排序结果存储在索引数组里。第二次计数排序,以索引数组为遍历顺序,以第一关键字对当前名次数组排序,将排序结果存储在当前后缀数组里。这样可以保证基数排序的正确性。计数排序的代码如下:

#define CountSort(_rank,_sa,_index,_n,_m,_incr) \
{ \
    for(int __tmp_i=0;__tmp_i<=_m;__tmp_i++) \
        count[__tmp_i]=0; \
    for(int __tmp_i=1;__tmp_i<=_n;__tmp_i++) \
        count[__tmp_i+_incr<=_n?_rank[__tmp_i+_incr]:0]++; \
    for(int __tmp_i=1;__tmp_i<=_m;__tmp_i++) \
        count[__tmp_i]+=count[__tmp_i-1]; \
    for(int __tmp_i=_n;__tmp_i>0;__tmp_i--) \
        _sa[count[__tmp_i+_incr<=_n? \
            _rank[_incr>0?__tmp_i+_incr:_index[__tmp_i]]:0]--] \
            =_incr>0?__tmp_i:_index[__tmp_i]; \
}

基数排序后,要对名次数组重编号,其中第一关键字和第二关键字对应相同的元素,名次也应相同。由于基数排序处理出当前后缀数组,所以可以用当前后缀数组将当前名次数组处理出来。具体方法是,必然有 rank[sa[i – 1]] = rank[sa[i]] 或 rank[sa[i – 1]] + 1 = rank[sa[i]]。重编号的代码如下:

#define ReOrder(_rank,_sa,_index,_n,_m,_incr) \ { \     for(int __tmp_i=1;__tmp_i<=n;__tmp_i++) \         _index[__tmp_i]=_rank[__tmp_i]; \     _rank[_sa[1]]=1; \     for(int __tmp_i=2;__tmp_i<=n;__tmp_i++) \         if(_index[_sa[__tmp_i-1]]!=_index[_sa[__tmp_i]] \             ||(_sa[__tmp_i-1]+_incr<=_n)!=(_sa[__tmp_i]+_incr<=_n) \             ||_sa[__tmp_i]+_incr<=_n \                 &&_index[_sa[__tmp_i-1]+_incr]!=_index[_sa[__tmp_i]+_incr]) \             _rank[_sa[__tmp_i]]=_rank[_sa[__tmp_i-1]]+1; \         else \             _rank[_sa[__tmp_i]]=_rank[_sa[__tmp_i-1]]; \     _m=_rank[_sa[_n]]; \ }

在倍增算法之前,先将字符转成数字存储在名次数组里,再用一次重编号将 rank[0][1 .. n] 处理出来。在倍增算法的过程中,若增量 i 大于等于 n 或重编号后的最大名次等于 n,则可以确定后缀数组已经构造完成。倍增算法的代码如下:

char s[MAX_N];
int height[MAX_N],count[MAX_N],n,rank[MAX_N],sa[MAX_N];
void Doubling()
{
    int m;
    for(int i=1;i<=n;i++)
        height[i]=i,
        rank[i]=s[i]-'a'+1;
    CountSort(rank,sa,height,n,26,0);
    ReOrder(rank,sa,height,n,m,0);
    for(int i=1;i<n&&m<n;i<<=1)
    {
        CountSort(rank,height,sa,n,m,i);
        CountSort(rank,sa,height,n,m,0);
        ReOrder(rank,sa,height,n,m,i);
    }
    m=0;
    for(int i=1;i<=n;height[rank[i++]]=m)
        for(m?m--:0;s[i+m]==s[sa[rank[i]-1]+m];m++);
}
点赞