[整理]字符串全家桶之從普及組到切黑題(零基礎最友好向)


字符串串惡心心

0.前言

本篇博客創作初衷是對於一些看起來沒那么簡單、顯然的算法(例如哈希算法,雖然應用也很多,但基本原理是很淺顯的,故沒有放在本篇博客中)做一些盡量讓新人看得懂、理解得清楚的講解。本篇博客會盡量使用淺顯易懂、生動形象的語言來詮釋某個算法或數據結構,同時穿插代碼以便於理解;有些較易的證明或較顯然的性質在文中略去,也是由於希望讀者能夠積極思考的緣故。由於作者就是一個對於字符串算法理解得尤其不清楚的人,所以寫下這篇博客花費了作者不少心血去重新學習、理解一些算法,如果它對在學某個算法卻一直理解不清楚的你起到了一絲微薄的幫助,那么請給作者點贊支持吧!
內容主要按照知識點相關性排列,難度不一定遞增,標題前加 * 的為拓展內容,建議在第一遍閱讀時略讀或跳過。
由於個人整理,知識點難免有不全、缺漏之處,如果您發現了作者遺漏了某些算法,或是對於某些算法的講解不夠深入,希望能在評論區留下您的意見和建議。

1.KMP、*擴展 KMP、Trie 和 AC 自動機

難度:普及~省選

1.0 KMP

既然題目這么說了,那么我們當然要從普及組難度的講起。
KMP 用於求解單模式匹配問題。
為什么暴力不可行,是因為它沒有吸取之前的教訓,由於從一個位置失配意味着這個位置之前的全部匹配,所以我們可以根據這個跳過那些不可能的位置。

我們發現這個綠色部分(以下記作 \(\text{nxt}\) 數組)非常強,那么如何求它呢?
考慮如何遞推出它,發現它有美妙的性質:

不難分析出這個過程是 \(\mathcal O(n)\) 的。
模板題核心代碼如下(由於年代久遠碼風與現在有較大的不同):

int len1,len2,j,nxt[1000010];
char s1[1000010],s2[1000010];
int main(){
  cin>>s1+1>>s2+1;
  len1=strlen(s1+1),len2=strlen(s2+1);
  for(int i=2;i<=len2;i++){
    while(j&&s2[i]!=s2[j+1])j=nxt[j];
    if(s2[j+1]==s2[i])j++;
    nxt[i]=j;
  }
  j=0;
  for(int i=1;i<=len1;i++){
    while(j>0&&s2[j+1]!=s1[i])j=nxt[j];
    if(s2[j+1]==s1[i])j++;
    if(j==len2){
      cout<<i-len2+1<<endl;
      j=nxt[j];
    }
  }
  for(int i=1;i<=len2;i++)cout<<nxt[i]<<" ";
  return 0;
}

1.1 Trie

Trie 是一個很好理解的數據結構,它是對多個字符串信息的一種壓縮。
一張圖就可以講明 Trie 樹的結構,對 "he","her","his","him","dark","dash" 構建的 Trie 樹長這樣:

其中節點的編號沒有意義,邊表示字符,從根節點向下走,遇到結束標記(圖中加粗的節點)就代表有一個字符串結束。
容易發現它的節點數為 \(\mathcal O(n|s|)\)(其中 \(n\) 為字符串個數,\(|s|\) 為字符串長度)。

1.2 AC 自動機

AC 自動機(Aho-Corasick Automaton,顧名思義是あほ算法),可簡稱 ACAM,是在 Trie 樹上運用 KMP 的思想進行多模式匹配。
先來介紹自動機的概念。自動機是一個數學模型,我們在 OI 中基本上接觸的是確定性有限狀態自動機(DFA)
一個自動機由字符集、狀態集合、起始狀態、接受狀態集合和轉移函數構成,它可以看做一張 DAG,狀態相當於圖上的節點,而轉移函數相當於有向邊。
輸入一個字符串,按照轉移函數一個一個字符地轉移,如果最終到達的是接受狀態則接受這個字符串,反之不接受。
例:我們剛剛講的 Trie 是一個自動機,它接受且僅接受指定的字符串集合。
回到 ACAM。它有一個關鍵的概念叫做 \(\text{fail}\) 指針,實際上和 KMP 中的 \(\text{nxt}\) 數組定義差不多:它指到當前狀態的最長后綴。

加入一個字符時的求解過程和 KMP 是類似的:沿着父節點的 \(\text{fail}\) 指針向上,直到跳到的節點擁有該字符的兒子,連向這個兒子。
但是一次次跳 \(\text{fail}\) 指針太慢了,我們可以 BFS 來優化這個過程。
簡單版核心代碼如下(同樣是年代久遠的代碼):

int tot;
struct Node{
  int fail,ed,son[26];
}tr[1000010];
il void Insert(string s){//建Trie樹 
  int len=s.length(),now=0,tmp;
  for(rg int i=0;i<len;i++){
    tmp=s[i]-'a'; 
    if(!tr[now].son[tmp])tr[now].son[tmp]=++tot;
    now=tr[now].son[tmp];
  }
  tr[now].ed++;
}
il void Fail(){//求失配指針 
  queue<int> q;
  for(rg int i=0;i<26;i++){//第二層都指向根節點,先處理出來 
    if(tr[0].son[i]){
      tr[tr[0].son[i]].fail=0;
      q.push(tr[0].son[i]);
    }
  }
  while(!q.empty()){
    int now=q.front();q.pop();
    for(rg int i=0;i<26;i++){
      if(tr[now].son[i]){//跳父親的fail 
        tr[tr[now].son[i]].fail=tr[tr[now].fail].son[i];
        q.push(tr[now].son[i]);
      }else tr[now].son[i]=tr[tr[now].fail].son[i];
    }
  }
}
il int Find(string s){//匹配 
  int len=s.length(),now=0,tmp,ans=0;
  for(rg int i=0;i<len;i++){
    tmp=s[i]-'a',now=tr[now].son[tmp];
    for(rg int j=now;j&&tr[j].ed!=-1;j=tr[j].fail){
      ans+=tr[j].ed,tr[j].ed=-1;
    }
  }
  return ans;
}

