Text Search §
Text search finds occurrences of a pattern (or a set of patterns) in a text, or builds an index over the text so that such queries are cheap. It is the engine behind grep, search engines, spell-checkers, code editors, and database full-text search.
Terse definition: given a text of length and a pattern of length , return the positions where the pattern occurs (or answer membership/ranking queries over a corpus).
Classes of solutions:
- Linear-time scanning (Knuth–Morris–Pratt, Boyer–Moore, Rabin–Karp): per query, no preprocessing of the text.
- Precomputed text structures (suffix trees, suffix arrays, inverted indexes): preprocess the text once, then answer each query in time depending on the pattern length and the number of matches.
Text search is the class of the Search and Data Structures table concerned with pattern/substring access; it contrasts with positional (index-based) access, where the location is known and only the value must be fetched.
Authoritative sources
Linear-time string matching §
Terse definition: scan the text once, comparing the pattern against a sliding window, and use the pattern’s internal structure (or a rolling hash) to skip positions that cannot match. All three algorithms below run in worst/expected case with no preprocessing of the text.
- Knuth–Morris–Pratt (KMP): worst case. Precompute the pattern’s prefix function and, on a mismatch, jump the pattern using instead of restarting.
- Boyer–Moore: worst case but on average. Compare right-to-left and use the bad-character and good-suffix heuristics to skip.
- Rabin–Karp: expected. Compare a rolling hash of the window to the pattern’s hash; verify on a hash collision.
Details
Why the naive scan is quadratic. The naive algorithm aligns the pattern at each of the positions and compares up to characters: . The key observation behind every linear algorithm is that a mismatch at position tells you something about the next alignment, so you should not re-examine characters you already know match.
KMP — the prefix function. The prefix function is the length of the longest proper prefix of that is also a suffix of it. When a mismatch happens at pattern index , KMP slides the pattern so that the already-matched prefix of length lines up, and resumes comparing at index . This is exactly the “jump” that avoids re-scanning.
Worked example. Let () and ().
Prefix function of :
| index j | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| P[j] | a | b | a | b | a | c |
| π[j] | 0 | 0 | 1 | 2 | 3 | 0 |
Trace ( = how many pattern characters currently match):
| i | T[i] | j before | comparison | action | j after |
|---|---|---|---|---|---|
| 0 | a | 0 | a = a | advance | 1 |
| 1 | b | 1 | b = b | advance | 2 |
| 2 | a | 2 | a = a | advance | 3 |
| 3 | b | 3 | b = b | advance | 4 |
| 4 | a | 4 | a = a | advance | 5 |
| 5 | b | 5 | b ≠ c | jump j = π[4] = 3, then b = b | 4 |
| 6 | a | 4 | a = a | advance | 5 |
| 7 | b | 5 | b ≠ c | jump j = π[4] = 3, then b = b | 4 |
| 8 | a | 4 | a = a | advance | 5 |
| 9 | c | 5 | c = c | match at position 4 | π[5] = 0 |
The single match is at position 4: . Notice the two mismatches at and : instead of restarting at , KMP jumps to because the prefix “aba” of the pattern is also the suffix of what matched so far.
Boyer–Moore. Compare from the right end of the window. The bad-character rule shifts the pattern so that the mismatching text character aligns with its rightmost earlier occurrence in the pattern (or past the pattern if it is absent). The good-suffix rule shifts so that a matched suffix re-aligns with an equal prefix. Both shifts are at least 1, and on a large alphabet the average shift is large, giving comparisons.
Rabin–Karp. Maintain a hash of the current window and compare it to the hash of . When the window slides by one, update the hash in (drop the leftmost character’s contribution, shift, add the new rightmost character). A hash match triggers a full verification, so the expected time is but the worst case (many collisions) is .
Authoritative source: CLRS Ch. 32 (String Matching), pp. 831–866. Knuth, Morris & Pratt, “Fast pattern matching in algorithms” (1977).
Example problem
Compute the prefix function for .
Solution
| index j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| P[j] | a | a | b | a | a | a | b |
| π[j] | 0 | 1 | 0 | 1 | 2 | 2 | 3 |
- j=1: “aa” → longest border “a” → 1.
- j=2: “aab” → no border → 0.
- j=3: “aaba” → border “a” → 1.
- j=4: “aabaa” → border “aa” → 2.
- j=5: “aabaaa” → border “aa” (not “aab”) → 2.
- j=6: “aabaaab” → border “aab” → 3.
Tries and prefix matching §
Terse definition: a trie (prefix tree) stores a set of strings so that shared prefixes are stored once; each edge is labeled by a character and each root-to-node path spells a prefix. Lookup and insertion of a string of length cost , and prefix queries (all strings starting with ) cost for results.
- Space: where is the total number of characters across all stored strings and is the alphabet size (each of the nodes stores child pointers).
- Use cases: autocomplete, spell-checking, IP routing (radix trees), and the building block of the Bloom filter family of probabilistic structures.
Details
Step 1 — The structure. A trie is a rooted tree. The root represents the empty string. Each edge from a node is labeled by one character; the path from the root to a node spells the string stored at that node. A node is marked terminal when the spelled string is a complete member of the set. Because shared prefixes share nodes, the trie is compact for sets with many common prefixes.
Step 2 — Worked example. Insert the words cat, car, card, dog. The prefixes “ca” and “car” are shared, so they occupy a single path.
Step 3 — Operations.
- Insert “car”: walk c → a → r, mark the “r” node terminal. .
- Lookup “card”: walk c → a → r → d; the “d” node is terminal → found. .
- Prefix query “ca”: walk to the “a” node, then DFS its subtree to collect all terminal nodes (cat, car, card). .
Step 4 — Space. With total characters across all stored strings, the trie has at most nodes (one per distinct prefix, plus the root). If each node stores a full array of child pointers, the space is . For a large alphabet (e.g. Unicode), this is wasteful, so real implementations store a sparse map (or a sorted child list) per node, dropping the factor to the actual branching.
Step 5 — Variants. A radix tree (or Patricia trie) compresses unary chains into single edges labeled by a string, shrinking the tree for sparse data; it is the standard structure for longest-prefix matching such as IP routing. A double-array trie maps the tree into two arrays for cache-friendly, disk-resident lookup.
Authoritative source: CLRS Ch. 32.3 (Tries). Crochemore & Rytter, “Jewels of the Stringology” (2002), Ch. 2.
Example problem
You store the words the, then, them, theme in a trie. How many nodes (including the root) does the trie have, and how many are terminal?
Solution
Distinct prefixes: “” (root), t, th, the, then, them, theme. That is 7 nodes total. Terminal nodes are the, then, them, theme → 4 terminal nodes. The shared prefix “the” is stored once and reused by all four words.
Suffix structures §
Terse definition: a suffix array sorts all suffixes of ; a suffix tree is the compact trie of those suffixes; the FM-index compresses the suffix array into the Burrows–Wheeler transform plus rank structures. All three support substring queries in time proportional to the pattern length and the number of matches, after (array) or (naive array construction) preprocessing.
- Suffix array + LCP array: space; a query is a binary search over the array, , then a linear scan of the LCP array to count matches.
- Suffix tree: space, query (walk the tree, then count leaves).
- FM-index: space (often bits/char); query via backward search.
Details
Step 1 — The suffix array. The suffix array is a permutation of such that the suffixes are in lexicographic order. A pattern occurs in iff it is a prefix of some suffix, i.e. iff the suffixes having prefix form a contiguous interval in . Finding that interval is a binary search: .
Step 2 — Worked example. Let ().
| rank | suffix | original index SA[i] | LCP with previous |
|---|---|---|---|
| 0 | a | 5 | 0 |
| 1 | ana | 3 | 1 |
| 2 | anana | 1 | 3 |
| 3 | banana | 0 | 0 |
| 4 | na | 4 | 0 |
| 5 | nana | 2 | 2 |
So and the LCP array (longest common prefix of each suffix with its predecessor) is .
Step 3 — Counting matches with LCP. Once the binary search locates the interval of suffixes prefixed by , the number of matches is the number of entries in that interval whose LCP value is at least (the pattern length). For (), the interval is ranks 1–2 (suffixes “ana”, “anana”); both have LCP ≥ 3 within the interval, so there are 2 matches, at original indices 3 and 1.
Step 4 — The suffix tree. The suffix tree is the compact trie of all suffixes: internal nodes have at least two children and edges are labeled by strings (unary chains are compressed). A query walks the tree following in , then counts the leaves in the reached subtree in . Total . It is the most query-efficient structure but harder to build and maintain.
Step 5 — The FM-index. The FM-index does not store the suffix array explicitly. It stores:
- The Burrows–Wheeler transform (BWT) of — a permutation of the characters that groups similar contexts together (highly compressible).
- A C-array: for each character , the number of characters in the BWT that are lexicographically smaller than .
- Rank structures (succinct bitvectors or a wavelet tree) that answer = the number of occurrences of in .
- A sampled suffix array (one entry every rows) so that match positions can be reported by walking back from a sample.
A query runs backward search: starting from the whole BWT, it repeatedly narrows the row interval by one character of (read right-to-left) using the C-array and rank. Each step is , so the whole query is , in space (often under 2 bits per character). This is the index behind bwa/bowtie genome aligners.
Authoritative sources: CLRS Ch. 32.4 (Suffix Trees). Manber & Myers, “Suffix arrays: a new method for on-line string searches” (1993). Ferragina & Manzini, “Indexing string databases” (2000).
Example problem
For , how many times does the pattern occur, and at which original indices?
Solution
(). In the sorted suffix list, the suffixes prefixed by “na” are “na” (index 4) and “nana” (index 2) — ranks 4 and 5. Both have LCP ≥ 2 within the interval, so there are 2 matches, at original indices 4 and 2.
Inverted index and information retrieval §
Terse definition: an inverted index maps each term to the list of documents (and positions) containing it; a ranking function (TF-IDF, BM25) orders the documents that match a query by relevance. This is the core of every search engine.
- Inverted index: build in over the corpus; a query for terms intersects posting lists.
- TF-IDF: score a document by how often a term appears in it (TF) weighted by how rare the term is across the corpus (IDF).
- BM25: a saturated, length-normalized refinement of TF-IDF; the de-facto standard ranking function.
Details
Step 1 — The inverted index. Instead of storing “document → its terms” (a forward index), the inverted index stores “term → documents containing it”. Each entry (a posting list) is a sorted list of document IDs, optionally with term frequencies and positions.
A query for multiple terms is answered by intersecting the posting lists (AND) or merging them (OR). Because the lists are sorted, an intersection is a linear merge in the total list lengths.
Step 2 — TF-IDF. The raw term frequency rewards documents that mention often, but a term like “the” appears everywhere and is uninformative. TF-IDF down-weights common terms:
where is the number of documents and is the number of documents containing . A term that appears in few documents (large ) gets a high weight.
Step 3 — BM25. TF-IDF has two problems: the term frequency grows without bound, and long documents are unfairly favored. BM25 fixes both. It saturates the frequency with a parameter and normalizes by document length with a parameter :
Typical values are and . The fraction approaches as (saturation), and the length term penalizes documents longer than average.
Authoritative source: Robertson, S. E., & Spärck Jones, K. (1994). “Relevance weighting of search terms.” Journal of the American Society for Information Science, 45(7), 520–536. DOI: 10.5555/106765.106783.
Step 4 — n-grams and fuzzy matching. To tolerate typos, index the words by their n-grams (substrings of length ) and require a candidate to share enough n-grams with the query. The arithmetic: a word of length has bigrams, and a single edit (insert, delete, or substitute) changes at most 2 bigrams. So a word within edit distance of the query shares at least bigrams with it.
Example: the query elephant (length 8) has 7 bigrams: el, le, ep, ph, ha, an, nt. A candidate within edit distance 2 must share at least bigrams. The typo elephat (one deletion) shares el, le, ep, ph, ha = 5 bigrams ≥ 3, so it passes the filter. An unrelated word like computer shares 0 bigrams and is rejected before any expensive edit-distance computation. (Note: a string of length 10 has 9 bigrams, not — the latter counts 2-element subsets, not substrings.)
Authoritative source: Manku, Baeza-Yates & Jain, “Efficient computation of edit distance” (2006).
Example problem
A corpus has documents. The term “quantum” appears in documents, and in a particular document it appears times. Compute the TF-IDF weight of “quantum” in (use natural log, round to 2 decimals).
Solution
.
Inverted index and scalability §
Terse definition: a production inverted index is sharded across machines, with posting lists compressed and merged in parallel; at the top of the stack, keyword retrieval is increasingly complemented by vector (semantic) search, which ranks by embedding similarity rather than exact term overlap.
Details
Step 1 — Scaling the index. A single machine cannot hold the inverted index of a web-scale corpus. The standard design shards the corpus: documents are partitioned (by document-ID hash) across nodes, and each node builds and serves the inverted index for its shard. A query is fanned out to all shards, which return their top- locally, and a coordinator merges the per-shard ranked lists into a global top-. Posting lists are compressed with delta encoding + variable-byte or bit-packing, and term dictionaries are stored in a B-tree or a hash table for fast lookup.
Step 2 — Keyword vs. semantic search. Keyword retrieval (the inverted index above) matches on exact terms: a query for “car” will not return documents that only say “automobile”. Vector / embedding-based (semantic) search instead maps each document and the query to a high-dimensional dense vector (an embedding) and ranks by a similarity measure such as cosine similarity or inner product. This captures meaning and paraphrase but cannot do exact-term or positional matching, and the nearest-neighbor search over millions of vectors requires its own index (e.g. an approximate nearest-neighbor structure). Modern engines run hybrid search: the inverted index for precision and the vector index for recall, then fuse the two ranked lists.
Authoritative source: Zobel & Moffat, “Inverted files for text search engines” (2006).
Example problem
A search cluster has 8 shards. A query returns a local top-5 from each shard. How many candidate documents does the coordinator merge, and what is the asymptotic cost of the merge if each local list is already sorted by score?
Solution
The coordinator merges candidates. Since each of the 8 lists is sorted, a multi-way merge (e.g. with a min-heap of size 8) costs , i.e. linear in the number of candidates times the log of the number of shards.
Regular expression matching §
Terse definition: a regular expression describes a set of strings with operators for concatenation, alternation (), and repetition (, , ). Matching a regex against a text is done either by backtracking (try and undo) or by automaton simulation (build an NFA/DFA and run it), the latter guaranteeing linear time.
- Backtracking engines (PCRE, most language
remodules): simple to write, support lookaheads, but can be on adversarial input (catastrophic backtracking). - Automaton engines (Thompson NFA simulation, Pike VM, DFA): per input character, no worst-case blowup; the basis of
grepandripgrep.
Details
Step 1 — From regex to automaton. The theoretical foundation is that every regular expression denotes a regular language accepted by a finite automaton (see Computability and Complexity Classes for the automata and decidability background). Thompson’s construction builds an NFA for a regex in linear time, using only -transitions (transitions that consume no input):
- A literal : a two-state NFA with a -labeled edge.
- Concatenation : wire the accept state of to the start of with an -edge.
- Alternation : a new start -branches to and ; both accept states -merge into a new accept.
- Repetition : a new start -branches into and directly to a new accept; ’s accept -loops back to its start and also -exits.
Step 2 — The NFA for “ab*”. The regex (an “a” followed by zero or more “b”s) compiles to:
Step 3 — Running the NFA: Thompson simulation. An NFA can be in many states at once. Thompson’s algorithm keeps the set of active states and, for each input character, computes the next active set by following -closures and character edges. This is in the input length (each state is processed a bounded number of times). The Pike VM (used in grep) is a compiled, instruction-based version of the same idea.
Step 4 — Backtracking vs. automata. A backtracking engine walks the regex and the text together, and on a failed branch it undoes the last choice and tries the next. It is easy to implement and supports powerful features (lookaheads, backreferences), but a regex like against the input (no “b”) explores an exponential number of ways to split the “a”s — catastrophic backtracking. Automaton engines have no such blowup because they track a set of states, not a single path. In practice, grep/ripgrep use automata for speed and safety, while language regex libraries often use backtracking for expressiveness.
Authoritative sources: Thompson, “Regular expression search algorithm” (1968). Pike, “The Pike programming language” (1988).
Example problem
Does the regex match the string ? Trace the active states of the NFA above.
Solution
- Start: active = {0}.
- Read “a”: 0 → 1 on “a”; ε-closure of 1 = {1, 2}. Active = {1, 2}.
- Read “b”: from 2 → 3 on “b”; ε-closure of 3 = {2, 3, 4}. Active = {2, 3, 4}.
- Read “b”: again → active = {2, 3, 4}.
- Read “b”: again → active = {2, 3, 4}.
- End of input: the accept state 4 is active, so yes, matches .
Quizz §
Quizz
You are building a spell-checker over a dictionary of 100,000 English words and must suggest corrections for a mistyped word of length 8 within edit distance 2. You have two candidate designs:
- For each dictionary word, compute the full edit distance to the query and keep those ≤ 2.
- Index every dictionary word by its bigrams; for the query, keep only dictionary words that share at least 3 bigrams, then compute the full edit distance only on those survivors.
Which design is faster in practice, and why? (Hint: how many bigrams does the query have, and how many bigrams can 2 edits destroy?)
Solution
Design 2 is faster. The query word of length 8 has bigrams. Each edit changes at most 2 bigrams, so 2 edits can destroy at most 4 bigrams; therefore any word within edit distance 2 must share at least bigrams with the query. Design 2 uses the bigram index to discard the vast majority of the 100,000 words in -ish set lookups (a word that shares fewer than 3 bigrams is rejected without any edit-distance work), and only runs the expensive dynamic-programming edit distance on the small set of survivors. Design 1 runs the full edit distance on all 100,000 words, which is orders of magnitude more work. The bigram filter is a classic filter-then-verify pattern: a cheap necessary condition (shared bigrams) prunes candidates before the expensive exact test.