Structures

A document already in the answer

The same walk, with the chain's test replaced by a lookup in the answer so far. It reports the same documents at the same cost, and two exchanged lines make it lose nine of seventeen without failing.

The algorithm is six lines, and every one of them was already there.

Given the rows [lo, hi) of a pattern: ask the range minimum for the position of the smallest chain value in the subrange; look up the document at that position; if it is already in the answer, stop; otherwise report it and recurse on both sides, left first.

Two tests, one columnThe first 8 steps of a document listing over rows 476 to 489 — the rows of a 4-character pattern in a collection of 8 documents. At each step the range minimum returns a position; the published method compares the chain entry there against the start of the range, and this one asks whether the document at that position is already in the answer. The two columns are the same column, at every step and on every collection tried. The chain is therefore not read — and once it is not read it does not have to be stored, because the structure over it keeps the shape of an array and not its numbers.range consideredminimum atC[at] < lonew document[476, 489)483 · document 4yesyes[476, 483)477 · document 1yesyes[476, 477)476 · document 0yesyes[478, 483)482 · document 2yesyes[478, 482)478 · document 0nono[484, 489)486 · document 3yesyes[484, 486)484 · document 0nono[487, 489)487 · document 2nono8 steps, 5 documents, and the two columns never differrows 476–489 · 8 documents8 steps
Fig. 1 One listing, step by step: the subrange, the position the minimum landed on, the document there, what the chain test says, and what the answer so far says.

The only change from the published version is the third line, which used to compare a chain entry against the start of the range.

Why it is the same walk

The two tests agree at every step, which is stronger than the two methods agreeing on the answer.

That is checked directly: run the walk with both tests evaluated at every subrange, and require them never to differ. Over forty collections, 268 steps in total, they never do — and a step where they differed would be a counterexample rather than a discrepancy to explain.

The array the walk never reads is the argument for why: C[at] < lo says this is the first row of this document in the range, and a left-first walk has already reported every document whose first row lies to the left. The test is asking whether the document is new, and the answer knows.

Where the recursion order matters

The chain version does not care about the order. C[at] < lo is a comparison of two numbers that do not depend on what has been visited, so recursing right before left reports exactly the same set, in a different sequence.

The chainless version cares completely.

Exchange the two recursions and the walk loses documents. On a collection of 24 documents, over 40 patterns tried, some were lost on 5% of them — and the worst case reported nine documents against a true seventeen.

The mechanism: with the order reversed, a subrange to the right can be visited before the subrange containing a document’s first row. The document is not yet in the answer, so the walk reports it there — fine — but it also prunes on the left where the real first occurrence was, and the recursion that would have found other documents beneath it never happens.

The same work, from a structure that holds lessThe pattern repeated more and more often inside the same 3 documents, so the answer stays fixed and only the occurrences move. The rising line is a scan over every occurrence; the flat one is range-minimum queries, and it is flat because the walk visits the answer rather than the occurrences. Both output-sensitive methods sit on that line: 14 queries with the chain and 14 without it, at every point on the plate. Removing the array did not make the walk cheaper or dearer — it removed a thing the walk was not reading.050100406080100occurrences of the patternrows read, and range-minimum queriesevery occurrence readqueries, both ways12 documents · answer 814 queries at 114 occurrences
Fig. 2 What the correct walk costs: identical range-minimum queries with and without the chain, at every occurrence count.

The failure is silent, which is what makes it worth a check rather than a comment. Every document the broken walk reports is genuinely in the answer; the answer is short, and a document listing has no way to notice what it did not find.

The bitmap, and how it is cleared

The state is one bit per document, and clearing it is where a naive implementation loses the property the method exists for.

Walking the bitmap costs d per query. On two thousand documents, answering a query whose answer is three, that is the dominant cost and it is proportional to the collection rather than to the answer — which is exactly the thing output-sensitive listing is supposed to avoid.

So the walk remembers what it set and unsets that: |answer| writes, not d. Measured on a collection of 512 documents with an answer of 166: 166 clears.

The check requires both halves — that the clears equal the answer’s size, and that they are fewer than the collection — because a version that cleared everything would satisfy the first half of the sentence and none of its purpose.

And the mirror defect is in the gate too. Leave the bitmap dirty between queries and a later query reports 0 documents where the answer is 3, because they were all marked by an earlier one. Same silence, same shape.

The range minimum, told to forget

The structure over the chain is built from the chain and does not hold it. That is the property the shape a range question is about establishes: 2n parentheses hold the shape of the array, the query returns a position, and the value at that position is read out of the caller’s array purely as a convenience.

A caller that only ever compares the answer has no use for the values, so this one drops them — and the range minimum drops its reference too.

That is a method rather than a constructor flag, because it is a claim: after the call the structure cannot read a value even by accident, so a caller that still works has demonstrated it never needed them. Asking it for one raises.

The check is the obvious one: build the index, ask the forgotten structure for a value, require it to refuse. Without that, “the chain is not part of the structure” would be a statement about code somebody read rather than a property of the object.

The same tree, written down in 2n bitsEvery node opens a parenthesis when it is pushed and closes one when something smaller arrives, so 12 values become exactly 24 parentheses and nothing else is stored. The pushes happen in index order, which is the property the whole structure turns on: the k-th opening parenthesis is position k, so getting from an array index to its place in the sequence is a select rather than a stored table of 12 positions. The ordinary encoding — an Euler tour of the tree — puts the openings in the order the VALUES sort the indices, and needs that table.the sequencethe index each opening is538279164738the value at that position12 values · 24 parentheses2 bits a value
Fig. 3 Why the values can go: the structure stores the tree’s shape as parentheses, and the shape is all a query needs.

What is checked, in total

Five things, and they divide into the ones that must hold and the ones that must fail.

Must hold. All three methods — scanning the occurrences, walking with the chain, walking without it — return the same documents on every pattern tried. The two tests agree step for step. The queries are identical in number. The clears equal the answer’s size. The apparatus is smaller than the index it sits on, which is the size claim of the strand.

Must fail. A right-first walk loses documents. A dirty bitmap under-reports. A forgotten range minimum refuses to hand back a value.

That division is the shape every strand here ends with: the check must reject, because an assertion that has never rejected anything proves nothing about the sentences written on top of it.

The apparatus against the index, as the documents multiply131,072 characters cut into more and more documents, with the listing apparatus priced as a multiple of the index it sits on. Both lines rise, because the document array is n⌈log₂ d⌉ bits and is the only term here that knows how many documents there are. The upper line is the published structure and never falls below 2.43x; the lower one has no chain, and stays under the index itself until about 2,214 documents. The saving is 76.3% at 4 documents and 64.7% at 2,214 — it shrinks, because what is left is the term that grows.1010010001documents in the collectionapparatus ÷ indexwith the chainwithout itthe index itself131,072 characters64.7% saved at 2,214 documents
Fig. 4 The size claim the last check is about: the apparatus against the index it sits on, with the chain and without it.

What Sadakane’s substitution actually is

Worth naming plainly, because “replace the test” undersells it.

The published method needs the chain because its test is about the chain. What the substitution notices is that the information in the test — is this document new — is available for free from the output, and that the output exists whether or not anybody thought of it as state.

That is a specific kind of move: an algorithm’s own accumulating answer is a data structure it is already paying for. Nothing else in this collection uses it, and it is worth having as a habit, because a structure that duplicates something the algorithm already holds is a structure that can be removed rather than shrunk.

The cost is that the algorithm becomes order-dependent. The chain version’s test is a function of the input alone, so it is correct under any traversal; the chainless version’s test is a function of the traversal’s history, so exactly one order works. That is the price of removing n⌈log₂ n⌉ bits, and it is paid in the form of an invariant somebody has to know about.

Every part, at each stageThe same collection indexed three ways, drawn part by part. The suffix array, the text and the document array are identical in all three — nothing here makes an index smaller. What moves is the range structure, which falls by a factor of 3.19 when the segment tree is replaced, and the chain, which is 2,363,886 bits in the first two stages and is absent from the third. What replaces it is one bit per document: 256 bits, which is the shortest bar on the plate.tree · suffix array2,363,886tree · text656,635tree · document array1,050,616tree · previous-occurrence chain2,363,886tree · range minimum4,727,772succinct · suffix array2,363,886succinct · text656,635succinct · document array1,050,616succinct · previous-occurrence chain2,363,886succinct · range minimum1,483,854chainless · suffix array2,363,886chainless · text656,635chainless · document array1,050,616chainless · range minimum1,483,854chainless · reported bitmap256131,327 characters · 256 documents6 parts
Fig. 5 Every part at each of the three stages, with the chain present in the first two and absent from the third.

Reading the trace

The plate at the top of this page is the algorithm running, and it repays being read row by row.

Each row is one subrange the walk considered. The first column is the subrange; the second is the position the range minimum returned and the document there; the third is what the chain test said; the fourth is what the answer-so-far test said.

The first row is the whole range: the minimum lands somewhere in the middle, its chain entry is before the range, and both tests say new. The walk reports the document and recurses left.

Two rows later a subrange arrives whose minimum is a document already reported. The chain entry there is inside the range — it points at the row where that document was first found — and both tests say not new. The walk stops, and everything below that subrange is pruned in one step.

