Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
12/38
Bài 12 / 38~16 phútPattern matching & StringMiễn phí lượt xem

Naive string matching — vì sao O(n·m) chậm

Thuật toán so khớp chuỗi ngây thơ: trượt pattern qua text, so từng vị trí. Vì sao worst-case O(n·m) và khi nào vẫn đủ dùng trong thực tế.

TL;DR: Naive matching trượt pattern qua text từ trái sang phải, mỗi vị trí so từng ký tự. Worst-case O(n·m) xảy ra khi text lặp một ký tự (ví dụ "aaaa...a") và pattern gần như khớp hoàn toàn rồi lệch ở ký tự cuối — mỗi vị trí khởi đầu đều so gần hết pattern. Nhưng với văn bản tự nhiên và bảng chữ cái lớn, lệch xảy ra sớm nên thực nghiệm gần O(n). Hiểu rõ điểm lãng phí của naive — lùi con trỏ text và so lại đoạn đã biết — chính là chìa khoá để thấy tại sao KMP và Rabin-Karp ra đời.

Hãy tưởng tượng bạn cần kiểm tra xem một đoạn DNA dài 10 triệu base pair có chứa một chuỗi mồi (primer) dài 20 ký tự không. Cách đơn giản nhất: đặt chuỗi mồi vào vị trí 0, so từng ký tự, rồi trượt sang 1, so lại, tiếp tục cho đến cuối. Đây chính là naive matching — trực quan, dễ cài đặt, và trong trường hợp xấu nhất tốn tới 200 triệu phép so sánh. Bài này phân tích cơ chế, worst-case, và điều kiện naive vẫn đủ dùng trước khi ta đào sâu vào KMP ở bài tiếp theo.

1. Cơ chế trượt cửa sổ

Naive matching duy trì một cửa sổ (window) dài m (độ dài pattern) trượt qua text dài n. Với mỗi vị trí khởi đầu i từ 0 đến n - m, thuật toán so từng ký tự P[j] với T[i + j] cho j từ 0 đến m - 1. Nếu tất cả khớp → tìm thấy. Nếu có ký tự lệch → dịch cửa sổ sang phải 1 ô và bắt đầu lại từ j = 0.

Điểm mấu chốt cần nhớ: khi lệch tại j = k, ta vứt bỏ mọi thông tin về k ký tự vừa khớp — con trỏ text lùi về i + 1 và so lại từ đầu. Đây chính là "lãng phí" mà KMP sẽ loại bỏ.

Plain Text
function naiveSearch(T, P):
    n <- T.length
    m <- P.length
    results <- rỗng
    for i from 0 to n - m:            -- dịch cửa sổ qua text
        j <- 0
        while j < m and T[i + j] = P[j]:   -- so từng ký tự
            j <- j + 1
        if j = m:                      -- khớp toàn bộ pattern
            thêm i vào results
    return results
// Time: O(n·m) worst-case  Space: O(1) ngoài kết quả

1.1 Trace nhỏ: T = "ABCABD", P = "ABD"

ijSo sánhKết quả
00T[0]='A' = P[0]='A'khớp
01T[1]='B' = P[1]='B'khớp
02T[2]='C' ≠ P[2]='D'lệch → i=1
10T[1]='B' ≠ P[0]='A'lệch → i=2
20T[2]='C' ≠ P[0]='A'lệch → i=3
30T[3]='A' = P[0]='A'khớp
31T[4]='B' = P[1]='B'khớp
32T[5]='D' = P[2]='D'khớp → tìm thấy tại 3

Trace naive trên T bằng ABCABD: tại i bằng 0 khớp hai ký tự rồi lệch, con trỏ text lùi một ô và hỏi lại từ đầu pattern

Thứ bị vứt không trừu tượng chút nào: hai xác nhận vừa mua được — T[0]A, T[1]B — và một bước con trỏ. KMP ở bài sau giữ đúng hai xác nhận đó.

Thử ngẫmnếu pattern primer DNA của bạn dài 20 ký tự nhưng bảng chữ cái chỉ có 4 ký tự (A,C,G,T), cửa sổ trượt này có còn lệch sớm như trên văn bản tiếng Anh không?

2. Worst-case O(n·m) — giải phẫu

Worst-case xảy ra khi text và pattern đều có độ lặp cao. Ví dụ kinh điển:

  • Text T = "aaa...a" (n ký tự a)
  • Pattern P = "aaa...ab" (m-1 ký tự a + 1 ký tự b)

Với mỗi vị trí i (từ 0 đến n - m), thuật toán khớp được m - 1 ký tự a trước khi lệch ở ký tự cuối b. Số phép so sánh cho vị trí đó là m. Tổng cộng (n - m + 1) × m phép so sánh — xấp xỉ n·m khi m nhỏ so n.

Ví dụ cụ thể: n = 1 000 000, m = 1 000. Worst-case: 10^9 phép so sánh. Với tốc độ 10^9 thao tác/giây, mất ~1 giây cho mỗi lần tìm kiếm — không chấp nhận được cho hệ thống thời gian thực.

Ba cửa sổ của naive trên T bằng aaaab xếp thành thang, mỗi cửa sổ đều tốn đúng ba phép so sánh

Điều làm worst-case nhân lên không phải độ dài, mà là tính đều đặn: không cửa sổ nào thoát sớm, vì mọi ký tự trừ ký tự cuối đều khớp. Trên văn bản tiếng Anh cột phải sẽ là 1, 1, 1… — đó mới là lý do naive thường đủ dùng.

2.1 Bảng phân tích số phép so sánh

Trường hợpTextPatternSố phép so sánh
Best-casebất kỳký tự đầu pattern hiếm~n
Average (văn bản tự nhiên)tiếng Anh/Việttừ thông thường~1.1·n
Worst-caseaaaa...aaaa...ab~n·m

3. Khi nào naive vẫn đủ tốt

Mặc dù worst-case O(n·m) đáng ngại về lý thuyết, naive thường đủ tốt trong thực tế khi:

Bảng chữ cái lớn, pattern không lặp. Tìm từ tiếng Anh trong văn bản — lệch xảy ra ở ký tự thứ 1 hoặc 2 của pattern với xác suất cao. Trung bình mỗi vị trí chỉ cần ~1-2 phép so sánh, tổng ~1.1·n.

Text và/hoặc pattern ngắn. Nếu n < 1000 hoặc m < 10, ngay cả O(n·m) cũng tính xong trong microsecond. Overhead cài đặt KMP (xây bảng lps) lúc này không đáng.

Tìm một lần, không lặp. Nếu pattern thay đổi mỗi lần truy vấn (như tìm kiếm người dùng nhập), chi phí preprocessing O(m) của KMP được hoàn vốn ngay. Nhưng nếu chỉ tìm 1-2 lần rồi bỏ, naive đơn giản hơn.

Môi trường nhúng / bộ nhớ hạn chế. Naive chỉ cần O(1) bộ nhớ phụ. KMP cần O(m) cho bảng lps; Rabin-Karp cần xử lý số học modular.

Nguyên tắc chọn thuật toán

Với n·m dưới 10^7 thao tác, naive thường đủ. Khi tìm kiếm trên genome (n10^9), dữ liệu nhị phân lặp cao, hoặc cần tìm đa mẫu — lúc đó KMP, Rabin-Karp, hay Aho-Corasick mới thật sự cần thiết.

Thử ngẫmsearch box bạn đang làm nhận một pattern khác nhau mỗi lần người dùng gõ. Theo mục 3, naive hay KMP là lựa chọn hợp lý hơn ở đây?

4. Vì sao lùi con trỏ text là lãng phí

Đây là điểm quan trọng nhất để hiểu tại sao KMP ra đời. Khi naive lệch sau khi khớp j ký tự, nó biết chắc:

T[i], T[i+1], ..., T[i+j-1] khớp với P[0], P[1], ..., P[j-1]

Nhưng thay vì tận dụng thông tin này, naive quay con trỏ text về i + 1 và so lại từ đầu — bỏ qua j ký tự đã biết. KMP hỏi: "Nếu pattern có tiền tố trùng với hậu tố của đoạn vừa khớp, tại sao không dịch thẳng để tận dụng?"

Hình ở mục 1.1 đã vẽ đúng chỗ này với j = 2. Bài sau sẽ đặt hai phản ứng cạnh nhau trên cùng một chỗ lệch để thấy khoảng cách giữa chúng là bao nhiêu ô.

Thử ngẫmnếu chỉ nhớ số ký tự j vừa khớp mà không nhìn vào cấu trúc lặp lại của chính pattern, bạn có tính được nên dịch cửa sổ đi bao nhiêu ô cho an toàn không?

5. Pitfall

Pitfall 1 — Điều kiện vòng ngoài sai: i < n thay vì i <= n - m

Plain Text
-- SAI: có thể đọc ngoài biên T[i + j] với i+j >= n
for i from 0 to n - 1:
    for j from 0 to m - 1:
        if T[i + j] = P[j]: ...    -- NGUY HIỂM khi i + j >= n
Plain Text
-- ĐÚNG: dừng khi không còn đủ chỗ cho pattern
for i from 0 to n - m:
    ...

Khi i > n - m, cửa sổ vượt quá cuối text — không thể khớp đủ m ký tự. Giới hạn đúng là i chạy đến n - m (bao gồm).

Pitfall 2 — Trả về ngay khi tìm thấy lần đầu thay vì tiếp tục

