Structures

A rectangle over a permutation

Two orderings of one set of boundaries are two permutations, so a phrase index's intersection is a rectangle over a permutation grid — the one point set a wavelet tree stores exactly, at one bit per point per level and no coordinates at all.

The question at the centre of a phrase index is: which of these zz points lie inside this rectangle? The candidates a filter cannot avoid establishes that it is a rectangle and measures what answering it by scanning costs. This essay builds the structure that answers it without scanning, and the interesting part is how little the structure has to store.

156 phrase boundaries, and the rectangle one split point asks aboutEach point is one phrase boundary of a collection of 1,024 characters. Its horizontal position is the boundary's rank when the boundaries are sorted by the text BEFORE them and its vertical position is its rank when they are sorted by the text AFTER them. Splitting the pattern "ss is un" after 4 characters gives a range of 0 boundaries whose preceding text ends with the left half and 0 whose following text begins with the right half; an occurrence crossing a boundary at this split is a point in both, which is a rectangle. 0 of the 156 points are in it, and the filter this collection shipped examines the smaller of the two ranges — 0 of them — to find out which.rank among the boundaries, sorted by the text before themsorted by the text after0 in the rectanglepattern "ss is un"split after 40 end with the left half0 begin with the rightEnglish-like · 2 copies of 512z = 156 · 0 crossing
Fig. 1 The point set. Every phrase boundary has a rank in each of two orders, no two boundaries share either rank, and the rectangle is one split point’s two conditions.

A permutation is the easy case, and this is one

The points are not arbitrary. Every boundary has exactly one rank in the prefix order and one in the suffix order, and both orders are total, so the map from one rank to the other is a bijection on {0,1,,z1}\{0, 1, \ldots, z-1\}.

That is what makes the structure cheap. A general set of zz points on a z×zz \times z grid needs its coordinates stored — 2zlog2z2z\lceil \log_2 z\rceil bits before anything is indexed. A permutation needs half of that in principle and, stored the way below, needs zlog2zz\lceil \log_2 z\rceil bits including the index: the xx coordinates are the positions in a sequence, so they are not stored at all, and the yy coordinates are the paths through a tree, so they are not stored either.

The whole structure is a pile of bits saying, at each level, which half of the remaining value range each point’s yy falls in.

The construction, in one paragraph

Take the sequence y0,y1,,yz1y_0, y_1, \ldots, y_{z-1}, where yxy_x is the second rank of the boundary whose first rank is xx. Write one bit per element: zero if the value is in the lower half of [0,2L1][0, 2^L - 1], one if it is in the upper. That is level zero. Now stably partition the sequence — all the zeros first, in their original order, then all the ones — and repeat on each half with its own halved value range. After L=log2zL = \lceil \log_2 z\rceil levels every group holds one value and there is nothing left to split.

A level is zz bits however many groups it contains, because the groups partition the same zz points. So the payload is exactly zlog2zz\lceil \log_2 z\rceil bits, plus the rank directory each level needs to answer questions about itself.

This is the same object rank is the only thing it does built over a text, with values in place of symbols and a balanced shape in place of a Huffman one. The reason it is a different object here is that the query is different: a text’s wavelet tree is asked how many times a symbol occurs before a position, and this one is asked which positions hold a value in a range.

The two grids are 27% of the structure and answer nothing on their ownEvery part of the phrase index over a collection of 16,384 characters with z = 156 phrases. The parse itself and the three orderings of its boundaries are the index; the two grids are what makes its search proportional to the answer. Together they are 3,240 bits of 12,132, of which 896 is rank directory rather than payload — the o(z log z) that the published bound puts under the leading term, drawn at the size where it is 28% of it.phrase table5,148 bitsboundary orders3,744 bitsintersection grid1,696 bitspropagation grid1,544 bitsz = 156 · ⌈log₂ z⌉ = 8 levels16,384 characters · 12,132 bitsgrids 26.7% · 20.8 bits a point
Fig. 2 What that costs on the collection this ladder is measured on: two grids, one for the intersection and one for the propagation, against the index they are bolted to.

The descent

A query is a rectangle [x0,x1)×[y0,y1][x_0, x_1) \times [y_0, y_1]. Start at the root with the whole sequence and the whole value range.

If the node’s value range misses the rectangle’s, stop: no point beneath it can qualify. If the node’s value range is contained in the rectangle’s, every point in the node’s slice of the current xx interval qualifies, and they can be reported without looking any further down. Otherwise split: the points of the node whose bit is zero go left, those whose bit is one go right, and the xx interval maps into each child by a pair of rank operations on the level’s bit vector.

The visited nodes are the ones whose value range straddles an edge of the rectangle, plus the ones contained in it. In a balanced tree of LL levels that is O(L)O(L) straddling nodes and however many contained ones the answer needs — which is why the descent costs a logarithm and not a range.

What each policy looks at, and what the answer isOne pattern of 8 characters against a collection growing from one copy to 32. The filter examines 105 candidates at one copy and 4,355 at 32, while the answer grows from 1 occurrences to 32 — 136.1 candidates per occurrence, and rising. The grid examines 33, which is 1.03 per occurrence and is the answer plus the split points it was found at. Both axes are logarithmic.1,00010,00011010010³candidates examined12481632characters in the collection · copies abovefiltering a rangethe gridthe answerEnglish-like · m = 8132x fewer candidates
Fig. 3 The consequence, measured on the same collection as the previous essay. The lower line is what the descent examines and the upper is what a scan examines.

The lifting, which is the part that costs

A node deep in the tree knows how many points qualify and it does not know where they are. The position of a point inside a level-\ell node is a position in a sequence that has been stably partitioned \ell times, and recovering its original xx means undoing each partition.

Undoing one is a select. If the point came down the zero branch, its position in the parent is the position of the kk-th zero, where kk is its offset inside the child; if it came down the one branch, the position of the kk-th one. So reporting a point costs one select per level between it and the root, and reporting tt points costs tlogzt \log z selects in the worst case.

That is where the “plus one times a logarithm” in the published bound lives, and it is why a query returning every point in the grid should be answered by reading the level-zero sequence instead. The structure is worth having exactly when the answer is small compared with the range — the same condition the previous essay measured and the same one a filter has a selectivity states for filters.

What a rank and a select cost, since the answer is denominated in them

The descent spends ranks and the lifting spends selects, and this collection has already measured both against the same bit vectors.

A rank is a superblock counter, a block counter and a scan of the remainder — an index larger than what it indexes is the essay about what happens when the directory is not built carefully, and the index that is smaller than the text is where the two-level directory’s own arithmetic is set out. A select is a binary search over the superblock counters and then a scan, which is select is not rank backwards: the two are not symmetric, the second is dearer, and a structure that spends one per level per reported point is spending the dearer one.

That asymmetry is not a detail here. A query reporting tt points at LL levels costs about 2L2L ranks for the descent and up to tLtL selects for the lifting, so the lifting dominates as soon as the answer is more than a couple of points. A structure sold as “output-sensitive” is one whose cost is tt times a constant, and the constant is a select — which is the most expensive primitive in the whole field.

The measured consequence is the next essay’s subject and it is worth stating here so the structure is not oversold: on the collection this ladder runs on, the grid examines 33 candidates where the filter examines 4,355, and performs 2,471 rank and select operations doing it.

Why not simply store the other rank

There is a much simpler structure for the same query and it is worth saying why it is not this one, because it is what the index already has.

The index stores rankInPrefix, an array mapping a boundary to its rank in the prefix order. Given the suffix-order range, the filter reads that array for each member — one array read per candidate, no logarithms anywhere. It is faster per candidate than any grid descent, which is exactly why the comparison in the next essay is not the walkover the candidate counts suggest.

What it cannot do is skip. An array indexed by one rank answers “what is this boundary’s other rank” and nothing else; there is no query it supports whose cost is smaller than the range. The grid is not a faster way of doing what the array does, it is the structure for a question the array cannot be asked — the same relationship a search that runs backwards has with a scan, and the sampling that goes the other way has with a walk.

And the array is not free either: three orderings of zz boundaries at log2z\lceil \log_2 z\rceil bits each is 3,744 bits on the collection above, against the intersection grid’s 1,696. A reader deciding what to build should notice that the grid replaces one of the three, which makes its true marginal cost smaller than its size.

The leaves have to exist, and the first version’s did not

A tree of LL levels over a value range of 2L2^L ends with nodes covering a single value each. Those nodes have nothing to split, hold no bit vector, and it is tempting not to build them.

The first version of this structure did not, and it lost points. A rectangle whose yy range covers one value of a sibling pair and not the other is answered at exactly those single-value nodes: the parent’s range straddles the edge, so the descent does not stop there, and it walks off the bottom of the tree into a node the construction never made. Across the check’s forty random rectangles it lost twelve points of 614, on ten of the forty — and the first one it stopped at held seven and reported six.

The failure is invisible on any wide query, which is the shape worth noticing rather than the bug. A range structure tested on rectangles covering a decent fraction of the grid is tested on the part of the descent that never reaches a leaf. The check that found it compares against exhaustion on forty random rectangles at every width, which is what a check has to do when the object being checked has a fast path and a slow one: check both, and let the slow one be the oracle.

A longer pattern has more split points and a smaller answer at eachThe crossing-occurrence search at five pattern lengths on 8,192 characters. A pattern of m characters is split at m − 1 places and each split is its own rectangle, so the number of queries grows with the pattern while the answer at each shrinks. The filter examines 5 candidates at m = 4 and 8 at m = 16; the grid examines 1 and 8. The two converge, because at a long pattern the ranges are already small and there is nothing left for a rectangle to remove.43658712111615candidates examinedpattern length · split points belowfilteringthe gridEnglish-like · z = 15616 occurrences throughout
Fig. 4 The narrow-rectangle regime, drawn: a longer pattern gives more rectangles and a smaller answer in each, so the queries that reach the last level are the ordinary case rather than the corner one.

One vector per level, not one per node

The layout above stores the levels. The obvious alternative is to store the nodes — one bit vector per node of the tree, each holding only its own points — and it is arithmetically identical in payload, because the nodes of a level partition the same zz points.

It is absurd in overhead, and this file measured how absurd before it was rewritten. Each bit vector carries a rank directory whose smallest superblock and block entries do not shrink with the vector, so a node holding five bits still pays for two superblock counters and four block counters. Over the roughly 2z2z nodes of the tree that is a directory much larger than the whole payload: the first version of this structure reported 10,094 bits of grid over a 7,285-bit index — 2,328 bits of payload and 7,766 of directory, which is more than three times the thing it is a directory for.

The level layout reports 3,224 bits for the same two grids — 2,328 of payload and 896 of directory. Same points, same queries, same answers, the same payload to the bit, and a third of the total.

The moral is not about wavelet trees. It is that a structure described as “a tree of bit vectors” has an implementation whose cost is dominated by a per-object constant that no asymptotic statement about it mentions, which is the constant the notation drops at the level of a data structure’s layout rather than of an algorithm’s loop. The same directory arithmetic decides the shape of every compressed index in this field, and what is still proportional to n is where this collection first measured it.

The two grids are 28% of the structure and answer nothing on their ownEvery part of the phrase index over a collection of 16,384 characters with z = 148 phrases. The parse itself and the three orderings of its boundaries are the index; the two grids are what makes its search proportional to the answer. Together they are 3,232 bits of 11,372, of which 896 is rank directory rather than payload — the o(z log z) that the published bound puts under the leading term, drawn at the size where it is 28% of it.phrase table4,588 bitsboundary orders3,552 bitsintersection grid1,632 bitspropagation grid1,600 bitsz = 148 · ⌈log₂ z⌉ = 8 levels16,384 characters · 11,372 bitsgrids 28.4% · 21.8 bits a point
Fig. 5 The same accounting on four-symbol text, where the phrase count is larger. The grid’s share moves because z moves, and the payload is still z⌈log₂ z⌉ exactly.

The second grid is the same machinery and a different point set

Propagation asks which phrases copy from a region containing a given position: source at most pp, source end at least p+mp + m. Both conditions are on values rather than ranks, so the coordinates are ranks of those values — the phrases sorted by source give the xx axis, and the rank of each phrase’s source end among all source ends gives the yy.

The query is then a rectangle with two of its sides at the edges of the grid: xx from zero to the number of sources at most pp, yy from the number of ends below p+mp + m to the top. Two binary searches to convert the position into ranks, then one descent.

Which phrases copy from a region holding position 100Every copying phrase of a 2,048-character collection, drawn as the interval of text it copies FROM, sorted by where that interval starts. The vertical rule is one occurrence already found; a phrase produces a new occurrence exactly when its source interval contains the whole of it, which is 1 of the 137 phrases here. The walk this collection shipped examines every phrase whose source starts at or before the rule — the whole upper part of the picture — and the grid asks for the two-sided condition directly.the occurrence at 100sources1 of 137 phrases copy from a region that holds itEnglish-like · 4 copies · z = 1561 covering · 137 copying
Fig. 6 The intervals the second grid is over, with one query’s answer marked. The rectangle’s two open sides are why this is a stabbing query rather than a general one.

Nothing else changes. The same class, the same levels, the same descent, the same lifting — which is the reason the two are one file and one structure rather than two, and the reason the propagation’s saving could be measured the day the intersection’s was.

Two ways to get a correct-looking structure that is wrong

Both were found by checking against exhaustion rather than by reading the code, and both produce answers that pass every test asking whether the points reported are inside the rectangle.

A rectangle whose upper edge is exclusive where it is meant to be closed. The suffix-range search returns a half-open interval and the grid takes a closed yy range, so passing the interval’s end where its last element is meant drops the top row. On most queries the top row is empty and nothing happens. Shrinking every rectangle by one row across sixty random queries loses sixty points, which is the check.

A grid whose axes have been exchanged. Both coordinates are ranks of the same boundaries, both are permutations of the same range, and a grid built with the roles swapped is a perfectly valid grid over a perfectly valid permutation. It answers rectangles correctly and answers the wrong rectangles: on the check’s collection it reports zero occurrences where there are eight, and every one of the zero is right.

That second one is the one worth carrying, because it has no signature at all. There is no exception, no impossible value, no assertion that could notice — the only symptom is a smaller answer, and a smaller answer from a search is indistinguishable from a pattern that does not occur.

The whole propagation, both policiesPhrases examined during the propagation, against the number of occurrences it produces. The walk's total grows from 242 to 4,352 while the occurrences it produces grow from 1 to 31; the grid's total is 31, which is the occurrences themselves. Both axes are logarithmic.10,00011010010³phrases examined2481632characters in the collection · copies abovewalking the sourcesthe stabbing queryEnglish-like · m = 8140x apart at 32 copies
Fig. 7 The propagation’s whole cost under both policies. The comparison is only meaningful because the two are required to return identical occurrence sets before any of it is reported.

The per-object constant, and where it stops mattering

The two layouts differ by 6,870 bits over an identical payload, and the difference decomposes into two terms that behave completely differently — which is worth doing, because the second term is the one a bound would call negligible.

A rank directory over a vector of bb bits costs a part proportional to bb — a block counter of nine bits every sixty-four, a superblock counter of eight every five hundred and twelve, so about 0.156 bits per bit — plus a part that is fixed per vector, being the counters a vector pays for existing at all.

Both layouts hold 2,328 bits of payload, so both owe the same 363 bits of proportional directory. The level layout reports 896, leaving about 530 across sixteen vectors: roughly 33 bits of fixed cost per vector. The node layout reports 7,766, leaving 7,400 — which at the same 33 bits apiece is about 224 vectors, the nodes of two trees that hold more than a single point.

So the entire 6,870-bit difference is one constant times a count, and the count is the only thing the two layouts disagree about: sixteen bit vectors against two hundred and twenty-four.

Written that way it generalises, and the generalisation is sharper than the moral this page draws. A tree-of-vectors layout has about 2z2z nodes, so its fixed directory cost is Θ(z)\Theta(z) bits against a payload of Θ(zlogz)\Theta(z\log z) — formally a lower-order term, and exactly the kind of thing an o(zlogz)o(z\log z) absorbs without comment.

Evaluate the ratio. Fixed cost per point comes out near 25 bits here, against a payload of log2z\log_2 z bits per point, so the directory exceeds the payload whenever

log2z<25,\log_2 z < 25,

which is to say whenever zz is under about thirty-three million points.

That is the number worth carrying. The per-node constant is asymptotically negligible and it is larger than the thing it is a directory for at every size any of these structures is built at — a factor of 3.3 here, and still above one at ten million points. A term that becomes lower-order somewhere past the largest collection anybody indexes is not a lower-order term in any useful sense; it is the leading term with an apology attached.

Which is the constant the notation drops with a crossing point rather than an anecdote, and the same shape what is still proportional to n found in an index’s sampling. The level layout does not make the constant smaller — 33 bits a vector is 33 bits a vector — it makes there be sixteen of them instead of two hundred, and that is the whole of the third.

What is being claimed

A permutation grid costs zlog2zz\lceil \log_2 z\rceil bits of payload, measured exactly, plus a rank directory that is 38% of the payload at the sizes here and shrinks as a share when zz grows.

A rectangle is answered by a descent of O(logz)O(\log z) straddling nodes, plus one select per level per point reported — so the cost is the answer times a logarithm rather than the range.

The two grids in a phrase index are one structure over two point sets, and the second one’s query is the first one’s with two sides pushed to the edges.

And the structure is easy to build wrongly in ways nothing notices. Both defects above return real points, in the right order, from a correctly built tree. That is the same shape as the failure the occurrences that cross a boundary guards against — a search reporting its primaries and skipping the propagation returns nothing but real occurrences and a fraction of the answer — and it is why every check in this ladder compares against exhaustion rather than against a property.

What has not been claimed here is that any of it is worth it. The descent costs operations, the operations are not the candidates it replaces, and the bits are paid before the first query — which is the next essay and the one after it.

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

Every essay whose body links to this one.

The objects this essay names

Each one links to every other essay that touches it.

Bit vectorConstructionGridOutput-sensitivePermutationPrimary occurrenceRange reportingRank querySecondary occurrenceSelf-indexSpace overheadStabbing queryWavelet tree