The index that replaces the text

A search that runs backwards

Twenty-three occurrences of a six-character pattern in sixteen thousand characters, found in twelve rank queries and zero character comparisons. Not few comparisons — none. The algorithm never asks whether two symbols are equal, and it knows how many matches there are before it has located one.

A text of 16,384 characters is searched for the six-character string s than. It occurs 23 times. Finding that took twelve rank queries and zero character comparisons.

The zero is not an artefact of instrumentation. Character comparisons are counted here in the same field a naive scan and a binary search over a suffix array are charged in — the same unit the strings field opened with — and a suffix-array search over the identical text and pattern spends ninety-one of them. The backward search spends none, because it never asks whether two symbols are equal. It asks a different question entirely.

Searching 4,096 characters for "s than": 7 occurrences, 0 comparisonsEach row is the suffix-array interval after one more character of the pattern has been consumed, right to left. The bar spans the rows of the sorted suffixes that begin with the part of the pattern read so far, and it is computed by two rank queries on the transform: no character of the text and no character of the pattern is ever compared with anything. When the pattern runs out the width of the interval is the number of occurrences, and it is known before any occurrence has been located.all rows4,097...n213 rows2 ranks...an90 rows4 ranks...han24 rows6 ranks...than24 rows8 ranks... than24 rows10 ranks...s than7 rows12 ranksEnglish-like, 4,096 characters · one row per character consumed0 character comparisons
Fig. 1 The search, one row per character of the pattern consumed. The bar is the range of sorted suffixes that begin with the part of the pattern read so far, and each row costs two rank queries. When the pattern runs out the bar’s width is the answer.

One property of the sorted order, and only one

A suffix array sorts every suffix of the text. That gives the structure a property the binary search exploits and a different property that this search exploits instead:

The suffixes beginning with any given string are contiguous. All of them, always, in one block. If two suffixes both begin with abr then everything sorting between them also begins with abr, because sorting is lexicographic and there is nothing between abr… and abr… that is not abr….

So the answer to where does this pattern occur is an interval — a first row and a last row — rather than a set. Its width is the number of occurrences. And a search is the business of narrowing that interval one character at a time.

The rows beginning with "abra": 2 of 12Every rotation of the text, sorted. The rows beginning with the pattern are contiguous, which is the only property of the sorted order the search uses, so a search is the narrowing of an interval and a count is its width. The shaded band is rows 2 to 3, found in 8 rank queries and 0 character comparisons. The last column is the transform; the first is the same text sorted.0$abracadabra1a$abracadabr2abra$abracad3abracadabra$4acadabra$abr5adabra$abrac6bra$abracada7bracadabra$a8cadabra$abra9dabra$abraca10ra$abracadab11racadabra$aball 12 rotations of "abracadabra$", sorted0 character comparisons
Fig. 2 Every rotation of a small text, sorted. The rows beginning with the pattern form one block, which is the only property of the sorted order this search uses. The final column is the transform; the first is the same characters in sorted order.

The binary search narrows the interval from the outside, by comparing the pattern against candidate suffixes. That needs the text. The backward search narrows it from the inside, by extending the pattern one character at a time to the left, and that needs something else.

The two arrays it needs

Write LL for the last column of the sorted rotations — the transform — and FF for the first, which is just the text’s characters in sorted order.

Two things are kept:

  • C[c]C[c], how many characters of the text sort strictly before cc. That is one number per symbol of the alphabet: for a twenty-one-symbol text, twenty-one integers.
  • the ability to answer rankc(i)\mathrm{rank}_c(i): how many times symbol cc occurs in the first ii positions of LL.

Given those, if [sp,ep)[sp, ep) is the interval of rows whose suffixes begin with some string PP, then the interval for cPcP — the same string with cc stuck on the front — is

sp=C[c]+rankc(sp)ep=C[c]+rankc(ep)\begin{aligned} sp' &= C[c] + \mathrm{rank}_c(sp) \\ ep' &= C[c] + \mathrm{rank}_c(ep) \end{aligned}

Two rank queries. That is the whole step, and it is why the pattern is consumed from its last character to its first: each step prepends.

character read interval rows rank queries so far
n 9,888 – 10,769 881 2
an 3,385 – 3,705 320 4
han 7,342 – 7,452 110 6
than 14,429 – 14,539 110 8
* than* 2,349 – 2,459 110 10
s than 12,984 – 13,007 23 12

Two rows of that table are worth reading twice. Between han and than the width does not move: every han in this text is a than. And the interval jumps around the array wildly between steps — 9,888 to 3,385 to 7,342 — because each step is a lookup into a different part of the sorted order, not a refinement of a region.

Why there is no comparison

