「学习笔记」后缀数组

引子

(LCP)...只能二分加 (HASH) 吗...?


算法

​ 为了描述方便, 我们把 "后缀 (i) " 定义为 : 以下标 (i) 为起点的后缀.

初始目的

后缀数组的主要过程, 实际上就是为了求两个数组.

  1. (rank[i]) : 后缀 (i) 的排名.
  2. (sa[i]) : 排名为 (i) 的后缀的编号.

实现过程

​ 最暴力的想法 : 把 (n) 个后缀都扒下来, 排个序. ( STL string 真好用)


​ 我们发现, 任意两个后缀都会有相同的部分, 那我们是否可以利用这一点来优化排序的过程.


​ 总思路 : 倍增.

​ (为了更方便描述, 我们设 (s(i,j)) 为以下标 (i) 为起点, 且长度为 (2^j) 的字符串, 长度不够时在后面补一个小于字符集中任意字符的元素).

​ 先弄出所有 (s(i,0)) 的排名, 然后据此得出所有 (s(i,1)) 的排名, 然后再据此得出所有 (s(i,2)) 的排名, 以此类推, 直到 (2^j ge |S|) 为止.


大概过程 :

​ 我们设 (rk[i][j])(s(i,j)) 在当前阶段的排名 (当前所有的字符串长度都为 (2^j)).

​ 假设我们当前已经得到了(rk[i][j-1]), 我们现在想得到 (rk[i][j]).

​ 那么, 我们需要对每个 (s(i,j)) 都建一个排序用的 (pair).

(pair[i]) 的第一关键字是 在长度为 (2^{j-1}) 时, 以 (i) 为起点的字符串的排名, 即 (rk[i][j-1]),

(pair[i]) 的第二关键字是 在长度为 (2^{j-1}) 时, 以 (i+2^{j-1}) 为起点的字符串的排名, 即 (rk[i+2^{j-1}][j]).


​ 这样设置的原因是 : (s(i,j-1) + s(i+2^{j-1},j-1)) 实际上就等于 (s(i,j)), 那么我们用这两个关键字排序, 就可以得到 (rk[i][j]).

​ 如果直接 (sort) 的话, 总复杂度为 (O(nlog^2 n)), 但是我们可以使用基数排序, 使得每次排序复杂度降低到 (O(n)), 总复杂度也就变为 (O(nlog n)).


基数排序要利用到两次桶排, 大致过程如下,

  1. 先按照第二关键字桶排.
  2. 算出每个第一关键字所拥有的元素数量, 按照第二关键字从小到大地把元素放到第一关键字的桶的对应位置上.

代码

int n,m=100,rk[_],sa[_],c[_],t[_];
// n: 字符串长度  m: 字符集大小  rk[i]: 字符串 i 排名  sa[i]: 排名为 i 的字符串
// c[i]: 第一关键字为 i 的字符串个数  t[i]: 以第二关键字排序的桶
void g_sa(){
	for(int k=1;k<=n;k<<=1){  // 倍增, k 是 2 的若干次方
		int num=0;
		for(int i=n-k+1;i<=n;i++) t[++num]=i;  // 第二关键字为 0 的字符串, 在桶中一定排在最前面
		for(int i=1;i<=n;i++) if(sa[i]>k) t[++num]=sa[i]-k; 
		// 编号大于 k 的字符串的排名, 可作为 i-k 字符串的第二关键字
		for(int i=1;i<=m;i++) c[i]=0;  // 清空
		for(int i=1;i<=n;i++) c[rk[i]]++;  // 统计第一关键字为 rk[i] 的字符串个数
		for(int i=1;i<=m;i++) c[i]+=c[i-1];  // 前缀和, 相当于确定每种第一关键字在桶中的位置
		for(int i=n;i>=1;i--) sa[c[rk[t[i]]]--]=t[i];  // 把字符串按第二关键字的顺序放入第一关键字桶中
		for(int i=1;i<=n;i++) t[i]=rk[i];  // 备份第一关键字
		num=0;
		for(int i=1;i<=n;i++)  // 重新排序, 两个关键字都相同的字符串排名也相同
			if(i!=1&&t[sa[i]]==t[sa[i-1]]&&t[sa[i]+k]==t[sa[i-1]+k]) rk[sa[i]]=num;
			else rk[sa[i]]=++num;
		if(num==n) break;  // 有 n 个排名, 表示每个后缀的排名已经分出来了, 直接退出
		m=num;
	}
}

