五一機房的專題是字符串問題,自己剛好也在學習字符串匹配,於是就打算寫篇關於最近學的幾個經典的算法的 \(Blog\) ~
嗯,還是先甩定義
模式匹配
是數據結構中字符串的一種基本運算,給定一個子串,要求在某個字符串中找出與該子串相同的所有子串,這就是模式匹配。——參考自 \(Baidupedia\)
熟悉幾個名詞
- 模式串:給定的子串 \(P\)
- 匹配串:待查找的字符串 \(T\)
- 失配:按位匹配的過程中,某一位不匹配時稱當前位置失配
接下來會介紹兩種算法
- \(KMP\):解決單個模式串的匹配問題的一種算法
- \(AC\) 自動機:解決多個模式串的匹配問題的一種算法
\(KMP\)
甩定義
\(KMP\) \((Knuth\)-\(Morris\)-\(Pratt)\)
即克努斯-莫里斯-普拉特算法,該算法可在一個主文本字符串 內查找一個詞的出現位置。此算法通過運用對這個詞在不匹配時本身就包含足夠的信息來確定下一個匹配將在哪里開始的發現,從而避免重新檢查先前匹配的字符。
這個算法是由高德納和沃恩·普拉特在1974年構思,同年詹姆斯 \(·H·\) 莫里斯也獨立地設計出該算法,最終由三人於 \(1977\) 年聯合發表。——參考自 \(Wikipedia\)
好了,舉栗子時間到~
設模式串 \(P=\) " \(abcabd\) " ,匹配串 \(T=\) " \(abcabcabd\) " ,\(i\) 和 \(j\) 分別表示當前匹配完的模式串 \(P\) ,匹配串 \(T\) 的指針(即字符串下標,個人習慣,從 \(1\) 開始算起),初始時,\(i=1\) ,\(j=1\)
如圖,當 \(i=5\) ,\(j=5\) 時, \(T[1,5]\) 和 \(P[1,5]\) 恰完全匹配,現在先讓 \(i\) 往后移一位,即 \(i=6\) ,顯然 \(T[i] \neq T[j+1]\) ,\(j\) 后移一位會造成失配。
如果我們暴力去匹配,此時應該將 \(i\) ,\(j\) 置為此次匹配的初值,再讓 \(i\) 后移一位,重新開始匹配,這樣的復雜度讓人難以接受。
有沒有什么好的辦法呢?我們發現,可以保持 \(i\) 不變, 讓 \(j\) 直接跳轉至 \(2\) ,理由很簡單:對於 模式串 \(P\) ,位置 \(j=5\) 的前綴 \(P[1,4]\) 有公共的前后綴 \(P[1,2]\) ,\(P[2,4]\) ,\(j\) 跳轉完后,就可以繼續看 \(j\) 后移一位能否與 \(i\) 匹配,因為跳轉后的位置應該比 \(j\) 要靠前,特別地,只有一個字符時前后綴完全覆蓋,跳轉位置應為 \(0\) 。
這里,\(j\) 的跳轉位置只與模式串 \(P\) 有關,我們完全可以預處理出模式串 \(P\) 每一個位置的下一位失配后接下來的跳轉位置。處理出的跳轉數組又稱失配數組,一般用 \(next\) 表示,我的代碼中簡稱為 \(nxt\) 。
- \(nxt[i]\) :滿足 \(P[1,x]=P[i-x+1,i]\) 的最大 \(x\)
拿上面的栗子算一下,\(next[1,6]=\{0,0,0,1,2,0\}\)
\(PS\):實際應用中,因為跳轉后還是要后移,有一些做法是選擇先后移再跳轉,並定義 \(next[i]\):滿足 \(P[1,x]=P[i-x,i-1]\) 的最大 \(x\) ,比如 \(kuangbin\) 的板子。 一開始我兩個都看了然后傻傻分不清
下面給出關鍵的代碼
代碼中,\(p\) 就是指針 \(j\) ,\(s_1\) 就是匹配串,長度為 \(len_1\) ,\(s_2\) 就是模式串,長度為 \(len_2\) 。
Code :模式匹配的過程
void mp_count(void){
int p=0; //指針初始化為0
for(int i=1;i<=len1;i++){
while(p>0&&s1[i]!=s2[p+1]) p=nxt[p]; //下一位失配且p能往前跳轉時則跳轉
if(s1[i]==s2[p+1]) p++; //當前位置匹配成功,p后移
if(p==len2){ //匹配成功
printf("%d\n",i-len2+1); //輸出位置
p=nxt[p]; //繼續匹配
}
}
}
Code :\(next\) 數組的預處理
void nxt_init(void){ //實際上就是模式串自我匹配的過程
int p=0; //指針初始化為0
/*
nxt[1]=0,表示指針無法更往前跳轉,下面我們從nxt[2]開始處理
*/
for(int i=2;i<=len2;i++){
while(p>0&&s2[i]!=s2[p+1]) p=nxt[p]; //當下一位失配且能往前跳轉時則跳轉
nxt[i]=s2[i]==s2[p+1]?++p:p; //當前位置匹配成功,p后移
}
}
結合代碼模擬一下應該能弄明白吧~
貼一個板子題鏈接+代碼:P3375 【模板】KMP字符串匹配
Code
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+5;
const int M=1e6+5;
char s1[N];
char s2[M];
int len1,len2;
int nxt[M];
void nxt_init(void);
void mp_count(void);
int main(void){
scanf("%s%s",s1+1,s2+1);
len1=strlen(s1+1);
len2=strlen(s2+1);
nxt_init();
mp_count();
for(int i=1;i<=len2;i++) printf("%d ",nxt[i]);
putchar('\n');
return 0;
}
void nxt_init(void){
int p=0;
for(int i=2;i<=len2;i++){
while(p>0&&s2[i]!=s2[p+1]) p=nxt[p];
nxt[i]=s2[i]==s2[p+1]?++p:p;
}
}
void mp_count(void){
int p=0;
for(int i=1;i<=len1;i++){
while(p>0&&s1[i]!=s2[p+1]) p=nxt[p];
if(s1[i]==s2[p+1]) p++;
if(p==len2){
printf("%d\n",i-len2+1);
p=nxt[p];
}
}
}
到這里,我們的 \(KMP\) 就算完...才怪了~
上述的 \(KMP\) 算法,其實是 \(MP\) 算法。什么?這不是 \(KMP\) ?那講它干嘛?其實,所謂的 \(KMP\) 和 \(MP\) 相比,區別只有在 \(next\) 數組的處理上做了一些優化。至於為什么現在很多都直接管這個叫 \(KMP\) ... 我也不知道呢
如圖,當 \(P[j+1]=P[next[j]+1]\) 時,跳轉再后移仍然會出現失配,實際匹配過程中這里會繼續往前跳轉,這一步完全可以在預處理 \(next\) 數組時就完成。
Code :優化后 \(next\) 數組的預處理
void nxt_init(void){
int p=0;
for(int i=2;i<=len2;i++){
while(p>0&&s2[i]!=s2[p+1]) p=nxt[p];
if(s2[i]==s2[p+1]) p++;
nxt[i]=(i<len2&&s2[i+1]==s2[p+1])?nxt[p]:p; //這里多加一個后面字符的是否相同的判斷
}
}
這里 \(next\) 數組的含義其實就發生了改變,在原來的基礎上加上了一個 \(P[j+1] \neq P[next[j]+1]\) 的前提。 這里給的板子題要求輸出優化前的 \(next\) 數組,不要交錯哦
設模式串和匹配串的長度分別為 \(n\) ,\(m\)
時間復雜度:\(O(n+m)\)
好了,這次是真的結束了~
參考資料:
- Matrix67大神的《KMP算法詳解》 前面 \(MP\) 算法的部分基本是一樣的
- 板子題上皎月dalao的高贊題解
其實兩個都差不多(講得都比我好QAQ
來看下一個qwq
\(AC\) 自動機
\(AC\) 自動機 \((Aho\)-\(Corasick\) \(automaton)\)
是由 \(Alfred\) \(V.\) \(Aho\) 和 \(Margaret\) \(J.\) \(Corasick\) 發明的字符串搜索算法,用於在輸入的一串字符串中匹配有限組“字典”中的子串。它與普通字符串匹配的不同點在於同時與所有字典串進行匹配。算法均攤情況下具有近似於線性的時間復雜度,約為字符串的長度加所有匹配的數量。然而由於需要找到所有匹配數,如果每個子串互相匹配(如字典為 \(A\) ,\(AA\) ,\(AAA\) ,\(AAAA\) ,輸入的字符串為 \(AAAA\)),算法的時間復雜度會近似於匹配的二次函數。
該算法主要依靠構造一個有限狀態機(類似於在一個 \(Trie\) 樹中添加失配指針)來實現。這些額外的失配指針允許在查找字符串失敗時進行回退(例如設 \(Trie\) 樹的單詞 \(cat\) 匹配失敗,但是在 \(Trie\) 樹中存在另一個單詞 \(cart\),失配指針就會指向前綴 \(ca\)),轉向某前綴的其他分支,免於重復匹配前綴,提高算法效率。——參考自 \(Wikipedia\)
定義看着很頭大?沒關系,實際上學會 \(KMP\) 之后,學習 \(AC\) 自動機就相對容易了~
前置知識
- \(Trie\) 樹
因為比較簡單,所以就講一下下吧~
甩定義~
\(Trie\) 樹
又稱前綴樹或字典樹,是一種有序樹,用於保存關聯數組,其中的鍵通常是字符串。與二叉查找樹不同,鍵不是直接保存在節點中,而是由節點在樹中的位置決定。一個節點的所有子孫都有相同的前綴,也就是這個節點對應的字符串,而根節點對應空字符串。一般情況下,不是所有的節點都有對應的值,只有葉子節點和部分內部節點所對應的鍵才有相關的值。——參考自 \(Wikipedia\)
簡單來說, \(Trie\) 樹就是一棵樹 廢話 ,上面能保存多個字符串,構建方法如下:
- 選擇一個字符串
- 從根節點開始帕努單當前節點的子節點是否含有下一個字符
- 如果含有則訪問
- 否則,新建這個節點
- 回到第二步
- 所有字符串都被選擇后結束
舉個栗子
如果字符串分別是 " \(TLE\) " ," \(LFT\) " ," \(LTL\) " ,則構建完的 \(Trie\) 樹長這樣
構建完這顆樹之后有什么用呢?我們用這棵樹代替模式串和匹配串 \(T\) 進行匹配,和 \(KMP\) 一樣,我們用指針完成這個過程,只不過原來模式串的指針變成了 \(Trie\) 樹上的指針。考慮一個問題,當匹配不能繼續進行(指針移動到了葉節點)或失配時 \(Trie\) 樹上的指針該移到哪?回想一下 \(KMP\) 中的 \(next\) 數組,我們知道指針移到的節點 \(p\) ,應滿足從根節點到當前節點 \(p\) (含 \(p\) ,不含根節點)的字符形成的字符串與當前節點的某一祖先到當前節點(含祖先節點與當前節點)字符形成的字符串相同,且長度最長,相當於最長的相同“前后綴”
我們把當前節點應指向的節點 \(p\) 稱為失配指針,習慣上也叫 \(fail\) 指針,代碼中也用
\(fail\) 指代。
- \(fail[i]\) : 節點 \(i\) 的失配指針 \(p\) ,滿足從根節點到 \(p\) (含 \(p\) ,不含根節點)的字符形成的字符串與 \(i\) 的某一祖先到 \(i\) (含祖先節點與節點 \(i\) )的字符形成的字符串相同,且長度最長
構建完失配指針的 \(Trie\) 樹長這樣
如何構建 \(fail[i]\) 指針呢?假設節點 \(i\) 的祖先的 \(fail[i]\) 指針已經構造完成,那么只需沿着祖先的 \(fail[i]\) 指針一路往前,找到第一個子節點中含有節點 \(i\) 的字符的節點即可,顯然這是一個 \(BFS\) 的過程。
下面給出代碼,節點編號從 \(0\) 開始,節點 \(i\) 的字符為 \(ch\) (這里假設都是小寫)的子節點為 \(son[i][ch-ascii(a)]\) 。
Code :構建 \(fail\) 指針
void fail_init(void){
//根節點的子節點的fail指針顯然就是根節點,這里根節點的編號為0
for(int i=0;i<26;i++) //先將子節點都加入隊列
if(son[0][i]>0) que.push(son[0][i]);
while(que.empty()==false){
int tmp=que.front();
que.pop();
/*
子節點x的fail指針即
當前節點的fail指針y中字符與x節點字符ch相同的“子節點”所指向的節點z
這個節點z有可能是當前這個“子節點”——當節點y確實有代表ch的子節點時
也有可能是沿着節點y的fail指針繼續往上的某一節點的字符與ch相同的子節點
為了達到這個目的,我們每遍歷到一個節點
不僅要處理出存在的子節點的fail指針
還要處理出代表其他字符的“子節點”與上一層的聯系
*/
for(int i=0;i<26;i++)
if(son[tmp][i]>0){
fail[son[tmp][i]]=son[fail[tmp]][i];
que.push(son[tmp][i]);
}else son[tmp][i]=son[fail[tmp]][i];
}
}
構建完 \(fail\) 指針后,匹配的過程就簡單了。
舉個栗子,求有多少個模式串在匹配串中出現了幾次。我們讓每個節點記錄下以這個節點為結尾的模式串個數(一般為1,要求重復的模式串計算多次時可以大於1),用 \(ec[i]\) 表示,匹配串為 \(t\) ,就會有如下代碼。
Code :求模式串在匹配串中總的出現次數
int query(void){
int len=strlen(s+1);
int pos=0,ans=0;
for(int i=1;i<=len;i++){
pos=son[pos][s[i]-'a'];
for(int j=pos;j>0&&ec[j]!=-1;j=fail[j]){
ans+=ec[j];
ec[j]=-1; //已經計算過的標記為負一防止重復計算
}
}
return ans;
}
再舉個栗子,求每個模式串在匹配串中出現了幾次。我們讓每個節點記錄下以這個節點為結尾的模式串的編號,用 \(ed[i]\) 表示, 為了簡化問題就假設模式串是無重復(有重復其實就把 \(ed\) 用 \(vector\) 之類的改成鏈表就行了),同樣,匹配串為 \(t\) ,模式串 \(i\) 的出現次數為 \(ans[i]\) ,有如下代碼。
Code :求每個模式串在匹配串中出現次數
void query(void){
int len=strlen(t+1);
int pos=0;
for(int i=1;i<=len;i++){
pos=son[pos][t[i]-'a'];
for(int j=pos;j>0;j=fail[j]) ans[ed[j]].sec++;
}
}
上面兩個栗子其實就是兩個板子題~
貼下鏈接+代碼:
P3808 【模板】AC自動機(簡單版)
Code
#include <bits/stdc++.h>
using namespace std;
const int L=1e6+5;
int n;
char s[L];
int cnt,ec[L],fail[L],son[L][26];
queue<int> que;
void build(void);
void fail_init(void);
int query(void);
int main(void){
scanf("%d",&n);
for(int i=0;i<n;i++){
scanf("%s",s+1);
build();
}
fail_init();
scanf("%s",s+1);
printf("%d\n",query());
return 0;
}
void build(void){
int len=strlen(s+1);
int pos=0;
for(int i=1;i<=len;i++){
int tmp=s[i]-'a';
if(son[pos][tmp]==0) son[pos][tmp]=++cnt;
pos=son[pos][tmp];
}
ec[pos]++;
}
void fail_init(void){
for(int i=0;i<26;i++)
if(son[0][i]>0) que.push(son[0][i]);
while(que.empty()==false){
int tmp=que.front();
que.pop();
for(int i=0;i<26;i++)
if(son[tmp][i]>0){
fail[son[tmp][i]]=son[fail[tmp]][i];
que.push(son[tmp][i]);
}else son[tmp][i]=son[fail[tmp]][i];
}
}
int query(void){
int len=strlen(s+1);
int pos=0,ans=0;
for(int i=1;i<=len;i++){
pos=son[pos][s[i]-'a'];
for(int j=pos;j>0&&ec[j]!=-1;j=fail[j]){
ans+=ec[j];
ec[j]=-1;
}
}
return ans;
}
Code
#include <bits/stdc++.h>
using namespace std;
#define fir first
#define sec second
#define mp make_pair
typedef pair<int,int> pii;
const int N=155;
const int S=75;
const int L=1e6+5;
int n;
char s[N][S];
char t[L];
pii ans[N];
int cnt,ed[L],fail[L],son[L][26];
queue<int> que;
void reset(void);
void build(int num);
void fail_init(void);
void query(void);
bool cmp(const pii &a,const pii &b);
int main(void){
while(true){
scanf("%d",&n);
if(n==0) break;
reset();
for(int i=1;i<=n;i++){
ans[i]=mp(i,0);
scanf("%s",s[i]+1);
build(i);
}
fail_init();
scanf("%s",t+1);
query();
sort(ans+1,ans+n+1,cmp);
printf("%d\n",ans[1].sec);
for(int i=1;i<=n&&ans[i].sec==ans[1].sec;i++) printf("%s\n",s[ans[i].fir]+1);
}
return 0;
}
inline void reset(void){
memset(ans,0,sizeof(ans));
cnt=0;
memset(ed,0,sizeof(ed));
memset(fail,0,sizeof(fail));
memset(son,0,sizeof(son));
}
void build(int num){
int len=strlen(s[num]+1);
int pos=0;
for(int i=1;i<=len;i++){
int tmp=s[num][i]-'a';
if(son[pos][tmp]==0) son[pos][tmp]=++cnt;
pos=son[pos][tmp];
}
ed[pos]=num;
}
void fail_init(void){
for(int i=0;i<26;i++)
if(son[0][i]>0) que.push(son[0][i]);
while(que.empty()==false){
int tmp=que.front();
que.pop();
for(int i=0;i<26;i++)
if(son[tmp][i]>0){
fail[son[tmp][i]]=son[fail[tmp]][i];
que.push(son[tmp][i]);
}else son[tmp][i]=son[fail[tmp]][i];
}
}
void query(void){
int len=strlen(t+1);
int pos=0;
for(int i=1;i<=len;i++){
pos=son[pos][t[i]-'a'];
for(int j=pos;j>0;j=fail[j]) ans[ed[j]].sec++;
}
}
inline bool cmp(const pii &a,const pii &b){
return a.sec==b.sec?a.fir<b.fir:a.sec>b.sec;
}
設模式串(總和)和匹配串的長度分別為 \(n\) ,\(m\)
時間復雜度:\(O(n+m)\)
講完啦~
學習字符串還會遇到很多神奇 毒瘤 的算法 比如后綴數組 ,有機會下次再分享吧~
這個月要去打人生第一場正式比賽了qwq, CCPC 組隊賽!
希望不要零 負 貢獻orz
希望不要拖dalao隊友的后腿orz
希望見識一下,
我未見過的風景。