Plain Text
-- SAI khi cần tìm TẤT CẢ lần xuất hiện:
if j = m:
    return i    -- bỏ sót các lần sau
Plain Text
-- ĐÚNG: ghi nhận và tiếp tục
if j = m:
    thêm i vào results
    -- tiếp tục vòng for

Trường hợp điển hình: đếm số lần từ xuất hiện trong văn bản, hoặc tìm tất cả vị trí primer trong genome.

Pitfall 3 — Nhầm worst-case với average-case cho văn bản tự nhiên

Naive thực tế thường chạy gần O(n) trên văn bản tiếng Anh/Việt — lệch sớm ở ký tự đầu hoặc thứ hai. Lầm lẫn này dẫn đến hai hướng sai: (a) tối ưu hóa quá sớm khi không cần, hoặc (b) dùng naive cho dữ liệu lặp cao (DNA, nhị phân) mà không biết worst-case thực sự đạt được.

6. Liên hệ các bài khác

  • KMP — failure function & matching O(n+m): Bài kế tiếp trực tiếp — KMP sinh ra để vá đúng điểm lãng phí của naive (lùi con trỏ text). Hiểu rõ "lãng phí ở đâu" trong bài này là tiền đề để thấy vì sao failure function giải quyết được.
  • Rabin-Karp — rolling hash cho đa mẫu: Hướng tiếp cận hoàn toàn khác — thay vì tối ưu bước dịch, dùng hash để bỏ qua phần lớn các vị trí không khớp. So sánh với naive để thấy tradeoff hash vs so ký tự trực tiếp.
  • Big-O từ bản chất: Phân tích O(n·m) của naive là ví dụ điển hình về vòng lồng nhau và tầm quan trọng của phân tích worst vs average-case — nên đọc nếu chưa vững Big-O.

📚 Deep Dive

📚 Deep Dive — Nguồn gốc & tham khảo

Lịch sử: Naive matching không có "cha đẻ" cụ thể — nó là cách tiếp cận tự nhiên nhất và xuất hiện từ thời máy tính đầu tiên. Chính sự "ngây thơ" của nó là động lực cho Boyer-Moore (1977), KMP (1977), và Rabin-Karp (1987) ra đời cùng thập kỷ.

Sách:

  • Introduction to Algorithms (CLRS), §32.1 "The naive string-matching algorithm" — chứng minh tính đúng và phân tích worst-case chi tiết.
  • Algorithms (Sedgewick & Wayne), Chapter 5.4 "Substring Search" — so sánh naive, KMP, Boyer-Moore trên benchmark thực tế.

Quan sát thú vị: Trong thực tế hệ thống, naive thường nhanh hơn lý thuyết dự đoán vì CPU hiện đại có branch predictor tốt và cache locality cao khi duyệt text liên tục. Boyer-Moore (không dạy trong module này) thường nhanh nhất trên văn bản tự nhiên vì trượt pattern nhiều ô một lúc.

Tóm tắt

  • Naive matching trượt pattern qua text từ trái sang phải, so từng ký tự tại mỗi vị trí — O(1) bộ nhớ, đơn giản để cài đặt.
  • Worst-case O(n·m) xảy ra khi text và pattern đều lặp cao (vd: text "aaaa...a", pattern "aaa...ab").
  • Average-case trên văn bản tự nhiên gần O(n) vì lệch xảy ra sớm ở ký tự đầu.
  • Naive vẫn đủ tốt khi: bảng chữ cái lớn, n·m nhỏ hơn ~10^7, pattern thay đổi liên tục, hoặc bộ nhớ hạn chế.
  • Điểm lãng phí cốt lõi: khi lệch sau j ký tự khớp, naive lùi con trỏ text về i+1 và bỏ thông tin về j ký tự đã biết.
  • KMP loại bỏ chính xác điểm lãng phí đó bằng failure function — con trỏ text không bao giờ lùi.

Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì sao worst-case của naive là O(n·m) chứ không phải O(n·m²) hay O(n²)?
  2. Q2
    Với text T = "aaaa" (4 ký tự) và pattern P = "aa" (2 ký tự), naive báo khớp tại những vị trí nào? Vẽ bảng trace.
  3. Q3
    Tại sao điều kiện vòng ngoài phải là i ≤ n - m chứ không phải i < n?
  4. Q4
    Vì sao average-case của naive trên văn bản tiếng Anh gần O(n) chứ không phải O(n·m)?
  5. Q5
    Naive lãng phí gì cụ thể khi lệch tại j = 3 (đã khớp 3 ký tự)? KMP tận dụng thông tin đó ra sao?
  6. Q6
    Khi nào bạn chọn naive thay vì KMP dù biết rõ naive có worst-case O(n·m)?

Bài tiếp theo: KMP — failure function & matching O(n+m)

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

KMP — failure function & matching O(n+m)