初學者學 KMP 演算法

第一次碰 KMP,被搞得暈頭轉向,花了兩天才搞定。只好趕緊寫下來,以免半小時後就忘記。


要怎麼在主串裡找到某個子串(模式)呢?

比如 abcabcabe 是主串,要怎麼找到 abcabe 這個子串?

最簡單的想法,就是從主串的第一個字跟子串的第一個字開始比,如果第一個字相同,再接著比第二個字,若第二個字也相同,再接著比第三個字⋯⋯如果不同,則主串回到第二個字、子串回到第一個字,接著主串的第二個字跟子串的第一個字比,如果相同,則主串的第三個字跟子串的第二個字比;如果不同,則主串回到第三個字,子串回到第一個字⋯⋯以此類推。

用程式碼表示就是這樣:

function indexOfByBf(s, p) {
  let sI = 0;
  let pI = 0;

  while (sI < s.length && pI < p.length) {
    if (s[sI] === p[pI]) {
      sI += 1;
      pI += 1;
    } else {
      sI = sI - pI + 1;
      pI = 0;
    }
  }

  if (pI >= p.length) {
    return sI - p.length;
  } else {
    return -1;
  }
}

這個算法直觀,又叫暴力(Brute Force)算法。暴力儘管好用,但當主串一長,便顯得沒效率。它的時間複雜度最好和最壞分別是 O(n + m)O(n * m)(假設主串的長度為 n,子串的長度為 m)。

KMP 算法

要怎麼提高效率呢?首先,我們可以把匹配的時間複雜度分成兩個部分,一個是比較的趟(次)數,另一個是比較的字數。在暴力算法中,前者在最壞情況下為主串的長度,後者則為子串的長度。我們無法降低比較的字數(要找 abc,就只能 abc 一一比對,不然呢?),但可以減少比較的趟數。

要怎麼減少呢?這要看我們手上握有什麼資訊。當我們知道這一次比較找不到子串、準備回頭的那一刻,我們究竟獲得了什麼?這些資訊有辦法降低我們接下來比較的趟數嗎?

讓我們看看一個範例,主串是 abcabcabcabe、子串是 abcabe。第一次比較:

✔✔✔✔✔✗
abcabcabcabe
abcabe
✔✔✔✔✔✗

我們可以看到子串的第六個字 e 比較失敗了,這時暴力算法的第二次比較會這麼做:

 ↓
abcabcabcabe
 abcabe
 ↑

將子串向右移一格,也就是從主串的第個字開始跟子串的第個字比。不用一秒,你就知道這次比較不會成功(ba 不一樣);再將子串向右移(從主串的第個字開始跟子串的第個字比),也不會成功⋯⋯我們有沒有辦法直接移到可能會成功的位置?

回到第一次比較失敗的那刻,滿足「可能成功」的主串位置的條件是什麼?至少那個位置開始的第一個字要與子串的第一個字相同吧!當然,第二個字相同也很好,第三個字相同更好⋯⋯依此邏輯,我們可以把我們要找的東西描述為:主串中的某個位置,這個位置開始的字符能最多地與子串的字符相同。

不過,還未比較的主串字符(即主串的倒數六個字符 abcabe),由於我們不知道它們的長相,因此也無從得知它們哪個位置能最多地與子串的字符相同。我們只能從已比較過且確認相符的字符去尋找正確的位置,即主串的前五個字符 abcab而這五個字符,其實也就是子串的前五個字符

單用眼睛看,我們可以很快地發現這個位置:

   ↓
abcabcabcabe
   abcabe
   ↑

但程式沒辦法這樣「看」,它需要方法去推導出來。這個方法就是,拿子串匹配成功的部分(即 abcab)的前綴去對準自己的後綴,而且這個對準的字符數要最多;換句話說,我們在找的是 abcab次長共同前後綴(Longest Proper Prefix Which Is Also Suffix,以下簡稱為 LPS。會說次長,是因為這個前後綴不能等於字串本身)。

一樣,先用簡單的方法來找:abcab 的(真)前綴有 aababcabca;(真)後綴有 babcabbcab。LPS 顯然是 ab,它的長度為 2。

得到這個數字後,我們就可以知道要從哪個位置開始比了。回到第一次比較失敗的那一刻:

     ✗
abcabcabcabe
abcabe
     ✗

現在,我們不要動主串指針的位置,只要將子串向右移三位:

     ↓
abcabcabcabe
   abcabe
     ↑

我們便可以從 c 開始比了。省去了跟主串的第二個字和第三個字比較的功(即減少比較的趟數),也跳過了從主串的第四個字開始比較時的頭兩個字 ab 的比較——是不是比暴力算法有效率多了?

到此,我們可以把 KMP 算法的程式寫出來了:

function indexOfByKmp(s, p) {
  let sI = 0;
  let pI = 0;
  const next = getNext(s);
  while (sI < s.length && pI < p.length) {
    if (s[sI] === p[pI]) {
      sI += 1;
      pI += 1;
    } else if (pI <= 0) {
      sI += 1;
    } else {
      // 主串指針不動,只動子串指針
      pI = next[pI - 1];
    }
  }

  if (pI >= p.length) {
    return sI - p.length;
  } else {
    return -1;
  }
}

等等,getNext 是什麼東西?它回傳的是一個陣列,這個陣列含有一系列部分子串的 LPS 長,它是讓 KMP 算法得以運行的關鍵,接下來就讓我們來看看怎麼求得這個陣列。

Next 表

以子串 abcabe 為例:

abcabe
000120

這張表的意思是:a 的 LPS 長為 0,ab 為 0、abc 也為 0、abca 則為 1、abcab 為 2、abcabe 為 0。

要怎麼求得這張表呢?先看最簡單的方法:

function getNext(p) {
  const next = [];

  for (let i = 0; i < p.length; i += 1) {
    const pSub = p.slice(0, i + 1);

    for (let j = pSub.length - 1; j >= 1; j -= 1) {
      if (pSub.slice(0, j) === pSub.slice(-j)) {
        next[i] = j;

        break;
      }
    }

    if (next[i] === undefined) {
      next[i] = 0;
    }
  }

  return next;
}

裡頭有兩個迴圈,時間複雜度為 O(m^2),效率顯然不佳,會拖累 KMP 算法的整體速度。

要怎麼提高建造這張表的效率呢?思路是這樣的:拿完整字串的前綴去對準部分字串的後綴。假設有一個字串 p,值為 abcabffabcabc,現在我們要求前兩個字符的 LPS 長(第一個字的 LPS 長無論如何都是 0,須先補上):

0↓
ab
 abcabffabcabc
 ↑

