[20210405]最小表示
零、前言 ¶
考场写的,可能有点冲......
壹、题目描述 ¶
有一个长度为 \(n\) 的仅含前 \(m\) 个小写字母的串 \(S\),你希望知道这个串本质不同的子串有多少个。
你觉得太简单,所以定义两个串本质不同当且仅当它们的最小表示不同。
最小表示指将该串靠前出现的字符都替换为字符集的第一个字符,次靠前出现的字符都替换为字符集的第二个字符,以此类推。例如,字符集是小写字母时,\(\tt reverse\) 的 小表示是 \(\tt abcbadb\),\(\tt test\) 和 \(\tt calc\) 本质相同。
贰、一些思考 ¶
最小表示不同的子串个数。
不同子串个数,还是考虑相同的方案,但是 \(\tt SAM\) 肯定用不了了,考虑能否改进一下 \(\tt SA\),如果得到 \(\tt SA\),那么我们同样可以计算答案。
首先考虑如何得到 \(\tt LCP\),考虑使用 \(\tt hash\) 加上二分,但是这个 \(\tt hash\) 怎么做?分析一下这个 \(\tt hash\) 需要做到什么:
- 不冲突;
- 能涵盖最小表示;
第一条就是个*,可以不用管它,重点在第二条。
考虑直接进行 \(\tt hash\),比如 \(\tt a\) 就当成 \(\tt a\),字符保持不变,这可以做出来那出题人他*就上天了。
考虑两个串的最小表示相同,有几个条件:
- 相同字符出现位置相同;
- 不同字符第一次出现位置顺序相同;
满足这两个条件即满足最小表示不同。
那么,我们的 \(\tt hash\) 需要包含两个要素:子串中字符出现位置,子串中这个字符第一次出现的位置。
对于子串字符出现位置,可以很经典地记录一条链,但是如果记录位置,取子串的时候会出问题,不过我们可以它和它前一次出现位置相距多远,特别地,字符第一次出现的位置前驱是零。
嗯?这个前驱距离好像可以同时记录第二个条件,取子串的之后,只需要将这个字符在子串中第一次出现的位置的前驱记录为 \(0\) 就可以了。
这样,如果两个串的 \(\tt hash\) 相同,我们即可认为他们的最小表示相同。
但是对于 \(s[l:r]\),如何快速找到每个字符第一次出现的位置?发现字符集很小,似乎可以直接暴力预处理。
同时,快速得到 \(s[l:r]\) 的 \(\tt hash\) 值,可以选择使用前缀作差的方法。
如何统计不同子串个数?应该能用类似 \(\tt SA\) 的方法,将后缀排序之后,用总子串数量减去相邻后缀的 \(\tt LCP\) 即可。
如何对于后缀进行排序?还记得之前有一种比较方式,是找到 \(\tt LCP\),然后比较 \(\tt LCP\) 那个位置的字符,反正我们都要用 \(\tt LCP\),但是我们还要得到它的最小表示啊,这个好办,先得到它的出现位置,然后看有多少其他的字符出现在它前面。
复杂度,唔,瓶颈应该在后缀排序那里,大概 \(\mathcal O(nm\log^2 n)\).
叁、参考代码 ¶
#include<cstdio>
#include<algorithm>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
template<class T>inline T readin(T x){
x=0; int f=0; char c;
while((c=getchar())<'0' || '9'<c) if(c=='-') f=1;
for(x=(c^48); '0'<=(c=getchar()) && c<='9'; x=(x<<1)+(x<<3)+(c^48));
return f? -x: x;
}
const int maxn=5e4;
const int maxm=10;
const ull sigma=10007;
const int inf=0x3f3f3f3f;
char s[maxn+5];
int n, m;
int nxt[maxn+5][maxm+5];
inline void input(){
n=readin(1), m=readin(1);
scanf("%s", s+1);
}
int las[maxm+5];
inline void getnxt(){
for(int i=0; i<m; ++i) las[i]=inf;
for(int i=n; i>=1; --i){
int c=s[i]-'a';
las[c]=i;
for(int j=0; j<m; ++j)
nxt[i][j]=las[j];
}
}
int pre[maxn+5];
inline void getpre(){
for(int i=0; i<m; ++i) las[i]=0;
for(int i=1; i<=n; ++i){
int c=s[i]-'a';
if(!las[c]) pre[i]=0;
else pre[i]=i-las[c];
las[c]=i;
}
}
ull has[maxn+5], pows[maxn+5];
inline void gethash(){
pows[0]=1;
for(int i=1; i<=n; ++i) pows[i]=pows[i-1]*sigma;
for(int i=1; i<=n; ++i){
has[i]=has[i-1]*sigma+pre[i];
// printf("has[%d] == %llu\n", i, has[i]);
}
}
// get the hash of a particular interval
inline ull query(int l, int r){
return has[r]-has[l-1]*pows[r-l+1];
}
// get a hash value of substring s[l:r]
inline ull solve(int l, int r){
ull ret=query(l, r);
// enumerate each character
for(int i=0; i<m; ++i) if(nxt[l][i]<=r)
ret-=pre[nxt[l][i]]*pows[r-nxt[l][i]];
return ret;
}
inline int getlcp(int a, int b){
int l=0, r=n-max(a, b)+1, mid, ret;
while(l<=r){
mid=(l+r)>>1;
if(solve(a, a+mid-1)==solve(b, b+mid-1))
ret=mid, l=mid+1;
else r=mid-1;
}
return ret;
}
inline int cmp(int a, int b){
int lcp=getlcp(a, b);
// printf("lcp (%d, %d) == %d\n", a, b, lcp);
int leva=0, levb=0;
if(a+lcp<=n){
int c=s[a+lcp]-'a';
// the first position where the char appears
int pos=nxt[a][c];
for(int i=0; i<m; ++i) if(nxt[a][i]<=pos)
++leva;
}
if(b+lcp<=n){
int c=s[b+lcp]-'a';
int pos=nxt[b][c];
for(int i=0; i<m; ++i) if(nxt[b][i]<=pos)
++levb;
}
// printf("leva == %d, levb == %d\n", leva, levb);
return leva<levb;
}
int sa[maxn+5];
inline void getsa(){
for(int i=1; i<=n; ++i) sa[i]=i;
sort(sa+1, sa+n+1, cmp);
}
inline void getans(){
ll ans=1ll*n*(n+1)>>1;
// for(int i=1; i<=n; ++i) printf("sa[%d] == %d\n", i, sa[i]);
for(int i=1; i<n; ++i)
ans=ans-getlcp(sa[i], sa[i+1]);
printf("%lld\n", ans);
}
signed main(){
freopen("string.in", "r", stdin);
freopen("string.out", "w", stdout);
input();
getnxt();
getpre();
gethash();
getsa();
getans();
return 0;
}
肆、用到 の \(\tt trick\) ¶
使用 \(\tt hash\) 的时候,关注我们需要保存的是什么,将特征放入 \(\tt hash\) 值中,并且要想办法让这个 \(\tt hash\) 值没有全局的牵连,而是可以单独拿出来用。不要认为 \(\tt hash\) 可以乱塞东西进去。
另外,没有思路的时候可以先想想如何处理部分条件,对于剩下的能否在处理部分条件的基础上额外增加一些方法进行维护。如此处先记录“出现位置”,然后发现可以同时保存“第一次出现位置”。