第一次在正式比赛过了字符串的高阶算法题,感动 ̄▽ ̄,写个题解记录一下
题目链接:题目链接
首先看到本质不同的子串就想到了后缀自动机,因为后缀自动机的节点压缩的存储的一个字符串的所有子串。后缀自动机的一个模板题也就是求本质不同的子串数量。
如果这题的询问只限制结尾的地方 R R R那就很简单,离线询问,遍历字符串,边扩展自动机边处理询问即可。
暂时先抛开起始位置
L
L
L的限制,“第一次出现"怎么处理?
回想一下传统"求本质不同的子串数量"的做法是求
∑
(
l
e
n
[
i
]
−
l
e
n
[
p
a
r
[
i
]
]
)
∑(len[i]-len[par[i]])
∑(len[i]−len[par[i]]),
l
e
n
[
i
]
len[i]
len[i]是当前节点代表的所有字符串的最大长度。后缀自动机的每个节点
i
i
i表示结束位置在
e
n
d
p
o
s
[
i
]
endpos[i]
endpos[i]中的所有子串,换句话说,一个子串可能出现在多个位置,但是我只记录一个,两个不同的子串可能出现的位置的右端点相同(就是endpos相同),我也把它们记录在一个状态(节点)里
比如说:abcdbcdcd
对于子串
d
d
d,
c
d
cd
cd,它们的endpos相同,都是{ 4, 7, 9 },但是向左边加个b,那原来出现
c
d
cd
cd 的位置只有两个左边有
b
b
b, 子串
b
c
d
bcd
bcd的endpos就为{ 4, 7 }。通俗的说,越长的子串,在原串中出现的次数相对更少,越短的字串,在原串的出现位置相对更多。像什么 “parent tree越"下面"的节点
l
e
n
[
i
]
len[i]
len[i]越大”,节点(状态)
i
i
i 中所有字符串的长度恰好覆盖区间
[
l
e
n
[
p
a
r
[
i
]
]
+
1
,
l
e
n
[
i
]
]
[ \ len[par[i]]+1,\ len[i]\ ]
[ len[par[i]]+1, len[i] ] 中的每一个整数”,这些涉及到
l
e
n
[
i
]
len[i]
len[i] 的性质我当初都是这么理解的。
再来看一下每次扩展SAM时涉及到
l
e
n
[
i
]
len[i]
len[i]的部分:
先贴下自己的SAM板子
void Add(int ch,int pos)
{
int p = last;
tot++;
int np = last = tot;
len[np] = len[p] + 1;
while( p>0 && sam[p][ch]==0 ){
sam[p][ch] = np;
p = par[p];
}
if( p==0 ){
par[np] = 1;
}
else{
int q = sam[p][ch];
if( len[q] == len[p]+1 )par[np] = q;
else{
tot++;
int nq = tot;
len[nq] = len[p]+1;
par[nq] = par[q];
for(int i=0;i<26;i++)sam[nq][i] = sam[q][i];
par[np] = par[q] = nq;
while( p>0 && sam[p][ch]==q ){
sam[p][ch] = nq;
p = par[p];
}
}
}
}
- 开始创建一个新节点(状态),设置其(最长的字符串)长度为上一个前缀所在的节点的len+1,因为新增的这个节点也是表示前缀,以某个位置结尾的所有子串中肯定是前缀的长度最长嘛。
int p = last;
tot++;
int np = last = tot;
len[np] = len[p] + 1;
2.从前一个前缀所在的节点(状态)沿着parent tree往上找,没有找到一个有出边为 c h ch ch的节点(状态),(虽然这个不涉及到 l e n len len哈)。这一步发生在当前扩展的字符 c h ch ch是该字符在字符串中第一次出现,前面都没有出现过
if( p==0 ){
par[np] = 1;
}
3.找到了,判断是否满足 l e n [ q ] = = l e n [ p ] + 1 len[q] == len[p]+1 len[q]==len[p]+1
else{
int q = sam[p][ch];
if( len[q] == len[p]+1 )par[np] = q;
else{
tot++;
int nq = tot;
len[nq] = len[p]+1;
par[nq] = par[q];
for(int i=0;i<26;i++)sam[nq][i] = sam[q][i];
par[np] = par[q] = nq;
while( p>0 && sam[p][ch]==q ){
sam[p][ch] = nq;
p = par[p];
}
}
}
首先这个 “从前一个前缀所在的节点(状态)沿着parent tree往上找” 大概是这样的 :假设当前字符串:cde cd bcd abcd(空格仅为了隔开),接下来的字符是 ‘e’ 。现在我要扩展 ‘e’,那么首先cde cd bcd abcd所在的节点(状态)肯定没有 ‘e’ 的出边,沿着parent tree往上会遇到 abcd,显然没有;bcd,还是没有; cd,诶,状态节点{ 2,5,8,12 }(endpos集) 沿着 该节点sam上的 ‘e’ 边可以到达{ 3 }。这一步其实就是不断把左边的字符去掉,越短的串出现的位置更多,更有可能碰到一个后边跟着 ‘e’ 的位置,如果全都去掉了,那就是空串(跑到根节点1号)如果1号节点都没有 ‘e’ 的出边的话,就说明当前我插入的 ‘e’ 是第一次出现。
(接下来我用字符串表示那个子串所在的节点)好的接下来由于
l
e
n
[
′
c
d
e
′
]
=
=
l
e
n
[
′
c
d
′
]
+
1
len['cde'] == len['cd']+1
len[′cde′]==len[′cd′]+1,走上面那个if,那么现在SAM的parent tree中有一部分子树大概长这样:
好的,现在观察一下扩展了 **‘e’**之后出现了多少个新的子串?
——10个,也就是
l
e
n
[
3
]
−
l
e
n
[
2
]
=
13
−
3
=
10
len[3] - len[2] = 13 - 3 = 10
len[3]−len[2]=13−3=10 。
好的相信看到这里的你已经知道怎么做了,本篇文章到此结束。
写着写着发现了更多的对后缀自动机不了解的地方了╭(╯^╰)╮,想了半天
l
e
n
[
q
]
=
=
l
e
n
[
p
]
+
1
len[q] == len[p]+1
len[q]==len[p]+1不满足的情况是怎样的想着有点头痛。(感觉是不是可能后面遇到 …bcde,往上找,最后
p
=
2
,
q
=
3
p=2,q=3
p=2,q=3,然后
l
e
n
[
3
]
!
=
l
e
n
[
2
]
+
1
len[3]!=len[2]+1
len[3]!=len[2]+1,于是就新建一个节点插到中间??额不对
p
、
q
p、q
p、q是sam边上的关系不是 parent tree 的父子)
回到这个题上来,可以发现每扩展一个字符,新增的、也就是前面从未出现过的的子串有
l
e
n
[
i
]
−
l
e
n
[
p
a
r
[
i
]
]
len[i]-len[par[i]]
len[i]−len[par[i]] 个。关于这个性质可以感性的认知一下:扩展第
i
i
i 个字符,首先至少会出现一个新的子串——那就是长度为
i
i
i 的前缀,前面我说了“越长的子串出现的次数相对更少,越短的出现次数相对更对多”,那么前缀
i
i
i 的所有后缀中,随着长度减小,它在之前已经出现过的概率越大。比如:abcdecde,刚刚扩展了 **‘e’**之后,abcdecde肯定是新增的,短一个的 bcdecde也是,再短一个的 cdecde还是,直到 cde 就不是了。
所以扩展第
i
i
i 个字符时,创建一个新节点,可以理解为是因为产生了新的、前面没有出现过的子串,这些子串肯定都是前缀
i
i
i 的某个后缀,其中最长的肯定就是前缀
i
i
i 本身,而最短的那个后缀长度,就是 前面已经出现过的后缀 中,最长的那一个的长度+1,而前面已经出现过的后缀中最长的那个在哪个节点?其实SAM每次扩展就是找这么一个节点,然后把新节点作为那个节点的儿子。
通过这点,用第
i
i
i 个字符扩展SAM我们可以知道一些区间 [ x, i ],其中
x
∈
[
i
−
l
e
n
[
c
u
r
]
,
i
−
(
l
e
n
[
p
a
r
[
c
u
r
]
+
1
)
]
x∈[\ i-len[cur],\ i-(len[par[cur]+1) \ ]
x∈[ i−len[cur], i−(len[par[cur]+1) ],这些区间位置指示的是字符串中第一次出现的子串的位置。
那么就先对所有查询按右端点排序,然后逐步扩展SAM,也就是处理查询 [L, R]时,当前SAM中的所有子串(的结束位置)都是在 R 左边的(包括R)。那我现在就只需要考虑有多少 “第一次出现的子串”的起始位置 在 L 右边(包括L)。每次扩展后,用线段树对起始位置的区间 [ i − l e n [ c u r ] , i − ( l e n [ p a r [ c u r ] + 1 ) ] [\ i-len[cur],\ i-(len[par[cur]+1) \ ] [ i−len[cur], i−(len[par[cur]+1) ] 加1,然后查询 [ L, R ]之间的和即可。
个人代码:
#include<iostream>
#include<cstring>
#include<cstdio>
#include<vector>
#include<algorithm>
using namespace std;
const int MAX_N = 100005;
struct Query{
int x,y,no;
Query(int a=0,int b=0,int c=0){ x=a; y=b; no=c; }
};
bool comp(const Query& a, const Query& b){
return a.y < b.y;
}
// 这个是自己开始验证思路用的qwq
struct FakeSegmentTree{
int s[200010];
void modify(int x,int y,int d){
for(int i=x;i<=y;i++)s[i] += d;
}
int query(int x,int y){
int res = 0;
for(int i=x;i<=y;i++)res += s[i];
return res;
}
};
struct SegmentTree {
struct node {
long long sum;
int len, tag;
} st[MAX_N << 2];
void push_up(int x) {
st[x].sum = st[x << 1].sum + st[x << 1 | 1].sum;
st[x].len = st[x << 1].len + st[x << 1 | 1].len;
}
void push_down(int x) {
if (!st[x].tag) return;
st[x << 1].tag += st[x].tag;
st[x << 1].sum += st[x << 1].len * st[x].tag;
st[x << 1 | 1].tag += st[x].tag;
st[x << 1 | 1].sum += st[x << 1 | 1].len * st[x].tag;
st[x].tag = 0;
}
void build(int x, int l ,int r) {
if (l == r) {
st[x] = {0, 1, 0};
return;
}
int mid = (l + r) >> 1;
build(x << 1, l, mid);
build(x << 1 | 1, mid + 1, r);
push_up(x);
}
void modify(int x, int l, int r, int ql, int qr) {
if (ql > qr || ql > r || qr < l) return;
if (ql <= l && r <= qr) {
st[x].tag += 1;
st[x].sum += st[x].len;
return;
}
push_down(x);
int mid = (l + r) >> 1;
modify(x << 1, l, mid, ql, qr);
modify(x << 1 | 1, mid + 1, r, ql, qr);
push_up(x);
}
long long query(int x, int l, int r, int ql, int qr) {
if (ql > qr || ql > r || qr < l) return 0;
if (ql <= l && r <= qr) return st[x].sum;
push_down(x);
int mid = (l + r) >> 1;
return query(x << 1, l, mid, ql, qr) + query(x << 1 | 1, mid + 1, r, ql, qr);
}
};
SegmentTree startpos;
vector<Query>query;
char s[MAX_N];
int par[MAX_N*2], sam[MAX_N*2][26], len[MAX_N*2];
int last,tot;
long long ans[100010];
int n;
void Add(int ch,int pos)
{
int p = last;
tot++;
int np = last = tot;
len[np] = len[p] + 1;
// Size[np] = 1;
while( p>0 && sam[p][ch]==0 ){
sam[p][ch] = np;
p = par[p];
}
if( p==0 ){
par[np] = 1;
}
else{
int q = sam[p][ch];
if( len[q] == len[p]+1 )par[np] = q;
else{
tot++;
int nq = tot;
len[nq] = len[p]+1;
par[nq] = par[q];
for(int i=0;i<26;i++)sam[nq][i] = sam[q][i];
par[np] = par[q] = nq;
while( p>0 && sam[p][ch]==q ){
sam[p][ch] = nq;
p = par[p];
}
}
}
startpos.modify( 1,1,n, pos-len[np]+1, pos-len[par[np]] );
}
int main()
{
// int n;
int Q,x,y;
scanf("%d",&n);
scanf("%s",s+1);
scanf("%d",&Q);
for(int i=1;i<=Q;i++){
scanf("%d %d",&x,&y);
query.push_back( Query(x,y,i) );
}
sort( query.begin(), query.end(), comp );
int pos = 0;
startpos.build(1,1,n);
last = 1; tot = 1;
for(auto& [x,y,no] : query){
while( pos < y ){
pos++;
Add(s[pos] - 'a',pos);
}
ans[no] = startpos.query( 1,1,n, x,pos );
}
for(int i=1;i<=Q;i++)printf("%lld\n",ans[i]);
}
本来是心血来潮想写下题解加深一下对SAM的理解的结果越写越乱,磨磨蹭蹭写了一个半小时,还是太菜了555。
不过这题能"一次过"还是多亏了数据结构队友能手写各种"100%正确"的线段树,要我写还真写不来
不过写这题的时候中途看了眼提交看下队友有没有开别的题,然后发现队友K题交了6发,我一看K是哪题啊,结果就是我写的这道题!你们又不懂SAM交个毛线啊,演我吧。