Aho-Corasick — automaton so khớp đa mẫu
Trie nhiều pattern + failure link tạo automaton tìm tất cả pattern trong một lần quét O(n+z). Nền của antivirus và IDS signature scanning.
TL;DR: Bài toán đa mẫu: cho tập k pattern, tìm tất cả lần xuất hiện của bất kỳ pattern nào trong text T. Chạy KMP k lần tốn O(k·n) — quá chậm khi k lớn. Aho-Corasick xây một trie từ toàn bộ pattern (O(tổng độ dài)), rồi thêm failure link — tổng quát hoá lps của KMP lên cây trie — bằng BFS một lần. Kết quả là một automaton cho phép quét T một lần duy nhất O(n), thu thập mọi lần khớp. Tổng thể O(Σ|Pᵢ| + n + z) với z là số lần khớp. Đây là nền tảng của ClamAV (antivirus), Snort (IDS), và mọi hệ thống quét chữ ký song song.
Ở bài trước bạn thấy Z-function giải bài đơn pattern bằng cách ghép P + '#' + T. Nếu có k = 10.000 pattern (như cơ sở dữ liệu chữ ký virus), ghép 10.000 lần và quét 10.000 lần là không thực tế. Aho-Corasick (1975, Bell Labs) hỏi: tại sao không dồn toàn bộ pattern vào một cấu trúc và quét text đúng một lần?
1. Bài toán và tại sao cần Aho-Corasick
Đầu vào: tập pattern {P₁, P₂, ..., Pₖ} và text T độ dài n. Tổng độ dài pattern M = Σ|Pᵢ|.
Đầu ra: mọi cặp (i, j) sao cho pattern Pᵢ kết thúc tại vị trí j trong T.
So sánh chi phí:
| Thuật toán | Build | Quét text | Tổng | Ghi chú |
|---|---|---|---|---|
| KMP × k lần | O(M) | O(k·n) | O(M + k·n) | Chậm khi k lớn |
| Rabin-Karp đa mẫu | O(M) | O(n + z) kỳ vọng | O(M + n + z) | Có false positive |
| Aho-Corasick | O(M·Σ) | O(n + z) | O(M·Σ + n + z) | Tất định, mọi khớp |
Với bảng chữ cái Σ cố định (ASCII = 256), O(M·Σ) = O(M) về hằng số. Trên thực tế, Aho-Corasick build O(M), quét O(n + z) — tuyến tính trên cả hai pha.
ClamAV lưu hàng triệu chữ ký virus dưới dạng Aho-Corasick automaton. Mỗi file cần scan chỉ cần một lần quét O(n) — không quan tâm có bao nhiêu chữ ký. Snort IDS dùng cùng nguyên lý để khớp rules trong packet payload. Elasticsearch (Lucene) dùng cho multi-term phrase matching. Xem thêm ở bài case study.
2. Bước 1 — Xây Trie từ tập pattern
Trie (prefix tree) là cây trong đó mỗi cạnh biểu diễn một ký tự và mỗi node biểu diễn một tiền tố của pattern nào đó. Các pattern dùng chung tiền tố sẽ dùng chung đường đi từ gốc.
Ví dụ tập pattern {"he", "she", "his", "hers"}:
graph TD
ROOT(["root"]) -->|"h"| H["h"]
ROOT -->|"s"| S["s"]
H -->|"e"| HE["he ✓"]
H -->|"i"| HI["hi"]
HE -->|"r"| HER["her"]
HER -->|"s"| HERS["hers ✓"]
HI -->|"s"| HIS["his ✓"]
S -->|"h"| SH["sh"]
SH -->|"e"| SHE["she ✓"]Node đánh dấu ✓ là output node — pattern kết thúc tại đây.
function buildTrie(patterns):
root <- node mới (id=0)
for each pattern P trong patterns:
node <- root
for each ký tự c trong P:
if node không có cạnh c:
node.children[c] <- node mới
node <- node.children[c]
node.output.add(P) -- đánh dấu pattern kết thúc ở đây
return root
// Time: O(M·Σ) Space: O(M·Σ) -- M = tổng độ dài pattern
3. Bước 2 — Xây failure link bằng BFS
3.1 Failure link là gì?
Khi quét text và đang ở node v của trie, nhưng ký tự tiếp theo không có cạnh từ v — ta thất bại. Câu hỏi: chuyển về node nào để không mất thông tin đã khớp?
Failure link fail[v] trỏ đến node ứng với hậu tố thực sự dài nhất của chuỗi biểu diễn bởi đường đi từ gốc đến v mà cũng là tiền tố của ít nhất một pattern.
Đây chính xác là ý tưởng lps của KMP — nhưng áp lên trie thay vì mảng một chiều.
3.2 Xây bằng BFS theo tầng
Failure link của node tầng 1 luôn là root (không có hậu tố thực sự nào là tiền tố). Với node sâu hơn, dùng failure link của cha để tìm.
function buildFailureLinks(root):
Queue Q <- rỗng
-- Tầng 1: failure link trỏ về root
for each ký tự c và node v = root.children[c]:
fail[v] <- root
Q.enqueue(v)
-- BFS các tầng tiếp
while Q không rỗng:
u <- Q.dequeue()
for each ký tự c và node v = u.children[c]:
-- Tìm failure link cho v
f <- fail[u]
while f != root and f.children[c] không tồn tại:
f <- fail[f]
if f.children[c] tồn tại and f.children[c] != v:
fail[v] <- f.children[c]
else:
fail[v] <- root
-- Gộp output: kế thừa pattern từ failure node
v.output.addAll(fail[v].output)
Q.enqueue(v)
// Time: O(M·Σ) Space: O(M·Σ)
Trực giác gộp output: nếu fail[v] là output node (có pattern kết thúc tại đó), thì khi ta đi đến v trong quá trình quét, cũng cần báo những pattern đó — chúng là hậu tố của chuỗi ta vừa đọc. Gộp vào v.output ngay lúc build để quét text O(1) mỗi node.
3.3 Minh hoạ failure link cho tập {"he", "she", "his", "hers"}
graph TD
ROOT(["root"]) -->|"h"| H["h"]
ROOT -->|"s"| S["s"]
H -->|"e"| HE["he ✓"]
H -->|"i"| HI["hi"]
HE -->|"r"| HER["her"]
HER -->|"s"| HERS["hers ✓"]
HI -->|"s"| HIS["his ✓"]
S -->|"h"| SH["sh"]
SH -->|"e"| SHE["she ✓"]
HE -. "fail" .-> E_ROOT["(root)"]
SHE -. "fail" .-> HE
HER -. "fail" .-> E_ROOT
HIS -. "fail" .-> S
HERS -. "fail" .-> SFailure link quan trọng nhất: fail[SHE] = HE — sau khi khớp "she", hậu tố "he" cũng là pattern → SHE.output chứa cả "she" lẫn "he".
4. Bước 3 — Quét text bằng automaton
Sau khi build trie + failure link, quét text T:
function search(root, T):
node <- root
matches <- danh sách rỗng
for i from 0 to T.length-1:
c <- T[i]
-- Đi theo failure link cho đến khi tìm được cạnh c hoặc về root
while node != root and node.children[c] không tồn tại:
node <- fail[node]
if node.children[c] tồn tại:
node <- node.children[c]
-- Báo mọi pattern kết thúc tại node hiện tại
for each pattern P trong node.output:
matches.append((P, i - P.length + 1)) -- (pattern, vị trí bắt đầu)
return matches
// Time: O(n + z) Space: O(1) ngoài output -- z = số match
4.1 Trace T = "ushers", pattern {"he", "she", "his", "hers"}
| i | T[i] | node trước | node sau | output |
|---|---|---|---|---|
| 0 | u | root | root (không có cạnh u) | — |
| 1 | s | root | S | — |
| 2 | h | S | SH | — |
| 3 | e | SH | SHE ✓ | she (pos 1), he (từ fail, pos 2) |
| 4 | r | SHE → fail=HE → HER | HER | — |
| 5 | s | HER | HERS ✓ | hers (pos 2) |
Một lần quét 6 ký tự, tìm được 3 khớp: she tại 1, he tại 2, hers tại 2.
5. Tại sao tổng độ phức tạp là O(M + n + z)?
Build trie: mỗi ký tự của mỗi pattern tạo đúng một node → O(M).
Build failure link: BFS mỗi node đúng một lần. Vòng while f != root... có thể lặp nhiều lần trên một node, nhưng tổng số bước theo failure link trong toàn bộ BFS bị chặn bởi O(M) (phân tích amortized tương tự KMP — mỗi bước tụt failure link tương ứng rút ngắn chiều sâu, tổng số lần rút bị chặn bởi tổng chiều sâu = O(M)). → O(M).
Quét text: con trỏ i tăng đúng n lần. Mỗi lần tụt failure link làm giảm chiều sâu node hiện tại ít nhất 1, mà chiều sâu chỉ tăng khi i tăng (đi theo cạnh trie) → tổng số lần tụt failure link toàn quá trình quét bị chặn bởi n. Báo output: O(z) lần. → O(n + z).
Tổng: O(M + n + z).
6. Pitfall
Pitfall 1 — Quên gộp output từ failure node khi build
-- SAI: chỉ đánh dấu pattern kết thúc trực tiếp tại node
node.output.add(P) -- đúng khi build trie
-- ... nhưng không gộp fail[v].output khi build failure link
-- Hệ quả: khi SHE.output = {"she"} nhưng KHÔNG có "he"
-- Quét "ushers" → tìm thấy "she" nhưng BỎ SÓT "he"
-- DUNG: sau khi set fail[v], gộp output ngay
fail[v] <- f.children[c]
v.output.addAll(fail[v].output) -- kế thừa toàn bộ output từ failure chain
Không gộp output khi build buộc quét text phải đi theo chuỗi failure link để thu output — phá vỡ đảm bảo O(1) mỗi ký tự và dễ bỏ sót match.
Pitfall 2 — BFS không đúng tầng, dẫn đến failure link chưa được tính
-- SAI: dùng DFS thay vì BFS để xây failure link
-- DFS có thể xử lý node v trước khi fail[parent(v)] được tính
-- Hệ quả: fail[v] dựa trên fail[parent] chưa đúng → failure link sai
-- DUNG: BFS đảm bảo xử lý tầng i trước tầng i+1
-- fail[v] = f(fail[parent(v)], ký tự cạnh) -- chỉ đúng khi fail[parent(v)] đã tính
Failure link có tính phụ thuộc theo tầng — node tầng d cần failure link tầng d-1. BFS tự nhiên đảm bảo điều này.
Pitfall 3 — Không xử lý trường hợp failure link về root khi root cũng không có cạnh
-- SAI: vòng while chỉ dừng khi node != root, nhưng root cũng có thể không có cạnh c
while node != root and node.children[c] không tồn tại:
node <- fail[node]
if node.children[c] tồn tại:
node <- node.children[c]
-- Thiếu: nếu root cũng không có cạnh c, node ở lại root (đúng)
-- Nhưng code không kiểm tra lại sau vòng while → node có thể là root
-- Và `node.children[c]` tồn tại? Chỉ đúng nếu có pattern bắt đầu bằng c
-- Trường hợp không có → node ở lại root, i tiến. Đây là hành vi ĐÚNG.
-- DUNG: kiểm tra rõ ràng tại root
-- Sau vòng while, nếu node = root và root.children[c] không tồn tại:
-- → ký tự c không bắt đầu pattern nào → giữ nguyên root, tiến i.
-- Code trên đã đúng nhưng cần đọc kỹ: if sau while là conditional, không mandatory.
Lỗi thường gặp là vô tình tạo vòng lặp vô hạn khi fail[root] được set thành root (thay vì null) và vòng while node != root không bao giờ kết thúc.
7. Liên hệ các bài khác
- KMP — failure function: Failure link của Aho-Corasick là failure function
lpscủa KMP mở rộng lên trie nhiều nhánh. Khi trie chỉ có một nhánh (k=1 pattern), Aho-Corasick thu gọn thành đúng KMP. Hiểu KMP sâu là điều kiện tiên quyết để hiểu tại sao BFS xây failure link đúng. - Z-function: Z-function là giải pháp O(n+m) cho đơn pattern. Khi cần đa mẫu, Z-function không mở rộng tự nhiên — phải chạy k lần O(k·n). Aho-Corasick là câu trả lời cho bài đa mẫu.
- Trie — cấu trúc dữ liệu nền: Bước 1 của Aho-Corasick là xây trie chuẩn. Nếu chưa quen trie, đọc bài đó trước — Aho-Corasick thêm failure link và output link lên trie, không thay đổi cấu trúc trie cơ bản.
- Case study — Lucene & ClamAV: Bài này mổ xẻ cách ClamAV dùng Aho-Corasick quét hàng triệu chữ ký và Lucene tối ưu multi-term search — xem sau khi nắm vững automaton ở bài này.
📚 Deep Dive
Bài báo gốc: Aho, Alfred V. và Corasick, Margaret J. (1975) — "Efficient string matching: An aid to bibliographic search", Communications of the ACM 18(6). Được viết tại Bell Labs; nguyên bản áp dụng cho hệ thống tìm kiếm thư mục. Bài báo gốc gọi automaton là "goto function + failure function + output function" — ba hàm cấu thành AC hoàn chỉnh.
Sách:
- Introduction to Algorithms (CLRS) — không cover Aho-Corasick trực tiếp, nhưng nền KMP + finite automaton (§32.3) là bước đệm.
- Algorithms on Strings, Trees, and Sequences (Gusfield) — chương 3 "Exact set matching", chứng minh đầy đủ độ phức tạp và so sánh với suffix automaton.
- Competitive Programmer's Handbook (Laaksonen) — chương String algorithms, mục "Aho-Corasick": code mẫu ngắn gọn cho thi đấu.
Ứng dụng thực tế:
- ClamAV (
libclamav): lưu cơ sở chữ ký virus dạng chuỗi byte dưới dạng AC trie. Mỗi file được quét đúng một lần bất kể có bao nhiêu chữ ký. - Snort/Suricata IDS: content rules (từ khoá trong packet payload) được biên dịch thành AC automaton; một packet được quét qua một lần.
- Hyperscan (Intel): engine regex/string matching dùng SIMD để song song hoá nhiều automaton, đạt throughput hàng chục Gbps.
- grep -F (fixed string, nhiều pattern): GNU grep dùng biến thể Commentz-Walter (kết hợp ý tưởng Aho-Corasick với Boyer-Moore để nhảy bỏ ký tự) cho mode
-Fđa pattern.
Tóm tắt
- Bài toán đa mẫu: KMP × k lần tốn O(k·n) — không thực tế khi
klớn. - Aho-Corasick xây trie từ toàn bộ pattern O(M), thêm failure link bằng BFS O(M), rồi quét text O(n + z) — tổng O(M + n + z).
- Failure link
fail[v]= node ứng với hậu tố dài nhất của chuỗi tạivmà cũng là tiền tố pattern — tổng quát hoálpsKMP lên trie. - Output link: mỗi node gộp output của failure node để quét text thu đủ match O(1) mỗi ký tự.
- BFS xây failure link đảm bảo tầng
d-1tính trước tầngd— điều kiện cần để failure link đúng. - Pitfall hay gặp: quên gộp output, dùng DFS thay BFS, failure link root tạo vòng lặp vô hạn.
- Nền tảng của ClamAV, Snort, Hyperscan — bất cứ hệ thống nào cần khớp nhiều pattern trong một lần quét.
Tự kiểm tra
Q1Tại sao cần failure link? Nếu không có failure link, automaton Aho-Corasick sẽ hỏng như thế nào?▸
Không có failure link, khi ký tự tiếp theo không có cạnh từ node hiện tại, ta không biết chuyển về đâu ngoài root. Điều này có thể bỏ sót match: ví dụ đang ở node SH (khớp "sh"), gặp ký tự 'e' không có cạnh — về root rồi thử lại từ 'e'. Nhưng 'e' cũng không có cạnh từ root → bỏ sót. Thực ra ta vừa đọc "she" — mà "he" là pattern, cần báo.
Failure link fail[SH] = H cho phép: khi không có cạnh 'e' từ SH, chuyển sang H; H có cạnh 'e' → HE → báo "he". Không có failure link, automaton phải restart từ root mỗi khi thất bại → có thể bỏ sót pattern là hậu tố của chuỗi vừa đọc.
Q2Tại sao xây failure link bằng BFS theo tầng, không phải DFS?▸
Failure link của node v được tính từ failure link của parent(v): ta theo fail[parent(v)] rồi kiểm tra cạnh ký tự cạnh đến v. Nếu fail[parent(v)] chưa được tính đúng, fail[v] sẽ sai.
BFS đảm bảo khi xử lý node tầng d, mọi node tầng d-1 (bao gồm parent(v)) đã có failure link chính xác. DFS có thể xử lý node tầng d trước khi failure link tầng d-1 hoàn tất — dẫn đến failure link lan truyền sai theo chuỗi.
Q3Khi xây failure link cho node v (tầng d), tại sao ta bắt đầu từ fail[parent(v)] thay vì từ root?▸
Chuỗi biểu diễn bởi v là chuỗi biểu diễn bởi parent(v) + ký tự cạnh c. Failure link của v cần là node ứng với hậu tố dài nhất của chuỗi v mà cũng là tiền tố pattern.
Hậu tố dài nhất đó phải là hậu tố của chuỗi parent(v) (cộng ký tự c). fail[parent(v)] cho ta hậu tố dài nhất của parent(v) là tiền tố pattern — đây là ứng viên tốt nhất để thử thêm c. Bắt đầu từ root sẽ phải duyệt qua tất cả hậu tố ngắn hơn, tốn O(depth) mỗi node trong worst case thay vì được bù bởi amortized.
Q4Cho pattern {"ab", "bc", "abc"}. Vẽ trie và cho biết failure link của node tương ứng "abc".▸
Trie: root → A → AB → ABC; root → B → BC. Node AB có output "ab". Node ABC có output "abc". Node BC có output "bc".
Tầng 1: fail[A] = root, fail[B] = root.
Tầng 2: AB — cha A, fail[A]=root, thử cạnh 'b' từ root → B tồn tại → fail[AB] = B. BC — cha B, fail[B]=root, thử cạnh 'c' từ root → không có → fail[BC] = root.
Tầng 3: ABC — cha AB, fail[AB]=B, thử cạnh 'c' từ B → BC tồn tại → fail[ABC] = BC. Gộp output: ABC.output = {"abc"} + BC.output = {"abc", "bc"}. Vậy khi automaton đến ABC (vừa đọc "abc"), nó báo cả "abc" lẫn "bc" — hậu tố "bc" cũng là pattern.
Q5Aho-Corasick quét text O(n + z). Biến z là gì và tại sao cần cộng z vào? Nếu z = O(n·k) thì tổng là bao nhiêu?▸
z là tổng số lần khớp — mỗi cặp (pattern, vị trí) tìm được. Cần cộng z vì bản thân việc in ra hoặc ghi lại mỗi lần khớp tốn O(1); tổng output là O(z).
Nếu z = O(n·k) (mọi ký tự text khớp mọi pattern — ví dụ text = "aaaa..." và mọi pattern là "a", "aa", "aaa"...), tổng là O(M + n·k). Lúc này bottleneck là output, không phải automaton. Aho-Corasick vẫn tối ưu vì không thể báo n·k lần khớp trong ít hơn O(n·k) — đây là lower bound của output.
Trong thực tế (ClamAV, IDS), z rất nhỏ so với n (file lớn, ít chữ ký khớp) → hiệu quả gần O(n).
Q6So sánh failure link của Aho-Corasick với lps của KMP. Điểm giống và điểm khác cốt lõi là gì?▸
Giống: cả hai đều trả lời câu hỏi "khi thất bại tại vị trí hiện tại, có thể dịch về đâu để không mất thông tin đã khớp?". Cả hai dựa trên nguyên lý "hậu tố dài nhất của đoạn đã khớp mà cũng là tiền tố pattern". Cả hai build bằng kỹ thuật BFS/DP tương đương, chạy trong O(M).
Khác: lps là mảng 1D trên đơn pattern — chuỗi pattern là cây một nhánh. Failure link là con trỏ trên trie nhiều nhánh. lps[i] chỉ có một giá trị; fail[v] là một node trong trie có thể có nhiều con. Failure link của Aho-Corasick phải xử lý thêm output link (gộp match từ failure chain) vì nhiều pattern có thể là hậu tố của nhau — không có analogue trong KMP đơn pattern.
Q7Nếu muốn thêm một pattern mới vào Aho-Corasick đã build, có cách nào cập nhật incremental không? Chi phí là bao nhiêu?▸
Về lý thuyết: thêm pattern mới vào trie là O(|P_new|) — đơn giản. Nhưng failure link và output link cần rebuild vì pattern mới có thể tạo ra failure link mới cho các node hiện có (pattern mới có thể là hậu tố của pattern cũ, hoặc ngược lại).
Rebuild toàn bộ failure link sau khi thêm: O(M_new·Σ) = O((M + |P_new|)·Σ). Không có thuật toán công khai nổi tiếng cho incremental update thực sự O(|P_new|). Trong thực tế (ClamAV), cập nhật cơ sở dữ liệu chữ ký là batch: download cả file mới → rebuild toàn bộ automaton — acceptable vì cập nhật ít thường xuyên so với số file scan.
Bài tiếp theo: Mini-challenge — tự viết grep bằng KMP
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