DSA DSA In Depth
0%
build build-labaho-corasickautomata

Build Lab: Multi-Pattern Aho-Corasick Automaton with Overlapping Match Streamer

7 min read Module 4.1: Tries, Exact String Matching & Multi-Pattern Automata Updated Oct 23, 2026
📄 Cheat Sheet
Aho-Corasick Multi-Pattern Automaton: Trie, BFS Failure Links & Output Links
CodeLucky Aho-Corasick Multi-Pattern Automaton: Trie, BFS Failure Links & Output Links Watch on YouTube ↗

Covers the exact arena implementation details for wiring go[u][c], fail[u], and terminal exit links in a single BFS pass.

0:00 / --:--

1. Engineering Specifications for a Streaming Scanner

To make our Aho-Corasick engine suitable for intrusion detection, log redaction, or DLP (Data Loss Prevention) pipelines, we enforce four architectural invariants:

  1. Zero-Allocation Streaming State (ScannerCursor): The compiled AhoCorasickAutomaton is immutable and thread-safe (const), so 64 worker threads can share a single compiled dictionary while each stream maintains a tiny 16-byte ScannerCursor { int32_t state; uint64_t bytes_seen; }.
  2. Constant-Size Node Layout via Intrusive Pattern Lists: Storing a std::vector<int> inside every Trie node wastes 24 bytes of heap-vector header per state even when 95% of states are non-terminal. Instead, each node stores a single int32_t first_pat = -1, and we store next_pat_at_same_node[pat_id] in a flat array of size $K$.
  3. Exact Start & End Offset Calculation: Because matches can span across chunk boundaries (e.g., $\texttt{" Drop"}$ at the end of Chunk 1 and $\texttt{"Table"}$ at the start of Chunk 2), the cursor tracks bytes_seen, and when pattern pid of length $L_{\text{pid}}$ matches at global byte index pos, its exact start offset is pos + 1 - L_pid.
  4. Compressed Output Links (exit_link): During BFS, we set exit_link[u] to fail[u] if nodes[fail[u]].first_pat != -1, and to exit_link[fail[u]] otherwise.
Component / Operation Time Complexity Space Overhead Thread-Safety / Streaming
add_pattern(pat) $O(|P_i|)$ $1$ int per pattern in linked list Builder phase (mutable)
freeze() (BFS DFA Build) $O(\text{states} \cdot |\Sigma|)$ Zero heap pointers per node Produces immutable DFA
scan_chunk(cursor, bytes, cb) $O(|\text{chunk}| + Z_{\text{chunk}})$ $O(1)$ ($16$-byte cursor) Lock-free across threads & chunks
Cross-Chunk State Continuity Invariant

Let $T = C_1 \mathbin{\|} C_2 \mathbin{\|} \dots \mathbin{\|} C_m$ be a stream partitioned into arbitrary byte chunks. Because $u_{i+1} = \text{go}[u_i][T[i]]$ depends only on $u_i$ and $T[i]$, carrying cursor.state and cursor.bytes_seen from the end of $C_k$ into the start of $C_{k+1}$ visits the exact same sequence of states $u_1, u_2, \dots, u_N$ and emits the exact same set of matches $(i_{\text{start}}, i_{\text{end}}, \text{pid})$ as scanning the concatenated string $T$ in one buffer.

Replacing Per-Node std::vector with Intrusive Pattern Chains

A std::vector<int> inside every Node adds 24 bytes of struct overhead plus a separate heap allocation for every terminal state, scattering terminal pattern lists across memory pages. Storing int32_t first_pat in Node (4 bytes) and int32_t next_pat[K] in a single flat array of length $K$ eliminates all per-node heap allocations and cuts terminal match traversal to sequential array reads.

2. Architecture Diagram: Immutable DFA + Streaming Cursor

Chunk 1: "GET /admin/p" Ends in state u = "p" (offset 12) Chunk 2: "sql?id=1" Resumes from cursor.state = u Immutable DFA Arena go[u][c] + exit_link[u] first_pat[u] -> next_pat[pid] Overlapping Emissions Match "psql" [11..15) Match "sql" [12..15)
flowchart LR A["Read byte b = chunk[i]"] --> B["cursor.state = go[cursor.state][b]"] B --> C["cur = (first_pat[state] != -1) ? state : exit_link[state]"] C --> D{"cur != 0?"} D -- "Yes" --> E["Walk pid = first_pat[cur] via next_pat[pid]; emit (end - len[pid], end, pid)"] E --> F["cur = exit_link[cur]"] F --> D D -- "No" --> G["cursor.bytes_seen++; advance i"]
Aho-Corasick Multi-Pattern DFA (Failure + Output Links) & Manacher / Eertree
O(N + ∑|P_i| + Z) Multi-Pattern · O(N) Palindromes

Scan text T = "ushers" in a single pass against dictionary {"he", "she", "his", "hers"} using BFS failure links and output links, or trace Manacher’s O(N) palindrome radius mirroring & Eertree dual roots.

Step 1 / 6
Custom Input:
Timeline

Invariant & State

Active Trie State = root ("") | Failure Suffix Link fail(u) = root | Emitted Matches = ∅
🌳 Live 2D Hierarchical Tree Topology — Aho-Corasick Multi-Pattern DFA (Failure + Output Links) & Manacher / Eertree Visited/Active Pivot/Root Resolved
state=ro u [0] s [1] h [2] e [3] r [4] s [5]
[0]ustate=root ("")
[1]s
[2]h
[3]e
[4]r
[5]s
Text Index i
i = 0 ('u')
Automaton State
root ("")
fail(state)
root
Output Matches
∅
[Step 1] Read "u": no "u" edge from root -> stay at root.

3. Complete Build Implementation: Streaming Aho-Corasick Engine

#include <array>
#include <cstdint>
#include <queue>
#include <string_view>
#include <vector>

struct MatchEvent {
    uint64_t start_offset; // Inclusive global byte offset
    uint64_t end_offset;   // Exclusive global byte offset
    int32_t pattern_id;
};

struct ScannerCursor {
    int32_t state = 0;
    uint64_t bytes_seen = 0;
};

class StreamingAhoCorasick {
public:
    struct Node {
        std::array<int32_t, 256> go{};
        int32_t fail = 0;
        int32_t exit_link = 0;
        int32_t first_pat = -1; // Head of intrusive linked list of pattern IDs
    };

    StreamingAhoCorasick() { nodes_.emplace_back(); }

    int32_t add_pattern(std::string_view pat) {
        int32_t pid = static_cast<int32_t>(pat_len_.size());
        pat_len_.push_back(static_cast<uint32_t>(pat.size()));

        int32_t u = 0;
        for (unsigned char c : pat) {
            if (nodes_[u].go[c] == 0) {
                nodes_[u].go[c] = static_cast<int32_t>(nodes_.size());
                nodes_.emplace_back();
            }
            u = nodes_[u].go[c];
        }
        // Prepend pid to node u's intrusive pattern list
        next_pat_.push_back(nodes_[u].first_pat);
        nodes_[u].first_pat = pid;
        return pid;
    }

    void freeze() {
        std::queue<int32_t> q;
        for (int c = 0; c < 256; ++c) {
            int32_t v = nodes_[0].go[c];
            if (v != 0) {
                nodes_[v].fail = 0;
                q.push(v);
            }
        }
        while (!q.empty()) {
            int32_t u = q.front();
            q.pop();
            int32_t f = nodes_[u].fail;
            nodes_[u].exit_link = (nodes_[f].first_pat != -1) ? f : nodes_[f].exit_link;

            for (int c = 0; c < 256; ++c) {
                int32_t v = nodes_[u].go[c];
                if (v != 0) {
                    nodes_[v].fail = nodes_[f].go[c];
                    q.push(v);
                } else {
                    nodes_[u].go[c] = nodes_[f].go[c];
                }
            }
        }
        frozen_ = true;
    }

    template <typename Callback>
    void scan_chunk(ScannerCursor& cursor, std::string_view chunk, Callback&& on_match) const {
        int32_t u = cursor.state;
        uint64_t pos = cursor.bytes_seen;

        for (unsigned char c : chunk) {
            u = nodes_[u].go[c];
            ++pos; // pos is now the exclusive end offset of any match ending here

            int32_t term = (nodes_[u].first_pat != -1) ? u : nodes_[u].exit_link;
            while (term != 0) {
                for (int32_t pid = nodes_[term].first_pat; pid != -1; pid = next_pat_[pid]) {
                    on_match(MatchEvent{pos - pat_len_[pid], pos, pid});
                }
                term = nodes_[term].exit_link;
            }
        }
        cursor.state = u;
        cursor.bytes_seen = pos;
    }

private:
    std::vector<Node> nodes_;
    std::vector<uint32_t> pat_len_;
    std::vector<int32_t> next_pat_;
    bool frozen_ = false;
};
Unsigned Byte Casting & Empty-Pattern Guards

In C and C++, char is signed on x86_64 and ARM Linux/macOS targets ($-128..127$). Indexing nodes_[u].go[ch] directly with a signed char on UTF-8 or binary payloads causes out-of-bounds negative array indexing whenever a byte has its high bit set ($ge ext{0x80}$)! Always cast to unsigned char or uint8_t before indexing the transition table, and reject or handle empty patterns ($|P_i| = 0$) before insertion so root state $0$ never becomes a terminal state (since our output loop terminates on term != 0).

Check yourself Question 1 of 4
Concept Check State Preservation Across Streaming Chunk Boundaries recall

A streaming Aho-Corasick scanner processes a 10 GB network stream in 64 KB chunks $C_1, C_2, \dots$. Pattern $P_0 = \texttt{"DROP TABLE"}$ starts 4 bytes before the end of $C_1$ (`"...DROP"`) and finishes 6 bytes into $C_2$ (`" TABLE..."`).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Which hybrid layout (used in `BurntSushi/aho-corasick` and Snort/Hyperscan) drastically shrinks memory while keeping 99% of state transitions in $O(1)$ L1/L2 cache?

Key Takeaways

Decoupling the immutable Aho-Corasick DFA arena from a 16-byte ScannerCursor (state + bytes_seen) allows thread-safe, zero-allocation streaming across arbitrary chunk boundaries.
Replacing per-node std::vector pattern lists with an intrusive linked list (first_pat[u] + next_pat[pid]) eliminates heap allocations inside states and handles duplicate patterns cleanly.
Always index 256-entry transition tables using uint8_t / unsigned char and keep root state 0 non-terminal so the exit_link loop terminates at 0.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue