<p class="hint">Three roommates split <b>$3000</b> over three unequal rooms. Each slider
sets how much a person would <i>happily pay</i> for a room (their private value). The
solver searches room prices so that, at those prices, every roommate's best deal is a
<b>different</b> room — an <b>envy-free</b> split. By Sperner's lemma one always exists.</p>
<div id="grid" class="grid"></div>
<div class="status" id="status">Set values, then find a split.</div>
<div id="result" class="result"></div>
<div class="btns">
<button id="find" type="button">Find a fair split</button>
<button id="rand" type="button" class="ghost">Randomize values</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 .8rem; line-height: 1.45; }
.grid { display: grid; grid-template-columns: auto repeat(3, 1fr); gap: .4rem .7rem; align-items: center; }
.h { font-weight: 700; color: #1d3557; font-size: .85rem; text-align: center; }
.name { font-weight: 700; color: #1d3557; font-size: .9rem; }
.vcell { display: flex; flex-direction: column; gap: 2px; }
.vcell input { width: 100%; }
.vcell span { font: 600 12px ui-monospace, monospace; color: #555; text-align: center; }
.status { font-size: 1rem; font-weight: 600; margin: .8rem 0 .3rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.result { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; margin: .3rem 0 .6rem; }
.card { border: 1px solid #cdd9e3; background: #e8eef3; border-radius: 8px; padding: .5rem; text-align: center; }
.card .who { font-weight: 700; color: #1d3557; }
.card .room { font-size: .85rem; color: #333; }
.card .price { font: 700 16px ui-monospace, monospace; color: #0a7d33; margin-top: 2px; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rent division: total rent T over 3 rooms, split among 3 roommates.
// value[i][j] = how much roommate i would pay for room j (their private worth).
// A price vector p (p0+p1+p2 = T) is ENVY-FREE if we can assign rooms so that each
// roommate's assigned room maximizes (value - price) for them — i.e. nobody prefers
// someone else's room-and-price. Sperner's lemma (Su, 1999) guarantees one exists.
const T = 3000;
const ROOMS = ['Sunny room', 'Tiny room', 'Street room'];
const NAMES = ['Alice', 'Bob', 'Carol'];
// Each person's value for each room (must roughly sum near T so prices stay sane).
let value = [
[1500, 800, 900],
[1100, 1200, 800],
[1000, 900, 1300],
];
function buildGrid() {
const g = document.getElementById('grid'); g.innerHTML = '';
g.appendChild(cell('', 'h'));
ROOMS.forEach(r => g.appendChild(cell(r, 'h')));
for (let i = 0; i < 3; i++) {
g.appendChild(cell(NAMES[i], 'name'));
for (let j = 0; j < 3; j++) {
const c = document.createElement('div'); c.className = 'vcell';
const inp = document.createElement('input');
inp.type = 'range'; inp.min = '200'; inp.max = '2000'; inp.step = '50';
inp.value = value[i][j];
const lab = document.createElement('span'); lab.textContent = '
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ value[i][j];
inp.addEventListener('input', () => { value[i][j] = +inp.value; lab.textContent = '
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ inp.value;
setStatus('Values changed — find a split.', ''); document.getElementById('result').innerHTML = ''; });
c.appendChild(inp); c.appendChild(lab); g.appendChild(c);
}
}
}
function cell(t, cls) { const d = document.createElement('div'); d.className = cls; d.textContent = t; return d; }
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
// All 6 permutations of room assignment (perm[i] = room given to roommate i).
const PERMS = [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]];
// Is assignment `perm` envy-free at prices p (using value matrix `val`)? Each roommate
// must (weakly) prefer their own room: val[i][perm[i]]-p[perm[i]] >= val[i][k]-p[k].
function envyFree(perm, p, val, tol) {
tol = tol || 1e-9;
for (let i = 0; i < 3; i++) {
const mine = val[i][perm[i]] - p[perm[i]];
for (let k = 0; k < 3; k++)
if (val[i][k] - p[k] > mine + tol) return false;
}
return true;
}
// Normalize each person's values so their three numbers sum to the rent T. This is
// Su's "miserly tenant" hypothesis (a tenant's budget equals the rent), under which
// an envy-free split with non-negative prices is guaranteed to exist.
function normalized() {
return value.map(row => { const s = row[0]+row[1]+row[2];
return row.map(x => x / s * T); });
}
// Exact solver (no grid luck). Step 1: pick the welfare-MAXIMIZING assignment. Step 2:
// its envy-free prices satisfy difference constraints p[b]-p[a] >= val[i][b]-val[i][a]
// for the holder i of room a. LP duality guarantees no positive cycle, so a longest-path
// relaxation finds prices; shift them so they sum to T. This always succeeds.
function find() {
const val = normalized();
let perm = PERMS[0], bw = -Infinity;
for (const pm of PERMS) { let w = 0; for (let i = 0; i < 3; i++) w += val[i][pm[i]];
if (w > bw) { bw = w; perm = pm; } }
const edges = [];
for (let i = 0; i < 3; i++) { const a = perm[i];
for (let b = 0; b < 3; b++) edges.push([a, b, val[i][b] - val[i][a]]); }
let p = [0, 0, 0];
for (let it = 0; it < 3; it++) for (const [a, b, w] of edges)
if (p[a] + w > p[b]) p[b] = p[a] + w;
const shift = (T - (p[0] + p[1] + p[2])) / 3;
p = p.map(x => x + shift);
// Round to whole dollars while keeping the sum exactly T (last room absorbs rounding).
p = [Math.round(p[0]), Math.round(p[1]), 0];
p[2] = T - p[0] - p[1];
const best = { perm, p };
// Whole-dollar rounding can leave a couple of dollars of slack; a $2 tolerance on a
// $3000 rent is negligible and the float prices are exactly envy-free.
if (!envyFree(perm, p, val, 2)) { setStatus('Rounding edge case — adjust a slider.', 'bad'); return; }
render(best);
setStatus('Found an envy-free split — everyone prefers their own room.', 'ok');
console.log('Assignment', best.perm.map((r,i)=>fmt('%v%→%v2%', { v: NAMES[i], v2: ROOMS[r] })).join(', '),
'| prices', best.p.map(x=>'
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+x).join(' '), '| sum', best.p.reduce((s,x)=>s+x,0));
}
function render(best) {
const res = document.getElementById('result'); res.innerHTML = '';
for (let i = 0; i < 3; i++) {
const room = best.perm[i];
const d = document.createElement('div'); d.className = 'card';
d.innerHTML = '<div class="who">' + NAMES[i] + '</div><div class="room">' + ROOMS[room] +
'</div><div class="price">
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ best.p[room] + '</div>';
res.appendChild(d);
}
}
function randomize() {
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++)
value[i][j] = 200 + Math.round((Math.random() * 36)) * 50; // 200..2000 step 50
buildGrid(); document.getElementById('result').innerHTML = '';
setStatus('New random values — find a split.', '');
}
document.getElementById('find').addEventListener('click', find);
document.getElementById('rand').addEventListener('click', randomize);
buildGrid();
<p class="hint">Three roommates split <b>$3000</b> over three unequal rooms. Each slider
sets how much a person would <i>happily pay</i> for a room (their private value). The
solver searches room prices so that, at those prices, every roommate's best deal is a
<b>different</b> room — an <b>envy-free</b> split. By Sperner's lemma one always exists.</p>
<div id="grid" class="grid"></div>
<div class="status" id="status">Set values, then find a split.</div>
<div id="result" class="result"></div>
<div class="btns">
<button id="find" type="button">Find a fair split</button>
<button id="rand" type="button" class="ghost">Randomize values</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 .8rem; line-height: 1.45; }
.grid { display: grid; grid-template-columns: auto repeat(3, 1fr); gap: .4rem .7rem; align-items: center; }
.h { font-weight: 700; color: #1d3557; font-size: .85rem; text-align: center; }
.name { font-weight: 700; color: #1d3557; font-size: .9rem; }
.vcell { display: flex; flex-direction: column; gap: 2px; }
.vcell input { width: 100%; }
.vcell span { font: 600 12px ui-monospace, monospace; color: #555; text-align: center; }
.status { font-size: 1rem; font-weight: 600; margin: .8rem 0 .3rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.result { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; margin: .3rem 0 .6rem; }
.card { border: 1px solid #cdd9e3; background: #e8eef3; border-radius: 8px; padding: .5rem; text-align: center; }
.card .who { font-weight: 700; color: #1d3557; }
.card .room { font-size: .85rem; color: #333; }
.card .price { font: 700 16px ui-monospace, monospace; color: #0a7d33; margin-top: 2px; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rent division: total rent T over 3 rooms, split among 3 roommates.
// value[i][j] = how much roommate i would pay for room j (their private worth).
// A price vector p (p0+p1+p2 = T) is ENVY-FREE if we can assign rooms so that each
// roommate's assigned room maximizes (value - price) for them — i.e. nobody prefers
// someone else's room-and-price. Sperner's lemma (Su, 1999) guarantees one exists.
const T = 3000;
const ROOMS = ['Sunny room', 'Tiny room', 'Street room'];
const NAMES = ['Alice', 'Bob', 'Carol'];
// Each person's value for each room (must roughly sum near T so prices stay sane).
let value = [
[1500, 800, 900],
[1100, 1200, 800],
[1000, 900, 1300],
];
function buildGrid() {
const g = document.getElementById('grid'); g.innerHTML = '';
g.appendChild(cell('', 'h'));
ROOMS.forEach(r => g.appendChild(cell(r, 'h')));
for (let i = 0; i < 3; i++) {
g.appendChild(cell(NAMES[i], 'name'));
for (let j = 0; j < 3; j++) {
const c = document.createElement('div'); c.className = 'vcell';
const inp = document.createElement('input');
inp.type = 'range'; inp.min = '200'; inp.max = '2000'; inp.step = '50';
inp.value = value[i][j];
const lab = document.createElement('span'); lab.textContent = '
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ value[i][j];
inp.addEventListener('input', () => { value[i][j] = +inp.value; lab.textContent = '
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ inp.value;
setStatus('Values changed — find a split.', ''); document.getElementById('result').innerHTML = ''; });
c.appendChild(inp); c.appendChild(lab); g.appendChild(c);
}
}
}
function cell(t, cls) { const d = document.createElement('div'); d.className = cls; d.textContent = t; return d; }
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
// All 6 permutations of room assignment (perm[i] = room given to roommate i).
const PERMS = [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]];
// Is assignment `perm` envy-free at prices p (using value matrix `val`)? Each roommate
// must (weakly) prefer their own room: val[i][perm[i]]-p[perm[i]] >= val[i][k]-p[k].
function envyFree(perm, p, val, tol) {
tol = tol || 1e-9;
for (let i = 0; i < 3; i++) {
const mine = val[i][perm[i]] - p[perm[i]];
for (let k = 0; k < 3; k++)
if (val[i][k] - p[k] > mine + tol) return false;
}
return true;
}
// Normalize each person's values so their three numbers sum to the rent T. This is
// Su's "miserly tenant" hypothesis (a tenant's budget equals the rent), under which
// an envy-free split with non-negative prices is guaranteed to exist.
function normalized() {
return value.map(row => { const s = row[0]+row[1]+row[2];
return row.map(x => x / s * T); });
}
// Exact solver (no grid luck). Step 1: pick the welfare-MAXIMIZING assignment. Step 2:
// its envy-free prices satisfy difference constraints p[b]-p[a] >= val[i][b]-val[i][a]
// for the holder i of room a. LP duality guarantees no positive cycle, so a longest-path
// relaxation finds prices; shift them so they sum to T. This always succeeds.
function find() {
const val = normalized();
let perm = PERMS[0], bw = -Infinity;
for (const pm of PERMS) { let w = 0; for (let i = 0; i < 3; i++) w += val[i][pm[i]];
if (w > bw) { bw = w; perm = pm; } }
const edges = [];
for (let i = 0; i < 3; i++) { const a = perm[i];
for (let b = 0; b < 3; b++) edges.push([a, b, val[i][b] - val[i][a]]); }
let p = [0, 0, 0];
for (let it = 0; it < 3; it++) for (const [a, b, w] of edges)
if (p[a] + w > p[b]) p[b] = p[a] + w;
const shift = (T - (p[0] + p[1] + p[2])) / 3;
p = p.map(x => x + shift);
// Round to whole dollars while keeping the sum exactly T (last room absorbs rounding).
p = [Math.round(p[0]), Math.round(p[1]), 0];
p[2] = T - p[0] - p[1];
const best = { perm, p };
// Whole-dollar rounding can leave a couple of dollars of slack; a $2 tolerance on a
// $3000 rent is negligible and the float prices are exactly envy-free.
if (!envyFree(perm, p, val, 2)) { setStatus('Rounding edge case — adjust a slider.', 'bad'); return; }
render(best);
setStatus('Found an envy-free split — everyone prefers their own room.', 'ok');
console.log('Assignment', best.perm.map((r,i)=>fmt('%v%→%v2%', { v: NAMES[i], v2: ROOMS[r] })).join(', '),
'| prices', best.p.map(x=>'
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+x).join(' '), '| sum', best.p.reduce((s,x)=>s+x,0));
}
function render(best) {
const res = document.getElementById('result'); res.innerHTML = '';
for (let i = 0; i < 3; i++) {
const room = best.perm[i];
const d = document.createElement('div'); d.className = 'card';
d.innerHTML = '<div class="who">' + NAMES[i] + '</div><div class="room">' + ROOMS[room] +
'</div><div class="price">
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
+ best.p[room] + '</div>';
res.appendChild(d);
}
}
function randomize() {
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++)
value[i][j] = 200 + Math.round((Math.random() * 36)) * 50; // 200..2000 step 50
buildGrid(); document.getElementById('result').innerHTML = '';
setStatus('New random values — find a split.', '');
}
document.getElementById('find').addEventListener('click', find);
document.getElementById('rand').addEventListener('click', randomize);
buildGrid();
Notice the asymmetry. Checking a proposed split is instant: at the given prices, ask each person which room they'd pick, and see whether the three choices are all different. Finding the split is the work — but unlike many puzzles on this site, a solution is guaranteed to exist. Move the sliders however you like and press Find a fair split: it never comes back empty-handed.
Comments
Loading comments...