Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Graph Structure & Decomposition  ›  Lesson

Bipartite Graphs & Matching

Bipartite Graphs & Matching 30 minPair drivers with jobs, and flip a chain when you get stuck
You're building a piece ofMeridian — the network model
This piece — assign_drivers(): Covers as many jobs as possible with drivers licensed for them.
Scenario Every morning Meridian has a board of jobs (van runs, truck runs, bike couriers) and a list of drivers, each licensed for some of them. Handing out jobs first-come-first-served leaves jobs uncovered that a smarter shuffle would have filled. Driver assignment finds the largest set of jobs that can go out at once.
Your task
Build assign_drivers(can_do). `can_do` maps each driver to the list of jobs they are licensed for. Each driver takes at most one job and each job needs at most one driver. Return the largest number of jobs that can be covered at the same time. Example: assign_drivers({"ana": ["van", "truck"], "ben": ["van"]}) → 2 (ana takes the truck so ben can take the van).

Bipartite Graphs & Matching

In short: a bipartite graph splits into two sides with links only crossing between them, and a matching pairs them up with no driver or job reused twice. An augmenting path runs from a free driver to a free job, alternating unused and used links; flipping every link on it grows the matching by exactly one. Kuhn's algorithm is a DFS that looks for such a path from each driver in turn, bumping whoever currently holds a wanted job one level deeper, and a matching is maximum exactly when no augmenting path is left to find.

Four drivers ana, ben, cy, dee on the left and four jobs van, truck, bike, crane on the right; the matched links dee-van, ana-truck, ben-bike and cy-crane are bold green, the rest grey dashed

Every morning at six, Meridian's dispatch board fills up with jobs: van runs, a truck delivery, a bike courier loop, a crane lift at the harbour. Every driver is licensed for some of them. Each driver can take at most one job, and each job needs one driver. The shift lead wants as many jobs covered as possible, and wants it before the first van leaves.

The obvious method is first come, first served. Walk down the drivers and give each one any free job they're licensed for. It's fast, and it's often wrong.

Two sides, links only across

Drivers on the left, jobs on the right, and a link wherever a driver holds the licence. No link ever joins two drivers or two jobs. A graph whose nodes split into two sides like this, with every link crossing between them, is bipartite. A set of links that share no endpoint (no driver twice, no job twice) is a matching, and the shift lead wants a maximum matching.

Not every graph can be split into two sides. BFS can test it by colouring each newly found node the opposite colour to the node that found it. A link between two nodes of the same colour means there's no split:

What does this print?
from collections import deque

near = {"a": ["b", "c"], "b": ["a", "c"], "c": ["a", "b"]}
side = {"a": 0}
queue = deque(["a"])
ok = True
while queue:
    n = queue.popleft()
    for m in near[n]:
        if m not in side:
            side[m] = 1 - side[n]
            queue.append(m)
        elif side[m] == side[n]:
            ok = False
print(ok)

A graph is bipartite exactly when it has no ring of odd length. Driver-and-job graphs are bipartite by construction, so the question here is the matching.

Why first come, first served fails

What does this print?
can_do = {"ana": ["van", "truck"], "ben": ["truck", "bike"], "cy": ["bike", "crane"], "dee": ["van"]}
taken = set()
covered = 0
for driver in ["ana", "ben", "cy", "dee"]:
    for job in can_do[driver]:
        if job not in taken:
            taken.add(job)
            covered += 1
            break
print(covered)

To get from 3 to 4, three drivers have to move at once: dee takes the van, which pushes ana to the truck, which pushes ben to the bike, which pushes cy to the crane, which nobody had. That chain has a name.

Augmenting paths

An augmenting path starts at an unmatched driver and ends at an unmatched job, and alternates: an unused link, then a used one, then an unused one, and so on. Flip every link on it, so unused become used and used become unused. Every driver and job in the middle swaps one partner for another, and the two ends gain one. The matching grows by exactly one. That flip is the whole algorithm:

