DSA DSA In Depth
0%
lesson boyer-moorehorspoolstring-search

Sublinear Average-Case Matching: Boyer-Moore Bad-Character/Good-Suffix & Horspool

7 min read Module 4.1: Tries, Exact String Matching & Multi-Pattern Automata Updated Oct 23, 2026
📄 Cheat Sheet
Boyer-Moore String Search Algorithm
Ben Langmead Boyer-Moore String Search Algorithm Watch on YouTube ↗

Ben Langmead explains how scanning the pattern right-to-left while shifting the alignment window left-to-right yields O(N/M) average-case character comparisons.

0:00 / --:--

1. Right-to-Left Comparison & The Two Shift Rules

Suppose pattern $P[0..M-1]$ is aligned at text offset $s$, so $P[j]$ sits above $T[s + j]$. We compare from $j = M - 1$ down to $0$. When a mismatch occurs at pattern index $j$ ($P[j] \neq T[s + j]$), we have already verified that the suffix $P[j+1..M-1]$ matches $T[s+j+1..s+M-1]$. Boyer-Moore takes the maximum shift proposed by two independent rules:

  • Bad-Character Rule: Let $c = T[s + j]$ be the mismatched text byte, and let $\text{last}[c]$ be the rightmost index in $P[0..M-2]$ where byte $c$ appears ($-1$ if absent). To align $T[s + j]$ with that occurrence in $P$, we shift the window right by $\Delta_{\text{bad}} = \max(1,\; j - \text{last}[c])$. If $c$ does not appear in $P$ at all, $\Delta_{\text{bad}} = j - (-1) = j + 1$; when the mismatch happens immediately at $j = M - 1$, the window leaps by a full $M$ bytes!
  • Good-Suffix Rule: Let $t = P[j+1..M-1]$ be the matched suffix before the mismatch. We shift $P$ right to align $T[s+j+1..s+M-1]$ with the rightmost earlier occurrence of $t$ in $P$ that is not preceded by the same mismatched character $P[j]$ (the strong good-suffix rule), or, if no full copy of $t$ exists inside $P$, with the longest prefix of $P$ that matches a suffix of $t$.

Combined with the Galil rule (which remembers matched prefixes across shifts so characters are never re-compared), full Boyer-Moore runs in $O(N + M)$ worst-case time (at most $3N$ comparisons on non-periodic patterns) and $O(N / M)$ average time.

2. Boyer-Moore-Horspool (BMH): Simplifying the Inner Loop

Nigel Horspool observed in 1980 that on natural alphabets ($|\Sigma| = 256$), the Bad-Character shift dominates the Good-Suffix shift almost everywhere, while computing and branching on two tables slows down the CPU pipeline. Horspool's algorithm always computes the shift using the text byte aligned with the last position of the pattern, $c_{\text{end}} = T[s + M - 1]$, regardless of where the mismatch occurred inside the window:

$$\text{shift}[c] = \begin{cases} M & \text{if } c \notin P[0..M-2] \\ M - 1 - \max\{k < M - 1 : P[k] = c\} & \text{otherwise} \end{cases}$$

Note that $P[M - 1]$ itself is deliberately excluded when building $\text{shift}[c]$ so that $\text{shift}[c] \ge 1$ is strictly guaranteed, preventing zero-length infinite loops.

Algorithm Variant Preprocessing Time Lookup Space Average Text Comparisons Worst-Case Time
Full Boyer-Moore (+ Galil) $O(M + |\Sigma|)$ $O(M + |\Sigma|)$ $O(N / M)$ $O(N + M)$
Boyer-Moore-Horspool (BMH) $O(M + |\Sigma|)$ $O(|\Sigma|) = 256\text{ ints}$ $O(N / M)$ $O(N \cdot M)$
BMH + SIMD Guard-Byte Filter $O(M + |\Sigma|)$ $O(|\Sigma|)$ $O(N / 32)$ vector ops $O(N \cdot M)$ (or $O(N)$ w/ Two-Way fallback)
Boyer-Moore-Horspool Safe-Shift Invariant

