Home › Sorting, searching & vector search › Text retrieval
algorithm medium

Linear-time regular expression matching

Problem Services and tools must test text against patterns written by users or operators (filters, validation rules, search queries) at high volume, and one unlucky pattern-and-input pair must not pin a CPU for seconds.

Match a regular expression in O(pattern x text) worst case by tracking all live parse positions at once, then cache the state sets as a lazily built DFA for speed.

NFA simulation O(m*n) for pattern size m and text length n; lazy DFA O(n) once states are cached, at most one new state per byte; backtracking exponential in the worst case
regexautomataredosthompson-nfalazy-dfapike-vmlinear-time
Overview

A backtracking matcher tries one way to match, then the next on failure, so some pattern-and-input pairs take exponential time. A Thompson engine keeps the set of every pattern position that could be live after the bytes seen so far and moves the whole set once per byte: at most m positions, n steps. The next set depends only on the set and the byte, so sets can be memoised as DFA states built on demand.

Key facts

Use when

  • Code, log and text search; alert rules; lexers; user-supplied validation rules; network signature sets; limiting which tokens a language model may emit.

Avoid when

Deep dive

How it works

Parts:

  • Parser — pattern to a syntax tree; rejects backreferences and look-around.
  • Compiler — tree to a small NFA program: Char, Any, Split(a,b) (both branches live), Jmp, Match (Thompson 1968; Cox's virtual-machine form).
  • State-set simulator (Pike VM) — set of live program counters, one step per byte; per-thread capture slots give captures.
  • Lazy DFA — memo table (state set, byte) to next state set, built during search, bounded cache.
  • Prefilter — required literal found by SIMD search or Aho-Corasick before any automaton runs.
  1. Parse and compile once to O(m) instructions.
  2. Closure: from a counter follow Jmp and Split to the Char/Any/Match instructions; mark each counter once per step, so the set stays within m.
  3. Step: for each live Char c or Any that accepts the byte, add the closure of pc + 1 to the next set.
  4. Search: add the closure of instruction 0 at every offset; full match: at offset 0 only.
  5. Accept when Match is live (full match: only at the end).
  6. Lazy DFA: key the set by its sorted counters; on a cache miss do steps 2-3 once and store the result. Later visits cost one table load.
  7. Cache full: clear it and rebuild from the current state; if clears are too frequent, drop the DFA and use the step-3 simulation.
  8. Captures: find the span with the DFA, then run the state-set engine on the span only.
a(b|c)*d  ->  0 Char a      4 Jmp 6         set after "a": closure(1) = {3:b, 5:c, 7:d}
              1 Split 2,7   5 Char c        set after "ab": closure(4) = {3:b, 5:c, 7:d}
              2 Split 3,5   6 Jmp 1         same set -> one DFA state, reused
              3 Char b      7 Char d  8 Match

Code

Parser for literals, ., |, *, +, ? and groups; NFA compiler; state-set and backtracking matchers; the test checks they agree on 40,000 random pattern/text pairs, full match and search.

use Inst::*;
pub enum Inst { Char(u8), Any, Split(usize, usize), Jmp(usize), Match }
enum Ast { Lit(u8), Any, Cat(Vec<Ast>), Alt(Box<Ast>, Box<Ast>), Rep(Box<Ast>, u8) }
// alt := cat ('|' cat)* ; cat := (atom [*+?]*)+ ; atom := byte | '.' | '(' alt ')'
fn alt(s: &[u8], i: &mut usize) -> Option<Ast> {
    let mut l = cat(s, i)?;
    while s.get(*i) == Some(&b'|') { *i += 1; l = Ast::Alt(Box::new(l), Box::new(cat(s, i)?)); }
    Some(l)
}
fn cat(s: &[u8], i: &mut usize) -> Option<Ast> {
    let mut v = vec![];
    while let Some(&c) = s.get(*i) {
        if c == b'|' || c == b')' { break; }
        *i += 1;
        let mut a = match c {
            b'(' => { let a = alt(s, i)?; if s.get(*i) != Some(&b')') { return None; } *i += 1; a }
            b'*' | b'+' | b'?' => return None,
            b'.' => Ast::Any,
            _ => Ast::Lit(c),
        };
        while let Some(&q @ (b'*' | b'+' | b'?')) = s.get(*i) { *i += 1; a = Ast::Rep(Box::new(a), q); }
        v.push(a);
    }
    if v.is_empty() { None } else { Some(Ast::Cat(v)) }
}
fn emit(a: &Ast, p: &mut Vec<Inst>) {
    match a {
        Ast::Lit(c) => p.push(Char(*c)),
        Ast::Any => p.push(Any),
        Ast::Cat(v) => v.iter().for_each(|x| emit(x, p)),
        Ast::Alt(x, y) => { // L: split L+1, M+1 ; x ; M: jmp end ; y
            let l = p.len(); p.push(Split(0, 0)); emit(x, p);
            let m = p.len(); p.push(Jmp(0)); emit(y, p);
            p[l] = Split(l + 1, m + 1); p[m] = Jmp(p.len());
        }
        Ast::Rep(x, b'+') => { let l = p.len(); emit(x, p); p.push(Split(l, p.len() + 1)); }
        Ast::Rep(x, op) => { // L: split L+1, end ; x ; (star only: jmp L)
            let l = p.len(); p.push(Split(0, 0)); emit(x, p);
            if *op == b'*' { p.push(Jmp(l)); }
            p[l] = Split(l + 1, p.len());
        }
    }
}
pub fn compile(re: &str) -> Option<Vec<Inst>> {
    let (s, mut i, mut p) = (re.as_bytes(), 0, vec![]);
    emit(&alt(s, &mut i)?, &mut p); p.push(Match);
    if i == s.len() { Some(p) } else { None } // stray ')' left over
}
/// State-set simulation, O(|p|*|s|).
pub fn pike(p: &[Inst], s: &[u8], full: bool) -> bool {
    let mut mark = vec![usize::MAX; p.len()]; // mark[pc] == step: already in this step's set
    let (mut cur, mut next) = (vec![], vec![]);
    fn add(p: &[Inst], mark: &mut [usize], set: &mut Vec<usize>, step: usize, pc: usize) {
        let mut stack = vec![pc]; // follow Jmp/Split; keep Char/Any/Match
        while let Some(pc) = stack.pop() {
            if mark[pc] == step { continue; }
            mark[pc] = step;
            match p[pc] { Jmp(t) => stack.push(t), Split(a, b) => stack.extend([b, a]), _ => set.push(pc) }
        }
    }
    for i in 0..=s.len() {
        if !full || i == 0 { add(p, &mut mark, &mut cur, i, 0); } // search: start anywhere
        if cur.iter().any(|&pc| matches!(p[pc], Match)) && (!full || i == s.len()) { return true; }
        if i == s.len() { return false; }
        next.clear();
        for &pc in &cur {
            if matches!(p[pc], Any) || matches!(p[pc], Char(c) if c == s[i]) { add(p, &mut mark, &mut next, i + 1, pc + 1); }
        }
        std::mem::swap(&mut cur, &mut next);
    }
    unreachable!()
}
/// Backtracking: exponential in the worst case.
pub fn backtrack(p: &[Inst], s: &[u8], full: bool) -> bool {
    fn go(p: &[Inst], s: &[u8], mut pc: usize, mut i: usize, seen: &mut [usize], full: bool) -> bool {
        loop {
            match p[pc] {
                Char(c) if i < s.len() && s[i] == c => { pc += 1; i += 1; }
                Any if i < s.len() => { pc += 1; i += 1; }
                Char(_) | Any => return false,
                Jmp(t) => pc = t,
                Match => return !full || i == s.len(),
                Split(a, b) => {
                    if seen[pc] == i { return false; } // empty loop
                    let old = std::mem::replace(&mut seen[pc], i);
                    let r = go(p, s, a, i, seen, full) || go(p, s, b, i, seen, full);
                    seen[pc] = old;
                    return r;
                }
            }
        }
    }
    (0..=if full { 0 } else { s.len() }).any(|st| go(p, s, 0, st, &mut vec![usize::MAX; p.len()], full))
}
#[test]
fn engines_agree() {
    fn rnd(r: &mut u64) -> u32 { *r ^= *r << 13; *r ^= *r >> 7; *r ^= *r << 17; (*r >> 33) as u32 }
    fn gen(r: &mut u64, d: u32) -> String {
        match if d == 0 { rnd(r) % 3 } else { rnd(r) % 8 } {
            0 => "a".into(), 1 => "b".into(), 2 => ".".into(),
            3 | 4 => gen(r, d - 1) + &gen(r, d - 1),
            5 => format!("({}|{})", gen(r, d - 1), gen(r, d - 1)),
            _ => format!("({}){}", gen(r, d - 1), ["*", "+", "?"][rnd(r) as usize % 3]),
        }
    }
    let mut r = 0x9E3779B97F4A7C15u64;
    for _ in 0..5000 {
        let re = gen(&mut r, 4);
        let p = compile(&re).unwrap();
        for _ in 0..8 {
            let s: Vec<u8> = (0..rnd(&mut r) % 7).map(|_| b"ab"[rnd(&mut r) as usize % 2]).collect();
            for full in [true, false] { assert_eq!(pike(&p, &s, full), backtrack(&p, &s, full), "{re} {s:?}"); }
        }
    }
    let p = compile("a(b|c)*d").unwrap();
    assert!(pike(&p, b"xabcbd", false) && !pike(&p, b"xabcbd", true));
    assert!(compile("a|").is_none() && compile("(a").is_none() && compile("*a").is_none());
}

Complexity and constants

  • Compile O(m); simulation O(m·n) time, O(m) space. Backtracking is exponential, not bounded by m·n.
  • Measured on this Mac (Apple M4 Pro, rustc 1.99.1 --release, single thread, load average about 4, one run each), full match of a?ⁿaⁿ against aⁿ with the code above:
nstate setbacktracking
1611 µs0.54 ms
2010 µs9.4 ms
2417 µs147 ms
2826 µs2.47 s
  • The fixed pattern (a|aa)*b against aⁿ: backtracking 0.27 ms at n=20, 578 ms at n=36; state set 92 µs for 10³ bytes, 87 ms for 10⁶ (linear, about 87 ns/byte). Cox's article reports Perl needing over sixty seconds at n=29.
  • A lazy-DFA step is a table load; each new state costs an NFA step, so it wins when states repeat.

Efficiency

  • Cost: this code, 9-instruction NFA, M4 Pro: about 87 ns per byte, unoptimised (allocates a stack per closure); bound: set bookkeeping. RE2's source comment puts building a DFA state on every byte at about 0.2 MB/s against about 2 MB/s for its NFA (old, order of).
  • Levers:
    • lazy DFA over NFA simulation — at most one state built per byte searched (regex-automata docs) — no captures or Unicode word boundaries in this pass
    • literal prefilter or pure-literal bypass — a literal or small literal set may need no automaton (regex-automata docs) — patterns with a required literal
    • captures only on the match span (regex-automata docs) — when groups are requested
    • cache size — a cache that is too small is reset often and slows search (regex-automata docs) — large Unicode classes
    • memory budget — RE2 limits memory and fails gracefully (RE2 README) — untrusted patterns
  • Naive → standard → state of the art:
    • backtracking · exponential worst case
    • state-set simulation · O(m·n), about 87 ns/byte here
    • prefilter + lazy DFA + NFA fallback · O(n) once cached; Hyperscan for large pattern sets

Implementation notes

  • Cache fallback. RE2 resets a full state cache and, if it computed a state for fewer than about every 10 bytes, fails the DFA so the NFA takes over (re2/dfa.cc; RE2::Set cannot fall back). regex-automata resets the cache and can be configured to give up after a minimum number of clears.
  • Empty loops. (a*)*: per-step marking stops them; a backtracker needs the seen guard.

Pitfalls

  • Backtracking engine on attacker-supplied input: Cloudflare's 2 July 2019 outage (27 minutes) came from one WAF rule whose pattern backtracked enormously and exhausted CPU (post-mortem).
  • Full DFA compile: exponential in time and memory in the worst case; compile lazily or cap states.
  • One lazy-DFA cache shared by threads: it is mutable scratch space, keep one per thread.
  • Unicode word boundaries and large Unicode classes defeat or bloat the DFA path.

Variants and alternatives

In production

ripgrep (Rust regex); RE2; Go regexp; Hyperscan/Vectorscan in packet inspection signature sets.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
regex / regex-automataRustrust-lang/regexcargo add regex————worst case O(m·n); PikeVM, lazy DFA, prefilters; no look-around or backreferences
RE2C++google/re2brew install re2————linear time, memory budget; pip install google-re2 for Python
Hyperscan / VectorscanC++intel/hyperscan · VectorCamp/vectorscanbrew install vectorscan————SIMD multi-pattern sets; no backreferences or arbitrary assertions; Vectorscan = fork with Arm support
PCRE2CPCRE2Project/pcre2brew install pcre2————depth-first backtracking by default (JIT, match limit); pcre2_dfa_match is the other algorithm
regexpGogolang/go (src/regexp)stdlib: import "regexp"————RE2 syntax, documented linear time

Sources

  • K. Thompson, Regular expression search algorithm, Communications of the ACM 11(6), 1968, pp. 419-422. paper
  • R. Cox, Regular Expression Matching Can Be Simple And Fast. article · Regular Expression Matching: the Virtual Machine Approach. article · Regular Expression Matching in the Wild. article · Regular Expression Matching with a Trigram Index. article
  • regex-automata docs (hybrid, meta). docs · code; RE2 README and re2/dfa.cc. code
  • X. Wang et al., Hyperscan: A Fast Multi-pattern Regex Matcher for Modern CPUs, NSDI 2019. paper
  • J. Graham-Cumming, Details of the Cloudflare outage on July 2, 2019. post
  • J. C. Davis, C. A. Coghlan, F. Servant, D. Lee, The impact of regular expression denial of service (ReDoS) in practice: an empirical study at the ecosystem scale, ESEC/FSE 2018, pp. 246-256. paper
  • PCRE2 pcre2matching docs; Go regexp docs

Builds on

Used by

bounce processing · plannedParse DSNs and feedback loops, classify hard/soft bounces, maintain suppression lists
Complex event processingSystems must notice that a particular sequence of events has happened (a rise, then a spike, with no reversal) across millions of streams and rules, and raise one alert, without re-querying stored history.Data maskingDevelopers, analysts and support staff need realistic data to work, but most must never see customers' card numbers, emails or birth dates, and copies of production data spread those values everywhere.
trigram index · plannedTrigram posting lists accelerating regex and substring search
yara rule matching · plannedYARA-style rules: string patterns plus boolean conditions over files and memory, compiled to Aho-Corasick plus checks

Systems (23):

Alternatives and related

glob matching · plannedGlob/gitignore pattern matching without backtracking blowup
finite state transducer · plannedMinimal acyclic FST mapping sorted keys to values; compact term dictionaries with prefix/fuzzy search
Unicode text tokenization for searchA search engine over user text in many languages must make different spellings of the same word (case, accents, ligatures, fullwidth, curly apostrophes, CJK) match, when building the index and when handling queries.Constrained decodingCode that consumes LLM output (tool-call arguments, JSON, SQL, classification labels) breaks when the model emits malformed text, especially with small models. This is about guaranteeing the output matches a required format at generation time.
deep packet inspection · plannedClassify flows by payload/SNI/protocol signatures for application-aware policy
levenshtein automata · plannedLevenshtein automata / edit-distance search over dictionaries for typo tolerance