目录
1. 算法概述
匈牙利算法 (Hungarian Algorithm)是一种用于求解二部图最大权完美匹配(或最小权完美匹配)的组合优化算法。
- 时间复杂度: O(n³),其中 n 为矩阵维度
- 核心目标: 在 n×n 的代价矩阵中,为每行选择一个且仅一个元素,使得所选元素之和最小(或最大)
通俗理解
假设有 n 个工人和 n 个任务,每个工人完成每个任务的成本不同。如何安排任务分配,使得总成本最低?这就是匈牙利算法要解决的问题。
2. 核心思想
匈牙利算法基于以下关键定理:
核心定理
如果对代价矩阵的某一行或某一列 的所有元素同时加上(或减去)同一个常数,那么最优匹配方案不变。
思路
通过反复对矩阵进行行列变换,最终将矩阵转换为每行每列都含有 0 元素的形式,然后从 0 元素中选出 n 个,使得它们不同行不同列,即为最优匹配。
流程图
┌─────────────────────┐
│ 输入 n×n 代价矩阵 │
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 步骤1: 行规约 │ 每行减去该行最小值
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 步骤2: 列规约 │ 每列减去该列最小值
└─────────┬───────────┘
▼
┌─────────────────────┐
│ 步骤3: 用最少的线覆盖 │ 尝试用 ≤n 条线覆盖所有0
│ 所有0元素 │
└─────────┬───────────┘
▼
线的数量 == n ?
╱ ╲
是 否
▼ ▼
┌──────────┐ ┌─────────────────┐
│ 从0中选出 │ │ 步骤4: 找未被线 │
│ n个不同行 │ │ 覆盖的最小值, │
│ 不同列的 │ │ 未覆盖行减该值, │
│ 元素即为 │ │ 覆盖列加该值, │
│ 最优匹配 │ │ 返回步骤3 │
└──────────┘ └─────────────────┘
3. 基本概念
3.1 二部图 (Bipartite Graph)
二部图是顶点可以分成两个不相交集合 U 和 V 的图,所有边都连接 U 和 V 中的顶点。
工人集合 U 任务集合 V
工人A ──────────────── 任务1
│╲ ╱│
│ ╲ ╱ │
│ ╲ ╱ │
工人B ──────────────── 任务2
│╲ ╱ │
工人C ──────────────── 任务3
每条边表示工人可以完成该任务
每条边有权重(完成该任务的成本)
3.2 完美匹配 (Perfect Matching)
在二部图中,如果一个匹配覆盖了所有顶点,则称为完美匹配。
3.3 代价矩阵 (Cost Matrix)
| 任务1 | 任务2 | 任务3 | |
|---|---|---|---|
| 工人A | 4 | 1 | 3 |
| 工人B | 2 | 0 | 5 |
| 工人C | 3 | 2 | 2 |
矩阵中 C[i][j] 表示工人 i 完成任务 j 的代价。
3.4 覆盖线 (Covering Lines)
用最少的行划线 和列划线来覆盖矩阵中所有的 0 元素。这是判断当前是否可以得到最优解的关键步骤。
4. 算法原理与图解
4.1 为什么行列规约有效?
证明核心定理: 对第 i 行所有元素同时减去常数 k:
规约前代价: C[i][j]
规约后代价: C[i][j] - k
如果最优匹配方案中选择了元素 C[i][j]:
规约后总代价 = 原总代价 - k
所有匹配方案的总代价都减少了 k,因此相对大小关系不变!
列规约同理。
4.2 覆盖线数 == 矩阵阶数 的含义
根据 König 定理:在二部图中,最大匹配数 = 最小点覆盖数。
- 如果最少覆盖线数 = n → 可以从 0 元素中选出 n 个不同行不同列的 → 找到了最优解
- 如果最少覆盖线数 < n → 尚未找到最优解 → 需要继续迭代
4.3 迭代调整原理
当覆盖线数 < n 时:
找到未被覆盖区域的最小值 d
规则:
① 所有未被覆盖的元素 → 减去 d (产生新的0)
② 所有被两条线覆盖的元素 → 加上 d (保证已有0不消失)
③ 被一条线覆盖的元素 → 不变
这样调整后:
- 不会破坏已有的0(被线覆盖的0仍为0或变为正数被一条线覆盖时)
- 会产生新的0
- 覆盖线数至少增加1
图示:
调整前: 调整后:
× │ × │ a ×-d │ × │ a-d
───────┼────── ───────┼───────
b │ × │ c b-d │ × │ c-d
───────┼────── ───────┼───────
× │ d │ e ×-d │ d-d │ e-d
↑ ↑
新的0 原有的d变为0
× = 被线覆盖的元素 a,b,c,d,e = 未被覆盖的元素
5. 详细求解过程
示例问题
4 个工人完成 4 个任务,代价矩阵如下,求最小总代价的任务分配:
原始代价矩阵:
T1 T2 T3 T4
W1 [ 2, 1, 3, 4 ]
W2 [ 3, 2, 1, 2 ]
W3 [ 5, 4, 3, 1 ]
W4 [ 4, 3, 2, 3 ]
步骤 1: 行规约(每行减去该行最小值)
W1 行最小值 = 1 → [2-1, 1-1, 3-1, 4-1] = [1, 0, 2, 3]
W2 行最小值 = 1 → [3-1, 2-1, 1-1, 2-1] = [2, 1, 0, 1]
W3 行最小值 = 1 → [5-1, 4-1, 3-1, 1-1] = [4, 3, 2, 0]
W4 行最小值 = 2 → [4-2, 3-2, 2-2, 3-2] = [2, 1, 0, 1]
行规约后:
T1 T2 T3 T4
W1 [ 1, 0, 2, 3 ]
W2 [ 2, 1, 0, 1 ]
W3 [ 4, 3, 2, 0 ]
W4 [ 2, 1, 0, 1 ]
步骤 2: 列规约(每列减去该列最小值)
T1 列最小值 = 1 → [1-1, 2-1, 4-1, 2-1] = [0, 1, 3, 1]
T2 列最小值 = 0 → [0-0, 1-0, 3-0, 1-0] = [0, 1, 3, 1]
T3 列最小值 = 0 → [2-0, 0-0, 2-0, 0-0] = [2, 0, 2, 0]
T4 列最小值 = 0 → [3-0, 1-0, 0-0, 1-0] = [3, 1, 0, 1]
列规约后:
T1 T2 T3 T4
W1 [ 0, 0, 2, 3 ] ← 有两个0
W2 [ 1, 1, 0, 1 ]
W3 [ 3, 3, 2, 0 ]
W4 [ 1, 1, 0, 1 ]
步骤 3: 尝试用最少的线覆盖所有 0
T1 T2 T3 T4
W1 [ 0, 0, 2, 3 ] ← 划线覆盖 W1 行 (覆盖了 T1列0 和 T2列0)
W2 [ 1, 1, 0, 1 ] ← 划线覆盖 T3 列 (覆盖了 W2, W4)
W3 [ 3, 3, 2, 0 ] ← 划线覆盖 T4 列 (覆盖了 W3)
W4 [ 1, 1, 0, 1 ]
覆盖线: W1行, T3列, T4列 → 共 3 条线
3 < 4,继续迭代!
步骤 4: 找未被覆盖的最小值,调整矩阵
覆盖线为 W1行、T3列、T4列,逐元素分析覆盖状态:
覆盖状态 (c = 被至少一条线覆盖, _ = 未被覆盖):
T1 T2 T3 T4
W1 [ c, c, cc, cc ] ← W1整行被W1行线覆盖
W2 [ _, _, c, c ] ← T3列线覆盖W2-T3, T4列线覆盖W2-T4
W3 [ _, _, c, c ] ← T3列线覆盖W3-T3, T4列线覆盖W3-T4
W4 [ _, _, c, c ] ← T3列线覆盖W4-T3, T4列线覆盖W4-T4
c = 单线覆盖, cc = 双线覆盖, _ = 未覆盖
未被覆盖的元素(只有 _ 标记的位置):
W2-T1(1), W2-T2(1),
W3-T1(3), W3-T2(3),
W4-T1(1), W4-T2(1)
最小值 d = 1
注意 : W2-T3、W2-T4、W3-T3、W3-T4、W4-T3、W4-T4 虽然不是 0(W2-T3、W4-T3 除外),但它们已被 T3列线 或 T4列线覆盖,不属于未覆盖区域。
调整规则:
- 未覆盖区域: 减 1
- 双线覆盖区域: 加 1
- 单线覆盖区域: 不变
逐元素计算:
W1: [0, 0, 2+1, 3+1] = [0, 0, 3, 4]
T1,W1行线覆盖→不变; T2,W1行线覆盖→不变; T3,W1行线+T3列线→加1; T4,W1行线+T4列线→加1
W2: [1-1, 1-1, 0, 1] = [0, 0, 0, 1]
T1,T2未覆盖→减1; T3,T3列线覆盖→不变; T4,T4列线覆盖→不变
W3: [3-1, 3-1, 2, 0] = [2, 2, 2, 0]
T1,T2未覆盖→减1; T3,T3列线覆盖→不变; T4,T4列线覆盖→不变
W4: [1-1, 1-1, 0, 1] = [0, 0, 0, 1]
T1,T2未覆盖→减1; T3,T3列线覆盖→不变; T4,T4列线覆盖→不变
调整后:
T1 T2 T3 T4
W1 [ 0, 0, 3, 4 ]
W2 [ 0, 0, 0, 1 ]
W3 [ 2, 2, 2, 0 ]
W4 [ 0, 0, 0, 1 ]
步骤 5: 再次尝试用最少的线覆盖所有 0
调整后矩阵:
T1 T2 T3 T4
W1 [ 0, 0, 3, 4 ]
W2 [ 0, 0, 0, 1 ]
W3 [ 2, 2, 2, 0 ]
W4 [ 0, 0, 0, 1 ]
0 元素位置:
W1: T1, T2
W2: T1, T2, T3
W3: T4
W4: T1, T2, T3
划线覆盖过程:
① 划 W1 行 → 覆盖了 W1 的全部 0 (T1, T2)
② 划 W2 行 → 覆盖了 W2 的全部 0 (T1, T2, T3)
③ 划 W4 行 → 覆盖了 W4 的全部 0 (T1, T2, T3)
④ 划 T4 列 → 覆盖了 W3-T4 的 0
检查: 所有 0 都被覆盖了!
T1 T2 T3 T4
W1 [ 0, 0, 3, 4 ] ← 划线覆盖 W1 行
W2 [ 0, 0, 0, 1 ] ← 划线覆盖 W2 行
W3 [ 2, 2, 2, 0 ] ← 划线覆盖 T4 列
W4 [ 0, 0, 0, 1 ] ← 划线覆盖 W4 行
覆盖线: W1行, W2行, W4行, T4列 → 共 4 条线
4 == 4,找到了最优解!
步骤 6: 从 0 元素中选出 n 个不同行不同列的
0 元素位置:
W1: T1, T2
W2: T1, T2, T3
W3: T4
W4: T1, T2, T3
不同行不同列的选择:
W1→T2, W2→T1, W3→T4, W4→T3 → 全部不同行不同列 ✓
或
W1→T1, W2→T2, W3→T4, W4→T3 → 全部不同行不同列 ✓
最终结果
选择方案: W1→T2, W2→T1, W3→T4, W4→T3
原始矩阵:
T1 T2 T3 T4
W1 [ 2, 1, 3, 4 ]
W2 [ 3, 2, 1, 2 ]
W3 [ 5, 4, 3, 1 ]
W4 [ 4, 3, 2, 3 ]
最优匹配 (★ 表示选中):
T1 T2 T3 T4
W1 [ ·, ★, ·, · ] 代价 1
W2 [ ★, ·, ·, · ] 代价 3
W3 [ ·, ·, ·, ★ ] 代价 1
W4 [ ·, ·, ★, · ] 代价 2
最小总代价 = 1 + 3 + 1 + 2 = 7
6. Python 代码实现
python
"""
匈牙利算法 (Hungarian Algorithm) 完整实现
用于求解 n×n 代价矩阵的最小权完美匹配问题
┌─────────────────────────┬────────────────────────────────┐
│ 函数 │ 职责 │
├─────────────────────────┼────────────────────────────────┤
│ _row_reduction │ 行规约:每行减去最小值 │
├─────────────────────────┼────────────────────────────────┤
│ _col_reduction │ 列规约:每列减去最小值 │
├─────────────────────────┼────────────────────────────────┤
│ _find_independent_zeros │ 贪心找独立 0 元素,标记为 ★ │
├─────────────────────────┼────────────────────────────────┤
│ _extract_matching │ 从 ★ 矩阵提取匹配结果 │
├─────────────────────────┼────────────────────────────────┤
│ _is_perfect_matching │ 判断是否找到完美匹配 │
├─────────────────────────┼────────────────────────────────┤
│ _find_augmenting_path │ 沿 ★ 和 ○ 交替回溯,找增广路径 │
├─────────────────────────┼────────────────────────────────┤
│ _augment_matching │ 沿增广路径翻转,★ 数量 +1 │
├─────────────────────────┼────────────────────────────────┤
│ _cover_lines_with_konig │ König 定理构造覆盖线 │
├─────────────────────────┼────────────────────────────────┤
│ _adjust_matrix │ 找未覆盖最小值,调整矩阵 │
├─────────────────────────┼────────────────────────────────┤
│ hungarian_algorithm │ 主流程:调用上述函数完成迭代 │
└─────────────────────────┴────────────────────────────────┘
三个示例运行结果与之前一致,总代价分别为 7、5、0.60。
"""
import numpy as np
def _row_reduction(cost):
"""行规约:每行减去该行最小值,保证每行至少有一个 0"""
for i in range(cost.shape[0]):
cost[i] -= np.min(cost[i])
def _col_reduction(cost):
"""列规约:每列减去该列最小值,保证每列至少有一个 0"""
for j in range(cost.shape[1]):
cost[:, j] -= np.min(cost[:, j])
def _find_independent_zeros(cost):
"""
贪心寻找独立 0 元素,标记为 ★
返回:
star_matrix: ★ 标记矩阵,star[i][j]=True 表示位置 (i,j) 被选中
col_covered: 列覆盖标记,包含 ★ 的列被标记为 True
"""
n = cost.shape[0]
star_matrix = np.zeros((n, n), dtype=bool)
col_covered = np.zeros(n, dtype=bool)
# 临时行标记,用于贪心选择
row_marked = np.zeros(n, dtype=bool)
for i in range(n):
for j in range(n):
if cost[i][j] == 0 and not row_marked[i] and not col_covered[j]:
star_matrix[i][j] = True
row_marked[i] = True
col_covered[j] = True
# 标记包含 ★ 的列
col_covered[:] = False
for j in range(n):
for i in range(n):
if star_matrix[i][j]:
col_covered[j] = True
return star_matrix, col_covered
def _extract_matching(star_matrix):
"""从 ★ 矩阵中提取匹配结果:row_ind[i] = j 表示行 i 匹配列 j"""
n = star_matrix.shape[0]
row_ind = [-1] * n
for i in range(n):
for j in range(n):
if star_matrix[i][j]:
row_ind[i] = j
return row_ind
def _is_perfect_matching(col_covered):
"""判断是否找到完美匹配:所有列都被 ★ 覆盖"""
return np.sum(col_covered) == col_covered.shape[0]
def _find_augmenting_path(star_matrix, prime_matrix, start, n):
"""
从 primed 0 出发,沿 ★ 和 ○ 交替回溯,找到增广路径
返回:
path: 增广路径,交替包含 primed 0 和 ★ 的坐标
"""
row_z, col_z = start
path = [(row_z, col_z)]
r, c = row_z, col_z
while True:
# 找同一列中的 ★
for i in range(n):
if star_matrix[i][c]:
path.append((i, c))
r = i
break
# 找同一行中的 ○ (primed 0)
found_prime = False
for j in range(n):
if prime_matrix[r][j]:
path.append((r, j))
c = j
found_prime = True
break
if not found_prime:
break
return path
def _augment_matching(star_matrix, path):
"""
沿增广路径翻转:primed 0 (○) → ★,★ → 取消标记
这样 ★ 的数量增加 1
"""
for idx, (r, c) in enumerate(path):
if idx % 2 == 0:
star_matrix[r][c] = True # ○ → ★
else:
star_matrix[r][c] = False # ★ → 取消
def _cover_lines_with_konig(star_matrix, cost, n):
"""
使用 König 定理的构造方法,用最少的线覆盖所有 0 元素
过程:
1. 找未被覆盖的 0 元素,primed (○) 标记
2. 如果该行有 ★:标记该行,取消标记 ★ 所在列
3. 如果该行没有 ★:找到增广路径,翻转后 ★ 数量 +1
4. 没有未被覆盖的 0 时,覆盖线数已确定
返回:
row_covered: 行覆盖标记
col_covered: 列覆盖标记
found_perfect: 是否找到了完美匹配
"""
col_covered = np.zeros(n, dtype=bool)
# 标记包含 ★ 的列
for j in range(n):
for i in range(n):
if star_matrix[i][j]:
col_covered[j] = True
row_covered = np.zeros(n, dtype=bool)
prime_matrix = np.zeros((n, n), dtype=bool)
while True:
# 找一个未被覆盖的 0 元素
found = False
row_z, col_z = -1, -1
for i in range(n):
if row_covered[i]:
continue
for j in range(n):
if not col_covered[j] and cost[i][j] == 0:
row_z, col_z = i, j
found = True
break
if found:
break
if not found:
# 没有未被覆盖的 0 了,覆盖线数确定
return row_covered, col_covered, False
# primed 该 0 元素
prime_matrix[row_z][col_z] = True
# 检查该行是否有 ★
col_star = -1
for j in range(n):
if star_matrix[row_z][j]:
col_star = j
break
if col_star >= 0:
# 有 ★: 标记该行,取消标记 ★ 所在列
row_covered[row_z] = True
col_covered[col_star] = False
else:
# 没有 ★: 找到增广路径!
path = _find_augmenting_path(star_matrix, prime_matrix, (row_z, col_z), n)
_augment_matching(star_matrix, path)
# 清除标记,重新计算列覆盖
prime_matrix[:] = False
row_covered[:] = False
col_covered[:] = False
for j in range(n):
for i in range(n):
if star_matrix[i][j]:
col_covered[j] = True
if _is_perfect_matching(col_covered):
return row_covered, col_covered, True
return row_covered, col_covered, False
def _adjust_matrix(cost, row_covered, col_covered):
"""
调整矩阵:为下一轮迭代准备更多 0 元素
规则:
- 未被覆盖的元素: 减 min_val
- 被两条线覆盖的元素: 加 min_val
- 被一条线覆盖的元素: 不变
"""
n = cost.shape[0]
# 找未被覆盖元素中的最小值
min_val = float('inf')
for i in range(n):
if row_covered[i]:
continue
for j in range(n):
if not col_covered[j] and cost[i][j] < min_val:
min_val = cost[i][j]
# 调整矩阵
for i in range(n):
for j in range(n):
if not row_covered[i] and not col_covered[j]:
cost[i][j] -= min_val # 未覆盖
elif row_covered[i] and col_covered[j]:
cost[i][j] += min_val # 双线覆盖
def hungarian_algorithm(cost_matrix):
"""
匈牙利算法求解最小代价匹配
参数:
cost_matrix: n×n 的二维列表或 numpy 数组
cost_matrix[i][j] 表示工人 i 完成任务 j 的代价
返回:
row_ind: 匹配结果中各行对应的列索引
row_ind[i] = j 表示行 i 匹配列 j
total_cost: 最小总代价
"""
cost = np.array(cost_matrix, dtype=float)
n = cost.shape[0]
# 步骤 1-2: 行规约 + 列规约
_row_reduction(cost)
_col_reduction(cost)
# 步骤 3-5: 迭代找最优匹配
max_iter = n * n
for _ in range(max_iter):
# 贪心找独立 0 元素
star_matrix, col_covered = _find_independent_zeros(cost)
# 判断是否找到完美匹配
if _is_perfect_matching(col_covered):
row_ind = _extract_matching(star_matrix)
total_cost = sum(cost_matrix[i][row_ind[i]] for i in range(n))
return row_ind, total_cost
# 用 König 定理覆盖所有 0
row_covered, col_covered, found = _cover_lines_with_konig(
star_matrix, cost, n
)
if found:
row_ind = _extract_matching(star_matrix)
total_cost = sum(cost_matrix[i][row_ind[i]] for i in range(n))
return row_ind, total_cost
# 调整矩阵,增加 0 元素
_adjust_matrix(cost, row_covered, col_covered)
raise RuntimeError("匈牙利算法未在最大迭代次数内收敛")
def print_solution(cost_matrix, row_ind):
"""
打印匹配结果
参数:
cost_matrix: 原始代价矩阵
row_ind: 匹配结果,row_ind[i] = j 表示行 i 匹配列 j
"""
n = len(row_ind)
total = 0
print("\n匹配结果:")
print("-" * 50)
for i in range(n):
j = row_ind[i]
cost = cost_matrix[i][j]
total += cost
print(f" 工人 {i+1} → 任务 {j+1},代价 = {cost}")
print("-" * 50)
print(f" 最小总代价 = {total}")
# 可视化矩阵,★ 标记选中的位置
print("\n代价矩阵 (★ = 选中):")
header = " " + " ".join(f"T{j+1}" for j in range(n))
print(header)
for i in range(n):
row_str = f" W{i+1} ["
for j in range(n):
if row_ind[i] == j:
row_str += f" ★{cost_matrix[i][j]} "
else:
row_str += f" {cost_matrix[i][j]} "
row_str += "]"
print(row_str)
# ============================================================
# 运行示例
# ============================================================
if __name__ == "__main__":
print("=" * 60)
print(" 匈牙利算法 ------ 最小代价匹配求解")
print("=" * 60)
# ---- 示例 1: 4×4 代价矩阵 ----
cost_matrix_1 = [
[2, 1, 3, 4],
[3, 2, 1, 2],
[5, 4, 3, 1],
[4, 3, 2, 3]
]
print("\n【示例 1】4×4 代价矩阵:")
print(" T1 T2 T3 T4")
for i, row in enumerate(cost_matrix_1):
print(f"W{i+1} {row}")
row_ind, total_cost = hungarian_algorithm(cost_matrix_1)
print_solution(cost_matrix_1, row_ind)
# ---- 示例 2: 3×3 代价矩阵 ----
cost_matrix_2 = [
[4, 1, 3],
[2, 0, 5],
[3, 2, 2]
]
print("\n\n【示例 2】3×3 代价矩阵:")
print(" T1 T2 T3")
for i, row in enumerate(cost_matrix_2):
print(f"W{i+1} {row}")
row_ind, total_cost = hungarian_algorithm(cost_matrix_2)
print_solution(cost_matrix_2, row_ind)
# ---- 示例 3: 目标检测中的应用 ----
# 预测框与真实框之间的 IoU 代价矩阵
# IoU 越大越好 → 用 1-IoU 作为代价
print("\n\n【示例 3】目标检测中的 IoU 匹配:")
print(" 预测框与真实框的 IoU 矩阵:")
iou_matrix = [
[0.9, 0.2, 0.1],
[0.1, 0.8, 0.3],
[0.2, 0.1, 0.7]
]
# 转换为代价矩阵: cost = 1 - IoU
cost_matrix_3 = [[1 - iou for iou in row] for row in iou_matrix]
print(" IoU 矩阵:")
for row in iou_matrix:
print(f" {row}")
print(" 代价矩阵 (1-IoU):")
for row in cost_matrix_3:
print(f" {row}")
row_ind, total_cost = hungarian_algorithm(cost_matrix_3)
print("\n 匹配结果:")
for i, j in enumerate(row_ind):
print(f" 真实框 {i+1} ↔ 预测框 {j+1} (IoU = {iou_matrix[i][j]})")
print(f" 总代价 = {total_cost:.2f}")
print(f" 平均 IoU = {(len(row_ind) - total_cost) / len(row_ind):.2f}")