N or P
Contents Glossary
13

Matching two sides

Two-sided matching is a settled category. Buy the algorithm. Do not let your team build it.

Matching problems with two distinct sides are completely solved. Pairing job applicants with positions, drivers with deliveries, students with mentors, ads with slots — all bipartite matching. Two questions arise: maximum matching (pair as many as possible) and assignment (find the pairing with the lowest total cost). Both are polynomial. Hopcroft-Karp handles unweighted matching at scale. The Hungarian algorithm — also called Kuhn-Munkres — handles cost-minimizing assignment. Rust's pathfinding library implements both as one-line calls and handles tens of thousands of items in milliseconds. When the problem has more than two sides — for example, matching student to mentor to time slot all together — it becomes the three-dimensional matching problem and is genuinely hard (NP-complete, see page 38). Two sides cheap, three sides expensive.

  1. 01
    pathfinding::kuhn_munkres is the Hungarian algorithm in one call. Feed it a square cost matrix — rows are workers, columns are jobs, cells are costs — and it returns the assignment that minimizes total cost.
    use pathfinding::kuhn_munkres::kuhn_munkres_min;
    use pathfinding::matrix::Matrix;
    
    fn assign_jobs(cost: Vec<Vec<i32>>) -> (i32, Vec<usize>) {
        let m = Matrix::from_rows(cost).expect("square cost matrix");
        kuhn_munkres_min(&m) // (total_cost, assignment[worker] = job)
    }
  2. 02
    .expect(msg) is the "if this fails, crash with this message" method on Result and Option. For Ok(x) it returns x; for Err(_) it panics, printing msg on the way out. Use it when the failure case is a programming bug — something that should never happen at runtime, and if it does you want to crash loudly rather than silently keep going on corrupt data. Here, Matrix::from_rows only fails when the input rows have unequal lengths; if upstream code has already validated the shape, that case is impossible, and .expect("square cost matrix") documents the assumption. The alternative is the ? operator — "if this is Err, return early from the containing function with that error; otherwise unwrap and continue." ? requires the containing function to return a compatible Result, so swapping .expect for ? is a function-signature change, not a one-line edit.
    // ── .expect — panics on Err with a message ────────────────────
    fn assign_jobs_panic(cost: Vec<Vec<i32>>) -> (i32, Vec<usize>) {
        let m = Matrix::from_rows(cost).expect("square cost matrix");
        //                              └──────── if rows are uneven,
        //                                        the program crashes here
        //                                        with the message printed
        //                                        and a stack trace.
        kuhn_munkres_min(&m)
    }
    
    // ── ? operator — bubbles the Err up to the caller ─────────────
    //
    // The function's return type has to become Result for ? to work.
    use pathfinding::matrix::MatrixFormatError;
    
    fn assign_jobs_fallible(cost: Vec<Vec<i32>>)
        -> Result<(i32, Vec<usize>), MatrixFormatError>
    {
        let m = Matrix::from_rows(cost)?;
        //                            └── on Err, return early from the
        //                                whole function with that Err.
        //                                on Ok, unwrap and continue.
        Ok(kuhn_munkres_min(&m))
    }
    
    // Compare:
    //   .expect("…")  →  fail loudly, terminate the program.
    //                    Use when failure means a bug in our code.
    //   ?             →  fail quietly, return Err to the caller.
    //                    Use when failure is a legitimate runtime
    //                    condition the caller should handle.
    
    // One more variant worth knowing — .unwrap() is .expect() without
    // a message.  Convenient for prototypes; .expect() is strictly
    // better for production code because the panic prints useful text.
  3. 03
    One important caveat. The .expect and .unwrap calls throughout this book make great examples — they fit on one line, name the failure case, and put the fallible operation on display. They are bad to copy-paste into a library. A library has no business deciding how its caller responds to failure; panicking inside library code crashes the entire process for what may be a perfectly recoverable condition. The rule for any code you publish as a crate, or any code called by code you do not own, is the same: every fallible operation returns a Result, the function signature reflects that, and the caller decides whether to retry, fall back, or propagate. Application code (main, binaries, integration tests) is allowed to panic for genuinely unrecoverable conditions — there is no caller above you, so the process crashing is the only thing left to do. The thiserror crate makes the library pattern convenient: you derive std::error::Error and the formatting boilerplate, and the only thing you write is the variants and their messages.
    // ── Library code — never panics, returns Result ────────────────
    use thiserror::Error;
    use pathfinding::matrix::MatrixFormatError;
    
    #[derive(Debug, Error)]
    pub enum AssignError {
        #[error("cost matrix rows have unequal lengths")]
        NotRectangular(#[from] MatrixFormatError),
    
        #[error("matrix must be square: got {rows}×{cols}")]
        NotSquare { rows: usize, cols: usize },
    }
    
    pub fn assign_jobs(cost: Vec<Vec<i32>>)
        -> Result<(i32, Vec<usize>), AssignError>
    {
        let m = Matrix::from_rows(cost)?;     // ? converts MatrixFormatError
                                               //   via the #[from] above.
        if m.rows != m.columns {
            return Err(AssignError::NotSquare {
                rows: m.rows, cols: m.columns,
            });
        }
        Ok(kuhn_munkres_min(&m))
    }
    
    // ── Application code — .expect is fine here ───────────────────
    fn main() {
        let cost = vec![vec![12,  8, 20],
                        vec![ 9, 15, 11],
                        vec![14, 10,  7]];
        let (total, picks) = assign_jobs(cost).expect("known good input");
        println!("total = {}, picks = {:?}", total, picks);
    }
    
    // Rule:
    //   library code      → return Result, never panic.  thiserror
    //                       reduces a custom error type to one derive.
    //   application code  → .expect / .unwrap fine for cases the
    //                       binary author has decided are unrecoverable.
Hopcroft, J., Karp, R. (1973) for maximum matching. Kuhn, H. (1955) for assignment. König, D. (1931) for the structural foundation. Source →
In plain terms

Two-sided matching shows up in business constantly. Applicants to jobs, customers to support reps, drivers to deliveries, tutors to students, ads to ad slots, organs to recipients, surgeons to operating rooms. Every one of these is the same problem in different vocabulary.

The structure is: two sets, with potential pairings between them, and some criterion to optimize. When the criterion is "maximum number of pairings," the problem is bipartite matching and is solved by Hopcroft-Karp's 1973 algorithm. When the criterion is "lowest total cost," it is the assignment problem and is solved by the Hungarian algorithm, also known as Kuhn-Munkres, from 1955. Both are polynomial. Both handle tens of thousands of items in milliseconds on a laptop. Both are one function call in Rust's pathfinding library.

The fact that these are solved is not always obvious to product teams. Engineering tickets that read "build matching system" routinely turn into multi-week projects when an unfamiliar team tries to design the algorithm from scratch. The right ticket is "wire up Kuhn-Munkres for our assignment problem." Days, not weeks.

The trap is dimension. Two-sided matching is in the cheap category. Three-sided matching — match three things together at once, like student to mentor to time slot — is NP-complete. The problem looks innocent ("just one more dimension") and it is not. When the requirement involves a triple constraint, the matching category changes and the engineering estimate triples.

A workaround for three-sided problems: solve them sequentially. Match students to mentors first (two-sided, cheap), then match mentor-student pairs to time slots (two-sided again, cheap). The result is not provably optimal in the three-sided sense, but it is often good enough and stays in the affordable category.

When you see "matching" in a requirement, the first question is how many sides. Two — cheap. Three or more — buy a solver or accept an approximation.

← 12 Flow, capacity, and bottlenecks
13/39
14 Matching with no clean sides →