*1.3 擴展 KMP

擴展 KMP 又稱為 Z 算法,它的核心是 Z 函數:對於一個字符串 \(s\)\(z(i)\) 表示 \(s\)\(s[i,n]\) 的最長公共前綴長度,下面我們可以看到,它是可以 \(\mathcal O(n)\) 計算的。
我們依然去遞推這個東西,其實這里的遞推方式(大部分直接轉移、小部分均攤線性暴力)很像 4.0 中要講的 Manacher 算法,如果不理解接下來的遞推方式不妨先看看那部分,再將它們放到一起對比學習。
對於一個 \(i\) 我們稱區間 \([i,i+z(i)-1]\)\(i\)匹配段或 Z-box,計算的時候我們需要維護右端點最靠右的 Z-box(設為 \([l,r]\)),這樣我們就可以分情況討論 \(i\)\(r\) 的關系(如下圖)。

兩個字符串匹配是大同小異的(這一點和 KMP 相似)。
模板題核心代碼:

const int N=20000010;
int n,m,z[N],lcp[N];
LL ans;char s[N],t[N];
signed main(){
  scanf("%s%s",s+1,t+1),n=strlen(s+1),m=strlen(t+1);
  z[1]=m,ans=m+1;
  for(rg int i=2,l=0,r=0;i<=m;i++){
    if(i<=r)z[i]=min(z[i-l+1],r-i+1);
    while(i+z[i]<=m&&t[i+z[i]]==t[z[i]+1])z[i]++;
    if(i+z[i]-1>r)l=i,r=i+z[i]-1;
    ans^=(LL)i*(z[i]+1);
  }
  cout<<ans<<endl,ans=0;
  for(rg int i=1;i<=min(n,m);i++,lcp[1]++){
    if(s[i]!=t[i])break;
  }ans=lcp[1]+1;
  for(rg int i=2,l=0,r=0;i<=n;i++){//與上面是幾乎相同的,甚至可以封裝到一個函數里,不過我這里懶就沒封
    if(i<=r)lcp[i]=min(z[i-l+1],r-i+1);
    while(i+lcp[i]<=n&&s[i+lcp[i]]==t[lcp[i]+1])lcp[i]++;
    if(i+lcp[i]-1>r)l=i,r=i+lcp[i]-1;
    ans^=(LL)i*(lcp[i]+1);
  }
  cout<<ans<<endl;
  KafuuChino HotoKokoa
}

while 循環一定會導致 \(r\) 增加,所以均攤下來是 \(\mathcal O(n)\) 的。

1.4 應用

例題 \(1.0\):[NOI2014]動物園
這個題的答案限制太多了,我們先考慮求一個沒有長度限制的 \(\text{num}\)。有一個暴力的想法就是直接跳 \(\text{nxt}\) 數組看跳了多少次,不過我們發現可以在遞推 \(\text{nxt}\) 時順便求出來,所以就解決了弱化版的問題。現在考慮加上這個一半長度限制怎么辦,我們發現一個等價的做法就是每次暴力跳 \(\text{nxt}\) 直到長度小於一半。

計算答案那部分的核心代碼:

for(rg int i=2,j=0;i<=n;i++){
  while(j&&s[i]!=s[j+1])j=nxt[j];
  if(s[i]==s[j+1])j++;
  while((j<<1)>i)j=nxt[j];
  ans=(LL)ans*(num[j]+1)%p;
}

在實際應用中,以上幾種算法一般不會單獨使用,而是作為 dp 等算法的輔助工具。
例題 \(1.1\):[JSOI2007]文本生成器
在 OI 中我們經常會遇到 ACAM 上 dp 的問題,它有一個常見套路就是設 \(f_{i,j}\) 為第 \(i\) 個點、第 \(j\) 個字符的答案,然后再帶一些附加信息便於轉移。這道題中我們要求至少經過一個結束標記,於是可以設 \(f_{i,j,0/1}\) 表示第 \(i\) 個點、第 \(j\) 個字符、沒有/有經過結束標記的方案數。那么答案就是 \(\sum f_{u,m,1}\),轉移需要從父親轉移到兒子,再特判到達一個結束標記的情況,時間復雜度大概是 \(\mathcal O(nm^2|\Sigma|)\)?。
需要注意特殊處理后綴關系,建自動機時如果一個點的 \(\text{fail}\) 指針指向的點有結束標記,那么這個串也需要打標記。
例題 \(1.2\):[BJOI2019]奧術神杖
我們發現這個答案式子實在是太難受了,嘗試通過取對數把它轉化成加法:\(\dfrac1c\sum\ln v_i\),然后這東西可以二分,答案可行等價於 \(\sum(\ln v_i-mid)>0\),然后在自動機上 dp,需要注意 dp 時記錄決策點以便輸出方案。

2.后綴數組和后綴自動機

難度:省選~NOI

2.0 后綴數組簡介

對於一個字符串,將它的所有后綴排序得到一個 \(\text{sa}\) 數組表示排名對應的后綴,\(\text{rk}\) 數組表示后綴的排名,這兩個數組是互逆的。
我們還可以通過 \(\text{sa}\) 得到另一個很有用的數組 \(\text{height}\),它表示排名相鄰的兩個后綴的最長公共前綴,求法也會在下面提到。

2.1 倍增

我們根據后綴的性質,可以倍增在 \(\mathcal O(n\log n)\) 的時間內求解。
考慮先排序第一個字符,再倍增排序的長度。只排一個字符是很簡單的,但是加上第二個字符怎么排呢?我們需要重新排序一遍嗎?
顯然是不用的,假設我們已經排好了前 \(k\) 位想要排接下來的 \(k\) 位,我們發現第 \(i\) 個后綴的后 \(k\) 位就是第 \(i+k\) 個后綴的前 \(k\) 位。
所以說我們可以用第 \(i\) 個后綴前 \(k\) 位作為第一關鍵字,第 \(i+k\) 個后綴前 \(k\) 位作為第二關鍵字進行排序,這樣就完成了一次倍增。
最終的復雜度是倍增的 \(\mathcal O(\log n)\) 乘上排序的 \(\mathcal O(n\log n)\),如果使用基數排序(沒接觸過的讀者可以自行查找資料學習)可以優化到 \(\mathcal O(n\log n)\)
下面對於倍增法的代碼進行重點講解,這里也是很多人容易記不住或理解不了的部分,在代碼中給出了較為詳細的注釋。

