Here are four applicants (A–D) and four companies (1–4), each with a private ranking of the other side. Press Next round to let every unmatched applicant propose to their most-preferred company that hasn't yet rejected them. Each company tentatively holds its best proposer and turns the rest away.
<p class="hint">Four applicants (A–D) and four companies (1–4) each rank the other side. Each round, every free applicant proposes to its top remaining choice; each company tentatively holds its best proposer and rejects the rest. Watch the holds settle into a stable matching.</p>
<div class="cols">
<div class="panel">
<h4>Applicants → prefer</h4>
<div id="apref" class="prefs"></div>
</div>
<div class="panel">
<h4>Companies → prefer</h4>
<div id="copref" class="prefs"></div>
</div>
</div>
<div class="matchbox" id="match"></div>
<div class="status" id="status">Press "Next round" to begin.</div>
<div class="btns">
<button id="next" type="button">Next round</button>
<button id="run" type="button">Run to end</button>
<button id="verify" type="button" class="ghost">Verify stability</button>
<button id="reset" type="button" class="ghost">Reset</button>
</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; }
.cols { display: flex; gap: 1rem; flex-wrap: wrap; }
.panel { flex: 1 1 220px; }
h4 { margin: 0 0 .4rem; font-size: .85rem; color: #1d3557; }
.prefs { font: 600 13px ui-monospace, monospace; line-height: 1.7; color: #333; }
.prefs .who { color: #1d3557; }
.matchbox { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: .8rem 0 .3rem; }
.pair { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .5rem; text-align: center; font: 700 15px ui-monospace, monospace; color: #1d3557; }
.pair.free { background: #f3f4f6; color: #9aa0a6; border-color: #e2e4e8; }
.pair small { display: block; font: 600 10px system-ui; color: #6b7480; margin-top: 2px; }
.pair.changed { background: #d7ead9; border-color: #8fc79a; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gale-Shapley deferred acceptance (1962). Applicants propose; companies hold the best proposer so far and reject the rest. Always yields a STABLE matching in O(n^2).
const APPS = ['A','B','C','D'];
const COS = ['1','2','3','4'];
// Each applicant's ranking of companies (most preferred first).
const APREF = { A:['1','2','3','4'], B:['1','4','3','2'], C:['2','1','4','3'], D:['4','2','3','1'] };
// Each company's ranking of applicants (most preferred first).
const COPREF = { '1':['C','A','D','B'], '2':['A','C','B','D'], '3':['B','D','A','C'], '4':['D','B','A','C'] };
let next, held, prevHeld, done;
function reset(){
next = {}; APPS.forEach(a => next[a] = 0); // index into APREF[a] of next company to ask
held = {}; COS.forEach(c => held[c] = null); // company -> currently held applicant
prevHeld = {}; done = false;
setStatus('Press "Next round" to begin.','');
render();
}
const rank = (list, x) => list.indexOf(x);
// One round: every currently-free applicant proposes to its next choice.
function round(){
if (done) return;
prevHeld = Object.assign({}, held);
const matchedApps = new Set(Object.values(held).filter(Boolean));
const proposers = APPS.filter(a => !matchedApps.has(a) && next[a] < COS.length);
if (proposers.length === 0){ done = true; setStatus('Done — every applicant is matched. The holds are now final.','ok'); render(); return; }
let events = 0;
for (const a of proposers){
const c = APREF[a][next[a]]; next[a]++;
const cur = held[c];
if (cur === null){ held[c] = a; events++; }
else if (rank(COPREF[c], a) < rank(COPREF[c], cur)){ held[c] = a; events++; } // a is better
// else: company keeps cur, a is rejected and will try its next choice later
}
setStatus(fmt('%length% applicant(s) proposed; %events% tentative hold(s) updated.', { length: proposers.length, events: events }),'');
render();
}
// A matching is stable iff no applicant a and company c both prefer each other over their current partners.
function partnerOf(){ const p = {}; for (const c of COS){ if (held[c]) p[held[c]] = c; } return p; }
function verify(){
const p = partnerOf();
for (const a of APPS){
const ac = p[a];
for (const c of COS){
if (c === ac) continue;
const cur = held[c];
const aPrefersC = ac == null || rank(APREF[a], c) < rank(APREF[a], ac);
const cPrefersA = cur == null || rank(COPREF[c], a) < rank(COPREF[c], cur);
if (aPrefersC && cPrefersA){
setStatus(fmt('UNSTABLE: %a% and %c% both prefer each other!', { a: a, c: c }),'bad');
console.log('blocking pair', a, c); return false;
}
}
}
setStatus('Stable — no pair would run away. ✓','ok');
console.log('matching', p, 'is stable'); return true;
}
function render(){
const ap = document.getElementById('apref'); ap.innerHTML = APPS.map(a =>
'<div><span class="who">'+a+'</span>: '+APREF[a].join(' > ')+'</div>').join('');
const cp = document.getElementById('copref'); cp.innerHTML = COS.map(c =>
'<div><span class="who">'+c+'</span>: '+COPREF[c].join(' > ')+'</div>').join('');
const p = partnerOf();
document.getElementById('match').innerHTML = APPS.map(a => {
const c = p[a];
const changed = c && prevHeld[c] !== held[c];
return '<div class="pair '+(c?'':'free')+(changed?' changed':'')+'">'+a+
(c? ' ↔ '+c : ' ↔ ?')+'<small>'+(c?'held':'free')+'</small></div>';
}).join('');
document.getElementById('next').disabled = done;
document.getElementById('run').disabled = done;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
document.getElementById('next').addEventListener('click', round);
document.getElementById('run').addEventListener('click', ()=>{ let g=0; while(!done && g++<50) round(); });
document.getElementById('verify').addEventListener('click', verify);
document.getElementById('reset').addEventListener('click', reset);
reset();
<p class="hint">Four applicants (A–D) and four companies (1–4) each rank the other side. Each round, every free applicant proposes to its top remaining choice; each company tentatively holds its best proposer and rejects the rest. Watch the holds settle into a stable matching.</p>
<div class="cols">
<div class="panel">
<h4>Applicants → prefer</h4>
<div id="apref" class="prefs"></div>
</div>
<div class="panel">
<h4>Companies → prefer</h4>
<div id="copref" class="prefs"></div>
</div>
</div>
<div class="matchbox" id="match"></div>
<div class="status" id="status">Press "Next round" to begin.</div>
<div class="btns">
<button id="next" type="button">Next round</button>
<button id="run" type="button">Run to end</button>
<button id="verify" type="button" class="ghost">Verify stability</button>
<button id="reset" type="button" class="ghost">Reset</button>
</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; }
.cols { display: flex; gap: 1rem; flex-wrap: wrap; }
.panel { flex: 1 1 220px; }
h4 { margin: 0 0 .4rem; font-size: .85rem; color: #1d3557; }
.prefs { font: 600 13px ui-monospace, monospace; line-height: 1.7; color: #333; }
.prefs .who { color: #1d3557; }
.matchbox { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: .8rem 0 .3rem; }
.pair { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .5rem; text-align: center; font: 700 15px ui-monospace, monospace; color: #1d3557; }
.pair.free { background: #f3f4f6; color: #9aa0a6; border-color: #e2e4e8; }
.pair small { display: block; font: 600 10px system-ui; color: #6b7480; margin-top: 2px; }
.pair.changed { background: #d7ead9; border-color: #8fc79a; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gale-Shapley deferred acceptance (1962). Applicants propose; companies hold the best proposer so far and reject the rest. Always yields a STABLE matching in O(n^2).
const APPS = ['A','B','C','D'];
const COS = ['1','2','3','4'];
// Each applicant's ranking of companies (most preferred first).
const APREF = { A:['1','2','3','4'], B:['1','4','3','2'], C:['2','1','4','3'], D:['4','2','3','1'] };
// Each company's ranking of applicants (most preferred first).
const COPREF = { '1':['C','A','D','B'], '2':['A','C','B','D'], '3':['B','D','A','C'], '4':['D','B','A','C'] };
let next, held, prevHeld, done;
function reset(){
next = {}; APPS.forEach(a => next[a] = 0); // index into APREF[a] of next company to ask
held = {}; COS.forEach(c => held[c] = null); // company -> currently held applicant
prevHeld = {}; done = false;
setStatus('Press "Next round" to begin.','');
render();
}
const rank = (list, x) => list.indexOf(x);
// One round: every currently-free applicant proposes to its next choice.
function round(){
if (done) return;
prevHeld = Object.assign({}, held);
const matchedApps = new Set(Object.values(held).filter(Boolean));
const proposers = APPS.filter(a => !matchedApps.has(a) && next[a] < COS.length);
if (proposers.length === 0){ done = true; setStatus('Done — every applicant is matched. The holds are now final.','ok'); render(); return; }
let events = 0;
for (const a of proposers){
const c = APREF[a][next[a]]; next[a]++;
const cur = held[c];
if (cur === null){ held[c] = a; events++; }
else if (rank(COPREF[c], a) < rank(COPREF[c], cur)){ held[c] = a; events++; } // a is better
// else: company keeps cur, a is rejected and will try its next choice later
}
setStatus(fmt('%length% applicant(s) proposed; %events% tentative hold(s) updated.', { length: proposers.length, events: events }),'');
render();
}
// A matching is stable iff no applicant a and company c both prefer each other over their current partners.
function partnerOf(){ const p = {}; for (const c of COS){ if (held[c]) p[held[c]] = c; } return p; }
function verify(){
const p = partnerOf();
for (const a of APPS){
const ac = p[a];
for (const c of COS){
if (c === ac) continue;
const cur = held[c];
const aPrefersC = ac == null || rank(APREF[a], c) < rank(APREF[a], ac);
const cPrefersA = cur == null || rank(COPREF[c], a) < rank(COPREF[c], cur);
if (aPrefersC && cPrefersA){
setStatus(fmt('UNSTABLE: %a% and %c% both prefer each other!', { a: a, c: c }),'bad');
console.log('blocking pair', a, c); return false;
}
}
}
setStatus('Stable — no pair would run away. ✓','ok');
console.log('matching', p, 'is stable'); return true;
}
function render(){
const ap = document.getElementById('apref'); ap.innerHTML = APPS.map(a =>
'<div><span class="who">'+a+'</span>: '+APREF[a].join(' > ')+'</div>').join('');
const cp = document.getElementById('copref'); cp.innerHTML = COS.map(c =>
'<div><span class="who">'+c+'</span>: '+COPREF[c].join(' > ')+'</div>').join('');
const p = partnerOf();
document.getElementById('match').innerHTML = APPS.map(a => {
const c = p[a];
const changed = c && prevHeld[c] !== held[c];
return '<div class="pair '+(c?'':'free')+(changed?' changed':'')+'">'+a+
(c? ' ↔ '+c : ' ↔ ?')+'<small>'+(c?'held':'free')+'</small></div>';
}).join('');
document.getElementById('next').disabled = done;
document.getElementById('run').disabled = done;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
document.getElementById('next').addEventListener('click', round);
document.getElementById('run').addEventListener('click', ()=>{ let g=0; while(!done && g++<50) round(); });
document.getElementById('verify').addEventListener('click', verify);
document.getElementById('reset').addEventListener('click', reset);
reset();
Watch how a company can drop a tentative partner the moment a better proposer shows up — that "deferred acceptance" is the whole trick. After at most a handful of rounds, nobody is left proposing and the holds become final. The result is always stable: check any pair you like, and at least one of them prefers who they ended up with. Try pressing Verify: the demo searches for any runaway pair and finds none.
Comments
Loading comments...