Exercises — Aho-Corasick — multiple pattern search, automaton
A tiny reminder of the vocabulary you will use, in plain words:
Related tools you may want open in another tab: Trie — prefix tree, KMP — single pattern matching, BFS — breadth-first search, Finite Automata — DFA/NFA.
Level 1 — Recognition
L1.1
State, in one sentence each, what these four objects are: str(v), fail[v], δ(v,c), the dictionary chain.
Recall Solution
str(v): the string spelled from root to .fail[v]: the node for the longest proper suffix ofstr(v)that is still a node (a pattern prefix).δ(v,c): where readingctakes you — the real child if it exists, otherwiseδ(fail[v], c).- Dictionary chain: the sequence of terminal nodes found by following
faillinks up from ; all report a match ending here.
L1.2
Patterns {he, she, his, hers}. Which of these strings is a node in the trie, and which is not: h, sh, her, hi, sha, e?
Recall Solution
A string is a node iff it is a prefix of some pattern.
h✓ (prefix of he, his, hers)sh✓ (prefix of she)her✓ (prefix of hers)hi✓ (prefix of his)sha✗ (no pattern startssha)e✗ (no pattern starts withe; note "e" appears in "he" but is not a prefix)
Level 2 — Application
L2.1
Patterns {a, ab, bab, bc, bca, c, caa}. Compute fail[bab] by hand, showing the failure-chain search.
Recall Solution
str(bab) = "bab", parent p = ba via character b.
We want the longest proper suffix of "bab" that is a node. Proper suffixes ending in the last char b: "ab", "b", "" (plus c-check).
Walk the failure chain of p = ba, looking for a child on b:
fail[ba]= longest proper suffix of"ba"that is a node ="a"(node ✓). Does nodeahave a child onb? Yes:a → ab. Stop.- So
fail[bab] = ab. Check:"ab"is indeed the longest proper suffix of"bab"that is a node. ✓
L2.2
Same patterns. Fill the scan table for text abcc. Report all matches, and for each character say why you moved where you did.
Recall Solution
| char | state after | reports | why |
|---|---|---|---|
| a | a |
a | because root has a real edge on a; a terminal |
| b | ab |
ab | because a has a real edge on b; ab terminal |
| c | bc |
bc, c | because ab has no real edge on c, so δ(ab,c)=δ(fail[ab]=b, c); b has real edge c → bc; dictLink[bc]=c fires c |
| c | c |
c | because bc has no real edge on c, so δ(bc,c)=δ(fail[bc]=c, c); c has no c edge, so δ(c,c)=δ(fail[c]=root, c)=root child c=c; c terminal |
Matches (grouped by end position, longest-first within a position): a (pos 1), ab (pos 2), bc, c (pos 3), c (pos 4).
L2.3
For text abcc, why did the third character (c) report two patterns from a single state?
Recall Solution
After landing at node bc (a terminal), the dictionary chain is bc → fail[bc] = c → fail[c] = root. Node c is also terminal, so it fires too. Both patterns end at that same text position, one being a suffix of the other. The dictLink[bc] shortcut points straight from bc to c, letting us emit both without visiting non-terminal nodes.
Level 3 — Analysis
L3.1
Prove that fail[v] always points to a node of strictly smaller depth than . Why does this single fact justify BFS ordering?
Recall Solution
Recall (defined at the top of this page): the number of characters in the string spelled from the root to . Now fail[v] is a proper suffix of str(v), so |str(fail[v])| < |str(v)|, hence — strictly smaller. Therefore when BFS reaches depth , every node of depth (including all fail targets it needs) is already finalized. This is exactly why we compute fail in breadth-first order and not recursively.
L3.2
The parent claims scanning costs where = number of reported matches. Explain the "potential argument": why does the total failure-chain walking across the whole scan not blow up, even though a single step might jump many links?
Recall Solution
Define a potential = current of the state. Each read of a text character increases depth by at most (one downward edge), so over characters depth rises by at most total. Every failure jump decreases depth by at least . Since depth starts at , stays , and only rises times total, the number of failure jumps summed over the entire scan is . Hence all the jumping is amortized — no single step's cost matters, only the total. (With the full δ table each character is even simpler: one lookup.) The extra is unavoidable: you must spend time emitting each of the matches.
L3.3
Patterns {he, she, his, hers}. A student claims fail[hers] = s. Is this correct? If not, give the right answer with reasoning.
Recall Solution
str(hers) = "hers". Longest proper suffix of "hers" that is a node: check "ers" (✗), "rs" (✗), "s" (✓ node). Any longer suffix that is a node? "ers", "rs" are not prefixes of any pattern. So fail[hers] = s. The student is correct. (Trap defused: many expect a fancier target, but s genuinely is the longest matching suffix here.)
Level 4 — Synthesis
L4.1
Build the automaton for patterns {ab, bab, ba} from scratch: list all nodes, give fail[v] for each, then draw the failure links.
The figure below shows the finished automaton so you can check yourself: solid teal arrows are trie edges (each adds one character, going down to greater depth), dashed plum arrows are failure links (each points up to a strictly shorter suffix), and orange nodes are terminals where a pattern ends. Notice visually that every plum arrow rises to a shallower node — the depth-strictly-decreases fact from L3.1 made concrete.

Recall Solution
Nodes (as strings): root, a, ab(✓ab), b, ba(✓ba), bab(✓bab).
Compute fail in BFS (increasing depth):
- depth 1:
fail[a]=root,fail[b]=root(root's children fail to root). - depth 2:
fail[ab]: parenta, charb. Walkfail[a]=root; root child onb? yesb. Sofail[ab]=b.fail[ba]: parentb, chara. Walkfail[b]=root; root child ona? yesa. Sofail[ba]=a.
- depth 3:
fail[bab]: parentba, charb. Walkfail[ba]=a; nodeachild onb? yesab. Sofail[bab]=ab. This matches the plum arrows in the figure above.
L4.2
Using the automaton from L4.1, scan text babab and report every match with the reason for each transition.
Recall Solution
| char | state | reports | why |
|---|---|---|---|
| b | b |
– | because root has a real edge on b |
| a | ba |
ba | because b has a real edge on a; ba terminal |
| b | bab |
bab, ab | because ba has a real edge on b → bab terminal; dictLink[bab]=ab fires ab |
| a | ba |
ba | because bab has no real edge on a, so δ(bab,a)=δ(fail[bab]=ab, a); ab has no a edge, so =δ(fail[ab]=b, a); b has real edge a → ba terminal |
| b | bab |
bab, ab | because ba has a real edge on b → bab terminal; dictLink[bab]=ab fires ab |
Matches grouped by end position (longest-first): ba(pos 2), bab, ab(pos 3), ba(pos 4), bab, ab(pos 5).
L4.3
Design patterns (at least three) for which some node's dictionary chain reports three patterns at one position. Give the patterns and the position.
Recall Solution
Take {c, bc, abc}. Scan text abc.
a→ nodea(no report), because root has real edgeab→ab? real edge exists →ab(no report)c→abc(✓abc), becauseabhas real edgec. Dictionary chain:abc → fail = bc (✓) → fail = c (✓) → root. At the finalc, three patterns fire: abc, bc, c — each a suffix of the previous. Position: index 3 (last char).
Level 5 — Mastery
L5.1
You are given patterns with total length over alphabet size , and a text of length with total matches. A colleague proposes: "just run KMP once per pattern." State both costs and give a precise inequality for when Aho-Corasick is asymptotically cheaper.
Recall Solution
- KMP-per-pattern total cost: — building all prefix functions is , each of the patterns scans the whole text (), plus reporting .
- Aho-Corasick: build (full table), scan ; total . Precise criterion. Aho-Corasick is asymptotically cheaper exactly when its total is smaller: Rearranging (the terms cancel): So for any , Aho-Corasick wins once the text is longer than . As grows this threshold shrinks, and with maps (build , so the term becomes ) the condition relaxes to simply whenever .
L5.2
Prove the amortized bound: over the entire scan, the total number of failure-link hops used to emit outputs (following fail links to collect matches) is only if you use dictLink; naively it can be . Give a family of inputs where the naive output-walk is quadratic.
Recall Solution
Naive quadratic family: patterns {a, aa, aaa, …, a^k} (all prefixes of aⁿ), text aⁿ.
At text position , the state is node a^i, and its failure chain is a^i → a^{i-1} → … → a → root. To emit outputs the naive walk visits every node on the chain, costing at position , and — quadratic.
The dictLink fix: dictLink[v] = the nearest terminal node strictly above on the failure chain. Emitting then hops only from one reported match to the next terminal, so the emit work is proportional to the number of matches plus per character, giving . The key: never touch a non-terminal node during output.
L5.3
Full construction challenge. Patterns {ab, ba, bab, aba}, alphabet {a,b}. (a) List nodes and fail. (b) Compute the full transition table for all nodes and both characters, justifying each cell. (c) Scan ababa and list matches.
Recall Solution
(a) Nodes: root, a, ab(✓ab), aba(✓aba), b, ba(✓ba), bab(✓bab).
Fail (BFS):
fail[a]=root,fail[b]=rootfail[ab]: parenta,b;fail[a]=root, root childb→fail[ab]=bfail[ba]: parentb,a;fail[b]=root, root childa→fail[ba]=afail[aba]: parentab,a;fail[ab]=b,bchilda? yesba→fail[aba]=bafail[bab]: parentba,b;fail[ba]=a,achildb? yesab→fail[bab]=ab
(b) Full δ — each cell says whether a real edge exists, else recurses via fail:
| node | δ(·,a) | why | δ(·,b) | why |
|---|---|---|---|---|
| root | a | real edge root→a | b | real edge root→b |
| a | a | no real edge; δ(fail[a]=root,a)=a | ab | real edge a→b |
| ab | aba | real edge ab→a | b | no real edge; δ(fail[ab]=b,b)=b (b has no b-edge, δ(root,b)=b) |
| aba | a | no real edge; δ(fail[aba]=ba,a): ba has no a-edge, δ(fail[ba]=a,a)=a | ab | no real edge; δ(fail[aba]=ba,b)=bab? ba has real edge b→bab, so =bab |
| b | ba | real edge b→a | b | no real edge; δ(fail[b]=root,b)=b |
| ba | a | no real edge; δ(fail[ba]=a,a)=a | bab | real edge ba→b |
| bab | aba | real edge bab→a (ab→a=aba)… actually bab has no a-edge; δ(fail[bab]=ab,a)=aba | b | no real edge; δ(fail[bab]=ab,b)=b |
Correction on δ(aba,b): aba has no real b-edge, so δ(aba,b)=δ(fail[aba]=ba, b)=bab (ba has real edge b). And δ(bab,a): bab has no real a-edge, so δ(bab,a)=δ(fail[bab]=ab, a)=aba (ab has real edge a).
(c) Scan ababa:
| char | state | reports | why |
|---|---|---|---|
| a | a | – | real edge root→a |
| b | ab | ab | real edge a→b; ab terminal |
| a | aba | aba, ba | real edge ab→a → aba; dictLink[aba]=ba fires ba |
| b | ab | ab | no real edge aba→b; δ(fail[aba]=ba,b)? ba real edge b=bab… wait: see note |
| a | aba | aba, ba | continues |
Careful re-scan (using the full δ table):
a: root→a. reports: none.b:areal edge b →ab. reports: ab.a:abreal edge a →aba. reports aba; dictLink[aba]=ba→ ba.b:abano b-edge → δ(aba,b)=bab. reports bab; dictLink[bab]=ab→ ab.a:babno a-edge → δ(bab,a)=aba. reports aba; dictLink[aba]=ba→ ba.
Matches grouped by end position (longest-first): ab(pos 2), aba, ba(pos 3), bab, ab(pos 4), aba, ba(pos 5).
Recall Self-test checklist (open only after finishing)
Did you, for every problem, (1) confirm each claimed node is a real prefix, (2) verify , (3) follow the dictionary chain (or dictLink) for output not just the terminal flag, and (4) build fail/δ in BFS order? If yes, you have mastered the mechanics.