KMP算法(1):如何理解KMP

原文連接: https://subetter.com/algorith...

一:背景

給定一個主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出現的位置,此即串的模式匹配問題。html

Knuth-Morris-Pratt 算法(簡稱 KMP)是解決這一問題的經常使用算法之一,這個算法是由高德納(Donald Ervin Knuth)和沃恩·普拉特在1974年構思,同年詹姆斯·H·莫里斯也獨立地設計出該算法,最終三人於1977年聯合發表。ios

在繼續下面的內容以前,有必要在這裏介紹下兩個概念:真前綴真後綴c++

由上圖所得, "真前綴"指除了自身之外,一個字符串的所有頭部組合;"真後綴"指除了自身之外,一個字符串的所有尾部組合。(網上不少博客,應該說是幾乎全部的博客,也包括我之前寫的,都是「前綴」。嚴格來講,「真前綴」和「前綴」是不一樣的,既然不一樣,仍是不要混爲一談的好!)算法

二:樸素字符串匹配算法

初遇串的模式匹配問題,咱們腦海中的第一反應,就是樸素字符串匹配(即所謂的暴力匹配),代碼以下:數組

/* 字符串下標始於 0 */
int NaiveStringSearch(string S, string P)
{
    int i = 0;    // S 的下標
    int j = 0;    // P 的下標
    int s_len = S.size();
    int p_len = P.size();

    while (i < s_len && j < p_len)
    {
        if (S[i] == P[j])  // 若相等,都前進一步
        {
            i++;
            j++;
        }
        else               // 不相等
        {
            i = i - j + 1;
            j = 0;
        }
    }

    if (j == p_len)        // 匹配成功
        return i - j;

    return -1;
}

暴力匹配的時間複雜度爲 $O(nm)$,其中 $n$ 爲 S 的長度,$m$ 爲 P 的長度。很明顯,這樣的時間複雜度很難知足咱們的需求。數據結構

接下來進入正題:時間複雜度爲 $Θ(n+m)$ 的 KMP 算法。優化

三:KMP字符串匹配算法

3.1 算法流程

如下摘自阮一峯的字符串匹配的KMP算法,並做稍微修改。spa

(1)設計

首先,主串"BBC ABCDAB ABCDABCDABDE"的第一個字符與模式串"ABCDABD"的第一個字符,進行比較。由於B與A不匹配,因此模式串後移一位。指針

(2)

由於B與A又不匹配,模式串再日後移。

(3)

就這樣,直到主串有一個字符,與模式串的第一個字符相同爲止。

(4)

接着比較主串和模式串的下一個字符,仍是相同。

(5)

直到主串有一個字符,與模式串對應的字符不相同爲止。

(6)

這時,最天然的反應是,將模式串整個後移一位,再從頭逐個比較。這樣作雖然可行,可是效率不好,由於你要把"搜索位置"移到已經比較過的位置,重比一遍。

(7)

一個基本事實是,當空格與D不匹配時,你實際上是已經知道前面六個字符是"ABCDAB"。KMP算法的想法是,設法利用這個已知信息,不要把"搜索位置"移回已經比較過的位置,而是繼續把它向後移,這樣就提升了效率。

(8)

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[i] -1 0 0 0 0 1 2 0

怎麼作到這一點呢?能夠針對模式串,設置一個跳轉數組int next[],這個數組是怎麼計算出來的,後面再介紹,這裏只要會用就能夠了。

(9)

已知空格與D不匹配時,前面六個字符"ABCDAB"是匹配的。根據跳轉數組可知,不匹配處D的next值爲2,所以接下來從模式串下標爲2的位置開始匹配

(10)

由於空格與C不匹配,C處的next值爲0,所以接下來模式串從下標爲0處開始匹配。

(11)

由於空格與A不匹配,此處next值爲-1,表示模式串的第一個字符就不匹配,那麼直接日後移一位。

(12)

逐位比較,直到發現C與D不匹配。因而,下一步從下標爲2的地方開始匹配。

(13)

逐位比較,直到模式串的最後一位,發現徹底匹配,因而搜索完成。

3.2 next數組是如何求出的

next數組的求解基於「真前綴」和「真後綴」,即next[i]等於P[0]...P[i - 1]最長的相同真先後綴的長度(請暫時忽視i等於0時的狀況,下面會有解釋)。咱們依舊以上述的表格爲例,爲了方便閱讀,我複製在下方了。

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[ i ] -1 0 0 0 0 1 2 0
  1. i = 0,對於模式串的首字符,咱們統一爲next[0] = -1
  2. i = 1,前面的字符串爲A,其最長相同真先後綴長度爲0,即next[1] = 0
  3. i = 2,前面的字符串爲AB,其最長相同真先後綴長度爲0,即next[2] = 0
  4. i = 3,前面的字符串爲ABC,其最長相同真先後綴長度爲0,即next[3] = 0
  5. i = 4,前面的字符串爲ABCD,其最長相同真先後綴長度爲0,即next[4] = 0
  6. i = 5,前面的字符串爲ABCDA,其最長相同真先後綴爲A,即next[5] = 1
  7. i = 6,前面的字符串爲ABCDAB,其最長相同真先後綴爲AB,即next[6] = 2
  8. i = 7,前面的字符串爲ABCDABD,其最長相同真先後綴長度爲0,即next[7] = 0

那麼,爲何根據最長相同真先後綴的長度就能夠實如今不匹配狀況下的跳轉呢?舉個表明性的例子:假如i = 6時不匹配,此時咱們是知道其位置前的字符串爲ABCDAB,仔細觀察這個字符串,首尾都有一個AB,既然在i = 6處的D不匹配,咱們爲什麼不直接把i = 2處的C拿過來繼續比較呢,由於都有一個AB啊,而這個AB就是ABCDAB的最長相同真先後綴,其長度2正好是跳轉的下標位置。

有的讀者可能存在疑問,若在i = 5時匹配失敗,按照我講解的思路,此時應該把i = 1處的字符拿過來繼續比較,可是這兩個位置的字符是同樣的啊,都是B,既然同樣,拿過來比較不就是無用功了麼?其實不是我講解的有問題,也不是這個算法有問題,而是這個算法還未優化,關於這個問題在下面會詳細說明,不過建議讀者不要在這裏糾結,跳過這個,下面你天然會恍然大悟。

思路如此簡單,接下來就是代碼實現了,以下:

/* P 爲模式串,下標從 0 開始 */
void GetNext(string P, int next[])
{
    int p_len = P.size();
    int i = 0;   // P 的下標
    int j = -1;  
    next[0] = -1;

    while (i < p_len - 1)
    {
        if (j == -1 || P[i] == P[j])
        {
            i++;
            j++;
            next[i] = j;
        }
        else
            j = next[j];
    }
}

一臉懵逼,是否是。。。上述代碼就是用來求解模式串中每一個位置的next[]值。

下面具體分析,我把代碼分爲兩部分來說:

(1):i和j的做用是什麼?

i和j就像是兩個」指針「,一前一後,經過移動它們來找到最長的相同真先後綴。

(2):if...else...語句裏作了什麼?

假設i和j的位置如上圖,由next[i] = j得,也就是對於位置i來講,區段[0, i - 1]的最長相同真先後綴分別是[0, j - 1]和[i - j, i - 1],即這兩區段內容相同

按照算法流程,if (P[i] == P[j]),則i++; j++; next[i] = j;;若不等,則j = next[j],見下圖:

next[j]表明[0, j - 1]區段中最長相同真先後綴的長度。如圖,用左側兩個橢圓來表示這個最長相同真先後綴,即這兩個橢圓表明的區段內容相同;同理,右側也有相同的兩個橢圓。因此else語句就是利用第一個橢圓和第四個橢圓內容相同來加快獲得[0, i - 1]區段的相同真先後綴的長度。

