Type any short word below. The demo builds its suffix array with a clean O(nlog2n) prefix-doubling sort (fast enough to follow by eye), then highlights the LCP — how many characters each consecutive pair of sorted suffixes shares.
<p class="hint">Type a word (up to 20 chars). The demo sorts every suffix lexicographically, shows the suffix array (SA) and the longest-common-prefix array (LCP). No suffix tree — pure comparison-based doubling sort.</p>
<div class="input-row">
<input id="word" type="text" maxlength="20" value="banana" autocomplete="off" spellcheck="false" />
<button id="run" type="button">Build SA</button>
</div>
<div id="output" class="output"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.input-row { display: flex; gap: .5rem; margin-bottom: .9rem; }
#word { font: 700 16px ui-monospace, monospace; padding: .4rem .6rem;
border: 1px solid #adb1b8; border-radius: 8px; width: 180px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
.output { overflow-x: auto; }
table { border-collapse: collapse; font-size: .88rem; min-width: 340px; }
th { background: #1d3557; color: #fff; padding: .3rem .7rem; text-align: left; }
td { padding: .28rem .7rem; border-bottom: 1px solid #e2e8f0; }
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: #f4f7fa; }
.sa-idx { font-weight: 700; color: #1d3557; font-family: ui-monospace, monospace; }
.lcp-val { font-family: ui-monospace, monospace; color: #0a7d33; }
.lcp-zero { color: #aaa; }
.suffix-text { font-family: ui-monospace, monospace; }
.suffix-shared { background: #d0f0de; border-radius: 3px; padding: 0 1px; }
// Suffix array via O(n log^2 n) prefix-doubling (Manber & Myers style).
// Each round doubles the key length; we use JavaScript sort (O(n log n)) per round.
// Total: O(n log^2 n) — correct, legible, easy to verify.
function buildSA(s) {
const n = s.length;
if (n === 0) return [];
// Initial rank = char code
let rank = Array.from(s, c => c.charCodeAt(0));
let sa = Array.from({length: n}, (_, i) => i);
let tmp = new Array(n);
for (let gap = 1; gap < n; gap *= 2) {
// Comparator: rank by (rank[i], rank[i+gap])
const r = rank.slice(); // snapshot
sa.sort((a, b) => {
if (r[a] !== r[b]) return r[a] - r[b];
const ra = a + gap < n ? r[a + gap] : -1;
const rb = b + gap < n ? r[b + gap] : -1;
return ra - rb;
});
// Rebuild ranks
tmp[sa[0]] = 0;
for (let i = 1; i < n; i++) {
const prev = sa[i - 1], cur = sa[i];
const same = r[prev] === r[cur] &&
(prev + gap < n ? r[prev + gap] : -1) === (cur + gap < n ? r[cur + gap] : -1);
tmp[cur] = tmp[prev] + (same ? 0 : 1);
}
rank = tmp.slice();
if (rank[sa[n - 1]] === n - 1) break; // all ranks distinct — done early
}
return sa;
}
// Kasai's O(n) LCP array algorithm.
function buildLCP(s, sa) {
const n = s.length;
const rank = new Array(n);
for (let i = 0; i < n; i++) rank[sa[i]] = i;
const lcp = new Array(n).fill(0);
let h = 0;
for (let i = 0; i < n; i++) {
if (rank[i] > 0) {
const j = sa[rank[i] - 1];
while (i + h < n && j + h < n && s[i + h] === s[j + h]) h++;
lcp[rank[i]] = h;
if (h > 0) h--;
}
}
return lcp; // lcp[0] = 0 by convention
}
function render() {
const raw = document.getElementById('word').value.trim();
const s = raw || 'banana';
const n = s.length;
const sa = buildSA(s);
const lcp = buildLCP(s, sa);
let html = '<table><thead><tr><th>#</th><th>SA[i]</th><th>LCP</th><th>Suffix</th></tr></thead><tbody>';
for (let i = 0; i < n; i++) {
const start = sa[i];
const suffix = s.slice(start);
const lcpVal = lcp[i];
// Highlight the shared prefix with the previous suffix
const shared = suffix.slice(0, lcpVal);
const rest = suffix.slice(lcpVal);
const suffixHtml = lcpVal > 0
? '<span class="suffix-shared">' + shared + '</span>' + rest
: suffix;
const lcpClass = lcpVal === 0 ? 'lcp-zero' : 'lcp-val';
const lcpText = i === 0 ? '<span class="lcp-zero">—</span>' :
'<span class="' + lcpClass + '">' + lcpVal + '</span>';
html += '<tr>' +
'<td class="sa-idx">' + i + '</td>' +
'<td class="sa-idx">' + start + '</td>' +
'<td>' + lcpText + '</td>' +
'<td class="suffix-text">' + suffixHtml + '</td>' +
'</tr>';
}
html += '</tbody></table>';
document.getElementById('output').innerHTML = html;
// Log for verification
console.log('SA:', sa.join(','), '| LCP:', lcp.join(','));
}
document.getElementById('run').addEventListener('click', render);
document.getElementById('word').addEventListener('keydown', e => { if (e.key === 'Enter') render(); });
render();
<p class="hint">Type a word (up to 20 chars). The demo sorts every suffix lexicographically, shows the suffix array (SA) and the longest-common-prefix array (LCP). No suffix tree — pure comparison-based doubling sort.</p>
<div class="input-row">
<input id="word" type="text" maxlength="20" value="banana" autocomplete="off" spellcheck="false" />
<button id="run" type="button">Build SA</button>
</div>
<div id="output" class="output"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.input-row { display: flex; gap: .5rem; margin-bottom: .9rem; }
#word { font: 700 16px ui-monospace, monospace; padding: .4rem .6rem;
border: 1px solid #adb1b8; border-radius: 8px; width: 180px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
.output { overflow-x: auto; }
table { border-collapse: collapse; font-size: .88rem; min-width: 340px; }
th { background: #1d3557; color: #fff; padding: .3rem .7rem; text-align: left; }
td { padding: .28rem .7rem; border-bottom: 1px solid #e2e8f0; }
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: #f4f7fa; }
.sa-idx { font-weight: 700; color: #1d3557; font-family: ui-monospace, monospace; }
.lcp-val { font-family: ui-monospace, monospace; color: #0a7d33; }
.lcp-zero { color: #aaa; }
.suffix-text { font-family: ui-monospace, monospace; }
.suffix-shared { background: #d0f0de; border-radius: 3px; padding: 0 1px; }
// Suffix array via O(n log^2 n) prefix-doubling (Manber & Myers style).
// Each round doubles the key length; we use JavaScript sort (O(n log n)) per round.
// Total: O(n log^2 n) — correct, legible, easy to verify.
function buildSA(s) {
const n = s.length;
if (n === 0) return [];
// Initial rank = char code
let rank = Array.from(s, c => c.charCodeAt(0));
let sa = Array.from({length: n}, (_, i) => i);
let tmp = new Array(n);
for (let gap = 1; gap < n; gap *= 2) {
// Comparator: rank by (rank[i], rank[i+gap])
const r = rank.slice(); // snapshot
sa.sort((a, b) => {
if (r[a] !== r[b]) return r[a] - r[b];
const ra = a + gap < n ? r[a + gap] : -1;
const rb = b + gap < n ? r[b + gap] : -1;
return ra - rb;
});
// Rebuild ranks
tmp[sa[0]] = 0;
for (let i = 1; i < n; i++) {
const prev = sa[i - 1], cur = sa[i];
const same = r[prev] === r[cur] &&
(prev + gap < n ? r[prev + gap] : -1) === (cur + gap < n ? r[cur + gap] : -1);
tmp[cur] = tmp[prev] + (same ? 0 : 1);
}
rank = tmp.slice();
if (rank[sa[n - 1]] === n - 1) break; // all ranks distinct — done early
}
return sa;
}
// Kasai's O(n) LCP array algorithm.
function buildLCP(s, sa) {
const n = s.length;
const rank = new Array(n);
for (let i = 0; i < n; i++) rank[sa[i]] = i;
const lcp = new Array(n).fill(0);
let h = 0;
for (let i = 0; i < n; i++) {
if (rank[i] > 0) {
const j = sa[rank[i] - 1];
while (i + h < n && j + h < n && s[i + h] === s[j + h]) h++;
lcp[rank[i]] = h;
if (h > 0) h--;
}
}
return lcp; // lcp[0] = 0 by convention
}
function render() {
const raw = document.getElementById('word').value.trim();
const s = raw || 'banana';
const n = s.length;
const sa = buildSA(s);
const lcp = buildLCP(s, sa);
let html = '<table><thead><tr><th>#</th><th>SA[i]</th><th>LCP</th><th>Suffix</th></tr></thead><tbody>';
for (let i = 0; i < n; i++) {
const start = sa[i];
const suffix = s.slice(start);
const lcpVal = lcp[i];
// Highlight the shared prefix with the previous suffix
const shared = suffix.slice(0, lcpVal);
const rest = suffix.slice(lcpVal);
const suffixHtml = lcpVal > 0
? '<span class="suffix-shared">' + shared + '</span>' + rest
: suffix;
const lcpClass = lcpVal === 0 ? 'lcp-zero' : 'lcp-val';
const lcpText = i === 0 ? '<span class="lcp-zero">—</span>' :
'<span class="' + lcpClass + '">' + lcpVal + '</span>';
html += '<tr>' +
'<td class="sa-idx">' + i + '</td>' +
'<td class="sa-idx">' + start + '</td>' +
'<td>' + lcpText + '</td>' +
'<td class="suffix-text">' + suffixHtml + '</td>' +
'</tr>';
}
html += '</tbody></table>';
document.getElementById('output').innerHTML = html;
// Log for verification
console.log('SA:', sa.join(','), '| LCP:', lcp.join(','));
}
document.getElementById('run').addEventListener('click', render);
document.getElementById('word').addEventListener('keydown', e => { if (e.key === 'Enter') render(); });
render();
Notice how the LCP values cluster near long repeated substrings. The sum of all LCP values equals the total number of shared prefix characters across consecutive sorted suffixes — a quantity that drives compression and the pattern matching algorithms built on suffix arrays.
Comments
Loading comments...