扩展应用

​ 求 (LCP). ( (Longest common prefix) 最长公共前缀)

​ 说是扩展应用, 但是后缀数组基本上就是用来做这个的...


​ 我们引入一个 (height) 数组,

(height[i]) 表示 : 排名为 (i) 的字符串与排名为 (i-1) 的字符串的最长公共前缀, 即 (LCP(sa[i],sa[i-1])).


​ 这东西是可以 (O(n)) 求的, 为了达到这个复杂度, 我们需要用到一个引理,

[height(rk[i]) ge height(rk[i-1])-1 ]

证明

​ 设后缀 (i-1) 与后缀 (x)(LCP) 长度为 (len). 因为后缀 (i) 相当于把后缀 (i-1) 的第一个字符去掉后得到的字符串, 而后缀 (x+1) 也是把后缀 (x) 的第一个字符去掉的后得到的字符串, 所以后缀 (i) 与后缀 (x+1)(LCP) 长度为 (len-1), 得证.

​ 这样, 我们就可以枚举 (i), 并从 (height(rk[i-1])-1) 的位置开始, 将字符串 (i) 和字符串 (sa[rk[i]-1]) 逐位匹配.


代码

int hgt[_];
void g_hgt(){
  int las=0;
  for(int i=1;i<=n;i++){
    int j=sa[rk[i]-1];
    if(las) las--;
    while(j+las<=n&&i+las<=n&&a[j+las]==a[i+las]) las++;
    hgt[rk[i]]=las;
  }
}

​ 处理完 (height) 数组后, 我们该怎么求任意两个数组间的 (LCP) 呢?

​ 再来一个引理

[egin{align} LCP(i,j) &= min { LCP(k,sa[rk[k]-1]) }, ( rk[i] < rk[k] le rk[j]) \ &= min { height[t] }, (rk[i] < t le rk[j]) end{align} ]

证明

​ 设 (T = min { height[t] } = len, (rk[i] < t le rk[j])), (s) 为原字符串.

​ 首先证明上界.

​ 因为 (rk[i] < rk[sa[T-1]] < rk[sa[T]] le rk[j]).

​ 所以 (s[i+len] le s[sa[T-1]+len] < s[sa[T]+len] le sa[j+len]).

​ 即 (s[i+len] < s[j+len])

​ 所以 (LCP(i,j) le len), 上界得证.

​ 再证明下界. 运用反证法.

​ 假设 (LCP(i,j) < len),

​ 那么可以得到 (s[i+len-1] < s[sa[T-1]+len-1] = s[sa[T]+len-1] < s[j+len-1]),

​ 那么一定会存在一个 (x in (i,T-1] cup (T,j]), 满足 (s[sa[x-1]+len-1] < s[sa[x]+len-1]), 即 (height[x] < len < height[T]), 与条件矛盾, 故假设不成立.

​ 所以 (LCP(i,j) ge len), 下界得证

​ 综上所述, (LCP(i,j) = len), 得证.


​ 那么, 对于任意两个后缀 (i,j (i le j)), 我们可以求出满足 (rk[i] < t le rk[j]), 最小的 (height[t]), 这个最小值即是 (LCP(i,j)).


例题

hihocoder 后缀数组系列 (搜索 "后缀数组" 即可)

简要题解

后缀数组一 : 单调栈

后缀数组二 : 二分答案

后缀数组三 : 将两个数组拼接, 比较 (sa) 上相邻两个串的 (LCP).

​ 扩展 : [POI2000]公共串 : 将直接比较改为单调队列即可.

后缀数组四 : 枚举 (l), 每次把序列分成 (frac{n}{l}) 个长度为 (l) 的块, 查询相邻两个块首的 (LCP), 再根据 (LCP) 处理最优解 的起点在块内的情况


参考资料

OI Wiki - 后缀数组(SA)

后缀数组 学习笔记 by xMinh

原文地址:https://www.cnblogs.com/BruceW/p/12202935.html