DSA DSA In Depth
0%
lesson hungarian-algorithmassignment-problemprimal-dual

Minimum-Weight Bipartite Matching: Kuhn-Munkres (Hungarian Algorithm) & Primal-Dual Potentials

7 min read Module 5.3: Network Flow, Min-Cut & Bipartite Matching Updated Oct 23, 2026
📄 Cheat Sheet
The Munkres Assignment Algorithm (Hungarian Algorithm)
CompSci The Munkres Assignment Algorithm (Hungarian Algorithm) Watch on YouTube ↗

Clear walkthrough of the assignment problem, dual row/column potentials, and zero-slack augmenting paths.

0:00 / --:--

Linear Programming Duality & Complementary Slackness

The Assignment Problem seeks a permutation $\pi$ of $\{1, \dots, N\}$ minimizing $\sum_{i=1}^N C[i][\pi(i)]$. Consider any dual vertex potentials $u[1..N]$ and $v[1..N]$ satisfying the dual feasibility invariant:

$$u[i] + v[j] \le C[i][j] \quad \forall i, j \in \{1, \dots, N\}$$

Define the slack of edge $(i, j)$ as $\text{slack}(i, j) = C[i][j] - u[i] - v[j] \ge 0$. For any valid permutation $\pi$, summing the dual inequality over all matched pairs $(i, \pi(i))$ gives:

$$\sum_{i=1}^N C[i][\pi(i)] \ge \sum_{i=1}^N \big(u[i] + v[\pi(i)]\big) = \sum_{i=1}^N u[i] + \sum_{j=1}^N v[j]$$
Formal Theorem — Kuhn-Munkres Primal-Dual Optimality & $O(N^3)$ Slack Tracking

Theorem (Complementary Slackness): If a perfect matching $M$ uses exclusively tight edges ($\text{slack}(i, j) = C[i][j] - u[i] - v[j] = 0$ for all $(i, j) \in M$), then $\text{cost}(M) = \sum u[i] + \sum v[j]$, so $M$ is a globally minimum-cost perfect matching.

Why $O(N^3)$ Instead of $O(N^4)$? When matching worker $i$ ($1 \le i \le N$), we grow an alternating tree $T$ of tight edges. Whenever $T$ cannot expand to an unmatched task, we compute the minimum slack $\Delta = \min_{i' \in T_L, j' \notin T_R} (C[i'][j'] - u[i'] - v[j'])$, add $+\Delta$ to $u[i']$ for $i' \in T_L$, and subtract $-\Delta$ from $v[j']$ for $j' \in T_R$. By maintaining an array minv[j] $= \min_{i' \in T_L} \text{slack}(i', j)$ for each column $j \notin T_R$, each of the $\le N$ tree expansions per worker takes $O(N)$ time, giving $O(N^2)$ per worker and $O(N^3)$ total!

flowchart LR Worker["For worker i = 1..N: start alternating tree at dummy column j0 = 0"] --> UpdateSlack["Update minv[j] = min(minv[j], C[i0][j] - u[i0] - v[j]) for j not in T_R"] UpdateSlack --> PickMin["Find j1 not in T_R minimizing delta = minv[j1]"] PickMin --> Shift["For k in T: u[p[k]] += delta, v[k] -= delta; for k not in T: minv[k] -= delta"] Shift --> Free{"Is column j1 unmatched (p[j1] == 0)?"} Free -- "No (matched to i0 = p[j1])" --> UpdateSlack Free -- "Yes" --> Augment["Flip matching along way[j] chain back to column 0"]
Min-Cut Project Selection Closure, Hungarian (u_i + v_j = c_ij) & Min-Cost Max-Flow
O(V³) Hungarian · O(F·E log V) MCMF

Model Max-Weight Closure (Project Selection) as S-T Min-Cut with ∞ prerequisite arcs, tighten Hungarian dual potentials u_i + v_j = c_ij on the equality subgraph, or route Min-Cost Max-Flow via Johnson reduced costs.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Max-Weight Closure Theorem: Prerequisite arcs (P1→D1, P2→D2) have capacity ∞ so no finite S-T cut can sever them! Min-Cut = 5 (pay D1) + 8 (drop P2) = 13 ⇒ Max Net Profit = (12+8) - 13 = +7 (select {P1, D1})!
cap=12 (+rev) cap=8 (+rev) cap=∞ (prereq) cap=∞ (prereq) cap=5 (cost) cap=11 (cost) S Source P1 (+12) IN S (Keep!) P2 (+8) IN T (Drop) D1 (-5) IN S (Pay 5) D2 (-11) IN T (Skip) T Sink
Gross Positive Sum W⁺
12 + 8 = 20
S-T Min-Cut Capacity
5 (buy D1) + 8 (forgo P2) = 13
Optimal Net Profit
W⁺ - MinCut = 20 - 13 = +7
[Step 1] S-T Min-Cut capacity 13 selects closed project set {P1, D1} for optimal profit 12 - 5 = +7.
Hardware Reality — Jonker-Volgenant / Lopatin-Kuznetsov 1-Indexed Array Formulation

Rather than building graph objects, the industry-standard 1-indexed array formulation (Jonker & Volgenant 1987) uses Column $0$ as a virtual root holding the current worker $i$, and updates four flat 1D vectors (u, v, p, minv, way) in a tight inner loop over $j \in [1..M]$. Because it executes pure sequential array arithmetic with zero heap allocations, it solves $N = 500$ assignment instances in under $4\text{ ms}$!

Algorithm VariantTime ComplexitySpace ComplexityNotes
Naive Kuhn-Munkres (Matrix Re-scan)$O(N^4)$$O(N^2)$Recomputes $\Delta$ over all $(i', j')$ in $O(N^2)$ per relabel
Hungarian with minv[j] Slack Array$O(N^3)$$O(N^2)$Optimal for dense $N \times M$ assignment ($N \le M \le 1000$)
Rectangular $N \times M$ ($N \le M$)$O(N^2 M)$$O(N M)$Matches every row $1..N$ to a distinct column in $1..M$

Production C++20 Implementation: $O(N^2 M)$ Hungarian Algorithm with Potentials

#include <vector>
#include <limits>

struct AssignmentResult {
    long long minCost;
    std::vector<int> rowMatch; // 0-indexed: rowMatch[i] = assigned column j
};

// Solves Min-Weight Matching matching all N rows to M columns (requires N <= M)
AssignmentResult hungarianMinWeightAssignment(const std::vector<std::vector<long long>>& cost) {
    int n = static_cast<int>(cost.size());
    int m = static_cast<int>(cost[0].size());
    constexpr long long INF = std::numeric_limits<long long>::max() / 4;

    // 1-indexed potentials u[1..n], v[0..m], matching p[0..m] (row matched to col j), way[1..m]
    std::vector<long long> u(n + 1, 0), v(m + 1, 0);
    std::vector<int> p(m + 1, 0), way(m + 1, 0);

    for (int i = 1; i <= n; ++i) {
        p[0] = i;
        int j0 = 0;
        std::vector<long long> minv(m + 1, INF);
        std::vector<uint8_t> used(m + 1, 0);

        do {
            used[j0] = 1;
            int i0 = p[j0], j1 = 0;
            long long delta = INF;

            for (int j = 1; j <= m; ++j) {
                if (!used[j]) {
                    long long cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
                    if (cur < minv[j]) {
                        minv[j] = cur;
                        way[j] = j0;
                    }
                    if (minv[j] < delta) {
                        delta = minv[j];
                        j1 = j;
                    }
                }
            }
            for (int j = 0; j <= m; ++j) {
                if (used[j]) {
                    u[p[j]] += delta;
                    v[j] -= delta;
                } else {
                    minv[j] -= delta;
                }
            }
            j0 = j1;
        } while (p[j0] != 0);

        // Augment matching along tight alternating path traced by way[]
        do {
            int j1 = way[j0];
            p[j0] = p[j1];
            j0 = j1;
        } while (j0 != 0);
    }

    std::vector<int> rowMatch(n, -1);
    for (int j = 1; j <= m; ++j) {
        if (p[j] > 0) rowMatch[p[j] - 1] = j - 1;
    }
    // Dual objective at column 0: -v[0] equals sum u[i] + sum v[j] = exact minimum cost
    return {-v[0], rowMatch};
}
Failure Mode — Passing $N > M$ or Maximize-Weight Without Negation

The 1-indexed Hungarian loop assumes every row $i \in [1..N]$ can be matched to a distinct column, which requires $N \le M$ (if $N > M$, transpose the matrix first!). To solve Maximum-Weight Matching, simply negate all matrix entries (cost[i][j] = -profit[i][j])—unlike Dijkstra, the Hungarian algorithm handles negative edge weights natively without offset hacks!

Check yourself Question 1 of 4
Concept Check Tight (Equality) Subgraph Condition in the Hungarian Algorithm recall
Section: Dual Potentials

In the Hungarian algorithm for minimizing $\sum C[i][\pi(i)]$, what defines a **tight edge** $(i, j)$ under dual vertex potentials $u[i]$ and $v[j]$?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

During a relabel step in the Hungarian algorithm, we add $+\Delta$ to $u[i']$ for all $i' \in T_L$ and subtract $-\Delta$ from $v[j']$ for all $j' \in T_R$. What is the effect on (1) existing tight edges inside the alternating tree $T$ ($i' \in T_L, j' \in T_R$), and (2) frontier edges ($i' \in T_L, j' \notin T_R$)?

Key Takeaways

The Hungarian algorithm maintains dual potentials u[i] + v[j] <= C[i][j] and augments only along tight zero-slack edges where u[i] + v[j] == C[i][j].
By Complementary Slackness, any perfect matching composed of tight edges has cost equal to sum(u[i]) + sum(v[j]), proving global optimality.
Maintaining per-column minimum slack `minv[j]` reduces each worker's phase from O(N^3) to O(N^2), achieving O(N^3) total time.
The algorithm handles negative costs natively and returns the optimal objective value directly in `-v[0]`.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue