跳轉到

第一部分:字串演算法

< 返回目錄 | 下一題:重複內容偵測 >


場景:用戶搜尋「機器學習」

用戶在搜尋框輸入「機器學習」,按下 Enter。

你的系統要在**數十億個網頁**中,找出所有包含「機器學習」這四個字的頁面。

最直覺的方法:對每個網頁,從頭到尾掃一遍,看有沒有這四個字。

「等等,這樣每個網頁都要掃 \(O(nm)\) 次?太慢了!」

設文本長度 \(n\),模式長度 \(m\)。暴力法在最壞情況下是 \(O(nm)\)

\(n = 10^6\)(一個網頁)、\(m = 100\)(一個查詢詞組),這還勉強可以。但如果要搜尋的模式很長,或者文本有特殊結構,暴力法會非常慢。

你需要一個**線性時間**的字串匹配演算法。


第 1 題:網頁內容搜尋 - KMP 演算法

KMP(Knuth-Morris-Pratt)演算法是字串匹配的經典解法,能在 \(O(n + m)\) 時間內完成匹配。


(a) 暴力法的問題在哪?

3 分大二

你需要知道:暴力法為什麼慢?浪費了什麼資訊?

題目

  1. 寫出暴力字串匹配的虛擬碼

  2. 分析最壞時間複雜度,並給出一個達到最壞情況的例子

  3. 觀察下面的匹配過程,指出暴力法「浪費」了什麼資訊

Text:    A B A B A B A C A B A B
Pattern: A B A B A C
         ↑ ↑ ↑ ↑ ↑ ✗ (第 6 位不匹配)

暴力法:回到 Text[1],Pattern[0] 重新開始
Text:    A B A B A B A C A B A B
Pattern:   A B A B A C
           ✗ (第 1 位就不匹配)
提示
  • 暴力法每次失敗後,Text 指標回退,Pattern 指標歸零
  • 但我們已經知道 Pattern 的前幾個字元長什麼樣...
解答

1. 暴力法虛擬碼

def brute_force(text, pattern):
    n, m = len(text), len(pattern)
    for i in range(n - m + 1):
        j = 0
        while j < m and text[i + j] == pattern[j]:
            j += 1
        if j == m:
            return i  # 找到匹配
    return -1  # 沒找到

2. 最壞時間複雜度

\(O(nm)\)

最壞情況例子

Text:    A A A A A A A A A B
Pattern: A A A A B

每次都要比較 5 個字元才發現不匹配,然後只移動 1 格。

總比較次數:\((n - m + 1) \times m = O(nm)\)

3. 浪費的資訊

在第一次匹配中,我們已經知道:

  • Text[0..4] = "ABABA"(匹配了 5 個字元)
  • Pattern[0..4] = "ABABA"

Text[5] ≠ Pattern[5] 時,暴力法回到 Text[1] 重新比較。

但我們**已經知道** Text[1] = 'B',而 Pattern[0] = 'A',所以 Text[1] 開頭不可能匹配!

更聰明的做法:利用 Pattern 本身的結構,不回退 Text 指標,只調整 Pattern 指標。

這就是 KMP 的核心思想

KMP 的關鍵洞察:Pattern 的前綴可能等於某個後綴

例如 ABABA: - 前綴 ABA = 後綴 ABA(長度 3) - 前綴 A = 後綴 A(長度 1)

當匹配失敗時,不需要從頭開始。如果已匹配的部分有「前綴 = 後綴」的結構,可以直接跳到那個位置繼續比較。


(b) Prefix Function(失敗函數)

4 分大三

你需要知道:KMP 的核心是 Prefix Function,它記錄了什麼資訊?

題目

  1. 定義 Prefix Function \(\pi[i]\)

  2. 手算 Pattern = "ABABAC" 的 Prefix Function

  3. 說明當 Pattern[j] 匹配失敗時,如何利用 \(\pi\) 決定下一步

提示
  • \(\pi[i]\) = Pattern[0..i] 的「最長真前綴 = 真後綴」的長度
  • 「真」表示不能是自己本身
解答

1. Prefix Function 定義

對於 Pattern \(P[0..m-1]\),定義:

\[\pi[i] = \max\{k : k < i+1 \text{ 且 } P[0..k-1] = P[i-k+1..i]\}\]

白話文:\(\pi[i]\)\(P[0..i]\) 這個字串中,最長的「真前綴 = 真後綴」 的長度。

2. 手算 "ABABAC"

\(i\) \(P[0..i]\) 真前綴 = 真後綴 \(\pi[i]\)
0 A 無(長度 1 沒有真前綴) 0
1 AB 0
2 ABA A = A 1
3 ABAB AB = AB 2
4 ABABA ABA = ABA 3
5 ABABAC 無(C 打破了結構) 0

結果\(\pi = [0, 0, 1, 2, 3, 0]\)

