kmp 演算法

zybing發表於2021-09-09

前言

字串匹配演算法是常見的一種字串操作,其是在一個主字串中查詢一個子字串(也叫模式串),即判斷模式串是否是主字串的一個子串。最簡單的做法是兩個迴圈分別比較每個字元,直到找到匹配的位置或遍歷結束。這種做法需要消耗 O(m * n) (m 表示主字串的長度,n 表示模式串的長度) 的時間複雜度。而 KMP 演算法則可以透過跳過一些重複的比較過程將時間複雜度控制在 O(m + n) (m 表示主字串的長度,n 表示模式串的長度)。

傳統字串匹配演算法

基本過程

傳統的字串匹配演算法,是用兩個迴圈,外層迴圈遍歷主字串,裡層迴圈遍歷模式串。在外層迴圈的每一次迴圈過程,都以當前主字串指標的位置為起始位置,遍歷 n 個字元( n 為模式串長度),與模式串的字元一 一進行比較,直到所有字元匹配或遍歷結束。其過程大致如下圖所示

當主字串和模式串的當前位置字元匹配時,指標向後移動一位

圖片描述

當發現有字元不匹配時,主字串的查詢起始位置相比上次的查詢起始位置加一,模式串的查詢起始位置歸零

圖片描述

重複上述過程直到所有字元匹配或遍歷結束

圖片描述

程式程式碼

/**
 * 字串匹配:如果 needle 是 haystack 的子字串,則輸出最先匹配的 haystack 的起始下標,否則返回 -1
 * 輸入引數:haystack 主字串; needle 模式串
 * 返回:-1 或其他非負整數。-1:needle 不是 haystack 的子字串,其他非負整數:最先匹配的 haystack 的起始下標
 */
public int strStr(String haystack, String needle) 
{
    if (haystack == null || needle == null || needle.length() > haystack.length())
    {
        return -1;
    }

    if (needle.length() == 0)
    {
        return 0;
    }

    for (int i = 0; i < haystack.length(); i++)
    {
        int j = 0;
        for (; j < needle.length(); j++)
        {
            if (i + j >= haystack.length() || haystack.charAt(i + j) != needle.charAt(j))
            {
                break;
            }
        }

        if (j == needle.length())
        {
            return i;
        }
    }

    return -1;
}

KMP演算法

由於傳統的字串匹配演算法在面對字元不匹配時,會將模式串的查詢起始位置歸零,而主字串的查詢起始位置僅僅是相比上次增加一而已。從這個地方進行重新比較,將會產生大量的重複的比較。例如主字串是abababababc,模式串是ababc,採用傳統的字串匹配演算法將會對abab在主字串abababababc中進行重複的比較。而實際上,在模式串c字元匹配失敗時,由於abab的最長相同字首字尾是ab,所以下次重新進行查詢匹配時,模式串的查詢起始位置可以從第三個字元開始,而主字串的查詢起始位置也可以從失配的地方開始。這就可以減少字串ab的重複匹配,而這也就是KMP演算法的主要思想。

基本過程

KMP演算法主要是先對模式串求解其部分匹配表(網上也有稱之為 next 陣列),即字串的最長公共字首字尾字串長度,主要是用來在字串發生失配時決定模式串指標的移動。然後用兩個指標分別指向主字串和模式串,比較兩個指標指向的字元是否相同,如果相同,則分別將指標向後移動一位,如果不同,則保持主字串指標不動,將模式串的指標根據其部分匹配表(或 next 陣列)移動位置,進行下一輪比較。

流程圖

其大致流程如下圖所示:

圖片描述

示例

仍以“傳統字串匹配演算法”章節中的例子為例,KMP演算法的匹配過程大致如下

當主字串和模式串的的指標指向的字元匹配時,兩者的指標均向後移動一位

圖片描述

當主字串和模式串的指標指向的字元不匹配時,如果模式串指標不是在第一位,則保持主字串指標不動,根據部分匹配表將模式串的指標進行移動;如果模式串指標在第一位,則保持模式串指標不動,將主字串指標向後移動一位

圖片描述

重複上述過程直到所有字元匹配或遍歷結束

圖片描述

部分匹配表的作用

部分匹配表是用來在字元匹配失敗時決定模式串指標的移動的。那為什麼這種方式有效呢。現在來解釋一下為什麼在字元不匹配時可以根據部分匹配表直接跳過模式串部分的字元,並且可以保持主字串的指標不動。

首先,假設已經正確的求出了模式串的部分匹配表,在某個字元匹配失敗時,模式串的前一個字元對應的部分匹配表的值為 n(為簡單起見,假設為 2),根據KMP演算法,可以得知模式串的指標應該指向第 3 個字元。如下圖所示

圖片描述

可是為什麼是第 3 個字元,而不是大於 3 的其他字元呢(為簡單起見,假設為第 4 個字元)。如下圖所示,假設可以指標跳到第 4 個字元,則意味著前 3 個字元與主字元指標的前 3 個字元相等,那麼就可以推出剛才失配的位置前面的模式串子串的最長公共字首字尾是 3 而不是 2,這與部分匹配表中的值 2 相矛盾,而在此之前已經假設了部分匹配表是正確的,所以在匹配失敗時,模式串的指標只能跳到第 3 個字元而不是第 4 個字元。

圖片描述

因此在字元匹配失敗時,可以根據部分匹配表直接跳到將模式的指標移動到正確的位置並且可以跳過一些無效的重複比較項,從而提高效率。

最長公共字首字尾

部分匹配表是根據字串的最長公共字首字尾求出的。那麼什麼是字串的最長公共字首字尾呢。假設一個字串的長度為 m ,那麼字串的最長公共字首字尾即是滿足以下要求的由 [0, n] 組成子字串:

  • 存在正整數 n,n <= m
  • 由 [0, n] 組成的字串與由 [m - n, m]組成的字串相等
  • 若 n == m,則要求所有字元都相等,若 n != m,則由 [0, n + 1] 組成的字串與由 [m - n - 1, m]組成的字串不相等

簡單來說,就是一個字串的最開始的前 n (n 要儘可能的大)個字元和最後的 n 個字元相等。例如:aaaaaa的最長公共字首字尾是aaaaaa,而aaaabbbaaabb沒有最長的公共字首字尾,而aaaabbaaa的最長公共字首字尾是aaa

部分匹配表求解過程

部分匹配表(也稱為 next 陣列),主要的求解過程是用兩個指標,第一個指標指向第一個字元,第二個指標指向第二個字元,然後比較兩個指標指向的字元是否相等,如果相等,則當前第二個指標指向的字元的部分匹配表值是第一個指標所在位置的下標加一,然後分別將兩個指標向後移動一位;如果兩個指標指向的字元不相等,則取出第一個指標的前一位字元對應的部分匹配表的值,將第一個指標跳到此位置,然後與第二個指標指向的字元進行比較,如果不相等則重複上述過程直到相等或回到第一個字元,如果回到了第一個字元還沒有遇到字元相等的情況,則值是0,然後將第二個指標往後移動一位,否則將第一個指標所在位置的下標加一得到當前第二個指標指向的字元的部分匹配表值,然後分別將兩個指標往後移動一位。重複上述過程直到第二個指標到字串末尾結束。

流程圖

其大致流程圖如下

圖片描述

示例

仍以“傳統字串匹配演算法”章節中的模式串為例,其求解部分匹配表的流程大致如下

第一次迴圈時,由於 k 和 i 所在位置的字元相等,所以 i 位置的部分匹配表的值為 k 的下標位置加一,然後將 k 和 i 分別向後移動一位

圖片描述

第二次迴圈時,由於 k 和 i 所在位置的字元不匹配,所以將 k 移動到 k 前一個位置的部分匹配表的值對應的地方,即 0 的位置,此時與 i 位置的字元仍然不匹配,由於此時 k 已經是在字串的第一個位置,所以此時 i 所在位置的部分匹配表的值為 0,然後將 i 向後移動一位

第三次迴圈時,由於 k 和 i 所在位置的字元相等,所以 i 位置的部分匹配表的值為 k 的下標位置加一,然後將 k 和 i 分別向後移動一位

圖片描述

第四次迴圈時,由於 k 和 i 所在位置的字元相等,所以 i 位置的部分匹配表的值為 k 的下標位置加一,然後將 k 和 i 分別向後移動一位

第五次迴圈時,由於 k 和 i 所在位置的字元不匹配,所以將 k 移動到 k 前一個位置的部分匹配表的值對應的地方,即 1 的位置,此時與 i 位置的字元相等,所以 i 位置的部分匹配表的值為此時 k 的下標位置加一,即 2,然後將 k 和 i 分別向後移動一位

圖片描述

第六次迴圈時,由於 k 和 i 所在位置的字元不匹配,所以將 k 移動到 k 前一個位置的部分匹配表的值對應的地方,即 1 的位置,此時與 i 位置的字元仍然不匹配,由於此時 k 不是在字串的第一個位置,所以繼續將 k 移動到 k 前一個位置的部分匹配表的值對應的地方,即 0 的位置,此時與 i 位置的字元仍然不匹配,由於此時 k 已經是在字串的第一個位置,所以此時 i 所在位置的部分匹配表的值為 0。由於 i 已經到達字串最後一位,所以結束迴圈。至此,字串的部分匹配表也求解完畢

圖片描述

程式程式碼

/**
 * 獲取字串的部分匹配表(next陣列),即截止當前位置的字串的最長公共字首字尾的長度(第一個位置預設為0)
 * 例如:輸入"aabaaac"
 *      輸出:[0, 1, 0, 1, 2, 2, 0]
 */
public int[] getPartialMatchTable(String str)
{
    if (str == null || str.length() == 0)
    {
        return null;
    }

    int[] partialMatchTable = new int[str.length()];
    int pointer = 0;
    partialMatchTable[0] = 0;
    for (int i = 1; i < str.length(); i++)
    {
        while (str.charAt(i) != str.charAt(pointer) && pointer > 0)
        {
            pointer = partialMatchTable[pointer - 1];
        }

        if (str.charAt(i) == str.charAt(pointer))
        {
            partialMatchTable[i] = pointer + 1;
            pointer++;
        }
        else
        {
            partialMatchTable[i] = 0;
        }
    }
    return partialMatchTable;
}

/**
 * 字串匹配:如果 needle 是 haystack 的子字串,則輸出最先匹配的 haystack 的起始下標,否則返回 -1
 * 輸入引數:haystack 主字串; needle 模式串
 * 返回:-1 或其他非負整數。-1:needle 不是 haystack 的子字串,其他非負整數:最先匹配的 haystack 的起始下標
 */
public int strStr(String haystack, String needle) 
{
    if (haystack == null || needle == null)
    {
        return -1;
    }

    if (needle.length() == 0)
    {
        return 0;
    }

    int[] partialMatchTable = getPartialMatchTable(needle);
    int index = 0; // 模式串的指標
    int i = 0;     // 主字串的指標
    while (i < haystack.length())
    {
        if (haystack.charAt(i) == needle.charAt(index))
        {
            if (index == needle.length() - 1)
            {
                return i - index;
            }

            index++;
            i++;
        }
        else
        {
            // 如果模式串指標大於0,說明已經進行了部分匹配,此時只需要根據部分匹配表移動模式串的指標即可,不需要移動主字串的指標
            if (index > 0)
            {
                index = partialMatchTable[index - 1];
            }
            // 如果模式串指標為0,說明第一個字元就匹配失敗,此時需要移動主字串指向到下一個位置
            else
            {
                i++;
            }
        }
    }

    return -1;
}

參考資料

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/855/viewspace-2824177/,如需轉載,請註明出處,否則將追究法律責任。

相關文章