<div class="layout">
<div class="info-bar">
<span id="phase-label">Phase: 0</span>
<span id="match-label">Matched: 0 / 0</span>
<span id="status-label"></span>
</div>
<svg id="graph" width="100%" viewBox="0 0 420 320"></svg>
<div class="btns">
<button id="btn-phase" type="button">Next Phase</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="leg-unmatched">โโ unmatched edge</span>
<span class="leg-matched">โโ matched edge</span>
<span class="leg-aug">โโ augmenting path</span>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.layout { display: flex; flex-direction: column; gap: .5rem; padding: .5rem; }
.info-bar { display: flex; gap: 1.2rem; font-size: .9rem; font-weight: 600; flex-wrap: wrap; }
#phase-label { color: #1d3557; }
#match-label { color: #0a7d33; }
#status-label { color: #e76f00; }
svg { border: 1px solid #dde3ea; border-radius: 8px; background: #f8fafc; display: block; max-height: 300px; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .4rem .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; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .78rem; color: #555; }
.leg-unmatched::before { content: ""; display: inline-block; width: 22px; height: 2px;
background: #adb5c3; vertical-align: middle; margin-right: 4px; }
.leg-matched::before { content: ""; display: inline-block; width: 22px; height: 3px;
background: #1d6edc; vertical-align: middle; margin-right: 4px; }
.leg-aug::before { content: ""; display: inline-block; width: 22px; height: 2px;
background: #e76f00; border-top: 2px dashed #e76f00; vertical-align: middle; margin-right: 4px; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Hopcroft-Karp algorithm visualised step by step.
// Left nodes = applicants (A0-A4), right nodes = jobs (J0-J4).
// Each "Next Phase" runs one full BFS+DFS phase of Hopcroft-Karp,
// highlighting the augmenting paths found before flipping them.
const SVG_NS = 'http://www.w3.org/2000/svg';
const W = 420, H = 320;
const LX = 80, RX = 340; // x positions for left / right columns
// โโ graph state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let nL, nR, adj, matchL, matchR, phase, done, augPaths;
function randomGraph() {
nL = 5; nR = 5;
adj = Array.from({length: nL}, () => []);
// Ensure graph is connected enough to be interesting (at least 2 edges per left node)
const edges = new Set();
// Guarantee a perfect matching seed so the demo is always solvable
const perm = [0,1,2,3,4].sort(() => Math.random()-.5);
for (let i = 0; i < nL; i++) {
edges.add(i + ',' + perm[i]);
const extra = Math.floor(Math.random() * 2) + 1;
for (let k = 0; k < extra; k++) {
const j = Math.floor(Math.random() * nR);
edges.add(i + ',' + j);
}
}
edges.forEach(e => {
const [l, r] = e.split(',').map(Number);
adj[l].push(r);
});
matchL = new Array(nL).fill(-1);
matchR = new Array(nR).fill(-1);
phase = 0;
done = false;
augPaths = [];
}
// โโ Hopcroft-Karp internals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const INF = 1e9;
let distL;
function bfs() {
distL = new Array(nL).fill(INF);
const queue = [];
for (let u = 0; u < nL; u++) {
if (matchL[u] === -1) { distL[u] = 0; queue.push(u); }
}
let found = false;
let qi = 0;
while (qi < queue.length) {
const u = queue[qi++];
for (const v of adj[u]) {
const w = matchR[v]; // the left node matched to v (or -1)
if (w === -1) { found = true; }
else if (distL[w] === INF) { distL[w] = distL[u] + 1; queue.push(w); }
}
}
return found;
}
// DFS that also records the path taken
function dfs(u, path) {
for (const v of adj[u]) {
const w = matchR[v];
if (w === -1 || (distL[w] === distL[u] + 1 && dfs(w, path))) {
path.push([u, v]);
matchL[u] = v;
matchR[v] = u;
return true;
}
}
distL[u] = INF;
return false;
}
function runPhase() {
// Returns array of augmenting paths found this phase.
if (!bfs()) return null; // no augmenting path => done
const paths = [];
// We need to capture paths before flipping, so we use a two-pass approach:
// first collect paths by running dfs on a copy, then actually flip.
// Simpler: record edges inside dfs.
const savedMatchL = matchL.slice();
const savedMatchR = matchR.slice();
for (let u = 0; u < nL; u++) {
if (matchL[u] === -1) {
const path = [];
if (dfs(u, path)) {
// path is filled in reverse by the recursion; edges are (leftNode, rightNode) pairs
paths.push(path.slice());
}
}
}
return paths; // matchL/matchR already updated by dfs
}
// โโ layout helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function ly(i) { return 40 + i * ((H - 80) / (nL - 1)); }
function ry(j) { return 40 + j * ((H - 80) / (nR - 1)); }
// โโ SVG rendering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function clearSVG() {
const svg = document.getElementById('graph');
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function makeLine(x1, y1, x2, y2, cls, dashed) {
const el = document.createElementNS(SVG_NS, 'line');
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
el.setAttribute('class', cls);
if (dashed) el.setAttribute('stroke-dasharray', '6 4');
return el;
}
function makeCircle(cx, cy, r, cls) {
const el = document.createElementNS(SVG_NS, 'circle');
el.setAttribute('cx', cx); el.setAttribute('cy', cy); el.setAttribute('r', r);
el.setAttribute('class', cls); return el;
}
function makeText(x, y, txt, cls) {
const el = document.createElementNS(SVG_NS, 'text');
el.setAttribute('x', x); el.setAttribute('y', y);
el.setAttribute('class', cls); el.textContent = txt; return el;
}
// Build a set of (l,r) pairs that are on an augmenting path this phase
function augEdgeSet(paths) {
const s = new Set();
paths.forEach(path => path.forEach(([l, r]) => s.add(l + ',' + r)));
return s;
}
function render(highlightPaths) {
clearSVG();
const svg = document.getElementById('graph');
// Add SVG styles
const style = document.createElementNS(SVG_NS, 'style');
style.textContent = `
.edge-unmatched { stroke: #adb5c3; stroke-width: 1.5; }
.edge-matched { stroke: #1d6edc; stroke-width: 2.5; }
.edge-aug { stroke: #e76f00; stroke-width: 2; }
.node-left { fill: #dce8f7; stroke: #1d3557; stroke-width: 1.5; }
.node-right { fill: #e8f5e9; stroke: #2e7d32; stroke-width: 1.5; }
.node-matched-l { fill: #1d6edc; stroke: #1d3557; stroke-width: 1.5; }
.node-matched-r { fill: #0a7d33; stroke: #2e7d32; stroke-width: 1.5; }
.node-aug-l { fill: #e76f00; stroke: #b34700; stroke-width: 1.5; }
.node-aug-r { fill: #e76f00; stroke: #b34700; stroke-width: 1.5; }
.lbl-left { font: 600 12px system-ui; fill: #1d3557; text-anchor: middle; dominant-baseline: central; }
.lbl-right { font: 600 12px system-ui; fill: #2e7d32; text-anchor: middle; dominant-baseline: central; }
.lbl-left-matched { font: 600 12px system-ui; fill: #fff; text-anchor: middle; dominant-baseline: central; }
.lbl-right-matched { font: 600 12px system-ui; fill: #fff; text-anchor: middle; dominant-baseline: central; }
`;
svg.appendChild(style);
const augSet = highlightPaths ? augEdgeSet(highlightPaths) : new Set();
const augLeftSet = new Set();
const augRightSet = new Set();
if (highlightPaths) {
highlightPaths.forEach(path => path.forEach(([l, r]) => { augLeftSet.add(l); augRightSet.add(r); }));
}
// Draw edges
for (let u = 0; u < nL; u++) {
for (const v of adj[u]) {
const isMatched = matchL[u] === v;
const isAug = augSet.has(u + ',' + v);
let cls = isAug ? 'edge-aug' : (isMatched ? 'edge-matched' : 'edge-unmatched');
svg.appendChild(makeLine(LX, ly(u), RX, ry(v), cls, isAug && !isMatched));
}
}
// Draw left nodes
for (let u = 0; u < nL; u++) {
const y = ly(u);
const isAug = augLeftSet.has(u);
const isMatched = matchL[u] !== -1 && !isAug;
const nodeCls = isAug ? 'node-aug-l' : (isMatched ? 'node-matched-l' : 'node-left');
const lblCls = (isMatched || isAug) ? 'lbl-left-matched' : 'lbl-left';
svg.appendChild(makeCircle(LX, y, 18, nodeCls));
svg.appendChild(makeText(LX, y, 'A' + u, lblCls));
}
// Draw right nodes
for (let j = 0; j < nR; j++) {
const y = ry(j);
const isAug = augRightSet.has(j);
const isMatched = matchR[j] !== -1 && !isAug;
const nodeCls = isAug ? 'node-aug-r' : (isMatched ? 'node-matched-r' : 'node-right');
const lblCls = (isMatched || isAug) ? 'lbl-right-matched' : 'lbl-right';
svg.appendChild(makeCircle(RX, y, 18, nodeCls));
svg.appendChild(makeText(RX, y, 'J' + j, lblCls));
}
// Column labels
const lblY = H - 8;
const lbl1 = makeText(LX, lblY, 'Applicants', 'lbl-left');
lbl1.setAttribute('font-size', '10'); svg.appendChild(lbl1);
const lbl2 = makeText(RX, lblY, 'Jobs', 'lbl-right');
lbl2.setAttribute('font-size', '10'); svg.appendChild(lbl2);
}
function updateInfo(statusText) {
const matched = matchL.filter(v => v !== -1).length;
document.getElementById('phase-label').textContent = fmt('Phase: %phase%', { phase: phase });
document.getElementById('match-label').textContent = fmt('Matched: %matched% / %nL%', { matched: matched, nL: nL });
document.getElementById('status-label').textContent = statusText || '';
}
// โโ button handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function onPhase() {
if (done) return;
const paths = runPhase();
phase++;
if (!paths || paths.length === 0) {
done = true;
render(null);
updateInfo('Maximum matching reached!');
document.getElementById('btn-phase').disabled = true;
return;
}
// Show augmenting paths briefly, then settle
render(paths);
updateInfo(fmt('Phase %phase%: %length% %v% found', { phase: phase, length: paths.length, v: paths.length > 1 ? 'augmenting paths' : 'augmenting path' }));
}
function onReset() {
randomGraph();
render(null);
updateInfo('');
document.getElementById('btn-phase').disabled = false;
}
document.getElementById('btn-phase').addEventListener('click', onPhase);
document.getElementById('btn-reset').addEventListener('click', onReset);
onReset();
// Expose for node verification
if (typeof module !== 'undefined') {
module.exports = { randomGraph, runPhase, matchL: () => matchL, matchR: () => matchR };
}
<div class="layout">
<div class="info-bar">
<span id="phase-label">Phase: 0</span>
<span id="match-label">Matched: 0 / 0</span>
<span id="status-label"></span>
</div>
<svg id="graph" width="100%" viewBox="0 0 420 320"></svg>
<div class="btns">
<button id="btn-phase" type="button">Next Phase</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="leg-unmatched">โโ unmatched edge</span>
<span class="leg-matched">โโ matched edge</span>
<span class="leg-aug">โโ augmenting path</span>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.layout { display: flex; flex-direction: column; gap: .5rem; padding: .5rem; }
.info-bar { display: flex; gap: 1.2rem; font-size: .9rem; font-weight: 600; flex-wrap: wrap; }
#phase-label { color: #1d3557; }
#match-label { color: #0a7d33; }
#status-label { color: #e76f00; }
svg { border: 1px solid #dde3ea; border-radius: 8px; background: #f8fafc; display: block; max-height: 300px; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .4rem .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; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .78rem; color: #555; }
.leg-unmatched::before { content: ""; display: inline-block; width: 22px; height: 2px;
background: #adb5c3; vertical-align: middle; margin-right: 4px; }
.leg-matched::before { content: ""; display: inline-block; width: 22px; height: 3px;
background: #1d6edc; vertical-align: middle; margin-right: 4px; }
.leg-aug::before { content: ""; display: inline-block; width: 22px; height: 2px;
background: #e76f00; border-top: 2px dashed #e76f00; vertical-align: middle; margin-right: 4px; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Hopcroft-Karp algorithm visualised step by step.
// Left nodes = applicants (A0-A4), right nodes = jobs (J0-J4).
// Each "Next Phase" runs one full BFS+DFS phase of Hopcroft-Karp,
// highlighting the augmenting paths found before flipping them.
const SVG_NS = 'http://www.w3.org/2000/svg';
const W = 420, H = 320;
const LX = 80, RX = 340; // x positions for left / right columns
// โโ graph state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let nL, nR, adj, matchL, matchR, phase, done, augPaths;
function randomGraph() {
nL = 5; nR = 5;
adj = Array.from({length: nL}, () => []);
// Ensure graph is connected enough to be interesting (at least 2 edges per left node)
const edges = new Set();
// Guarantee a perfect matching seed so the demo is always solvable
const perm = [0,1,2,3,4].sort(() => Math.random()-.5);
for (let i = 0; i < nL; i++) {
edges.add(i + ',' + perm[i]);
const extra = Math.floor(Math.random() * 2) + 1;
for (let k = 0; k < extra; k++) {
const j = Math.floor(Math.random() * nR);
edges.add(i + ',' + j);
}
}
edges.forEach(e => {
const [l, r] = e.split(',').map(Number);
adj[l].push(r);
});
matchL = new Array(nL).fill(-1);
matchR = new Array(nR).fill(-1);
phase = 0;
done = false;
augPaths = [];
}
// โโ Hopcroft-Karp internals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const INF = 1e9;
let distL;
function bfs() {
distL = new Array(nL).fill(INF);
const queue = [];
for (let u = 0; u < nL; u++) {
if (matchL[u] === -1) { distL[u] = 0; queue.push(u); }
}
let found = false;
let qi = 0;
while (qi < queue.length) {
const u = queue[qi++];
for (const v of adj[u]) {
const w = matchR[v]; // the left node matched to v (or -1)
if (w === -1) { found = true; }
else if (distL[w] === INF) { distL[w] = distL[u] + 1; queue.push(w); }
}
}
return found;
}
// DFS that also records the path taken
function dfs(u, path) {
for (const v of adj[u]) {
const w = matchR[v];
if (w === -1 || (distL[w] === distL[u] + 1 && dfs(w, path))) {
path.push([u, v]);
matchL[u] = v;
matchR[v] = u;
return true;
}
}
distL[u] = INF;
return false;
}
function runPhase() {
// Returns array of augmenting paths found this phase.
if (!bfs()) return null; // no augmenting path => done
const paths = [];
// We need to capture paths before flipping, so we use a two-pass approach:
// first collect paths by running dfs on a copy, then actually flip.
// Simpler: record edges inside dfs.
const savedMatchL = matchL.slice();
const savedMatchR = matchR.slice();
for (let u = 0; u < nL; u++) {
if (matchL[u] === -1) {
const path = [];
if (dfs(u, path)) {
// path is filled in reverse by the recursion; edges are (leftNode, rightNode) pairs
paths.push(path.slice());
}
}
}
return paths; // matchL/matchR already updated by dfs
}
// โโ layout helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function ly(i) { return 40 + i * ((H - 80) / (nL - 1)); }
function ry(j) { return 40 + j * ((H - 80) / (nR - 1)); }
// โโ SVG rendering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function clearSVG() {
const svg = document.getElementById('graph');
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function makeLine(x1, y1, x2, y2, cls, dashed) {
const el = document.createElementNS(SVG_NS, 'line');
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
el.setAttribute('class', cls);
if (dashed) el.setAttribute('stroke-dasharray', '6 4');
return el;
}
function makeCircle(cx, cy, r, cls) {
const el = document.createElementNS(SVG_NS, 'circle');
el.setAttribute('cx', cx); el.setAttribute('cy', cy); el.setAttribute('r', r);
el.setAttribute('class', cls); return el;
}
function makeText(x, y, txt, cls) {
const el = document.createElementNS(SVG_NS, 'text');
el.setAttribute('x', x); el.setAttribute('y', y);
el.setAttribute('class', cls); el.textContent = txt; return el;
}
// Build a set of (l,r) pairs that are on an augmenting path this phase
function augEdgeSet(paths) {
const s = new Set();
paths.forEach(path => path.forEach(([l, r]) => s.add(l + ',' + r)));
return s;
}
function render(highlightPaths) {
clearSVG();
const svg = document.getElementById('graph');
// Add SVG styles
const style = document.createElementNS(SVG_NS, 'style');
style.textContent = `
.edge-unmatched { stroke: #adb5c3; stroke-width: 1.5; }
.edge-matched { stroke: #1d6edc; stroke-width: 2.5; }
.edge-aug { stroke: #e76f00; stroke-width: 2; }
.node-left { fill: #dce8f7; stroke: #1d3557; stroke-width: 1.5; }
.node-right { fill: #e8f5e9; stroke: #2e7d32; stroke-width: 1.5; }
.node-matched-l { fill: #1d6edc; stroke: #1d3557; stroke-width: 1.5; }
.node-matched-r { fill: #0a7d33; stroke: #2e7d32; stroke-width: 1.5; }
.node-aug-l { fill: #e76f00; stroke: #b34700; stroke-width: 1.5; }
.node-aug-r { fill: #e76f00; stroke: #b34700; stroke-width: 1.5; }
.lbl-left { font: 600 12px system-ui; fill: #1d3557; text-anchor: middle; dominant-baseline: central; }
.lbl-right { font: 600 12px system-ui; fill: #2e7d32; text-anchor: middle; dominant-baseline: central; }
.lbl-left-matched { font: 600 12px system-ui; fill: #fff; text-anchor: middle; dominant-baseline: central; }
.lbl-right-matched { font: 600 12px system-ui; fill: #fff; text-anchor: middle; dominant-baseline: central; }
`;
svg.appendChild(style);
const augSet = highlightPaths ? augEdgeSet(highlightPaths) : new Set();
const augLeftSet = new Set();
const augRightSet = new Set();
if (highlightPaths) {
highlightPaths.forEach(path => path.forEach(([l, r]) => { augLeftSet.add(l); augRightSet.add(r); }));
}
// Draw edges
for (let u = 0; u < nL; u++) {
for (const v of adj[u]) {
const isMatched = matchL[u] === v;
const isAug = augSet.has(u + ',' + v);
let cls = isAug ? 'edge-aug' : (isMatched ? 'edge-matched' : 'edge-unmatched');
svg.appendChild(makeLine(LX, ly(u), RX, ry(v), cls, isAug && !isMatched));
}
}
// Draw left nodes
for (let u = 0; u < nL; u++) {
const y = ly(u);
const isAug = augLeftSet.has(u);
const isMatched = matchL[u] !== -1 && !isAug;
const nodeCls = isAug ? 'node-aug-l' : (isMatched ? 'node-matched-l' : 'node-left');
const lblCls = (isMatched || isAug) ? 'lbl-left-matched' : 'lbl-left';
svg.appendChild(makeCircle(LX, y, 18, nodeCls));
svg.appendChild(makeText(LX, y, 'A' + u, lblCls));
}
// Draw right nodes
for (let j = 0; j < nR; j++) {
const y = ry(j);
const isAug = augRightSet.has(j);
const isMatched = matchR[j] !== -1 && !isAug;
const nodeCls = isAug ? 'node-aug-r' : (isMatched ? 'node-matched-r' : 'node-right');
const lblCls = (isMatched || isAug) ? 'lbl-right-matched' : 'lbl-right';
svg.appendChild(makeCircle(RX, y, 18, nodeCls));
svg.appendChild(makeText(RX, y, 'J' + j, lblCls));
}
// Column labels
const lblY = H - 8;
const lbl1 = makeText(LX, lblY, 'Applicants', 'lbl-left');
lbl1.setAttribute('font-size', '10'); svg.appendChild(lbl1);
const lbl2 = makeText(RX, lblY, 'Jobs', 'lbl-right');
lbl2.setAttribute('font-size', '10'); svg.appendChild(lbl2);
}
function updateInfo(statusText) {
const matched = matchL.filter(v => v !== -1).length;
document.getElementById('phase-label').textContent = fmt('Phase: %phase%', { phase: phase });
document.getElementById('match-label').textContent = fmt('Matched: %matched% / %nL%', { matched: matched, nL: nL });
document.getElementById('status-label').textContent = statusText || '';
}
// โโ button handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function onPhase() {
if (done) return;
const paths = runPhase();
phase++;
if (!paths || paths.length === 0) {
done = true;
render(null);
updateInfo('Maximum matching reached!');
document.getElementById('btn-phase').disabled = true;
return;
}
// Show augmenting paths briefly, then settle
render(paths);
updateInfo(fmt('Phase %phase%: %length% %v% found', { phase: phase, length: paths.length, v: paths.length > 1 ? 'augmenting paths' : 'augmenting path' }));
}
function onReset() {
randomGraph();
render(null);
updateInfo('');
document.getElementById('btn-phase').disabled = false;
}
document.getElementById('btn-phase').addEventListener('click', onPhase);
document.getElementById('btn-reset').addEventListener('click', onReset);
onReset();
// Expose for node verification
if (typeof module !== 'undefined') {
module.exports = { randomGraph, runPhase, matchL: () => matchL, matchR: () => matchR };
}
Comments
Loading comments...