3. 匹配失敗時如何利用 \(\pi\)

假設我們正在比較 Text[i]Pattern[j],發現不匹配。

暴力法\(i \leftarrow i - j + 1\)\(j \leftarrow 0\)(回退 Text)

KMP\(j \leftarrow \pi[j-1]\)(不回退 Text!)

為什麼?因為 \(\pi[j-1]\) 告訴我們:Pattern[0..j-1] 的前 \(\pi[j-1]\) 個字元,等於它的後 \(\pi[j-1]\) 個字元。

而這後 \(\pi[j-1]\) 個字元,正是我們剛才在 Text 中匹配過的!所以不需要重新比較。

Text:    ... X X X X X X ? ...
             └───────┘
             已匹配的部分 = Pattern[0..j-1]

Pattern:     A B A B A C
             └─────┘
             π[4] = 3,表示前 3 個 = 後 3 個

失敗後:
Pattern:         A B A B A C
                 └─┘
                 從 Pattern[3] 繼續比較
數學小結:Prefix Function
概念 說明
\(\pi[i]\) \(P[0..i]\) 的最長「真前綴 = 真後綴」長度
計算時間 \(O(m)\)
用途 匹配失敗時,決定 Pattern 指標跳到哪

© Prefix Function 的計算

4 分大三

你需要知道:如何在 \(O(m)\) 時間內計算 Prefix Function?

題目

  1. 寫出計算 Prefix Function 的演算法

  2. 證明時間複雜度是 \(O(m)\)

  3. 考古題練習:對於 Pattern = "ABCDABCE",計算 \(\pi[7]\) 的值(110 Q10)

提示
  • 關鍵:\(\pi[i]\) 可以從 \(\pi[i-1]\) 遞推
  • 如果 \(P[\pi[i-1]] = P[i]\),則 \(\pi[i] = \pi[i-1] + 1\)
  • 否則,嘗試更短的前綴
解答

1. 計算演算法

def compute_prefix(pattern):
    m = len(pattern)
    pi = [0] * m
    k = 0  # 當前匹配的前綴長度

    for i in range(1, m):
        # 當前字元不匹配時,回退到更短的前綴
        while k > 0 and pattern[k] != pattern[i]:
            k = pi[k - 1]

        # 如果匹配,前綴長度 +1
        if pattern[k] == pattern[i]:
            k += 1

        pi[i] = k

    return pi

2. 時間複雜度證明

觀察變數 \(k\): - k += 1 最多執行 \(m - 1\) 次(每次 \(i\) 增加 1,\(k\) 最多增加 1) - k = pi[k-1] 每次至少讓 \(k\) 減少 1

由於 \(k \geq 0\)\(k\) 的增加總量 \(\leq m\),所以減少的總量也 \(\leq m\)

因此 while 迴圈的總執行次數 \(\leq m\)

總時間複雜度:\(O(m)\)

3. 考古題:"ABCDABCE"

\(i\) \(P[0..i]\) \(\pi[i]\)
0 A 0
1 AB 0
2 ABC 0
3 ABCD 0
4 ABCDA 1
5 ABCDAB 2
6 ABCDABC 3
7 ABCDABCE 0

答案:\(\pi[7] = 0\)

(因為 ABCDABCE 沒有「真前綴 = 真後綴」,E 打破了結構)


(d) KMP 演算法完整實現

3 分大二

你需要知道:如何用 Prefix Function 實現線性時間字串匹配?

題目

  1. 寫出完整的 KMP 演算法

  2. 分析時間複雜度

  3. 手動執行:Text = "ABABABABAC",Pattern = "ABABAC"

解答

1. KMP 演算法

def kmp_search(text, pattern):
    n, m = len(text), len(pattern)
    if m == 0:
        return 0

    # 預處理:計算 Prefix Function
    pi = compute_prefix(pattern)

    # 匹配
    j = 0  # Pattern 指標
    results = []

    for i in range(n):  # Text 指標
        # 不匹配時,利用 pi 跳轉
        while j > 0 and text[i] != pattern[j]:
            j = pi[j - 1]

        # 匹配時,前進
        if text[i] == pattern[j]:
            j += 1

        # 找到完整匹配
        if j == m:
            results.append(i - m + 1)
            j = pi[j - 1]  # 繼續找下一個匹配

    return results