讓完整字串的前綴去跟 ab 的後綴比,若相同,則 LPS 長加一;若不同,且完整字串正在被比較的前綴為第一個前綴(即第一個字符 a,那 LPS 長便為 0。接著繼續拿完整字串的前綴去跟 abc 的後綴比:

00↓
abc
  abcabffabcabc
  ↑

LPS 長也為 0,再繼續往下:

000↓
abca
   abcabffabcabc
   ↑

喔!現在 abca 的後綴與完整字串的第一個前綴相同了,LPS 長加一。再繼續往下,拿完整字串的第二個前綴跟 abcab 的第二個後綴比:

0001↓
abcab
   abcabffabcabc
    ↑

一樣!LPS 長再加一!

00012↓
abcabf
   abcabffabcabc
     ↑

又遇到不同了,這邊也是直接設為 0 嗎?其實不能,中間還需要經過一些計算,但這邊先不細講,因為這次結果的確是 0,計算的效果不明顯。再繼續往下吧!會告訴你答案的!

讓我們把求 LPS 長度的過程加快,直接到最後一個字符:

000120012345↓
abcabffabcabc
       abcabffabcabc
            ↑

現在我們又遇到比較失敗的字了,也是直接設為 0 嗎?不能!在這之前,我們還要做一些掙扎——也許我們可以試看看將完整字串向右移幾位(亦即將其指針向左移),再繼續比。但該移多少呢?

不覺得這個問題有點熟悉嗎?似乎在前面講 KMP 時說過。你可以把現在遇到的問題想像成也是在做字串匹配:abcabffabcabc 是主串,abcabf 是子串,當比較失敗時,我們要做什麼?

abcab 的 LPS 長度!那我們還要再建一個 Next 表嗎?不用!早就建好了!不就是 2 嗎?

    ↓
000120012345
abcabffabcabc
    ↑  abcabffabcabc

現在,將完整字串的指針移到索引 2

000120012345↓
abcabffabcabc
          abcabffabcabc
            ↑

太棒了!再度配對成功!LPS 長為 3(注意,不是 6 喔)。

(前面沒說明的第六個字符設為 0,其實也要經過同樣的過程,只是因為再次比較時仍失敗,而此時完整字串正在被比較的前綴是第一個前綴,因此才會結果看起來都一樣)

把上述過程轉換成程式碼,便是這樣:

function getNext(p) {
  const next = [0];
  
  // 部分字串的指針
  let iPartial = 1;
  
  // 完整字串的指針
  let iWhole = iPartial - 1;

  while (iPartial < p.length) {
    if (p[iPartial] === p[iWhole]) {
      iPartial += 1;
      iWhole += 1;

      next[iPartial - 1] = iWhole;
    } else if (iWhole <= 0) { // 當完整字串正在被比較的前綴是第一個前綴
      iPartial += 1;

      next[iPartial - 1] = 0;
    } else {
      iWhole = next[iWhole - 1];
    }
  }

  return next;
}

時間複雜度從 O(m^2) 降到 O(m),與前述 KMP 的主體 O(n) 加起來,時間複雜度共為 O(n + m)

那些長得不太一樣的 Next 表

你如果看過其它講 KMP 算法的文章,可能會發現它們的 Next 表長得跟這裡的 Next 表不太一樣:

 abcabe
 000120 <- 這裡的
-100012 <- 別人的

不同之處不過是後者將前者的數字都往右移了一位,並在第一個位置補上 -1。為什麼要這麼做呢?其實只是為了計算上的方便。

仔細看前面寫的 indexOfByKmpgetNext 兩個函數,裡面都有這個處理:

function indexOfByKmp(s, p) {
  let sI = 0;
  let pI = 0;

  const next = getNext(s);

  while (sI < s.length && pI < p.length) {
    if (s[sI] === p[pI]) {
      sI += 1;
      pI += 1;
    } else if (pI <= 0) {      sI += 1;    } else {
      pI = next[pI - 1];
    }
  }

  if (pI >= p.length) {
    return sI - p.length;
  } else {
    return -1;
  }
}

function getNext(p) {
  const next = [0];
  let iPartial = 1;
  let iWhole = iPartial - 1;

  while (iPartial < p.length) {
    if (p[iPartial] === p[iWhole]) {
      iPartial += 1;
      iWhole += 1;

      next[iPartial - 1] = iWhole;
    } else if (iWhole <= 0) {      iPartial += 1;      next[iPartial - 1] = 0;    } else {
      iWhole = next[iWhole - 1];
    }
  }

  return next;
}

這是為了防止當 pIiWhole 等於 0 的時候會出的問題。有人覺得這樣額外開一個分支處理很麻煩,於是就做了上述移位補 -1 的動作,並把算法改寫如下:

function indexOfByKmp(s, p) {
  let sI = 0;
  let pI = 0;
  const next = getNext(s);

  while (sI < s.length && pI < p.length) {
    if (pI === -1 || s[sI] === p[pI]) {      sI += 1;
      pI += 1;
    } else {
      pI = next[pI];    }
  }

  if (pI >= p.length) {
    return sI - p.length;
  } else {
    return -1;
  }
}

function getNext(p) {
  const next = [-1];  let iPartial = 0;  let iWhole = iPartial - 1;
  while (iPartial < p.length) {
    if (iWhole === -1 || p[iPartial] === p[iWhole]) {      iPartial += 1;
      iWhole += 1;

      next[iPartial] = iWhole;    } else {
      iWhole = next[iWhole];    }
  }

  return next;
}

自己實際拿筆跟紙跑過一遍,會發現原理是相同的,答案當然也是一樣的噢。

相關資料