【串匹配算法】BF和KMP完全詳解+手動求next數組

關於字符串的模式匹配問題,有許多算法,這裏介紹最簡單著名的兩個。

1. 暴力求解

一個簡單粗暴的方法就是,從主串的第一個字符和模式串的第一個字符開始,一個一個字符匹配。一旦失配,就從該次匹配開始時匹配的主串字符的下一個字符開始重新匹配

int BruteForce(const string &src, const string &pattern)
{
    int srcPtr = 0, patPtr = 0;
    while (srcPtr < src.length() && patPtr < pattern.length())
    {
        //如果當前字符匹配,就指針後移
        if (src[srcPtr] == pattern[patPtr])
        {
            ++srcPtr;
            ++patPtr;
        }
        else
        {
            //srcPtr 回退到上一次的起始位的下一位, patternPtr重新歸零
            srcPtr = srcPtr - patPtr + 1;
            patPtr = 0;
        }
    }
    //如果爲 true,說明匹配成功,否則爲失敗
    //如果成功 srcPtr - pattern.length() 就是模式子串開始的位置
    return (patPtr == pattern.length())? srcPtr - patPtr : -1;
}

時間複雜度分析:

令主串長n,模式串長m。則:

  • 最好:開頭位置就匹配成功了,所以爲 O(m)O(m)
  • 最壞:根本匹配不成功,結果爲 O(m×n)O(m\times n)

2. KMP算法

2.1 對蠻力算法的反思

以上的蠻力算法的低效的原因在於對主串中每個字符都進行了大量的重複性比較。最糟糕的情況下,主串中的任意一個字符可能會和模式串中的每一個字符都比較一次。就好比運用遞歸計算斐波那契數列一樣,過多的重複計算比對大大拖慢了算法。
不妨來考察一下最壞的情況:

000000...0010001 000000...001\\ 0001

如果要匹配以上兩個字符串的話,就會陷入到最壞的情況。但是爲什麼會這樣呢?進過分析,我們可以看出,主要是因爲在模式串中存在大量與主串匹配的前綴,所以大量地出現“在最後一個字符的位置失配導致不得不從頭再來”的情況。

2.2 新算法的提出

事實上,大量重複的比較是可以避免的。首先我們要注意到一個問題:在任何一個時刻,當前正在比對的模式串字符之前的前綴串和對應的主串中的子串是完全相等的。所以我們要想法設法將這些信息“存儲”起來,爲後面的比對所用。

那麼儲存起來又該幹什麼用呢?

不妨這樣來考慮,之前我們在蠻力求解的算法中,每次失配,都要重新移動主串指針和模式串的指針並進行重新比對。可是,我們不妨換條思路:既然之前比對過的主串的信息已經知道了,不妨將主串的指針定住不動,只移動模式串的指針

在這裏插入圖片描述

以上圖爲例,我們讓主串的比對字符仍爲空格符,向右滑動模式串,使得模式串中的另外一個繼任字符繼續和空格符比對,在這裏這個繼任字符是 ‘C’。

在這裏插入圖片描述


那麼,如何找到這個字符呢?換而言之,如何確定模式串要向右滑動幾個單位呢?注意到一點:在這個繼任字符之前的前綴子串必須和主串對應位置相配

譬如在這裏我們就發現,在移動前主串的部分匹配子串的後綴 AB 正好和模式串的前綴 AB 匹配,所以我們向右滑動模式串直到兩組 AB 對齊

如何知道該滑動幾位?這裏就要藉助到我們之前所說的儲存起來了的主串的部分匹配的部分了。不過要注意到,那部分匹配的區域是和模式串的部分匹配前綴相同的,所以與其說是存儲了主串的信息,倒不如說是儲存模式串信息並間接地分析出主串的信息

方法在於,我們要事先構造出一個 next 數組,它記錄了模式串的每個字符的某種信息。每當適配的時候,通過執行 patPtr = next[patPtr] 來得到下一次應該匹配的繼任字符,從而實現了模式串的右滑。


