字符串四姐妹

這裏說幾種處理字符串問題的經常使用方法
包括 哈希Hash,KMP,Trie字典樹 和 AC自動機 四種算法

哈希 Hash

哈希算法是經過構造一個哈希函數,將一種數據轉化爲可用變量表示或者是可做數組下標的數數組

哈希函數轉化獲得的數值稱之爲哈希值數據結構

經過哈希算法能夠實現快速匹配與查找函數

字符串 Hash

通常用於尋找一個字符串的匹配串出現的位置或次數優化

思考一下,若是咱們要比較兩個字符串是否相同,應該如何作ui

若是一個字符一個字符的對比,當兩個字符串足夠長時,時間代價太大spa

可是若是把每段字符串處理成一個數值,而後經過比較數值來肯定是否相同,就能夠作到 \(O(1)\) 實現這個操做3d

而處理每段字符串數值的函數,就是上面說的哈希函數指針

對於哈希函數的構造,一般選取兩個數互質的 \(Base\)\(Mod\) \((Base<Mod)\),假設字符串 \(S=s_1s_2s_3……s_n\)code

則咱們能夠定義哈希函數 \(Hash(i)=(s_1\times Base^{i-1} +s_2\times Base^{i-2}+……+s_i\times Base^{0})\mod Mod\)

其中 \(Hash(i)\) 就是前 \(i\) 個字符的哈希值

而後,對於 \(Base\) 的次方咱們也能夠將它存儲到一個數組 \(Get[]\) 中,使得 \(Get[i]=Base^{i}\),這樣能夠實現 \(O(1)\) 查詢

\(Tips:\) 使用 unsigned long long 經過它的天然溢出,能夠省去哈希函數中取模的一步

以上步驟的代碼實現比較容易,以下:

Get[0]=1;
for(int i=1;i<=m;i++) Get[i]=Get[i-1]*base; 
for(int i=1;i<=m;i++) val[i]=val[i-1]*b+(uLL)s1[i];

構造完哈希函數以後,要想求一個字符串某個區間內的哈希值怎麼辦

根據哈希函數的構造方式,不難想到,\(Hash\{l,r\}=Hash(r)-Hash(l-1)\times Base^{r-l+1}\)

換成代碼也就是這樣:

inline int gethash(int l,int r){
    int len=r-l+1;
    return val[r]-val[l-1]*get[len];
}

哈希表

一種高效的數據結構,對於查找的效率幾乎等同於常數時間,同時容易實現

好比說,要存儲一個線性表 \(A=\{k_1,k_2,k_3,k_4,……,k_n\}\)

咱們能夠開一個一維數組,而後依次存儲

但查找時會十分不便,當 \(n\) 足夠大時,即便二分查找也仍需 \(O(\log n)\) 的時間去查找某個元素

咱們能夠開一個數組 \(B[n]\),在存儲時使得 \(B[k_i]=k_i\),能夠將查找的時間降到 \(O(1)\),可是會形成極大的空間浪費

因此能夠對此進行優化,使得 \(B[k_i\!\!\mod 13]\),這樣數組大小隻需開到 \(12\) 便可

不過又會出現另外一個問題,那就是會發生衝突,好比 \(B[1]=B[14]=1\)

所以咱們考慮對數值相同的數記一個鏈表,查找時只查找對應的鏈表便可,時間複雜度取決於實際的鏈表長度,這就是哈希表

多產生的代價僅僅是消耗較多的內存,至關於用空間換取時間

另外一方面,哈希函數也是決定哈希表查找速率的重要因素,由於只要哈希值分佈足夠均勻,查找的複雜度就會盡可能小

對於哈希函數的構造,能夠選擇多種方法,好比除餘法,乘積法,基數轉換法等,只要儘可能規避衝突都是能夠的

爲了規避不一樣的字符串出現相同的哈希值這種衝突,能夠選擇較大的模數,也能夠構造多個哈希函數,比較當全部哈希值相同時才斷定兩個字符串相同

