Cairn
algorithms · string matching · a faster build for Suffix Array, not a new search · O(n log² n) here (comparator sort each round)

↩ back to Exact Match

Suffix Array Construction (Prefix Doubling)

Suffix Array's own Pitfalls section names a real, measured trap: sorting suffixes by slicing and comparing full strings costs up to O(n) character work per comparison, and on repetitive text that makes the whole sort O(n² log n) instead of the O(n log n) a suffix array construction is supposed to cost. This page builds the fix that page's own text names but doesn't build: instead of re-reading characters from scratch on every comparison, prefix doubling keeps a rank table that already knows, from the previous round, how every suffix compares up to some prefix length — and doubles that known length each round by combining two already-computed ranks instead of rescanning any text. The output is the identical suffix array Suffix Array's own binary search relies on; only how it gets built changes.

Try it

Enter a text (lowercase letters, up to 12 characters) and press Build, then press Step to walk through one round at a time. Each round doubles k — the number of characters' worth of order already known — by sorting on the pair (rank[i], rank[i+k]): the first half of the comparison is what the previous round already settled, the second half is one round-k-ago rank lookup instead of a fresh character scan. The two checkboxes reproduce this page's two Pitfalls live — uncheck one to see the final order come out wrong, flagged in red against an independently sorted reference order computed fresh every time.

press Build to start
Press Build, then Step through the rounds.

Why it works

Call a suffix's 2r-rank its position if every suffix were sorted only by its first 2r characters (ties allowed for suffixes that agree that far). Round r of this algorithm — parameterized by k = 2r-1 — computes the 2r-rank from the previous round's already-correct k-rank, by induction: suffix i's first k characters are already ordered by rank[i], and its next k characters — text positions i+k through i+2k-1 — are exactly suffix i+k's own first k characters, already ordered by rank[i+k]. Sorting the pair (rank[i], rank[i+k]) is therefore sorting by the true first 2k characters, without ever touching the text itself. A suffix with fewer than k characters left before i+k has no real "next chunk" at all — it's a proper prefix of whatever's sorted near it, and a proper prefix must sort as strictly smaller than anything it prefixes (exactly matching how plain string comparison treats "aa" as less than "aaa"), so that missing chunk needs a key below every real rank, not a stand-in for one. Once every suffix's rank is unique, no further round can change anything — the order is already the full suffix array — which is why the demo above stops as soon as that happens rather than always running ⌈log&sub2; n⌉ rounds.

That early stop means the round count actually taken depends on the text, not just its length: a string with no repeated characters at all can finish in a single round (every suffix is already distinguished by its first character), while n copies of the same character is the genuine worst case, needing the full ⌈log&sub2; n⌉ rounds because no two suffixes separate until the doubling window finally exceeds the distance between them.

Reference implementation

This is the exact scheme the demo above steps through:

function buildSuffixArrayDoubling(text) {
  const n = text.length;
  const chars = Array.from(new Set(text)).sort();
  const rankOf = new Map(chars.map((c, i) => [c, i]));
  let rank = Array.from(text, c => rankOf.get(c));   // round 0: rank by one character
  let sa = Array.from({ length: n }, (_, i) => i);

  for (let k = 1; ; k *= 2) {
    const key2 = i => (i + k < n) ? rank[i + k] : -1;  // -1: "nothing here" sorts lowest
    sa = sa.slice().sort((a, b) =>
      rank[a] !== rank[b] ? rank[a] - rank[b] : key2(a) - key2(b));

    const newRank = new Array(n);
    newRank[sa[0]] = 0;
    for (let i = 1; i < n; i++) {
      const tied = rank[sa[i - 1]] === rank[sa[i]] && key2(sa[i - 1]) === key2(sa[i]);
      newRank[sa[i]] = newRank[sa[i - 1]] + (tied ? 0 : 1);   // dedupe: ties keep one rank
    }
    rank = newRank;

    if (rank[sa[n - 1]] === n - 1) break;   // every rank now unique -- done
    if (k >= n) break;                       // safety net, same conclusion either way
  }
  return sa;
}

Pitfalls

Skipping rank deduplication — assigning each suffix its sorted position as the new rank, instead of giving tied suffixes the same rank — locks in a wrong order after the very first round and never fixes it. On "aaa": round 1 correctly sorts the three suffixes by their first two characters, discovering "a" is smallest but leaving "aa" and "aaa" tied (both start "aa"). The correct rule keeps that tie — both get rank 1 — so round 2 can still tell them apart using their third character's worth of information. Skipping the dedupe instead hands them the arbitrary ranks 1 and 2 straight from sort position, even though nothing about their content actually differs yet. Every later round sorts primarily by that already-all-distinct rank, so the tiebreak that should have waited for real evidence is now permanent — round 2's correct insight (a suffix that runs out of characters, like "aa" does, must sort below one that doesn't, like "aaa") never gets consulted, because the primary key alone already decided the order. The demo's default "mississippi" shows this isn't just a degenerate all-one-character case: with deduplication off it produces [10,7,1,4,0,9,8,3,6,2,5] against the correct [10,7,4,1,0,9,8,6,3,5,2] — three pairs of positions swapped. Checked, not just reasoned about: across 30,000 random (length 2–20, alphabet size 1–4) trials against an independently sorted reference order, this variant disagrees on 79.7% of them, while the correct algorithm above matched that same reference on all 30,000.

Treating a suffix that runs past the end of the text as rank 0, instead of as categorically lower than every real rank, ties it against whichever real content happens to already hold rank 0 — and loses whenever that tie is broken the wrong way. Rank 0 is earned by some actual character or chunk; a suffix with nothing left to compare isn't tied with that content, it's a proper prefix of whatever it's being measured against; and a proper prefix must sort strictly below any string it prefixes, the same way plain string comparison already treats "baab" as less than "baabaab". On "baabaab", the correct order ends …,6,3,0 (index 3, the suffix "baab", sorts just before index 0, the suffix "baabaab" it's a prefix of); treating the missing chunk as rank 0 instead of a true sentinel swaps that pair to …,6,0,3. Measured the same way: 38.4% of 30,000 trials disagree with the reference order. Try baabaab above with the second box unchecked to see this exact swap live.

Complexity

Time: as shipped, each round is one comparator-based Array.sort over all n suffixes — O(n log n) comparisons, each O(1) since a comparison only ever reads two already-computed ranks, never the text itself — followed by one O(n) linear pass to assign new ranks. With up to O(log n) rounds, that's O(n log² n) total, not the O(n log n) textbook figure Suffix Array's own page cites for "prefix doubling" — that bound needs a radix/counting sort within each round (each key is a small integer pair, not requiring general comparison) to make every round O(n) instead of O(n log n); not shipped here, for the same reason the naive sort was shipped on Suffix Array's own page instead of SA-IS — clarity of one recognizable step (Array.sort) over a specialized one. Measured directly against that page's own repeated-character worst case (n copies of 'a', the exact input its Pitfalls section uses): at n = 200 / 400 / 800 this page's construction takes 1,772 / 3,829 / 8,287 total comparator calls against that page's own measured 20,099 / 80,199 / 320,399 character comparisons on the identical input — each doubling of n multiplies this page's cost by roughly 2.16× (the n log n signature) against the naive sort's roughly 4× (the n² signature), a 38.7× gap already open by n = 800. That gap is specific to repetitive text, though, not universal: on random text at the same sizes this page's comparator-call count sits within a small constant factor of the naive sort's character-comparison count either way — measured at roughly 0.9–1.3× across repeated trials at n = 50 to 800, with no consistent winner — because a naive comparison on random text usually diverges within the first character or two and stops early, while every round here always pays a full O(n log n) sort regardless of how quickly real suffixes would diverge. The advantage this page exists for is specifically bounding the worst case, not beating the common one. Space: O(n) for the rank array and each round's freshly sorted index array, the same footprint Suffix Array's own page already keeps for the array itself.

The resulting array, and everything Suffix Array's own binary search does with it, is unchanged — this page is a faster path to the same structure, not a different one. See Choosing an Exact-Match String Matcher for how Suffix Array itself weighs against the other exact-match entries; this page is a refinement on that comparison; not a new branch in it.