現在來討論一下這個 next 數組有什麼要求。根據我們之前的分析我們可以得出一個結論,next[patPtr] 必然滿足這樣以下條件:

next[patPtr]所指向的字符之前的前綴子串必然與 patPtr 之前的子串的某個後綴是匹配的,這樣他就能滿足我們之前說的“繼任字符之前的前綴子串必須和主串對應位置相配”的要求。

通俗地講就是:2

  1. pattern[0:patPtr] 爲 s1
  2. pattern[0:next[patPtr]] 爲 s2
  3. s1 的某個後綴子串 == s2

又因爲 pattern 串的任何一個前綴子串必然從第一個字符開始,所以 next[patPtr] 所指向的字符的索引值一定等於該字符之前的前綴子串的長度。

也就是說,如果字符串的索引是從 0 開始的話,那麼 next[patPtr] 值是代表着字符 pattern[patPtr] 之前的子串真前綴真後綴相同的最大長度(真前綴表示長度至少爲 1 的前綴,因爲空串也算前綴)。

不過還有一個小問題,如果在第一個字符就失配了怎麼辦?爲此,我們不妨令 next[0] 爲 -1,也就是假設存在一個虛擬的哨兵頭結點,我們假設這個哨兵結點是一個通配符,這樣就保持了邏輯的一致性。


在這裏插入圖片描述

2.3 KMP的初步框架

至此,我們已經可以構建出KMP算法的大體框架了:

int KMPMatch(const string &src, const string &pattern)
{
    const int srcLen = src.length(), patLen = pattern.length();
    int srcPtr = 0, patPtr = 0;
    int next[patLen];
    ConstructNext(pattern, next);
    while (srcPtr < srcLen && patPtr < patLen)
    {
        //後面一個部分好理解,前面的 -1 主要是我們會把 next[0] 設置爲 -1
        //這樣即使在第一個字符就失配了,patPtr 也可以變成 0 然後繼續和下一個字符進行比較
        if (patPtr == -1 || src[srcPtr] == pattern[patPtr])
        {
            ++srcPtr;
            ++patPtr;
        }
        else
            patPtr = next[patPtr];
    }
    return (patPtr == patLen)? srcPtr - patPtr : -1;
}

可以看出,算法在一個循環當中進行,每次有兩種分支情況:

  • 匹配
    • 正常的字符匹配
      比較下一位字符
    • 虛擬的通配符匹配
      邏輯上是比較哨兵字符的下一位,實質上就是從新開始比較
  • 不匹配
    將模式串右滑,一直滑到繼任字符

注意不要用 size_t,因爲這裏i是可以等於-1的

下面我們來考慮如何構造 next 數組。

2.4 構造 next

我們大可不必真的去對每個子串求最大匹配的真前綴和真後綴的長度,實際上,我們可以通過遞推的方式來進行求取。

首先我們有如下結論:

next[i]i next[i] \leqslant i

這當然是利索當然的了,next[i] 是在找前綴,所以肯定至少不會比 i 還大。

另外:

next[j+1]next[j]+1 next[j+1] \leqslant next[j] + 1

對於 j + 1而言,最好的情況不過是 pattern[j] == pattern[next[j]],也只有在這種情況下才能取等號。


現在我們開始討論已知 next[i]next[i] 如何求 next[i+1]next[i+1]

假設當前 next[i] 的值爲 t,這就說明:

p0p1pt1=pitpit+1pi1 p_{0}p_{1}\cdots p_{t-1} = p_{i-t}p_{i-t+1}\cdots p_{i-1}

在這裏插入圖片描述

現在分兩種情況討論:

2.3.1 Case 1:如果p[i] == p[t]

顯然這就意味着:

p0p1pt1pk=pitpit+1pi1pi p_{0}p_{1}\cdots p_{t-1}p_{k} = p_{i-t}p_{i-t+1}\cdots p_{i-1}p_{i}

