Matching applications
Matching models many pairing problems: jobs and workers, students and projects with limited seats, dominoes on a board, tasks with deadlines, and the minimum number of paths that cover a DAG.
Learning goals
- Model assignment problems as bipartite matchings
- Handle capacities by duplicating vertices
- Model board tilings by colouring cells
- Compute a minimum path cover of a DAG by matching
Prerequisites
The hard part of using matching is rarely the algorithm. It is recognising that a problem is a matching in disguise and drawing the right two sides. This lesson goes through five standard disguises.
Job assignment
The simplest application: W workers, J jobs, and a list of which worker can do which job. Draw workers on the left, jobs on the right, and an edge for every “can do”. A maximum matching gives the largest number of jobs that can be done simultaneously, one worker per job, one job per worker.
The code runs Kuhn's algorithm on the “can do” lists and returns the job given to each worker (or -1). If the matching size equals the number of jobs, every job is covered. If not, the bottleneck is explained by Hall's condition and by the König cover from the previous lessons: the cover lists the workers and jobs that block a larger answer.
The animation below is the same bipartite matcher as before. Enter your own workers on the left and jobs on the right (edges as worker-job) and compare Kuhn with Hopcroft-Karp on your input.
1static boolean assign(int w, List<Integer>[] canDo, boolean[] seen, int[] workerOf) {2for (int j : canDo[w]) {3if (seen[j]) continue;4seen[j] = true;5if (workerOf[j] == -1 || assign(workerOf[j], canDo, seen, workerOf)) { workerOf[j] = w; return true; }6}7return false;8}9// canDo[w] = jobs worker w is able to do; result[w] = the job given to w, or -110static int[] assignJobs(int workers, int jobs, List<Integer>[] canDo) {11int[] workerOf = new int[jobs];12Arrays.fill(workerOf, -1);13for (int w = 0; w < workers; w++) assign(w, canDo, new boolean[jobs], workerOf);14int[] jobOf = new int[workers];15Arrays.fill(jobOf, -1);16for (int j = 0; j < jobs; j++) if (workerOf[j] != -1) jobOf[workerOf[j]] = j;17return jobOf;18}
Worst
O(W · E) with Kuhn, O(E · √V) with Hopcroft-Karp
Space
O(W + J + E)
Maximum bipartite matching: Kuhn's DFS and Hopcroft-Karp
Interactive · edit the inputleft vertices 0, 1, … and right vertices 0′, 1′, …; green edges are matched · 10 vertices · 9 edges
free
free
free
free
free
free
free
free
free
free
mate of each left vertex
mate of each right vertex
Kuhn's algorithm: for each left vertex u in turn, run a DFS that looks for an augmenting path starting at u. Each search visits every right vertex at most once.
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {2for (int v : adj[u]) {3if (seen[v]) continue; // visit every right vertex at most once per search4seen[v] = true;5if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { // v is free, or its mate can move elsewhere6matchR[v] = u;7return true;8}9}10return false;11}12static int kuhn(int left, int right, List<Integer>[] adj) {13int[] matchR = new int[right];14Arrays.fill(matchR, -1);15int size = 0;16for (int u = 0; u < left; u++) if (tryAugment(u, adj, new boolean[right], matchR)) size++;17return size;18}19static boolean layers(int left, List<Integer>[] adj, int[] matchL, int[] matchR, int[] dist) {20Deque<Integer> queue = new ArrayDeque<>();21for (int u = 0; u < left; u++) {22dist[u] = matchL[u] == -1 ? 0 : -1; // free left vertices form layer 023if (dist[u] == 0) queue.add(u);24}25boolean found = false;26while (!queue.isEmpty()) {27int u = queue.poll();28for (int v : adj[u]) {29int w = matchR[v];30if (w == -1) found = true; // a free right vertex ends an augmenting path31else if (dist[w] == -1) { dist[w] = dist[u] + 1; queue.add(w); }32}33}34return found;35}36static boolean extend(int u, List<Integer>[] adj, int[] matchL, int[] matchR, int[] dist) {37for (int v : adj[u]) {38int w = matchR[v];39if (w == -1 || (dist[w] == dist[u] + 1 && extend(w, adj, matchL, matchR, dist))) {40matchL[u] = v;41matchR[v] = u;42return true;43}44}45dist[u] = -1; // dead end: skip u for the rest of the phase46return false;47}48static int hopcroftKarp(int left, int right, List<Integer>[] adj) {49int[] matchL = new int[left], matchR = new int[right], dist = new int[left];50Arrays.fill(matchL, -1);51Arrays.fill(matchR, -1);52int size = 0;53while (layers(left, adj, matchL, matchR, dist)) { // one phase: BFS layers, then vertex-disjoint DFS54for (int u = 0; u < left; u++) if (matchL[u] == -1 && extend(u, adj, matchL, matchR, dist)) size++;55}56return size;57}
Live state
left
5
right
5
edges
9
Execution trace (dry run table)Show 27 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L12 | left=5 right=5 edges=9 | Kuhn's algorithm: for each left vertex u in turn, run a DFS that looks for an augmenting path starting at u. Each search visits every right vertex at most once. |
| 2 | L16 | u=0 size=0 | Search for an augmenting path from left vertex 0. |
| 3 | L5 | u=0 v=0′ matchR[v]=free | 0′ is free: the DFS has reached the end of an augmenting path. |
| 4 | L16 | u=0 size=1 | Success: the path is flipped and the matching grows to 1. |
| 5 | L16 | u=1 size=1 | Search for an augmenting path from left vertex 1. |
| 6 | L5 | u=1 v=0′ matchR[v]=0 | 0′ is matched to 0. Ask 0 to move to another right vertex (recursive call). |
| 7 | L5 | u=0 v=1′ matchR[v]=free | 1′ is free: the DFS has reached the end of an augmenting path. |
| 8 | L16 | u=1 size=2 | Success: the path is flipped and the matching grows to 2. |
| 9 | L16 | u=2 size=2 | Search for an augmenting path from left vertex 2. |
| 10 | L5 | u=2 v=1′ matchR[v]=0 | 1′ is matched to 0. Ask 0 to move to another right vertex (recursive call). |
| 11 | L5 | u=0 v=0′ matchR[v]=1 | 0′ is matched to 1. Ask 1 to move to another right vertex (recursive call). |
| 12 | L5 | u=1 v=2′ matchR[v]=free | 2′ is free: the DFS has reached the end of an augmenting path. |
| 13 | L16 | u=2 size=3 | Success: the path is flipped and the matching grows to 3. |
| 14 | L16 | u=3 size=3 | Search for an augmenting path from left vertex 3. |
| 15 | L5 | u=3 v=2′ matchR[v]=1 | 2′ is matched to 1. Ask 1 to move to another right vertex (recursive call). |
| 16 | L5 | u=1 v=0′ matchR[v]=0 | 0′ is matched to 0. Ask 0 to move to another right vertex (recursive call). |
| 17 | L5 | u=0 v=1′ matchR[v]=2 | 1′ is matched to 2. Ask 2 to move to another right vertex (recursive call). |
| 18 | L5 | u=2 v=3′ matchR[v]=free | 3′ is free: the DFS has reached the end of an augmenting path. |
| 19 | L16 | u=3 size=4 | Success: the path is flipped and the matching grows to 4. |
| 20 | L16 | u=4 size=4 | Search for an augmenting path from left vertex 4. |
| 21 | L5 | u=4 v=3′ matchR[v]=2 | 3′ is matched to 2. Ask 2 to move to another right vertex (recursive call). |
| 22 | L5 | u=2 v=1′ matchR[v]=0 | 1′ is matched to 0. Ask 0 to move to another right vertex (recursive call). |
| 23 | L5 | u=0 v=0′ matchR[v]=1 | 0′ is matched to 1. Ask 1 to move to another right vertex (recursive call). |
| 24 | L5 | u=1 v=2′ matchR[v]=3 | 2′ is matched to 3. Ask 3 to move to another right vertex (recursive call). |
| 25 | L5 | u=3 v=4′ matchR[v]=free | 4′ is free: the DFS has reached the end of an augmenting path. |
| 26 | L16 | u=4 size=5 | Success: the path is flipped and the matching grows to 5. |
| 27 | L17 | return=5 augmentations=5 | Maximum matching size 5, found with 5 augmentations. |
Students-projects
Now suppose each project can take several students. A matching allows only one partner per vertex, so capacities need a modelling trick: replace project p by capacity[p] **identical slots**, each connected to every student who accepts p. A matching between students and slots then respects the capacities automatically, because each slot holds at most one student.
The code expands the projects into slots, builds the student-to-slot lists, calls assignJobs from the previous section and translates the result back to projects. A student who cannot be placed gets -1. The number of placed students is the maximum possible: any valid assignment is a matching in the expanded graph, and the other way round.
Capacities make the expanded graph larger (the number of slots is the sum of the capacities), so for large capacities use a max-flow formulation instead: a source with capacity 1 to each student, student-to-project edges, and each project with capacity capacity[p] to the sink. The two models are equivalent; flow avoids the duplication.
1// project p takes up to capacity[p] students: turn it into capacity[p] identical slots, then match students to slots2@SuppressWarnings("unchecked")3static int[] assignToProjects(int[] capacity, int[][] choices) { // choices[s] = projects student s accepts4int slots = 0;5int[] projectOfSlot = new int[Arrays.stream(capacity).sum()];6for (int p = 0; p < capacity.length; p++) for (int k = 0; k < capacity[p]; k++) projectOfSlot[slots++] = p;7List<Integer>[] canDo = new List[choices.length];8for (int s = 0; s < choices.length; s++) {9canDo[s] = new ArrayList<>();10for (int slot = 0; slot < slots; slot++)11for (int p : choices[s]) if (projectOfSlot[slot] == p) { canDo[s].add(slot); break; }12}13int[] slotOf = assignJobs(choices.length, slots, canDo);14int[] result = new int[choices.length]; // the project of each student, or -115for (int s = 0; s < result.length; s++) result[s] = slotOf[s] == -1 ? -1 : projectOfSlot[slotOf[s]];16return result;17}
Domino tiling
Can a board with some squares missing be tiled by 1 × 2 dominoes? Colour the squares like a chessboard. Every domino covers one black and one white square, so a tiling is a perfect matching in the graph that connects each free square to its free neighbours. Because neighbours always have opposite colours, this graph is bipartite: black squares on one side, white squares on the other.
The maximum number of dominoes that fit is the size of a maximum matching, and a perfect matching exists exactly when the board can be tiled. The cheap necessary condition is a count: a perfect matching needs equally many black and white free squares. The famous mutilated chessboard has two opposite corners removed, both the same colour, which leaves 32 squares of one colour and 30 of the other, so at most 30 dominoes fit and the board cannot be tiled. The matching confirms that 30 is also reachable.
The code below is a Kuhn search on the grid: each black square tries to claim an adjacent white square, and may ask the black square that already owns it to move. Cell numbers r * cols + c index the arrays.
| Board | Free squares | Black / white | Dominoes that fit |
|---|---|---|---|
| 2 × 3 full | 6 | 3 / 3 | 3 (tiling exists) |
| 3 × 3 full | 9 | 5 / 4 | 4 (one square stays empty) |
| 8 × 8 minus two opposite corners | 62 | 30 / 32 | 30 (no tiling) |
1// blocked[r][c] = true for a hole. Every domino covers one "black" and one "white" square of the checkerboard,2// so the most dominoes that fit is a maximum matching between black and white free squares.3static int maxDominoes(boolean[][] blocked) {4int rows = blocked.length, cols = blocked[0].length, count = 0;5int[] owner = new int[rows * cols]; // owner[white] = the black square matched to it, or -16Arrays.fill(owner, -1);7for (int r = 0; r < rows; r++)8for (int c = 0; c < cols; c++)9if ((r + c) % 2 == 0 && !blocked[r][c] && place(r, c, blocked, new boolean[rows * cols], owner)) count++;10return count;11}12static boolean place(int r, int c, boolean[][] blocked, boolean[] seen, int[] owner) {13int cols = blocked[0].length;14int[] dr = {1, -1, 0, 0}, dc = {0, 0, 1, -1};15for (int d = 0; d < 4; d++) {16int nr = r + dr[d], nc = c + dc[d];17if (nr < 0 || nc < 0 || nr >= blocked.length || nc >= cols || blocked[nr][nc] || seen[nr * cols + nc]) continue;18seen[nr * cols + nc] = true;19int other = owner[nr * cols + nc];20if (other == -1 || place(other / cols, other % cols, blocked, seen, owner)) { owner[nr * cols + nc] = r * cols + c; return true; }21}22return false;23}
Scheduling
Unit-length tasks, each with a deadline, are to be placed into time slots with at most one task per slot. Model slots as one side and tasks as the other, with an edge from a task to every slot it may use (slots 0 … deadline − 1). A maximum matching is the largest set of tasks that can all meet their deadlines.
For this particular shape, sorting by deadline and filling earliest-free slots also works, and it is faster. The matching version is worth knowing because it survives changes that break the greedy rule: tasks that may only run in certain slots, machines that are unavailable at some times, or tasks that can use any slot from a list. Whenever the allowed slots are an arbitrary set rather than a prefix, matching still applies.
For example, with deadlines 2, 1, 1, 3 and plenty of slots, only 3 of the 4 tasks fit: the two tasks with deadline 1 compete for slot 0, and one of them must be dropped. The code below runs Kuhn's search from each task.
1// job i must run in one of the time slots 0 .. deadline[i] - 1; one job per slot; returns the most jobs that fit2static int maxOnTimeJobs(int[] deadline, int slots) {3int[] jobIn = new int[slots]; // the job running in each slot, or -14Arrays.fill(jobIn, -1);5int done = 0;6for (int i = 0; i < deadline.length; i++) if (run(i, deadline, new boolean[slots], jobIn)) done++;7return done;8}9static boolean run(int i, int[] deadline, boolean[] seen, int[] jobIn) {10for (int t = 0; t < Math.min(deadline[i], jobIn.length); t++) {11if (seen[t]) continue;12seen[t] = true;13if (jobIn[t] == -1 || run(jobIn[t], deadline, seen, jobIn)) { jobIn[t] = i; return true; }14}15return false;16}
Minimum path cover in DAG
Given a directed acyclic graph, what is the smallest number of **vertex-disjoint paths** that together cover every vertex? Split each vertex v into an outgoing copy v_out and an incoming copy v_in, and for every edge u → v add an edge u_out — v_in. A matching in this bipartite graph chooses which edges the paths use: each vertex then has at most one successor and at most one predecessor.
Every chosen edge joins two paths into one, so starting from n single-vertex paths the number of paths is n − (matching size). The matching is as large as possible, so the number of paths is as small as possible. Acyclicity is what guarantees that the chosen edges form paths and not cycles.
Open the animation: the default DAG 0-1 0-2 1-3 2-3 3-4 2-4 has 5 vertices and a maximum matching of 3, so the minimum cover uses 5 − 3 = 2 paths, 0 → 1 → 3 and 2 → 4. The code returns the paths themselves, built by following next[] from every vertex that has no predecessor. If the paths may share vertices (a path cover with overlaps), take the transitive closure first; that variant is related to Dilworth's theorem.
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {2for (int v : adj[u]) {3if (seen[v]) continue;4seen[v] = true;5if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { matchR[v] = u; return true; }6}7return false;8}9@SuppressWarnings("unchecked")10static List<List<Integer>> pathCover(int n, int[][] edges) { // vertex-disjoint paths that cover every vertex of a DAG11List<Integer>[] adj = new List[n]; // split graph: left copy u (outgoing), right copy v (incoming)12for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();13for (int[] e : edges) adj[e[0]].add(e[1]);14int[] matchR = new int[n]; // matchR[v] = u means the edge u -> v is used by a path15Arrays.fill(matchR, -1);16for (int u = 0; u < n; u++) tryAugment(u, adj, new boolean[n], matchR);17int[] next = new int[n];18Arrays.fill(next, -1);19for (int v = 0; v < n; v++) if (matchR[v] != -1) next[matchR[v]] = v;20List<List<Integer>> paths = new ArrayList<>();21for (int v = 0; v < n; v++) {22if (matchR[v] != -1) continue; // v has no predecessor: a path starts here23List<Integer> path = new ArrayList<>();24for (int x = v; x != -1; x = next[x]) path.add(x);25paths.add(path);26}27return paths;28}
Worst
O(V · E) with Kuhn, O(E · √V) with Hopcroft-Karp
Space
O(V + E)
Minimum path cover of a DAG = n − maximum matching in the split graph
Interactive · edit the inputDAG; green edges are used by a path · 5 vertices · 6 edges
predecessor on its path (matchR)
A path cover uses vertex-disjoint paths. Split every vertex into an outgoing copy and an incoming copy: a matching in that bipartite graph chooses which edges the paths use (each vertex gets at most one successor and one predecessor). Every chosen edge merges two paths, so paths = n − matching.
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {2for (int v : adj[u]) {3if (seen[v]) continue;4seen[v] = true;5if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { matchR[v] = u; return true; }6}7return false;8}9@SuppressWarnings("unchecked")10static List<List<Integer>> pathCover(int n, int[][] edges) { // vertex-disjoint paths that cover every vertex of a DAG11List<Integer>[] adj = new List[n]; // split graph: left copy u (outgoing), right copy v (incoming)12for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();13for (int[] e : edges) adj[e[0]].add(e[1]);14int[] matchR = new int[n]; // matchR[v] = u means the edge u -> v is used by a path15Arrays.fill(matchR, -1);16for (int u = 0; u < n; u++) tryAugment(u, adj, new boolean[n], matchR);17int[] next = new int[n];18Arrays.fill(next, -1);19for (int v = 0; v < n; v++) if (matchR[v] != -1) next[matchR[v]] = v;20List<List<Integer>> paths = new ArrayList<>();21for (int v = 0; v < n; v++) {22if (matchR[v] != -1) continue; // v has no predecessor: a path starts here23List<Integer> path = new ArrayList<>();24for (int x = v; x != -1; x = next[x]) path.add(x);25paths.add(path);26}27return paths;28}
Live state
n
5
edges
6
Execution trace (dry run table)Show 7 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L10 | n=5 edges=6 | A path cover uses vertex-disjoint paths. Split every vertex into an outgoing copy and an incoming copy: a matching in that bipartite graph chooses which edges the paths use (each vertex gets at most one successor and one predecessor). Every chosen edge merges two paths, so paths = n − matching. |
| 2 | L16 | u=0 matching=1 paths if stopped=4 | Vertex 0 gets a successor (a path edge is added or re-routed): the matching has 1 edges, which would leave 4 paths. |
| 3 | L16 | u=1 matching=2 paths if stopped=3 | Vertex 1 gets a successor (a path edge is added or re-routed): the matching has 2 edges, which would leave 3 paths. |
| 4 | L16 | u=2 matching=3 paths if stopped=2 | Vertex 2 gets a successor (a path edge is added or re-routed): the matching has 3 edges, which would leave 2 paths. |
| 5 | L16 | u=3 matching=3 paths if stopped=2 | Vertex 3 gets no successor now: it is the end of its path. |
| 6 | L16 | u=4 matching=3 paths if stopped=2 | Vertex 4 gets no successor now: it is the end of its path. |
| 7 | L27 | return=2 paths=0→1→3 2→4 n − matching=2 | 2 paths: 0→1→3, 2→4. This equals n − matching = 5 − 3 = 2, and no cover can use fewer paths. |
Practice ladder
Now try it yourself
Easy
- Assign Cookies
Medium
- Maximum Number of Accepted Invitations
- Course Schedule III
Advanced
- Maximum Students Taking Exam
Key takeaways
- Recognise the two sides; draw an edge for every allowed pair; run a maximum matching.
- Capacities on one side become that many identical copies (or a flow capacity).
- Board tilings are matchings between the two colours of the chessboard; DAG path cover is n minus a matching in the split graph.