2. 時間複雜度

  • 預處理:\(O(m)\)
  • 匹配:\(O(n)\)(同樣的分析,\(j\) 的增減總量都是 \(O(n)\)

總時間:\(O(n + m)\)

3. 手動執行

Text:    A B A B A B A B A C
Pattern: A B A B A C
pi:      [0,0,1,2,3,0]

i=0: T[0]=A, P[0]=A, j=1
i=1: T[1]=B, P[1]=B, j=2
i=2: T[2]=A, P[2]=A, j=3
i=3: T[3]=B, P[3]=B, j=4
i=4: T[4]=A, P[4]=A, j=5
i=5: T[5]=B, P[5]=C, 不匹配!j=pi[4]=3
     T[5]=B, P[3]=B, j=4
i=6: T[6]=A, P[4]=A, j=5
i=7: T[7]=B, P[5]=C, 不匹配!j=pi[4]=3
     T[7]=B, P[3]=B, j=4
i=8: T[8]=A, P[4]=A, j=5
i=9: T[9]=C, P[5]=C, j=6 ✓

找到匹配!位置 = 9 - 6 + 1 = 4

答案:在位置 4 找到匹配


(e) 有限自動機表示

4 分碩一

你需要知道:KMP 可以用有限自動機(DFA)來理解

題目(改編自 110 Q11):

給定字串匹配的狀態轉移表:

狀態 A B C
0 1 0 0
1 1 2 0
2 3 0 0
3 1 4 0
4 3 0 5
5 (接受)
  1. 推導出這個 DFA 對應的 Pattern 是什麼

  2. 解釋狀態轉移和 Prefix Function 的關係

提示
  • 狀態 \(i\) 表示「已匹配 Pattern 的前 \(i\) 個字元」
  • 轉移 \(\delta(i, c)\) = 「在狀態 \(i\) 遇到字元 \(c\),下一個狀態是什麼」
解答

1. 推導 Pattern

從狀態轉移表分析: - 狀態 0 → 1:讀 A(Pattern[0] = A) - 狀態 1 → 2:讀 B(Pattern[1] = B) - 狀態 2 → 3:讀 A(Pattern[2] = A) - 狀態 3 → 4:讀 B(Pattern[3] = B) - 狀態 4 → 5:讀 C(Pattern[4] = C)

Pattern = "ABABC"

驗證一些轉移: - \(\delta(2, A) = 3\):在狀態 2(已匹配 "AB"),遇到 A,變成狀態 3(已匹配 "ABA") - \(\delta(4, A) = 3\):在狀態 4(已匹配 "ABAB"),遇到 A 而非 C。此時已匹配的後綴 "ABA" 是 Pattern 的前綴,所以跳到狀態 3

2. 與 Prefix Function 的關係

\(P[j] \neq c\) 時:

\[\delta(j, c) = \delta(\pi[j-1], c) \quad \text{if } j > 0\]

也就是說,DFA 的失敗轉移等價於 KMP 的 \(\pi\) 函數跳轉。

**DFA 版本**需要 \(O(m|\Sigma|)\) 空間(\(|\Sigma|\) = 字母表大小),但查詢時不需要回退。

**KMP 版本**只需要 \(O(m)\) 空間,但每次失敗可能要多次跳轉。

兩者時間複雜度都是 \(O(n + m)\)


進階挑戰:KMP 執行次數分析

4 分碩一

考古題(112 Q16):

對於 KMP 演算法的以下程式碼,計算第 7 行執行次數的最大值。

1  def kmp(text, pattern):
2      pi = compute_prefix(pattern)
3      j = 0
4      for i in range(len(text)):
5          while j > 0 and text[i] != pattern[j]:
6              j = pi[j - 1]  # 第 6 行
7          if text[i] == pattern[j]:  # 第 7 行
8              j += 1

設 Text 長度 \(n\),Pattern 長度 \(m\)

解答

分析

第 7 行在 for 迴圈內,每次 \(i\) 增加時執行一次。

for 迴圈執行 \(n\) 次,所以**第 7 行最多執行 \(n\) 次**。

這個上界是緊的(tight):當每個字元都匹配時,第 7 行每次都執行。

答案:\(n\)

(注意:第 6 行的執行次數分析更複雜,需要用到 \(j\) 的增減平衡論證)


第 1 題小結:你的字串匹配工具箱

「怎麼快速在網頁中找到關鍵字?」
    ├─► (a) 暴力法太慢
    │       → O(nm),浪費已匹配的資訊
    ├─► (b) 關鍵洞察:Prefix Function
    │       → π[i] = 最長「真前綴 = 真後綴」
    │       → 匹配失敗時,跳到 π[j-1]
    ├─► (c) 計算 Prefix Function
    │       → O(m) 時間
    │       → 遞推:從 π[i-1] 推 π[i]
    ├─► (d) KMP 完整演算法
    │       → O(n + m) 時間
    │       → 永不回退 Text 指標
    └─► (e) DFA 觀點
            → 狀態 = 已匹配長度
            → 轉移 = 下一個狀態
概念 重點
暴力法 \(O(nm)\),回退浪費資訊
Prefix Function \(\pi[i]\) = 最長真前綴=真後綴
KMP \(O(n+m)\),利用 \(\pi\) 跳轉
DFA \(O(m\|\Sigma\|)\) 空間,\(O(n)\) 查詢

< 返回目錄 | 下一題:重複內容偵測 >