const int N=200010;
int n;char s[N];
int m,sa[N],rk[N],c[N],y[N];
//sa和rk與上文一致,c是基數排序用到的桶,y用於臨時存儲第二關鍵字
il void Sort(){//這個函數實現的是雙關鍵字基數排序,rk為第一關鍵字,y為第二關鍵字
  for(rg int i=1;i<=m;i++)c[i]=0;
  for(rg int i=1;i<=n;i++)c[rk[i]]++;
  for(rg int i=2;i<=m;i++)c[i]+=c[i-1];
  for(rg int i=n;i;i--)sa[c[rk[y[i]]]--]=y[i];
  //如果不理解這里請自行查閱資料
}
il void SA(){
  for(rg int i=1;i<=n;i++)rk[i]=s[i],y[i]=i;//先只排第一個字符
  Sort();
  for(rg int k=1;k<=n;k<<=1){//倍增,k意同上文
    int num=0;
    for(rg int i=n-k+1;i<=n;i++)y[++num]=i;
    for(rg int i=1;i<=n;i++)if(sa[i]>k)y[++num]=sa[i]-k;
    //以上兩行是一個優化:先把第二關鍵字為空的放進去,再放剩下的
    Sort(),swap(rk,y);//備份rk作為下次的第二關鍵字
    rk[sa[1]]=num=1;
    for(rg int i=2;i<=n;i++){
      //兩個關鍵字均相同或有不相同
      if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k]){
        rk[sa[i]]=num;
      }else rk[sa[i]]=++num;
    }
    if(num==n)break;//已經能夠區分所有后綴
    m=num;
  }
}

*2.2 SA-IS