That pruning is where the output-sensitivity comes from. A subrange whose minimum fails the test contains nothing new by definition: the minimum is the smallest chain value in it, so every other entry is at least as large and therefore also inside the range.

Why exhaustion rather than examples

The equivalence is checked over forty collections and 268 steps, and the checks that must fail are run over forty patterns each. That is not a large number, and it is a deliberate choice about what kind of evidence this is.

A proof would be better and this collection does not prove things: it states a claim and gives it a test it could fail. The test here is strong in a specific way — it compares the two tests at every step, not the two answers at the end — so a single subrange where they differed would be a counterexample, and a hundred collections agreeing on their answers while differing internally would not pass.

The other side of the same discipline is that the defects have to be found rather than argued about. The wrong recursion order does not fail on every pattern; it failed on 5% of the forty tried, which means a check running one pattern would have passed and a check running ten would probably have passed.

That is the argument for sweeping rather than sampling wherever it is affordable, and here it is affordable: forty patterns over a small collection is milliseconds.

Forty patterns is not enough, and the arithmetic says how many is

“It failed on 5% of the forty tried, which means a check running one pattern would have passed” is the right instinct and it stops one step short. The same rate says something about forty.

A defect that fails on a share pp of patterns escapes a gate of NN independent patterns with probability (1p)N(1-p)^N. At p=0.05p = 0.05 and N=40N = 40 that is 0.9540=0.1290.95^{40} = 0.129: the gate as run misses this defect about one time in eight. Not once ever, on the collection it was tuned against — but a gate is meant to catch the defect on a tree nobody has seen, and one run in eight is not a check, it is a coin weighted three to one.

Rearranged, the requirement is

N    ln(1c)ln(1p),N \;\ge\; \frac{\ln(1-c)}{\ln(1-p)},

which for 99% confidence is about 4.6/p4.6/p: ninety patterns to catch a defect failing at five per cent, four hundred and sixty to catch one failing at one per cent, and forty-six hundred at a tenth of a per cent. The size of a sweep is set by the rarest defect it has to catch, and that rate is not observable from the defects it has already caught — the 5% here is measured on one broken implementation, and the next broken one will have its own.

Two things follow, and the first is free.

Run more patterns. Forty patterns over a small collection is milliseconds, so the gate is nowhere near a cost that justifies the sample size it uses. Two hundred would put the escape probability at 0.952000.95^{200}, which is one in twenty-three thousand, at a cost nobody would notice.

And prefer the check that fails per step. This is where the step-level comparison earns its place beyond the argument this page already makes for it. Comparing the two tests at every subrange gives 268 opportunities to disagree across the same forty collections, against forty opportunities to produce a short answer — so the defect’s per-case rate is enormously higher against the stronger check, and the required NN collapses. A check placed close to the mechanism needs a smaller sweep than one placed at the output, and the ratio between the two sample sizes is the ratio between their per-case rates.

That is the same discipline one pass, k slots, and two randomness budgets applies to a biased sampler, where the noise floor at four hundred runs is fourteen per cent against a bias of twenty-three and the test proves nothing, and at forty thousand runs the two are a factor of seventeen apart. There the sample size is set by an estimator’s variance; here it is set by a defect’s incidence; in both cases a gate that has not had its own detection probability computed is a gate whose passing means an unknown amount.

Neither changes the conclusion of this page. The right-first walk is broken, the check found it, and the equivalence holds where it was measured. What the arithmetic adds is that the sweep should be larger than the one that happened to be enough.

What an implementation should actually do

Three notes for anybody writing this, because the details are where it goes wrong.

Keep the reported set as a bitmap indexed by document, not as a hash set. The cost of the method is meant to be proportional to the answer, and a hash set’s constant is large enough to matter against a range-minimum query that is nine or ten memory accesses.

Clear it by the answer. The list of reported documents is the output, so unsetting exactly those bits is free of extra bookkeeping. Clearing by sweeping is d per query and undoes the method on a large collection.

And make the range minimum forget. If the structure holds a reference to the chain, the chain stays alive and the saving is imaginary — which is the difference between a paper result and a measured one. The forgetting is also what turns “the values are not needed” into something the build can check.

A fourth note, which is about the shape of the code rather than about performance: the recursion is naturally written as two calls, and the order of those two calls is load-bearing. It deserves a comment, because it looks like a symmetry and is not.

The rule, drawn: the rightmost lowest point between two openingsThe excess is the number of parentheses open at each point, and it moves by exactly one at every step. The openings of positions 2 and 8 are marked; between them the excess reaches its lowest value last at position 12, and the number of openings before that point is 6 — the range's minimum. The reason is that the openings still unclosed at a point are the stack the tree's construction was holding, and the outermost of them inside the range is its smallest value. Taking the LEFTMOST lowest point instead answers two ranges in five wrongly, with an index inside the range every time.012305101520position in the parenthesis sequenceexcessposition 6opening of 2opening of 812 values · range 2..8minimum at 6
Fig. 6 The structure the walk queries, drawn as the excess sequence its query rule runs on.

What it does not fix

Two things, and both are properties of the method rather than of this formulation.

The answer can still be large. Output-sensitive means proportional to the answer, and a pattern occurring in every document of a large collection has an answer the size of the collection. The method is then doing d queries, each of which is several memory accesses, against a scan that would have read the occurrences — and the crossing that never arrives is the page about when that comparison goes the wrong way.

And the document array is still there. Every step reads D[at] to find out which document a row belongs to, so the array that answers that is not removable by this argument. It is the largest remaining part of the apparatus, and what the chain cost is where the accounting lands.

So this page removes one of the three structures the apparatus is made of. The second was replaced by a smaller one in an earlier strand, and the third is information the collection genuinely contains.

The apparatus against the index, as the documents multiply131,072 characters cut into more and more documents, with the listing apparatus priced as a multiple of the index it sits on. Both lines rise, because the document array is n⌈log₂ d⌉ bits and is the only term here that knows how many documents there are. The upper line is the published structure and never falls below 2.43x; the lower one has no chain, and stays under the index itself until about 2,214 documents. The saving is 76.3% at 4 documents and 64.7% at 2,214 — it shrinks, because what is left is the term that grows.1010010001documents in the collectionapparatus ÷ indexwith the chainwithout itthe index itself131,072 characters64.7% saved at 2,214 documents
Fig. 7 What remains, against the document count: the two structures this argument does not touch.

The invariant, written down

Since the whole correctness of this rests on a property of the traversal, it is worth stating in the form somebody could check against an implementation.

At the moment the walk considers a subrange [a, b), every position in [lo, a) has been visited, and every document whose first row in [lo, hi) lies in [lo, a) has been reported.

The first clause is the left-first order. The second follows from it plus the pruning rule: a document’s first row has a chain entry below lo, so no subrange containing it can be pruned, so if it is to the left it has been reported.

Given that, the test D[at] ∈ answer is true exactly when C[at] ≥ lo, which is the equivalence the substitution needs. And an implementation that violates either clause — by recursing right first, by pruning early, by clearing the bitmap mid-query — loses documents silently.

That is the sort of invariant that belongs in a comment beside the recursion rather than in a paper, because the code that breaks it looks correct.

The apparatus, in three stagesWhat answering "which documents hold this" costs beyond the index that answers "where is this", on 131,327 characters in 256 documents. As the document strand shipped it, the apparatus was 2.70x the index it sits beside — a document array, a chain as wide as the suffix array, and a segment tree over the chain. Replacing the segment tree with the succinct range minimum from the following strand takes it to 1.62x. Removing the chain — which the walk never reads — takes it to 0.84x, which is smaller than the index itself.a segment tree over the chain2.70x8,142,274 bitsa succinct range minimum over the chain1.62x4,898,356 bitsno chain at all0.84x2,534,726 bitsthe dashed rule is the index itself: a suffix array and the text131,327 characters · 256 documents2.70x → 0.84x
Fig. 8 And what the invariant buys, which is the next page: the apparatus at each of three stages, against the index it sits on.

Where this sits in the strand

The array the walk never reads is what the chain’s test is asking. This page is the algorithm. What the chain cost is the size the removal buys, and the apparatus that is smaller than its index is the accounting with the recommendation attached.

The structure the walk queries was built in the shape a range question is about, and the reason the walk exists at all is a list of documents is not a list of occurrences — a collection supports a question a text does not, and answering it at the price of its answer is the whole of document retrieval.

The two silences

Both defects in this page fail silently, and the pair is worth putting beside each other because they fail in the same direction and for different reasons.

A right-first walk under-reports because the equivalence between the two tests breaks: a document not yet in the answer is reported at the wrong position, and the recursion that would have found the rest is pruned. Nothing about the answer looks wrong; it is short.

A dirty bitmap under-reports because the per-query state is not per-query: documents reported by an earlier query are skipped by a later one. Again, nothing looks wrong.

Neither is detectable from the output, because a document listing’s output is a set with no size anybody knows in advance. That is what makes them worth catching in a gate rather than in review, and it is why both are in this collection as checks that must fail rather than as comments about care.

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 10 that link here.

The objects this essay names

Each one links to every other essay that touches it.

BitmapCheckDocument arrayDocument listingIndex sizeInvariantOutput-sensitivePrevious occurrence chainRange minimumRecursionWalk