开始前,会这些就够了
- 会读列表、循环、队列
- 先看 图与 BFS 中顶点、边和邻接表
有些分组要求是“两个人不能在同一组”。把人画成点,把禁同组关系画成连线,问题就变成:能否只用红、蓝两种颜色,让每条线两端颜色不同?人数多少本身并不决定答案。
我们不猜一个失败名单,而是让程序给出两种可核对的结果:要么完整分组,要么一条边数为奇数的闭合路线。这里的冲突只表示题目约束,不涉及真实人员数据。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- bipartite.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B bipartite.py解压到独立练习文件夹,在含 bipartite.py 的目录打开终端运行。无向图二染色与奇数环证据;不求组大小均衡、最大匹配或删边最优;主函数至多1000顶点,独立穷举至多6顶点。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
从三个人的最小矛盾开始
A 不能和 B 同组,B 不能和 C 同组。若 A 红、B 蓝,C 就必须红。目前没有矛盾;再加上 C 不能与 A 同组,就要求两个红色不同色。把初始红蓝全部交换也一样,问题来自这三条关系。
沿着一条边就必须换一次颜色。绕偶数条边回到起点时换色次数为偶数,可以与原色一致;绕奇数条边回来就要求起点变成另一色。因此任何奇数环都会阻止两组安排。四个人首尾相连的方圈却可以红蓝交替。
沿队列传播要求,别漏掉孤立的小圈
程序先选一个未分组的人涂成 0,把他加入队列;每次取出一个人,把尚未分色的邻居涂成 1-color。已经分色的邻居若与当前同色,这条边就是冲突现场。0 和 1 只代表两组,没有优劣之分。
一个大图可能分成几块互不相连的关系,所以外层循环必须继续从所有未访问点启动。只从 0 出发,可能漏掉另一块中的三角形。孤立点没有约束,可以放任一组;本例统一放 0,不声称分组唯一或人数平衡。
为何冲突边能拼成一条奇数环?
每个首次发现的点都保存“是从谁走来的”。遇到同色端点 u、v 时,分别沿父节点向根回退,找到第一处共同祖先。u 到祖先、祖先到 v,再接上冲突边 v→u,就得到一条闭合路线。
树中颜色等于从根走过边数的奇偶性。同色端点到共同祖先的两段路合起来有偶数条边,再添一条冲突边便是奇数。程序去掉共用尾巴,所以除首尾重复外不重复顶点。自环是长度 1 的直接反例;重复边不改变能否分组。
运行后检查颜色或反例的每一条边
保存 bipartite.py 后运行 python -B bipartite.py。triangle 返回 bipartite=false 和 odd_cycle;square_plus_isolated 返回五个颜色,最后一位对应没有连线的孤立点。不要把具体红蓝排列当作唯一标准。
独立核对不调用另一份 BFS:它把每个小图的全部 2^V 种红蓝分配直接列出,检查是否存在一种满足所有边。程序枚举 0 到 5 个顶点的全部无向简单图,另测自环、重复边、断开的三角形和非法输入;反例检查器逐边确认圈确实存在、边数奇数、没有中途重复顶点。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Two teams or an odd cycle; standard-library teaching program."""
from collections import deque
from itertools import combinations, product
import json
def partition(n, edges):
edges = tuple(edges)
if type(n) is not int or not 0 <= n <= 1000:
raise ValueError("n must be an integer in 0..1000")
adjacency = [[] for _ in range(n)]
for u, v in edges:
if any(type(x) is not int or not 0 <= x < n for x in (u, v)):
raise ValueError("unknown vertex")
adjacency[u].append(v)
adjacency[v].append(u)
color, parent = [None] * n, [-1] * n
for root in range(n):
if color[root] is not None:
continue
color[root] = 0
queue = deque([root])
while queue:
u = queue.popleft()
for v in adjacency[u]:
if color[v] is None:
color[v], parent[v] = 1 - color[u], u
queue.append(v)
elif color[v] == color[u]:
a, b = [u], [v]
while parent[a[-1]] != -1:
a.append(parent[a[-1]])
while parent[b[-1]] != -1:
b.append(parent[b[-1]])
ancestors = set(a)
j = next(i for i, x in enumerate(b) if x in ancestors)
i = a.index(b[j])
cycle = a[:i + 1] + list(reversed(b[:j])) + [u]
return {"bipartite": False, "colors": None, "odd_cycle": cycle}
return {"bipartite": True, "colors": color, "odd_cycle": None}
def exhaustive_coloring(n, edges):
# Independent oracle: enumerate every assignment, no traversal or parent tree.
if n > 6:
raise ValueError("oracle limited to six vertices")
return any(all(bits[u] != bits[v] for u, v in edges)
for bits in product((0, 1), repeat=n))
def verify_witness(n, edges, result):
if result["bipartite"]:
colors = result["colors"]
return len(colors) == n and all(colors[u] != colors[v] for u, v in edges)
cycle = result["odd_cycle"]
edge_set = {frozenset((u, v)) for u, v in edges}
return (cycle[0] == cycle[-1] and (len(cycle) - 1) % 2 == 1
and len(set(cycle[:-1])) == len(cycle) - 1
and all(frozenset((u, v)) in edge_set for u, v in zip(cycle, cycle[1:])))
def main():
checks = 0
def check(ok):
nonlocal checks
if not ok:
raise AssertionError("bipartite check failed")
checks += 1
graph_count = 0
for n in range(6):
possible = list(combinations(range(n), 2))
for flags in product((0, 1), repeat=len(possible)):
edges = [e for e, include in zip(possible, flags) if include]
result = partition(n, edges)
check(result["bipartite"] == exhaustive_coloring(n, edges))
check(verify_witness(n, edges, result))
graph_count += 1
for n, edges in [(1, [(0, 0)]), (2, [(0, 1), (0, 1)]),
(4, [(0, 1), (1, 2), (2, 0)]), (4, [])]:
result = partition(n, edges)
check(result["bipartite"] == exhaustive_coloring(n, edges))
check(verify_witness(n, edges, result))
check(partition(3, iter([(0, 1), (1, 2)]))["bipartite"])
for args in [(True, []), (-1, []), (2, [(0, 2)]), (2, [(False, 1)])]:
try:
partition(*args)
except ValueError:
check(True)
else:
raise AssertionError("invalid graph accepted")
triangle = partition(3, [(0, 1), (1, 2), (2, 0)])
square = partition(5, [(0, 1), (1, 2), (2, 3), (3, 0)])
print(json.dumps({"checks_passed": checks, "exhaustive_graphs": graph_count,
"triangle": triangle, "square_plus_isolated": square,
"scope": "undirected graphs; two-color feasibility, not balanced teams or matching"}, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B bipartite.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。无向图二染色与奇数环证据;不求组大小均衡、最大匹配或删边最优;主函数至多1000顶点,独立穷举至多6顶点。
运行后对照结果
{
"checks_passed": 2213,
"exhaustive_graphs": 1100,
"triangle": {
"bipartite": false,
"colors": null,
"odd_cycle": [
1,
0,
2,
1
]
},
"square_plus_isolated": {
"bipartite": true,
"colors": [
0,
1,
0,
1,
0
],
"odd_cycle": null
},
"scope": "undirected graphs; two-color feasibility, not balanced teams or matching"
}这个结论没有承诺两组同样大
按邻接表扫描时,分色与一次证据回溯用 O(V+E) 时间,保存图需 O(V+E),额外队列、颜色和父节点需 O(V)。穷举核对要尝试指数数量的颜色分配,只限小图找错,不是正式算法的成本。
二分图回答能否满足所有异组关系。它没有寻找最多配对、没有按人数平衡、没有解决三组或更多组、也没有自动找出“删除最少哪些冲突”。这些都是额外目标。程序上限 1000 个顶点;没有网络或多线程运行。
记住这三点
- 奇数环是不能二染色的具体证据。
- 必须遍历全部连通部分;颜色编号与组大小都不是最优性结论。
- 成功检查所有边,失败检查整条闭合路线。
别把例子用过头
- 把任意环都当作失败,忽略偶数环可以交替分组。
- 只从一个顶点出发,漏掉别的连通部分。
- 把分组可行误读成分组均衡或最大匹配。
自己试一小步
六个人的分组结果可以给别人独立核验吗?
- 自行画六个顶点,先包含一个四边形和两个孤立点。
- 加入两条自选关系,并在运行前预测结果。
- 若不可行,列出一条奇数环;若可行,列出两组并逐边检查。
做到这些,就算完成
- 不要把答案写死为程序的某个颜色顺序。
- 解释新增边是否改变连通部分,以及是否形成奇数环。
- 成功时逐边检查颜色不同;失败时验证环闭合且边数为奇数。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Algorithms 作者:BipartiteX.java (新标签页)↗
支持二分图、奇数环、分量遍历与 BFS 染色。
- NetworkX:二分图 color 接口 (新标签页)↗
支持两种颜色分配及不可二染色时拒绝结果的契约。
- Python 3.14:deque (新标签页)↗
支持用双端队列保存待访问顶点。