Four advertisers each have one ad slot. Queries arrive one at a time and you must assign each to a relevant, still-free advertiser — or skip it. You can't go back. Try to match as many as you can.
<p class="hint">Four advertisers (A B C D) each have one ad slot. Search queries arrive <b>one at a time</b> and must be assigned <b>immediately</b> to a relevant, still-free advertiser — or skipped forever. You are the matcher. Maximize matches, then compare with the hindsight optimum and the RANKING algorithm.</p>
<div class="slots" id="slots"></div>
<div class="arrival" id="arrival">Press <b>Start</b> to receive the first query.</div>
<div class="choices" id="choices"></div>
<div class="score" id="score">Your matches: 0</div>
<div class="btns">
<button id="start" type="button">Start / Reset</button>
<button id="opt" type="button" class="ghost">Show hindsight optimum</button>
<button id="rank" type="button" class="ghost">Run RANKING ×2000</button>
</div>
<div class="status" id="status"></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 .7rem; line-height: 1.45; }
.slots { display: flex; gap: 8px; margin: .4rem 0 .8rem; flex-wrap: wrap; }
.slot { width: 64px; height: 56px; border-radius: 10px; border: 1px solid #cdd9e3;
background: #e8eef3; display: flex; flex-direction: column; align-items: center;
justify-content: center; font: 700 18px ui-monospace, monospace; color: #1d3557; }
.slot small { font: 600 10px system-ui; color: #5a7088; margin-top: 2px; }
.slot.taken { background: #1d3557; color: #fff; border-color: #11243d; }
.slot.taken small { color: #9fb2c8; }
.arrival { font-size: 1rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.choices { display: flex; gap: .4rem; flex-wrap: wrap; margin: .5rem 0; }
.choices button { font: 700 14px ui-monospace, monospace; }
.score { font-size: 1rem; font-weight: 700; margin: .5rem 0; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
.status { font-size: .95rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; line-height: 1.4; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Online bipartite matching. Advertisers A,B,C,D each have one slot. Queries arrive one at a time; each lists the advertisers it's relevant to and must be matched on arrival (or skipped). The hindsight optimum (offline) can rearrange everything; the online matcher cannot see the future. RANKING fixes a RANDOM rank over the advertisers and always matches an arriving query to its lowest-ranked free option; in expectation it matches at least (1 - 1/e) ≈ 63% of the optimum (Karp, Vazirani & Vazirani, 1990) — the best any online algorithm can do.
const ADV = ['A','B','C','D']; const nR = 4;
// adj[query] = relevant advertiser indices. q1,q2 are broad; q3 needs A, q4 needs B.
const ADJ = [[0,1,2,3],[0,1,2,3],[0],[1]];
const nL = ADJ.length;
// Offline hindsight optimum via augmenting paths (Kuhn's algorithm).
function maxMatching(adj){
const matchR = new Array(nR).fill(-1);
function tryK(l, seen){ for(const r of adj[l]){ if(seen[r]) continue; seen[r]=true;
if(matchR[r]===-1 || tryK(matchR[r], seen)){ matchR[r]=l; return true; } } return false; }
let c=0; for(let l=0;l<nL;l++){ const s=new Array(nR).fill(false); if(tryK(l,s)) c++; } return c;
}
// RANKING: lowest-ranked free relevant advertiser.
function ranking(rank){ const used=new Array(nR).fill(false); let c=0;
for(let l=0;l<nL;l++){ let b=-1; for(const r of ADJ[l]) if(!used[r] && (b===-1||rank[r]<rank[b])) b=r;
if(b!==-1){ used[b]=true; c++; } } return c; }
let step, used, matched;
const $ = id => document.getElementById(id);
function drawSlots(){ const s=$('slots'); s.innerHTML='';
ADV.forEach((a,i)=>{ const d=document.createElement('div');
d.className='slot'+(used[i]?' taken':'');
d.innerHTML = a + '<small>'+(used[i]?'matched':'free')+'</small>'; s.appendChild(d); }); }
function setStatus(t,cls){ const s=$('status'); s.textContent=t; s.className='status '+(cls||''); }
function start(){ step=0; used=new Array(nR).fill(false); matched=0;
drawSlots(); $('score').textContent='Your matches: 0'; setStatus('',''); next(); }
function next(){ const ch=$('choices'); ch.innerHTML='';
if(step>=nL){ $('arrival').textContent='No more queries arrive.';
setStatus(fmt('You matched %matched% of %maxMatching% (hindsight optimum). Reveal it or run RANKING below.', { matched: matched, maxMatching: maxMatching(ADJ.map(a=>a.slice())) }), matched>=3?'ok':'bad'); return; }
const fits = ADJ[step];
$('arrival').textContent = fmt('Query q%step%%v% arrives — relevant to: %map%', { step: step, v: 1, map: fits.map(i=>ADV[i]).join(', ') });
fits.forEach(i=>{ const b=document.createElement('button');
b.textContent=fmt('Give to %v%', { v: ADV[i] }); b.disabled=used[i];
b.onclick=()=>{ used[i]=true; matched++; drawSlots();
$('score').textContent=fmt('Your matches: %matched%', { matched: matched }); step++; next(); }; ch.appendChild(b); });
const skip=document.createElement('button'); skip.className='ghost'; skip.textContent='Skip query';
skip.onclick=()=>{ step++; next(); }; ch.appendChild(skip);
}
function showOpt(){ const o=maxMatching(ADJ.map(a=>a.slice()));
setStatus(fmt('Hindsight optimum = %o% matches (q1→C, q2→D, q3→A, q4→B). A naive grab of A then B for the first two queries leaves q3 and q4 with nothing — only 2 matches.', { o: o }),
'ok'); console.log('Hindsight optimum =', o); }
function runRanking(){ const T=2000; let sum=0; const dist={};
for(let t=0;t<T;t++){ const perm=[0,1,2,3];
for(let i=3;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [perm[i],perm[j]]=[perm[j],perm[i]]; }
const rank=new Array(nR); perm.forEach((adv,pos)=>rank[adv]=pos);
const c=ranking(rank); sum+=c; dist[c]=(dist[c]||0)+1; }
const avg=sum/T, opt=maxMatching(ADJ.map(a=>a.slice()));
setStatus(fmt('RANKING over %T% random ranks: average %avg% of %opt% (%round%% of optimum), far above the (1−1/e)≈63% worst-case floor. Distribution: %map%', { T: T, avg: avg.toFixed(2), opt: opt, round: Math.round(100*avg/opt), map: Object.keys(dist).sort().map(k=>k+'→'+(100*dist[k]/T).toFixed(0)+'%').join(', ') }),'ok');
console.log('RANKING avg', avg.toFixed(3), 'opt', opt, 'dist', dist); }
$('start').onclick=start;
$('opt').onclick=showOpt;
$('rank').onclick=runRanking;
used=new Array(nR).fill(false); matched=0; step=nL; drawSlots();
<p class="hint">Four advertisers (A B C D) each have one ad slot. Search queries arrive <b>one at a time</b> and must be assigned <b>immediately</b> to a relevant, still-free advertiser — or skipped forever. You are the matcher. Maximize matches, then compare with the hindsight optimum and the RANKING algorithm.</p>
<div class="slots" id="slots"></div>
<div class="arrival" id="arrival">Press <b>Start</b> to receive the first query.</div>
<div class="choices" id="choices"></div>
<div class="score" id="score">Your matches: 0</div>
<div class="btns">
<button id="start" type="button">Start / Reset</button>
<button id="opt" type="button" class="ghost">Show hindsight optimum</button>
<button id="rank" type="button" class="ghost">Run RANKING ×2000</button>
</div>
<div class="status" id="status"></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 .7rem; line-height: 1.45; }
.slots { display: flex; gap: 8px; margin: .4rem 0 .8rem; flex-wrap: wrap; }
.slot { width: 64px; height: 56px; border-radius: 10px; border: 1px solid #cdd9e3;
background: #e8eef3; display: flex; flex-direction: column; align-items: center;
justify-content: center; font: 700 18px ui-monospace, monospace; color: #1d3557; }
.slot small { font: 600 10px system-ui; color: #5a7088; margin-top: 2px; }
.slot.taken { background: #1d3557; color: #fff; border-color: #11243d; }
.slot.taken small { color: #9fb2c8; }
.arrival { font-size: 1rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.choices { display: flex; gap: .4rem; flex-wrap: wrap; margin: .5rem 0; }
.choices button { font: 700 14px ui-monospace, monospace; }
.score { font-size: 1rem; font-weight: 700; margin: .5rem 0; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
.status { font-size: .95rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; line-height: 1.4; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Online bipartite matching. Advertisers A,B,C,D each have one slot. Queries arrive one at a time; each lists the advertisers it's relevant to and must be matched on arrival (or skipped). The hindsight optimum (offline) can rearrange everything; the online matcher cannot see the future. RANKING fixes a RANDOM rank over the advertisers and always matches an arriving query to its lowest-ranked free option; in expectation it matches at least (1 - 1/e) ≈ 63% of the optimum (Karp, Vazirani & Vazirani, 1990) — the best any online algorithm can do.
const ADV = ['A','B','C','D']; const nR = 4;
// adj[query] = relevant advertiser indices. q1,q2 are broad; q3 needs A, q4 needs B.
const ADJ = [[0,1,2,3],[0,1,2,3],[0],[1]];
const nL = ADJ.length;
// Offline hindsight optimum via augmenting paths (Kuhn's algorithm).
function maxMatching(adj){
const matchR = new Array(nR).fill(-1);
function tryK(l, seen){ for(const r of adj[l]){ if(seen[r]) continue; seen[r]=true;
if(matchR[r]===-1 || tryK(matchR[r], seen)){ matchR[r]=l; return true; } } return false; }
let c=0; for(let l=0;l<nL;l++){ const s=new Array(nR).fill(false); if(tryK(l,s)) c++; } return c;
}
// RANKING: lowest-ranked free relevant advertiser.
function ranking(rank){ const used=new Array(nR).fill(false); let c=0;
for(let l=0;l<nL;l++){ let b=-1; for(const r of ADJ[l]) if(!used[r] && (b===-1||rank[r]<rank[b])) b=r;
if(b!==-1){ used[b]=true; c++; } } return c; }
let step, used, matched;
const $ = id => document.getElementById(id);
function drawSlots(){ const s=$('slots'); s.innerHTML='';
ADV.forEach((a,i)=>{ const d=document.createElement('div');
d.className='slot'+(used[i]?' taken':'');
d.innerHTML = a + '<small>'+(used[i]?'matched':'free')+'</small>'; s.appendChild(d); }); }
function setStatus(t,cls){ const s=$('status'); s.textContent=t; s.className='status '+(cls||''); }
function start(){ step=0; used=new Array(nR).fill(false); matched=0;
drawSlots(); $('score').textContent='Your matches: 0'; setStatus('',''); next(); }
function next(){ const ch=$('choices'); ch.innerHTML='';
if(step>=nL){ $('arrival').textContent='No more queries arrive.';
setStatus(fmt('You matched %matched% of %maxMatching% (hindsight optimum). Reveal it or run RANKING below.', { matched: matched, maxMatching: maxMatching(ADJ.map(a=>a.slice())) }), matched>=3?'ok':'bad'); return; }
const fits = ADJ[step];
$('arrival').textContent = fmt('Query q%step%%v% arrives — relevant to: %map%', { step: step, v: 1, map: fits.map(i=>ADV[i]).join(', ') });
fits.forEach(i=>{ const b=document.createElement('button');
b.textContent=fmt('Give to %v%', { v: ADV[i] }); b.disabled=used[i];
b.onclick=()=>{ used[i]=true; matched++; drawSlots();
$('score').textContent=fmt('Your matches: %matched%', { matched: matched }); step++; next(); }; ch.appendChild(b); });
const skip=document.createElement('button'); skip.className='ghost'; skip.textContent='Skip query';
skip.onclick=()=>{ step++; next(); }; ch.appendChild(skip);
}
function showOpt(){ const o=maxMatching(ADJ.map(a=>a.slice()));
setStatus(fmt('Hindsight optimum = %o% matches (q1→C, q2→D, q3→A, q4→B). A naive grab of A then B for the first two queries leaves q3 and q4 with nothing — only 2 matches.', { o: o }),
'ok'); console.log('Hindsight optimum =', o); }
function runRanking(){ const T=2000; let sum=0; const dist={};
for(let t=0;t<T;t++){ const perm=[0,1,2,3];
for(let i=3;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [perm[i],perm[j]]=[perm[j],perm[i]]; }
const rank=new Array(nR); perm.forEach((adv,pos)=>rank[adv]=pos);
const c=ranking(rank); sum+=c; dist[c]=(dist[c]||0)+1; }
const avg=sum/T, opt=maxMatching(ADJ.map(a=>a.slice()));
setStatus(fmt('RANKING over %T% random ranks: average %avg% of %opt% (%round%% of optimum), far above the (1−1/e)≈63% worst-case floor. Distribution: %map%', { T: T, avg: avg.toFixed(2), opt: opt, round: Math.round(100*avg/opt), map: Object.keys(dist).sort().map(k=>k+'→'+(100*dist[k]/T).toFixed(0)+'%').join(', ') }),'ok');
console.log('RANKING avg', avg.toFixed(3), 'opt', opt, 'dist', dist); }
$('start').onclick=start;
$('opt').onclick=showOpt;
$('rank').onclick=runRanking;
used=new Array(nR).fill(false); matched=0; step=nL; drawSlots();
Here's the trap. The first two queries fit everyone, so grabbing A then B feels harmless — but the last two queries can only go to A and B, and now those slots are spent: you finish with 2 matches when 4 were possible in hindsight. Press Show hindsight optimum to see it, then Run RANKING ×2000: the randomized algorithm picks a secret random priority over the advertisers and tends to keep the scarce ones free, averaging well above any naive grab.
Comments
Loading comments...