1. 程式人生 > 其它 >淺談字串演算法

淺談字串演算法

Luogu P3805 manacher演算法

馬拉車演算法是求最長迴文串的演算法,其核心在於減少了冗餘的重複計算,利用當前已知的資料儘可能的推出之後的資料,從而達到線性的複雜度。

我認為這個演算法的核心之處是充分利用了迴文串的對稱性。

首先是處理迴文串的一個小技巧,對於奇偶迴文串,我們只需要在相鄰的兩個字元之間加上一個 '#' 字元,那麼無論是奇串還是偶串,都會變成奇串(自行模擬)。

對於你目前已經求出的迴文串 \([1,mx]\),定義 \(mid\) 為這個串的迴文中心,p[i] 陣列表示以 \(i\) 為迴文中心的最長的迴文半徑。

首先給出一個結論,以 \(i\) 為迴文中心的字串的最長迴文串的長度會等於 p[i]-1

,因為要減去一個 '#'。

那麼怎麼進行 \(manacher\) 呢?

對於一個 \(i(mid<i<mx)\),這時根據對稱性,\(i\) 的對稱點 \(i'\) 點為 \(mid*2-i\),那麼以 \(i\) 為迴文中心的最長迴文半徑一定不會小於以 \(i'\) 點為迴文中心的最長迴文半徑,所以我們可以先讓 p[i]=min(p[mid*2-i],mx-i)(注意,迴文半徑的長度不能超過 \(mx-i\)),然後在暴力一個一個判斷出 \(i\) 超過 \(mx\) 範圍的迴文串,並且更新 \(mx\)\(mid\),反覆如此,終可求矣。

如果 \(i(mx<i)\)

呢?就直接考慮暴力求了,然後再更新 \(mx\)\(mid\)

#include<bits/stdc++.h>
using namespace std;
const int N=3e7+5;
int cnt,p[N],ans;
char s[N];
void read(char *str){
  char ch=getchar();
  str[0]='@';
  while(ch>='a'&&ch<='z') str[++cnt]='#',str[++cnt]=ch,ch=getchar();
  str[++cnt]='#';
}
int main(){
  read(s);
  int mid=1,mx=1;
  for(int i=1;i<cnt;i++){
    if(i<mx){
      p[i]=min(p[(mid<<1)-i],mx-i);
    }
    else p[i]=1;
    while(s[i-p[i]]==s[i+p[i]]) p[i]++;
    if(mx<i+p[i]){
      mx=i+p[i];
      mid=i;
    }
    ans=max(ans,p[i]-1);
  }
  printf("%d",ans);
  return 0;
}

Luogu P1368 最小表示法

最小表示法就是對於一個字串,求她的迴圈同構的字串的最小的那個。

如字串 "gfedcba",她的迴圈同構的字串為:

"agfedcb""bagfedc""cbagfed""dcbagfe""edcbagf""fedcbag"

那麼很顯然,對於她來說,她的最小表示法為 "agfedcb"

考慮怎麼來求一個字串的最小表示法

注:這裡存字串從 \(0\) 開始存。

我們定義兩個指標 \(i\)\(j\),分別指向字串 \(s\) 中的兩個不同的位置(指向位置相同時就無意義了),定義當前分別從 \(i\)\(j\) 開始已經匹配了 \(k\) 個字元(從 \(i\)\(j\) 開始,包括 \(i\)\(j\) 本身的後 \(k\) 個字元相等)。

那麼對於 s[(i+k)%n]s[(j+k)%n]

  • 如果相等那麼匹配數 k++

  • 如果 s[(i+k)%n]>s[(j+k)%n],則說明 \(i\sim i+k\) 之間的字元都不會作為最小表示法的開頭,所以 \(i\) 直接跳到 \(i+k+1\)

  • 如果 s[(i+k)%n]<s[(j+k)%n],同理可得 \(j\) 跳到 \(j+k+1\)

最後,\(i\)\(j\) 位置最小的點就是最小表示法的開頭點,記為 \(ans\)。最後輸出 s[(i+ans)%n] 就好了。

有一點要注意的,就是 \(i\) 不能等於 \(j\),因為要指向不同的位置,否則將會一直匹配,所以相等時讓 i++ 就好了。

#include<bits/stdc++.h>
using namespace std;
inline int read(){
  int ans=0,f=1;char ch=getchar();
  while(!isdigit(ch)){if(ch=='-') f=-f;ch=getchar();}
  while(isdigit(ch)){ans=(ans<<3)+(ans<<1)+ch-48;ch=getchar();}
  return ans*f;
}
const int N=3e5+5;
int n,ans;
int s1[N];
int minshow(){
  int i=0,j=1,k=0;
  while(i<n&&j<n&&k<n){
    if(s1[(i+k)%n]==s1[(j+k)%n]) k++;
    else{
      if(s1[(i+k)%n]>s1[(j+k)%n]) i+=k+1;
      else j+=k+1;
      if(i==j) i++;
      k=0;
    }
  }
  return min(i,j);
}
int main(){
  n=read();
  for(int i=0;i<n;i++){
    s1[i]=read();
  }
  ans=minshow();
  for(int i=0;i<n;i++){
    printf("%d ",s1[(i+ans)%n]);
  }
  return 0;
}

Luogu P3375 KMP字串匹配

激動人心的 看毛片 演算法來了

KMP演算法核心處也是減少重複冗餘的計算。

樸素演算法就是雙指標一個一個匹配,當有一個失配後,就將模式串從頭開始匹配。複雜度可以被卡到 \(O (nm)\)

KMP失配後不是從頭開始,而是利用一個 kmp[j] 陣列,記錄 \(j+1\) 失配後 \(j\) 應該從哪裡開始重新匹配 。並且很容易發現,文字串的指標只增不減,模式串的指標會減少也會增加(因為每次失配後只用改變模式串指標位置就好了)。

上圖:

此時 a[i]!=b[j+1] 失配。

於是我們將 \(j\)\(4\) 移動到 \(2\) 的位置,重新開始匹配,就避免了從頭開始匹配。

我們先假設我們已經求出了 kmp 陣列,那麼對於這個問題就很簡單了。

我們只用雙指標依次匹配,當失配後令模式串指標 j=kmp[j] ,再與文字串重新匹配。

程式碼如下:

j=0;
  for(int i=1;i<=lena;i++){
    while(j&&a[i]!=b[j+1]) j=kmp[j];//如果不匹配,模式串就往前跳
    if(a[i]==b[j+1]) j++;//如果相等就匹配下一位
    if(j==lenb){//找到相同的串後輸出起始位置
      printf("%d\n",i-lenb+1);
      j=kmp[j];//跳回去
    }
  }

因為要讓指標跳回的儘可能少,所以我們就要讓 kmp[j] 陣列存的可以跳回的點儘可能大,所以我們就可以讓 kmp[j] 陣列為從 \(1 \sim j-1\) 位中,字首和字尾相等的部分的最長長度是多少。這樣就可以讓指標每次跳回的儘可以少。

那麼怎麼求出 kmp[j] 陣列呢?

其實就是用模式串對模式串自己做KMP操作

引用 Matrix67 Blog 中的巧妙證明就是

程式碼如下:

j=0;
  for(int i=2;i<=lenb;i++){
    while(j&&b[i]!=b[j+1]) j=kmp[j];
    if(b[i]==b[j+1]) j++;
    kmp[i]=j;
  }

複雜度如何證明?

引用 \(rqy\) 大佬的證明

每次位置指標 \(i++\) 時,失配指標 \(j\) 至多增加一次,所以 \(j\) 至多增加 \(len\) 次,從而至多減少 \(len\) 次,所以就是 \(\Theta(len_N + len_M) = \Theta(N + M)\) 的。

至此,我們結束了看毛片演算法

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
char a[N],b[N];
int lena,lenb;
int kmp[N],j;
int main(){
  cin>>a+1;
  cin>>b+1;
  lena=strlen(a+1),lenb=strlen(b+1);
  j=0;
  for(int i=2;i<=lenb;i++){
    while(j&&b[i]!=b[j+1]) j=kmp[j];
    if(b[i]==b[j+1]) j++;
    kmp[i]=j;
  }
  j=0;
  for(int i=1;i<=lena;i++){
    while(j&&a[i]!=b[j+1]) j=kmp[j];
    if(a[i]==b[j+1]) j++;
    if(j==lenb){
      printf("%d\n",i-lenb+1);
      j=kmp[j];
    }
  }
  for(int i=1;i<=lenb;i++) printf("%d ",kmp[i]);
  return 0;
}

Luogu P3808 AC自動機(簡單版)

AC自動機,全稱"Aho_Corasick_Automaton",用於求解多個模式串匹配一個文字串的問題。聯想到之前的學過的演算法,發現有一個KMP演算法(看毛片)可以求解一個模式串匹配一個文字串的問題,那麼利用OI的基本思想之一的 x套x 思想,是不是可以考慮把兩個演算法結合起來,以達到一種神奇的效果呢?

我們發現,對於多個模式串,我們可以建一棵 \(trie\) 樹方便求出他們的公共字首,然後就可以在 \(trie\) 樹上面看毛片KMP了!

眾所周知,KMP最重要的是失配陣列 fail,那我們怎麼在 \(trie\) 樹上求 fail 陣列呢?

很簡單,只用跑一邊 \(bfs\) 就好了,邏輯很明瞭看看程式碼就會了,不再贅述。注意優化!

void build(){
  for(int i=0;i<26;i++) if(trie[0][i]) fail[trie[0][i]]=0,q.push(trie[0][i]);
  while(!q.empty()){
    int u=q.front();q.pop();
    for(int i=0;i<26;i++){
      if(trie[u][i]) fail[trie[u][i]]=trie[fail[u]][i],q.push(trie[u][i]);
      else trie[u][i]=trie[fail[u]][i];//優化,沒有這條邊的時候早晚要跳回去,還不如現在跳
    }
  }
}

有了 \(fail\) 陣列,那麼怎麼匹配呢?

也很簡單,我們只用遍歷,列舉一條當前文字串字元連向的邊,然後把這些點的 \(fail\) 全部跑一邊,然後答案直接加上結束標記 \(end\)。注意不要重複計算,用完的標記要丟掉!

int query(char *str){
  int len=strlen(str),p=0,res=0;
  for(int i=0;i<len;i++){
    p=trie[p][str[i]-'a'];
    for(int t=p;t&&end[t]!=-1;t=fail[t]) res+=end[t],end[t]=-1;
  }
  return res;
}

最後,注意常數優化,這題別用藍書上面的程式碼,一直卡常,吸氧吸中毒了都沒用。

程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int n;
int trie[N][26],end[N],fail[N],tot;
queue<int> q;
void ins(char *str){
  int len=strlen(str),p=0;
  for(int i=0;i<len;i++){
    int ch=str[i]-'a';
    if(!trie[p][ch]) trie[p][ch]=++tot;
    p=trie[p][ch];
  }
  end[p]++;
}
void build(){
  for(int i=0;i<26;i++) if(trie[0][i]) fail[trie[0][i]]=0,q.push(trie[0][i]);
  while(!q.empty()){
    int u=q.front();q.pop();
    for(int i=0;i<26;i++){
      if(trie[u][i]) fail[trie[u][i]]=trie[fail[u]][i],q.push(trie[u][i]);
      else trie[u][i]=trie[fail[u]][i];
    }
  }
}
int query(char *str){
  int len=strlen(str),p=0,res=0;
  for(int i=0;i<len;i++){
    p=trie[p][str[i]-'a'];
    for(int t=p;t&&end[t]!=-1;t=fail[t]) res+=end[t],end[t]=-1;
  }
  return res;
}
char tmp[N];
int main(){
  cin>>n;
  for(int i=1;i<=n;i++) cin>>tmp,ins(tmp);
  build();
  cin>>tmp;
  printf("%d",query(tmp));
  return 0;
}