稍加思考就可以看出,這種情況下next[i+1] = t+1

在這裏插入圖片描述

比如對於:

a b b c a b a c
-1 0 0 0 0 1 2 1

第二個a的next爲0,且p[0]也爲a,所以第三個b的next一定爲1

2.3.2 Case 2: 如果p[i] != p[t]

這種情況就較爲複雜了,我們需要先仔細考慮一下我們需要幹什麼。正如前面分析過的,當我們求 next[i+1] 時我們希望找到一個繼任字符 (令爲k),使這個字符前的長度爲 k 的前綴子串和緊挨着 p[i+1] 前面的長度爲 k 的後綴子串匹配。

不妨分兩步考慮,令 k 前面那個字符爲tx。則必然有 p[0, tx) == p[i-tx, i)p[tx] == p[i]


  • p[0, tx) == p[i-tx, i)

在這裏插入圖片描述

我們仔細考慮一下就會發現,這和我們在KMP主算法中遇到的問題不正是樣的嗎?所以我們不妨把求 next 數組的問題看作是另一個模式匹配的問題,只不過在這裏整個模式串既是主串又是模式串。更準確的而言,我們要把當前需要匹配的 p[i] 附近當作是主串,p[tx] 附近當作是模式串

我們想找到 i + 1 對應的 tx,就類似地等價與 KMP 中 p[i] 在和 p[tx] 比較時失配的情況。很簡單,只需要右移模式串,直到兩個前綴相等時即可。符合要求的位置有哪些呢?顯然,next[i]是符合這個要求的,但同時需要注意,next[next[i]]也是符合這個要求的,以此類推next[next[...next[i]]]也都符合要求。唯一不同的是,隨着層次的推移,相同前綴子串的長度也不斷減少

總結以上的內容,我們可以得出一下結論:

t = next[i]通過不斷的迭代 t= next[t] N次(N=0, 1, 2…),我們可以得到 N+1 個位置 t,它們能都滿足 p[0, tx) == p[i-tx, i)。換而言之如果存在一個 tx 滿足 p[0, tx] == p[i-tx, i] 那麼這個tx 必然在這N+1個 t 中產生


  • p[tx] == p[i]

在這裏插入圖片描述

既然已經知道了tx的取樣範圍,那麼剩下的就好辦了。既然i是固定不變的,那我們可以使用一個額外的變量lastNext(原諒我的取名水平),它記錄的是上一個next[]的值。

通過不斷向前迭代(lastNext = next[lastNext]),依次取得之前所說的 tx並每次和p[i]進行比較。

  1. 如果存在p[lastNext] == p[i],那就恰好滿足了我們的要求:

  2. 如果不存在這樣的字符,那麼我們同樣依靠虛擬的通配符哨兵位,假設存在這樣一個能與任何字符匹配的結點。

在找到了我們的目標位置 tx 之後顯然這就轉化爲了p[i] = p[t]的情況,因此我們就可以將next[i+1]填入 k = tx + 1 的值了

2.4 next實現

void ConstructNext(const string &pattern, int next[])
{
    const int len = pattern.length();
    //虛擬的通配符
    next[0] = -1;
    //這裏的 ptr 就相當於分析中的 i, lastNext 就是 next[ptr]
    int ptr = 0, lastNext = -1;
    //在循環體中,當前爲 ptr 意味着我們的任務是填充 next[ptr + 1] 的值
    //所以 ptr 要小於 len - 1,這樣在 ptr = ptr - 2 時,我們就可以填充 next[len - 1] 了
    while (ptr < len - 1)
    {
        //Case 1: p[i] = p[t],則 next[i + 1] = next[i] + 1
        if (lastNext == -1 || pattern[ptr] == pattern[lastNext])
            next[++ptr] = ++lastNext;
        else
            lastNext = next[lastNext];

    }
}

程序分析:

  • 循環次數

可以看到我們在一開始先將 next[0] 填充,因此後續程序的循環次數應該爲length - 1次而非length

  • ptr

每一輪循環,ptr 都指向 next 數組已經填好了的部分的最後一位。換而言之如果某次循環 ptr 的值爲 x,那麼本次循環的目的是求得 next[x+1]。程序一直進行到 ptr == p.length() - 2,也就是指向倒數第二位,在這一組(注意並不是每次循環就填好一個數組位置,有些循環是用來計算 lastNext 的)循環中,我們將next的最後一位填好,退出程序。

  • lastNext

相對的,lastNext指向的是“上一次”next[]數組的值。

  • 條件分支
    • 若匹配
      匹配的情況有兩種:一種是真匹配,一種是通配符匹配
      巧妙的是,無論是哪種情況我們都可以用同一種語句來處理。
      • 對於前者,next[++ptr] = ++lastNext; 代表了 next[ptr+1] = next[ptr] + 1。注意原本 lastNext 代表的是 next[ptr] 的值,在執行後,它代表 next[ptr+1] 的值。
      • 對與後者,由於此時 lastNext 必定爲 -1,所以執行操作後恰好爲 next[ptr+1] = 0,即重新開始比對。
    • 若不匹配
      此時就開始了我們之前分析過的跳轉流程:lastNext在每進行一次循環後都向上一次的"lastlastNext" 跳轉。
      只沿着next跳轉保證了前綴除了最後一個字符的部分必然匹配;
      每次循環都做一次pattern[ptr] == pattern[lastNext]的判斷是爲了找出使前綴最後一個字符也匹配的位置。
      等到找到了這個位置後(不管是真匹配還是通配符匹配),**就跳入了if語句塊,從而填寫好next[ptr+1]的值。

2.5 再優化

事實上以上的KMP算法版本還是有缺陷的。不妨來考慮這個例子:

在這裏插入圖片描述

之前的KMP算法會怎麼處理這種情況?顯然它會先把 j 移到2,發現不匹配;再把 j 移到1,又不匹配···最後一隻移到了-1,才結束對本次主串中的字符1的匹配。

但是我們仔細分析就會發現這是沒有必要的!原因在於 p[j] == p[next[j]] == p[next[next[j]]]...,所以如果 p[j] 不和 src[i]匹配,那麼p[next[j]]也必定不和src[i]匹配,模式串註定要繼續後移。

通過以上的分析,我們就可以發現,要想優化算法,就需要優化next表的構成

void ConstructNext(const string &pattern, int next[])
{
    const int len = pattern.length();
    //虛擬的通配符
    next[0] = -1;
    //這裏的 ptr 就相當於分析中的 i, lastNext 就是 next[ptr]
    int ptr = 0, lastNext = -1;
    //在循環體中,當前爲 ptr 意味着我們的任務是填充 next[ptr + 1] 的值
    //所以 ptr 要小於 len - 1,這樣在 ptr = ptr - 2 時,我們就可以填充 next[len - 1] 了
    while (ptr < len - 1)
    {
        //Case 1: p[i] = p[t],則 next[i + 1] = next[i] + 1
        if (lastNext == -1 || pattern[ptr] == pattern[lastNext])
        {
            ++ptr;
            ++lastNext;
            //強制要求 pattern[i] != pattern[next[i]]
            next[ptr] = (pattern[ptr] == pattern[lastNext])? next[lastNext] : lastNext;
        }
        else
            lastNext = next[lastNext];

    }
}

注意在這裏我們不再簡單地next[++ptr] = ++lastNext。而是增加了判斷pattern[lastNext] == patern[ptr]:不相等一切好說;如果相等,就把它移到不相等的位置

等等,相等時爲什麼是next[ptr] = next[lastNext]?爲什麼這樣的賦值可以保證p[i] != p[next[pi]]?這是因爲 next 表是從左到右建立的,所以如果其中一個位置 k 是已經建立好了的,那麼必然滿足p[k] != p[next[k]]。考慮到lastNext必然在 i 的左側,所以 p[lastNext] 必然滿足以上性質。因此若 p[i] == p[lastNext],則 p[i] 必然不等於 p[next[lastNext]]。(可以聯想到我們在插入排序中,左側的區間段必定是有序的這樣一個考慮)

2.6 附:手工求next數組

  1. 步驟一:求pattern的部分匹配表

比如對於這個模式串

a b b c a b a c

假設部分匹配表爲 pm,那麼 pm[i] 表示pattern[i] 爲結尾的子串和以 pattern[0] 爲開頭的子串相匹配的最大位數,比如上述子串對應的部分匹配表如下

a b b c a b a c
0 0 0 0 1 2 1 0

  1. 步驟二:將部分匹配表整體右移,首位用-1代替

a b b c a b a c
-1 0 0 0 0 1 2 1

當然這是指索引從0開始的情況,如果索引從k開始,那最後結果整體加上k。

3. 小注意

並不是說有了KMP等高效算法就無需管BF算法了。

舉個例子,事實上JavaindexOf方法就是用BF實現的。

JDK的編寫者們認爲大多數情況下,字符串都不長,使用原始實現可能代價更低。因爲KMP和Boyer-Moore算法都需要預先計算處理來獲得輔助數組,需要一定的時間和空間,這可能在短字符串查找中相比較原始實現耗費更大的代價。而且一般大字符串查找時,程序員們也會使用其它特定的數據結構,查找起來更簡單。這有點類似於排除特定情況下的快速排序了。

所以也不是越快越好。

4. 擴展的 KMP

上面所說的 KMP 只能找到模式串在主串中出現的第一個位置,現在我們擴展一下,讓它可以一次性找到在主串中所有適配的位置

注意,既然我們說是“所有”位置,那就包括了重疊的部分,如下圖所示:

在這裏插入圖片描述

那麼怎麼做呢?我們仿照之前的思路,假設在 pattern 末尾也有一個虛擬的哨兵字符,這個字符不論碰到什麼其他字符都不匹配。這樣只要把匹配成功後當作一次新的失配,就可以和以前的代碼邏輯聯通了。

爲此,我們把 next[] 數組多加一位就可以了,就像這樣:

void Construct(const string &pat, int next[])
{
    const int len = pat.length();
    next[0] = -1;
    int j = 0, lastNext = -1;
    //沒錯,只要把 len - 1 改成 len 就可以把最後一個也填進去了
    while (j < len)
    {
        if (lastNext == -1 || pat[j] == pat[lastNext])
        {
            ++j; ++lastNext;
            //這裏不能優化,因爲我們需要 next[i] 保存的就是 i-1 子串的最大匹配
            //next[j] = (pat[j] == pat[lastNext])? next[lastNext] : lastNext;
            next[j] = lastNext;
        }
        else
            lastNext = next[lastNext];
    }
}

然後在主算法中寫成這樣:

void KMP(const string &src, const string &pat)
{
    const int slen = src.length();
    const int plen = pat.length();
    int i = 0, j = 0;
    //next 數組,next[i] 表示 pat[0~i] 的部分匹配數(即最長相匹配的真前綴後綴的長度)
    int next[plen + 1];
    Construct(pat, next);
    while (i < slen)
    {
        //匹配完了一次
        if (j == plen)
        {
            //輸出適配的位置
            cout << i - j << endl;
            //next[len] 存儲了 pat 最長的匹配前後綴的長度
            //可以想象成 pat[len] 是一個和任何字符都不同的虛擬哨兵
            //這樣情況就退化爲 pat[len] 和 src[i] 失配的情況了
            j = next[plen];
        }
        else if (j == -1 || src[i] == pat[j])
        {
            ++i;
            ++j;
        }
        else
            j = next[j];
    }
    //別玩了最後的檢查
    if (j == plen)
        cout << i - j + 1 << endl;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章