后缀数组
用一个题目来讲解后缀数组
使用字符串 \(s\) 的子串来构造一个前缀码集合(Codebook),且集合中的任何字符串都不能包含子串 \(p\)。在所有合法子串构成的 Trie(字典树)中,构造“前缀码”实际上就等价于在这棵字典树上寻找非空反链(即选出的节点集合中,没有任何一个节点是另一个节点的祖先)。
思路
很容易想到构建一颗字典树,然后在这课树上进行dp操作来组合计数。
首先对于s的每一个起始位置i,不包含p的子串最长有多长,所以我们先跑一个kmp,这样我们就可以找到他的最大合法长度。
然后如果我们现在要去构建一颗字典树,我们对于每一个合法子串都得加入字典树,合法子串的数量会是一个\(O\)(\(n^2\))级别的,所以我们现在得引入后缀数组来压缩一下这个字典树。
后缀数组
后缀数组有两个核心数组,sa,rk。
假设我们有一个字符串 \(s = \text{"abaa"}\)。首先,写出它所有的后缀,并给它们编上起始位置的序号(下标从 0 开始):
0: abaa 1: baa 2: aa 3: a
所谓后缀数组,顾名思义,就是把这些后缀按“字典序”从小到大排个序。
sa数组
代表排名为i的是下标为i开始的后缀
rk数组
下标为i的后缀排名第几名
求法
接下来我们怎么求排好序的后缀呢
\(O(n^2logn)\)的复杂的暴力很好想
我们可以采用一个倍增法来对这个进行一个优化
按照长度1,2,4,8,进行排序.
模板代码
#include <bits/stdc++.h>
using namespace std;
using i64 = long long;
using f64 = double;
i64 n, m, k;
f64 p;
string s;
i64 sa[300005],rk[300005],oldrk[300005];
i64 w=0;
void solve() {
cin>>s;
n=s.length();
s=" "+s;
for(int i=1;i<=n;i++){
sa[i]=i;
rk[i]=s[i];
}
for(int k=1;k<=n;k=k*2){
w=k;
sort(sa + 1, sa + n + 1, [](int x, int y) {
return rk[x] == rk[y] ? rk[x + w] < rk[y + w] : rk[x] < rk[y];//根据倍增偏移量去比较
});
memcpy(oldrk, rk, sizeof(rk));
for (int p = 0,i = 1; i <= n; ++i) {//去重
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
solve();
return 0;
}
求解height数组
height[i] 的定义是:排名第 \(i\) 的后缀与排名第 \(i-1\) 的后缀的最长公共前缀 (LCP) 的长度。
暴力的话最坏情况复杂度为\(O(n^2)\)
\(O(N)\) 求解
定义 \(h[i]\) 表示:从原串第 \(i\) 个字符开头的后缀,与它在名次表里的前一名后缀的 LCP 长度。
(注意:height 是按排名的顺序,而 \(h\) 是按原串下标的顺序。数学上即 \(h[i] = height[rk[i]]\))。
,我们在求 \(h[i]\) 时,不需要从头开始匹配,而是直接从 \(h[i-1] - 1\) 的长度开始往后继续匹配即可!
for (i = 1, k = 0; i <= n; ++i) {
if (rk[i] == 0) continue;
if (k) --k;
while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
height[rk[i]] = k;
}
建树
接下来我们来思考一下怎么利用之前得到的数组建一颗压缩完后的字典树。
后缀数组其实已经找到了每一个节点间的分叉点,然后我们在这一个分叉点连接一条边,这条边的长度就是节点的数量,于是我们就可以按后缀数组顺序依次加入后缀,利用相邻后缀的 LCP,维护一棵压缩后缀 Trie。
变量
len[u]:结点 u 代表字符串的长度,也叫字符串深度。
fa[u]:结点 u 的父亲。
last:上一个加入的后缀对应的叶子。
height[i] = LCP(sa[i-1], sa[i])。
pos[i]:从位置 i 开始的后缀对应哪个叶子。
从last往上找分界点
后缀数组中,相邻后缀字典序最接近。
加入当前后缀 s[sa[i]..n] 时,它和已经加入的后缀共享的最深前缀,一定由:
height[i] = LCP(sa[i-1], sa[i])
确定。
而 sa[i-1] 对应的正是 last,所以只需从 last 往父亲方向走,找到深度为 height[i] 的位置,然后挂上新叶子。
建树循环
向上寻找 LCP 所在位置
while (cur > 1 && len[cur] > lcp) {
如果 cur 的深度比 lcp 大,说明当前公共前缀位置位于 cur 的上方。
接下来有三种情况。
情况一:LCP 位于一条压缩边内部
那就要在这个压缩边上加一个深度为lcp的节点,然后把新后缀挂在下面
if(len[fa[cur]]<lcp){
i64 old=cur;
int w=++tot;
len[w]=lcp;
fa[w]=fa[cur];
fa[cur]=w;
cur=w;
break;
}
情况二:父亲的深度正好等于 LCP
说明 LCP 对应的结点已经存在,直接让 cur 指向它。
情况三:LCP 还在更高的位置
cur = fa[cur];
创建当前后缀结点
找到深度为 lcp 的位置后,创建当前后缀对应的结点:
int leaf=++tot;
len[leaf]=n-sa[i]+1;
fa[leaf]=cur;
last=leaf;
pos[sa[i]]=leaf;
存储起来
for(int i=2;i<=tot;i++){
t[fa[i]].push_back(i);
}
于是我们的压缩的字典树就建立出来了,但是题目要求我们不能包含子串p,所以我们得用kmp把包含p的处理出来,然后树上dp的时候就传递这个信息
KMP
pi[i]为i的失配指针
occ[i]为i是否为p的结尾
next_occ[i]为i往后找到的第一个p的结尾的位置
for(int i=2,j=0;i<=len1;i++){
while(j>0&&s1[i]!=s1[j+1])j=pi[j];
if(s1[i]==s1[j+1])j++;
pi[i]=j;
}
vector<int> occ(n+1,0);
for(int i=1,j=0;i<=n;i++)
{
while(j>0&&s[i]!=s1[j+1])j=pi[j];
if(s[i]==s1[j+1])j++;
if(j==len1){
occ[i]=1;
j=pi[j];
}
}
// 初始化为一个越界的大数(n+1)
int last_o = n + 1; // 记录最右边看到的 p 的结尾位置
for(int i = n; i >= 1; i--) {
if(occ[i]) last_o = i; // 如果刚好在 i 位置有一个 p 结尾,更新死期
next_occ[i] = last_o; // 把这个死期记下来
}
for(int i=1;i<=n;i++){
int target=i+len1-1;
if(target>n) m[i]=n-i+1;
else{
if(next_occ[target]<=n)m[i]=next_occ[target]-i;
else{
m[i]=n-i+1;
}
}
}
处理每一个节点
for(int i=1;i<=n;i++){
int target=i+len1-1;
if(target>n) m[i]=n-i+1;
else{
if(next_occ[target]<=n)m[i]=next_occ[target]-i;
else{
m[i]=n-i+1;
}
}
}
m[i]代表了从节点i往后还能走多少个节点
for(int i = 1; i <= n; i++) {
MaxLen[pos[i]] = m[i]; // 将限制条件挂载到对应的叶子节点上
}
当我们把这个限制条件传给叶子节点,那么我们就可以来计算答案了
dfs
void dfs(i64 x){
i64 prod=1;
for(auto it:t[x]){
dfs(it);
MaxLen[x]=min(MaxLen[x],MaxLen[it]);
prod=(prod*dp[it])%mod;
}
if(x==1){dp[x]=prod;return;}
i64 V_u = min((i64)len[x], (i64)MaxLen[x]);
i64 C_u = max(0LL, V_u - len[fa[x]]);
i64 f_u = 1;
if(C_u > 0) {
if(V_u < len[x]) {
f_u = (C_u + 1) % mod;
} else {
f_u = (C_u + prod) % mod;
}
}
dp[x] = f_u;
}
MaxLen[x] = x 子树所有后缀的 m[i] 最小值,它表示从根走向 x 的这条字符串路径中,最长合法长度。
如果不选择 x 所在压缩边上的字符串,不同儿子子树之间可以独立选择。prod *= dp[it];
但是当前压缩边上还有合法距离,我们得计算一下这个压缩边的合法距离
i64 V_u = min((i64)len[x], (i64)MaxLen[x]);
len[x] 是当前压缩边的终点深度,而 MaxLen[x] 是合法长度上限。
V_u表示当前分支中实际可以合法到达的最大深度。
所以合法字符串数量就是:
i64 C_u = max(0LL, V_u - len[fa[x]]);
如果V-u大于这个压缩边的长度,儿子就都可以带上,所以 f_u = (C_u + prod) % mod;
否则只能算上C_u+1;
然后就把答案算出来了
void dfs(i64 x){
i64 prod=1;
for(auto it:t[x]){
dfs(it);
MaxLen[x]=min(MaxLen[x],MaxLen[it]);
prod=(prod*dp[it])%mod;
}
if(x==1){dp[x]=prod;return;}
i64 V_u = min((i64)len[x], (i64)MaxLen[x]);
i64 C_u = max(0LL, V_u - len[fa[x]]);
i64 f_u = 1;
if(C_u > 0) {
if(V_u < len[x]) {
f_u = (C_u + 1) % mod;
} else {
f_u = (C_u + prod) % mod;
}
}
dp[x] = f_u;
}
最后输出dp[1]-1即可。
通过代码
#include <bits/stdc++.h>
using namespace std;
using i64 = long long;
using f64 = double;
i64 n, k;
f64 p;
string s,s1;
i64 sa[2000005],rk[2000005],oldrk[2000005],dp[2000005];
i64 w=0;
const i64 mod=998244353;
vector<int> pi(2e6+1,0);
vector<int> m(2e6+1);
vector<int> MaxLen(2e6 + 100, 1e9);
vector<int> next_occ(2e6 + 100, 1e9);
vector<vector<int>> t(2e6);
vector<i64> fa(2e6+2,0),len(2e6+2,0),pos(2e6,0);
void dfs(i64 x){
i64 prod=1;
for(auto it:t[x]){
dfs(it);
MaxLen[x]=min(MaxLen[x],MaxLen[it]);
prod=(prod*dp[it])%mod;
}
if(x==1){dp[x]=prod;return;}
i64 V_u = min((i64)len[x], (i64)MaxLen[x]);
i64 C_u = max(0LL, V_u - len[fa[x]]);
i64 f_u = 1;
if(C_u > 0) {
if(V_u < len[x]) {
f_u = (C_u + 1) % mod;
} else {
f_u = (C_u + prod) % mod;
}
}
dp[x] = f_u;
}
void solve() {
cin>>s>>s1;
n=s.length();
s=" "+s;
i64 len1=s1.length();
s1=" "+s1;
for(int i=1;i<=n;i++){
sa[i]=i;
rk[i]=s[i];
}
for(int k=1;k<=n;k=k*2){
w=k;
sort(sa + 1, sa + n + 1, [](int x, int y) {
return rk[x] == rk[y] ? rk[x + w] < rk[y + w] : rk[x] < rk[y];
});
memcpy(oldrk, rk, sizeof(rk));
for (int p = 0,i = 1; i <= n; ++i) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
}
vector<i64> height(n+1,0);
for (int i = 1, k = 0; i <= n; ++i) {
if (rk[i] == 1) continue;
if (k) --k;
while (i+k<=n&&sa[rk[i] - 1] + k<=n&&s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
height[rk[i]] = k;
}
int tot =1,last=1;
for(int i=1;i<=n;i++){
int lcp=height[i];
i64 cur=last;
while(cur>1&&len[cur]>lcp){
if(len[fa[cur]]<lcp){
i64 old=cur;
int w=++tot;
len[w]=lcp;
fa[w]=fa[cur];
fa[cur]=w;
cur=w;
break;
}
else if(len[fa[cur]]==lcp){
cur=fa[cur];
break;
}
else{
cur=fa[cur];
}
}
int leaf=++tot;
len[leaf]=n-sa[i]+1;
fa[leaf]=cur;
last=leaf;
pos[sa[i]]=leaf;
}
for(int i=2,j=0;i<=len1;i++){
while(j>0&&s1[i]!=s1[j+1])j=pi[j];
if(s1[i]==s1[j+1])j++;
pi[i]=j;
}
vector<int> occ(n+1,0);
for(int i=1,j=0;i<=n;i++)
{
while(j>0&&s[i]!=s1[j+1])j=pi[j];
if(s[i]==s1[j+1])j++;
if(j==len1){
occ[i]=1;
j=pi[j];
}
}
// 初始化为一个越界的大数(n+1)
int last_o = n + 1; // 记录最右边看到的 p 的结尾位置
for(int i = n; i >= 1; i--) {
if(occ[i]) last_o = i; // 如果刚好在 i 位置有一个 p 结尾,更新死期
next_occ[i] = last_o; // 把这个死期记下来
}
for(int i=1;i<=n;i++){
int target=i+len1-1;
if(target>n) m[i]=n-i+1;
else{
if(next_occ[target]<=n)m[i]=next_occ[target]-i;
else{
m[i]=n-i+1;
}
}
}
for(int i=2;i<=tot;i++){
t[fa[i]].push_back(i);
}
for(int i = 1; i <= n; i++) {
MaxLen[pos[i]] = m[i]; // 将限制条件挂载到对应的叶子节点上
}
dfs(1);
cout<<(dp[1]-1+mod)%mod<<"\n";
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
solve();
return 0;
}

浙公网安备 33010602011771号