Technopedia

The technologist’s encyclopedia

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 TT of length nn and a pattern of length mm, return the positions where the pattern occurs (or answer membership/ranking queries over a corpus).

Classes of solutions:

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 O(n+m)O(n+m) worst/expected case with no preprocessing of the text.

Details

Why the naive scan is quadratic. The naive algorithm aligns the pattern at each of the n−m+1n-m+1 positions and compares up to mm characters: O(nm)O(nm). The key observation behind every linear algorithm is that a mismatch at position ii 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 π[j]\pi[j] is the length of the longest proper prefix of P[0..j]P[0..j] that is also a suffix of it. When a mismatch happens at pattern index jj, KMP slides the pattern so that the already-matched prefix of length π[j−1]\pi[j-1] lines up, and resumes comparing at index π[j−1]\pi[j-1]. This is exactly the “jump” that avoids re-scanning.

Worked example. Let P=ababacP = \texttt{ababac} (m=6m = 6) and T=ababababacT = \texttt{ababababac} (n=10n = 10).

Prefix function of PP:

index j012345
P[j]ababac
π[j]001230

Trace (jj = how many pattern characters currently match):

iT[i]j beforecomparisonactionj after
0a0a = aadvance1
1b1b = badvance2
2a2a = aadvance3
3b3b = badvance4
4a4a = aadvance5
5b5b ≠ cjump j = π[4] = 3, then b = b4
6a4a = aadvance5
7b5b ≠ cjump j = π[4] = 3, then b = b4
8a4a = aadvance5
9c5c = cmatch at position 4π[5] = 0

The single match is at position 4: T[4..9]=ababacT[4..9] = \texttt{ababac}. Notice the two mismatches at i=5i = 5 and i=7i = 7: instead of restarting at j=0j = 0, KMP jumps to j=3j = 3 because the prefix “aba” of the pattern is also the suffix of what matched so far.

KMP jump: P = "ababac", π = [0,0,1,2,3,0] a b a b a c 0 0 1 2 3 0 mismatch at j=5 (b ≠ c) jump to j = π[4] = 3 green = prefix "aba" kept aligned (no re-scan)

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 O(n/m)O(n/m) comparisons.

Rabin–Karp. Maintain a hash of the current window T[i..i+m−1]T[i..i+m-1] and compare it to the hash of PP. When the window slides by one, update the hash in O(1)O(1) (drop the leftmost character’s contribution, shift, add the new rightmost character). A hash match triggers a full O(m)O(m) verification, so the expected time is O(n+m)O(n+m) but the worst case (many collisions) is O(nm)O(nm).

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 P=aabaaabP = \texttt{aabaaab}.

Solution
index j0123456
P[j]aabaaab
π[j]0101223
  • 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 LL cost O(L)O(L), and prefix queries (all strings starting with pp) cost O(∣p∣+k)O(|p| + k) for kk results.

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.

root c d c d a a t r t r d d o o g g green = terminal (cat, card, dog)

Step 3 — Operations.

Step 4 — Space. With NN total characters across all stored strings, the trie has at most N+1N+1 nodes (one per distinct prefix, plus the root). If each node stores a full array of σ\sigma child pointers, the space is O(N⋅σ)O(N \cdot \sigma). 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 σ\sigma 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 nn suffixes of TT; 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 O(n)O(n) (array) or O(nlog⁡n)O(n \log n) (naive array construction) preprocessing.

Details

Step 1 — The suffix array. The suffix array SASA is a permutation of 0..n−10..n-1 such that the suffixes T[SA[i]..]T[SA[i]..] are in lexicographic order. A pattern PP occurs in TT iff it is a prefix of some suffix, i.e. iff the suffixes having prefix PP form a contiguous interval in SASA. Finding that interval is a binary search: O(mlog⁡n)O(m \log n).

Step 2 — Worked example. Let T=bananaT = \texttt{banana} (n=6n = 6).

ranksuffixoriginal index SA[i]LCP with previous
0a50
1ana31
2anana13
3banana00
4na40
5nana22

So SA=[5,3,1,0,4,2]SA = [5, 3, 1, 0, 4, 2] and the LCP array (longest common prefix of each suffix with its predecessor) is LCP=[0,1,3,0,0,2]LCP = [0, 1, 3, 0, 0, 2].

LCP array for T = "banana": [0,1,3,0,0,2] 0 1 3 0 0 2 peak = "ana" (2 matches)