watch it move · graph

An augmenting path alternates unused and used links from a free driver to a free job. Flipping it adds exactly one pair.

First come, first served gives ana the van, ben the truck and cy the bike. 8 nodes, 7 edges, undirected. ana: van, ben: truck, cy: bike.

1 / 7

Berge's theorem closes the argument: a matching is maximum exactly when no augmenting path exists. So the algorithm is to try to find an augmenting path from each driver, and flip it when found.

Kuhn's algorithm

Finding the path is a depth-first search that alternates sides. To place driver d, try each job they can do: if it's free, take it. If it's held, try to place the holder somewhere else, which is the same question one level deeper. A tried set stops the search from going round in circles:

python

The flip is easy to miss in the code. Every holder[job] = driver along the successful chain runs as the recursion unwinds, and that reassigns each job to the new driver. Each driver's search visits each job at most once, so the whole algorithm is O(V × E). The recursion goes as deep as the longest chain, at most one level per driver, which is fine for a shift roster. For tens of thousands of drivers, use the iterative form, or the Hopcroft–Karp algorithm, which finds many shortest augmenting paths per round in O(E √V).

Space is O(V + E): can_do holds every licence once, holder holds at most one entry per job, and tried plus the recursion stack hold at most one entry per driver.

The tried set belongs to one attempt

This version creates the set once and shares it between every driver:

broken — fix it

Ana can take the truck, which frees the van for ben, so 2 jobs can be covered. This prints 1. Fix it so every driver gets a clean search.

Step through two-colouring on a four-node ring. An even ring splits cleanly:

step through it
1from collections import deque
2near = {"a": ["b", "d"], "b": ["a", "c"], "c": ["b", "d"], "d": ["c", "a"]}
3side = {"a": 0}
4queue = deque(["a"])
5clash = False
6while queue:
7 n = queue.popleft()
8 for m in near[n]:
9 if m not in side:
10 side[m] = 1 - side[n]
11 queue.append(m)
12 elif side[m] == side[n]:
13 clash = True
14print(side, clash)

Your turn to drive it

This is Kuhn's algorithm on the morning roster, drawing each chain it tries and badging every driver with their current job. Change the order in can_do, or take away a licence, and watch which chain it finds, or fails to find:

your code, drawn · graph

Your Kuhn's algorithm. Each attempt walks an alternating chain, and success flips it, so the matching only ever grows.

When can everyone be placed?

Sometimes the answer is "not everyone", and it helps to know why. Hall's condition: every driver can be placed exactly when every group of drivers is licensed, between them, for at least as many different jobs as there are drivers in the group. Three drivers who can only ever drive the van and the truck can't all work, and when Kuhn's search from the third one fails, the drivers and jobs it tried form exactly such a group.

How much does the search save? The brute-force alternative, checking every subset of the possible driver–job pairs, grows as 2^E. If each driver holds about 3 licences, there are 3n pairs and 2^(3n) subsets, while Kuhn's does about n × 3n steps:

drivers n brute force (2^(3n)) Kuhn's (3n²)
5 32,768 75
9 134,217,728 243
10 1,073,741,824 300

Ten drivers already pass a billion subsets. Brute force is hopeless for even a small depot's roster, while augmenting paths handle thousands of drivers.

❓ Cross-question: "What if some jobs pay more?" Then you want the assignment with the best total, not just the most pairs. That's the assignment problem, solved by the Hungarian algorithm or by min-cost flow. The augmenting-path idea is the same, but each augmentation follows the cheapest alternating chain, found by a shortest-path search.


Idioms & real-world patterns

Matching hides in grid puzzles

Anything that pairs up neighbours on a grid is often a bipartite matching in disguise. Colour the grid like a chessboard, and two squares side by side always have different colours. Pairing adjacent squares (tiles, parking bays, dominoes) is then a matching between black and white squares. The boss exercise parks vans this way.

Cover equals matching, but only on two sides

The capstone asks for the fewest depots that touch every link, a vertex cover. On a general graph that's one of the classic hard problems. On a bipartite graph, König's theorem says it equals the maximum matching size, so it's easy. The same question can be easy or hard depending on the graph's shape, which is why checking for two sides first is worth doing.

⚡ Advanced: matching is a flow

Add a source linked to every driver and a sink linked from every job, give every link capacity 1, and a maximum matching is a maximum flow. Flow algorithms generalise this lesson to capacities, costs and bottlenecks: how much a whole network can carry, and where it chokes. A later Meridian section builds exactly that generalisation — maximum flow, min cut, and paying drivers by the job.


Quick practice

What does this print?
from collections import deque

near = {"a": ["b", "e"], "b": ["a", "c"], "c": ["b", "d"], "d": ["c", "e"], "e": ["d", "a"]}
side = {"a": 0}
queue = deque(["a"])
ok = True
while queue:
    n = queue.popleft()
    for m in near[n]:
        if m not in side:
            side[m] = 1 - side[n]
            queue.append(m)
        elif side[m] == side[n]:
            ok = False
print(ok)
broken — fix it

A triangle is an odd ring and can't be split into two sides, but this prints True for it. The code colours every new node, but never checks an old one. Fix it.

🎯 Your turn

Write assign_drivers(can_do). can_do maps each driver to the jobs they're licensed for. Each driver does at most one job and each job needs at most one driver. Return the largest number of jobs covered at once:

  • assign_drivers({"ana": ["van", "truck"], "ben": ["van"]}) → 2
  • assign_drivers({"ana": [], "ben": []}) → 0

Hint: try to place each driver with a fresh tried set, and when a job is taken, recursively try to place its holder somewhere else.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Meridian is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upIs it a valid assignment?

Write is_matching(pairs) where pairs is a list of [driver, job]. Return True if no driver appears twice and no job appears twice. Example: [["ana", "van"], ["ben", "truck"]] → True.

bipartite-matchingset
DrillSplit into two sides

A graph is bipartite if its nodes split into two sides with every link crossing between them. Write two_sides(nodes, links) that colours nodes 0 and 1 by BFS (starting each piece at its alphabetically first unvisited node with colour 0, going through sorted(nodes)) and returns the dict, or None if some link would join two nodes of the same colour. Example: a path a–b–c → {"a": 0, "b": 1, "c": 0}.

bipartitetwo-colouringbfs
BuildWho drives what

Write match_pairs(can_do) returning an actual maximum assignment as a sorted list of [driver, job]. Place drivers in sorted order, and let each driver try jobs in the order listed. Holders moved along a chain keep the same rules. Example: {"ben": ["van"], "ana": ["van", "truck"]} → [["ana", "truck"], ["ben", "van"]].

augmenting-pathbipartite-matching
BossParking vans in the yard

The depot yard is a grid of strings, . for a free square and # for a blocked one. A van needs two free squares side by side (left–right or up–down), and no square can hold two vans. Write van_bays(yard) returning the most vans that fit. Example: ["..", ".."] → 2.

bipartitebipartite-matchingaugmenting-path
CapstoneWatch every crossing

Freight crosses the river on links between west-bank depots and east-bank depots: each link is [west, east]. An inspector posted at a depot watches every link touching it. Write fewest_inspectors(links) returning the fewest inspectors needed so that every link is watched. Example: [["w1", "e1"], ["w1", "e2"], ["w1", "e3"]] → 1 (post one at w1).

bipartite-matchingkonigaugmenting-path
ExplainWhy flipping the chain is always safe

An augmenting path starts at an unassigned driver, ends at an unassigned job, and alternates between unused and used links. Explain why flipping every link on it gives a valid assignment with exactly one more pair, and why the greedy approach of taking any free job can get stuck below the best.

assign_drivers.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
assign_drivers(can_do) → intThe maximum number of jobs covered at once, one driver per job.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.