qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

每次先配一对能配上的,为什么最后反而少配一个?

先配A与1会挡住B;把旧配对沿一条交替路线重新安排,数量反而能增加。用伙伴分配的穷举核对小图,而不是把“不能直接加边”当成最优。

展开本文目录
  1. 下载完整练习包
  2. 再也不能直接加一对,不代表已经配得最多
  3. 沿着未选、已选、未选的路线翻转
  4. 右边已经有人占用,先问旧搭档能不能换地方
  5. 为什么逐个处理左顶点,也能得到最大数量?
  6. 让每个左点直接枚举伙伴,独立核对小图
  7. 运行完整程序,对照数量、配对与覆盖范围
  8. 数量最大,不等于配对唯一或所有目标都最好
  9. 交付一份别人能逐边检查的安排
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了

设左边是任务,右边是可用机器;连线表示这台机器能完成这个任务。每个任务最多选一台机器,每台机器也最多接一个任务。两组已经给定,我们现在要找尽可能多的配对。

看到一个空闲组合就先定下来很自然,但早先的选择可能占住另一个任务唯一的去处。这里允许在规划阶段重新安排旧配对,再比较最后能完成多少对;没有执行任何真实分配。

把例子带到自己的电脑上

下载完整练习包

需要 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允许范围说成已经穷尽。

展开收起代码 · 小实验python · 125 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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沿用到下一轮搜索。
  • 要求最大匹配的具体边集合必须唯一。
  • 把可重新安排的规划算法用于已经不可撤销的实际分配。

自己试一小步

你能给出一份不能直接加边、却还能通过重排变大的安排吗?

  1. 自己画三左三右的允许边,先手工选一份合法匹配。
  2. 逐条检查还能不能直接加边,再寻找或排除一条增广路径。
  3. 运行程序与独立伙伴枚举,保存配对、数量和一轮手算轨迹。

做到这些,就算完成

  • 初始与最终边集均没有重复使用端点。
  • 若声称找到增广路,两个端点未配,沿途边状态交替,翻转后数量恰增一。
  • 若程序返回不同的同规模最优安排,按合法性与数量核对,而不按边的顺序判错。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Algorithms II, 7.4.1 Augmenting Paths (新标签页)↗Norbert Zeh, Dalhousie University · 英文

    增广路径两端未匹配、翻转增一,以及合法匹配最大当且仅当不存在增广路。

    2026-10-18 已实际核读:正文定义、Lemma 7.8的对称差论证与Corollary 7.9及其证明(页面正文约500–543行)。定理适用于一般图,不意味着本题二分图DFS可直接用于一般图。

  • CS 2110 Discussion 11: Using DFS for Bipartite Matching (新标签页)↗Cornell University · 英文

    用显式两组、端点不共享的定义和DFS增广构建教学顺序。

    2026-10-18 已实际核读:Bipartite Graphs、Matchings、Augmenting Paths和Find Augmenting Paths with DFS正文(约39–142行)。未下载或运行该课Java starter code;例子和Python源码自行构造。

  • hopcroft_karp_matching API (新标签页)↗NetworkX developers · 英文

    最大数量与具体配对不唯一的区别;显式分区的重要性。

    2026-10-18 已实际核读:返回最大基数匹配、结果不唯一、top_nodes以及不连通图的AmbiguousSolution说明(约98–141行)。官网本次显示3.7;未安装或运行NetworkX。本原型是逐起点DFS,不是Hopcroft–Karp。