Step 3 — Counting matches with LCP. Once the binary search locates the interval of suffixes prefixed by PP, the number of matches is the number of entries in that interval whose LCP value is at least mm (the pattern length). For P=anaP = \texttt{ana} (m=3m = 3), 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 PP in O(m)O(m), then counts the leaves in the reached subtree in O(k)O(k). Total O(m+k)O(m + k). 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:

  1. The Burrows–Wheeler transform (BWT) of TT — a permutation of the characters that groups similar contexts together (highly compressible).
  2. A C-array: for each character cc, the number of characters in the BWT that are lexicographically smaller than cc.
  3. Rank structures (succinct bitvectors or a wavelet tree) that answer rankc(i)\text{rank}_c(i) = the number of occurrences of cc in BWT[0..i]BWT[0..i].
  4. A sampled suffix array (one entry every BB 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 [lo,hi)[lo, hi) by one character of PP (read right-to-left) using the C-array and rank. Each step is O(log⁡σ)O(\log \sigma), so the whole query is O(mlog⁡σ)O(m \log \sigma), in O(n)O(n) 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 T=bananaT = \texttt{banana}, how many times does the pattern P=naP = \texttt{na} occur, and at which original indices?

Solution

P=naP = \texttt{na} (m=2m = 2). 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.

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.

Inverted index (corpus: d1="the cat sat", d2="the dog sat", d3="cat dog") cat dog sat the [d1, d3] [d2, d3] [d1, d2] [d1, d2] Query "cat dog": intersect [d1,d3] ∩ [d2,d3] = {d3}.

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 f(t,D)f(t, D) rewards documents that mention tt often, but a term like “the” appears everywhere and is uninformative. TF-IDF down-weights common terms:

tf-idf(t,D)=f(t,D)⋅log⁡ ⁣Nn(t)\mathrm{tf\text{-}idf}(t, D) = f(t, D) \cdot \log\!\frac{N}{n(t)}

where NN is the number of documents and n(t)n(t) is the number of documents containing tt. A term that appears in few documents (large N/n(t)N/n(t)) 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 k1k_1 and normalizes by document length with a parameter bb:

score(D,Q)=∑t∈QIDF(t)⋅f(t,D) (k1+1)f(t,D)+k1(1−b+b ∣D∣avgdl)\mathrm{score}(D, Q) = \sum_{t \in Q} \mathrm{IDF}(t) \cdot \frac{f(t, D)\,(k_1 + 1)}{f(t, D) + k_1\left(1 - b + b\,\dfrac{|D|}{\mathrm{avgdl}}\right)} IDF(t)=ln⁡ ⁣(N−n(t)+0.5n(t)+0.5+1)\mathrm{IDF}(t) = \ln\!\left(\frac{N - n(t) + 0.5}{n(t) + 0.5} + 1\right)

Typical values are k1∈[1.2,2.0]k_1 \in [1.2, 2.0] and b∈[0.75,1.0]b \in [0.75, 1.0]. The fraction approaches k1+1k_1 + 1 as f(t,D)→∞f(t, D) \to \infty (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 nn) and require a candidate to share enough n-grams with the query. The arithmetic: a word of length LL has L−1L - 1 bigrams, and a single edit (insert, delete, or substitute) changes at most 2 bigrams. So a word within edit distance dd of the query shares at least (L−1)−2d(L - 1) - 2d 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 7−2⋅2=37 - 2\cdot 2 = 3 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 (102)\binom{10}{2} — 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 N=1000N = 1000 documents. The term “quantum” appears in n(t)=10n(t) = 10 documents, and in a particular document DD it appears f(t,D)=4f(t, D) = 4 times. Compute the TF-IDF weight of “quantum” in DD (use natural log, round to 2 decimals).

Solution

tf-idf=f⋅log⁡(N/n(t))=4⋅ln⁡(1000/10)=4⋅ln⁡(100)=4⋅4.605=18.42\mathrm{tf\text{-}idf} = f \cdot \log(N/n(t)) = 4 \cdot \ln(1000/10) = 4 \cdot \ln(100) = 4 \cdot 4.605 = 18.42.

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-kk locally, and a coordinator merges the per-shard ranked lists into a global top-kk. 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 8×5=408 \times 5 = 40 candidates. Since each of the 8 lists is sorted, a multi-way merge (e.g. with a min-heap of size 8) costs O(40log⁡8)O(40 \log 8), 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.

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 ε\varepsilon-transitions (transitions that consume no input):

Step 2 — The NFA for “ab*”. The regex ab*\texttt{ab*} (an “a” followed by zero or more “b”s) compiles to:

0 1 2 3 4 a ε b ε ε NFA for "ab*": 0→1 on 'a', then loop on 'b' (2→3→2), exit to accept 4.

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 ε\varepsilon-closures and character edges. This is O(n)O(n) 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 (a+)+b\texttt{(a+)+b} against the input aaaa...a\texttt{aaaa...a} (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 ab*\texttt{ab*} match the string abbb\texttt{abbb}? 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, ab*\texttt{ab*} matches abbb\texttt{abbb}.

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:

  1. For each dictionary word, compute the full edit distance to the query and keep those ≤ 2.
  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 8−1=78 - 1 = 7 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 7−4=37 - 4 = 3 bigrams with the query. Design 2 uses the bigram index to discard the vast majority of the 100,000 words in O(1)O(1)-ish set lookups (a word that shares fewer than 3 bigrams is rejected without any edit-distance work), and only runs the expensive O(L2)O(L^2) 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.