DSA DSA In Depth
0%
lesson regexthompson-nfaredos

Regular Expression Engines: Thompson's O(NM) NFA Simulation vs Catastrophic ReDoS Backtracking

7 min read Module 4.2: Suffix Structures, Sequence Alignment & Data Compression Updated Oct 23, 2026
📄 Cheat Sheet
Regular Expressions & Finite Automata (Ken Thompson's NFA Construction)
Computerphile Regular Expressions & Finite Automata (Ken Thompson's NFA Construction) Watch on YouTube ↗

Contrasts recursive PCRE-style backtracking against Ken Thompson's 1968 lockstep multi-state NFA simulation and explains how Cloudflare and StackOverflow suffered ReDoS outages.

0:00 / --:--

1. Why Recursive Backtracking Explodes to $\Omega(2^N)$

Consider an NFA state with an $\varepsilon$-split (created by alternation e1|e2 or quantifiers e*, e+, e?). A backtracking engine treats the NFA like a maze and runs Depth-First Search (DFS) on a call stack: at each split, it guesses Branch 1 and explores all the way to the end of the text; if that fails, it pops the stack and tries Branch 2 from the same text index. On (a+)+b with input $\texttt{"a"}^N\texttt{"c"}$, every character $i \in [0, N-1]$ offers two choices (stay in the inner a+ or loop through the outer (...)+), creating a binary recursion tree of $2^N$ leaves—all of which fail at $\texttt{'c'}$!

2. Thompson's Construction & Lockstep Subset Simulation

Ken Thompson's algorithm replaces DFS over paths with Breadth-First Lockstep Simulation over states:

  1. Bounded Out-Degree NFA Fragments: Using postfix regex conversion (or AST traversal), every operator compiles into a tiny fragment with at most 2 outgoing arrows per state:
    • Literal $c$: A state Char(c) with 1 outgoing arrow out.
    • Concatenation $e_1 e_2$: Wire e1's dangling arrows to e2.start.
    • Alternation $e_1 | e_2$: A state Split(e1.start, e2.start) with two $\varepsilon$-arrows.
    • Zero-or-More $e^*$: A state Split(e.start, out) where e's end loops back to the Split.
  2. Deduplicated State Lists (clist, nlist) via list_id: At text index $i$, we maintain clist, the set of currently active NFA states (already closed under $\varepsilon$-transitions). For character $T[i]$, every Char(T[i]) state in clist steps along its outgoing arrow and recursively follows any Split ($\varepsilon$) transitions into nlist. To ensure each NFA state is added to nlist at most once per step in $O(1)$ time, each state $s$ stores last_step[s]: if last_step[s] == step_id, we immediately return without re-visiting $s$!
Regex Engine Architecture Worst-Case Time Memory Overhead Production Examples
Recursive Backtracking (DFS) $\Theta(2^N)$ exponential (ReDoS!) $O(N)$ call stack PCRE, Python re, JS RegExp, Java, .NET
Thompson NFA Lockstep Simulation $O(N \cdot M)$ strict polynomial $O(M)$ states Go regexp, Pike VM, grep fallback
Lazy DFA (On-the-Fly Subset Cache) $O(N)$ typical, $O(N \cdot M)$ fallback Bounded DFA cache (e.g., 2 MB) Google RE2, Rust regex, ripgrep
Thompson Lockstep Active-Set Invariant

After processing prefix $T[0..i-1]$ at step step_id, clist contains the exact $\varepsilon$-closure of all NFA states reachable from $q_0$ by spelling $T[0..i-1]$. Because last_step[s] == step_id prevents any state $s \in [0, |V|-1]$ from being inserted or $\varepsilon$-expanded more than once per character, and each state has out-degree $\le 2$, processing $T[i]$ performs at most $2|V| = O(M)$ operations regardless of path ambiguity.

Lazy DFA (On-the-Fly Powerset Caching in RE2 and Rust regex)

Full DFA compilation of an $M$-state NFA can blow up to $2^M$ states in memory, whereas Thompson NFA simulation does $O(M)$ work per byte. Google's RE2 and Rust's regex combine the best of both worlds with a Lazy DFA: each visited subset of NFA states clist is hashed into a single DFA state ID, and its transition dfa[u][c] is cached on first use! On normal inputs, after a few dozen bytes the hot DFA transitions are cached and the engine runs at $1$ table lookup per byte ($O(N)$); if an adversarial regex causes the DFA cache to hit its memory cap (e.g., 2 MB), the engine flushes the cache or falls back seamlessly to Thompson's $O(N \cdot M)$ NFA.

3. Visual Trace: Backtracking Tree vs. Thompson Lockstep on (a+)+b

Backtracking DFS on (a+)+b ("aaaa...c") Each 'a' branches into 2 stack frames: i=0 (1) -> i=1 (2) -> i=2 (4) -> ... -> 2^N Deduplication: NONE (re-explores same state at same text index millions of times) N = 35 => 34,359,738,368 steps (HANG!) Thompson Lockstep NFA on (a+)+b Maintains deduplicated active set clist: i=0 {s1,s3} -> i=1 {s1,s3} -> ... -> {s1,s3} Deduplication: last_step[s] == step_id guarantees |clist| <= M states at all times N = 35 => 35 * 4 = 140 steps (0.002 ms!)
flowchart LR A["Init step_id = 1; add_state(clist, start_state)"] --> B["For each char c in Text T"] B --> C["step_id++; clear nlist"] C --> D["For each state u in clist: if u.c == c, add_state(nlist, u.out)"] D --> E["swap(clist, nlist)"] E --> B
Burrows-Wheeler Transform (BWT) Rotation Matrix, FM-Index LF-Mapping & Thompson NFA
O(|P|) FM-Index Backward Search · O(NM) Thompson NFA

Trace the sorted cyclic rotation matrix of "banana$", LF-mapping rank queries shrinking [sp, ep] backward for P = "ana", and Ken Thompson’s lockstep NFA state-set simulation O(NM) vs O(2^N) backtracking ReDoS.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

BWT(banana$) = L = "annb$aa" (runs of "nn" and "aa" compress via RLE/MTF!). Backward search starts at P[2]='a': C['a']=1, range [sp, ep] = [1, 3].
📊 Live Magnitude & Pointer Stage — Burrows-Wheeler Transform (BWT) Rotation Matrix, FM-Index LF-Mapping & Thompson NFA Active Pivot Match/Sorted
0: $ba… [0] 1: a$b… [1] 2: ana… [2] 3: ana… [3] 4: ban… [4] 5: na$… [5] 6: nan… [6]
[0]0: $banan [a]
[1]1: a$bana [n]
[2]2: ana$ba [n]
[3]3: anana$ [b]
[4]4: banana [$]
[5]5: na$ban [a]
[6]6: nana$b [a]
BWT Last Column L
"annb$aa"
Matched Suffix of P
"a" (P[2])
SA Interval [sp, ep]
[0, 6] (7 rows)
Query Time
O(|P|) Backward Steps
[Step 1] Step 1 (char 'a'): C['a'] = 1, Occ('a', 7) = 3 -> all suffixes starting with "a" sit in SA range [1..3].

4. Production Implementation: Thompson NFA Lockstep Simulator

#include <string_view>
#include <vector>

class ThompsonNFA {
public:
    enum Op { CHAR, SPLIT, MATCH };
    struct State {
        Op op;
        char ch = 0;
        int out = -1;
        int out1 = -1;
    };

    explicit ThompsonNFA(std::vector<State> states, int start)
        : states_(std::move(states)), start_(start) {}

    // Simulates the NFA in strict O(N * M) time with zero backtracking
    bool full_match(std::string_view text) const {
        const int m = static_cast<int>(states_.size());
        std::vector<int> clist, nlist, last_step(m, 0);
        clist.reserve(m);
        nlist.reserve(m);

        int step_id = 1;
        add_state( start_, clist, last_step, step_id);

        for (char c : text) {
            ++step_id;
            nlist.clear();
            for (int u : clist) {
                if (states_[u].op == CHAR && (states_[u].ch == c || states_[u].ch == '.')) {
                    add_state(states_[u].out, nlist, last_step, step_id);
                }
            }
            clist.swap(nlist);
            if (clist.empty()) return false;
        }

        for (int u : clist) {
            if (states_[u].op == MATCH) return true;
        }
        return false;
    }

private:
    std::vector<State> states_;
    int start_;

    void add_state(int u, std::vector<int>& list,
                   std::vector<int>& last_step, int step_id) const {
        if (u < 0 || last_step[u] == step_id) return;
        last_step[u] = step_id;
        if (states_[u].op == SPLIT) {
            add_state(states_[u].out, list, last_step, step_id);
            add_state(states_[u].out1, list, last_step, step_id);
            return;
        }
        list.push_back(u);
    }
};
Why Backreferences (\1, \2) Break Linear-Time Automata

Why didn't Python, JavaScript, and PCRE simply switch to Thompson NFAs decades ago? Because POSIX/Perl backreferences (like ([a-z]+)_\1, matching any repeated word) are not regular expressions in the formal Chomsky hierarchy—in fact, matching regexes with backreferences is NP-complete (reducible from 3-SAT)! Engines like Google's RE2 and Rust's regex deliberately omit backreferences and arbitrary lookaround assertions from their syntax so they can guarantee $O(N \cdot M)$ worst-case execution on untrusted user inputs.

Check yourself Question 1 of 4
Concept Check Root Cause of Catastrophic Backtracking (ReDoS) recall

A web server validates user input with the regular expression `^(a+)+$` or `^([a-zA-Z0-9]+)*$` using a PCRE/JavaScript/Python backtracking regex engine, and freezes when sent `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"` ($30$ `'a'`s followed by `'!'$).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

From a formal language and complexity theory standpoint, why can't `(.*)\1` be implemented using a finite-state Thompson NFA or DFA?

Key Takeaways

Backtracking regex engines (PCRE, Python re, JS RegExp) explore paths via DFS without state deduplication, degrading to O(2^N) time (ReDoS) on ambiguous patterns like (a+)+b.
Thompson's construction compiles a regex of length M into an NFA with at most 2M states (each of out-degree <= 2) and simulates all active states in lockstep in O(N * M) worst-case time.
Using a generation counter last_step[u] == step_id during epsilon-closure expansion guarantees each NFA state is visited at most once per input character.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue