Python实战:13点13线射影平面PG(2,3)构造与可视化 射影几何这块内容很多人第一次接触时都会被“无穷远点”“齐次坐标”这些词劝退觉得抽象得没法落地。但真把 PG(2,3) 这个最小的非平凡射影平面摊开来看它其实只有 13 个点和 13 条线每点落在 4 条线上每线穿过 4 个点结构干净得像一块精心设计的积木。我这次就是拿它练手用 Python 把整个构造过程从有限域运算一路做到可视化中间踩了不少坑也攒了一些心得。这篇东西适合两类人看一类是想搞明白有限射影平面到底怎么“算”出来的数学或算法爱好者另一类是想找一个结构清晰、规模可控的组合对象来练可视化或做教学演示的开发者。读完你至少能拿到一套能直接跑起来的代码骨架以及几个我实测下来很稳的构造思路。1. 为什么选 PG(2,3) 作为入门实例1.1 射影平面的三条公理与最小规模推算射影平面的定义不复杂核心就三条公理任意两点确定唯一一条线任意两条线交于唯一一个点存在四个点使得其中任意三点不共线。第三条公理排除了退化情形保证结构不是“塌”的。从这三条能推出一堆对称性质比如每条线上的点数等于每个点上的线数记这个数为 n1n 就是平面的阶。那为什么最小非平凡阶是 2 而不是 1阶为 1 时每线只有 2 点、每点只有 2 线总共 3 点 3 线这就是个三角形虽然满足前两条公理但第三条要求的四点一般位置凑不出来所以被排除。阶为 2 时总点数 n²n1 7这就是著名的 Fano 平面。阶为 3 时总点数 931 13正好是标题里的 13 点 13 线。我选 3 而不是 2理由很实际Fano 平面只有 7 个点画出来太挤而且它的对称群结构相对特殊很多在一般阶下成立的现象在 n2 时会退化。n3 的规模刚好——13 个点在一张图上排得开13 条线能看出明显的对称模式同时有限域 GF(3) 的运算又足够简单只有 0、1、2 三个元素加减乘除都能心算验证。这个“手算能验、代码能跑、图能看清”的三重平衡是我认为它作为教学和练手实例的最大优势。1.2 有限域 GF(3) 的运算特性与构造前提PG(2,q) 的构造完全建立在有限域 GF(q) 上q3 时这个域就是模 3 的整数环。它的加法、减法、乘法都是模 3 意义下的除法稍微特殊一点除以 1 还是自己除以 2 等价于乘以 2 的逆元而 2×24≡1所以 2 的逆元就是它自己。这个性质很关键意味着 GF(3) 里非零元素的逆元映射是 1→1、2→2没有额外的计算负担。构造射影平面用的是三维向量空间 GF(3)³ 去掉零向量后的“方向”。具体说两个非零向量如果相差一个非零标量倍数就视为同一个射影点。GF(3)³ 有 27 个向量去掉零向量剩 26 个每个射影点对应 2 个非零标量倍数因为 GF(3) 的非零元素只有 1 和 2所以 26/2 13 个射影点。这个除法关系是整个构造的骨架后面代码里所有“去重”逻辑都围绕它展开。注意很多人一开始会误以为射影点就是三维向量本身结果数出 26 个点。关键在于“相差非零倍数视为同一点”这个等价关系漏掉它整个计数就全错了。2. 核心构造思路的拆解与选型2.1 齐次坐标表示与等价类去重策略射影平面上的点用齐次坐标 (x, y, z) 表示其中 x、y、z 取自 GF(3)且不能全为零。这个三元组代表的是“方向”所以 (1,0,2) 和 (2,0,1) 是同一个点因为后者是前者乘以 2 得到的。代码实现时最直接的做法是枚举所有 26 个非零三元组然后对每个三元组做规范化找到第一个非零分量把它缩放成 1这样每个等价类就只剩一个代表元。规范化这一步有个细节要处理好GF(3) 里第一个非零分量可能是 1 也可能是 2。如果是 2就整体乘以 2因为 2 的逆元是 2把它变成 1。举个例子(0,2,1) 第一个非零分量是 2整体乘 2 得 (0,4,2)≡(0,1,2)所以 (0,2,1) 和 (0,1,2) 是同一个点。这个规范化规则保证了每个等价类有且只有一个“首非零分量为 1”的代表元去重就变成了简单的集合操作。我试过另一种思路就是直接用字典按比例关系分组但那样代码写起来啰嗦而且容易在浮点或整数边界上出问题。规范化代表元这个方案干净利落13 个点一次性就能列出来后面建线的时候也方便直接引用。2.2 用对偶方式生成 13 条线而非暴力枚举有了 13 个点接下来要生成 13 条线。一条线在射影平面里也是用齐次坐标 (a, b, c) 表示的满足方程 ax by cz ≡ 0 (mod 3)。最笨的办法是枚举所有可能的 (a,b,c) 三元组然后对每个三元组找出满足方程的点集再去重。但这样会重复计算效率低且容易出错。更优雅的方式是利用对偶性线和点在射影平面里是对称的13 条线同样可以用“规范化代表元”的方式生成然后对每条线计算它包含哪些点。具体做法是对每个规范化后的线坐标 (a,b,c)遍历 13 个点检查 axbycz 模 3 是否为零。这样每条线自然得到 4 个点因为三维空间里一个平面方程在 GF(3)³ 里对应 9 个向量去掉零向量剩 8 个除以 2 个标量倍数得 4 个射影点完全符合理论预期。这个对偶思路的好处是代码结构对称点生成和线生成用同一套规范化逻辑只是最后一步一个做“收集点”、一个做“筛选点”。我在实际写的时候把规范化函数抽出来复用整个构造代码不到 40 行可读性很好。2.3 关联矩阵的构建与验证意义点和线都齐了之后最有价值的产物是关联矩阵一个 13×13 的 0-1 矩阵第 i 行第 j 列为 1 表示第 i 个点落在第 j 条线上。这个矩阵不只是个数据结构它是验证整个构造正确性的核心工具。理论上这个矩阵每行有 4 个 1每点在 4 条线上每列有 4 个 1每线过 4 个点任意两行的内积为 1两点恰好在一条线上相交任意两列的内积也为 1两线恰交于一点。我实测下来把这几个性质写成断言跑一遍基本就能确认构造没写错。尤其是“任意两行内积为 1”这一条如果去重逻辑有 bug比如某个等价类被拆成了两个点这个断言立刻就会挂掉。所以关联矩阵既是输出成果也是自检手段一举两得。3. 完整实操流程与关键代码实现3.1 有限域运算的基础封装先把 GF(3) 的运算封装成几个小函数虽然 Python 的取模运算直接能用但封装一层能让后面代码意图更清晰。加法和乘法就是普通的模 3 运算逆元用一个查表搞定。MOD 3 def gf_add(a, b): return (a b) % MOD def gf_mul(a, b): return (a * b) % MOD def gf_inv(a): # GF(3) 中非零元素的逆元1-1, 2-2 assert a ! 0 return pow(a, MOD - 2, MOD)这里用pow(a, MOD-2, MOD)是费马小定理的通用写法对 q3 来说等价于直接查表但写成通用形式方便以后扩展到 q5 或 q7 时复用。实测这个写法在 q 为素数时都成立是个稳妥的通用方案。3.2 13 个射影点的生成与规范化生成点的核心是枚举 26 个非零三元组并规范化。我用三重循环枚举然后对每个三元组调用规范化函数。def normalize(vec): # 找到第一个非零分量 for v in vec: if v % MOD ! 0: inv gf_inv(v % MOD) return tuple((x * inv) % MOD for x in vec) return None # 全零向量 points set() for x in range(MOD): for y in range(MOD): for z in range(MOD): if x 0 and y 0 and z 0: continue points.add(normalize((x, y, z))) points sorted(points) print(len(points)) # 输出 13跑出来正好 13 个点排序是为了后面输出稳定、方便对照。这里有个小坑normalize里对每个分量取模再乘逆元顺序不能反否则中间结果可能超出范围。虽然 Python 的整数不会溢出但保持“先取模再运算”的习惯移植到其他语言时不会翻车。3.3 13 条线的生成与点线关联计算线的生成逻辑和点几乎一样只是最后一步换成“筛选满足方程的点”。我先把 13 条线的规范化坐标算出来然后对每条线遍历所有点。lines set() for a in range(MOD): for b in range(MOD): for c in range(MOD): if a 0 and b 0 and c 0: continue lines.add(normalize((a, b, c))) lines sorted(lines) def incident(point, line): return sum(p * l for p, l in zip(point, line)) % MOD 0 incidence [] for line in lines: pts_on_line [i for i, p in enumerate(points) if incident(p, line)] incidence.append(pts_on_line) print(f线 {line}: 包含点索引 {pts_on_line})每条线应该恰好包含 4 个点打印出来核对一下13 条线全是 4 个点没有例外。这一步的验证很关键如果某条线只筛出 3 个或 5 个点说明规范化或方程计算有问题。我第一版代码就因为在incident里漏了取模导致部分线多出点来排查了十几分钟才发现是sum之后忘了% MOD。3.4 关联矩阵的构建与三重性质断言把上面的关联关系转成矩阵然后跑三个断言做自检。import numpy as np M np.zeros((13, 13), dtypeint) for j, pts in enumerate(incidence): for i in pts: M[i][j] 1 # 断言 1每行每列都是 4 个 1 assert all(M.sum(axis1) 4) assert all(M.sum(axis0) 4) # 断言 2任意两行内积为 1 for i in range(13): for j in range(i1, 13): assert np.dot(M[i], M[j]) 1 # 断言 3任意两列内积为 1 for i in range(13): for j in range(i1, 13): assert np.dot(M[:, i], M[:, j]) 1 print(所有断言通过构造正确)这三个断言全过基本可以拍胸脯说构造没问题了。我特别喜欢断言 2 和 3因为它们同时验证了“两点定一线”和“两线交一点”这两条核心公理比单纯数点数线数有说服力得多。4. 可视化实现与图形布局调优4.1 用 networkx 做点线二部图展示最直观的可视化是把点和线画成二部图13 个点节点在一侧13 个线节点在另一侧有关联就连边。这样一共 13×4 52 条边图虽然密但结构清晰。import networkx as nx import matplotlib.pyplot as plt G nx.Graph() for i in range(13): G.add_node(fP{i}, bipartite0) for j in range(13): G.add_node(fL{j}, bipartite1) for j, pts in enumerate(incidence): for i in pts: G.add_edge(fP{i}, fL{j}) pos {} for i in range(13): pos[fP{i}] (0, i) for j in range(13): pos[fL{j}] (1, j) nx.draw(G, pos, with_labelsTrue, node_size300, font_size7) plt.show()这个图画出来是两列节点加中间密集的连线能一眼看出每个点连 4 条线、每条线连 4 个点。缺点是边太多交叉严重视觉上有点乱。我后来把点的排列顺序按它在关联矩阵里的模式调整了一下让连线尽量少交叉效果好不少。4.2 平面嵌入布局的尝试与取舍二部图虽然准确但不够“几何”。射影平面本来是有几何直觉的我想把它画成平面上的点和线。问题是 13 点 13 线的射影平面没法无损嵌入欧氏平面它非可平面所以只能做近似布局。我试过用 spring layout 让 networkx 自动布局结果点挤成一团线的关系完全看不出来。后来改用圆形布局13 个点均匀排在一个圆上每条线用一条弧或直线连接它包含的 4 个点。这个方案视觉上漂亮但线的交叉还是免不了而且有些线在圆上看起来像“绕过去”的容易误导。最后的取舍是二部图用于精确验证结构圆形布局用于直观展示对称性两个图各司其职。这个“一图一用途”的思路我觉得比强行做一个“全能图”更实用。4.3 配色与标注的实用技巧可视化里配色不是装饰是信息载体。我的做法是给 13 个点分配 13 种区分度高的颜色然后每条线用它所含点的颜色做渐变或分段着色。这样看一条线就能立刻知道它穿过哪些点比纯黑白图信息密度高得多。标注上点用 P0 到 P12线用 L0 到 L12和图里的节点标签一致方便对照代码输出。字号我调到 7 到 8 之间太小看不清太大互相遮挡。实测下来这个字号在 13 个节点的规模下刚好平衡。提示matplotlib 默认的配色方案在 13 个类别时区分度不够建议手动指定一组高对比度的颜色列表或者用 tab20 色板取前 13 个。5. 常见问题排查与避坑经验5.1 点数或线数不对的典型原因最常见的问题是数出 26 个点或 26 条线原因就是前面说的没做等价类去重。另一个变体是数出 14 个或 12 个通常是规范化函数在某个边界上写错了比如第一个非零分量是 2 时忘了乘逆元导致 (0,2,1) 和 (0,1,2) 被当成两个点。排查方法很简单打印所有规范化后的三元组人工检查有没有两个三元组是倍数关系。如果有说明规范化没生效。我建议在开发阶段加一个断言检查任意两个点向量都不成比例这样问题会立刻暴露。5.2 关联矩阵断言失败的定位思路断言失败时先看是哪个断言挂的。如果是“每行 4 个 1”挂了问题在线的筛选逻辑如果是“两行内积为 1”挂了问题多半在点的去重或线的去重。定位技巧是把失败的那一对行或列打印出来看它们的交集大小是 0 还是大于 1。交集为 0 说明两点没有公共线通常是某个点被错误拆分交集大于 1 说明两点有两条公共线通常是某条线被重复计算。我遇到过一次“两列内积为 2”的情况查下来是两条线的坐标恰好成比例但没被去重原因是规范化时用了浮点除法而不是模逆元。换成整数模运算后问题消失。这个坑提醒我有限域运算全程用整数别碰浮点。5.3 可视化重叠与性能小贴士13 个节点的图性能完全不是问题但布局重叠很烦人。我的经验是节点位置手动指定比自动布局可靠尤其是二部图两列固定 x 坐标、y 坐标均匀分布出来的图最规整。如果非要自动布局把k参数调大一点比如k2能让节点散开些。还有一个细节画边的时候把alpha调到 0.5 左右重叠的边能看出层次不会糊成一片黑。节点边框加个白色描边节点之间挨得近时也能分清边界。这些小调整不改变信息但显著提升可读性。问题现象可能原因排查动作点数为 26未做等价类去重检查 normalize 是否被调用点数为 12 或 14规范化边界错误打印所有代表元人工核对某线点数不为 4方程计算漏取模检查 incident 函数返回值两行内积不为 1点被拆分或合并打印该对点的坐标对比两列内积不为 1线重复或遗漏检查线的规范化逻辑图中边糊成一团alpha 过高调低 alpha 至 0.5 左右6. 结构扩展与后续可玩方向这套代码骨架不只适用于 q3。把MOD改成 5点数变成 31线数也是 31每线 6 个点代码几乎不用改就能跑只是可视化会密一些。改成 7 就是 57 点规模上去了但构造逻辑完全一致。这个“改一个常数就扩展”的特性是我当初把 GF 运算封装成函数而不是硬编码的主要原因。另一个方向是拿关联矩阵做组合设计的研究比如找它的自同构群或者验证它作为差集的性质。13 点 13 线的结构在组合数学里对应一个 (13,4,1) 差集这个联系挺有意思值得单独写一篇。可视化方面可以尝试把 13 条线画成 13 条不同颜色的曲线用动画展示“任意两线必交于一点”的过程做教学演示会很直观。我个人在实际操作中的体会是这类有限结构的构造代码写对只是第一步真正的理解来自于把断言跑通、把图看明白、把参数改一改看结构怎么变。13 这个数字看着小但里面藏的对称性和组合规律够琢磨好一阵子。