Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
String Algorithms  ›  Capstone Project

Capstone: Quarry's Text Index

50 minChoose the matcher by what it remembers: a border, a fingerprint or a box of known matches
You're building a piece ofQuarry — the text index
This piece — text_index(): Answers exact and one-typo queries over a set of documents.
Scenario An on-call engineer searches Quarry for "~disc full" across last night's node logs. The one wrong letter still finds every "disk full", each with its node and position, in one pass over the text.
Your task
Build text_index(docs, queries). docs maps a document id to its text, and queries is a list of strings. A plain query finds exact occurrences. A query starting with "~" finds windows that differ from the rest of the query in at most one character. Match case-insensitively (compare .lower() on both sides) and count overlaps. Return a dict mapping each query, as given, to its [doc_id, start] pairs, sorted by document id and then by position. An empty query or a bare "~" maps to []. Example: text_index({"n1": "Disk full", "n2": "dusk"}, ["disk", "~disk"]) → {"disk": [["n1", 0]], "~disk": [["n1", 0], ["n2", 0]]}.

Capstone: Quarry's Text Index

In short: one text_index function routes each query to whichever linear-time tool fits it best — KMP for an exact phrase, a rolling hash's fingerprint set for many same-length phrases at once, and two Z-arrays (forwards and backwards) for a query that allows one typo. None of the tools is faster than the others; what differs is what each one remembers between characters: a border, a fingerprint, or a matched box.

One text_index box routing to three matchers: KMP for a plain query, two Z-arrays for a ~typo query, rolling hash for many same-length queries

Three lessons have given Quarry three linear-time ways to look at text. This one puts them behind a single function, text_index, that takes a set of documents and a batch of queries and returns every place each query occurs. A query may also ask for one typo of slack. By the end, the piece you build is the one a user actually types into.

Picking the tool

Each technique answers a slightly different question, and the index uses each one where it fits best:

question best tool why
where does this one phrase occur? KMP (or Z) exact, worst-case O(n + m), no false alarms
does this phrase occur in a live stream? KMP the only state is j, so the text is read once
which of many same-length phrases occur? rolling hash one pass, and a set lookup per window covers every phrase
which passages repeat across documents? rolling hash fingerprint every window, group by fingerprint
where does it occur with at most one typo? Z, forwards and backwards front match plus back match covers all but one character

None of them is "the fast one". They are all linear. What differs is what they remember: a fingerprint, a border, or a box of known matches.

The contract

text_index(docs, queries):

  • docs maps a document id to its text, for example {"n1": "Disk full on node7"}.
  • queries is a list of strings. A query starting with ~ means "the rest, allowing one substituted character".
  • Matching ignores case: compare text.lower() with query.lower().
  • The result maps each query, exactly as given, to a list of [doc_id, start] pairs, ordered by document id and then by position. Overlapping matches count. An empty query, or a bare ~, maps to [].

Positions refer to the lowercased text. For plain ASCII that is the same as the original. Case handling is less simple than it looks:

What does this print?
print("STRASSE".lower() == "Straße".lower(), "STRASSE".casefold() == "Straße".casefold())

Exact queries

Build the failure function once per query, not once per document, then run the same matcher over every document. With Q queries and documents totalling N characters, exact search costs O(Q · N + total query length), and each query makes at most 2N character comparisons however repetitive the text is.

Here is the matcher over one document, with a running count of character comparisons. Swap in a naive matcher, or a text full of near-misses like "disk fuldisk fuldisk full", and compare the counters:

your code, drawn · array

Your matcher over one document, one character per frame. The highlighted run is how much of the query is matched so far, and the comparison count never exceeds twice the document's length.

A common shortcut gets overlapping matches wrong:

broken — fix it

The contract counts overlapping matches, so "aa" occurs in "aaaa" at 0, 1 and 2. This prints [0, 2].

One-typo queries

The Z lesson's capstone idea becomes a query type. For each window, count how many characters match from the front (a Z-array of query + [None] + text) and how many match from the back (a Z-array of the reversed query against the reversed text). If the two together cover at least m − 1 characters, at most one character is wrong.

The only fiddly part is indexing into the reversed text. A window that starts at i ends at i + m − 1, and in the reversed text that character sits at n − 1 − (i + m − 1):

step through it
1text, m = "disk dusk", 4
2n = len(text)
3for i in [0, 5]:
4 end = i + m - 1
5 print(i, end, n - 1 - end)

Here is ~disk against the text disk dusk, with pre and suf for each start:

watch it move · array

The front match and the back match are read straight off two Z-arrays. When they cover all but one character, the window is a one-typo hit, and nothing inside it is compared again.

At start 0 the front match covers all four characters, so this is an exact hit. 9 cells. i at index 0. Comparing d, i, s and k.

pre = 4, suf = 4

1 / 5
What does this print?
def z(s):
    n = len(s)
    out = [0] * n
    if n:
        out[0] = n
    l = r = 0
    for i in range(1, n):
        if i < r:
            out[i] = min(r - i, out[i - l])
        while i + out[i] < n and s[out[i]] == s[i + out[i]]:
            out[i] += 1
        if i + out[i] > r:
            l, r = i, i + out[i]
    return out

q, t = "disk", "dusk"
front = z(list(q) + [None] + list(t))[len(q) + 1]
back = z(list(q[::-1]) + [None] + list(t[::-1]))[len(q) + 1]
print(front, back, front + back >= len(q) - 1)

Putting it together

The finished function is short, because each piece is a function you already have. Lowercase every document once, and for each query choose the matcher:

python

The one-typo branch plugs in the same way, with two Z-arrays per document. Every query is linear in the text it scans, and the whole batch is O(Q · N) plus the query lengths.

An interview question: redact many secrets in one pass

A message must have every occurrence of any banned token replaced by *. All the tokens have the same length k, and there may be thousands of them. Running KMP once per token costs O(tokens · n). One rolling hash, with a dict from fingerprint to the tokens that have it, checks every window against all of them at once:

python

The expected cost is O(n + total token length), plus O(k) for each real hit. That is the rolling hash's niche: many patterns of one length.

What it costs

operation cost
lowercase all documents O(N), once per batch
one exact query O(m) for π, then O(N) over all documents
one ~ query O(N + D · m) for D documents: two Z-arrays per document
shared passages of length k O(N) expected, rolling hash plus a dict
a batch of Q queries O(Q · N) plus the total query length

Scanning every document for every query is the honest limit of this section. A production engine goes further and indexes the documents once, for example by sorting all their suffixes, so that each query costs roughly the query's length times a logarithm rather than a full scan. That is a different structure, built from the same ideas: borders, fingerprints and prefix matches.


Idioms & real-world patterns

  • Preprocess the query once, reuse it for every document. That applies to π, the reversed query and the fingerprint alike.
  • Normalise once. Lowercase every document when it's loaded, not inside the query loop.
  • Sentinels over separators. list(q) + [None] + list(t) can't collide with any real character.
  • Sort ids, then walk positions in order. Deterministic output is what makes a search engine testable.
  • Where this lives: log search in operations tools, "find in files" in editors, and spam and secret scanners that check thousands of fixed tokens in one pass.

Quick practice

What does this print?
def z(s):
    n = len(s)
    out = [0] * n
    if n:
        out[0] = n
    l = r = 0
    for i in range(1, n):
        if i < r:
            out[i] = min(r - i, out[i - l])
        while i + out[i] < n and s[out[i]] == s[i + out[i]]:
            out[i] += 1
        if i + out[i] > r:
            l, r = i, i + out[i]
    return out

q, t = "full", "fall"
front = z(list(q) + [None] + list(t))[len(q) + 1]
back = z(list(q[::-1]) + [None] + list(t[::-1]))[len(q) + 1]
print(front, back, front + back >= len(q) - 1)
broken — fix it

"dusk" differs from "disk" by exactly one character, so this should be True. It prints False, rejecting the boundary case the contract is supposed to accept.


🎯 Your turn

Build text_index(docs, queries), Quarry's text index:

  • docs maps a document id to its text, and queries is a list of strings.
  • A plain query finds exact occurrences. A query starting with ~ finds windows that differ from the rest of the query in at most one character.
  • Matching is case-insensitive (use .lower() on both sides), and overlaps count.
  • Return a dict mapping each query, as given, to its [doc_id, start] pairs, sorted by document id and then by position. An empty query or a bare ~ maps to [].

For example, text_index({"n1": "Disk full", "n2": "dusk"}, ["disk", "~disk"]) → {"disk": [["n1", 0]], "~disk": [["n1", 0], ["n2", 0]]}.

Then press ▶ Run, tap the Live App try chips, and hit ✓ Check.

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCount a phrase

Write count_hits(text, pattern) returning how many times pattern occurs in text, ignoring case and counting overlaps. An empty pattern counts 0. Any linear-time matcher from this section will do. count_hits("Disk full, DISK full", "disk") → 2.

string-matchingstrings
DrillWhich documents mention it

Write docs_containing(docs, pattern) returning the sorted ids of the documents (a dict of id → text) that contain pattern at least once, ignoring case. Build the failure function once and stop scanning a document at its first hit. An empty pattern returns []. docs_containing({"a": "disk full", "b": "all fine", "c": "DISK"}, "disk") → ["a", "c"].

kmpstring-matching
BuildPassages shared across documents

Quarry flags copied text. Write shared_passages(docs, k) returning, sorted, every distinct length-k chunk (lowercased) that occurs in at least two different documents. Roll a fingerprint over each lowercased document, map fingerprint to the set of document ids that have it, and verify the actual chunks before reporting them. k is at least 1. shared_passages({"a": "disk full", "b": "DISK OK"}, 4) → ["disk", "isk "].

rolling-hashhashing
BossHighlight every term

Quarry's result page highlights every occurrence of any of several terms. Write highlight(text, patterns) returning the highlighted spans as a sorted list of [start, end) pairs, where overlapping or touching spans are merged into one. Matching is case-sensitive, overlaps count, and empty patterns are ignored. Run a linear-time matcher per pattern, then merge. highlight("disk full, disk ok", ["disk", "k f"]) → [[0, 6], [11, 15]].

string-matchingkmpsorting
CapstoneThe longest shared passage

Write longest_shared(a, b) returning the longest substring that occurs in both a and b (case-sensitive). If several have that length, return the one that starts earliest in a. Return "" if they share no character. Aim for O((|a| + |b|) log n) expected: if a length-L substring is shared, so is a shorter one, so binary search on L, and test each L by putting the fingerprints of b's windows in a set and rolling through a. longest_shared("disk full on node7", "node7 disk fault") → "disk f".

rolling-hashstring-matchingbinary-search
ExplainWhich matcher, and why

Quarry gets three new jobs: (a) alert the moment a live log contains OOM killed, (b) find every 64-character passage that appears in two or more of 10,000 documents, and (c) search for a part number allowing one wrong character. For each job, say which technique from this section you'd use and why it fits better than the other two.

text_index.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
text_index(docs, queries) → dictEach query mapped to every [document, position] where it occurs, exactly or with one typo.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.