The absence of character comparisons follows from the two formulas above and is worth stating as a fact about what the algorithm knows.

C[c]C[c] is a lookup by symbol. rankc(i)\mathrm{rank}_c(i) is a count of occurrences of a symbol before a position. Neither is an equality test between two unknown symbols; both are addressed by the pattern’s character. The algorithm is told which symbol it wants and asks how many there are. At no point does it hold two characters and compare them, and at no point does it read any character of the text.

That last clause is enforced rather than asserted. The text is taken away after the structure is built, and a query that reads it is stopped. Measured: zero text reads for the backward search and ninety-one for the binary search over the same pattern.

One query, against the length of the text it searchesA pattern of 8 characters looked up in texts from 1,024 to 16,384 characters. The backward search is flat: the same 16 rank queries whatever the text, because it never asks where in the text anything is. The binary search over a suffix array is not, and the gap between them grows. The unit is characters compared; each structure's own primitive is counted in its own field, and no two of them are the same act. Both axes are logarithmic.10,000110100characters of textcharacters comparedsuffix array + textcounter array per symbolFM-index, plainFM-index, compressedevery query run with the text withheldEnglish-like
Fig. 3 Characters compared during one query, against the length of the text. The compressed index’s series sits on the axis floor because the true value is zero at every point; the suffix array’s climbs, because a binary search over more suffixes compares more of them.

The count arrives before any occurrence does

This is the part that has no analogue in any earlier structure here, and it is easy to read past.

When the pattern is exhausted, the width of the interval is the number of occurrences. Twenty-three, known exactly, with no occurrence located. The structure has not computed a single position in the text. It has an interval of rows, and rows are not positions.

Every other matcher this collection has measured produces occurrences and counts them by having produced them. A scan finds them one by one; a suffix-array search finds a range of the array whose entries are the positions and reads them off. Here the count and the positions are two separate questions with two separate prices, and the count is the cheap one.

That separation is the reason a search engine can report 40,000 results in the time it takes to render ten of them. It is not a caching trick; it is a property of the structure.

Turning rows into positions is what the sampling dial is for, and it is where the other half of the cost lives.

Searching 4,096 characters for "the": 84 occurrences, 0 comparisonsEach row is the suffix-array interval after one more character of the pattern has been consumed, right to left. The bar spans the rows of the sorted suffixes that begin with the part of the pattern read so far, and it is computed by two rank queries on the transform: no character of the text and no character of the pattern is ever compared with anything. When the pattern runs out the width of the interval is the number of occurrences, and it is known before any occurrence has been located.all rows4,097...e386 rows2 ranks...he111 rows4 ranks...the84 rows6 ranksEnglish-like, 4,096 characters · one row per character consumed0 character comparisons
Fig. 4 A more common pattern in the same source: 84 occurrences, six rank queries, zero comparisons. The number of occurrences changes the answer and does not change the work, which is the property the plot below measures directly.

The cost does not depend on the text

Twelve rank queries for six characters. Sixteen for eight. Two per character consumed, and nothing else in the formula.

Not the length of the text. Not the number of occurrences. Not the alphabet, except through the cost of one rank query, which is the subject of the next essay.

The flatness is asserted rather than admired: the count is required to be identical at every text length in the sweep, and to equal exactly twice the pattern’s length. A structure whose backward search had picked up a dependence on n would be refused.

When the pattern is not there

An interval that has narrowed to zero rows is the answer absent, and it can happen at any step.

Two things follow, and both are the opposite of what an exact matcher does. The search stops early — the moment spepsp \ge ep there is nothing left to narrow and the remaining characters of the pattern are never read — so a pattern absent because of its last two characters costs four rank queries rather than two per character. A scan, by contrast, does the most work on a text where the pattern nearly occurs everywhere.

And a symbol the text does not contain at all costs nothing: it is not in the CC table, so the interval is empty before any rank query is made. That is a small thing which turns out to matter for the instrumentation, because a trace that reported “two ranks per character” would be contradicted by a step that made none. The plates count the steps that performed a rank rather than the characters of the pattern, and where the two differ the caption says which it is showing.

There is no partial credit and no near miss. The interval is either non-empty or it is not, and the structure has no notion of nearly. Everything the approximate search does is unavailable here without building something else on top — which is why approximate matching over compressed indexes is a subject rather than an option.

What it costs to run backwards

The pattern is consumed from its last character to its first. That is forced by the arithmetic — each step prepends a character to the front of the matched string — and it has a consequence worth naming.

An exact matcher that scans a text left to right can report an occurrence the moment it completes one, and can be handed the text a piece at a time. Backward search cannot be handed the pattern a piece at a time from the front; it needs the last character first. For a fixed pattern searched in a fixed text that is a non-issue, and for anything incremental — a user typing a query, a pattern being extended as it is refined — it means the interval is rebuilt rather than extended.

The reverse is true too and is more useful: extending a pattern on the left is free. Having the interval for than, the interval for s than is two more rank queries, and nothing about the first computation is discarded. A structure that answers queries about progressively longer left-extensions gets each one for a constant price.

The CC table, meanwhile, is not worth thinking about. One integer per symbol, twenty-one of them for the text above, at fifteen bits each: 315 bits, against the tens of thousands everything else costs. It appears on the size plates because leaving a part out is how a size claim becomes wrong, not because it matters.

The identity underneath

Everything above rests on one fact about the transform, and it deserves its own paragraph because it is what makes the structure possible rather than merely convenient.

The kk-th occurrence of a symbol cc in the last column and the kk-th occurrence of cc in the first column are the same character of the text.

The reason is that both columns list rotations sorted by what follows the character in question. In FF, the rows starting with cc are sorted by the rest of the rotation — that is, by what comes after that cc. In LL, the rows ending in cc are sorted by the row they are in, which is sorted by what comes after that cc as well. Two lists of the same characters, ordered by the same key, so they are in the same order.

That gives the LF mapping: from a row, one rank query and one addition produce the row whose suffix begins one character earlier.

The LF map on "abracadabra": one rank, one addition, one character earlierThe first column is the sorted symbols of the text and the last column is the transform. The k-th occurrence of a symbol in the last column and the k-th occurrence of the same symbol in the first are the same character of the text, so the arrow from a row of the last column to its position in the first steps one character backwards. That is the whole mechanism: the arrows are a permutation, they are computed by a rank query, and following them from any row spells the text out in reverse. $ marks the sentinel.F, the first columnL, the transform0a1ar2ad3a4ar5ac6ba7ba8ca9da10rb11rb12 rows · the sentinel sorts firsta permutation, computed by rank
Fig. 5 The map, drawn on a small text. Every arrow goes from a position in the last column to the same character’s position in the first, and the arrows form a permutation of the rows. Follow them from any starting row and the text spells itself out backwards.
The LF map on "mississippi": one rank, one addition, one character earlierThe first column is the sorted symbols of the text and the last column is the transform. The k-th occurrence of a symbol in the last column and the k-th occurrence of the same symbol in the first are the same character of the text, so the arrow from a row of the last column to its position in the first steps one character backwards. That is the whole mechanism: the arrows are a permutation, they are computed by a rank query, and following them from any row spells the text out in reverse. $ marks the sentinel.F, the first columnL, the transform0i1ip2is3is4im5m6pp7pi8ss9ss10si11si12 rows · the sentinel sorts firsta permutation, computed by rank
Fig. 6 The same map on the standard worked example, where the runs in the transform are long enough to see. Each block of identical characters in the last column maps to a contiguous block of the first, in order, which is the identity restated as a picture.

Backward search is the LF mapping applied to a whole interval at once rather than to a single row. That is the entire idea, and everything else — the counting, the locating, the extraction — is a use of the same two arrays.

The rows beginning with "ssi": 2 of 12Every rotation of the text, sorted. The rows beginning with the pattern are contiguous, which is the only property of the sorted order the search uses, so a search is the narrowing of an interval and a count is its width. The shaded band is rows 10 to 11, found in 6 rank queries and 0 character comparisons. The last column is the transform; the first is the same text sorted.0$mississippi1i$mississipp2ippi$mississ3issippi$miss4ississippi$m5mississippi$6pi$mississip7ppi$mississi8sippi$missis9sissippi$mis10ssippi$missi11ssissippi$miall 12 rotations of "mississippi$", sorted0 character comparisons
Fig. 7 The interval on that example. Two rows of twelve, found in six rank queries, and the shaded band contains exactly the rows the two occurrences correspond to.

What is being paid instead

Nothing here is free, and it is worth being clear about where the bill went.

A rank query is not one operation. Answering how many cc before position ii requires a structure, and the obvious structure — a counter array per symbol — is sixty-six times the size of the text. The whole art of this field is answering rank queries in a structure that is smaller than what it indexes rather than vastly larger.

Twelve rank queries for a symbol became forty-four rank queries on bit vectors in the run at the top of this page, because the structure that answers a symbol rank does so by walking a tree of bit vectors, one bit-vector rank per level. Which level count, and therefore which constant, is decided by the shape of that tree — and the shape turns out to be the text’s own entropy.

A balanced wavelet tree over "abracadabra": 217 bitsEvery node is a bit vector: one bit per symbol still in play, saying which half of the remaining alphabet that symbol falls in. A rank for a symbol walks its code from the root, one bit-vector rank a level, and no symbol is ever compared with another. The whole structure is 33 bits of payload and 184 bits of directory and code table, over 11 characters and 5 distinct symbols.00100000010root000101000000101000100001010010one box = one bit · left child 0, right child 15 symbols, 217 bits
Fig. 8 The structure that turns a rank over an alphabet into a handful of ranks over bits. One bit per symbol still in play at each node, saying which half of the remaining alphabet it falls in.

The widths on the way down are answers too

The trace table reports a width at every step — 881, 320, 110, 110, 110, 23 — and those are usually read as progress towards the answer. They are answers in their own right, and having them costs nothing.

The interval after jj steps is the interval of the pattern’s last jj characters. So its width is the number of occurrences of that suffix of the pattern, exact, in the same text. One backward search over a six-character pattern therefore reports the occurrence count of every one of its six suffixes, and the only thing separating those numbers from the final answer is that nobody asked for them.

The widths are non-increasing, and that is a fact rather than an observation about this text: every occurrence of cPcP contains an occurrence of PP one position along, so the set can only shrink as characters are prepended. The two consecutive 110s are the interesting kind of shrinkage — none — and they say that every han in this text is a than, which is a statement about the source that fell out of a search that was not asking.

That by-product is worth more than a curiosity, because it answers a question this collection left open in a different field. A seed-and-extend filter chooses its seed length from a formula in the pattern length, the error budget and the alphabet, and the formula predicts the expected number of chance occurrences on uniform text. Real text is not uniform, and the essay that measured the filter says exactly that: the variance of the candidate count on real data is larger than anything a mean can show.

The widths remove the need to predict. Extend a seed leftwards one character at a time and watch the width fall; stop when it is small enough to verify. That is a seed chosen by its measured occurrence count in the actual text rather than by an expectation over a text nobody has, it costs two rank queries per character of seed — the search that was going to be performed anyway — and it adapts automatically to a seed that happens to land in a common region. A quantity that a filter was estimating from parameters is available exactly, for free, as a side effect of the search.

Three things the interval is not

The interval is a range of rows and it is easy to treat as though it were the answer. It is not the answer to three separate questions, and each of them costs something different to get.

It is not a set of positions. Rows are not text positions, and turning one into the other is the sampling walk. That is the split the essay has already drawn — the count is cheap and the locations are not — and it is the largest of the three gaps.

It is not a set of documents. A collection concatenated into one text gives an interval whose width counts occurrences, and a reader who wants how many documents contain this has been given a different number. Twenty-three occurrences might be twenty-three documents or one document twenty-three times, and nothing about the interval distinguishes them; recovering the document count is its own construction with its own cost.

And it is not in text order. This one is the quietest and it catches people. The rows of the interval are sorted lexicographically by the text that follows each occurrence, which has no relation whatever to where the occurrences sit. So the first row of the interval is not the first occurrence in the text, and there is no way to get the earliest occurrence without locating all twenty-three and taking the minimum.

That last point turns a common optimisation upside down. A scan that wants only the first match stops at the first match and is cheaper for it. An index that wants only the first match must do all the work of finding every match and then sort, so asking for less is not cheaper here — and a system that reports a leftmost occurrence from an index has paid the full price of reporting all of them.

The check

The occurrences are required to agree with a scan. Every index in this collection is asked for the same patterns as a naive left-to-right search over the same text, and the two answers must match in count and in position — not because the arithmetic is doubted, but because a backward search that is subtly wrong returns a plausible interval containing a plausible number of rows, and every figure drawn from it would be a wrong figure that draws perfectly.

That is the failure mode this whole collection is arranged against, and it is sharper here than usual: the count is produced without any occurrence being examined, so there is nothing in the answer that could look wrong.

The check is run at four pattern lengths and three positions in the text, on every index in this collection, including the deliberately enormous counter table — which is there partly as a control. Two structures with completely different internals producing identical answers is much stronger evidence that both are right than either one agreeing with itself, and when a rank directory was quietly broken during construction it was this comparison that reported it rather than any figure looking wrong.

Beside it sits the check that matters most for the claim this essay makes: the search must examine zero characters, and the suffix-array search over the same pattern must examine more than zero. Without the second half, an implementation in which the character counter had stopped incrementing would pass.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

The 8 essays that link to this one and share the most of its objects, of 13 that link here.

The objects this essay names

Each one links to every other essay that touches it.

Backward searchBurrows-wheeler transformCharacter comparisonHonest limitIndex sizeMeasurementPattern matchingPreprocessingRank querySelf-indexSuffix arrayWavelet tree