STL 庫中的 \(unordered\_map\) 內部就至關於一個哈希表,其存儲信息爲無序的 \(pair\) 類型,插入速度較低,但查找速度更高,這裏很少贅述

具體代碼實現以下:

void init(){
    tot=0;
    while(top) adj[stk[top--]]=0;
}//初始化哈希表(多組數據時使用)

void insert(int key){
    int h=key%b;//除餘法
    for(int e=adj[h];e;e=nxt[e])
	if(num[e]==key) return;
    if(!adj[h]) stk[++top]=h;
    nxt[++top]=adj[h];adj[h]=tot;
    num[tot]=key;
}//將一個數字 key 插入哈希表

bool query(int key){
    int h=k%b;
    for(int e=adj[h];e;e=nxt[e])
        if(num[e]==key) return true;
    return false;
}//查詢數字 key 是否存在於哈希表中

注:由於我基本不寫哈希表,因此此代碼來自《信息學奧賽一本通提升篇》

\[\]

\[\]

KMP 算法

一種改進的字符串匹配算法,處理字符串匹配問題

由 D.E.Knuth,J.H.Morris 和 V.R.Pratt 同時發現,所以人們稱它爲克努特—莫里斯—普拉特操做,簡稱 KMP 算法

思考一下,若是咱們要比較兩個字符串,從而肯定其中一個字符串是否爲另外一個的子串,應該怎麼作

咱們從兩個字符串的第一個字符開始,逐個比較,當遇到不匹配的字符,就需從新匹配

若是從頭開始從新匹配,可能前面許多字符已經徹底相同,匹配過程時間代價較大

那咱們能夠根據匹配的字符串相同先後綴的長度來肯定指針回溯位置

根據此原理,從上一個與當前字符相同的位置開始匹配,能夠省掉許多無用的匹配過程,從而優化時間複雜度

這個操做的實現是經過處理出一個存儲下一次從頭匹配位置的數組 \(Nxt[]\),當某一位失配時,回溯到它對應的位置開始匹配,此位置以前的字符已確保徹底相同

實例如圖所示:

對於存儲下一次從頭匹配位置的數組的處理,能夠根據遞推來肯定,就是用前面的值推出後面的值

在處理某一位的回溯數組時,考慮一下當前位置是否有可能由前一個位置的狀況所包含的子串獲得,也就是考慮一下是否 \(Nxt[i]=Nxt[Nxt[i-1]]+1\)

而後再造樣例手玩一下就能夠了

具體代碼實現以下:

Nxt[1]=0;Fir=0;
for(int i=1;i<m;i++){
    while(Fir>0&&s2[Fir+1]!=s2[i+1]) Fir=Nxt[Fir];
	if(s2[Fir+1]==s2[i+1]) ++Fir;
	Nxt[i+1]=Fir;
}//處理 Nxt 數組

Fir=0;
for(int i=0;i<n;i++){
    while(Fir>0&&s2[Fir+1]!=s1[i+1]) Fir=Nxt[Fir];
        if(s2[Fir+1]==s1[i+1]) ++Fir;
   	if(Fir==m){ans++;Fir=Nxt[Fir];}
}//統計匹配個數

能夠看到預處理 \(Nxt[]\) 和主程序很像,由於預處理其實也是一個匹配串自我匹配的過程

\[\]

\[\]

Trie字典樹

踹樹

字典樹是一種像字典同樣的樹,可用於處理字符串出現或匹配問題

具體說,就是選定一個根節點,而後以每一個字符做爲一個轉移狀態連出一條邊,造成一個樹狀結構

樹上的節點自己無實際意義

而所謂轉移狀態,就是知足 當前所匹配字符與某一條邊上的字符相同 且 當前處於這條邊的始點時,能夠轉移到這條邊的終點並匹配下一個字符

這也是自動機的原理:知足條件即可轉移

自動機能夠是單向的,也能夠是雙向的

一般題目會給定若干個模式串,每一個模式串由若干個字符相同。建樹時,就以這些模式串來建樹

假設咱們給定了三個模式串

3
abc
de
afg

要將其建成一棵字典樹

具體的建樹方法以下:

先看第一個模式串,因爲樹目前只有一個根節點,因此一路新建下去,從根節點到葉節點每條邊的轉移狀態依次是abc的每一個字符

再看第二個模式串,雖然樹如今有了一條鏈,可是發現沒有以d爲轉移狀態的字符,所以另開一條鏈,也一路新建下去,方法同第一個模式串

最後看第三個模式串,發現有以a爲轉移狀態的邊,就沿此邊向下遍歷到 \(1\) 點,而後發現沒有以f爲轉移狀態的邊,就從當前點另開一條鏈,一路新建下去

最後造成的字典樹模型圖如上圖所示

建樹過程當中要維護的變量有 \(Nxt[i][j]\) 表示一條從節點 \(i\) 連出去的以 \(j\) 爲轉移狀態的邊,\(Flag[i]\) 標記以當前節點結尾的字符串是否存在

對於完成建樹,或者說完成插入以後的操做,其實與插入操做差很少

要求出一個字符串是否存在於字典樹中,就從根節點開始,每次找 轉移狀態與當前位置的字符相同的邊遍歷,若最後能找完就返回 \(true\),若中間失配則返回 \(false\)

若要求一個字符串在字典樹中能找到的最末位置,只需多維護一個計數器,在遍歷的同時統計,直到失配或者所有找完時再返回計數器的值就行了

具體代碼實現以下:

struct Trie{
    int Nxt[maxn][26],cnt;
    bool flag[maxn];
  
    void insert(char *s) {//插入字符串
        int p=0,len=strlen(s+1);
        for(int i=0;i<len;i++){
            int c=s[i]-'a';
            if (!Nxt[p][c]) Nxt[p][c]=++cnt;
            p=Nxt[p][c];
        }
        flag[p] = 1;
    }
  
    bool find(char *s) {//查找字符串是否出現 
        int p=0,len=strlen(s+1);
        for(int i=0;i<len;i++) {
            int c=s[i]-'a';
            if(!Nxt[p][c]) return 0;
            p=Nxt[p][c];
        }
        return flag[p];
    }
}Tri;

\[\]

\[\]

AC自動機

AC自動機,又叫 Aotumaton,是以自動機形式實現字符串查找匹配等其餘操做的算法

由於我的感受本身沒法做出一個更加全面簡介的概述了,因此在這裏放一個 OI-Wiki 概述

用另外一句話說,就是經過在 Trie字典樹 上跑 KMP 來處理問題

失配指針

字面意思,就是一個在字符串失配時轉移所用的指針 \(Fail[]\),相似與 KMP 的 \(Nxt[]\)

KMP 的 \(Nxt[]\) 的原理以及處理方法上面都講過了,這裏就對比一下 \(Fail[]\)\(Nxt[]\)

\(Nxt[]\) 是在失配時回到前一個相同字符的位置,也就是它前面的的最長公共先後綴的結尾位置,但 \(Fail[]\) 是在失配時回到當前前綴狀態可匹配的最長後綴狀態的起始位置

由於 KMP 只對一個模式串作處理,而 AC自動機 是在字典樹上運行,因此要處理多個模式串,因此 AC自動機 作匹配時,同一位置可能會匹配多個模式串

而後說明一下失配指針 \(Fail[]\) 該如何處理:

