Cairn
algorithms · string matching · a linear-time build for Suffix Array, not a new search · O(n) time, O(n) space

↩ back to Exact Match

Suffix Array Induced Sorting (SA-IS)

Suffix Array Construction (Prefix Doubling)'s own closing paragraph names the gap this page closes: prefix doubling gets suffix array construction down to O(n log² n) by comparing rank pairs instead of raw text, but "SA-IS's stronger O(n) bound is a further step past that one, still not built here." SA-IS (Suffix Array by Induced Sorting, Nong/Zhang/Chen 2009) gets there with a different mechanism entirely: classify every suffix as one of two types, use that classification to induce the position of most suffixes from a small subset's order rather than comparing anything directly, and recurse on a genuinely smaller problem only when that subset's own order isn't yet fully known. The output is the identical array Suffix Array's own binary search relies on, built faster still than prefix doubling — not a new way to search once built.

Try it

Enter a text (lowercase letters, up to 12 characters) and press Build, then press Step to walk through every phase: classifying each suffix's type, seeding and inducing a first guess at the LMS order, naming the LMS substrings, recursing if needed, then seeding and inducing again with the now-correct order. The default, mississippi, is the classic example specifically because it needs that recursion — try banana alongside it to see a text where the first naming pass already settles everything. The two checkboxes reproduce this page's two Pitfalls live.

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

Why it works

Call a suffix S-type if it's lexicographically smaller than the suffix starting one position to its right, L-type if it's larger. That's decidable without ever comparing full suffixes: the very last position (a sentinel, unique and smaller than every real character) is always S-type, and every other position compares its own character against the next position's — a strict inequality decides it outright, and a tie inherits whatever the next position already resolved to. A position is called LMS (leftmost S) if it's S-type but the position immediately before it is L-type — the left edge of a run of S's. On mississippi the run breaks down into L/S stretches with LMS positions at the starts of issi, issi (again), ippi, and the trailing sentinel — the repeated issi is exactly what will force this example into recursion below.

The genuinely surprising part is induced sorting: place the LMS positions into their character buckets in any order at all, then scan the array left to right — whenever a placed suffix's predecessor is L-type, that predecessor's correct bucket slot is now decidable purely from what's already been placed, so drop it in. Do the same scanning right to left for S-type predecessors. After both passes, the LMS positions land back out of the array in their true relative order by LMS substring — not because the initial guess was good, but because every non-LMS suffix's position was always fully determined by its neighbor's, and the induction just walks that dependency chain to a fixed point regardless of where it started. That's the one claim on this page taken from the source paper's proof rather than re-derived here; verified empirically instead, across every random trial below.

"True relative order by LMS substring" is a specific, narrower claim than "true suffix order" — an LMS substring runs from one LMS position to the next inclusive, and two LMS suffixes whose substrings are identical still need their full suffixes told apart, which the substring comparison alone can't do. So each LMS substring gets a name — walk the induced order, and any two adjacent LMS substrings that compare unequal (checked character by character, requiring both to hit their own LMS boundary at the same offset to count as equal — not just agreeing for a few characters) get a new name; identical ones share it. If every name is already distinct, the induced order already is the true LMS suffix order, nothing left to prove. If not — as happens to both copies of issi in mississippi, whose LMS substrings are identical — build a smaller string out of just those names, in their original left-to-right positions, and recurse: solving that string's suffix array (the same algorithm, one level down) is exactly solving "which of these tied LMS suffixes actually comes first," since two suffixes of the reduced string disagree in exactly the order their corresponding LMS suffixes do. The reduced string is provably at most half the original length (no two LMS positions are adjacent, by definition), which is what makes the recursion terminate in O(log n) levels and the whole algorithm sum to O(n) rather than blow up.

Either way — names already unique, or resolved by recursion — the true LMS order is now known. Seed the buckets again, this time with that correct order instead of an arbitrary one, and run the exact same two induce passes a second time. Nothing about induction changes; what changed is that the seed is now provably correct everywhere, not just for LMS substrings, so this second pass places every suffix in the array correctly, LMS and non-LMS alike.

Reference implementation

This is the exact scheme the demo above steps through, with the recursion written directly rather than unrolled:

function suffixArrayInducedSorting(text) {
  const codes = [...text].map(c => c.charCodeAt(0) - 96); // 'a'=1 .. 'z'=26
  codes.push(0);                                          // unique sentinel, smaller than everything
  return buildSA(codes, 27).slice(1);                     // drop the sentinel-only suffix

  function buildSA(s, K) {
    const n = s.length;
    if (n === 1) return [0];

    // classify each suffix S-type (smaller than the suffix one to its right) or L-type (larger)
    const isS = new Array(n);
    isS[n - 1] = true;                                      // the sentinel is always S-type
    for (let i = n - 2; i >= 0; i--) {
      isS[i] = s[i] < s[i + 1] || (s[i] === s[i + 1] && isS[i + 1]);
    }
    const isLMS = i => i > 0 && isS[i] && !isS[i - 1];       // leftmost S in a run of S's

    const sizes = new Array(K).fill(0);
    for (const c of s) sizes[c]++;
    const bucketHeads = () => { const h = new Array(K); let sum = 0; for (let c = 0; c < K; c++) { h[c] = sum; sum += sizes[c]; } return h; };
    const bucketTails = () => { const t = new Array(K); let sum = 0; for (let c = 0; c < K; c++) { sum += sizes[c]; t[c] = sum; } return t; };

    function seedAndInduce(lmsOrder) {
      const SA = new Array(n).fill(-1);
      const tails = bucketTails();
      for (let i = lmsOrder.length - 1; i >= 0; i--) { const j = lmsOrder[i]; SA[--tails[s[j]]] = j; }
      const heads = bucketHeads();
      for (let i = 0; i < n; i++) { const j = SA[i] - 1; if (j >= 0 && !isS[j]) SA[heads[s[j]]++] = j; }
      const tails2 = bucketTails();
      for (let i = n - 1; i >= 0; i--) { const j = SA[i] - 1; if (j >= 0 && isS[j]) SA[--tails2[s[j]]] = j; }
      return SA;
    }

    const lmsPositions = [];
    for (let i = 1; i < n; i++) if (isLMS(i)) lmsPositions.push(i);

    // pass 1: an arbitrary initial LMS order still induces the correct *relative* order of LMS substrings
    let SA = seedAndInduce(lmsPositions);
    const sortedLMS = [];
    for (let i = 0; i < n; i++) if (isLMS(SA[i])) sortedLMS.push(SA[i]);

    // name each LMS substring; identical substrings get identical names
    const names = new Array(n).fill(-1);
    let name = 0;
    names[sortedLMS[0]] = 0;
    for (let i = 1; i < sortedLMS.length; i++) {
      const prev = sortedLMS[i - 1], cur = sortedLMS[i];
      let diff = false, d = 0;
      while (true) {
        const prevEnd = d > 0 && isLMS(prev + d), curEnd = d > 0 && isLMS(cur + d);
        if (prevEnd || curEnd) { diff = !(prevEnd && curEnd); break; }
        if (s[prev + d] !== s[cur + d] || isS[prev + d] !== isS[cur + d]) { diff = true; break; }
        d++;
      }
      if (diff) name++;
      names[cur] = name;
    }

    // recurse only if some LMS substrings collided under naming; otherwise names are already the answer
    let sortedLMSFinal;
    if (name + 1 === sortedLMS.length) {
      sortedLMSFinal = sortedLMS;
    } else {
      const reduced = lmsPositions.map(p => names[p]);
      const reducedSA = buildSA(reduced, name + 1);
      sortedLMSFinal = reducedSA.map(idx => lmsPositions[idx]);
    }

    // pass 2: seed with the now-*correct* LMS order and induce again -- every suffix lands correctly
    return seedAndInduce(sortedLMSFinal);
  }
}

Verified against an independently sorted reference order (plain string comparison over every suffix) across 100,000 random trials (length 1–20, alphabet size 1–26, weighted toward small alphabets to force real collisions and real recursion) — 0 mismatches, including mississippi and every other example on this page.

Pitfalls

Naming LMS substrings by comparing only a fixed number of characters, instead of requiring both substrings to reach their own LMS boundary at the same offset to count as equal, treats one substring as a match for a longer one it's merely a prefix of. Two LMS substrings of different lengths can agree on their first several characters and then genuinely diverge — a fixed lookahead window can run out before that divergence and call them equal when they're not, silently under-recursing on a problem that needed the distinction. On bababab, comparing only a bounded window gives [5,3,1,6,4,0,2] against the correct [5,3,1,6,4,2,0] — the last pair 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 2.2% of them — real, but the rarest-triggering bug measured on this site's exact-match entries so far, since a fixed window has to land exactly between two substrings' point of divergence to matter. Try bababab above with the first box unchecked to see this exact swap live.

Classifying a tied character as L-type outright, instead of inheriting whatever the following suffix's type already resolved to, breaks the one invariant every later phase assumes: that S-type genuinely means "smaller." The tie-inherit rule exists because a character comparison alone can't settle a tie — only what comes after can — and skipping it doesn't just misclassify one position, it can misclassify entire runs at once, since the bug also compounds through the same backward scan that would otherwise propagate the correct answer. On caac, the correct build finishes [1,2,3,0]; misclassifying ties leaves one array slot never filled at all — [1,2,3,-1] — because a suffix silently lost LMS status it should have had, so nothing ever gets seeded into that bucket to induce from. Checked the same way: 40.6% of the same 30,000 trials disagree with the reference order (a higher rate than the first bug, since every tie anywhere in the text is a chance to trigger it, not just one specific boundary). Try caac above with the second box unchecked to see the unfilled slot live.

Complexity

Time: O(n) — each level of recursion does O(nlevel) work (classification, two induce passes, and naming are each one linear scan), and the reduced string at the next level is provably at most half the current length, so the levels' total sizes form a geometric series summing to O(n) overall, regardless of how many levels recursion actually needs. Measured directly, counting every array read/write across all recursion levels combined: on Suffix Array's own n = 200 / 400 / 800 / 1,600 / 3,200 repeated-'a' worst case, this page takes 2,008 / 4,008 / 8,008 / 16,008 / 32,008 operations — exactly 2.00× per doubling, every single time, the unmistakable signature of linear growth (this input needs no recursion at all — the sentinel is the only LMS position — so it's actually this page's cheapest case, not its worst one, unlike the naive sort it makes obsolete). On random 26-letter text at the same sizes: 2,520 / 5,946 / 11,957 / 24,072 / 48,319, converging to the same ~2.01× per doubling once past small-n constant overhead. Against Suffix Array Construction (Prefix Doubling)'s own measured 1,772 / 3,829 / 8,287 comparator calls at n = 200/400/800 on that identical repeated-character input, the two pages are close at this scale — SA-IS's 2,008/4,008/8,008 is actually a bit higher at n=200 and roughly even by n=800 — but prefix doubling grows at a measured ~2.16× per doubling (the log² n factor, still slowly widening) against SA-IS's flat 2.00×: a real gap, just one that needs considerably larger n than this demo's 12-character limit to look dramatic rather than marginal. Space: O(n) total — the per-level arrays (isS, bucket tables, the SA array itself) again form the same halving geometric series as the time bound, plus O(n) stack depth across O(log n) recursive calls.

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, exactly as Suffix Array Construction (Prefix Doubling) already is. See Choosing an Exact-Match String Matcher for how Suffix Array itself weighs against the other exact-match entries; this page is a further refinement on that same comparison, not a new branch in it.