Data Structures & Algorithms (DSA) Module 16 – String algorithms
Aho-Corasick: many patterns at once
Find every occurrence of many patterns in one pass over a text with a trie, failure links and output links, built breadth first in linear time.
What you will learn
- Build a trie of patterns with failure links and output links
- Find every occurrence of every pattern in one pass over the text
- Analyse the running time and the number of matches reported
Before you start
What you’ll learn · Before you start
What you will learn
- Build a trie of patterns with failure links and output links
- Find every occurrence of every pattern in one pass over the text
- Analyse the running time and the number of matches reported
Before you start
On this page
Aho-Corasick finds every occurrence of every pattern from a list in one left-to-right pass over a text. It builds a trie of the patterns and gives each node a failure link: the longest proper suffix of the node’s string that is also a path in the trie. While reading the text, the automaton follows a trie edge when the next character has one and failure links when it has none, so it never moves back in the text, and output links report every pattern that ends at the current character. Building takes time in proportion to the total length L of the patterns, and the scan takes O(n + z) for a text of n characters with z matches. Keyword filters, signature scanners and other searches for thousands of fixed strings at once are its typical uses.
Many patterns, one pass
Searching for each pattern on its own costs a pass over the text per pattern: 10⁴ patterns in a text of 10⁶ characters means 10¹⁰ character steps. Aho-Corasick makes at most 2n moves plus one report per match, whatever the number of patterns, because all the patterns share one automaton; NIST’s dictionary of algorithms notes that the machine’s transitions do not grow with the number of keywords. Alfred Aho and Margaret Corasick published the method in 1975, for searching bibliographic text. It generalises Knuth-Morris-Pratt: with a single pattern, the failure links are exactly KMP’s prefix function.
Trie, failure links and output links
The trie below stores the patterns tea, eat, ate and at. A node stands for the string its path spells from the root, and four nodes are marked as the places where a pattern ends.
The Aho-Corasick automaton for tea, eat, ate and at
Text description of the diagram
The root sits at the top. Below it are three columns, one per first letter, each three levels deep: t, te, tea on the left; e, ea, eat in the middle; a, at, ate on the right. Solid arrows are trie edges, labelled with the letter they read. A double border marks the four nodes where a pattern ends: tea, eat, at and ate.
Dashed arrows are failure links, each pointing to the longest proper suffix of the node's string that is also a node:
- te fails to e, and tea fails to ea, moving from the left column to the middle one.
- ea fails to a, and eat fails to at. The link from eat is also its output link, because at is a pattern: whenever eat is found, at ends at the same place.
- at fails to t, and ate fails to te, moving from the right column back to the left one.
- t, e and a fail to the root (not drawn).
A failure link answers one question. The text has just spelled this node’s string, and the next character has no edge: what is the longest piece at the end of the text read so far that can still grow into a pattern? It is the longest proper suffix of the node’s string that is a node too. The node tea fails to ea, because a text that spelled tea also spelled ea, and from ea the letter t continues into eat. A node with no such suffix fails to the root.
An output link points along the failure chain to the nearest node where a pattern ends. The node eat links to at, so reaching eat reports two matches: eat, and at, which ends at the same character. Without output links a scan reports only the deepest pattern at each position and misses the ones nested inside it.
Building the links breadth first
The children of the root fail to the root. For a node v reached from its parent u by the letter c, follow failure links from u until a node with an edge for c turns up, or the root does; v fails to that edge’s target, or to the root if there is none. A failure link always points to a shallower node, so taking nodes in breadth-first order guarantees that every link this step follows is already final. The output link of v is its failure target when a pattern ends there, and the failure target’s own output link otherwise. With dictionaries for the edges, the whole construction takes O(L) time for patterns of total length L. The links can also be compiled into a full transition table, one next node per node and letter, which spends memory to make the scan faster.
Scanning the text
The scan keeps one node: the longest suffix of the text read so far that is a trie path. For each character it follows failure links while the node has no edge for it, takes the edge if there is one, and reports the patterns at the node and along its output links. Its whole state is that one node, so the same scan works on a stream that arrives in pieces.
# Aho-Corasick for the patterns tea, eat, ate and at: the trie, the failure links built breadth first, the output
# links, then one pass over a short text that reports every occurrence of every pattern.
from collections import deque
def build(patterns):
goto = [{}] # goto[node][letter] = child node; node 0 is the root
fail = [0]
ends = [[]] # patterns that end exactly at this node
label = [""] # the string the path to this node spells
for p in patterns:
node = 0
for c in p:
if c not in goto[node]:
goto.append({})
fail.append(0)
ends.append([])
label.append(label[node] + c)
goto[node][c] = len(goto) - 1
node = goto[node][c]
ends[node].append(p)
out = [0] * len(goto) # output link: the nearest node on the failure chain where a pattern ends (0: none)
order = []
queue = deque(goto[0].values()) # depth 1: their failure link is the root
while queue:
u = queue.popleft()
order.append(u)
for c, v in goto[u].items():
f = fail[u]
while f and c not in goto[f]:
f = fail[f]
fail[v] = goto[f].get(c, 0)
out[v] = fail[v] if ends[fail[v]] else out[fail[v]]
queue.append(v)
return goto, fail, ends, out, label, order
def search(text, machine):
goto, fail, ends, out = machine[:4]
node = 0
for i, c in enumerate(text):
while node and c not in goto[node]:
node = fail[node] # no edge for c: fall back to the longest suffix that is still a trie path
node = goto[node].get(c, 0)
m = node if ends[node] else out[node]
while m:
for p in ends[m]:
yield i - len(p) + 1, p
m = out[m]
patterns = ["tea", "eat", "ate", "at"]
machine = build(patterns)
goto, fail, ends, out, label, order = machine
print(f"{'node':<6} {'failure link':<13} {'output link':<12} ends here")
for v in order:
shown = label[out[v]] if out[v] else "-"
print(f"{label[v]:<6} {label[fail[v]] or 'root':<13} {shown:<12} {', '.join(ends[v]) or '-'}")
text = "seated at the teatray"
print()
print(text)
for start, p in search(text, machine):
print(f"{' ' * start + p:<24}{p}, from position {start}") Output
node failure link output link ends here
t root - -
e root - -
a root - -
te e - -
ea a - -
at t - at
tea ea - tea
eat at at eat
ate te - ate
seated at the teatray
eat eat, from position 1
at at, from position 2
ate ate, from position 2
at at, from position 7
tea tea, from position 14
eat eat, from position 15
at at, from position 16
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 aho.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The table lists the nodes in breadth-first order with both links, and the scan of “seated at the teatray” finds seven matches. At position 3 the automaton stands on eat and reports eat, then at through the output link. In “teatray” it moves from tea to eat through the failure link of tea, without reading any character twice.
The number of matches is the part of the cost that the automaton cannot shrink. With the patterns a, aa and aaa, every a after the second one in a run of a’s ends three matches, and with k nested patterns of this kind every character can end k of them, so z can exceed n many times over. A filter that only needs a count per pattern should not list the matches one by one.
Find and Replace Text Apply a list of find-and-replace rules to a text at once, the everyday job a multi-pattern matcher does.Practise: count every pattern
Count each pattern’s occurrences in one pass; the sample tests check the counts against a brute-force search.
Exercise · Medium · Python, JavaScript
Count every pattern's occurrences in one pass
Write count_occurrences(text, patterns) (in JavaScript, countOccurrences(text, patterns)) that returns a list with one number per pattern, in the order of patterns: how many times that pattern occurs in text. Occurrences may overlap, so "aa" occurs 3 times in "aaaa". Build an Aho-Corasick automaton and read the text once.
count_occurrences("seated at the teatray", ["tea", "eat", "ate", "at"])returns[1, 2, 1, 3].- A pattern listed twice gets its count twice.
- A pattern longer than the text, or any pattern in an empty text, occurs 0 times. Every pattern has at least one character.
The sample tests check the example above, nested and repeated patterns, edge cases and 150 random inputs against a brute-force count. They are small enough for any correct method; the point of the automaton shows at 10⁴ patterns and a text of 10⁶ characters, where searching for each pattern separately takes billions of steps.
Python · Starter code · occurrences.py
def count_occurrences(text, patterns):
"""Returns how many times each pattern occurs in text (overlaps count), in the order of patterns."""
return [0 for _ in patterns] The sample tests · test_occurrences.py
import random
from occurrences import count_occurrences
def brute_force(text, patterns):
return [sum(text.startswith(p, i) for i in range(len(text))) for p in patterns]
def test_lesson_example():
"""counts tea, eat, ate and at in the lesson's text"""
assert count_occurrences("seated at the teatray", ["tea", "eat", "ate", "at"]) == [1, 2, 1, 3]
def test_overlapping_and_nested():
"""counts overlapping occurrences of nested patterns"""
assert count_occurrences("aaaa", ["a", "aa", "aaa", "aaaaa"]) == [4, 3, 2, 0]
def test_repeated_pattern():
"""gives a pattern listed twice its count twice"""
assert count_occurrences("abab", ["ab", "ba", "ab"]) == [2, 1, 2]
def test_suffix_patterns():
"""finds a pattern that ends inside a longer one"""
text = "she sells sea shells"
patterns = ["he", "she", "hell", "shell", "s", "sea"]
assert count_occurrences(text, patterns) == brute_force(text, patterns)
def test_edges():
"""handles an empty text and patterns longer than the text"""
assert count_occurrences("", ["a", "bc"]) == [0, 0]
assert count_occurrences("ab", ["abc", "b"]) == [0, 1]
def test_random_inputs():
"""matches a brute-force count on 150 random inputs"""
rng = random.Random(8)
for _ in range(150):
letters = "abc"[: rng.randint(2, 3)]
text = "".join(rng.choice(letters) for _ in range(rng.randint(0, 300)))
patterns = ["".join(rng.choice(letters) for _ in range(rng.randint(1, 6))) for _ in range(rng.randint(1, 15))]
assert count_occurrences(text, patterns) == brute_force(text, patterns), (text, patterns) JavaScript · Starter code · occurrences.mjs
/** Returns how many times each pattern occurs in text (overlaps count), in the order of patterns. */
export function countOccurrences(text, patterns) {
return patterns.map(() => 0);
} The sample tests · occurrences.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { countOccurrences } from './occurrences.mjs';
const bruteForce = (text, patterns) =>
patterns.map((p) => {
let n = 0;
for (let i = 0; i < text.length; i++) if (text.startsWith(p, i)) n++;
return n;
});
test("counts tea, eat, ate and at in the lesson's text", () => {
assert.deepEqual(countOccurrences('seated at the teatray', ['tea', 'eat', 'ate', 'at']), [1, 2, 1, 3]);
});
test('counts overlapping occurrences of nested patterns', () => {
assert.deepEqual(countOccurrences('aaaa', ['a', 'aa', 'aaa', 'aaaaa']), [4, 3, 2, 0]);
});
test('gives a pattern listed twice its count twice', () => {
assert.deepEqual(countOccurrences('abab', ['ab', 'ba', 'ab']), [2, 1, 2]);
});
test('finds a pattern that ends inside a longer one', () => {
const text = 'she sells sea shells';
const patterns = ['he', 'she', 'hell', 'shell', 's', 'sea'];
assert.deepEqual(countOccurrences(text, patterns), bruteForce(text, patterns));
});
test('handles an empty text and patterns longer than the text', () => {
assert.deepEqual(countOccurrences('', ['a', 'bc']), [0, 0]);
assert.deepEqual(countOccurrences('ab', ['abc', 'b']), [0, 1]);
});
test('matches a brute-force count on 150 random inputs', () => {
let state = 8;
const next = (m) => {
state = (state * 48271) % 2147483647; // stays below 2 ** 53, so every engine computes the same values
return state % m;
};
for (let t = 0; t < 150; t++) {
const letters = 'abc'.slice(0, 2 + next(2));
let text = '';
for (let i = next(301); i > 0; i--) text += letters[next(letters.length)];
const patterns = [];
for (let k = 1 + next(15); k > 0; k--) {
let p = '';
for (let i = 1 + next(6); i > 0; i--) p += letters[next(letters.length)];
patterns.push(p);
}
assert.deepEqual(countOccurrences(text, patterns), bruteForce(text, patterns), text);
}
}); A hint
Build the trie and the failure links breadth first. While scanning, add 1 to a visit count for the node you reach after each character. Then go through the nodes in reverse breadth-first order and add each node's count to its failure link's count: a node's count becomes the number of times its string ends in the text.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Why the scan is linear, and what the matches cost Pro: opens with a Pro, Premium or Ultimate pass
A single character can send the automaton through a long chain of failure links, yet the whole scan stays linear in the text. This part proves the scan correct, bounds and counts the failure steps on texts built to climb, and counts matches without listing them when there are millions.
In this part:
- The scan invariant, and its proof
- Failure steps counted on three texts
- When matches outnumber characters, measured
- Counting without listing: visits up the failure tree
2 runnable examples (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Tables or dictionaries, and a filter that holds up Pro: opens with a Pro, Premium or Ultimate pass
How the automaton is stored decides its speed and its memory, and what counts as a match decides whether a filter is useful. This part measures a full transition table against dictionaries, sizes both for bytes and Unicode, builds a chat filter that survives common tricks and scans a stream in chunks.
In this part:
- A full transition table against failure links, measured
- Memory per node for letters, bytes and code points
- A chat filter: normalisation, invisible characters, whole words
- Streams that arrive in chunks
3 runnable examples (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Five languages, the bugs small tests miss, and variants Pro: opens with a Pro, Premium or Ultimate pass
The automaton fits in about forty lines in any mainstream language, and the same few bugs turn up in all of them. This part shows five versions with their output, the bugs that pass small tests, and the match rules that replacement and regular expressions use.
In this part:
- The same automaton in Python, JavaScript, Java, C++ and Go
- Bugs that pass small tests
- Match rules: every match, leftmost-first, leftmost-longest
- Practice problems
3 runnable examples (C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does a failure link in an Aho-Corasick automaton point to, and why are the links built breadth first?
Every node of the trie stands for the string its path spells. Its failure link points to the node for the longest proper suffix of that string that is also a path in the trie, or to the root when there is none. When the scan is at a node and the next character has no edge, the failure link is the longest piece at the end of the text read so far that can still grow into a pattern, so the scan moves there and tries again without going back in the text. The link of a node is found by following its parent's link, and a link always points to a shallower node. Building the links level by level, breadth first, guarantees that every link this follows is already final.
-
Mid to senior
A chat service must flag messages that contain any of 20,000 banned words and phrases, in several scripts, at 50,000 messages a second. Design the matcher, and say how you keep innocent words that contain a banned one from being flagged.
-
Mid to senior
A log service uses Aho-Corasick and times out on one customer's 1 MB upload, although it scans 100 MB uploads easily. Their patterns are a, aa, aaa and so on up to 1,000 a's, and the upload is mostly the letter a. Explain the slowdown and fix it; the customer needs only counts.
-
Mid to senior
For each of 50,000 product names, count how many of 2 million customer reviews mention it at least once, not how many times. Design the matcher and the counting, and say what the whole job costs.
Key takeaways
- Aho-Corasick puts all patterns in one trie and adds failure links and output links, built breadth first.
- The scan follows edges and failure links, never moves back in the text, and makes at most 2n moves.
- The total cost is O(L) to build and O(n + z) to scan; z, the number of matches, can far exceed n.
- With one pattern the failure links are KMP’s prefix function.
References
- Efficient string matching: an aid to bibliographic search (Aho and Corasick, Communications of the ACM 18(6), 1975) (Association for Computing Machinery (ACM))
- Fast Pattern Matching in Strings (Knuth, Morris and Pratt, SIAM Journal on Computing 6(2), 1977) (Society for Industrial and Applied Mathematics (SIAM))
- Dictionary of Algorithms and Data Structures: Aho-Corasick (National Institute of Standards and Technology (NIST))
- re: regular expression operations (alternation with |) (Python Software Foundation)
- unicodedata: Unicode Database (normalize) (Python Software Foundation)
- Unicode Standard Annex #15: Unicode Normalization Forms (The Unicode Consortium)
- Built-in Types: str.casefold() and str.isalnum() (Python Software Foundation)
- ACM/IEEE-CS/AAAI Computer Science Curricula 2023: Algorithmic Foundations (AL) (ACM, IEEE Computer Society and AAAI)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress