后缀数组
序言 & 目录
序言:有时间的时候就写一下。
目录:
- 字符串哈希
- Trie 树
后缀数组
定义
后缀数组(SA)是用来解决后缀问题的一种强有力的工具,通常来说是字符串的一大支柱算法。
后缀数组主要是用来解决后缀排序问题(通常是从小到大)和后缀求最长公共前缀(LCP)问题,我们定义以下符号:
- \(s\) 表示原串。
- \([i, n]\) 表示一个以 \(i\) 开头的后缀。
- \(sa_i\) 表示排名为 \(i\) 的后缀的起始位置。
- \(rk_i\) 表示 \([i, n]\) 的排名。
由此不难推出 \(sa\) 与 \(rk\) 是互逆数组,即 \(sa_{rk_i} = i, rk_{sa_i} = i\)。
过程
暴力
我们普通的后缀排序是利用 sort,每次比较暴力比较两个串的字典序大小,不难推出 sort 一共有 \(O(n \log_2 n)\) 次比较,又因为每次比较是 \(O(n)\) 的复杂度,所以总时间复杂度为 \(O(n^2 \log_2 n)\)。
二分 + 哈希
这种方法主要是应对考场上想不到模板时的方法,也是不要脑子的方法。
考虑在暴力比较两个串的字典序大小时,我们可以二分 + 哈希出最长公共前缀的长度,然后往后数一位就能知道两个串的大小了,因为二分加哈希是 \(O(\log_2 n)\) 的,所以总时间复杂度为 \(O(n \log^2 n)\)。
倍增法
不难由名字看出,使用方法为倍增。
具体内容是,第 \(i\) 次比较我们只比较后缀的前 \(2^i\) 位,又因为一个后缀的前 \(2^{i - 1}\) 位和 \(2^{i - 1} + 1 \sim 2^i\) 位的排名在第 \(i - 1\) 轮已经求过了,所以我们利用上一轮的排名进行双关键字排序,不难证明这样是对的。
具体如图:

其中 * 表示空缺的,我们补齐,在过程中因为可能会有一样的后缀的前缀,所以中间的排名会相等,但是最多到了 \(\log_2 n\) 次,排名就会全部不一样了。
那你可能问我怎么知道每次这个长度为 \(2\) 的次幂的后缀的前缀切半后两边对应着上一轮的哪些前缀串呢?这里搬 OI-Wiki 的一张图就懂了:

就是总结一句切半后的前一半和后一半都能通过计算得到是上一轮的哪些串。
由于有 \(\log_2 n\) 轮,每轮都要排序,那么总时间复杂度为 \(O(n \log_2 n)\)。
倍增法的优化
不难发现上述的双关键字排序使用基数排序可以做到 \(O(n)\),所以总时间复杂度为 \(O(n \log_2 n)\),代码实现有很多细节。
主要代码:
for ( int i = 1; i <= n; i ++ ) rk[i] = s[i], sa[i] = i;
for ( int len = 1; len < n; len <<= 1 ) {
memset ( cnt, 0, sizeof ( cnt ) );
for ( int i = 1; i <= n; i ++ ) id[i] = sa[i];
for ( int i = 1; i <= n; i ++ ) ++ cnt[rk[id[i] + len]];
for ( int i = 1; i <= M; i ++ ) cnt[i] += cnt[i - 1];
for ( int i = n; i >= 1; i -- ) sa[cnt[rk[id[i] + len]] --] = id[i];
memset ( cnt, 0, sizeof ( cnt ) );
for ( int i = 1; i <= n; i ++ ) id[i] = sa[i];
for ( int i = 1; i <= n; i ++ ) ++ cnt[rk[id[i]]];
for ( int i = 1; i <= M; i ++ ) cnt[i] += cnt[i - 1];
for ( int i = n; i >= 1; i -- ) sa[cnt[rk[id[i]]] --] = id[i];
for ( int i = 1; i <= n; i ++ ) ork[i] = rk[i];
int r = 0;
for ( int i = 1; i <= n; i ++ ) {
if ( ork[sa[i]] == ork[sa[i - 1]] && ork[sa[i] + len] == ork[sa[i - 1] + len] ) rk[sa[i]] = rk[sa[i]] = rk[sa[i - 1]];
else rk[sa[i]] = i;
}
}
大多数情况下使用朴素倍增法或者二分 + 哈希足以应对大多数题目,由于基数排序不常用,倍增法的优化可以当作板子记下来。
Height 数组
Height 数组不能说是 SA 的灵魂,只能说没有 Height 数组的后缀数组啥都不是。
定义
Height 数组定义如下:
- \(height_i = \text{lcp}(sa_i, sa_{i - 1})\)
求法
首先需要一个引理,感性理解一下:
- \(height_{rk_i} \ge height_{rk_{i - 1}} - 1\)
然后我们来看如何 \(O(n)\) 求 \(height\)(不难发现可以二分 + 哈希求这个东西,但是这里不写),假设我们有一个串 nanana,把它的所有后缀排序之后的结果是:
[sa[1], n]: a
[sa[2], n]: ana
[sa[3], n]: anana
[sa[4], n]: na
[sa[5], n]: nana
[sa[6], n]: nanana
首先我们暴力求出最长的串的 \(height\),在上述中就是 nanana,不难看出是 \(4\),然后我们把它和它对应的串拎出来:
[sa[5], n]: nana
[sa[6], n]: nanana
然后我们把他们的第一个字符都抹掉,就变成了:
[sa[2], n]: ana
[sa[3], n]: anana
又因为上面那个定理,所以这两个串的 LCP 至少就是 \(4 - 1 = 3\)(因为前面抹掉了一位),我们可以再暴力往后找,再抹掉,重复这个过程,然后我们发现我们锁定串的长度是不断减一的,也就是起始位置是逐位增加的,所以我们可以用循环来不断重复这个过程。
然后为什么是 \(O(n)\) 的呢?考虑我们最开始的 LCP 长度设为 \(pos\)(相当于上述中的 \(4\)),那么由于有 \(n\) 个串,至多抹掉 \(n\) 位,所以 \(pos\) 至多减 \(n\) 次,又因为每次往后暴力找,总共只会找 \(n\) 次,所以一共 \(pos\) 只可能被修改 \(2n\) 次,总时间复杂度位 \(O(n)\)。
主要代码:
int pos = 0;
for ( int i = 1; i <= n; i ++ ) {
if ( pos ) pos --;
while ( s[i + pos] == s[sa[rk[i] - 1] + pos] ) pos ++;
h[rk[i]] = pos;
}
Height 数组 / 后缀数组的应用
掌握一个算法最重要的就是能够熟练应用。这里只选取了最主要的应用。
树上后缀排序
树上与序列上唯一的不同是在第 \(i\) 轮一个串切成两半后的后一半是一个节点的第 \(2^{i - 1}\) 级祖先,倍增的话还是一样的。
唯一要注意的就是相同串的处理!如果用我的板子最后的结果只可能是 1 2 2 4 不可能是 1 2 2 3,所以稍微每一层都注意以下就好了。
主要代码:
void help ( int x ) { // 处理第 x 层,g[x] 是第 x 层的所有点,p 是 tuple < int, int, int > 类型的 vector(按照优先级排序)
p.clear ();
int idx = 0;
for ( int i = 0; i < g[x].size (); i ++ ) {
p.emplace_back ( rk[g[x][i]], rk[f[g[x][i]][0]], g[x][i] );
}
sort ( p.begin (), p.end () );
for ( int i = 0; i < p.size (); i ++ ) {
if ( i == 0 || get < 0 >( p[i] ) != get < 0 >( p[i - 1] ) ) {
idx = get < 0 >( p[i] );
}
rk[get < 2 >( p[i] )] = idx ++;
}
}

浙公网安备 33010602011771号