The table holds a division: wins (W), losses (L) and the number of games still to play between every pair of teams. Pick a team and the demo builds its network:
<!-- Interface: preset buttons, standings table, team selector, step buttons, network drawing. -->
<div class="wrap">
<p class="hint">Edit wins, losses and the games left between each pair of teams, pick the team you are worried about, and watch the flow network decide whether it can still finish first.</p>
<div class="row presets">
<button id="p_classic" type="button" class="ghost">Classic 4 teams</button>
<button id="p_five" type="button" class="ghost">5 teams, subtle case</button>
<button id="p_alive" type="button" class="ghost">5 teams, still alive</button>
</div>
<div class="scroll"><table id="tbl" class="tbl" aria-label="Standings and remaining games"></table></div>
<p class="note">W = wins, L = losses, R = games left. The grid on the right holds the games left between each pair (it stays symmetric).</p>
<div class="row">
<label for="team">Team to test:</label>
<select id="team"></select>
</div>
<div class="row">
<button id="step" type="button">Next augmenting path</button>
<button id="run" type="button">Run to the end</button>
<button id="reset" type="button" class="ghost">Reset flow</button>
</div>
<div class="status" id="status" aria-live="polite"></div>
<div class="scroll"><svg id="net" class="net" viewBox="0 0 640 160" role="img" aria-label="Flow network: source, one node per remaining game, one node per team, sink"></svg></div>
<p class="legend">Left to right: source s, one node per pair of other teams with games left (capacity = games left), one node per team, sink t (capacity = wins that team may still add). Orange = current augmenting path, blue = carries flow, green = full edge, red dashed = minimum cut.</p>
<div class="cert" id="cert" aria-live="polite"></div>
</div>
/* Colours follow the light or dark preference of the visitor. */
:root { --fg:#1f2933; --bg:#ffffff; --mut:#52606d; --line:#c3ccd6; --acc:#1d3557; --ok:#0a7d33; --bad:#c92f3c; --pth:#e8890c; --soft:#eef2f6; --fl:#2a6fb0; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6ebf0; --bg:#1b2430; --mut:#a7b4c2; --line:#46566a; --acc:#7fb0e6; --ok:#4cc27a; --bad:#ff7a85; --pth:#ffb347; --soft:#243243; --fl:#6fb3f2; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; }
.wrap { padding: .6rem; }
.hint { font-size: .9rem; color: var(--mut); margin: 0 0 .6rem; line-height: 1.45; }
.note, .legend { font-size: .8rem; color: var(--mut); margin: .3rem 0; line-height: 1.4; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; }
.scroll { overflow-x: auto; }
.tbl { border-collapse: collapse; font-size: .85rem; }
.tbl th, .tbl td { padding: 2px 4px; text-align: center; border-bottom: 1px solid var(--line); }
.tbl th { color: var(--mut); font-weight: 600; }
.tbl .nm { text-align: left; white-space: nowrap; font-weight: 600; padding-right: .6rem; }
.tbl .rem { font-weight: 700; color: var(--acc); }
.tbl .dg { color: var(--mut); }
.tbl input { width: 3.1em; padding: 3px 2px; text-align: center; font: inherit; color: var(--fg); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; }
label { font-size: .9rem; font-weight: 600; }
select { font: inherit; padding: .3rem .5rem; color: var(--fg); background: var(--soft); border: 1px solid var(--line); border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--acc); background: var(--acc); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--acc); }
button:disabled { opacity: .45; cursor: default; }
.status { font-size: .95rem; font-weight: 600; margin: .4rem 0; min-height: 2.8em; line-height: 1.4; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.net { display: block; width: 100%; min-width: 520px; height: auto; background: var(--soft); border-radius: 8px; }
.ed { stroke: var(--line); stroke-width: 1.4; }
.ed.fl { stroke: var(--fl); stroke-width: 2.2; }
.ed.sat { stroke: var(--ok); }
.ed.pth { stroke: var(--pth); stroke-width: 4; }
.ed.cut { stroke: var(--bad); stroke-dasharray: 5 4; stroke-width: 3; }
.nd { fill: var(--bg); stroke: var(--acc); stroke-width: 1.6; }
.nd.s, .nd.t { fill: var(--acc); }
.nd.inS { stroke: var(--bad); stroke-width: 3; }
.nl { font: 600 10px system-ui, sans-serif; fill: var(--fg); pointer-events: none; }
.nd.s + .nl, .nd.t + .nl { fill: var(--bg); }
.el { font: 600 10px system-ui, sans-serif; fill: var(--mut); paint-order: stroke; stroke: var(--soft); stroke-width: 3px; }
.cap { font: 600 10px system-ui, sans-serif; fill: var(--mut); }
.cert { font-size: .9rem; line-height: 1.5; margin-top: .4rem; }
.cert:not(:empty) { padding: .5rem .7rem; border-left: 4px solid var(--bad); background: var(--soft); border-radius: 6px; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const INF = 1000000;
// Core: builds the flow network and solves it with Edmonds-Karp (shortest augmenting paths found by breadth-first search).
const remOf = (g, i) => g[i].reduce((a, b) => a + b, 0);
function buildNet(teams, g, x) {
const n = teams.length;
const maxW = teams[x].w + remOf(g, x);
const net = { x, maxW, nodes: [], edges: [], adj: [], total: 0, trivial: -1, flow: 0 };
for (let i = 0; i < n; i++) if (i !== x && teams[i].w > maxW && net.trivial < 0) net.trivial = i;
const addNode = (o) => { net.nodes.push(o); net.adj.push([]); return net.nodes.length - 1; };
const addEdge = (u, v, cap) => {
net.edges.push({ u, v, cap, flow: 0 }); net.adj[u].push(net.edges.length - 1);
net.edges.push({ u: v, v: u, cap: 0, flow: 0 }); net.adj[v].push(net.edges.length - 1);
};
const s = addNode({ type: 's' });
const others = [];
for (let i = 0; i < n; i++) if (i !== x) others.push(i);
const gameNodes = [];
for (let a = 0; a < others.length; a++) for (let b = a + 1; b < others.length; b++) {
const i = others[a], j = others[b];
if (g[i][j] > 0) gameNodes.push({ i, j, c: g[i][j] });
}
const gid = gameNodes.map((gm) => addNode({ type: 'g', i: gm.i, j: gm.j, c: g[gm.i][gm.j] }));
const tid = {};
others.forEach((i) => { tid[i] = addNode({ type: 'T', i }); });
const t = addNode({ type: 't' });
net.s = s; net.t = t;
gameNodes.forEach((gm, k) => {
addEdge(s, gid[k], gm.c); net.total += gm.c;
addEdge(gid[k], tid[gm.i], INF); addEdge(gid[k], tid[gm.j], INF);
});
others.forEach((i) => addEdge(tid[i], t, Math.max(0, maxW - teams[i].w)));
return net;
}
function bfs(net) {
const par = new Array(net.nodes.length).fill(-1);
const seen = new Array(net.nodes.length).fill(false);
seen[net.s] = true; const q = [net.s];
while (q.length) {
const u = q.shift();
for (const ei of net.adj[u]) {
const e = net.edges[ei];
if (!seen[e.v] && e.cap - e.flow > 0) { seen[e.v] = true; par[e.v] = ei; q.push(e.v); }
}
}
if (!seen[net.t]) return { path: null, seen };
const path = []; let v = net.t;
while (v !== net.s) { path.push(par[v]); v = net.edges[par[v]].u; }
return { path: path.reverse(), seen };
}
function augment(net) {
const r = bfs(net);
if (!r.path) { net.seen = r.seen; return null; }
let d = Infinity;
for (const ei of r.path) d = Math.min(d, net.edges[ei].cap - net.edges[ei].flow);
for (const ei of r.path) { net.edges[ei].flow += d; net.edges[ei ^ 1].flow -= d; }
net.flow += d;
return { path: r.path, delta: d };
}
function certificate(teams, g, net) {
const R = [];
net.nodes.forEach((nd, k) => { if (nd.type === 'T' && net.seen[k]) R.push(nd.i); });
let wR = 0, gR = 0;
R.forEach((i) => { wR += teams[i].w; });
for (let a = 0; a < R.length; a++) for (let b = a + 1; b < R.length; b++) gR += g[R[a]][R[b]];
return { R, wR, gR };
}
// Interface code: presets, table editing, drawing and buttons.
const PRESETS = {
classic: { x: 1, teams: [
{ name: 'Atlanta', ab: 'ATL', w: 83, l: 71 }, { name: 'Philadelphia', ab: 'PHI', w: 80, l: 79 },
{ name: 'New York', ab: 'NY', w: 78, l: 78 }, { name: 'Montreal', ab: 'MON', w: 77, l: 82 }],
g: [[0, 1, 6, 1], [1, 0, 0, 2], [6, 0, 0, 0], [1, 2, 0, 0]] },
five: { x: 3, teams: ['A', 'B', 'C', 'D', 'E'].map((c, i) => ({ name: c, ab: c, w: [68, 68, 66, 65, 63][i], l: 0 })),
g: [[0, 0, 3, 2, 2], [0, 0, 3, 1, 1], [3, 3, 0, 0, 1], [2, 1, 0, 0, 1], [2, 1, 1, 1, 0]] },
alive: { x: 2, teams: null, g: null }
};
PRESETS.five.teams.forEach((t, i) => { t.l = 162 - t.w - remOf(PRESETS.five.g, i); });
PRESETS.alive.teams = PRESETS.five.teams; PRESETS.alive.g = PRESETS.five.g;
let TEAMS, G, X, NET, LAST = null, DONE = false, STEPS = 0;
const $ = (id) => document.getElementById(id);
const ab = (i) => TEAMS[i].ab;
function loadPreset(key) {
const p = PRESETS[key];
TEAMS = p.teams.map((t) => ({ name: t.name, ab: t.ab, w: t.w, l: t.l }));
G = p.g.map((r) => r.slice()); X = p.x;
renderTable(); renderSelect(); rebuild();
}
function renderSelect() {
$('team').innerHTML = TEAMS.map((t, i) => '<option value="' + i + '"' + (i === X ? ' selected' : '') + '>' + t.name + '</option>').join('');
}
function renderTable() {
const n = TEAMS.length;
let h = '<tr><th>Team</th><th>W</th><th>L</th><th>R</th>';
for (let j = 0; j < n; j++) h += '<th class="gh">' + ab(j) + '</th>';
h += '</tr>';
for (let i = 0; i < n; i++) {
h += '<tr><td class="nm">' + TEAMS[i].name + '</td>';
h += '<td><input type="number" min="0" max="199" data-k="w" data-i="' + i + '" value="' + TEAMS[i].w + '"></td>';
h += '<td><input type="number" min="0" max="199" data-k="l" data-i="' + i + '" value="' + TEAMS[i].l + '"></td>';
h += '<td class="rem" id="rem' + i + '">' + remOf(G, i) + '</td>';
for (let j = 0; j < n; j++) {
h += i === j ? '<td class="dg">·</td>' : '<td><input type="number" min="0" max="30" data-k="g" data-i="' + i + '" data-j="' + j + '" value="' + G[i][j] + '"></td>';
}
h += '</tr>';
}
$('tbl').innerHTML = h;
}
function onEdit(ev) {
const el = ev.target; if (!el.dataset || !el.dataset.k) return;
let v = parseInt(el.value, 10); if (isNaN(v) || v < 0) v = 0;
const i = +el.dataset.i;
if (el.dataset.k === 'g') {
const j = +el.dataset.j; v = Math.min(v, 30); G[i][j] = v; G[j][i] = v;
const mirror = document.querySelector('input[data-k="g"][data-i="' + j + '"][data-j="' + i + '"]');
if (mirror) mirror.value = v;
for (let r = 0; r < TEAMS.length; r++) $('rem' + r).textContent = remOf(G, r);
} else { v = Math.min(v, 199); TEAMS[i][el.dataset.k] = v; }
rebuild();
}
function rebuild() {
NET = buildNet(TEAMS, G, X); LAST = null; DONE = false; STEPS = 0;
$('cert').innerHTML = '';
if (NET.trivial >= 0) { finish(); return; }
setStatus(fmt("%team% can reach at most %w% + %r% = %max% wins. The other teams still play %g% games among themselves, and each win must be given to someone without pushing that team above %max%.", { team: TEAMS[X].name, w: TEAMS[X].w, r: remOf(G, X), max: NET.maxW, g: NET.total }), '');
draw(); buttons();
}
function nodeName(k) {
const nd = NET.nodes[k];
if (nd.type === 's') return 's';
if (nd.type === 't') return 't';
if (nd.type === 'T') return ab(nd.i);
return ab(nd.i) + '–' + ab(nd.j);
}
function step() {
if (DONE) return;
const r = augment(NET);
if (!r) { finish(); return; }
STEPS++; LAST = r.path;
const names = [nodeName(NET.edges[r.path[0]].u)].concat(r.path.map((ei) => nodeName(NET.edges[ei].v)));
setStatus(fmt("Path %n%: %path% (pushes %d%). Flow so far: %f% of %t%.", { n: STEPS, path: names.join(' → '), d: r.delta, f: NET.flow, t: NET.total }), '');
draw(); buttons();
}
function runAll() { let guard = 0; while (!DONE && guard++ < 500) { step(); } }
function finish() {
DONE = true; LAST = null;
const nm = TEAMS[X].name;
if (NET.trivial >= 0) {
const o = TEAMS[NET.trivial];
setStatus(fmt("%team% is eliminated trivially: winning every remaining game gives only %max% wins, but %other% already has %ow%.", { team: nm, max: NET.maxW, other: o.name, ow: o.w }), 'bad');
$('cert').innerHTML = '';
$('net').innerHTML = ''; buttons(); return;
}
NET.seen = bfs(NET).seen;
if (NET.flow < NET.total) {
const c = certificate(TEAMS, G, NET);
const k = c.R.length, tot = c.wR + c.gR, best = Math.ceil(tot / k);
const val = Math.round(tot / k * 100) / 100;
setStatus(fmt("Maximum flow %f% < %t%: some games cannot be assigned. %team% is eliminated.", { team: nm, f: NET.flow, t: NET.total }), 'bad');
$('cert').innerHTML = '<b>' + "Certificate (minimum cut)" + '</b><br>' +
fmt("R = {%R%}: (w(R) + g(R)) / |R| = (%w% + %g%) / %k% = %v% > %max%", { R: c.R.map(ab).join(', '), w: c.wR, g: c.gR, k: k, v: val, max: NET.maxW }) + '<br>' +
fmt("The teams in R will finish with %tot% wins between them (their current wins plus one winner for each game they play against each other), so the best of them ends with at least %best% wins, above the ceiling of %max%.", { tot: tot, best: best, max: NET.maxW });
} else {
setStatus(fmt("Maximum flow %f% = %t%: every remaining game can be assigned without anyone passing the ceiling. %team% is not eliminated (it can still tie for first).", { team: nm, f: NET.flow, t: NET.total }), 'ok');
$('cert').innerHTML = '';
}
draw(); buttons();
}
function setStatus(t, cls) { const s = $('status'); s.textContent = t; s.className = 'status ' + cls; }
function buttons() { $('step').disabled = DONE; $('run').disabled = DONE; }
function draw() {
const nodes = NET.nodes, gl = [], tl = [];
nodes.forEach((nd, k) => { if (nd.type === 'g') gl.push(k); if (nd.type === 'T') tl.push(k); });
const rows = Math.max(gl.length, tl.length, 3), rh = 40, H = rows * rh + 24;
const pos = {};
pos[NET.s] = [36, H / 2]; pos[NET.t] = [604, H / 2];
gl.forEach((k, a) => { pos[k] = [200, 12 + (a + 0.5) * (H - 24) / gl.length]; });
tl.forEach((k, a) => { pos[k] = [420, 12 + (a + 0.5) * (H - 24) / tl.length]; });
const onPath = new Set((LAST || []).map((e) => e & ~1));
const cutS = DONE && NET.seen && NET.flow < NET.total;
let svg = '';
NET.edges.forEach((e, ei) => {
if (ei & 1) return;
const p1 = pos[e.u], p2 = pos[e.v];
const inf = e.cap >= INF, sat = !inf && e.flow === e.cap && e.cap > 0;
let cls = 'ed';
if (e.flow > 0) cls += ' fl';
if (sat) cls += ' sat';
if (onPath.has(ei)) cls += ' pth';
if (cutS && NET.seen[e.u] && !NET.seen[e.v]) cls += ' cut';
svg += '<line class="' + cls + '" x1="' + p1[0] + '" y1="' + p1[1] + '" x2="' + p2[0] + '" y2="' + p2[1] + '"/>';
let lab = '';
if (!inf) lab = e.flow + '/' + e.cap; else if (e.flow > 0) lab = String(e.flow);
if (lab) {
const f = 0.5;
svg += '<text class="el" x="' + (p1[0] + (p2[0] - p1[0]) * f) + '" y="' + (p1[1] + (p2[1] - p1[1]) * f - 3) + '" text-anchor="middle">' + lab + '</text>';
}
});
nodes.forEach((nd, k) => {
const x = pos[k][0], y = pos[k][1];
let cls = 'nd ' + nd.type;
if (cutS && NET.seen[k]) cls += ' inS';
const lab = nodeName(k);
if (nd.type === 'g') svg += '<rect class="' + cls + '" x="' + (x - 28) + '" y="' + (y - 12) + '" width="56" height="24" rx="6"/>';
else svg += '<circle class="' + cls + '" cx="' + x + '" cy="' + y + '" r="' + (nd.type === 'T' ? 17 : 13) + '"/>';
svg += '<text class="nl" x="' + x + '" y="' + (y + 4) + '" text-anchor="middle">' + lab + '</text>';
});
svg += '<text class="cap" x="200" y="9" text-anchor="middle">games</text><text class="cap" x="420" y="9" text-anchor="middle">teams</text>';
$('net').setAttribute('viewBox', '0 0 640 ' + (H + 6));
$('net').innerHTML = svg;
}
$('tbl').addEventListener('change', onEdit);
$('team').addEventListener('change', () => { X = +$('team').value; rebuild(); });
$('step').addEventListener('click', step);
$('run').addEventListener('click', runAll);
$('reset').addEventListener('click', rebuild);
$('p_classic').addEventListener('click', () => loadPreset('classic'));
$('p_five').addEventListener('click', () => loadPreset('five'));
$('p_alive').addEventListener('click', () => loadPreset('alive'));
loadPreset('classic');
<!-- Interface: preset buttons, standings table, team selector, step buttons, network drawing. -->
<div class="wrap">
<p class="hint">Edit wins, losses and the games left between each pair of teams, pick the team you are worried about, and watch the flow network decide whether it can still finish first.</p>
<div class="row presets">
<button id="p_classic" type="button" class="ghost">Classic 4 teams</button>
<button id="p_five" type="button" class="ghost">5 teams, subtle case</button>
<button id="p_alive" type="button" class="ghost">5 teams, still alive</button>
</div>
<div class="scroll"><table id="tbl" class="tbl" aria-label="Standings and remaining games"></table></div>
<p class="note">W = wins, L = losses, R = games left. The grid on the right holds the games left between each pair (it stays symmetric).</p>
<div class="row">
<label for="team">Team to test:</label>
<select id="team"></select>
</div>
<div class="row">
<button id="step" type="button">Next augmenting path</button>
<button id="run" type="button">Run to the end</button>
<button id="reset" type="button" class="ghost">Reset flow</button>
</div>
<div class="status" id="status" aria-live="polite"></div>
<div class="scroll"><svg id="net" class="net" viewBox="0 0 640 160" role="img" aria-label="Flow network: source, one node per remaining game, one node per team, sink"></svg></div>
<p class="legend">Left to right: source s, one node per pair of other teams with games left (capacity = games left), one node per team, sink t (capacity = wins that team may still add). Orange = current augmenting path, blue = carries flow, green = full edge, red dashed = minimum cut.</p>
<div class="cert" id="cert" aria-live="polite"></div>
</div>
/* Colours follow the light or dark preference of the visitor. */
:root { --fg:#1f2933; --bg:#ffffff; --mut:#52606d; --line:#c3ccd6; --acc:#1d3557; --ok:#0a7d33; --bad:#c92f3c; --pth:#e8890c; --soft:#eef2f6; --fl:#2a6fb0; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6ebf0; --bg:#1b2430; --mut:#a7b4c2; --line:#46566a; --acc:#7fb0e6; --ok:#4cc27a; --bad:#ff7a85; --pth:#ffb347; --soft:#243243; --fl:#6fb3f2; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; }
.wrap { padding: .6rem; }
.hint { font-size: .9rem; color: var(--mut); margin: 0 0 .6rem; line-height: 1.45; }
.note, .legend { font-size: .8rem; color: var(--mut); margin: .3rem 0; line-height: 1.4; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; }
.scroll { overflow-x: auto; }
.tbl { border-collapse: collapse; font-size: .85rem; }
.tbl th, .tbl td { padding: 2px 4px; text-align: center; border-bottom: 1px solid var(--line); }
.tbl th { color: var(--mut); font-weight: 600; }
.tbl .nm { text-align: left; white-space: nowrap; font-weight: 600; padding-right: .6rem; }
.tbl .rem { font-weight: 700; color: var(--acc); }
.tbl .dg { color: var(--mut); }
.tbl input { width: 3.1em; padding: 3px 2px; text-align: center; font: inherit; color: var(--fg); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; }
label { font-size: .9rem; font-weight: 600; }
select { font: inherit; padding: .3rem .5rem; color: var(--fg); background: var(--soft); border: 1px solid var(--line); border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--acc); background: var(--acc); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--acc); }
button:disabled { opacity: .45; cursor: default; }
.status { font-size: .95rem; font-weight: 600; margin: .4rem 0; min-height: 2.8em; line-height: 1.4; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.net { display: block; width: 100%; min-width: 520px; height: auto; background: var(--soft); border-radius: 8px; }
.ed { stroke: var(--line); stroke-width: 1.4; }
.ed.fl { stroke: var(--fl); stroke-width: 2.2; }
.ed.sat { stroke: var(--ok); }
.ed.pth { stroke: var(--pth); stroke-width: 4; }
.ed.cut { stroke: var(--bad); stroke-dasharray: 5 4; stroke-width: 3; }
.nd { fill: var(--bg); stroke: var(--acc); stroke-width: 1.6; }
.nd.s, .nd.t { fill: var(--acc); }
.nd.inS { stroke: var(--bad); stroke-width: 3; }
.nl { font: 600 10px system-ui, sans-serif; fill: var(--fg); pointer-events: none; }
.nd.s + .nl, .nd.t + .nl { fill: var(--bg); }
.el { font: 600 10px system-ui, sans-serif; fill: var(--mut); paint-order: stroke; stroke: var(--soft); stroke-width: 3px; }
.cap { font: 600 10px system-ui, sans-serif; fill: var(--mut); }
.cert { font-size: .9rem; line-height: 1.5; margin-top: .4rem; }
.cert:not(:empty) { padding: .5rem .7rem; border-left: 4px solid var(--bad); background: var(--soft); border-radius: 6px; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const INF = 1000000;
// Core: builds the flow network and solves it with Edmonds-Karp (shortest augmenting paths found by breadth-first search).
const remOf = (g, i) => g[i].reduce((a, b) => a + b, 0);
function buildNet(teams, g, x) {
const n = teams.length;
const maxW = teams[x].w + remOf(g, x);
const net = { x, maxW, nodes: [], edges: [], adj: [], total: 0, trivial: -1, flow: 0 };
for (let i = 0; i < n; i++) if (i !== x && teams[i].w > maxW && net.trivial < 0) net.trivial = i;
const addNode = (o) => { net.nodes.push(o); net.adj.push([]); return net.nodes.length - 1; };
const addEdge = (u, v, cap) => {
net.edges.push({ u, v, cap, flow: 0 }); net.adj[u].push(net.edges.length - 1);
net.edges.push({ u: v, v: u, cap: 0, flow: 0 }); net.adj[v].push(net.edges.length - 1);
};
const s = addNode({ type: 's' });
const others = [];
for (let i = 0; i < n; i++) if (i !== x) others.push(i);
const gameNodes = [];
for (let a = 0; a < others.length; a++) for (let b = a + 1; b < others.length; b++) {
const i = others[a], j = others[b];
if (g[i][j] > 0) gameNodes.push({ i, j, c: g[i][j] });
}
const gid = gameNodes.map((gm) => addNode({ type: 'g', i: gm.i, j: gm.j, c: g[gm.i][gm.j] }));
const tid = {};
others.forEach((i) => { tid[i] = addNode({ type: 'T', i }); });
const t = addNode({ type: 't' });
net.s = s; net.t = t;
gameNodes.forEach((gm, k) => {
addEdge(s, gid[k], gm.c); net.total += gm.c;
addEdge(gid[k], tid[gm.i], INF); addEdge(gid[k], tid[gm.j], INF);
});
others.forEach((i) => addEdge(tid[i], t, Math.max(0, maxW - teams[i].w)));
return net;
}
function bfs(net) {
const par = new Array(net.nodes.length).fill(-1);
const seen = new Array(net.nodes.length).fill(false);
seen[net.s] = true; const q = [net.s];
while (q.length) {
const u = q.shift();
for (const ei of net.adj[u]) {
const e = net.edges[ei];
if (!seen[e.v] && e.cap - e.flow > 0) { seen[e.v] = true; par[e.v] = ei; q.push(e.v); }
}
}
if (!seen[net.t]) return { path: null, seen };
const path = []; let v = net.t;
while (v !== net.s) { path.push(par[v]); v = net.edges[par[v]].u; }
return { path: path.reverse(), seen };
}
function augment(net) {
const r = bfs(net);
if (!r.path) { net.seen = r.seen; return null; }
let d = Infinity;
for (const ei of r.path) d = Math.min(d, net.edges[ei].cap - net.edges[ei].flow);
for (const ei of r.path) { net.edges[ei].flow += d; net.edges[ei ^ 1].flow -= d; }
net.flow += d;
return { path: r.path, delta: d };
}
function certificate(teams, g, net) {
const R = [];
net.nodes.forEach((nd, k) => { if (nd.type === 'T' && net.seen[k]) R.push(nd.i); });
let wR = 0, gR = 0;
R.forEach((i) => { wR += teams[i].w; });
for (let a = 0; a < R.length; a++) for (let b = a + 1; b < R.length; b++) gR += g[R[a]][R[b]];
return { R, wR, gR };
}
// Interface code: presets, table editing, drawing and buttons.
const PRESETS = {
classic: { x: 1, teams: [
{ name: 'Atlanta', ab: 'ATL', w: 83, l: 71 }, { name: 'Philadelphia', ab: 'PHI', w: 80, l: 79 },
{ name: 'New York', ab: 'NY', w: 78, l: 78 }, { name: 'Montreal', ab: 'MON', w: 77, l: 82 }],
g: [[0, 1, 6, 1], [1, 0, 0, 2], [6, 0, 0, 0], [1, 2, 0, 0]] },
five: { x: 3, teams: ['A', 'B', 'C', 'D', 'E'].map((c, i) => ({ name: c, ab: c, w: [68, 68, 66, 65, 63][i], l: 0 })),
g: [[0, 0, 3, 2, 2], [0, 0, 3, 1, 1], [3, 3, 0, 0, 1], [2, 1, 0, 0, 1], [2, 1, 1, 1, 0]] },
alive: { x: 2, teams: null, g: null }
};
PRESETS.five.teams.forEach((t, i) => { t.l = 162 - t.w - remOf(PRESETS.five.g, i); });
PRESETS.alive.teams = PRESETS.five.teams; PRESETS.alive.g = PRESETS.five.g;
let TEAMS, G, X, NET, LAST = null, DONE = false, STEPS = 0;
const $ = (id) => document.getElementById(id);
const ab = (i) => TEAMS[i].ab;
function loadPreset(key) {
const p = PRESETS[key];
TEAMS = p.teams.map((t) => ({ name: t.name, ab: t.ab, w: t.w, l: t.l }));
G = p.g.map((r) => r.slice()); X = p.x;
renderTable(); renderSelect(); rebuild();
}
function renderSelect() {
$('team').innerHTML = TEAMS.map((t, i) => '<option value="' + i + '"' + (i === X ? ' selected' : '') + '>' + t.name + '</option>').join('');
}
function renderTable() {
const n = TEAMS.length;
let h = '<tr><th>Team</th><th>W</th><th>L</th><th>R</th>';
for (let j = 0; j < n; j++) h += '<th class="gh">' + ab(j) + '</th>';
h += '</tr>';
for (let i = 0; i < n; i++) {
h += '<tr><td class="nm">' + TEAMS[i].name + '</td>';
h += '<td><input type="number" min="0" max="199" data-k="w" data-i="' + i + '" value="' + TEAMS[i].w + '"></td>';
h += '<td><input type="number" min="0" max="199" data-k="l" data-i="' + i + '" value="' + TEAMS[i].l + '"></td>';
h += '<td class="rem" id="rem' + i + '">' + remOf(G, i) + '</td>';
for (let j = 0; j < n; j++) {
h += i === j ? '<td class="dg">·</td>' : '<td><input type="number" min="0" max="30" data-k="g" data-i="' + i + '" data-j="' + j + '" value="' + G[i][j] + '"></td>';
}
h += '</tr>';
}
$('tbl').innerHTML = h;
}
function onEdit(ev) {
const el = ev.target; if (!el.dataset || !el.dataset.k) return;
let v = parseInt(el.value, 10); if (isNaN(v) || v < 0) v = 0;
const i = +el.dataset.i;
if (el.dataset.k === 'g') {
const j = +el.dataset.j; v = Math.min(v, 30); G[i][j] = v; G[j][i] = v;
const mirror = document.querySelector('input[data-k="g"][data-i="' + j + '"][data-j="' + i + '"]');
if (mirror) mirror.value = v;
for (let r = 0; r < TEAMS.length; r++) $('rem' + r).textContent = remOf(G, r);
} else { v = Math.min(v, 199); TEAMS[i][el.dataset.k] = v; }
rebuild();
}
function rebuild() {
NET = buildNet(TEAMS, G, X); LAST = null; DONE = false; STEPS = 0;
$('cert').innerHTML = '';
if (NET.trivial >= 0) { finish(); return; }
setStatus(fmt("%team% can reach at most %w% + %r% = %max% wins. The other teams still play %g% games among themselves, and each win must be given to someone without pushing that team above %max%.", { team: TEAMS[X].name, w: TEAMS[X].w, r: remOf(G, X), max: NET.maxW, g: NET.total }), '');
draw(); buttons();
}
function nodeName(k) {
const nd = NET.nodes[k];
if (nd.type === 's') return 's';
if (nd.type === 't') return 't';
if (nd.type === 'T') return ab(nd.i);
return ab(nd.i) + '–' + ab(nd.j);
}
function step() {
if (DONE) return;
const r = augment(NET);
if (!r) { finish(); return; }
STEPS++; LAST = r.path;
const names = [nodeName(NET.edges[r.path[0]].u)].concat(r.path.map((ei) => nodeName(NET.edges[ei].v)));
setStatus(fmt("Path %n%: %path% (pushes %d%). Flow so far: %f% of %t%.", { n: STEPS, path: names.join(' → '), d: r.delta, f: NET.flow, t: NET.total }), '');
draw(); buttons();
}
function runAll() { let guard = 0; while (!DONE && guard++ < 500) { step(); } }
function finish() {
DONE = true; LAST = null;
const nm = TEAMS[X].name;
if (NET.trivial >= 0) {
const o = TEAMS[NET.trivial];
setStatus(fmt("%team% is eliminated trivially: winning every remaining game gives only %max% wins, but %other% already has %ow%.", { team: nm, max: NET.maxW, other: o.name, ow: o.w }), 'bad');
$('cert').innerHTML = '';
$('net').innerHTML = ''; buttons(); return;
}
NET.seen = bfs(NET).seen;
if (NET.flow < NET.total) {
const c = certificate(TEAMS, G, NET);
const k = c.R.length, tot = c.wR + c.gR, best = Math.ceil(tot / k);
const val = Math.round(tot / k * 100) / 100;
setStatus(fmt("Maximum flow %f% < %t%: some games cannot be assigned. %team% is eliminated.", { team: nm, f: NET.flow, t: NET.total }), 'bad');
$('cert').innerHTML = '<b>' + "Certificate (minimum cut)" + '</b><br>' +
fmt("R = {%R%}: (w(R) + g(R)) / |R| = (%w% + %g%) / %k% = %v% > %max%", { R: c.R.map(ab).join(', '), w: c.wR, g: c.gR, k: k, v: val, max: NET.maxW }) + '<br>' +
fmt("The teams in R will finish with %tot% wins between them (their current wins plus one winner for each game they play against each other), so the best of them ends with at least %best% wins, above the ceiling of %max%.", { tot: tot, best: best, max: NET.maxW });
} else {
setStatus(fmt("Maximum flow %f% = %t%: every remaining game can be assigned without anyone passing the ceiling. %team% is not eliminated (it can still tie for first).", { team: nm, f: NET.flow, t: NET.total }), 'ok');
$('cert').innerHTML = '';
}
draw(); buttons();
}
function setStatus(t, cls) { const s = $('status'); s.textContent = t; s.className = 'status ' + cls; }
function buttons() { $('step').disabled = DONE; $('run').disabled = DONE; }
function draw() {
const nodes = NET.nodes, gl = [], tl = [];
nodes.forEach((nd, k) => { if (nd.type === 'g') gl.push(k); if (nd.type === 'T') tl.push(k); });
const rows = Math.max(gl.length, tl.length, 3), rh = 40, H = rows * rh + 24;
const pos = {};
pos[NET.s] = [36, H / 2]; pos[NET.t] = [604, H / 2];
gl.forEach((k, a) => { pos[k] = [200, 12 + (a + 0.5) * (H - 24) / gl.length]; });
tl.forEach((k, a) => { pos[k] = [420, 12 + (a + 0.5) * (H - 24) / tl.length]; });
const onPath = new Set((LAST || []).map((e) => e & ~1));
const cutS = DONE && NET.seen && NET.flow < NET.total;
let svg = '';
NET.edges.forEach((e, ei) => {
if (ei & 1) return;
const p1 = pos[e.u], p2 = pos[e.v];
const inf = e.cap >= INF, sat = !inf && e.flow === e.cap && e.cap > 0;
let cls = 'ed';
if (e.flow > 0) cls += ' fl';
if (sat) cls += ' sat';
if (onPath.has(ei)) cls += ' pth';
if (cutS && NET.seen[e.u] && !NET.seen[e.v]) cls += ' cut';
svg += '<line class="' + cls + '" x1="' + p1[0] + '" y1="' + p1[1] + '" x2="' + p2[0] + '" y2="' + p2[1] + '"/>';
let lab = '';
if (!inf) lab = e.flow + '/' + e.cap; else if (e.flow > 0) lab = String(e.flow);
if (lab) {
const f = 0.5;
svg += '<text class="el" x="' + (p1[0] + (p2[0] - p1[0]) * f) + '" y="' + (p1[1] + (p2[1] - p1[1]) * f - 3) + '" text-anchor="middle">' + lab + '</text>';
}
});
nodes.forEach((nd, k) => {
const x = pos[k][0], y = pos[k][1];
let cls = 'nd ' + nd.type;
if (cutS && NET.seen[k]) cls += ' inS';
const lab = nodeName(k);
if (nd.type === 'g') svg += '<rect class="' + cls + '" x="' + (x - 28) + '" y="' + (y - 12) + '" width="56" height="24" rx="6"/>';
else svg += '<circle class="' + cls + '" cx="' + x + '" cy="' + y + '" r="' + (nd.type === 'T' ? 17 : 13) + '"/>';
svg += '<text class="nl" x="' + x + '" y="' + (y + 4) + '" text-anchor="middle">' + lab + '</text>';
});
svg += '<text class="cap" x="200" y="9" text-anchor="middle">games</text><text class="cap" x="420" y="9" text-anchor="middle">teams</text>';
$('net').setAttribute('viewBox', '0 0 640 ' + (H + 6));
$('net').innerHTML = svg;
}
$('tbl').addEventListener('change', onEdit);
$('team').addEventListener('change', () => { X = +$('team').value; rebuild(); });
$('step').addEventListener('click', step);
$('run').addEventListener('click', runAll);
$('reset').addEventListener('click', rebuild);
$('p_classic').addEventListener('click', () => loadPreset('classic'));
$('p_five').addEventListener('click', () => loadPreset('five'));
$('p_alive').addEventListener('click', () => loadPreset('alive'));
loadPreset('classic');
Things to try. In the classic four-team preset, Philadelphia is eliminated even though it is within three wins of the leader, and Montreal is eliminated trivially (select it and read the message). Then load the five-team subtle case and switch between teams: D is out because A, B and C collectively must out-win its ceiling, yet none of them is individually decisive. Finally change one number of the remaining-games grid and see the verdict flip.
Comments
Loading comments...