<p class="hint">Type a word and choose a maximum edit distance <b>k</b>. The Levenshtein automaton runs each dictionary word through the NFA and lists every match instantly.</p>
<div class="controls">
<input id="query" type="text" value="kitten" autocomplete="off" spellcheck="false" placeholder="Type a word…" />
<label>k =
<select id="k">
<option value="0">0</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</label>
</div>
<div id="info" class="info"></div>
<div id="results" class="results"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
input[type=text] { font: 15px system-ui, sans-serif; padding: .4rem .7rem; border: 1px solid #adb1b8;
border-radius: 8px; width: 200px; }
select { font: 15px system-ui, sans-serif; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 8px; }
label { font-size: .95rem; display: flex; align-items: center; gap: .35rem; }
.info { font-size: .88rem; color: #555; margin-bottom: .5rem; min-height: 1.3em; }
.results { display: flex; flex-wrap: wrap; gap: .35rem; max-height: 220px; overflow-y: auto; }
.chip { font: 14px ui-monospace, monospace; padding: .25rem .6rem; border-radius: 6px;
background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.chip.exact { background: #0a7d33; color: #fff; border-color: #085e27; }
.chip.near { background: #1d3557; color: #fff; border-color: #152540; }
.chip.far { background: #c9ccd1; color: #333; border-color: #adb1b8; }
.none { font-size: .95rem; color: #888; font-style: italic; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Levenshtein Automaton (NFA simulation via bit-parallel row update).
// The NFA has states (i, e): position i in query, e errors so far.
// We represent the active NFA state-set as a row of the DP matrix and
// advance it character by character through the input word.
//
// editDistance(a, b) — classic DP, O(|a|*|b|)
function editDistance(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m + 1}, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0];
dp[0] = j;
for (let i = 1; i <= m; i++) {
const tmp = dp[i];
dp[i] = b[j-1] === a[i-1]
? prev
: 1 + Math.min(prev, dp[i], dp[i-1]);
prev = tmp;
}
}
return dp[m];
}
// levenshteinAccepts(query, word, k) — runs word through the NFA for (query, k).
// Uses the row-by-row DP approach: dp[i] = min edits to match query[0..i-1]
// against word[0..j-1]. Accept iff dp[m] <= k after consuming all of word.
// Also handles deletions (epsilon transitions) via a forward pass after each char.
function levenshteinAccepts(query, word, k) {
const m = query.length;
// dp[i] = edit distance between query[0..i-1] and the prefix of word consumed so far
let dp = Array.from({length: m + 1}, (_, i) => i);
// Clamp to k+1 (anything beyond k is "too far")
dp = dp.map(v => Math.min(v, k + 1));
for (let j = 0; j < word.length; j++) {
const c = word[j];
const next = new Array(m + 1);
next[0] = dp[0] + 1;
for (let i = 1; i <= m; i++) {
const sub = dp[i-1] + (query[i-1] === c ? 0 : 1);
const del = dp[i] + 1;
const ins = next[i-1] + 1;
next[i] = Math.min(sub, del, ins, k + 1);
}
dp = next;
// Early exit: if every state exceeds k, no path can recover
if (Math.min(...dp) > k) return false;
}
return dp[m] <= k;
}
// Dictionary: 120 common English words
const DICT = [
'kitten','sitting','kitchen','mitten','bitten','written','smitten','kipper',
'bitter','litter','glitter','twitter','knitter','kite','kit','kid','kin',
'king','ring','sing','sting','string','spring','bring','thing','think',
'thin','than','that','chat','cat','bat','hat','rat','mat','sat','fat',
'flat','flab','flag','flaw','claw','clay','play','plan','clan','can',
'van','ban','bin','bit','sit','hit','fit','fix','six','mix','max',
'map','cap','cup','cut','but','bus','bug','bun','sun','run','rut',
'nut','net','set','jet','yet','yep','yes','bay','day','say','pay',
'way','war','car','bar','far','fan','fun','gun','gut','guy',
'gym','gem','get','got','god','good','food','mood','moon','soon',
'spoon','tooth','booth','book','look','cook','hook','took','wood',
'wool','cool','pool','fool','tool','school','rule','role','pole',
'hole','mole','sole','sale','tale','pale','male','vale','gale'
];
function run() {
const query = document.getElementById('query').value.toLowerCase().trim();
const k = parseInt(document.getElementById('k').value, 10);
const resultsEl = document.getElementById('results');
const infoEl = document.getElementById('info');
resultsEl.innerHTML = '';
if (!query) { infoEl.textContent = 'Type a word to start.'; return; }
const matches = [];
for (const word of DICT) {
const d = editDistance(query, word);
if (levenshteinAccepts(query, word, k)) {
matches.push({ word, d });
}
}
matches.sort((a, b) => a.d - b.d || a.word.localeCompare(b.word));
infoEl.textContent =
matches.length === 0
? 'No matches — try a larger k.'
: fmt('%length% match(es) in %length2%-word dictionary (k = %k%).', { length: matches.length, length2: DICT.length, k: k });
if (matches.length === 0) {
resultsEl.innerHTML = '<span class="none">No words within that distance.</span>';
return;
}
for (const { word, d } of matches) {
const chip = document.createElement('span');
chip.className = 'chip ' + (d === 0 ? 'exact' : d === 1 ? 'near' : 'far');
chip.title = fmt('edit distance %d%', { d: d });
chip.textContent = word + (d > 0 ? ` (+${d})` : '');
resultsEl.appendChild(chip);
}
console.log('Query:', query, '| k:', k, '| matches:', matches.map(m => m.word + ':' + m.d).join(', '));
}
document.getElementById('query').addEventListener('input', run);
document.getElementById('k').addEventListener('change', run);
run();
<p class="hint">Type a word and choose a maximum edit distance <b>k</b>. The Levenshtein automaton runs each dictionary word through the NFA and lists every match instantly.</p>
<div class="controls">
<input id="query" type="text" value="kitten" autocomplete="off" spellcheck="false" placeholder="Type a word…" />
<label>k =
<select id="k">
<option value="0">0</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</label>
</div>
<div id="info" class="info"></div>
<div id="results" class="results"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
input[type=text] { font: 15px system-ui, sans-serif; padding: .4rem .7rem; border: 1px solid #adb1b8;
border-radius: 8px; width: 200px; }
select { font: 15px system-ui, sans-serif; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 8px; }
label { font-size: .95rem; display: flex; align-items: center; gap: .35rem; }
.info { font-size: .88rem; color: #555; margin-bottom: .5rem; min-height: 1.3em; }
.results { display: flex; flex-wrap: wrap; gap: .35rem; max-height: 220px; overflow-y: auto; }
.chip { font: 14px ui-monospace, monospace; padding: .25rem .6rem; border-radius: 6px;
background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.chip.exact { background: #0a7d33; color: #fff; border-color: #085e27; }
.chip.near { background: #1d3557; color: #fff; border-color: #152540; }
.chip.far { background: #c9ccd1; color: #333; border-color: #adb1b8; }
.none { font-size: .95rem; color: #888; font-style: italic; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Levenshtein Automaton (NFA simulation via bit-parallel row update).
// The NFA has states (i, e): position i in query, e errors so far.
// We represent the active NFA state-set as a row of the DP matrix and
// advance it character by character through the input word.
//
// editDistance(a, b) — classic DP, O(|a|*|b|)
function editDistance(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m + 1}, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0];
dp[0] = j;
for (let i = 1; i <= m; i++) {
const tmp = dp[i];
dp[i] = b[j-1] === a[i-1]
? prev
: 1 + Math.min(prev, dp[i], dp[i-1]);
prev = tmp;
}
}
return dp[m];
}
// levenshteinAccepts(query, word, k) — runs word through the NFA for (query, k).
// Uses the row-by-row DP approach: dp[i] = min edits to match query[0..i-1]
// against word[0..j-1]. Accept iff dp[m] <= k after consuming all of word.
// Also handles deletions (epsilon transitions) via a forward pass after each char.
function levenshteinAccepts(query, word, k) {
const m = query.length;
// dp[i] = edit distance between query[0..i-1] and the prefix of word consumed so far
let dp = Array.from({length: m + 1}, (_, i) => i);
// Clamp to k+1 (anything beyond k is "too far")
dp = dp.map(v => Math.min(v, k + 1));
for (let j = 0; j < word.length; j++) {
const c = word[j];
const next = new Array(m + 1);
next[0] = dp[0] + 1;
for (let i = 1; i <= m; i++) {
const sub = dp[i-1] + (query[i-1] === c ? 0 : 1);
const del = dp[i] + 1;
const ins = next[i-1] + 1;
next[i] = Math.min(sub, del, ins, k + 1);
}
dp = next;
// Early exit: if every state exceeds k, no path can recover
if (Math.min(...dp) > k) return false;
}
return dp[m] <= k;
}
// Dictionary: 120 common English words
const DICT = [
'kitten','sitting','kitchen','mitten','bitten','written','smitten','kipper',
'bitter','litter','glitter','twitter','knitter','kite','kit','kid','kin',
'king','ring','sing','sting','string','spring','bring','thing','think',
'thin','than','that','chat','cat','bat','hat','rat','mat','sat','fat',
'flat','flab','flag','flaw','claw','clay','play','plan','clan','can',
'van','ban','bin','bit','sit','hit','fit','fix','six','mix','max',
'map','cap','cup','cut','but','bus','bug','bun','sun','run','rut',
'nut','net','set','jet','yet','yep','yes','bay','day','say','pay',
'way','war','car','bar','far','fan','fun','gun','gut','guy',
'gym','gem','get','got','god','good','food','mood','moon','soon',
'spoon','tooth','booth','book','look','cook','hook','took','wood',
'wool','cool','pool','fool','tool','school','rule','role','pole',
'hole','mole','sole','sale','tale','pale','male','vale','gale'
];
function run() {
const query = document.getElementById('query').value.toLowerCase().trim();
const k = parseInt(document.getElementById('k').value, 10);
const resultsEl = document.getElementById('results');
const infoEl = document.getElementById('info');
resultsEl.innerHTML = '';
if (!query) { infoEl.textContent = 'Type a word to start.'; return; }
const matches = [];
for (const word of DICT) {
const d = editDistance(query, word);
if (levenshteinAccepts(query, word, k)) {
matches.push({ word, d });
}
}
matches.sort((a, b) => a.d - b.d || a.word.localeCompare(b.word));
infoEl.textContent =
matches.length === 0
? 'No matches — try a larger k.'
: fmt('%length% match(es) in %length2%-word dictionary (k = %k%).', { length: matches.length, length2: DICT.length, k: k });
if (matches.length === 0) {
resultsEl.innerHTML = '<span class="none">No words within that distance.</span>';
return;
}
for (const { word, d } of matches) {
const chip = document.createElement('span');
chip.className = 'chip ' + (d === 0 ? 'exact' : d === 1 ? 'near' : 'far');
chip.title = fmt('edit distance %d%', { d: d });
chip.textContent = word + (d > 0 ? ` (+${d})` : '');
resultsEl.appendChild(chip);
}
console.log('Query:', query, '| k:', k, '| matches:', matches.map(m => m.word + ':' + m.d).join(', '));
}
document.getElementById('query').addEventListener('input', run);
document.getElementById('k').addEventListener('change', run);
run();
Comments
Loading comments...