<div class="controls">
<label>Set size <b id="nLabel">8</b>
<input type="range" id="nSlider" min="4" max="20" value="8" />
</label>
<button id="runBtn" type="button">Collect all</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="summary" id="summary"></div>
<div class="chart-wrap">
<canvas id="chart"></canvas>
</div>
<div class="formula" id="formula"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .9rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; accent-color: #1d3557; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.summary { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin-bottom: .4rem; }
.summary.ok { color: #0a7d33; }
.chart-wrap { width: 100%; overflow-x: auto; }
canvas { display: block; }
.formula { font-size: .85rem; color: #444; margin-top: .5rem; line-height: 1.5; }
.formula b { color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Coupon Collector simulation.
// n = set size. Draw uniformly at random from {0..n-1} until all seen.
// Track draws_needed[k] = draws to get the k-th new distinct coupon.
// Expected draws for k-th new = n / (n - k + 1). Total = n * H(n).
const slider = document.getElementById('nSlider');
const nLabel = document.getElementById('nLabel');
const runBtn = document.getElementById('runBtn');
const resetBtn = document.getElementById('resetBtn');
const summary = document.getElementById('summary');
const formulaDiv = document.getElementById('formula');
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function harmonic(n) {
let h = 0;
for (let i = 1; i <= n; i++) h += 1 / i;
return h;
}
function simulate(n) {
const seen = new Set();
const drawsPerNew = []; // drawsPerNew[k] = draws to get (k+1)-th new coupon
let drawCount = 0;
let drawsSinceLastNew = 0;
while (seen.size < n) {
const coupon = Math.floor(Math.random() * n);
drawCount++;
drawsSinceLastNew++;
if (!seen.has(coupon)) {
seen.add(coupon);
drawsPerNew.push(drawsSinceLastNew);
drawsSinceLastNew = 0;
}
}
return { total: drawCount, drawsPerNew };
}
function drawChart(n, drawsPerNew) {
const BAR_W = 28;
const GAP = 4;
const PAD_L = 36;
const PAD_B = 28;
const PAD_T = 14;
const width = PAD_L + n * (BAR_W + GAP) + GAP + 20;
const maxVal = Math.max(...drawsPerNew, 1);
const chartH = 160;
canvas.width = width;
canvas.height = chartH + PAD_B + PAD_T;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// axes
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_L, PAD_T);
ctx.lineTo(PAD_L, PAD_T + chartH);
ctx.lineTo(PAD_L + n * (BAR_W + GAP), PAD_T + chartH);
ctx.stroke();
// expected line: E[k-th new] = n / (n - k + 1)
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath();
for (let k = 1; k <= n; k++) {
const expected = n / (n - k + 1);
const x = PAD_L + (k - 1) * (BAR_W + GAP) + BAR_W / 2;
const y = PAD_T + chartH - (expected / maxVal) * chartH;
k === 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]);
// bars
for (let k = 0; k < n; k++) {
const val = drawsPerNew[k];
const barH = (val / maxVal) * chartH;
const x = PAD_L + k * (BAR_W + GAP) + GAP / 2;
const y = PAD_T + chartH - barH;
// gradient: early coupons blue, late coupons orange
const t = k / (n - 1);
const r = Math.round(29 + t * (230 - 29));
const g = Math.round(53 + t * (100 - 53));
const b = Math.round(87 + t * (20 - 87));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.roundRect(x, y, BAR_W, barH, [3, 3, 0, 0]);
ctx.fill();
// value label on top of bar if fits
if (barH > 14) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(val, x + BAR_W / 2, y + 11);
}
// x-axis label
ctx.fillStyle = '#666';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(k + 1, x + BAR_W / 2, PAD_T + chartH + 14);
}
// y-axis ticks
ctx.fillStyle = '#888';
ctx.font = '10px system-ui';
ctx.textAlign = 'right';
const ticks = [1, Math.round(maxVal / 2), maxVal];
for (const t of ticks) {
const y = PAD_T + chartH - (t / maxVal) * chartH;
ctx.fillText(t, PAD_L - 4, y + 3);
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_L, y);
ctx.lineTo(PAD_L + n * (BAR_W + GAP), y);
ctx.stroke();
}
// legend
ctx.setLineDash([4, 3]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(PAD_L + 2, PAD_T + chartH - 6);
ctx.lineTo(PAD_L + 22, PAD_T + chartH - 6);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946';
ctx.font = '10px system-ui';
ctx.textAlign = 'left';
ctx.fillText('Expected', PAD_L + 24, PAD_T + chartH - 3);
}
function run() {
const n = parseInt(slider.value, 10);
const { total, drawsPerNew } = simulate(n);
const expected = n * harmonic(n);
const ratio = (total / expected).toFixed(2);
summary.textContent = fmt('Total draws: %total% (formula predicts %round%, ratio %ratio%)', { total: total, round: Math.round(expected), ratio: ratio });
summary.className = 'summary ok';
drawChart(n, drawsPerNew);
const gamma = 0.5772;
formulaDiv.innerHTML =
fmt('<b>Formula:</b> E[draws] = n·H(n) ≈ n·ln(n) + γ·n<br>For n = n=%n%: n·H(n) = <b>%expected%</b>, this run used <b>%total%</b>.<br>Bar chart: draws needed for each new coupon (blue = early, orange = late).', { n: n, expected: expected.toFixed(1), total: total });
console.log(fmt('result n=%n% total=%total% expected=%expected% ratio=%ratio%', { n: n, total: total, expected: expected.toFixed(2), ratio: ratio }));
}
function reset() {
summary.textContent = '';
summary.className = 'summary';
formulaDiv.innerHTML = '';
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
slider.addEventListener('input', () => { nLabel.textContent = slider.value; reset(); });
runBtn.addEventListener('click', run);
resetBtn.addEventListener('click', reset);
// draw initial blank chart axes
const initN = parseInt(slider.value, 10);
canvas.width = 36 + initN * 32 + 20;
canvas.height = 202;
<div class="controls">
<label>Set size <b id="nLabel">8</b>
<input type="range" id="nSlider" min="4" max="20" value="8" />
</label>
<button id="runBtn" type="button">Collect all</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="summary" id="summary"></div>
<div class="chart-wrap">
<canvas id="chart"></canvas>
</div>
<div class="formula" id="formula"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .9rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; accent-color: #1d3557; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.summary { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin-bottom: .4rem; }
.summary.ok { color: #0a7d33; }
.chart-wrap { width: 100%; overflow-x: auto; }
canvas { display: block; }
.formula { font-size: .85rem; color: #444; margin-top: .5rem; line-height: 1.5; }
.formula b { color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Coupon Collector simulation.
// n = set size. Draw uniformly at random from {0..n-1} until all seen.
// Track draws_needed[k] = draws to get the k-th new distinct coupon.
// Expected draws for k-th new = n / (n - k + 1). Total = n * H(n).
const slider = document.getElementById('nSlider');
const nLabel = document.getElementById('nLabel');
const runBtn = document.getElementById('runBtn');
const resetBtn = document.getElementById('resetBtn');
const summary = document.getElementById('summary');
const formulaDiv = document.getElementById('formula');
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function harmonic(n) {
let h = 0;
for (let i = 1; i <= n; i++) h += 1 / i;
return h;
}
function simulate(n) {
const seen = new Set();
const drawsPerNew = []; // drawsPerNew[k] = draws to get (k+1)-th new coupon
let drawCount = 0;
let drawsSinceLastNew = 0;
while (seen.size < n) {
const coupon = Math.floor(Math.random() * n);
drawCount++;
drawsSinceLastNew++;
if (!seen.has(coupon)) {
seen.add(coupon);
drawsPerNew.push(drawsSinceLastNew);
drawsSinceLastNew = 0;
}
}
return { total: drawCount, drawsPerNew };
}
function drawChart(n, drawsPerNew) {
const BAR_W = 28;
const GAP = 4;
const PAD_L = 36;
const PAD_B = 28;
const PAD_T = 14;
const width = PAD_L + n * (BAR_W + GAP) + GAP + 20;
const maxVal = Math.max(...drawsPerNew, 1);
const chartH = 160;
canvas.width = width;
canvas.height = chartH + PAD_B + PAD_T;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// axes
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_L, PAD_T);
ctx.lineTo(PAD_L, PAD_T + chartH);
ctx.lineTo(PAD_L + n * (BAR_W + GAP), PAD_T + chartH);
ctx.stroke();
// expected line: E[k-th new] = n / (n - k + 1)
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath();
for (let k = 1; k <= n; k++) {
const expected = n / (n - k + 1);
const x = PAD_L + (k - 1) * (BAR_W + GAP) + BAR_W / 2;
const y = PAD_T + chartH - (expected / maxVal) * chartH;
k === 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]);
// bars
for (let k = 0; k < n; k++) {
const val = drawsPerNew[k];
const barH = (val / maxVal) * chartH;
const x = PAD_L + k * (BAR_W + GAP) + GAP / 2;
const y = PAD_T + chartH - barH;
// gradient: early coupons blue, late coupons orange
const t = k / (n - 1);
const r = Math.round(29 + t * (230 - 29));
const g = Math.round(53 + t * (100 - 53));
const b = Math.round(87 + t * (20 - 87));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.roundRect(x, y, BAR_W, barH, [3, 3, 0, 0]);
ctx.fill();
// value label on top of bar if fits
if (barH > 14) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(val, x + BAR_W / 2, y + 11);
}
// x-axis label
ctx.fillStyle = '#666';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.fillText(k + 1, x + BAR_W / 2, PAD_T + chartH + 14);
}
// y-axis ticks
ctx.fillStyle = '#888';
ctx.font = '10px system-ui';
ctx.textAlign = 'right';
const ticks = [1, Math.round(maxVal / 2), maxVal];
for (const t of ticks) {
const y = PAD_T + chartH - (t / maxVal) * chartH;
ctx.fillText(t, PAD_L - 4, y + 3);
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_L, y);
ctx.lineTo(PAD_L + n * (BAR_W + GAP), y);
ctx.stroke();
}
// legend
ctx.setLineDash([4, 3]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(PAD_L + 2, PAD_T + chartH - 6);
ctx.lineTo(PAD_L + 22, PAD_T + chartH - 6);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946';
ctx.font = '10px system-ui';
ctx.textAlign = 'left';
ctx.fillText('Expected', PAD_L + 24, PAD_T + chartH - 3);
}
function run() {
const n = parseInt(slider.value, 10);
const { total, drawsPerNew } = simulate(n);
const expected = n * harmonic(n);
const ratio = (total / expected).toFixed(2);
summary.textContent = fmt('Total draws: %total% (formula predicts %round%, ratio %ratio%)', { total: total, round: Math.round(expected), ratio: ratio });
summary.className = 'summary ok';
drawChart(n, drawsPerNew);
const gamma = 0.5772;
formulaDiv.innerHTML =
fmt('<b>Formula:</b> E[draws] = n·H(n) ≈ n·ln(n) + γ·n<br>For n = n=%n%: n·H(n) = <b>%expected%</b>, this run used <b>%total%</b>.<br>Bar chart: draws needed for each new coupon (blue = early, orange = late).', { n: n, expected: expected.toFixed(1), total: total });
console.log(fmt('result n=%n% total=%total% expected=%expected% ratio=%ratio%', { n: n, total: total, expected: expected.toFixed(2), ratio: ratio }));
}
function reset() {
summary.textContent = '';
summary.className = 'summary';
formulaDiv.innerHTML = '';
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
slider.addEventListener('input', () => { nLabel.textContent = slider.value; reset(); });
runBtn.addEventListener('click', run);
resetBtn.addEventListener('click', reset);
// draw initial blank chart axes
const initN = parseInt(slider.value, 10);
canvas.width = 36 + initN * 32 + 20;
canvas.height = 202;
Notice the pattern: the first coupon always takes exactly 1 draw. The second takes on average n−1n. The k-th new coupon takes on average n−k+1n draws. Add them all up and you get n⋅H(n) — the harmonic series in disguise. Press Collect all several times to see the randomness, then compare the actual total with the formula's prediction.
Comments
Loading comments...