kmp 演算法簡介及 next 陣列推導

weiwenhao發表於2018-05-02

Knuth-Morris-Pratt字串查詢演算法(簡稱為KMP演算法)可在一個主文字字串S內查詢一個詞W的出現位置。此演算法通過運用對這個詞在不匹配時本身就包含足夠的資訊來確定下一個匹配將在哪裡開始的發現,從而避免重新檢查先前匹配的字元。

比如下面這種情況

kmp 演算法簡介及 next 陣列推導

kmp 演算法簡介及 next 陣列推導

gif中可以看出,匹配失敗之後kmp演算法不對主字串的指標進行任何的回退,其關心的是對搜尋詞指標的處理。

細心的你可能已經感受到了一點,上面的處理是抽象的(通用的),既完全不需要知道主字串具體是多少的情況下進行的模擬演習。

gif中模擬了指標k在字元c處失配的情況,通過這樣一個預處理,在實際匹配中,如果遇到了這種情況,我們只需要從容的將搜尋詞指標移動到E處,然後繼續匹配即可。

補充一下,為什麼搜尋詞在移動到 K = E的位置停了下來? 這裡可以感性的理解,由於在移動過程中 AB成功進行了匹配,而在不知道 ‘?’所代表的具體字元是多少的情況下繼續向前移動搜尋詞,則可能會出現錯失匹配的情況。

現在依舊已ABEABC作為搜尋詞,再看幾種演習情況

kmp 演算法簡介及 next 陣列推導

kmp 演算法簡介及 next 陣列推導
kmp 演算法簡介及 next 陣列推導
kmp 演算法簡介及 next 陣列推導

我們來總結一下規律。 對於前兩種情況K指標都沒有移動到起點0,而是中途位置停了下來。 可以發現 ABEAB?與ABEABC在失配字元之前的字元ABEAB 的頭部與尾部存在相同的字元AB

ABEA?和ABEAB在失配之前的字元ABEA中,頭部和尾部存在相同的字元 A

kmp 演算法簡介及 next 陣列推導
對於這種字元我們稱之為前字尾,通過上面的圖我們發現K指標在失配時移動到的位置剛好是字首的後一個字元。但一個字串的字首並不是唯一的,所以這句話非常不嚴謹。

首先,要了解兩個概念:"字首"和"字尾"。 "字首"指除了最後一個字元以外,一個字串的全部頭部組合;"字尾"指除了第一個字元以外,一個字串的全部尾部組合。

kmp 演算法簡介及 next 陣列推導
出自 阮一峰 字串匹配的KMP演算法

瞭解了字首與字尾之後,我們再次定義。 當指標j所指向的字元與指標k所指向的字元失配時,失配之前的字元存在一個字首集合和一個字尾集合,我們可以得到

k' = max(0 ~ k-1的字首集合 ∩ 0 ~ k-1的字尾集合字尾集合)

k'的含義從公式中看的很清楚(字首與字尾的交集的最大值)。而另一層含義則是,如果搜尋詞下標從0開始計算,當k處失配時,我們只需要將k移動到k'處繼續匹配即可。

kmp演算法的通常把計算出的k'放到next陣列中儲存 next[k] = k'。當我們實戰中在指標k處失配時,我們只需要將k指標回退到k'處,既k = k' = next[k]即可。

確實如我們之前所言,通過定義可以清楚的認識到,計算next陣列完全不需要主字串參與,完全是搜尋詞自匹配計算k' = max(0~k-1的字首集合 ∩ 0~k-1的字尾集合)的過程。

這個定義雖然很嚴謹,便於理解,但卻不能很好的使用計算機語言描述出來。下面看看便於計算機理解的next陣列的推導過程。這應該是整個kmp演算法最難理解的地方

next陣列推導

根據next陣列的定義,我們可以有

next[j] = k,則 w[0 ~ k-1] = w[j-k ~ j-1]

要明白這兩者之間是充分必要條件關係,既 若 w[0 ~ k-1] = w[j-k ~ j-1]next[j] = k

下圖圖中的情況為一種滿足定義的情況next[6] = 2

kmp 演算法簡介及 next 陣列推導

這個我不知道怎麼證明,因為這是由next陣列的定義得到的,所以也不需要證明。

現在已經知道了next[j] = k,順理成章,接下來我們繼續求next[j+1]next[j+1]求解過程中存在兩種情況

w[k] == w[j]

根據上面的推導,當 w[k] == w[j]時,有w[0 ~ k] = w[j-k ~ j], 則可以得到 next[j+1] = k + 1

kmp 演算法簡介及 next 陣列推導

w[k] != w[j]

w[k] != w[j]則進入了熟悉的字串失配環節,明確一下,誰與誰的比較中產生了失配?下圖是一個符合我們討論的例子

kmp 演算法簡介及 next 陣列推導

可以看出在尋找字串ABEFABA的最大前字尾交集時,kj發生了失配

kmp 演算法簡介及 next 陣列推導

在kmp演算法中如果發生了這種情況,則另 k = next[k],然後再次讓w[k]與w[j]比較。那麼問題來了

  1. 為什麼當w[k] != w[j]時,令 k = next[k], 而不是k = k-1或者其他呢?

    w[k]與w[j]失配時, k至少要移動到next[k]處才能使得k與主字串的j繼續匹配。這是next陣列的定義,現在只不過在使用這個定義而已

  2. w[k] != w[j],所以另k' = next[k],假如此時w[k'] == w[j],如何證明 w[0 ~ k'] == w[j-k' ~ j] 呢?(圖中粉色部分)

    kmp 演算法簡介及 next 陣列推導
    k' = next[k]得到w[0 ~ k'-1] == w[k-k' ~ k-1]

    next[j] = k得到w[0 ~ k-1] == w[j-k ~ j-1]

    因為 w[0 ~ k-1] == w[j-k ~ j-1] 所以 w[k-k' ~ k-1] == w[j-k' ~ j-1]

    這裡屬於感性證明,能力不足暫時無法使用公式證明

    所以 w[0 ~ k'-1] == w[j-k' ~ j-1]

    又因為 w[k'] == w[j] 所以 w[0 ~ k'] == w[j-k' ~ j]

    w[0 ~ k'] == w[j-k' ~ j],得到 next[j+1] = k' + 1

    這是假如此時 w[k'] == w[j]的情況,但大多數情況是w[k'] != w[j]的,這種情況我們在演算法實現中討論。

演算法實現

next陣列實現

private function getNext($word): array
{
    $next = [-1];
    $len = strlen($word);
    $k = -1;
    $j = 0;

    while ($j < $len - 1) {
        if ($k == -1 || $word[$j] == $word[$k]) {
            $next[++$j] = ++$k;
        } else {
            $k = $next[$k];
        }
    }

    return $next;
}
複製程式碼

next[0] = -1 中-1是一種特殊標誌,方便進行判斷。在上面的w[k] != w[j]時,我們另 k = next[k]然後再去判斷w[k]是否等於w[j],如果還是不相等,則再另k = next[k]像這樣一直迴圈下去。 但是迴圈總歸有個盡頭,在盡頭會出現這種情況,此時k = 0,w[k] != w[j],按照演算法k = next[0] = -1

因此當我們看到 k = -1時,我們就能夠知道 w[0 ~ k]不存在字首與字尾的交集,既 max(0~k的字首集合 ∩ 0~k的字尾集合) = 0 所以我們另 next[k+1] = 0即可

上面的演算法為了保持簡潔性,令特殊值為-1,使得一個if,else可以覆蓋三種情況,當然你用下面的寫法也是一個意思

if($k == -1) {
    $next[++$j] = 0;
} elseif ($word[$j] == $word[$k]) {
    $next[++$j] = ++$k;
} else {
    $k = $next[$k];
}

複製程式碼

詳細實現含測試用例 github.com/weiwenhao/a…

kmp演算法實際上在字串匹配中使用的情況並不多,雖然其時間複雜度是O(m+n),但實際上其表現跟樸素演算法並不會差太多,在學習的過程中其實也應該發現了,能夠部分匹配的情況其實不多見。 不得不說kmp演算法非常的難以理解,細節太多很容易陷入一個拆東牆補西牆的情況,各種牛角尖鑽到停不下來。但是其狀態機的思想,以及next陣列的推導過程卻非常值得學習。

相關文章