Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa/Mini-challenge — tự viết grep bằng KMP
17/66
Bài 17 / 66~30 phútPattern matching & StringMiễn phí lượt xem

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ài m.
  • 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ài n, chuỗi pattern độ dài m, mảng lps tính sẵn.
  • Output: danh sách tất cả vị trí bắt đầu (0-indexed) mà pattern xuất hiện trong text, 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

textpatternExpectedGhi 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"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"[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ệnLearning outcome
Implement computeLPSImplement KMP failure function trong O(m)
Implement kmpSearchAll không lùi iExplain 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 inputCompare 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 ý

💡 Bước 1 — computeLPS (đọc khi chưa biết bắt đầu)

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ùi len <- lps[len-1] (không tăng i) — 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].

💡 Bước 2 — kmpSearchAll với overlapping (đọc sau khi computeLPS đúng)

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].

💡 Bước 3 — in số dòng và offset (đọc sau khi tìm được vị trí)

Để 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

✅ Lời giải — xem sau khi đã tự làm

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"):

ilenpattern[i] vs pattern[len]Hành độnglps
10B ≠ Alen=0 → lps[1]=0, i=2[0,0,,,_]
20A = Alen=1, lps[2]=1, i=3[0,0,1,,]
31B = Blen=2, lps[3]=2, i=4[0,0,1,2,_]
42C ≠ Alen=lps[1]=0[0,0,1,2,_]
40C ≠ Alps[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]):

ijtext[i] vs pattern[j]Hành động
00A = Ai=1, j=1
11A = Ai=2, j=2
22A = Ai=3, j=3
33A = Ai=4, j=4
44A ≠ Bj>0 → j=lps[3]=3, i đứng yên
43A = Ai=5, j=4
54B = Bi=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ả textpattern 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 computeLPS trong O(m) — cấu trúc dữ liệu cốt lõi của KMP.
  • Viết kmpSearchAll vớ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 <- 0 sau 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

Đặt 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