For any alignment offset $s$, let $c = T[s + M - 1]$. Every occurrence of $P$ in $T$ at any offset $s' > s$ must align some character $P[k] = c$ with $T[s + M - 1]$ whenever $s' \le s + M - 1$. Because $\text{shift}[c] = M - 1 - k_{\max}$ picks the largest index $k_{\max} < M - 1$ with $P[k_{\max}] = c$ (or $k_{\max} = -1$ if none exists), no valid match can exist in the open interval $(s,\; s + \text{shift}[c])$, and $\text{shift}[c] \ge 1$ ensures strict forward progress.

SIMD Two-Byte Guard Filter (AVX2 _mm256_cmpeq_epi8)

Even Horspool's $O(N / M)$ scalar skip loop suffers from a data-dependent branch and unpredicted memory load s += shift[T[s + M - 1]] every iteration. Modern standard libraries (glibc memmem, Rust memchr::memmem) select the two rarest bytes in $P$ at offsets $k_1, k_2$ (based on an empirical frequency table of English/UTF-8 bytes) and load 32 bytes at $T[i + k_1..i + k_1 + 31]$ and $T[i + k_2..i + k_2 + 31]$ into AVX2 registers. A single _mm256_cmpeq_epi8 + bitwise AND + _mm256_movemask_epi8 checks 32 candidate alignments simultaneously without branches, calling memcmp only when both rare bytes match.

3. Visual Trace: BMH Shift Table & Window Leaps

Below we search for $P = \texttt{"NEEDLE"}$ ($M = 6$). Notice that $P[0..4] = \texttt{"NEEDL"}$ has rightmost indices $\text{last}[\texttt{'N'}] = 0$, $\text{last}[\texttt{'E'}] = 2$, $\text{last}[\texttt{'D'}] = 3$, $\text{last}[\texttt{'L'}] = 4$, giving shifts $M - 1 - \text{last}[c]$: $\text{shift}[\texttt{'N'}] = 5$, $\text{shift}[\texttt{'E'}] = 3$, $\text{shift}[\texttt{'D'}] = 2$, $\text{shift}[\texttt{'L'}] = 1$, and $\text{shift}[\text{other}] = 6$ (including $\texttt{'E'}$ at $P[5]$ being ignored so $\text{shift}[\texttt{'E'}] = 3$, not $0$).

Text T: F I N D _ A _ N E E D L E s = 0: N E E D L E T[5]='A' != 'E' → shift['A'] = 6 s = 6: N E E D L E T[11]='L' != 'E' → shift['L'] = 1 s = 7: N E E D L E Match at s = 7! (only 8 comparisons)
flowchart LR A["Align P at offset s"] --> B{"Compare P[M-1] == T[s+M-1]?"} B -- "No" --> C["s += shift[T[s+M-1]]"] B -- "Yes" --> D{"Compare P[0..M-2] == T[s..s+M-2]?"} D -- "Yes" --> E["Record match at s; s += shift[T[s+M-1]]"] D -- "No" --> C C --> A E --> A
Rabin-Karp Polynomial Rolling Hash Ring & Boyer-Moore-Horspool / Teddy SIMD Skip
O(1) Rolling Hash · O(N/M) Horspool Skip

Trace Rabin-Karp O(1) modular window slides H_{i+1} = ((H_i − T[i]·B^{m−1})·B + T[i+m]) mod P, Boyer-Moore-Horspool right-to-left bad-character skips, and ripgrep Teddy 16-byte SIMD nybble-mask filtering.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Rolling Hash Recurrence: H₀ = (3·10² + 1·10¹ + 4) mod 97 = 23 (Target H("159") = 159 mod 97 = 62)
🌳 Live 2D Hierarchical Tree Topology — Rabin-Karp Polynomial Rolling Hash Ring & Boyer-Moore-Horspool / Teddy SIMD Skip Visited/Active Pivot/Root Resolved
evict ne 3 [0] 1 [1] H=314 4 [2] 1 [3] 5 [4] 9 [5] 2 [6]
[0]3evict next
[1]1
[2]4H=314
[3]1
[4]5
[5]9
[6]2
Target Pattern P
"159" (H(P) = 62)
Active Window
T[0..2] = "314"
Window Hash H_i
314 mod 97 = 23
Update Cost
O(1) Modular Ops
[Step 1] Initial window T[0..2] = "314": polynomial hash H₀ = 314 mod 97 = 23 != H("159") = 62.

4. Production Implementation: BMH with Rare-Byte Guard

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

// Boyer-Moore-Horspool with First+Last Character Guard before memcmp
std::vector<size_t> bmh_search(std::string_view text, std::string_view pat) {
    std::vector<size_t> matches;
    const size_t n = text.size(), m = pat.size();
    if (m == 0 || n < m) return matches;

    // 1. Precompute 256-entry Bad-Character shift table for pat[0 .. m-2]
    std::array<size_t, 256> shift;
    shift.fill(m);
    for (size_t k = 0; k + 1 < m; ++k) {
        shift[static_cast<uint8_t>(pat[k])] = m - 1 - k;
    }

    const uint8_t last_byte = static_cast<uint8_t>(pat[m - 1]);
    const uint8_t first_byte = static_cast<uint8_t>(pat[0]);

    // 2. Scan alignment windows with sublinear skips
    size_t s = 0;
    while (s <= n - m) {
        const uint8_t c_end = static_cast<uint8_t>(text[s + m - 1]);
        if (c_end == last_byte && static_cast<uint8_t>(text[s]) == first_byte) {
            if (m <= 2 || std::memcmp(text.data() + s + 1, pat.data() + 1, m - 2) == 0) {
                matches.push_back(s);
            }
        }
        s += shift[c_end];
    }
    return matches;
}
Adversarial Periodic Inputs & The $O(N \cdot M)$ Horspool Trap

Because Horspool drops the Good-Suffix and Galil rules, searching for $P = \texttt{"BAAAAA"}$ inside $T = \texttt{"AAAAAAAAAAAA..."}$ compares $M$ characters at every offset and then shifts by $\text{shift}[\texttt{'A'}] = 1$, degrading to $\Theta(N \cdot M)$ time! If input texts or patterns are untrusted (e.g., network packet inspection or web-exposed search endpoints), either track how many characters have been compared and fall back to a linear-time algorithm (like KMP or Two-Way string matching) whenever the ratio of comparisons to shift progress exceeds a constant threshold, or use full Boyer-Moore with Galil's rule.

Check yourself Question 1 of 4
Concept Check Why Horspool Excludes $P[M - 1]$ from the Shift Table recall

When building the 256-entry Bad-Character shift table for Boyer-Moore-Horspool on pattern $P[0..M-1]$, we initialize `shift[c] = M` for all $c$ and then loop `for (size_t k = 0; k < M - 1; ++k) shift[P[k]] = M - 1 - k;`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

How should $k_1$ and $k_2$ be chosen from $P[0..M-1]$ to maximize throughput on natural-language or source-code corpora?

Key Takeaways

Boyer-Moore scans the pattern from right to left (j = M - 1 down to 0) so a mismatch at the end of the window can skip up to M text positions at once, yielding O(N / M) average-case comparisons.
Boyer-Moore-Horspool indexes the Bad-Character shift exclusively by T[s + M - 1] and deliberately excludes P[M - 1] when building the shift table so that shift[c] >= 1 is always guaranteed.
Modern substring searchers combine Horspool's skip logic with SIMD 2-byte guard filters (_mm256_cmpeq_epi8) to inspect 32 candidate alignments per cycle, with a linear-time fallback to prevent O(N * M) periodic degradation.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue