把KMP演算法嚼碎!(C++)

YVVT_Real發表於2023-01-20

相信不少人在學資料結構的時候都被KMP演算法搞的迷迷糊糊的,原理看的似懂非懂,程式碼寫不出來,或者寫出來了也不知道為什麼就可以這麼寫。本文力求儘可能通俗詳細的講解KMP演算法,讓你不再受到KMP演算法的困擾。

暴力匹配的痛點

所謂暴力匹配,就是從文字串的首端開始依次檢查子串是否與模式串匹配,如果不匹配就將模式串往後移一個位置,從頭開始匹配,直到在某處成功匹配或匹配到末尾也沒能成功匹配。如下圖:

img

設文字串為T,模式串為P,i為文字串中的下標,j為模式串中的下標,文字串的長度為m,模式串的長度為n,則程式碼如下:

int bruteForce(std::string t, std::string p) {
    int i = 0, j = 0;
    int m = t.length(), n = p.length();
    while (i < m && j < n) {
        if (t[i] == p[j]) {
            i++; j++;
        } else {
            i = i - j + 1;
            j = 0;
        }
    }
    return j == n ? i - j : -1;
}

那麼暴力匹配的時間效率如何呢?不難發現,每一次匹配中,我們都需要花費\(O(n)\)的時間成本來判斷子串是否與模式串匹配,而總共的判斷次數最多為\(m-n+1\),由於實際情況下有\(m>>n\),因此\(m-n+1\)近似等於\(m\),整個暴力匹配的時間複雜度為\(O(mn)\),顯然不理想。

經過觀察,我們不難發現,暴力匹配方法做了很多次不必要的匹配。在第一輪發現不匹配的時候,我們無需只將模式串後移一個位置,而是後移到文字串中下標為3的位置(第二個A),並直接從文字串中下標為5的位置(第2個C)開始匹配。從相對運動的角度來講,也就是將j前移為2,而i不用回退。

img

KMP演算法

事實上,之所以這麼做,是因為模式串中j前面的某些字元恰好與模式串的某個字首相等。如果你想到了這點,那你的想法剛好就跟發明KMP演算法的那三個人的想法一樣了(認真)。KMP演算法就利用了這一點,每次匹配失敗的時候不直接從頭開始繼續匹配,而是將j回溯到這個字首後面的字元,而i不用回退,以解決暴力匹配演算法的這一痛點。如圖:

img

構建next陣列

為了應對各種匹配失敗的情況,我們需要另開一個與模式串等長的陣列next,其中next[j]表示P[j]T[i]匹配失敗的情況下,j要移動到的下標。(顯然,對於任意的j,一定有next[j] < j)按照上面那個性質,P[next[j]]之前的p個字元也與P[j]左邊的p個字元相等。(其中p為P[next[j]]之前的字元數量)(這一點非常重要,可以說是next陣列構建演算法的靈魂!)

接下來的一個問題就是,如何判定某次匹配過程失敗後,j該移到哪個位置呢?

我們可以用遞推的思路來求解。

考慮模式串的第一個字元就不與文字串中的相應字元匹配的情況。如圖:

img

這個時候我們需要將i往後移,不妨將next[0]設為-1。(後面你就會看到這樣做自有其精妙之處)

再來考慮next[k]已知的情況,如何求得next[k+1]呢?分兩種情況討論:

第一種情況,P[k]==P[next[k]],如下圖。由上面那條性質,P[k]之前的p個字元與P[next[k]]之前的p個字元相等。而P[k]又是等於P[next[k]]的,因此,P[k+1]之前的p+1個字元與P[next[k]+1]之前的p+1個字元相等。所以,next[k+1]應該設為next[k]+1,以符合上面那條性質。

img

第二種情況,P[k]!=P[next[k]],如下圖。(這裡我用不同的顏色標出來了)

img

怎麼辦呢?再考慮P[next[next[k]]]P[k]之間的關係。

img

此時的思路與上面相似,如果P[k]==P[next[next[k]]],就將next[k+1]設為next[next[k]]+1,否則就依次檢查next[next[next[k]]]next[next[next[next[k]]]]、...

不難看出,接受檢查的下標是依次遞減的,但是遞減也得有個限度;另外next[0]永遠為-1,因此遞減到-1的時候,就說明一直檢查到P的第一個字元也沒檢查到與P[k]相等的字元。此時next[k+1]前面有0個字元與P中長度為0的字首相等。因此j需要回溯到0,將next[k+1]設為0。

將以上思路稍作整理,可得在next[k]已知的情況下,求得next[k+1]的步驟:

  1. 令t為next[k]
  2. 如果t等於-1,就將next[k+1]設為0。
  3. 否則,檢查P[k]是否等於P[t]。如果等於,就將next[k+1]設為t+1;否則,將t設為next[t],跳轉到第2步。

細心的你可能已經發現了,既然next[0]為-1,-1再加上1剛好也等於0,因此兩個條件可以合併起來,上述步驟可以最佳化一下:

  1. 令t為next[k]
  2. 如果t等於-1,或者P[k]等於P[t],就將next[k+1]設為t+1。
  3. 否則,將t設為next[t],跳轉到第2步。

現在你應該看到將next[0]設為-1這種做法的巧妙之處了吧!

這樣,由於next[0]事先約定為-1,而由next[0]可以求得next[1],由next[1]可以求得next[2]...,因此我們就可以得出構建next陣列的步驟:

  1. 初始化next陣列,令其長度為n。
  2. next[0]設為-1。
  3. 初始化k為0,迴圈執行以下步驟,每次迴圈完k就加一,如果k加到了n-1就退出迴圈。
  4. 令t為next[k]
  5. 如果t等於-1,或者P[k]等於P[t],就將next[k+1]設為t+1。
  6. 否則,將t設為next[t],跳轉到第5步。

程式碼實現:

std::vector<int> buildNext(std::string p) {
    int n = p.length();
    std::vector<int> next(n);
    next[0] = -1;
    for (int k = 0; k < n - 1; k++) {
        int t = next[k];
        while (t != -1 && p[k] != p[t]) {
            t = next[t];
        }
        next[k + 1] = t + 1;
    }
    return next;
}

KMP主演算法

有了next陣列,一切都好辦了。

每次匹配的時候,如果匹配成功了就i與j同時往後移一個位置,匹配失敗的話j設為next[j]。如果j為-1的話,i就往後移,同時j設為0。

int kmp(std::string t, std::string p) {
    int m = t.length(), n = p.length();
    int i = 0, j = 0;
    auto next = buildNext(p);
    while (i < m && j < n) {
        if (j < 0 || t[i] == p[j]) {
            i++; j++;
        } else {
            j = next[j];
        }
    }
    return j == n ? i - j : -1;
}

複雜度分析

不難看出,KMP演算法的空間複雜度(不計T和P本身所佔的記憶體空間)為\(O(n)\),這是來自next陣列所佔用的空間開銷。

那麼時間複雜度為多少呢?網上大多數博文直接在這裡放個結論,缺少必要的分析,讀者只是知道了結論,至於為什麼是這樣則是一頭霧水。

整個KMP演算法的時間複雜度分為以下兩部分:

  1. 構建next陣列的時間複雜度;
  2. 匹配的時間複雜度。

其中,構建next陣列的時間複雜度為多少呢?

這主要取決於給next陣列各項賦值的時間複雜度和對t賦值的次數。

顯而易見,前者的時間複雜度為\(O(n)\)。那後者的時間複雜度怎麼計算呢?

注意到,每次for迴圈的結尾,有一個next[k + 1] = t + 1;的語句,而下一次for迴圈開始時,由於k自增了1,因此int t = next[k];裡的next[k]其實就是上一次迴圈裡的next[k + 1],這條語句執行後的新t其實就是舊t加上1,可以等效的認為對t進行了一次++運算。顯而易見,t++的次數為n-1。而while迴圈裡面t = next[t];的最壞次數怎麼計算呢?我們知道,next[t]是必然小於t的,所以這條語句執行後t是要往回跳的。但是跳一次跨越的步數是大於等於1的,而往回跳的極限是-1,所以同樣的長度,往前跳的次數是n-1,往後跳的次數必然不超過n-1,所以對t賦值的次數(不如說是t跳躍的次數)不會超過2n-2,當然就是\(O(n)\)量級的。所以,構建next陣列的時間複雜度為\(O(n)\)

而匹配的時間複雜度又是多少呢?

這主要取決於while迴圈執行的次數,而while迴圈是否執行取決於i和j的取值,因此這也取決於對i和j賦值的次數。

對i賦值的操作只有i++這一條語句,顯然這條語句最多會執行m次。

對j的賦值(或者說是跳躍)呢,分析思路與上述類似,包括往前跳躍(j++)和往後跳躍(j = next[j])。其中前者是與i“攜手並進”的,因此執行次數也不會超過m。往後跳躍的次數同樣不會超過往前跳躍的次數(原因與上述分析一致)。因此,j的跳躍次數也是\(O(m)\)量級的。

因此,匹配的時間複雜度是\(O(m)\)

綜上所述,整個KMP演算法的時間複雜度為\(O(m+n)\),比暴力演算法的\(O(mn)\)要好得多。

這就完美了嗎?

考慮下面的情況:

文字串:AAAABAAAAA

模式串:AAAAA

如果我們用KMP演算法進行匹配的話,會由於T[4] != P[4]發生一次匹配失敗:

img

根據next陣列的指示,將會由P[3]繼續匹配T[4]

img

然後是P[2]P[1]P[0],最後因為P[0]T[4]匹配失敗而開始T[5]P[0]的比對。

但是,明眼人一眼就能看出,T[4]P[4]比對失敗後可以直接進行T[5]P[0]之間的比對,不需要進行T[4]P[3]P[2]...P[0]之間的比對了,因為P[4]P[3]P[2]...P[0]是一樣的,既然T[4]P[4]比對失敗了,那麼T[4]P[3]P[2]...P[0]之間的比對就一定會失敗,就像推銷員給你推銷某樣產品,你不感興趣,對方一直喋喋不休,只會讓你感到厭煩。

改進

那怎樣才能在一次比對失敗後不再比對P中相同的字元,而是從不相同的字元開始比對呢?換句話說,如何在比對失敗後,能夠讓j一次性跳轉到不一樣的字元呢?我們只需要對構建next陣列的程式碼稍作修改。在給next[j+1]賦值的時候,我們還需要檢查P[k+1]是否等於P[t+1]。如果等於的話,就賦值為next[t+1]。否則才賦值為t+1。如圖:

img

但是直接這樣改的話,每次for迴圈後的t就不一定等於上一次迴圈的t加1了,所以我們要顯式的維護變數t。

std::vector<int> buildNext() {
    int n = p.length();
    std::vector<int> next(n);
    next[0] = -1;
    int t = -1;
    for (int k = 0; k < n - 1; k++) {
        while (t != -1 && p[k] != p[t]) {
            t = next[t];
        }
        next[k + 1] = p[k + 1] == p[t + 1] ? next[t + 1] : t + 1;
        t++;
    }
    return next;
}

顯然,時間複雜度是不變的,但是因為跳躍次數減少了,整個演算法的效率也會提升。

相關文章