細心的朋友會問if語句中j == -1存在的意義是何?第一,程序剛運行時,j是被初始爲-1,直接進行P[i] == P[j]判斷無疑會邊界溢出;第二,else語句中j = next[j],j是不斷後退的,若j在後退中被賦值爲-1(也就是j = next[0]),在P[i] == P[j]判斷也會邊界溢出。綜上兩點,其意義就是爲了特殊邊界判斷。

四:完整代碼

#include <iostream>
#include <string>

using namespace std;

/* P 爲模式串,下標從 0 開始 */
void GetNext(string P, int next[])
{
    int p_len = P.size();
    int i = 0;   // P 的下標
    int j = -1;  
    next[0] = -1;

    while (i < p_len - 1)
    {
        if (j == -1 || P[i] == P[j])
        {
            i++;
            j++;
            next[i] = j;
        }
        else
            j = next[j];
    }
}

/* 在 S 中找到 P 第一次出現的位置 */
int KMP(string S, string P, int next[])
{
    GetNext(P, next);

    int i = 0;  // S 的下標
    int j = 0;  // P 的下標
    int s_len = S.size();
    int p_len = P.size();

    while (i < s_len && j < p_len)
    {
        if (j == -1 || S[i] == P[j])  // P 的第一個字符不匹配或 S[i] == P[j]
        {
            i++;
            j++;
        }
        else
            j = next[j];  // 當前字符匹配失敗,進行跳轉
    }

    if (j == p_len)  // 匹配成功
        return i - j;
    
    return -1;
}

int main()
{
    int next[100] = { 0 };

    cout << KMP("bbc abcdab abcdabcdabde", "abcdabd", next) << endl; // 15
    
    return 0;
}

五:KMP優化

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[ i ] -1 0 0 0 0 1 2 0

以3.2的表格爲例(已複製在上方),若在i = 5時匹配失敗,按照3.2的代碼,此時應該把i = 1處的字符拿過來繼續比較,可是這兩個位置的字符是同樣的,都是B,既然同樣,拿過來比較不就是無用功了麼?這我在3.2已經解釋過,之因此會這樣是由於KMP不夠完美。那怎麼改寫代碼就能夠解決這個問題呢?很簡單。

/* P 爲模式串,下標從 0 開始 */
void GetNextval(string P, int nextval[])
{
    int p_len = P.size();
    int i = 0;   // P 的下標
    int j = -1;  
    nextval[0] = -1;

    while (i < p_len - 1)
    {
        if (j == -1 || P[i] == P[j])
        {
            i++;
            j++;
          
            if (P[i] != P[j])
                nextval[i] = j;
            else
                nextval[i] = nextval[j];  // 既然相同就繼續往前找真前綴
        }
        else
            j = nextval[j];
    }
}

在此也給各位讀者提個醒,KMP算法嚴格來講分爲KMP算法(未優化版)和KMP算法(優化版),因此建議讀者在表述KMP算法時,最好告知你的版本,由於二者在某些狀況下區別很大,這裏簡單說下。

KMP算法(未優化版): next數組表示最長的相同真先後綴的長度,咱們不只能夠利用next來解決模式串的匹配問題,也能夠用來解決相似字符串重複問題等等,這類問題你們能夠在各大OJ找到,這裏不做過多表述。

KMP算法(優化版): 根據代碼很容易知道(名稱也改成了nextval),優化後的next僅僅表示相同真先後綴的長度,但不必定是最長(稱其爲「最優相同真先後綴」更爲恰當)。此時咱們利用優化後的next能夠在模式串匹配問題中以更快的速度獲得咱們的答案(相較於未優化版),可是上述所說的字符串重複問題,優化版本則一籌莫展。

因此,該採用哪一個版本,取決於你在現實中遇到的實際問題。

六:參考文獻

相關文章
相關標籤/搜索