考慮字典樹中當前的結點 \(u\)\(u\)的父結點是 \(p\)\(p\) 經過字符c的邊指向 \(u\),即 \(Nxt[p][c]\)。假設深度小於 \(p\) 的全部結點的 \(Fail[]\) 指針都已求得

  • 若是 \(Nxt[p][c]\) 存在:則讓 \(u\)\(Fail[]\) 指針指向 \(Nxt[Fail[p]][c]\)。至關於在 \(p\)\(Fail[p]\) 後面加一個字符c ,分別對應 \(u\)\(Fail[u]\)
  • 若是 \(Nxt[p][c]\) 不存在:那麼咱們繼續找到 \(Nxt[Fail[Fail[p]]][c]\) 。重複上一步的判斷過程,一直跳 \(Fail[]\) 指針直到根結點。
  • 若是真的沒有,就讓 \(Fail\) 指針指向根結點。
    而後就處理完了 \(Fail[]\) 指針

咱們能夠簡化它的過程,使得它的時間耗費下降,具體方法就是改變字典樹的結構,使其成爲字典圖,方法以下:

  • 若是 \(Nxt[u][i]\) 存在,咱們就將它的的 \(Fail[]\) 指針賦值爲 \(Nxt[Fail[u]][i]\)
  • 若是不存在,則令 \(Nxt[u][i]\) 指向 \(Nxt[Fail[u]][i]\) 的狀態
    顯然,當在一個模式串後添加新字符時,咱們會由原先模式串轉移到新模式串的後綴,而後捨棄原模式串的部分前綴

由於上文說過 \(Fail[]\) 指針求的是最長後綴狀態,與上面的顯然結論相應

修改字典樹結構後,儘管增長了許多轉移關係,但結點所表明的字符串是不變的,因此這樣處理可節省時間

構建與匹配

以上的處理步驟是出如今建樹過程當中的

建樹,就是先將與根節點所連的點加入一個隊列中 BFS,而後每次取出一個點處理對應的全部點的 \(Fail[]\) 指針

用代碼實現就是這樣:

void Build(){
    for(int i=0;i<26;i++)
	if(Nxt[0][i]) q.push(Nxt[0][i]);
    while(!q.empty()){
        int u=q.front();q.pop();
        for(int i=0;i<26;i++){
       	    if(Nxt[u][i]) Fail[Nxt[u][i]]=Nxt[Fail[u]][i],q.push(Nxt[u][i]);
	    else Nxt[u][i]=Nxt[Fail[u]][i];
	}
    }
}

值得注意的是,入隊的是根節點的兒子,而不是它自己,不然它兒子的 \(Fail[]\) 會標記成它自己

能夠看出,建樹過程實現了兩個操做:構建失配指針和創建字典圖

插入操做與 Trie字典樹 裏的插入無異,只需多統計一個出度,以後計數時會用到

對於計數操做,無非也是遍歷,而後在存在出度的狀況下加上它的出度並更改就完成了

此過程代碼實現以下:

int Query(char *s){
    int u=0,ans=0;
    for(int i=1;s[i];i++){
    	u=Nxt[u][s[i]-'a'];
    	for(int j=u;j&&e[j]!=-1;j=Fail[j])
            ans+=e[j],e[j]=-1;
    }
    return ans;
}

固然,具體要進行什麼操做也要看實際狀況,這種計數只是最簡單的一種,有的也須要維護其餘變量

最後,若是還不理解其中的某個過程,能夠參考 OI-Wiki 對應文章裏的例子,經過圖示的方法進一步理解 Link

例題

Power Strings
Seek the Name, Seek the Fame
OKR-Periods of Words
A Horrible Poem
L語言
因而他錯誤的點名開始了
彷佛在夢中見過的樣子
AC自動機模板(簡單)
AC自動機模板(增強)

寫在最後

這四種知識點涵蓋的內容其實遠不止這些,還有一些更復雜的操做,好比 可持久化字典樹、可持久化KMP 等等,因爲本人能力問題就不寫了

這篇文章是我花了三天多時間一點一點寫出來的,是爲了寫給我和其餘像我同樣在這方面水平較低的人的,因此我修改了不少遍,但仍是可能會有一些錯誤,還請多多包涵

至於這篇博客的名字,是同機房的某大佬替我想出來的,爲了致敬同機房的另外一個隊爺(

但願這篇文章能讓別人有收穫吧

相關文章
相關標籤/搜索