Mini-challenge — tự viết grep bằng KMP
Lab: cài công cụ tìm tất cả vị trí xuất hiện của pattern trong file bằng KMP, đo so với naive trên input adversarial.
Bạn đang viết một công cụ dòng lệnh đơn giản giống grep: đọc một file văn bản, tìm tất cả vị trí mà một pattern xuất hiện, và in ra số dòng cùng offset. Khác với grep tiêu chuẩn chỉ in dòng đầu tiên khớp, công cụ của bạn phải báo cáo mọi vị trí — kể cả các lần xuất hiện chồng lấn (overlapping).
Bài toán này xuất hiện trong:
- Code search engine — tìm tất cả chỗ một function được gọi trong codebase lớn.
- Log analysis — tìm mọi lần một error code xuất hiện trong log file hàng GB.
- Bioinformatics — tìm mọi lần một đoạn gen ngắn xuất hiện trong chuỗi DNA — overlap là bình thường (
"ATAT"có 2 lần"AT"chồng lấn).
Dành 20–25 phút tự implement trước khi xem gợi ý.
🎯 Đề bài
Viết hai hàm:
Hàm 1 — computeLPS(pattern)
- Input: chuỗi
patternđộ dàim. - Output: mảng
lps[0..m-1]— failure function (longest proper prefix which is also suffix).
Hàm 2 — kmpSearchAll(text, pattern, lps)
- Input: chuỗi
textđộ dàin, chuỗipatternđộ dàim, mảnglpstính sẵn. - Output: danh sách tất cả vị trí bắt đầu (0-indexed) mà
patternxuất hiện trongtext, bao gồm cả trường hợp chồng lấn.
Ví dụ: text = "AAAA", pattern = "AA" → output [0, 1, 2] (ba lần xuất hiện chồng lấn).
📋 Test cases
text | pattern | Expected | Ghi chú |
|---|---|---|---|
"ABABABABC" | "ABABC" | [4] | Ví dụ chuẩn từ bài KMP |
"AAAA" | "AA" | [0, 1, 2] | Overlapping — reset j đúng |
"AAAAAB" | "AAAAB" | [1] | Input adversarial naive |
"AAAAAAAAAAAAAAAB" | "AAAAAB" | [10] | Stress test — naive O(n·m) chậm |
"ABCDEF" | "XYZ" | [] | Không tìm thấy |
"ABCABC" | "ABC" | [0, 3] | Hai lần không chồng lấn |
"" | "A" | [] | Text rỗng |
"A" | "" | [] | Pattern rỗng — trả empty |
Test case "AAAAAB" / "AAAAB" là input adversarial cho naive matching: tại mỗi vị trí i, naive khớp được 4 ký tự A rồi lệch ở B, buộc phải lùi về i+1 và so lại — worst-case O(n·m). KMP xử lý trong O(n+m) vì failure function lps của "AAAAB" là [0,1,2,3,0] — khi lệch tại B, nhảy j về 3 chứ không về 0.
📦 Concept mapping
Bài này luyện trực tiếp ba learning outcome của module:
| Bước thực hiện | Learning outcome |
|---|---|
Implement computeLPS | Implement KMP failure function trong O(m) |
Implement kmpSearchAll không lùi i | Explain vì sao naive lãng phí + Implement O(n) matching |
Xử lý overlapping bằng j <- lps[j-1] | Trace cơ chế KMP reset j sau khi tìm thấy |
| So sánh KMP vs naive trên adversarial input | Compare KMP và naive về worst-case |
▶️ Starter pseudocode
function computeLPS(pattern):
m <- pattern.length
lps[0..m-1] <- 0
-- TODO: điền lps[1..m-1]
-- Gợi ý: dùng con trỏ len = độ dài prefix-suffix đang khớp
return lps
function kmpSearchAll(text, pattern, lps):
results <- []
-- TODO: quét text với hai con trỏ i (text) và j (pattern)
-- Khi j = pattern.length: ghi nhận vị trí, reset j <- lps[j-1]
-- Khi lệch và j > 0: j <- lps[j-1], i đứng yên
-- Khi lệch và j = 0: i++
return results
💡 Gợi ý
lps[i] là độ dài của tiền tố thực sự dài nhất của pattern[0..i] mà đồng thời là hậu tố. "Thực sự" nghĩa là tiền tố không phải toàn bộ chuỗi (độ dài tối đa i, không phải i+1).
Dùng con trỏ len = độ dài prefix-suffix đang khớp hiện tại, bắt đầu từ len=0, i=1:
- Nếu
pattern[i] = pattern[len]: mở rộng được —len++,lps[i] = len,i++. - Nếu không khớp và
len > 0: lùilen <- lps[len-1](không tăngi) — thử ứng viên prefix-suffix ngắn hơn. - Nếu không khớp và
len = 0:lps[i] = 0,i++.
Kiểm tra: computeLPS("ABABC") phải cho [0, 0, 1, 2, 0].
Hai con trỏ: i quét text (chỉ tăng), j chỉ vị trí trong pattern.
Khi j = pattern.length (khớp đủ): ghi i - j vào results, sau đó reset j <- lps[j-1] — không về 0. Đây là điểm cốt lõi để bắt được trường hợp chồng lấn: với P="AA", T="AAAA", sau khi khớp tại vị trí 0, j về lps[1]=1 (không về 0), lần duyệt kế tiếp chỉ cần khớp thêm 1 ký tự → báo vị trí 1.
Cạm bẫy: nếu reset j <- 0 thay vì lps[j-1], bài P="AA", T="AAAA" chỉ báo [0, 2] thay vì [0, 1, 2].
Để in dạng grep (số dòng + offset trong dòng), cần tách text thành các dòng và ánh xạ offset tuyệt đối sang (dòng, offset_trong_dòng):
-- Xây mảng lineStart[i] = offset tuyệt đối bắt đầu dòng i
lineStart[0] <- 0
lineIdx <- 1
for k from 0 to text.length - 1:
if text[k] = '\n':
lineStart[lineIdx] <- k + 1
lineIdx++
-- Với mỗi vị trí pos trong results: binary search lineStart tìm dòng
✅ Lời giải
computeLPS
function computeLPS(pattern):
m <- pattern.length
lps[0..m-1] <- 0 -- lps[0] luôn bằng 0
len <- 0 -- độ dài prefix-suffix đang khớp
i <- 1
while i < m:
if pattern[i] = pattern[len]: -- mở rộng được prefix-suffix
len <- len + 1
lps[i] <- len
i <- i + 1
else if len > 0: -- lệch, lùi len về ứng viên ngắn hơn
len <- lps[len-1] -- KHÔNG tăng i
else: -- len = 0, không còn ứng viên
lps[i] <- 0
i <- i + 1
return lps
// Time: O(m) Space: O(m)
Trace computeLPS("ABABC"):
| i | len | pattern[i] vs pattern[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 | len=lps[1]=0 | [0,0,1,2,_] |
| 4 | 0 | C ≠ A | lps[4]=0, i=5 | [0,0,1,2,0] |
Kết quả: lps = [0, 0, 1, 2, 0].
kmpSearchAll
function kmpSearchAll(text, pattern, lps):
n <- text.length
m <- pattern.length
results <- []
if m = 0: return results -- pattern rỗng — không tìm
i <- 0 -- con trỏ text, CHỈ tăng
j <- 0 -- con trỏ pattern
while i < n:
if text[i] = pattern[j]: -- khớp một ký tự
i <- i + 1
j <- j + 1
if j = m: -- khớp hoàn toàn
results.append(i - j) -- vị trí bắt đầu
j <- lps[j-1] -- reset để bắt overlap, KHÔNG về 0
else if j > 0: -- lệch nhưng đã khớp được j ký tự
j <- lps[j-1] -- dịch pattern, i đứng yên
else: -- lệch ngay từ đầu
i <- i + 1
return results
// Time: O(n) Space: O(1) ngoài mảng lps và results
Tổng pipeline:
function grepKMP(text, pattern):
if pattern.length = 0: return []
lps <- computeLPS(pattern)
results <- kmpSearchAll(text, pattern, lps)
return results
// Time: O(n + m) Space: O(m) cho lps
Trace adversarial text = "AAAAAB", pattern = "AAAAB" (lps = [0,1,2,3,0]):
| i | j | text[i] vs pattern[j] | Hành động |
|---|---|---|---|
| 0 | 0 | A = A | i=1, j=1 |
| 1 | 1 | A = A | i=2, j=2 |
| 2 | 2 | A = A | i=3, j=3 |
| 3 | 3 | A = A | i=4, j=4 |
| 4 | 4 | A ≠ B | j>0 → j=lps[3]=3, i đứng yên |
| 4 | 3 | A = A | i=5, j=4 |
| 5 | 4 | B = B | i=6, j=5 = m → báo pos=1, j=lps[4]=0 |
KMP tìm thấy vị trí 1 trong 7 bước. Naive sẽ mất ít nhất 4×5=20 phép so sánh cho bài này.
🎓 Mở rộng
Case-insensitive search: trước khi gọi grepKMP, chuyển cả text và pattern sang lowercase — O(n+m) tiền xử lý, không thay đổi logic matching.
Tìm nhiều pattern: nếu cần tìm đồng thời nhiều pattern trong cùng một text (ví dụ danh sách từ khoá), chạy KMP từng pattern sẽ là O(k × n + tổng_m) với k là số pattern. Khi k lớn (hàng nghìn pattern), đây không hiệu quả — đây chính là lý do Aho-Corasick tồn tại: xây trie một lần, duyệt text một lần O(n + tổng_m + k).
Đa dòng, báo cáo dạng grep: xây mảng lineStart như gợi ý Bước 3, binary search để ánh xạ offset → (line, col). Complexity tổng vẫn O(n+m) vì binary search là O(log n) trên mảng đã sắp xếp.
Wildcard matching (? khớp bất kỳ ký tự): KMP chuẩn không xử lý wildcard. Cần thuật toán khác (DP hoặc NFA simulation) — bài toán này phức tạp hơn đáng kể.
✨ Điều bạn vừa làm được
- Implement failure function
computeLPStrong O(m) — cấu trúc dữ liệu cốt lõi của KMP. - Viết
kmpSearchAllvới con trỏ text không bao giờ lùi — đảm bảo O(n) matching. - Xử lý đúng trường hợp chồng lấn bằng reset
j <- lps[j-1]thay vìj <- 0sau khi tìm thấy. - Kiểm chứng trên input adversarial
"AAAA..."— nơi naive matching thật sự đạt O(n·m). - Nhận ra giới hạn của KMP với đa pattern và hiểu khi nào cần chuyển sang Aho-Corasick.
Bài tiếp theo: Case study — Lucene index & ClamAV signature
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