字串家族 學習筆記
本來想著一天速通字串,看來我還是想多了。
可能需要的前置
-
字串雜湊
-
KMP
-
trie
樹 -
manacher
演算法
可能涵蓋的內容
目前已有的:
-
字尾陣列
SA
-
AC
自動機
未來可能會有的:
-
擴充套件
KMP
-
字尾自動機
-
迴文自動機
-
子序列自動機
本文可能會有很多錯誤,還請發現的大佬們指出,本蒟蒻感到非常榮幸。
參考資料
- 字尾陣列
-
AC
自動機
對此,本蒟蒻不勝感激
如有侵權問題,請聯絡我,我會馬上標明出處或修改。
字尾陣列
字尾排序
模板題:P3809 【模板】字尾排序
字尾陣列可以用來實現一個字串的每個字尾按照字典序排序的操作,根據這個操作,可以引申出很多用法。
字尾陣列 SA
的實現是基於基數排序的思想,在普通基數排序的基礎上加了倍增。
演算法流程大致如下:
這裡假設待排序字串是 abacabc
。
-
首先用一個字母進行排序,結果更新到一個
rk
陣列(表示該字尾排名),上述字串應為1 2 1 3 1 2 3
。 -
然後相鄰兩個字串拼接起來,對於每個字尾,得到它長度為 \(2\) 的字首的兩位標號。對於最後一個長度為 \(1\) 的字尾,因為沒有第二位字串,所以它第二位字典序最小,通過補零解決。此時上述字串的標號為
12 23 13 31 12 23 30
-
然後對這些原來相同的字尾們重新排序,標號變成
1 3 2 5 1 3 4
。 -
然後我們重複第二步過程,讓每個字尾和它隔一個的那個字尾拼接起來,得到它長度為 \(4\) 的字首的兩位標號。同理,不夠的補零。此時上述字串標號為
12 35 21 53 14 30 40
,注意要隔一個,因為現在每一位代表的是兩個字元的字串的排序。 -
然後重新排序,得到
1 5 3 7 2 4 6
。 -
我們發現現在標號已經沒有重複了的,得到的數字即是對應字尾在所有後綴中的排名。
我們發現這樣子每次每個字尾的長度會 \(\times2\),所以最多隻會進行 \(O(\log n)\) 次拼接-標號過程,每次都是 \(O(n)\)
其實字尾陣列還有另外一個演算法 DC3
,能做到時間複雜度 \(O(n)\),可是由於本蒟蒻不會 程式碼複雜度過高,而且空間複雜度不優,我們還是常用 SA
。
為了方便後面的使用,這裡封裝成了結構體。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n;
char s[N];
struct SA{
int m=131,x[N],y[N],c[N],sa[N],nx[N],hei[N];
void get_sa(){
for(int i=1;i<=n;i++)c[x[i]=s[i]]++;//處理第一個字元的排序
int l=0;
for(int i=1;i<=m;i++)c[i]+=c[i-1];
for(int i=n;i>=1;i--)sa[c[s[i]]--]=i;
for(int k=1;k<=n;k<<=1){
int num=0;
for(int i=n-k+1;i<=n;i++)y[++num]=i;//後面的字串已經排好序了,不需要加入排序
for(int i=1;i<=n;i++)if(sa[i]>k)y[++num]=sa[i]-k;
for(int i=1;i<=m;i++)c[i]=0;//桶排
for(int i=1;i<=n;i++)c[x[i]]++;
for(int i=2;i<=m;i++)c[i]+=c[i-1];
for(int i=n;i>=1;i--)sa[c[x[y[i]]]--]=y[i],y[i]=0;//倒序附排名,保證排序穩定
swap(x,y);
num=1,x[sa[1]]=1;
for(int i=2;i<=n;i++){//處理下一次排序的關鍵字
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;//若兩個都相等,那麼當前兩個字尾是相同的
else x[sa[i]]=++num;
}
if(num==n)break;//如果已經排完了,就不管了
m=num;
}
}
}sa;
signed main(){
scanf("%s",s+1),n=strlen(s+1);
sa.get_sa();
for(int i=1;i<=n;i++)printf("%d ",sa.sa[i]);
puts("");
}
注意,在上述程式碼中,sa
陣列存的是排名為 \(i\) 的字尾的第一個字元在原串中的位置,不要搞混了。如果要求 第 \(i\) 個字尾的排名,也就是上述解釋中的標號,需要再進行轉化。因為 \(sa\) 和 \(rk\) 是互逆的,也就是 \(sa_{rk_i}=i\),所以這個過程比較簡單,便不再贅述。
當然,這只是萬里長征路中的微不足道的一步,但同時也是意義非凡的一步。
字尾陣列的運用:height
陣列與 LCP
先擺出一些定義:
\(rk_i\) 表示第 \(i\) 個字尾的排名。
\(lcp(s,t)\) 表示兩個字串 \(s\) 和 \(t\) 它們的最長公共字首,在本文中,表示編號分別為 \(s,t\) 的兩個字尾的最長公共字首。
\(hei_i=lcp(sa_i,sa_{i-1})\),也就是排名為 \(i\) 和 \(i-1\) 的兩個字尾的最長公共字首。
\(h_i=hei_{rk_i}\),也就是當前字尾與比他排名前一位的字尾最長公共字首。
接下來,是一些性質。
性質 1:\(lcp(i,j)=lcp(j,i)\)。
並不需要什麼證明。
性質 2:\(lcp(i,i)=n-sa_i+1\)。
可以發現,兩個完全一樣的字串它們的最長公共字首就是它本身,長度為 \(n-sa_i+1\)。
性質 3
LCP Lemma
:\(lcp(i,j)=\min(lcp(i,k),lcp(k,j))(1\le i\le k\le j \le n)\)。
這裡開始有點燒腦了。
設 \(p=\min(lcp(i,k),lcp(k,j))\),則有 \(lcp(i,k)\ge p,lcp(k,j)\ge p\)。
設 \(sa_i,sa_j,sa_k\) 所代表的字尾分別是 \(u,v,w\)。
得到 \(u,w\) 前 \(p\) 個字元相等,\(w,v\) 前 \(p\) 個字元也相等,
所以得到 \(u,v\) 前 \(p\) 個字元也相等,
設 \(lcp(i,j)=q\),則有 \(q\ge p\)。
接下來,我們採用反證法證明 \(q=p\)。
假設 \(q>p\),即 \(q\ge p+1\)。
因此 \(u_{p+1}=v_{p+1}\)。
因為 \(p=\min(lcp(i,k),lcp(k,j))\),所以有 \(u_{p+1}\not=w_{p+1}\) 或 \(v_{p+1}\not=w_{p+1}\)。
所以得到 \(u_{p+1}\not=v_{p+1}\),與前面矛盾。
因此得到 \(q\le p\),綜合得 \(q=p\),即 \(lcp(i,j)=\min(lcp(i,k),lcp(k,j))(1\le i\le k \le j \le n)\)。
性質 4
LCP Theorem
:\(lcp(i,j)=\min(lcp(k,k-1))(1<i\le k\le j\le n)\)。
我們可以用剛得到的性質三來證。
\(lcp(i,j)=\min(lcp(i,i+1),lcp(i+1,j))\\=\min(lcp(i,i+1),\min(lcp(i+1,i+2),lcp(i+2,j))\\=\dots=min(lcp(k,k-1))(i\le k\le j)\)
性質 5:\(h_i\le h_{i-1}-1\)。
轉載至簡書-資訊學小屋:
迴歸正題,設 \(hei_1=0\),考慮如何求 \(hei\)。
因為 \(lcp(i,j)=min(lcp(k,k-1))(1<i\le k\le j\le n)\),
所以 \(lcp(i,j)=min(hei_k)(i<k\le j)\),
前面有提過 \(sa_{rk_i}=i\),所以 \(hei_{i}=h_{sa_i}\)。
我們先把 \(h\) 求出來,然後就能利用性質 4,用 rmq
之類的東西求一下,能做到 \(O(1)\) 查詢。
int n,lg[N];
char s[N];
struct SA{
int m=131,x[N],y[N],c[N],sa[N],rk[N],nx[N],hei[N],h[N];
int mn[N][20];
void get_sa(){
for(int i=1;i<=n;i++)c[x[i]=s[i]]++;//處理第一個字元的排序
int l=0;
for(int i=1;i<=m;i++)c[i]+=c[i-1];
for(int i=n;i>=1;i--)sa[c[s[i]]--]=i;
for(int k=1;k<=n;k<<=1){
int num=0;
for(int i=n-k+1;i<=n;i++)y[++num]=i;//後面的字串已經排好序了,不需要加入排序
for(int i=1;i<=n;i++)if(sa[i]>k)y[++num]=sa[i]-k;
for(int i=1;i<=m;i++)c[i]=0;//桶排
for(int i=1;i<=n;i++)c[x[i]]++;
for(int i=2;i<=m;i++)c[i]+=c[i-1];
for(int i=n;i>=1;i--)sa[c[x[y[i]]]--]=y[i],y[i]=0;//倒序附排名,保證排序穩定
swap(x,y);
num=1,x[sa[1]]=1;
for(int i=2;i<=n;i++){//處理下一次排序的關鍵字
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])x[sa[i]]=num;//若兩個都相等,那麼當前兩個字尾是相同的
else x[sa[i]]=++num;
}
if(num==n)break;//如果已經排完了,就不管了
m=num;
}
for(int i=1;i<=n;i++)rk[sa[i]]=i;
}
void get_h(){
for(int i=1,k=0;i<=n;i++){
int j=sa[rk[i]-1];k-=(k!=0);
while(s[i+k]==s[j+k])++k;
h[i]=hei[rk[i]]=k;
}
}
void rmq(){
for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
for(int i=1;i<=n;i++){
mn[i][0]=hei[i];
for(int j=1;i>=(1<<j);j++)mn[i][j]=min(mn[i][j-1],mn[i-(1<<j-1)][j-1]);
}
}
int lcp(int l,int r){
if(l>r)swap(l,r);++l;
int d=lg[r-l+1];
return min(mn[r][d],mn[l+(1<<d)-1][d]);
}
}sa;
字尾陣列的簡單運用
題目大意:統計一個字串中本質不同的子串個數。
ps:本題可以用字尾自動機做,但同時也是字尾陣列好題。
正難則反,我們考慮計算所有子串個數減去相同子串個數。
我們求出 \(hei\) 之後,剪掉相同字首數量即可。
由於篇幅問題,本文中例題只放主要程式碼。
signed main(){
scanf("%lld%s",&n,s+1);
sa.get_sa(),sa.get_h();
int ans=n*(n+1)/2;
for(int i=1;i<=n;i++)ans-=sa.hei[i];
printf("%lld\n",ans);
}
題目大意:給出兩個串 \(S_0\) 和 \(S\),求 \(S_0\) 中有多少個長度和 \(S\) 相同的子串,使得這個子串能通過修改 \(\le 3\) 個字元與 \(S\) 相同。多組詢問。
ps:本題似乎有多項式做法,有興趣的可以瞭解一下。
我們可以把 \(S\) 插入到 \(S_0\) 後面,中間用一個精心挑選的分隔符,然後就可以得到 \(S_0\) 的每個字尾和 \(S\) 的 lcp
了。
然後列舉每一個開頭,和 \(S\) 的 lcp
暴力往後跳,跳到一個不匹配的位置就跳過,只要跳完後失配點不超過三個 就能統計。處理以每個字元開頭的子串時間複雜度 \(O(1)\)。
因為是多測,注意封裝函式時是否清空函式,寧願多清也不漏清。
int _;
scanf("%d",&_);
for(;_--;){
scanf("%s%s",s+1,t+1);
k=n=strlen(s+1),m=strlen(t+1);
s[++n]='#';
for(int i=1;i<=m;i++)s[++n]=t[i];
sa.get_sa(),sa.get_h(),sa.rmq();
int ans=0;
for(int i=1;i<=k-m+1;i++){
int __=0;
for(int j=1;__<=3&&j<=m;){
// printf("%d %d\n",i,j);
if(s[i+j-1]^s[k+j+1])++j,++__;
else j+=sa.lcp(sa.rk[i+j-1],sa.rk[k+j+1]);
}
if(__<=3)++ans;
}
printf("%d\n",ans);
}
下面給出幾道題作為練習。
接下來,字尾陣列的事情可能就要告一段落了。
AC
自動機
AC
自動機作為自動機家族裡面幾乎是最容易入手的一個,這裡介紹一下。
這一部分需要讀者能夠深刻理解 trie
,瞭解 kmp
。
從 kmp
到 AC
自動機
我們回憶一下 kmp
是處理什麼問題的:單模式串匹配問題。
那如果很多個模式串和一個文字串匹配呢?
這時候,AC
自動機重磅出擊!
首先有個很 naive
的想法,把模式串們放進一個 trie
樹上,然後列舉每一個文字串的字尾,放上去匹配一下。
舉個例子,假設我們有模式串 abc
,abb
,bcc
,文字串 abccabbcc
。
那麼我們建出來的 trie
樹大概就是這樣。
這個時候如我們先匹配 a
,然後走到 abc
,發現匹配不了了,倒回起點,從 b
開始匹配,匹配到 bcc
。
思考:如果這樣子下去,我們會發現這個思路絕對會 T
。
考慮如何優化這個過程。
我們發現,我們從 abc
走到下一個 c
時,沒有辦法匹配,我們把這個情況叫做失配。但是,如果把開頭的 a
扔掉,我們發現我們能夠走到 bcc
。也就是說,每次失配時,我們可以把一些字首扔掉,走到另外一個能讓它不失配的點,這樣次就不需要每次失配都倒回起點重頭再來。
如果我們對每個點,向它丟掉最短非空字首之後的點連一條邊,(保證狀態儘量長)那麼,每次失配了就跳到上一個點上就好了。
練完之後的圖大概長這樣:
我們把這樣練得邊叫做 fail
指標。
我們考慮這樣匹配:從字典樹的根節點開始依次新增匹配串的字元。遇到失配時,順著 fail
指標找到能匹配新字元的第一個狀態。若當前狀態 fail
鏈上的某個祖先是終止狀態,則成功匹配模式串 。
考慮如何快速找到失配點,如果有這個兒子,可以把 fail
指標指向父親的對應 fail
指標,否則把兒子設為父親的對應 fail
指標,方便之後的更新。這裡可以用類似廣搜的方法更新,詳見程式碼。
如果我們查詢的時候暴力向上跳失配點,直到根節點,統計答案,這樣的話時間複雜度最多能被卡到 \(O(模式串長\times 文字串長)\),能過 P3796 【模板】AC 自動機(加強版),但是過不了 P5357 【模板】AC 自動機(二次加強版)。
這些操作是依據 trie
樹的,因此 AC
自動機也被稱作 trie
圖。
void push(char *s,int k){
int p=0,len=strlen(s+1);
for(int i=1;i<=len;i++){
int c=s[i]-'a';
if(!tr[p][c])tr[p][c]=++tot;
p=tr[p][c];
}
vis[num[k]=p]=1;
}
void get_fail(){
queue<int> q;
for(int i=0;i<26;i++){
if(tr[0][i])q.push(tr[0][i]);
}
while(!q.empty()){
int p=q.front();q.pop();
for(int i=0;i<26;i++){//最難理解的部分
if(tr[p][i])fail[tr[p][i]]=tr[fail[p]][i],q.push(tr[p][i]);
else tr[p][i]=tr[fail[p]][i];
}
}
}
void find(char *s){
int len=strlen(s+1);
int p=0;
for(int i=1;i<=len;i++){
int c=s[i]-'a';
p=tr[p][c];
for(int k=p;k;k=fail[k])ans[k]++;
}
}
我們繼續考慮優化這個過程。
我們發現在原來暴力跳的過程中,我們每經過一次 abc
,都要統計一次 bc
,如果有 c
的話也要跟的統計,非常麻煩,所以我們考慮能不能一次性統計完。比如我們到達一個點打一個標記,打完標記後統一上傳,這樣就能夠優化這個過程了。
那麼,我們如何確定上傳順序呢?
拓撲排序!
我們在統計答案的時候打一個標記,然後用類似拓撲排序的方法,從深度大的點更新到深度小的點。
void find(char *s){
int len=strlen(s+1);
int p=0;
for(int i=1;i<=len;i++){
int c=s[i]-'a';
ans[p=tr[p][c]]++;
}
queue<int> q;
for(int i=1;i<=tot;i++)if(!d[i])q.push(i);
while(!q.empty()){
int u=q.front();q.pop();
int v=fail[u];
d[v]--,ans[v]+=ans[u];
if(!d[v])q.push(v);
}
}
至此,你已能通過谷上三道模板題了。
AC自動機的簡單運用
模板題,不講(
例 2:P3121 [USACO15FEB]Censoring G
題目大意:給你一個文字串和一堆模式串,在文字串中找到出現位置最靠前的模式串並刪掉,重複這個過程,求最後的文字串。
注意有刪除操作,所以我們可以把掃到的節點放到一個棧裡面,每次匹配到了就倒退回去就好了。
為了方便輸出,我用了 deque
實現。
因為不需要在自動機上統計什麼答案,所以也不需要拓撲優化。
inline void find(string s){
deque<cxk> q;
register int p=0;
q.push_back({' ',0});
for(register int i=0;i<s.size();i++){
register int c=s[i]-'a';
register int k=trie[p][c];
if(num[k]){
for(int j=1;j<num[k];j++)q.pop_back();
p=q.back().p;
}else{
p=trie[p][c];
q.push_back({s[i],p});
}
}
q.pop_front();
while(!q.empty()){
cout<<q.front().ch;
q.pop_front();
}
}
題目大意:給出若干個模式串,每次詢問一個文字串最長的能被模式串們完全匹配的字首長度。
屬於在 AC
自動機上跑簡單 dp
。
我們考慮到這建 AC
自動機。
設 \(f_i\) 表示字首 \(i\) 是否完全匹配,列舉每一個字首,到這從這一位往前找,每次加入一個點,如果適配了就直接彈(因為必須要完全匹配)。
考慮模式串比較小,所以這樣做是可行的。
當然正解是在 ,具體可見 扶蘇大佬 的 題解。AC
自動機上狀壓
for(int i=1;i<=len;i++){
f[i]=false;pos=0;
for(int j=i;j>=1;j--){
if(!trie[pos][t[j]-'a'])break;
pos=trie[pos][t[j]-'a'];
if(vis[pos]){
f[i]|=f[j-1];
if(f[i])break;
}
}
}
接下來是幾道練習,可能有點困難。
P5231 [JSOI2012]玄武密碼 ps:也能用字尾陣列做。
P3763 [TJOI2017]DNA ps:剛剛在後綴陣列有,但是也可以在 AC
自動機上 dp
。
Loj 668 yww 與樹上的迴文串 ps:點分治與 AC
自動機結合。
51nod1600 Simple KMP ps:對 fail
鏈的深刻理解,與 LCT
結合。