字串家族 學習筆記

__Diu發表於2022-03-15

本來想著一天速通字串,看來我還是想多了。

可能需要的前置

  • 字串雜湊

  • KMP

  • trie

  • manacher 演算法

可能涵蓋的內容

目前已有的:

  • 字尾陣列 SA

  • AC 自動機

未來可能會有的:

  • 擴充套件 KMP

  • 字尾自動機

  • 迴文自動機

  • 子序列自動機

本文可能會有很多錯誤,還請發現的大佬們指出,本蒟蒻感到非常榮幸。

參考資料

  • 字尾陣列

xMinh大佬講解

Rainy7大佬學習筆記

曲神學長演算法總結

Ckj 同機房大佬學習筆記

  • AC 自動機

Hastieyua 大佬詳細講解

對此,本蒟蒻不勝感激

如有侵權問題,請聯絡我,我會馬上標明出處或修改。

字尾陣列

字尾排序

模板題: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)\) 時間,總時間複雜度 \(O(n\log 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;

字尾陣列的簡單運用

例 1:P2408 不同子串個數

題目大意:統計一個字串中本質不同的子串個數。

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);
}

評測記錄

例 2:P3763 [TJOI2017]DNA

題目大意:給出兩個串 \(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);
}

下面給出幾道題作為練習。

評測記錄

P4248 [AHOI2013]差異

P4051 [JSOI2007]字元加密

P1117 [NOI2016] 優秀的拆分

CF1043G Speckled Band

接下來,字尾陣列的事情可能就要告一段落了。

AC 自動機

AC 自動機作為自動機家族裡面幾乎是最容易入手的一個,這裡介紹一下。

這一部分需要讀者能夠深刻理解 trie,瞭解 kmp


kmpAC 自動機

我們回憶一下 kmp 是處理什麼問題的:單模式串匹配問題。

那如果很多個模式串和一個文字串匹配呢?

這時候,AC 自動機重磅出擊!

首先有個很 naive 的想法,把模式串們放進一個 trie 樹上,然後列舉每一個文字串的字尾,放上去匹配一下。

舉個例子,假設我們有模式串 abcabbbcc,文字串 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自動機的簡單運用

例 1:P3966 [TJOI2013]單詞

模板題,不講(

例 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();
	}
}

評測記錄

例 3:P2292 [HNOI2004] L 語言

題目大意:給出若干個模式串,每次詢問一個文字串最長的能被模式串們完全匹配的字首長度。

屬於在 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:也能用字尾陣列做。

P2414 [NOI2011] 阿狸的打字機

P3763 [TJOI2017]DNA ps:剛剛在字尾陣列有,但是也可以在 AC 自動機上 dp

P3735 [HAOI2017]字串

Loj 668 yww 與樹上的迴文串 ps:點分治與 AC 自動機結合。

51nod1600 Simple KMP ps:對 fail 鏈的深刻理解,與 LCT 結合。


相關文章