设左边是任务,右边是可用机器;连线表示这台机器能完成这个任务。每个任务最多选一台机器,每台机器也最多接一个任务。两组已经给定,我们现在要找尽可能多的配对。
看到一个空闲组合就先定下来很自然,但早先的选择可能占住另一个任务唯一的去处。这里允许在规划阶段重新安排旧配对,再比较最后能完成多少对;没有执行任何真实分配。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;2026-10-18 实测 Windows / Python 3.14.0,3.10 仅做语法检查。仅用标准库,无第三方依赖。仅使用标准库。实际运行日期、环境和范围见随包核验记录。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- matching_augment.py:教学源码
- matching_augment.observed.stdout.txt:说明或核验记录
- README.md:说明或核验记录
- verification.json:说明或核验记录
- SHA256SUMS.txt:说明或核验记录
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B matching_augment.py解压到独立练习文件夹,在包含源码的目录打开终端,按下方顺序运行。示例运行记录不代表独立练习已完成;修改代码后需重新验证。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
再也不能直接加一对,不代表已经配得最多
左边有A、B,右边有1、2;允许的边只有A—1、A—2、B—1。若按这个次序先取A—1,A和1都占用了,剩下两条边都不能直接加进来。当前只有一对。
这叫极大匹配:保持已有选择不变,已经无法再加一条边。但我们可以把A改配2,让B配1,得到两对。最大匹配要求没有任何其他合法方案能配得更多;“不能直接加”与“不能通过重排增加”不同。
匹配首先必须合法:每个端点最多出现在一条被选边中。A—1和A—2不能同时算作一个合法匹配,再拿它讨论最优性。这里求的是配对数量,不比较收入、偏好或稳定性。
沿着未选、已选、未选的路线翻转
从尚未配对的B出发,可以走B→1→A→2。B—1未选,1—A已选,A—2未选;路线两端B和2都未配对。这样的路线叫增广路径:边在未选与已选之间交替,起点和终点都空闲。
沿路线把已选边去掉、未选边加上。内部点失去一条配对又得到一条,两个端点各得到一条,仍然没有谁同时占两对。未选边比已选边恰好多一条,所以总数恰好增加一。
对一个合法匹配M,只有当它不是最大基数匹配时,才能保证图中某处存在增广路。其理由可以从M与一个更大匹配的差异看出:两份匹配不同的边分成若干交替路径与环;环两边数量相同,若总数有差,至少有一条路径在更大匹配中多一条边,其两端就没有被M匹配。反过来,有增广路当然能够把M增大。
右边已经有人占用,先问旧搭档能不能换地方
程序用owner[v]保存右顶点v当前的左搭档,−1表示空闲。搜索左顶点u时,按邻接表尝试右顶点v:若v空闲,可以配上;若v已有搭档,就递归尝试给旧搭档另找位置,成功后再把v交给u。
每次从一个新左顶点出发,都新建一次seen集合。它只防止本轮搜索沿着同一批点反复绕,不是永久宣布某个点没有出路。早先的配对改变后,旧的失败标记不能继续阻止新一轮重排。
递归内部可能先把旧搭档写到新位置,再由外层覆盖原来的owner槽位。因此本文只在一次增广完整返回后展示整份匹配;此时每个左、右顶点至多出现一次,所有配对都是输入边。不要把任意一条递归中的临时赋值截出来,当成已完成的分配结果。
为什么逐个处理左顶点,也能得到最大数量?
可以把外层循环理解为逐渐加入左顶点。处理前几个左点以后,维护的匹配在这些已处理左点与全部右点构成的子图中数量最大;初始没有左点,空匹配显然满足。
加入一个新左点u后,最优数量最多增加一,因为只多了一个能参加配对的左点。若新图确实存在更大的匹配,增广路不能完全落在旧子图中,否则旧匹配就还可增大。新u尚未匹配,也不能作为增广路内部点,因此这条增广路必须以u为端点。
从u出发的DFS完整搜索这些交替路线:找到就增加一对,找不到则新子图也无法增大。这样归纳到所有左点。这个论证依赖显式二分图和当前合法匹配;不是“随便一个起点找不到,就说明整个图最优”。
让每个左点直接枚举伙伴,独立核对小图
参考程序不再写一份DFS,而是给每个左顶点枚举“未配”或某个右顶点,拒绝右顶点重复以及不存在的边,保留合法方案中的最大数量。这样即使增广实现走错路线,参考也不会沿同一套更新逻辑一起出错。
本次检查左、右两侧顶点数分别为0到3的全部固定标号简单二分图,共689图;参考累计检查35,799个伙伴分配向量。每图都比较最大数量,并检查返回配对确实存在、左右端点均不重复。固定顺序的贪心在其中104图少配;这个比例不代表真实任务分布。
空边集的最大匹配是空集。两左点一右点、仅A—1可用时,即使A—1已配而B仍空闲,也没有增广路;反过来,在空匹配下只从孤立B搜索失败,却不能否定A—1这条长度1增广路。检查时还要拒绝非法起始匹配,并允许多份同样大的最优解。
运行完整程序,对照数量、配对与覆盖范围
保存为matching_augment.py,运行python -B matching_augment.py。第一行显示贪心的一对与增广得到的两对;完整报告matching_augment.report.json保存逐轮增广后的配对、枚举范围和计数。
源码只用Python标准库,已在Python 3.14.0实际运行;按Python 3.10语法检查通过,但没有启动Python 3.10解释器。输入约定为左右分区各0到6个顶点,边用两侧各自的下标表示,并拒绝越界、布尔下标与重复边。实际全枚举只到每侧3点,不把API允许范围说成已经穷尽。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Research prototype: maximum cardinality matching in small bipartite graphs.
Python standard library only. Partitions are explicit and indexed separately.
The algorithm accepts at most six vertices per side; exhaustive checks stop at
three per side. This is neither weighted matching nor a performance benchmark.
"""
from itertools import product
import json
from pathlib import Path
import platform
def normalize(left_count, right_count, edges):
if any(type(n) is not int or not 0 <= n <= 6 for n in (left_count, right_count)):
raise ValueError("partition sizes must be integers in 0..6")
clean = []
for edge in edges:
if not isinstance(edge, (list, tuple)) or len(edge) != 2:
raise ValueError("each edge needs one index from each partition")
u, v = edge
if (type(u) is not int or type(v) is not int
or not 0 <= u < left_count or not 0 <= v < right_count):
raise ValueError("edge outside its explicit partitions")
if (u, v) in clean:
raise ValueError("simple graph expected; duplicate edge")
clean.append((u, v))
return sorted(clean)
def maximum_matching(left_count, right_count, edges):
edges = normalize(left_count, right_count, edges)
adjacent = [[] for _ in range(left_count)]
for u, v in edges:
adjacent[u].append(v)
owner = [-1] * right_count
trace = []
def reroute(u, seen):
if u in seen:
return False
seen.add(u)
for v in adjacent[u]:
if owner[v] == -1 or reroute(owner[v], seen):
owner[v] = u
return True
return False
for u in range(left_count):
# A visited set belongs to this one search, not to the whole algorithm.
if reroute(u, set()):
pairs = sorted((left, right) for right, left in enumerate(owner) if left != -1)
trace.append({"start_left": u, "pairs_after_augmentation": pairs})
return sorted((u, v) for v, u in enumerate(owner) if u != -1), trace
def first_free_greedy(left_count, right_count, edges):
edges = normalize(left_count, right_count, edges)
used_left, used_right, chosen = set(), set(), []
for u, v in edges:
if u not in used_left and v not in used_right:
chosen.append((u, v))
used_left.add(u)
used_right.add(v)
return chosen
def assignment_oracle(left_count, right_count, edges):
"""Independent reference: enumerate each left vertex's partner or -1.
No DFS, alternating path or right-owner updates are used here.
"""
allowed = set(edges)
best, vectors = 0, 0
for assignment in product(range(-1, right_count), repeat=left_count):
vectors += 1
pairs = [(u, v) for u, v in enumerate(assignment) if v != -1]
if (len({v for _, v in pairs}) == len(pairs)
and all(pair in allowed for pair in pairs)):
best = max(best, len(pairs))
return best, vectors
def valid_witness(pairs, edges):
return (len({u for u, _ in pairs}) == len(pairs)
and len({v for _, v in pairs}) == len(pairs)
and all(pair in edges for pair in pairs))
def main():
example = [(0, 0), (0, 1), (1, 0)]
greedy = first_free_greedy(2, 2, example)
best, trace = maximum_matching(2, 2, example)
assert len(greedy) == 1 and len(best) == 2
graph_count = assignment_vectors = greedy_shortfalls = 0
for nl in range(4):
for nr in range(4):
universe = [(u, v) for u in range(nl) for v in range(nr)]
for mask in range(1 << len(universe)):
edges = [edge for bit, edge in enumerate(universe) if mask & (1 << bit)]
pairs, _ = maximum_matching(nl, nr, edges)
optimum, inspected = assignment_oracle(nl, nr, edges)
assert valid_witness(pairs, edges)
assert len(pairs) == optimum
g = first_free_greedy(nl, nr, edges)
assert valid_witness(g, edges) and len(g) <= optimum
greedy_shortfalls += len(g) < optimum
assignment_vectors += inspected
graph_count += 1
report = {
"status": "PASS", "python_version": platform.python_version(),
"example_edges": example, "greedy": greedy, "maximum": best,
"augmentation_trace": trace, "graphs_compared": graph_count,
"assignment_vectors_inspected": assignment_vectors,
"greedy_shortfall_graphs": greedy_shortfalls,
"scope": "All labeled simple bipartite graphs for each left/right size in 0..3; only cardinality and witness validity compared. No weighted/general graph matching, NetworkX run, or timing claims."
}
Path(__file__).with_suffix('.report.json').write_text(
json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f"greedy={greedy}; maximum={best}")
print(f"graphs_compared={graph_count}; assignment_vectors={assignment_vectors}; greedy_shortfalls={greedy_shortfalls}")
print("PASS; exhaustive scope: labeled bipartite partitions 0..3 each")
if __name__ == '__main__':
main()
怎样运行:在同一独立目录保存为matching_augment.py并执行python -B matching_augment.py;只用标准库,写出matching_augment.report.json。
运行后对照结果
greedy=[(0, 0)]; maximum=[(0, 1), (1, 0)]
graphs_compared=689; assignment_vectors=35799; greedy_shortfalls=104
PASS; exhaustive scope: labeled bipartite partitions 0..3 each数量最大,不等于配对唯一或所有目标都最好
在两左两右、四条边都存在的图中,对角配法和交叉配法都能得到两对。因此验收应比较最大数量、端点唯一性和边的合法性,不要求具体配对与某一份答案逐项相同。最大匹配也不自动等于每个顶点都被匹配的完美匹配。
核心搜索按左顶点反复扫描邻接关系;这个教学实现还排序输入、用列表检查重复,并为输出整理配对轨迹,这些都有额外成本。没有把它称为Hopcroft–Karp,也不直接照搬那个算法的复杂度或性能数字。小规模独立枚举的作用是发现实现错误,不是替代前面的正确性论证。
如果一条配对有金额、偏好次序、多个名额,或者已经执行的分配不允许撤回,问题就换了。本程序没有实现带权匹配、稳定匹配、一般图匹配或在线不可撤销分配。重新安排只发生在这个可修改的计划中。
交付一份别人能逐边检查的安排
一份可核验的结果应同时写清左右分区、允许边、最终配对、未配顶点和数量;若展示一次改进,还应列出增广前的合法匹配、交替路径与翻转后的结果。只输出“最大是2”会丢掉最容易检查的证据。
下面的练习只给任务与自检标准。先手算,再运行;若换了顶点编号导致程序选择另一份同样大的匹配,应按合法性和数量解释差异。不要为了让输出看起来一样而把结果写死。
记住这些要点
- 极大只说明不能直接加边,最大才说明没有配得更多的合法方案。
- 增广路两端未配、边状态交替,翻转后数量增加一。
- 只有合法而非最大的匹配才保证某处有增广路;单个起点失败不够。
- 独立伙伴枚举只核对明确的小图范围,不代替一般正确性证明。
别把例子用过头
- 把有未配顶点误当成一定存在增广路。
- 把一轮DFS的seen沿用到下一轮搜索。
- 要求最大匹配的具体边集合必须唯一。
- 把可重新安排的规划算法用于已经不可撤销的实际分配。
自己试一小步
你能给出一份不能直接加边、却还能通过重排变大的安排吗?
- 自己画三左三右的允许边,先手工选一份合法匹配。
- 逐条检查还能不能直接加边,再寻找或排除一条增广路径。
- 运行程序与独立伙伴枚举,保存配对、数量和一轮手算轨迹。
做到这些,就算完成
- 初始与最终边集均没有重复使用端点。
- 若声称找到增广路,两个端点未配,沿途边状态交替,翻转后数量恰增一。
- 若程序返回不同的同规模最优安排,按合法性与数量核对,而不按边的顺序判错。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Algorithms II, 7.4.1 Augmenting Paths (新标签页)↗
增广路径两端未匹配、翻转增一,以及合法匹配最大当且仅当不存在增广路。
- CS 2110 Discussion 11: Using DFS for Bipartite Matching (新标签页)↗
用显式两组、端点不共享的定义和DFS增广构建教学顺序。
- hopcroft_karp_matching API (新标签页)↗
最大数量与具体配对不唯一的区别;显式分区的重要性。