Luogu P5410 擴充套件KMP(Z函式)

擴充套件看毛片

其實我覺得有點像馬拉車思想和KMP的結合,但這三個演算法都是利用減少冗餘重複計算來達到線性的。

相較於KMP演算法,exKMP演算法求的是模式串與文字串的字尾的LCP(最長公共字首)

我們令 extend 陣列為文字串 \(S\) 的字尾與模式串 \(T\) 的LCP,z 陣列為模式串 \(T\) 的字尾與模式串 \(T\) 的LCP。

怎麼來求呢?先畫圖。

我們假設當前已經求出了 \(1 \sim i\)extend 陣列和 z 陣列,在匹配過程中記最遠的匹配距離為 \(r\),即 \(r=max(i+extend[i]-1)\),能夠取到最大值 \(r\)\(i\) 我們記為 \(l\)

  • 第一種情況,當 \(i+L\)\(r\) 左邊時:

z[i-l+1] 代表著 \(T[i-l+2\sim m]\) 中與 \(T[1 \sim m]\) 的最長公共字首,我們設為 \(L\)

所以 \(T[1\sim L]=T[i-l+2\sim i-l+2+L]\),圖中藍色綠色部分長度字元都相等(下文簡稱“相等”)。

根據 \(l\)\(r\) 的定義,可知 \(S[l\sim r]=T[1\sim r-l+1]\),所以 \(S[i+1\sim i+L]=T[i-l+2\sim i-l+2+L]\),圖中紅色藍色部分相等。

綜上圖中紅綠藍三個部分都相等

因為 \(S[i\sim n]\)\(T[1\sim m]\) 的最長公共字首為 extend[i],所以 extend[i]=L

  • 第二種情況,當 \(i+L\)\(r\) 右邊時:

我們令 extend[i]=r-i+1 靠暴力跑出剩下的答案。

那麼從哪裡開始跑暴力呢?

就從當前的 \(i\) 開始跑,與模式串一一比對。

最後別忘了更新一下 \(l\)\(r\)

對於 \(z\) 陣列就是重複一遍這個演算法的事情了。

因為每個點最多跑一次,所以做一遍的複雜度是線性的。

zextend 分別做了一遍,所以總的時間複雜度為\(\Theta(n+m)\)

程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N=2e7+5;
char s[N],t[N];
int lens,lent;
int z[N],extend[N];
void doZ(){
  z[1]=lent;
  for(int i=2,l=0,r=0;i<=lent;i++){
    if(i<=r) z[i]=min(z[i-l+1],r-i+1);
    while(i+z[i]<=lent&&t[i+z[i]]==t[z[i]+1]) ++z[i];
    if(i+z[i]-1>r) l=i,r=i+z[i]-1;
  }
}
void exkmp(){
  doZ();
  for(int i=1,l=0,r=0;i<=lens;i++){
    if(i<=r) extend[i]=min(z[i-l+1],r-i+1);
    while(i+extend[i]<=lens&&s[i+extend[i]]==t[extend[i]+1]) ++extend[i];
    if(i+extend[i]-1>r) l=i,r=i+extend[i]-1;
  }
}
int main(){
  scanf("%s%s",s+1,t+1);
  lens=strlen(s+1);lent=strlen(t+1);
  exkmp();
  long long ans1=0,ans2=0;
  for(int i=1;i<=lent;i++) ans1^=1ll*i*(z[i]+1);
  for(int i=1;i<=lens;i++) ans2^=1ll*i*(extend[i]+1);
  printf("%lld\n%lld",ans1,ans2);
  return 0;
}

還有一點就是為什麼跑暴力不分情況,寫在迴圈外面,因為對於上述的情況一,不可能有 \(T[L+1]=S[i+L+1]\) 了。因為如果存在 \(T[L+1]=S[i+L+1]\),他們的最長公共字首就是 \(L+1\) 了,\(L\) 就不是最長公共字首了,就不滿足 \(L\) 的定義了。所以只會在情況二的時候跑暴力,這也是演算法的巧妙精簡美啊!