Chapter 40: Graph Matchings and Assignment
40.06Intermediate / Advanced5 topics17 min2 interactive animations

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

Bipartite matching (Lesson 40.02)Directed acyclic graphs

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.

40.06.1

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.

Java
1static boolean assign(int w, List<Integer>[] canDo, boolean[] seen, int[] workerOf) {
2 for (int j : canDo[w]) {
3 if (seen[j]) continue;
4 seen[j] = true;
5 if (workerOf[j] == -1 || assign(workerOf[j], canDo, seen, workerOf)) { workerOf[j] = w; return true; }
6 }
7 return false;
8}
9// canDo[w] = jobs worker w is able to do; result[w] = the job given to w, or -1
10static int[] assignJobs(int workers, int jobs, List<Integer>[] canDo) {
11 int[] workerOf = new int[jobs];
12 Arrays.fill(workerOf, -1);
13 for (int w = 0; w < workers; w++) assign(w, canDo, new boolean[jobs], workerOf);
14 int[] jobOf = new int[workers];
15 Arrays.fill(jobOf, -1);
16 for (int j = 0; j < jobs; j++) if (workerOf[j] != -1) jobOf[workerOf[j]] = j;
17 return 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 input

Your input

Try

left vertices 0, 1, … and right vertices 0′, 1′, …; green edges are matched · 10 vertices · 9 edges

0

free

1

free

2

free

3

free

4

free

0′

free

1′

free

2′

free

3′

free

4′

free

Search path being triedMatched edge or vertexRoot of the search

mate of each left vertex

free0
free1
free2
free3
free4

mate of each right vertex

free0′
free1′
free2′
free3′
free4′
Setup

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.

Step 1 / 27
Java
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {
2 for (int v : adj[u]) {
3 if (seen[v]) continue; // visit every right vertex at most once per search
4 seen[v] = true;
5 if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { // v is free, or its mate can move elsewhere
6 matchR[v] = u;
7 return true;
8 }
9 }
10 return false;
11}
12static int kuhn(int left, int right, List<Integer>[] adj) {
13 int[] matchR = new int[right];
14 Arrays.fill(matchR, -1);
15 int size = 0;
16 for (int u = 0; u < left; u++) if (tryAugment(u, adj, new boolean[right], matchR)) size++;
17 return size;
18}
19static boolean layers(int left, List<Integer>[] adj, int[] matchL, int[] matchR, int[] dist) {
20 Deque<Integer> queue = new ArrayDeque<>();
21 for (int u = 0; u < left; u++) {
22 dist[u] = matchL[u] == -1 ? 0 : -1; // free left vertices form layer 0
23 if (dist[u] == 0) queue.add(u);
24 }
25 boolean found = false;
26 while (!queue.isEmpty()) {
27 int u = queue.poll();
28 for (int v : adj[u]) {
29 int w = matchR[v];
30 if (w == -1) found = true; // a free right vertex ends an augmenting path
31 else if (dist[w] == -1) { dist[w] = dist[u] + 1; queue.add(w); }
32 }
33 }
34 return found;
35}
36static boolean extend(int u, List<Integer>[] adj, int[] matchL, int[] matchR, int[] dist) {
37 for (int v : adj[u]) {
38 int w = matchR[v];
39 if (w == -1 || (dist[w] == dist[u] + 1 && extend(w, adj, matchL, matchR, dist))) {
40 matchL[u] = v;
41 matchR[v] = u;
42 return true;
43 }
44 }
45 dist[u] = -1; // dead end: skip u for the rest of the phase
46 return false;
47}
48static int hopcroftKarp(int left, int right, List<Integer>[] adj) {
49 int[] matchL = new int[left], matchR = new int[right], dist = new int[left];
50 Arrays.fill(matchL, -1);
51 Arrays.fill(matchR, -1);
52 int size = 0;
53 while (layers(left, adj, matchL, matchR, dist)) { // one phase: BFS layers, then vertex-disjoint DFS
54 for (int u = 0; u < left; u++) if (matchL[u] == -1 && extend(u, adj, matchL, matchR, dist)) size++;
55 }
56 return size;
57}

Live state

0 searches0 augmentations

left

5

right

5

edges

9

No condition evaluated on this step
Execution trace (dry run table)Show 27 steps
StepLineStateWhat happens
1L12left=5 right=5 edges=9Kuhn'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.
2L16u=0 size=0Search for an augmenting path from left vertex 0.
3L5u=0 v=0′ matchR[v]=free0′ is free: the DFS has reached the end of an augmenting path.
4L16u=0 size=1Success: the path is flipped and the matching grows to 1.
5L16u=1 size=1Search for an augmenting path from left vertex 1.
6L5u=1 v=0′ matchR[v]=00′ is matched to 0. Ask 0 to move to another right vertex (recursive call).
7L5u=0 v=1′ matchR[v]=free1′ is free: the DFS has reached the end of an augmenting path.
8L16u=1 size=2Success: the path is flipped and the matching grows to 2.
9L16u=2 size=2Search for an augmenting path from left vertex 2.
10L5u=2 v=1′ matchR[v]=01′ is matched to 0. Ask 0 to move to another right vertex (recursive call).
11L5u=0 v=0′ matchR[v]=10′ is matched to 1. Ask 1 to move to another right vertex (recursive call).
12L5u=1 v=2′ matchR[v]=free2′ is free: the DFS has reached the end of an augmenting path.
13L16u=2 size=3Success: the path is flipped and the matching grows to 3.
14L16u=3 size=3Search for an augmenting path from left vertex 3.
15L5u=3 v=2′ matchR[v]=12′ is matched to 1. Ask 1 to move to another right vertex (recursive call).
16L5u=1 v=0′ matchR[v]=00′ is matched to 0. Ask 0 to move to another right vertex (recursive call).
17L5u=0 v=1′ matchR[v]=21′ is matched to 2. Ask 2 to move to another right vertex (recursive call).
18L5u=2 v=3′ matchR[v]=free3′ is free: the DFS has reached the end of an augmenting path.
19L16u=3 size=4Success: the path is flipped and the matching grows to 4.
20L16u=4 size=4Search for an augmenting path from left vertex 4.
21L5u=4 v=3′ matchR[v]=23′ is matched to 2. Ask 2 to move to another right vertex (recursive call).
22L5u=2 v=1′ matchR[v]=01′ is matched to 0. Ask 0 to move to another right vertex (recursive call).
23L5u=0 v=0′ matchR[v]=10′ is matched to 1. Ask 1 to move to another right vertex (recursive call).
24L5u=1 v=2′ matchR[v]=32′ is matched to 3. Ask 3 to move to another right vertex (recursive call).
25L5u=3 v=4′ matchR[v]=free4′ is free: the DFS has reached the end of an augmenting path.
26L16u=4 size=5Success: the path is flipped and the matching grows to 5.
27L17return=5 augmentations=5Maximum matching size 5, found with 5 augmentations.
40.06.2

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.

Java
1// project p takes up to capacity[p] students: turn it into capacity[p] identical slots, then match students to slots
2@SuppressWarnings("unchecked")
3static int[] assignToProjects(int[] capacity, int[][] choices) { // choices[s] = projects student s accepts
4 int slots = 0;
5 int[] projectOfSlot = new int[Arrays.stream(capacity).sum()];
6 for (int p = 0; p < capacity.length; p++) for (int k = 0; k < capacity[p]; k++) projectOfSlot[slots++] = p;
7 List<Integer>[] canDo = new List[choices.length];
8 for (int s = 0; s < choices.length; s++) {
9 canDo[s] = new ArrayList<>();
10 for (int slot = 0; slot < slots; slot++)
11 for (int p : choices[s]) if (projectOfSlot[slot] == p) { canDo[s].add(slot); break; }
12 }
13 int[] slotOf = assignJobs(choices.length, slots, canDo);
14 int[] result = new int[choices.length]; // the project of each student, or -1
15 for (int s = 0; s < result.length; s++) result[s] = slotOf[s] == -1 ? -1 : projectOfSlot[slotOf[s]];
16 return result;
17}
40.06.3

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.

BoardFree squaresBlack / whiteDominoes that fit
2 × 3 full63 / 33 (tiling exists)
3 × 3 full95 / 44 (one square stays empty)
8 × 8 minus two opposite corners6230 / 3230 (no tiling)
Java
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) {
4 int rows = blocked.length, cols = blocked[0].length, count = 0;
5 int[] owner = new int[rows * cols]; // owner[white] = the black square matched to it, or -1
6 Arrays.fill(owner, -1);
7 for (int r = 0; r < rows; r++)
8 for (int c = 0; c < cols; c++)
9 if ((r + c) % 2 == 0 && !blocked[r][c] && place(r, c, blocked, new boolean[rows * cols], owner)) count++;
10 return count;
11}
12static boolean place(int r, int c, boolean[][] blocked, boolean[] seen, int[] owner) {
13 int cols = blocked[0].length;
14 int[] dr = {1, -1, 0, 0}, dc = {0, 0, 1, -1};
15 for (int d = 0; d < 4; d++) {
16 int nr = r + dr[d], nc = c + dc[d];
17 if (nr < 0 || nc < 0 || nr >= blocked.length || nc >= cols || blocked[nr][nc] || seen[nr * cols + nc]) continue;
18 seen[nr * cols + nc] = true;
19 int other = owner[nr * cols + nc];
20 if (other == -1 || place(other / cols, other % cols, blocked, seen, owner)) { owner[nr * cols + nc] = r * cols + c; return true; }
21 }
22 return false;
23}
40.06.4

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.

Java
1// job i must run in one of the time slots 0 .. deadline[i] - 1; one job per slot; returns the most jobs that fit
2static int maxOnTimeJobs(int[] deadline, int slots) {
3 int[] jobIn = new int[slots]; // the job running in each slot, or -1
4 Arrays.fill(jobIn, -1);
5 int done = 0;
6 for (int i = 0; i < deadline.length; i++) if (run(i, deadline, new boolean[slots], jobIn)) done++;
7 return done;
8}
9static boolean run(int i, int[] deadline, boolean[] seen, int[] jobIn) {
10 for (int t = 0; t < Math.min(deadline[i], jobIn.length); t++) {
11 if (seen[t]) continue;
12 seen[t] = true;
13 if (jobIn[t] == -1 || run(jobIn[t], deadline, seen, jobIn)) { jobIn[t] = i; return true; }
14 }
15 return false;
16}
40.06.5

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.

Java
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {
2 for (int v : adj[u]) {
3 if (seen[v]) continue;
4 seen[v] = true;
5 if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { matchR[v] = u; return true; }
6 }
7 return false;
8}
9@SuppressWarnings("unchecked")
10static List<List<Integer>> pathCover(int n, int[][] edges) { // vertex-disjoint paths that cover every vertex of a DAG
11 List<Integer>[] adj = new List[n]; // split graph: left copy u (outgoing), right copy v (incoming)
12 for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();
13 for (int[] e : edges) adj[e[0]].add(e[1]);
14 int[] matchR = new int[n]; // matchR[v] = u means the edge u -> v is used by a path
15 Arrays.fill(matchR, -1);
16 for (int u = 0; u < n; u++) tryAugment(u, adj, new boolean[n], matchR);
17 int[] next = new int[n];
18 Arrays.fill(next, -1);
19 for (int v = 0; v < n; v++) if (matchR[v] != -1) next[matchR[v]] = v;
20 List<List<Integer>> paths = new ArrayList<>();
21 for (int v = 0; v < n; v++) {
22 if (matchR[v] != -1) continue; // v has no predecessor: a path starts here
23 List<Integer> path = new ArrayList<>();
24 for (int x = v; x != -1; x = next[x]) path.add(x);
25 paths.add(path);
26 }
27 return 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 input

Your input

Try

DAG; green edges are used by a path · 5 vertices · 6 edges

0
1
2
3
4
Path 1Path 2Path 3Path 4

predecessor on its path (matchR)

none0
none1
none2
none3
none4
Setup

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.

Step 1 / 7
Java
1static boolean tryAugment(int u, List<Integer>[] adj, boolean[] seen, int[] matchR) {
2 for (int v : adj[u]) {
3 if (seen[v]) continue;
4 seen[v] = true;
5 if (matchR[v] == -1 || tryAugment(matchR[v], adj, seen, matchR)) { matchR[v] = u; return true; }
6 }
7 return false;
8}
9@SuppressWarnings("unchecked")
10static List<List<Integer>> pathCover(int n, int[][] edges) { // vertex-disjoint paths that cover every vertex of a DAG
11 List<Integer>[] adj = new List[n]; // split graph: left copy u (outgoing), right copy v (incoming)
12 for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();
13 for (int[] e : edges) adj[e[0]].add(e[1]);
14 int[] matchR = new int[n]; // matchR[v] = u means the edge u -> v is used by a path
15 Arrays.fill(matchR, -1);
16 for (int u = 0; u < n; u++) tryAugment(u, adj, new boolean[n], matchR);
17 int[] next = new int[n];
18 Arrays.fill(next, -1);
19 for (int v = 0; v < n; v++) if (matchR[v] != -1) next[matchR[v]] = v;
20 List<List<Integer>> paths = new ArrayList<>();
21 for (int v = 0; v < n; v++) {
22 if (matchR[v] != -1) continue; // v has no predecessor: a path starts here
23 List<Integer> path = new ArrayList<>();
24 for (int x = v; x != -1; x = next[x]) path.add(x);
25 paths.add(path);
26 }
27 return paths;
28}

Live state

0 matched edges0 paths

n

5

edges

6

No condition evaluated on this step
Execution trace (dry run table)Show 7 steps
StepLineStateWhat happens
1L10n=5 edges=6A 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.
2L16u=0 matching=1 paths if stopped=4Vertex 0 gets a successor (a path edge is added or re-routed): the matching has 1 edges, which would leave 4 paths.
3L16u=1 matching=2 paths if stopped=3Vertex 1 gets a successor (a path edge is added or re-routed): the matching has 2 edges, which would leave 3 paths.
4L16u=2 matching=3 paths if stopped=2Vertex 2 gets a successor (a path edge is added or re-routed): the matching has 3 edges, which would leave 2 paths.
5L16u=3 matching=3 paths if stopped=2Vertex 3 gets no successor now: it is the end of its path.
6L16u=4 matching=3 paths if stopped=2Vertex 4 gets no successor now: it is the end of its path.
7L27return=2 paths=0→1→3 2→4 n − matching=22 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.