想學線性求 SA 的算法,但由於看不懂 DC3 的論文,又聽說 SA-IS 跑得像香港記者,所以就拋棄 DC3 投入 SA-IS 的懷抱了。
我們首先會在字符串結尾加入一個哨兵節點 \(\texttt{#}\),然后再對於每個后綴進行分類:一個后綴是 S 型L 型當且僅當它小於大於它的下一個后綴,一個字符的類型是它對應的后綴的類型。特別地,我們認為后綴 \(\texttt{#}\) 是 S 型的。
例如字符串 \(\texttt{abbccbabb#}\) 的每個后綴的類型就是 \(\texttt{SSSLLLSLLS}\)
容易發現 \(s[i]<s[i+1]\)\(s[i]>s[i+1]\)\(s[i\dots n]\) 必然是 S 型或 L 型,否則直接繼承 \(s[i+1\dots n]\) 的類型即可,於是我們可以線性遞推出每個后綴的類型(記作 \(t\) 數組)。
下面我們有 LMS 字符LMS 子串的概念:LMS 字符是左邊為 L 型的 S 型字符(或者說是一段 S 型字符中最靠左的一個),LMS 子串是 LMS 字符分割出的串(包含兩端的 LMS 字符本身),將 LMS 子串出現的位置記作 \(p\) 數組。
我們想要對 LMS 子串進行排序,就要定義比較方式:在比較字典序的基礎上,比較每個字符的類型,因為兩個字符相同時顯然 S 型的更大。
現在我們把所有 LMS 子串排序、離散化得到一個 \(s_1\) 數組。

如圖,\(\texttt{*}\) 標記的是 LMS 字符,右側是桶排序的結果。
然后我們驚奇地發現,這個 \(s_1\) 有一個保序性:\(s_1\) 中相鄰后綴的大小關系等價於原串中對應位置后綴的大小關系!
由於 LMS 子串至少間隔一個字符,所以 \(s_1\) 的長度會減半,我們就縮小了問題規模。現在假設我們已經求出了 \(s_1\) 的后綴數組 \(\text{sa}_1\),考慮如何求出原串的后綴數組 \(\text{sa}\)
考察 SA 的結構:由於它將后綴排好了序,所以首字母相同的后綴一定是一個連續段,而且這一段內部的 S 型后綴和 L 型后綴也是分別連續的(由於 L 型后綴小於 S 型后綴,所以 L 型后綴集中在前面),我們可以把這些連續段看成一個個桶。
那么現在我們有了一個從 \(\text{sa}_1\) 導到 \(\text{sa}\) 的方法:

  1. 確定 S 桶的起始位置。把 \(\text{sa}_1\) 中的所有后綴加入對應位置的 S 桶中;
  2. 確定 L 桶的起始位置。從左往右掃一遍 \(\text{sa}\),如果 \(s[\text{sa}[i]-1]\) 為 L 型則將 \(\text{sa}[i]-1\) 從右側加入 L 桶中;
  3. 重新確定 S 桶的起始位置。從右往左掃一遍 \(\text{sa}\),如果 \(s[\text{sa}[i]-1]\) 為 S 型則將 \(\text{sa}[i]-1\) 從左側加入 S 桶中。

簡要證明一下這樣做的正確性。第一步由於排出來的不是最終結果所以無需證明,只需要知道它將已經排好序的 LMS 子串按順序加入了新的數組。對於第二步,由於后綴 \(i\) 只會被后綴 \(i-1\) 導入,而后綴 \(i-1\) 是 L 型后綴,所以由大小關系易知每個 L 型后綴都會被加入;又因為 \(s[i-1\dots n]>s[i\dots n]\)\(s[i\dots n]\) 先被導入,所以導入的 L 后綴一定是排好序的。對於第三步,證明與第二步類似。
現在我們已經幾乎得到了 SA-IS 算法了,唯一的問題是如何快速排序 LMS 子串。我們當然可以寫一個很麻煩的東西來完成它(事實上,這就是另一個線性算法 KA 的流程。但是舉個例子,對字符串進行基數排序的代碼復雜度是可想而知的……),但是 SA-IS 告訴我們,這一步也可以用誘導排序完成。
實現也很簡單,只需將第一步改為將 LMS 字符放入桶中即可。為了證明它的正確性,下面引入 LMS 前綴的概念:LMS 前綴是從當前字符到第一個位置大於等於它的 LMS 字符這一段構成的子串,容易發現 LMS 字符的 LMS 前綴是它本身,LMS 前綴的類型是開頭字符的類型。
對於第一步,放入后一定是有序的。對於第二、第三步,只需要數學歸納然后反證即可。
由於每次都是 \(\mathcal O(|s|)\) 的,而每次 \(s\) 的長度減半,所以總復雜度為 \(\mathcal O(|s|)\)
看了上面的過程可能會覺得很混亂,所以下面給出模板題核心代碼便於理解(坑點只是卡了我的地方,可能對讀者沒有什么殺傷力):

const int N=2000010;
int n,s[N];bool t[N];//0:L型 1:S型 
int sa[N],rk[N],p[N],b[N],c[N];
//加入L/S桶
#define L(x) sa[c[s[x]]++]=x
#define S(x) sa[c[s[x]]--]=x
//誘導排序,需要先加入LMS后綴
//坑點:注意傳入s的頭指針
il void IS(int *sa,int *s,bool *t,int *b,int n,int m){
  //此處c數組作為L桶左端點,需要注意邊界
  for(rg int i=1;i<=m;i++)c[i]=b[i-1]+1;
  //坑點:sa[i]>1
  for(rg int i=1;i<=n;i++)if(sa[i]>1&&!t[sa[i]-1])L(sa[i]-1);
  //此處c數組作為S桶右端點
  for(rg int i=1;i<=m;i++)c[i]=b[i];
  for(rg int i=n;i;i--)if(sa[i]>1&&t[sa[i]-1])S(sa[i]-1);
}
//主體過程
void SAIS(int *s,bool *t,int *p,int *b,int n,int m){
  //#為S型
  t[n]=1;int tot=0,num=1,*s1=s+n+1;fill(rk+2,rk+1+n,0);
  //分類
  for(rg int i=n-1;i;i--)t[i]=s[i]==s[i+1]?t[i+1]:s[i]<s[i+1];
  //記錄LMS字符
  for(rg int i=2;i<=n;i++)if(t[i]&&!t[i-1])p[rk[i]=++tot]=i;
  //fill可換成memset,此處僅作卡常
  fill(sa+1,sa+n+1,0);
  for(rg int i=1;i<=n;i++)b[s[i]]++;
  for(rg int i=1;i<=m;i++)b[i]+=b[i-1];
  //此處c數組作為S桶右端點
  for(rg int i=1;i<=m;i++)c[i]=b[i];
  //加入所有LMS字符,進行第一輪排序
  for(rg int i=tot;i;i--)S(p[i]);
  IS(sa,s,t,b,n,m);
  //離散化新數組
  for(rg int i=1,x=0,lst=0;i<=n;i++){
    if(x=rk[sa[i]]){
      for(rg int j=p[x],k=p[lst];j<=p[x+1];j++,k++){
        if(s[j]^s[k]){
          num++;break;
        }
      }
      s1[lst=x]=num;
    }
  }
  //是否各不相同
  if(num<tot)SAIS(s1,t+n+1,p+tot+1,b+m+1,tot,num);
  else for(rg int i=1;i<=tot;i++)sa[s1[i]]=i;
  //同上,先加入LMS后綴
  for(rg int i=1;i<=tot;i++)s1[i]=p[sa[i]];
  for(rg int i=1;i<=m;i++)c[i]=b[i];
  fill(sa+1,sa+n+1,0);
  for(rg int i=tot;i;i--)S(s1[i]);
  IS(sa,s,t,b,n,m);
}

原論文中提到它可以用約 \(100\) 行來實現,可以看出只要壓行壓得好這個數字是很寬松的,我實現時(不帶注釋)用了約 \(40\) 行,僅比倍增法多了十幾行。但很遺憾的是,根據洛谷和 UOJ 的評測結果來看,由於常數巨大它比我的倍增法快不了多少qd

2.3 后綴自動機簡介與構造方法

后綴自動機(SAM)可以理解成一個將原字符串所有后綴的信息高度壓縮的一個圖,后面會提到它的點數和邊數僅為 \(\mathcal O(n)\)
SAM 與 SA 並沒有什么關系,甚至比 SA 簡單不少(指模板題難度和代碼復雜度),如果你不能理解 SA 也請繼續往下看(然后發現更加理解不了 SAM)

先來介紹幾個概念。
定義一個字符串的 \(\text{endpos}\) 為它在原串中的所有結束位置。例如對於字符串 \(\texttt{abcabcc}\)\(\text{endpos}(\texttt{abc})=\{3,6\},\text{endpos}(\texttt{c})=\{3,6,7\}\)
顯然 \(\text{endpos}\) 的包含關系代表了字符串的后綴關系,而不包含的 \(\text{endpos}\) 集合一定不交。
然后我們發現 \(\text{endpos}\) 集合的包含關系(稱為后綴鏈接 \(\text{fa}\))構成了一棵樹(稱為后綴鏈接樹或 Parent 樹),每個點的父親是它最長的 \(\text{endpos}\) 集合與該點不同的后綴。

我們還有一個 \(\text{len}\) 用於表示該點對應的最長字符串長度。
接下來考慮如何構建 SAM,我們增量構造,考慮插入一個新字符 \(c\) 時會發生什么。
首先整個串由於長度加了一肯定要作為一個新的狀態和上一個狀態連邊的,然后我們需要看其他后綴怎么轉移過來。
我們先沿着上一次的狀態在 Parent 樹上向上跳,每次都加入一個到 \(c\) 的轉移,直到這個點已經存在 \(c\) 的轉移(設這個點為 \(p\))。
如果 \(p\) 沒有跳到根,那么說明我們找到了一個轉移 \(p\xrightarrow{c}q\),也就是我們找到了一個 \(s\) 的后綴 \(x\) 滿足 \(x+c\)\(s\) 中出現過。
如果這個轉移滿足 \(\text{len}(p)+1=\text{len}(q)\),意味着 \(q\) 對應的最長串就是 \(x+c\),去掉 \(c\) 之后得到的 \(x\) 一定包含了前面的所有后綴。
否則情況就有些麻煩了,由於 \(c\) 的后綴鏈接必須連到最大長度為 \(\text{len}(p)+1\) 的狀態上而 \(q\) 對應了一個比 \(x+c\) 長的狀態,所以需要分出來一個 \(q'\),讓 \(\text{len}(q')=\text{len}(p)+1\)。也就是說,原來的 \(q\) 對應的某些 \(y+c\)\(y\) 不是 \(s\) 的后綴,我們需要把長度比較大的拿出來,在 \(q'\) 中保存更短的后綴 \(x+c\)
那么怎么處理這個新點的轉移和后綴鏈接呢?根據剛剛的分析,我們應該把 \(q\)\(c\) 的后綴鏈接都變為 \(q'\),然后沿着 \(p\) 的后綴鏈接上跳,這時那些原本連向 \(q\) 的都不能轉移了,應該先連到 \(q'\) 上。
這樣,我們就完成了 SAM 的構造。
我們來整理一下算法的過程(起始節點為 \(1\)):

\[\begin{array}{ll} \textbf{void}\ \text{Extend}(\textbf{int}\ c)\\ \qquad cur\gets tot+1,tot\gets tot+1,p\gets lst,lst\gets cur//新建節點\\ \qquad \textbf{while}\ ch[p][c]=0\ \textbf{do}\ p\gets fa[p]\\ \qquad\textbf{if}\ p=0\ \textbf{then}\ fa[cur]\gets 1\\ \qquad\textbf{else}\\ \qquad\qquad q\gets ch[p][c]\\ \qquad\qquad\textbf{if}\ len[p]+1=len[q]\ \textbf{then}\ fa[cur]\gets q//可以直接轉移\\ \qquad\qquad\textbf{else}\\ \qquad\qquad\qquad nq\gets tot+1,tot\gets tot+1//分裂新節點並繼承狀態\\ \qquad\qquad\qquad fa[nq]\gets fa[q],ch[nq]\gets ch[q],len[nq]\gets len[p]+1\\ \qquad\qquad\qquad fa[cur]\gets nq,fa[q]\gets nq\\ \qquad\qquad\qquad \textbf{while}\ ch[p][c]=q\ \textbf{do}\ ch[p][c]\gets nq,p\gets fa[p]\\ \end{array} \]

可能嵌套得有些迷惑,請仔細閱讀。
正確性基本上已經順帶證了,它的空間復雜度(狀態數和轉移數)是線性的,那么時間均攤也是線性的。

*2.4 廣義后綴自動機

普通的 SAM 可以解決一個字符串上的問題,而我們需要在多個字符串上解決同樣的問題時就需要用到廣義后綴自動機(GSAM),由於是多個串我們需要在 Trie 上建立(另說一句,GSAM 有很多假做法包括但不限於用特殊字符拼接、每個串分別插入 SAM 等等,它們通常能達到和 GSAM 一樣的正確性,但是時間會有危險)。
我們在普通的 SAM 上有 \(\text{endpos}\)、后綴鏈接和 \(\text{len}\) 等概念,如何在 Trie 上體現這些呢?我們在普通 SAM 上插入的是一串 \(\text{len}\) 遞增的節點,在 Trie 上我們把深度看成 \(\text{len}\) 的話就是按照 BFS 序在父親上插入一串 \(\text{len}\) 不減的節點。
這里可能說得有些迷惑,其實它和剛剛假做法里的分別插入是差不多的,只不過借助 Trie 壓縮了前綴使得復雜度正確。
它可以用於求解許多 SAM 能夠求解的東西,例如多個字符串的本質不同子串數也是 \(\sum(\text{len}(u)-\text{len}(\text{fa}(u)))\)
模板題核心代碼如下,請注意 SAM 中變化的地方:

const int N=1000010;
int n;char s[N];
struct SAM {
  struct Node {
    int fa,len,ch[26];
  }tr[N<<1];
  int tot=1;
  il int Extend(int c,int lst){
    int p=lst,cur=++tot;
    tr[cur].len=tr[p].len+1;
    for(;p&&!tr[p].ch[c];p=tr[p].fa)tr[p].ch[c]=cur;
    if(!p)tr[cur].fa=1;
    else {
      int q=tr[p].ch[c];
      if(tr[q].len==tr[p].len+1)tr[cur].fa=q;
      else {
        int nq=++tot;tr[nq]=tr[q],tr[nq].len=tr[p].len+1;
        tr[q].fa=tr[cur].fa=nq;
        for(;p&&tr[p].ch[c]==q;p=tr[p].fa)tr[p].ch[c]=nq;
      }
    }
    return cur;
  }
  il void Clear(){
    for(rg int i=1;i<=tot;i++){
      tr[i].fa=tr[i].len=0;
      memset(tr[i].ch,0,sizeof(tr[i].ch));
    }
    tot=1;
  }
}S;
struct Trie {
  struct Node {
    int ch[26],ed,c,fa;
  }tr[N];
  int tot=1,pos[N];
  il void Insert(char *s){
    int n=strlen(s+1),p=1;
    for(rg int i=1;i<=n;i++){
      if(!tr[p].ch[s[i]-97]){
        tr[p].ch[s[i]-97]=++tot;
        tr[tot].fa=p,tr[tot].c=s[i]-97;
      }
      p=tr[p].ch[s[i]-97];
    }
    tr[p].ed++;
  }
  il void BFS(){
    queue<int> q;
    for(rg int i=0;i<26;i++){
      if(tr[1].ch[i])q.push(tr[1].ch[i]);
    }
    pos[1]=1;
    while(!q.empty()){
      int u=q.front();q.pop();
      pos[u]=S.Extend(tr[u].c,pos[tr[u].fa]);
      for(rg int i=0;i<26;i++){
        if(tr[u].ch[i])q.push(tr[u].ch[i]);
      }
    }
  }
}T;
int main(){
  Read(n);
  for(rg int i=1;i<=n;i++)scanf("%s",s+1),T.Insert(s);
  T.BFS();LL ans=0;
  for(rg int i=2;i<=S.tot;i++){
    ans+=S.tr[i].len-S.tr[S.tr[i].fa].len;
  }
  cout<<ans<<endl;
  KafuuChino HotoKokoa
}

另外,也可不顯式構建 Trie,而是直接按照 Trie 的結構在線插入 SAM,具體來說,需要在前面分別插入的錯誤寫法基礎上加入兩處特判。
我們需要特判什么呢?首先一個簡單的特判是判斷節點是否已經插入過,如果插入過直接返回。假做法里還有什么問題呢?
我們發現,假做法中的每次暴力插入會插出來一些奇奇怪怪的空節點,例如當有轉移但 \(\text{len}(q)\ne\text{len}(p)+1\) 時,我們發現 \(\text{len}(cur)=\text{len}(p)+1\) 而它的最小長度為 \(\text{len}(q')+1=\text{len}(p)+2\),也就是說這個點沒有儲存任何一個串的信息,是一個空節點。空節點一般不會影響正確性,但是在某些題中可以構造數據卡掉它,故我們特判掉會產生空節點的情況。

il int Extend(int c,int lst){
  int p=lst;
  if(tr[p].ch[c]){
    int q=tr[p].ch[c];
    if(tr[q].len==tr[p].len+1)return q;//需要的節點已經插入過
    else {//防止空節點,直接分裂
      int nq=++tot;tr[nq]=tr[q],tr[nq].len=tr[p].len+1;
      tr[q].fa=nq;
      for(;p&&tr[p].ch[c]==q;p=tr[p].fa)tr[p].ch[c]=nq;
      return nq;
    }
  }
  int cur=++tot;
  tr[cur].len=tr[p].len+1;
  for(;p&&!tr[p].ch[c];p=tr[p].fa)tr[p].ch[c]=cur;
  if(!p)tr[cur].fa=1;
  else {
    int q=tr[p].ch[c];
    if(tr[q].len==tr[p].len+1)tr[cur].fa=q;
    else {
      int nq=++tot;tr[nq]=tr[q],tr[nq].len=tr[p].len+1;
      tr[q].fa=tr[cur].fa=nq;
      for(;p&&tr[p].ch[c]==q;p=tr[p].fa)tr[p].ch[c]=nq;
    }
  }
  return cur;
}

由於不需要顯式建 Trie,在線做法比離線做法快了一倍

2.5 應用

2.5.0 SA 部分

很多題目用 SA 和 SAM 都可以做,所以接下來大概是先分別講完兩個東西的基礎應用再混着講一些題目。
然而由於 SAM 好寫且功能強大所以大部分題采用了 SAM 做法
SA 的一個應用是求最小循環移位,例題 \(2.0\):[JSOI2007]字符加密。
對於循環移位的問題我們的常用套路是倍長原串,然后就可以直接進行后綴排序。
接下來我們重點講解 \(\text{height}\) 數組的求法及用途。
首先根據 \(\text{height}\) 的定義我們容易發現 \(\text{height}(\text{rk}(i))\ge \text{height}(\text{rk}(i-1))-1\),然后我們根據這個性質暴力求解。
它可以求本質不同的子串個數,只需要用所有子串減去重復的 \(\text{height}\) 即可。
SA 還可以搭配並查集食用,例題 \(2.1\):[NOI2015]品酒大會。
由於 \(k\) 相似擁有很好的性質,所以我們從大到小枚舉 \(\text{height}\),把一樣的並查集合起來,記錄集合中的最值和大小。

2.5.1 SAM 部分

關於 SAM,它死了很明顯可以進行字符串匹配。
然后它還可以求本質不同的子串個數,由於每個子串都相當於自動機中的一些路徑,所以不同子串的個數相當於起點開始的不同路徑條數,這個可以 dp 計算。另外,還可以利用 Parent 樹,每個點包含的子串數量是 \(\text{len}(u)-\text{len}(\text{fa}(u))\),它們的出現次數都是 \(\text{siz}(u)\)
例題 \(2.2\):[SDOI2016]生成魔咒(子串計數)、例題 \(2.3\):[TJOI2019]甲苯先生和大中鋒的字符串(出現次數)。類似地還可以計算不同子串的總長度。
SAM 還有一大應用是求兩個串的最長公共子串。具體來說,假設現在要求 \(s\)\(t\) 的最長公共子串,我們需要先建出 \(s\) 的 SAM,然后把 \(t\) 扔上去匹配。如果有當前字符的轉移就轉移下去,否則一直跳后綴鏈接跳到有這個轉移為止,再找不到就只能重新開始一段了。

*2.5.2 GSAM 部分

其實上面也提到了,GSAM 的性質和 SAM 幾乎一致,許多用 SAM 做的題可以原封不動搬到 GSAM 上做多串版本。與普通 SAM 不同的是,GSAM 的一個節點可能存儲了多個串的信息無法分離,如果要求維護 \(\text{endpos}\) 集合需要對每個串分別維護。
例題 \(2.4\):[HAOI2016]找相同字符
由於不同位置算不同答案,我們需要記錄 \(\text{endpos}\) 集合大小,建出 GSAM 分別維護兩個串的 \(\text{endpos}\) 集合大小(設為 \(\text{siz}_s\)\(\text{siz}_t\)),答案就是 \(\sum\text{siz}_s(u)\text{siz}_t(u)(\text{len}(u)-\text{len}(\text{fa}(u)))\)

2.5.3 綜合部分

例題 \(2.5\):[AHOI2013]差異
這題有一個做法是 SA 加單調棧,不過看起來太麻煩了,我們可以直接用 SAM 解決它。
我們看這個式子的形式就很像一個樹上路徑,所以肯定會往 Parent 樹上想。我們發現 Parent 樹上兩個點的 LCA 代表了它們的最長公共后綴,那么我們發現把邊權賦值為 \(\text{len}(u)-\text{len}(\text{fa}(u))\) 就可以把問題轉化成樹上所有路徑邊權和之和,這個分別計算每條邊的貢獻即可。但是原題問的是最長公共前綴啊?我們似乎求了一個錯誤的東西?其實不是的,因為很容易證明前綴的最長公共后綴與后綴的最長公共前綴是等價的。
例題 \(2.6\):[ZJOI2015]諸神眷顧的幻想鄉
這個題一看可能毫無頭緒,我們之前用 SAM 之類的維護的都是祖孫之間的信息,而樹上路徑可能會很奇形怪狀。但是我們發現題目給出了一個重要的條件:葉節點不超過 \(20\) 個,所以我們可以暴力換根建 GSAM 統計了。
SAM 還經常與其他數據結構如線段樹等結合變為毒瘤題
例題 \(2.7\):[NOI2018]你的名字讀者們夢寐以求的黑題終於來了
考慮一個弱化版 \(l=1,r=|s|\),我們可以補集轉化一下變成求 \(s\)\(t\)本質不同公共子串數量。我們對兩個串都建出 SAM,然后按照上面的方法求出以 \(t\) 的每個點結尾的最長公共子串的長度,記作 \(\text{lcs}(i)\)。接下來對於 \(t\) 的 SAM 的每個節點分別考慮和 \(s\) 的公共子串數量即可做到不重不漏。我們設節點 \(u\)\(\text{endpos}\) 集合中第一個出現的是 \(\text{fst}(u)\)(它可以在添加字符時順便求出),那么容易發現長度比 \(\text{lcs}(\text{fst}(u))\) 大的都不能匹配,也就是貢獻為 \(\max\{\text{len}(u)-\max\{\text{len}(\text{fa}(u)),\text{lcs}(\text{fst}(u))\},0\}\)
現在我們可以考慮取一段區間怎么做了。我們可以沿用上面的思路,把 \(\text{endpos}\) 集合放到線段樹上,每個點與自己的兒子合並,匹配 \(\text{lcs}\) 時判斷節點對應的 \(\text{endpos}\) 集合有沒有在給定區間里的。具體實現比較繁瑣,可以閱讀以下代碼結合注釋理解(其實如果你真正理解了 SAM 這題在思維難度上還是挺簡單的):

const int N=1000010;
int n,m,q;char s[N],t[N];
struct SAM {//SAM板子
  struct Node {
    int fa,len,fst,ch[26];//fst意同上文
  }tr[N];
  int tot=1,lst=1;
  il void Extend(int c,int pos){
    int p=lst,cur=++tot;lst=cur,tr[cur].fst=pos;//整串第一次出現在當前位置
    tr[cur].len=tr[p].len+1;
    for(;p&&!tr[p].ch[c];p=tr[p].fa)tr[p].ch[c]=cur;
    if(!p)tr[cur].fa=1;
    else {
      int q=tr[p].ch[c];
      if(tr[q].len==tr[p].len+1)tr[cur].fa=q;
      else {
        int nq=++tot;tr[nq]=tr[q],tr[nq].len=tr[p].len+1;
        tr[q].fa=tr[cur].fa=nq;
        for(;p&&tr[p].ch[c]==q;p=tr[p].fa)tr[p].ch[c]=nq;
      }
    }
  }
  il void Clear(){
    for(rg int i=1;i<=tot;i++){
      tr[i].fa=tr[i].len=tr[i].fst=0;
      memset(tr[i].ch,0,sizeof(tr[i].ch));
    }
    tot=lst=1;
  }
};
SAM s1,s2;
struct Node {//權值線段樹
  int l,r,wei;
}tr[N*50];
int tot,rt[N];
int Merge(int u,int v,int l=1,int r=n){
  if(!u||!v)return u+v;
  int k=++tot;tr[k].wei=tr[u].wei+tr[v].wei;
  if(l==r)return k;
  ls=Merge(tr[u].l,tr[v].l,l,nmid);
  rs=Merge(tr[u].r,tr[v].r,nmid+1,r);
  return k;
}
void Modify(int &k,int pos,int l=1,int r=n){
  if(!k)k=++tot;
  tr[k].wei++;
  if(l==r)return;
  if(pos<=nmid)Modify(ls,pos,l,nmid);
  else Modify(rs,pos,nmid+1,r);
}
int Query(int k,int L,int R,int l=1,int r=n){
  if(!k)return 0;
  if(L<=l&&r<=R)return tr[k].wei;
  int res=0;
  if(L<=nmid)res+=Query(ls,L,R,l,nmid);
  if(nmid<R)res+=Query(rs,L,R,nmid+1,r);
  return res;
}
struct Edge {
  int to,nxt;
}e[N<<1];
int hd[N],cnt;
il void ade(int u,int v){
  e[++cnt].to=v,e[cnt].nxt=hd[u],hd[u]=cnt;
}
void DFS(int u){//把子樹合並上來
  for(rg int i=hd[u];i;i=e[i].nxt){
    int v=e[i].to;
    DFS(v),rt[u]=Merge(rt[u],rt[v]);
  }
}
int lcs[N];
il void LCS(int l,int r){//lcs意同上文
  int p=1,len=0;
  for(rg int i=1;i<=m;i++){
    while(1){
      if(s1.tr[p].ch[t[i]-97]){//有這個轉移而且在給定范圍內
        if(Query(rt[s1.tr[p].ch[t[i]-97]],l+len,r)){//注意fst屬於endpos,所以要加上長度
          p=s1.tr[p].ch[t[i]-97],len++;break;
        }
      }
      if(!len)break;
      len--;//注意由於區間限制直接跳到父親可能會遺漏一些情況,有一個點卡了這里
      if(len==s1.tr[s1.tr[p].fa].len)p=s1.tr[p].fa;
    }
    lcs[i]=len;
  }
}
int main(){
  scanf("%s",s+1),n=strlen(s+1);
  for(rg int i=1;i<=n;i++){//記錄每個新加入節點的endpos
    s1.Extend(s[i]-97,i),Modify(rt[s1.lst],i);
  }
  for(rg int i=2;i<=s1.tot;i++)ade(s1.tr[i].fa,i);
  DFS(1),Read(q);//合並
  for(rg int i=1,l,r;i<=q;i++){
    scanf("%s",t+1),m=strlen(t+1),Read(l),Read(r);
    s2.Clear();
    for(rg int j=1;j<=m;j++)s2.Extend(t[j]-97,j);
    LCS(l,r);
    LL ans=0;
    for(rg int j=2;j<=s2.tot;j++){
      ans+=max(0,s2.tr[j].len-max(\//剛剛提到的式子
      s2.tr[s2.tr[j].fa].len,lcs[s2.tr[j].fst]));
    }
    cout<<ans<<endl;
  }
  KafuuChino HotoKokoa
}

*3.子序列自動機

難度:題目過少,暫不明
我們現在想要構造出一個圖,從它上面可以像其他自動機一樣匹配,那么一個最為暴力的方案就是起點連到每個點,每個點向后面的所有點及終點連邊。但是我們發現有很多邊是沒必要連的,例如,我們可以對於每個點的出邊,指向相同字符的只保留第一個。
在具體實現中,我們記錄每種字符出現的位置,對於模式串的每個字符依次二分找到最近的位置即可。
模板題核心代碼:

const int N=100010;
int n,q,m,s[N];
vector<int> pos[N];
int main(){
  Read(n),Read(n),Read(q),Read(m);
  for(rg int i=1;i<=n;i++)Read(s[i]),pos[s[i]].pub(i);
  for(rg int k=1,l;k<=q;k++){
    Read(l);bool ff=1;int lst=0;
    for(rg int i=1;i<=l;i++)Read(s[i]);
    for(rg int i=1;i<=l;i++){
      auto x=upper_bound(pos[s[i]].begin(),pos[s[i]].end(),lst);
      if(x==pos[s[i]].end()){
        ff=0;break;
      }else lst=*x;
    }
    cout<<(ff?"Yes":"No")<<endl;
  }
  KafuuChino HotoKokoa
}

*4.Manacher 和回文自動機

難度:提高~省選

4.0 Manacher

Manacher 是一種用於求解一個字符串的回文子串的神奇算法。
回文串分為奇數長度和偶數長度,但我們發現偶數長度的可以通過添加輔助字符等方式轉化為奇數,所以我們接下來只討論長度為奇數,也就是以某個字符為對稱中心的回文串。
現在我們要求出以每個位置為對稱中心的最長回文串的半徑長度,而朴素的算法是 \(O(n^2)\) 的,我們還是需要考慮盡可能多地利用已經求出的信息,假設我們記錄了右端點最靠右的回文子串,那么如圖:

顯然右端點是單調遞增的,所以復雜度是 \(\mathcal O(n)\)
模板題核心代碼:

const int N=22000010;
int len=1,ans=1,r,mid,p[N];
char c,s[N];
int main(){
  s[0]='#',s[len]='@',c=getchar();
  while(c<'a'||c>'z')c=getchar();
  while(c>='a'&&c<='z'){
    s[++len]=c,s[++len]='@',c=getchar();
  }
  for(rg int i=1;i<=len;i++){
    if(i<=r)p[i]=min(p[(mid<<1)-i],r-i+1);//先更新到最右 
    while(s[i-p[i]]==s[i+p[i]])++p[i];//然后暴力看外面的部分 
    if(i+p[i]>r)r=i+p[i]-1,mid=i;//更新右端點及答案 
    if(p[i]>ans)ans=p[i];
  }
  cout<<ans-1<<endl;
  return 0;
}

4.1 回文自動機

回文自動機(PAM),也可以叫做回文樹,它存儲了一個字符串的所有回文子串。
PAM 也是由狀態之間的轉移邊和后綴鏈接構成,每個狀態代表了一個回文子串,轉移邊是在該節點代表的串的兩端加一個相同的字符(因為是回文串),后綴鏈接指向的是該節點的最長回文后綴。與 Manacher 不同,這里我們將奇串和偶串分開建兩個根會更好考慮,偶根的后綴鏈接是奇根,奇根不需要后綴鏈接(必然不會失配)。

(嫖個圖,這便是 PAM 的基本構造了)
與 SAM 一樣,我們考慮如何增量構造它。
這里的構造方式非常簡單,我們只需要從上一個字符的最長回文子串開始不斷跳后綴鏈接跳到可行的回文子串即可(圖中的 \(A\)),而這個新節點的后綴鏈接應指向這個串的最長回文后綴,我們同樣可以直接跳后綴鏈接跳到 \(B\)

我們有一個定理是說一個字符串的本質不同回文子串個數不超過它的長度,由數學歸納法易證。所以 PAM 的狀態數對應也是線性的,由於每個節點只代表一個不同的回文子串,所以到它的轉移數也是線性的。
容易發現它的節點個數(去除兩個根)就是本質不同的回文子串個數,以某個點結尾的本質不同回文子串個數就是它在 Parent 樹上的深度。
模板題核心代碼:

const int N=500010;
char s[N];int n,k;
struct Node {
  int fa,dep,len,ch[26];
}tr[N];
int tot=1,lst=1;//0為偶根,1為奇根 
il int Find(int pos,int u){//跳后綴鏈接
  while(s[pos-tr[u].len-1]!=s[pos])u=tr[u].fa;
  return u;
}
il void Extend(int pos,int c){//添加字符,過程在上面說得很清楚了
  int cur,p=Find(pos,lst);
  if(!tr[p].ch[c]){
    tr[cur=++tot].len=tr[p].len+2;
    int q=Find(pos,tr[p].fa);tr[cur].fa=tr[q].ch[c];
    tr[cur].dep=tr[tr[cur].fa].dep+1,tr[p].ch[c]=cur;
  }
  lst=tr[p].ch[c];
}
int main(){
  tr[1].len=-1,tr[0].fa=1;//注意初始化
  scanf("%s",s+1),n=strlen(s+1);
  for(rg int i=1;i<=n;i++){
    s[i]=(s[i]-97+k)%26+97;
    Extend(i,s[i]-97);
    cout<<(k=tr[lst].dep)<<" ";
  }
  KafuuChino HotoKokoa
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM