KMP — failure function & matching O(n+m)
Knuth-Morris-Pratt dùng failure function (prefix function) để không bao giờ quay lui con trỏ text, đạt O(n+m). Cách xây bảng failure, vì sao nó đúng, và trace từng bước.
TL;DR: Naive matching lãng phí vì mỗi lần lệch nó quay con trỏ text về và so lại từ đầu. KMP loại bỏ lãng phí đó bằng failure function (còn gọi prefix function) lps[i] — độ dài của tiền tố thực sự dài nhất của pattern[0..i] mà đồng thời cũng là hậu tố. Khi lệch tại vị trí j của pattern, thay vì lùi text, ta dịch pattern về lps[j-1] — vì phần đầu pattern đến đó chắc chắn đã khớp. Con trỏ text không bao giờ lùi, nên matching chạy O(n). Xây bảng failure cũng O(m) bằng chính ý tưởng đó. Tổng O(n+m), so với O(n·m) của naive.
Ở bài trước bạn thấy naive matching đạt worst-case O(n·m): với text "aaaa...a" và pattern "aaa...ab", mỗi vị trí khởi đầu so gần hết pattern rồi lệch ở ký tự cuối, rồi lùi con trỏ text về so lại. KMP (Knuth-Morris-Pratt, 1977) hỏi một câu sắc bén: khi đã so khớp được j ký tự rồi mới lệch, chẳng phải ta đã biết j ký tự đó là gì sao? Tại sao phải so lại?
Bài này giải thích failure function — cấu trúc cho phép tái dùng thông tin đã khớp để không bao giờ lùi text — cách xây nó, vì sao nó đúng, và trace đầy đủ.
1. Vì sao naive lãng phí — và KMP tận dụng gì
Xét text T = "ABABABABC" và pattern P = "ABABC".
Naive khớp được ABAB (4 ký tự) rồi lệch tại T[4]='A' vs P[4]='C'. Phản ứng của naive: dịch pattern sang đúng 1 ô, đưa con trỏ text lùi về T[1], so lại từ P[0].
Nhưng ta vừa biết T[0..3] = "ABAB". Bên trong "ABAB", tiền tố "AB" trùng với hậu tố "AB". Nghĩa là sau khi lệch, ta có thể dịch pattern sao cho P[0..1]="AB" thẳng hàng ngay với hậu tố "AB" của đoạn vừa khớp — không cần so lại hai ký tự đó, và con trỏ text đứng yên tại T[4].
Đoạn pattern vừa khớp chính là một đoạn của text. Nếu đoạn đó có "tiền tố trùng hậu tố", ta dịch pattern để tiền tố nhảy tới chỗ hậu tố đang đứng — bỏ qua phần chắc chắn khớp. Con trỏ text không lùi.
2. Failure function — định nghĩa chính xác
Failure function (hay prefix function) của pattern P độ dài m là mảng lps[0..m-1] (lps = longest proper prefix which is also suffix):
lps[i]= độ dài của tiền tố thực sự dài nhất của chuỗi conP[0..i]mà đồng thời là hậu tố củaP[0..i].
"Tiền tố thực sự" (proper prefix) = tiền tố không phải toàn bộ chuỗi (độ dài tối đa i, không phải i+1). Điều này quan trọng: nếu cho phép lấy cả chuỗi thì đáp án luôn là chính nó — vô nghĩa.
Ví dụ P = "ABABC":
| i | P[0..i] | Tiền tố thực sự = hậu tố dài nhất | lps[i] |
|---|---|---|---|
| 0 | A | (không có) | 0 |
| 1 | AB | (không có) | 0 |
| 2 | ABA | A | 1 |
| 3 | ABAB | AB | 2 |
| 4 | ABABC | (không có) | 0 |
Đọc lps[3]=2 nghĩa là: trong "ABAB", hai ký tự đầu "AB" trùng với hai ký tự cuối "AB". Chính con số 2 này cho phép KMP dịch pattern thông minh khi lệch sau khi đã khớp 4 ký tự.
3. Matching: dùng lps để không bao giờ lùi text
Ý tưởng: dùng hai con trỏ — i quét text (không bao giờ giảm), j chỉ vị trí đang so trong pattern. Khi khớp, cả hai tiến. Khi lệch và j vẫn đang dương, ta không lùi i mà chỉ kéo j về lps[j-1].
function KMPSearch(T, P, lps): -- T text độ dài n, P pattern độ dài m
i <- 0 -- con tro text, KHONG bao gio giam
j <- 0 -- con tro pattern
while i < T.length:
if T[i] = P[j]: -- khop mot ky tu
i <- i + 1
j <- j + 1
if j = P.length: -- khop het pattern
báo tìm thấy tại vị trí i - j
j <- lps[j-1] -- tiep tuc tim lan xuat hien ke
else if j > 0: -- lech, nhung da khop duoc j ky tu
j <- lps[j-1] -- dich pattern, i dung yen
else: -- lech ngay ky tu dau (j = 0)
i <- i + 1
return
// Time: O(n) Space: O(1) ngoai bang lps
Điểm mấu chốt nằm ở nhánh else if j > 0: khi lệch, ta đã biết T[i-j .. i-1] khớp P[0..j-1]. Kéo j về lps[j-1] tương đương dịch pattern sao cho tiền tố dài lps[j-1] (đã chắc chắn khớp với hậu tố vừa rồi) thẳng hàng, rồi so tiếp từ vị trí j mới mà không đụng lại text cũ.
3.1 Trace T = "ABABABABC", P = "ABABC"
lps = [0, 0, 1, 2, 0].
| Bước | i | j | T[i] vs P[j] | Hành động |
|---|---|---|---|---|
| 1 | 0 | 0 | A = A | khớp → i=1, j=1 |
| 2 | 1 | 1 | B = B | khớp → i=2, j=2 |
| 3 | 2 | 2 | A = A | khớp → i=3, j=3 |
| 4 | 3 | 3 | B = B | khớp → i=4, j=4 |
| 5 | 4 | 4 | A ≠ C | lệch, j>0 → j = lps[3] = 2 (i đứng yên) |
| 6 | 4 | 2 | A = A | khớp → i=5, j=3 |
| 7 | 5 | 3 | B = B | khớp → i=6, j=4 |
| 8 | 6 | 4 | A ≠ C | lệch, j>0 → j = lps[3] = 2 |
| 9 | 6 | 2 | A = A | khớp → i=7, j=3 |
| 10 | 7 | 3 | B = B | khớp → i=8, j=4 |
| 11 | 8 | 4 | C = C | khớp → i=9, j=5 = m → tìm thấy tại 9-5=4 |
Để ý: con trỏ i chỉ tăng, chưa bao giờ lùi. Ở bước 5 và 8, khi lệch ta nhảy j từ 4 về 2 mà giữ nguyên i — đây chính là chỗ KMP tiết kiệm so với naive (naive sẽ lùi i về và so lại từng vị trí).
flowchart LR
START(["i=0, j=0"]) --> MATCH{"T[i] = P[j]?"}
MATCH -->|"đúng"| ADV["i++, j++"]
ADV --> FULL{"j = m?"}
FULL -->|"đúng"| FOUND(["báo khớp; j <- lps[j-1]"])
FULL -->|"chưa"| MATCH
MATCH -->|"sai, j>0"| JUMP["j <- lps[j-1] (i đứng yên)"]
JUMP --> MATCH
MATCH -->|"sai, j=0"| SKIP["i++"]
SKIP --> MATCH
FOUND --> MATCH4. Xây bảng lps trong O(m)
Mẹo hay nhất của KMP: xây bảng lps bằng chính KMP áp lên pattern so với chính nó. Ta duyệt P và giữ một con trỏ len = độ dài đoạn prefix-suffix khớp hiện tại.
function computeLPS(P):
lps[0] <- 0 -- proper prefix cua 1 ky tu luon rong
len <- 0 -- do dai prefix-suffix dang khop
i <- 1
while i < P.length:
if P[i] = P[len]: -- mo rong duoc doan prefix-suffix
len <- len + 1
lps[i] <- len
i <- i + 1
else if len > 0: -- lech: lui len ve lps cua chinh no
len <- lps[len-1] -- KHONG tang i
else: -- len = 0, khong co prefix-suffix
lps[i] <- 0
i <- i + 1
return lps
// Time: O(m) Space: O(m)
Nhánh else if len > 0 lùi len <- lps[len-1] chính là phép "dịch pattern" áp lên bản thân pattern: nếu không mở rộng được prefix-suffix dài len, thử prefix-suffix ngắn hơn — mà độ dài ngắn hơn đó lại do lps của đoạn đã tính nắm giữ. Đệ quy trên lps đúng vì một prefix-suffix ngắn hơn của P[0..i] phải là prefix-suffix của prefix-suffix dài hơn.
4.1 Trace computeLPS("ABABC")
| i | len | P[i] vs P[len] | Hành động | lps |
|---|---|---|---|---|
| 1 | 0 | B ≠ A | len=0 → lps[1]=0, i=2 | [0,0,,,_] |
| 2 | 0 | A = A | len=1, lps[2]=1, i=3 | [0,0,1,,] |
| 3 | 1 | B = B | len=2, lps[3]=2, i=4 | [0,0,1,2,_] |
| 4 | 2 | C ≠ A (P[2]) | len>0 → len=lps[1]=0 | [0,0,1,2,_] |
| 4 | 0 | C ≠ A (P[0]) | len=0 → lps[4]=0, i=5 | [0,0,1,2,0] |
Kết quả lps = [0,0,1,2,0] — khớp bảng định nghĩa ở phần 2.
5. Vì sao tổng độ phức tạp là O(n+m)
Thoạt nhìn vòng while của matching có nhánh j <- lps[j-1] không tăng i — liệu nó lặp vô hạn hay làm xấu độ phức tạp?
Phân tích amortized (xem amortized analysis): mỗi vòng lặp hoặc tăng i (tối đa n lần) hoặc giảm j (qua j <- lps[j-1]). Mà j chỉ tăng khi i tăng (cùng nhánh khớp), nên tổng số lần tăng j tối đa bằng tổng số lần tăng i, tức n. Số lần giảm j không thể vượt số lần tăng j. Vậy tổng số bước bị chặn bởi 2n = O(n). Tương tự computeLPS là O(m). Tổng O(n+m).
So sánh trực tiếp:
| Thuật toán | Build | Matching | Tổng | Con trỏ text lùi? |
|---|---|---|---|---|
| Naive | — | O(n·m) | O(n·m) | Có |
| KMP | O(m) | O(n) | O(n+m) | Không bao giờ |
6. Pitfall
Pitfall 1 — Nhầm lps là "khớp tới đâu" thay vì "prefix = suffix"
-- SAI: tuong lps[i] = so ky tu da khop tinh tu dau
-- Dan toi dich pattern sai khi lech
lps[i] không phải số ký tự đã khớp với text — nó là thuộc tính nội tại của pattern (prefix trùng suffix), tính trước khi nhìn text. Lẫn lộn hai khái niệm này dẫn đến dịch pattern sai hoặc bỏ sót lần xuất hiện.
Pitfall 2 — Lùi len rồi quên không tăng i trong computeLPS
-- SAI: trong nhanh lech van tang i
else if len > 0:
len <- lps[len-1]
i <- i + 1 -- BUG: lam mat co hoi so P[i] voi prefix ngan hon
-- DUNG: chi lui len, GIU nguyen i de so lai P[i] voi prefix ngan hon
else if len > 0:
len <- lps[len-1]
Tăng i ở nhánh lệch khiến ta bỏ qua việc thử khớp P[i] với một prefix-suffix ngắn hơn — bảng lps sai, kéo theo matching sai.
Pitfall 3 — Sau khi tìm thấy, quên reset j <- lps[j-1] để tìm lần kế
-- Neu chi return ngay khi j = m, ta bo sot cac lan xuat hien chong lan
-- Vi du P="aa", T="aaaa" co 3 lan xuat hien tai 0,1,2
if j = P.length:
báo tìm thấy tại i - j
j <- lps[j-1] -- tiep tuc, khong return
Với pattern tự chồng lấn (như "aa" trong "aaaa"), reset j về lps[j-1] cho phép bắt mọi lần xuất hiện kể cả khi chúng overlap.
7. Liên hệ các bài khác
- Naive string matching: KMP sinh ra để vá đúng điểm lãng phí của naive — đọc bài đó trước để thấy rõ "lùi text rồi so lại" tốn kém ra sao.
- Rabin-Karp: Hướng tiếp cận khác — dùng rolling hash thay vì failure function. KMP đảm bảo O(n+m) tất định; Rabin-Karp O(n+m) kỳ vọng nhưng mở rộng tốt cho đa mẫu.
- Z-function: Một cấu trúc prefix khác (độ dài đoạn từ vị trí
itrùng với prefix). Z-function và failure function chuyển đổi được cho nhau và giải cùng lớp bài. - Aho-Corasick: Tổng quát hoá failure function lên một trie nhiều pattern — "failure link" trên cây chính là lps đa mẫu.
- Amortized analysis: Công cụ để chứng minh vòng lặp có bước "lùi j" vẫn tổng O(n).
📚 Deep Dive
Bài báo gốc:
- Knuth, Morris, Pratt (1977) — "Fast Pattern Matching in Strings", SIAM Journal on Computing 6(2). Đây là công trình hợp nhất ý tưởng độc lập của Morris-Pratt và Knuth (Knuth phát hiện qua phân tích automaton của Cook).
Sách:
- Introduction to Algorithms (CLRS), §32.4 "The Knuth-Morris-Pratt algorithm" — chứng minh tính đúng của prefix function bằng bất biến vòng lặp.
- Competitive Programmer's Handbook (Laaksonen), chương String algorithms — trình bày prefix function gọn cho thi đấu.
Góc nhìn automaton: failure function tương đương xây một DFA (deterministic finite automaton) nhận diện pattern, trong đó lps định nghĩa các "fail transition". Đây là cầu nối sang lý thuyết ngôn ngữ hình thức và regex engine.
Tóm tắt
- Naive lãng phí vì lùi con trỏ text và so lại đoạn đã biết; KMP loại bỏ hoàn toàn việc lùi text.
- Failure function
lps[i]= độ dài tiền tố thực sự dài nhất củaP[0..i]mà cũng là hậu tố — thuộc tính nội tại của pattern. - Khi lệch tại
j > 0, kéoj <- lps[j-1]thay vì lùii— dịch pattern để tiền tố đã-chắc-chắn-khớp thẳng hàng. - Bảng
lpsxây trong O(m) bằng cách áp chính ý tưởng KMP lên pattern so với chính nó. - Tổng O(n+m) chứng minh bằng amortized: số lần giảm
jkhông vượt số lần tăngj, mà số lần tăngjbị chặn bởin. - Reset
j <- lps[j-1]sau khi khớp đủ để bắt cả các lần xuất hiện chồng lấn.
Tự kiểm tra
Q1Vì sao failure function chỉ tính trên pattern, không cần nhìn text? Lợi ích của tính chất này là gì?▸
lps[i] là thuộc tính cấu trúc nội tại của pattern: tiền tố nào trùng hậu tố trong P[0..i]. Nó không phụ thuộc text vì câu hỏi "khi đã khớp j ký tự rồi lệch thì dịch pattern bao nhiêu là an toàn" hoàn toàn do hình dạng pattern quyết định — j ký tự đã khớp chính là P[0..j-1].
Lợi ích: ta tính lps một lần trong O(m) trước, rồi dùng lại cho mọi text. Với cùng pattern quét nhiều text (như tìm chữ ký virus trong hàng nghìn file), chi phí build được khấu hao về gần như bằng 0.
Q2Tại sao khi lệch ta kéo j về lps[j-1] mà KHÔNG phải lps[j]?▸
Khi lệch tại vị trí j, nghĩa là P[0..j-1] đã khớp nhưng P[j] thì không. Phần đã chắc chắn khớp với text là P[0..j-1] — đoạn có chỉ số cuối là j-1. Ta cần biết tiền tố-trùng-hậu tố của đoạn đã khớp đó, tức lps[j-1].
Dùng lps[j] sẽ sai vì nó tính cho P[0..j] — đoạn bao gồm cả ký tự P[j] mà ta vừa biết là KHÔNG khớp với text. Ta không có quyền giả định gì về ký tự chưa khớp đó.
Q3Trong computeLPS, nhánh lệch làm len <- lps[len-1] mà không tăng i. Vì sao phép đệ quy trên lps này đúng?▸
Ta đang tìm prefix-suffix dài nhất của P[0..i]. Đoạn dài len không mở rộng được (vì P[i] != P[len]). Prefix-suffix dài nhì phải là một prefix-suffix của chính đoạn dài len đó — và độ dài của nó đúng bằng lps[len-1].
Trực giác: nếu X vừa là prefix vừa là suffix, và Y ngắn hơn cũng vậy, thì Y là prefix-suffix của X. Nên ta "tụt" theo chuỗi lps để thử các ứng viên ngắn dần, giữ i để so lại P[i] với ký tự kế của ứng viên mới.
Q4Vòng while matching có nhánh giảm j không tăng i. Lập luận nào đảm bảo tổng vẫn O(n), không phải O(n·m)?▸
Phân tích amortized theo biến j. Mỗi vòng lặp rơi vào đúng một trong: (a) khớp → i tăng, j tăng; (b) lệch với j>0 → j giảm, i đứng yên; (c) lệch với j=0 → i tăng.
j chỉ tăng ở nhánh (a), mỗi lần cùng lúc i tăng → tổng số lần tăng j tối đa là n. Mỗi lần giảm ở (b) làm j nhỏ đi ít nhất 1, mà j không âm → số lần giảm không vượt số lần tăng, tức tối đa n. Cộng nhánh (c) tối đa n. Tổng bước bị chặn 2n = O(n).
Q5Với P = "aaa" và T = "aaaaa", lps là gì và KMP báo khớp tại những vị trí nào?▸
lps = [0, 1, 2]: P[0..1]="aa" có prefix-suffix "a" (lps=1); P[0..2]="aaa" có prefix-suffix "aa" (lps=2).
Quét T="aaaaa": khớp đủ tại i=3 (j=3) → báo vị trí 0, reset j<-lps[2]=2. Tiếp tục, T[3] khớp P[2] → j=3 → báo vị trí 1, reset j=2. Lặp lại → báo vị trí 2.
Vậy KMP tìm thấy tại 0, 1, 2 — đúng cả các lần xuất hiện chồng lấn nhờ reset j<-lps[j-1] thay vì về 0.
Q6Khi nào KMP KHÔNG nhanh hơn naive đáng kể? Cho ví dụ.▸
Khi pattern và text có ít sự lặp lại và bảng chữ cái lớn (như văn bản tiếng Anh tự nhiên), naive hiếm khi chạm worst-case — nó lệch sớm ngay vài ký tự đầu, nên thực nghiệm gần O(n). Lúc đó KMP chỉ hơn về đảm bảo lý thuyết, không hơn nhiều về thời gian thực.
Ví dụ: tìm "algorithm" trong một bài báo. Tại mỗi vị trí lệch, naive thường lệch ngay ký tự đầu hoặc thứ hai. KMP toả sáng ở đầu vào bệnh lý nhiều lặp (DNA "AAAA...", dữ liệu nhị phân) nơi naive thật sự đạt O(n·m).
Bài tiếp theo: Rabin-Karp — rolling hash cho đa mẫu
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên