The array below is shuffled. Use Step to watch Quickselect home in on the median (rank 8 out of 15 elements) one partition at a time. The pivot lands in its final position and the algorithm recurses into only the side that can contain the target rank.
<div class="controls">
<button id="btn-step" type="button">Step</button>
<button id="btn-auto" type="button">Auto</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span id="target-label" class="target-label">Finding: <b>median (rank 8)</b></span>
</div>
<div id="array-vis" class="array-vis"></div>
<div id="legend" class="legend">
<span class="leg-item"><span class="dot active"></span>Active region</span>
<span class="leg-item"><span class="dot pivot"></span>Pivot</span>
<span class="leg-item"><span class="dot lo"></span>≤ pivot</span>
<span class="leg-item"><span class="dot hi"></span>> pivot</span>
<span class="leg-item"><span class="dot found"></span>Found!</span>
</div>
<div id="status" class="status">Press <b>Step</b> to begin.</div>
<div id="cost-bar" class="cost-bar">
<div class="cost-label">Elements touched: <span id="touched-count">0</span> / <span id="total-count">15</span></div>
<div class="bar-track"><div id="bar-fill" class="bar-fill"></div></div>
<div class="cost-note" id="sort-note">Full sort would touch all <span id="sort-n">15</span> × log₂(15) ≈ <span id="sort-cost">59</span></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .8rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.target-label { font-size: .85rem; color: #555; margin-left: .4rem; }
.array-vis { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: .7rem; align-items: flex-end; }
.cell { display: flex; flex-direction: column; align-items: center; }
.bar { width: 28px; border-radius: 4px 4px 0 0; transition: height .25s, background .25s; }
.val { font: 700 11px ui-monospace, monospace; margin-top: 2px; }
/* color states */
.bar.inactive { background: #d1d5db; }
.bar.active { background: #93c5fd; }
.bar.pivot { background: #f59e0b; }
.bar.lo { background: #6ee7b7; }
.bar.hi { background: #fca5a5; }
.bar.found { background: #22c55e; }
.legend { display: flex; gap: .8rem; flex-wrap: wrap; font-size: .78rem; color: #555; margin-bottom: .5rem; }
.leg-item { display: flex; align-items: center; gap: 4px; }
.dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
.dot.active { background: #93c5fd; }
.dot.pivot { background: #f59e0b; }
.dot.lo { background: #6ee7b7; }
.dot.hi { background: #fca5a5; }
.dot.found { background: #22c55e; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.5em; margin-bottom: .5rem; }
.status.ok { color: #15803d; }
.cost-bar { background: #f1f5f9; border-radius: 8px; padding: .6rem .8rem; }
.cost-label { font-size: .82rem; color: #334155; margin-bottom: .3rem; }
.bar-track { background: #cbd5e1; border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { background: #3b82f6; height: 100%; width: 0; border-radius: 4px; transition: width .3s; }
.cost-note { font-size: .78rem; color: #64748b; margin-top: .3rem; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Quickselect demo: find the median (rank k=8 out of 15 elements, 0-indexed rank 7)
// in expected O(n) time. We use Lomuto partition for clarity.
const N = 15;
const TARGET_RANK = 7; // 0-indexed; median of 15 = index 7
// Deterministic shuffle so the demo starts the same each load (Fisher-Yates with seed).
function seededRng(seed) {
let s = seed;
return function() { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 4294967296; };
}
function shuffle(arr, rng) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
let arr, lo, hi, touchedTotal, state, done, autoTimer;
// state encodes the stage within a single recursive call:
// { lo, hi, pivotIdx, partIdx, k }
// partIdx tracks where Lomuto partition is up to
let stepStack; // stack of pending recursive calls: [{lo, hi, k}]
let currentCall; // the call we are currently partitioning
function init() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
arr = Array.from({length: N}, (_, i) => i + 1);
const rng = seededRng(42);
shuffle(arr, rng);
lo = 0; hi = N - 1;
touchedTotal = 0;
done = false;
stepStack = [{ lo: 0, hi: N - 1, k: TARGET_RANK }];
currentCall = null;
state = 'idle'; // 'idle' | 'partitioning' | 'done'
render('idle');
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-auto').disabled = false;
}
// colorMap[i] = 'inactive' | 'active' | 'pivot' | 'lo' | 'hi' | 'found'
function buildColorMap(partInfo) {
const colors = Array(N).fill('inactive');
if (!partInfo) {
if (stepStack.length > 0) {
const top = stepStack[stepStack.length - 1];
for (let i = top.lo; i <= top.hi; i++) colors[i] = 'active';
}
return colors;
}
const { lo: plo, hi: phi, pivotPos, scanIdx, partSplit } = partInfo;
for (let i = plo; i <= phi; i++) {
if (i === pivotPos) colors[i] = 'pivot';
else if (partSplit !== undefined && i < partSplit) colors[i] = 'lo';
else if (scanIdx !== undefined && i <= scanIdx && i !== pivotPos) colors[i] = 'hi';
else colors[i] = 'active';
}
return colors;
}
function render(statusText, statusClass, partInfo, foundIdx) {
const vis = document.getElementById('array-vis');
const maxVal = N;
const maxH = 90;
const colors = foundIdx !== undefined
? Array(N).fill('inactive').map((_, i) => i === foundIdx ? 'found' : 'inactive')
: buildColorMap(partInfo);
vis.innerHTML = '';
arr.forEach((v, i) => {
const cell = document.createElement('div'); cell.className = 'cell';
const bar = document.createElement('div');
bar.className = 'bar ' + (colors[i] || 'inactive');
bar.style.height = Math.round((v / maxVal) * maxH) + 'px';
const lbl = document.createElement('div'); lbl.className = 'val'; lbl.textContent = v;
cell.appendChild(bar); cell.appendChild(lbl);
vis.appendChild(cell);
});
const s = document.getElementById('status');
s.innerHTML = statusText || '';
s.className = 'status ' + (statusClass || '');
const touched = document.getElementById('touched-count');
touched.textContent = touchedTotal;
const fill = document.getElementById('bar-fill');
fill.style.width = Math.min(100, Math.round(touchedTotal / N * 100)) + '%';
}
// Returns the final pivot index after Lomuto partition of arr[plo..phi]
// using the last element as pivot. Mutates arr.
function lomutoPartition(plo, phi) {
const pivot = arr[phi];
let i = plo - 1;
for (let j = plo; j < phi; j++) {
touchedTotal++;
if (arr[j] <= pivot) { i++; [arr[i], arr[j]] = [arr[j], arr[i]]; }
}
[arr[i + 1], arr[phi]] = [arr[phi], arr[i + 1]];
return i + 1;
}
function step() {
if (done) return;
// If no current call is in progress, pop the stack.
if (currentCall === null) {
if (stepStack.length === 0) { done = true; return; }
currentCall = stepStack.pop();
if (currentCall.lo >= currentCall.hi) {
// Single element: it must be the answer.
touchedTotal++;
done = true;
render(fmt('<span class="ok">Found median = %v% at index %lo% in %touchedTotal% comparisons!</span>', { v: arr[currentCall.lo], lo: currentCall.lo, touchedTotal: touchedTotal }), 'ok', null, currentCall.lo);
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-auto').disabled = true;
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
return;
}
render(fmt('Partitioning active region [%lo%…%hi%] — pivot = %v%', { lo: currentCall.lo, hi: currentCall.hi, v: arr[currentCall.hi] }), '',
{ lo: currentCall.lo, hi: currentCall.hi, pivotPos: currentCall.hi });
return;
}
// Execute the partition for currentCall.
const { lo: plo, hi: phi, k } = currentCall;
touchedTotal += (phi - plo + 1);
const pivotIdx = lomutoPartition(plo, phi);
if (pivotIdx === k) {
done = true;
render(fmt('<span class="ok">Found median = %v% at index %pivotIdx% — took %touchedTotal% total comparisons!</span>', { v: arr[pivotIdx], pivotIdx: pivotIdx, touchedTotal: touchedTotal }), 'ok', null, pivotIdx);
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-auto').disabled = true;
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
currentCall = null;
return;
} else if (k < pivotIdx) {
stepStack.push({ lo: plo, hi: pivotIdx - 1, k });
render(fmt('Pivot %v% landed at index %pivotIdx%. Target rank %k%%v2% is in the LEFT half [%plo%…%v3%]. Right side discarded.', { v: arr[pivotIdx], pivotIdx: pivotIdx, k: k, v2: 1, plo: plo, v3: pivotIdx-1 }), '',
{ lo: plo, hi: pivotIdx - 1, pivotPos: pivotIdx });
} else {
stepStack.push({ lo: pivotIdx + 1, hi: phi, k });
render(fmt('Pivot %v% landed at index %pivotIdx%. Target rank %k%%v2% is in the RIGHT half [%pivotIdx%%v2%…%phi%]. Left side discarded.', { v: arr[pivotIdx], pivotIdx: pivotIdx, k: k, v2: 1, phi: phi }), '',
{ lo: pivotIdx + 1, hi: phi, pivotPos: pivotIdx });
}
currentCall = null;
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-auto').addEventListener('click', function() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; this.textContent = 'Auto'; return; }
this.textContent = 'Pause';
autoTimer = setInterval(() => { if (done) { clearInterval(autoTimer); autoTimer = null; document.getElementById('btn-auto').textContent = 'Auto'; return; } step(); }, 900);
});
document.getElementById('btn-reset').addEventListener('click', () => { document.getElementById('btn-auto').textContent = 'Auto'; init(); });
// init cost bar labels
document.getElementById('total-count').textContent = N;
document.getElementById('sort-n').textContent = N;
document.getElementById('sort-cost').textContent = Math.round(N * Math.log2(N));
init();
<div class="controls">
<button id="btn-step" type="button">Step</button>
<button id="btn-auto" type="button">Auto</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span id="target-label" class="target-label">Finding: <b>median (rank 8)</b></span>
</div>
<div id="array-vis" class="array-vis"></div>
<div id="legend" class="legend">
<span class="leg-item"><span class="dot active"></span>Active region</span>
<span class="leg-item"><span class="dot pivot"></span>Pivot</span>
<span class="leg-item"><span class="dot lo"></span>≤ pivot</span>
<span class="leg-item"><span class="dot hi"></span>> pivot</span>
<span class="leg-item"><span class="dot found"></span>Found!</span>
</div>
<div id="status" class="status">Press <b>Step</b> to begin.</div>
<div id="cost-bar" class="cost-bar">
<div class="cost-label">Elements touched: <span id="touched-count">0</span> / <span id="total-count">15</span></div>
<div class="bar-track"><div id="bar-fill" class="bar-fill"></div></div>
<div class="cost-note" id="sort-note">Full sort would touch all <span id="sort-n">15</span> × log₂(15) ≈ <span id="sort-cost">59</span></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .8rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.target-label { font-size: .85rem; color: #555; margin-left: .4rem; }
.array-vis { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: .7rem; align-items: flex-end; }
.cell { display: flex; flex-direction: column; align-items: center; }
.bar { width: 28px; border-radius: 4px 4px 0 0; transition: height .25s, background .25s; }
.val { font: 700 11px ui-monospace, monospace; margin-top: 2px; }
/* color states */
.bar.inactive { background: #d1d5db; }
.bar.active { background: #93c5fd; }
.bar.pivot { background: #f59e0b; }
.bar.lo { background: #6ee7b7; }
.bar.hi { background: #fca5a5; }
.bar.found { background: #22c55e; }
.legend { display: flex; gap: .8rem; flex-wrap: wrap; font-size: .78rem; color: #555; margin-bottom: .5rem; }
.leg-item { display: flex; align-items: center; gap: 4px; }
.dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
.dot.active { background: #93c5fd; }
.dot.pivot { background: #f59e0b; }
.dot.lo { background: #6ee7b7; }
.dot.hi { background: #fca5a5; }
.dot.found { background: #22c55e; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.5em; margin-bottom: .5rem; }
.status.ok { color: #15803d; }
.cost-bar { background: #f1f5f9; border-radius: 8px; padding: .6rem .8rem; }
.cost-label { font-size: .82rem; color: #334155; margin-bottom: .3rem; }
.bar-track { background: #cbd5e1; border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { background: #3b82f6; height: 100%; width: 0; border-radius: 4px; transition: width .3s; }
.cost-note { font-size: .78rem; color: #64748b; margin-top: .3rem; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Quickselect demo: find the median (rank k=8 out of 15 elements, 0-indexed rank 7)
// in expected O(n) time. We use Lomuto partition for clarity.
const N = 15;
const TARGET_RANK = 7; // 0-indexed; median of 15 = index 7
// Deterministic shuffle so the demo starts the same each load (Fisher-Yates with seed).
function seededRng(seed) {
let s = seed;
return function() { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 4294967296; };
}
function shuffle(arr, rng) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
let arr, lo, hi, touchedTotal, state, done, autoTimer;
// state encodes the stage within a single recursive call:
// { lo, hi, pivotIdx, partIdx, k }
// partIdx tracks where Lomuto partition is up to
let stepStack; // stack of pending recursive calls: [{lo, hi, k}]
let currentCall; // the call we are currently partitioning
function init() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
arr = Array.from({length: N}, (_, i) => i + 1);
const rng = seededRng(42);
shuffle(arr, rng);
lo = 0; hi = N - 1;
touchedTotal = 0;
done = false;
stepStack = [{ lo: 0, hi: N - 1, k: TARGET_RANK }];
currentCall = null;
state = 'idle'; // 'idle' | 'partitioning' | 'done'
render('idle');
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-auto').disabled = false;
}
// colorMap[i] = 'inactive' | 'active' | 'pivot' | 'lo' | 'hi' | 'found'
function buildColorMap(partInfo) {
const colors = Array(N).fill('inactive');
if (!partInfo) {
if (stepStack.length > 0) {
const top = stepStack[stepStack.length - 1];
for (let i = top.lo; i <= top.hi; i++) colors[i] = 'active';
}
return colors;
}
const { lo: plo, hi: phi, pivotPos, scanIdx, partSplit } = partInfo;
for (let i = plo; i <= phi; i++) {
if (i === pivotPos) colors[i] = 'pivot';
else if (partSplit !== undefined && i < partSplit) colors[i] = 'lo';
else if (scanIdx !== undefined && i <= scanIdx && i !== pivotPos) colors[i] = 'hi';
else colors[i] = 'active';
}
return colors;
}
function render(statusText, statusClass, partInfo, foundIdx) {
const vis = document.getElementById('array-vis');
const maxVal = N;
const maxH = 90;
const colors = foundIdx !== undefined
? Array(N).fill('inactive').map((_, i) => i === foundIdx ? 'found' : 'inactive')
: buildColorMap(partInfo);
vis.innerHTML = '';
arr.forEach((v, i) => {
const cell = document.createElement('div'); cell.className = 'cell';
const bar = document.createElement('div');
bar.className = 'bar ' + (colors[i] || 'inactive');
bar.style.height = Math.round((v / maxVal) * maxH) + 'px';
const lbl = document.createElement('div'); lbl.className = 'val'; lbl.textContent = v;
cell.appendChild(bar); cell.appendChild(lbl);
vis.appendChild(cell);
});
const s = document.getElementById('status');
s.innerHTML = statusText || '';
s.className = 'status ' + (statusClass || '');
const touched = document.getElementById('touched-count');
touched.textContent = touchedTotal;
const fill = document.getElementById('bar-fill');
fill.style.width = Math.min(100, Math.round(touchedTotal / N * 100)) + '%';
}
// Returns the final pivot index after Lomuto partition of arr[plo..phi]
// using the last element as pivot. Mutates arr.
function lomutoPartition(plo, phi) {
const pivot = arr[phi];
let i = plo - 1;
for (let j = plo; j < phi; j++) {
touchedTotal++;
if (arr[j] <= pivot) { i++; [arr[i], arr[j]] = [arr[j], arr[i]]; }
}
[arr[i + 1], arr[phi]] = [arr[phi], arr[i + 1]];
return i + 1;
}
function step() {
if (done) return;
// If no current call is in progress, pop the stack.
if (currentCall === null) {
if (stepStack.length === 0) { done = true; return; }
currentCall = stepStack.pop();
if (currentCall.lo >= currentCall.hi) {
// Single element: it must be the answer.
touchedTotal++;
done = true;
render(fmt('<span class="ok">Found median = %v% at index %lo% in %touchedTotal% comparisons!</span>', { v: arr[currentCall.lo], lo: currentCall.lo, touchedTotal: touchedTotal }), 'ok', null, currentCall.lo);
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-auto').disabled = true;
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
return;
}
render(fmt('Partitioning active region [%lo%…%hi%] — pivot = %v%', { lo: currentCall.lo, hi: currentCall.hi, v: arr[currentCall.hi] }), '',
{ lo: currentCall.lo, hi: currentCall.hi, pivotPos: currentCall.hi });
return;
}
// Execute the partition for currentCall.
const { lo: plo, hi: phi, k } = currentCall;
touchedTotal += (phi - plo + 1);
const pivotIdx = lomutoPartition(plo, phi);
if (pivotIdx === k) {
done = true;
render(fmt('<span class="ok">Found median = %v% at index %pivotIdx% — took %touchedTotal% total comparisons!</span>', { v: arr[pivotIdx], pivotIdx: pivotIdx, touchedTotal: touchedTotal }), 'ok', null, pivotIdx);
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-auto').disabled = true;
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
currentCall = null;
return;
} else if (k < pivotIdx) {
stepStack.push({ lo: plo, hi: pivotIdx - 1, k });
render(fmt('Pivot %v% landed at index %pivotIdx%. Target rank %k%%v2% is in the LEFT half [%plo%…%v3%]. Right side discarded.', { v: arr[pivotIdx], pivotIdx: pivotIdx, k: k, v2: 1, plo: plo, v3: pivotIdx-1 }), '',
{ lo: plo, hi: pivotIdx - 1, pivotPos: pivotIdx });
} else {
stepStack.push({ lo: pivotIdx + 1, hi: phi, k });
render(fmt('Pivot %v% landed at index %pivotIdx%. Target rank %k%%v2% is in the RIGHT half [%pivotIdx%%v2%…%phi%]. Left side discarded.', { v: arr[pivotIdx], pivotIdx: pivotIdx, k: k, v2: 1, phi: phi }), '',
{ lo: pivotIdx + 1, hi: phi, pivotPos: pivotIdx });
}
currentCall = null;
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-auto').addEventListener('click', function() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; this.textContent = 'Auto'; return; }
this.textContent = 'Pause';
autoTimer = setInterval(() => { if (done) { clearInterval(autoTimer); autoTimer = null; document.getElementById('btn-auto').textContent = 'Auto'; return; } step(); }, 900);
});
document.getElementById('btn-reset').addEventListener('click', () => { document.getElementById('btn-auto').textContent = 'Auto'; init(); });
// init cost bar labels
document.getElementById('total-count').textContent = N;
document.getElementById('sort-n').textContent = N;
document.getElementById('sort-cost').textContent = Math.round(N * Math.log2(N));
init();
Comments
Loading comments...