射影平面这个词听起来像是纯数学教材里才会出现的东西但真动手把它画出来、跑起来你会发现它是一套极其精巧的组合结构。我最初接触 PG(2,3) 是因为一个组合设计的小项目需要在 13 个元素上构造一个对称的区组设计翻了一圈资料发现射影平面恰好就是最经典的例子。标题里说的13点13线指的就是三阶射影平面——每个点落在 4 条线上每条线穿过 4 个点总共 13 个点和 13 条线任意两点恰好共线一次任意两条线恰好交于一点。这套结构在编码理论、实验设计、纠错码构造里都有实际用途而用 Python 把它可视化出来是理解有限域上射影几何最直接的方式。这篇内容适合两类人一类是学过抽象代数或有限域、想把纸面上的定义变成能跑能看的代码的人另一类是做组合设计、需要快速验证某个关联结构是否满足射影平面公理的人。我会从有限域的构造讲起把齐次坐标怎么表示点、线怎么由向量叉积生成、关联关系怎么验证一步步拆开最后给出完整的可视化方案和几个我实际踩过的坑。全程用 Python不依赖冷门库numpy 和 matplotlib 足够。1. 为什么三阶射影平面值得单独拿出来做1.1 射影平面的公理与阶的关系射影平面有一套非常干净的公理体系任意两点确定唯一一条线任意两条线确定唯一一个交点存在四个点使得其中任意三点不共线。从这几条公理出发可以推出一个有限射影平面必然存在一个整数 n使得总点数为 n²n1每条线上的点数为 n1每个点落在 n1 条线上。这个 n 就叫射影平面的阶。三阶射影平面对应 n3总点数 3²3113每条线 4 个点每个点落在 4 条线上。为什么偏偏选 n3 来做可视化n2 只有 7 个点结构太简单画出来看不出层次n4 有 21 个点虽然也能画但关联关系开始密集图会显得乱n3 的 13 个点刚好在信息量和可读性之间取得平衡而且 3 是素数有限域 GF(3) 的运算最直观——只有 0、1、2 三个元素加减乘除都能心算验证。这里有个容易被忽略的点射影平面的阶必须是素数幂。n6 不存在射影平面这是著名的 Bruck-Ryser 定理的结论。所以如果你想构造更高阶的例子只能选 2、3、4、5、7、8、9 这些素数幂6、10、12 这类都不行。我当初想当然地试过 n6代码跑出来关联验证直接失败查了资料才知道根本不存在白白浪费半天。1.2 有限域 GF(3) 的运算规则构造 PG(2,3) 的底层是三元有限域 GF(3)它的元素是 {0, 1, 2}所有运算对 3 取模。加法表很直观120因为 3 mod 3 02214 mod 3 1。乘法也类似2×214 mod 3 1所以 2 的乘法逆元就是它自己。这个2 是自逆元的性质在后续计算里会反复用到。比如求一条线的法向量、做坐标归一化时除以 2 等价于乘以 2不需要单独处理逆元表。相比之下 GF(4) 或 GF(5) 就得老老实实建逆元查找表。我在代码里对 GF(3) 做了特化把逆元直接写成inv lambda a: a if a ! 0 else None因为 1 的逆是 12 的逆是 2省掉一次查表。有限域的构造本身也有讲究。GF(p) 对素数 p 就是整数模 p但 GF(p^k)k1需要多项式环模一个不可约多项式。三阶射影平面只用 GF(3)所以这部分可以简化。但如果你的项目要扩展到 n4GF(4)GF(2²)或 n8GF(8)GF(2³)就必须处理多项式表示这是另一个复杂度层级本文不展开但心里要有数。1.3 齐次坐标把点和线统一成三维向量射影平面和欧氏平面最大的区别在于无穷远点的处理。在 PG(2,3) 里每个点用三维齐次坐标 (x, y, z) 表示其中 x、y、z 取自 GF(3)且 (0,0,0) 不算点。两个坐标向量如果只差一个非零标量倍数就代表同一个点。比如 (1,0,0) 和 (2,0,0) 是同一个点因为 2 是 GF(3) 里的非零标量。这样算下来非零三维向量共有 3³-126 个按标量倍数分组每组 2 个因为 GF(3) 的非零元素只有 1 和 2所以点数 26/2 13。这个除法正好对上 n²n113是验证坐标枚举是否正确的一个快速检查点。线的表示同样用三维向量 (a, b, c)点 (x, y, z) 落在线上当且仅当 axbycz ≡ 0 (mod 3)。这个统一表示是射影几何最优雅的地方——点和线在代数上完全对称对偶原理在这里是字面意义上的成立把点和线的角色互换所有定理依然成立。我在实现时专门利用了这个对称性点和线共用同一套坐标生成逻辑只是语义不同。2. 用 Python 枚举 13 个点与 13 条线2.1 坐标枚举与归一化的实现细节枚举所有点的第一步是生成所有非零三维向量。用三重循环遍历 0 到 2 的每个组合跳过全零向量得到 26 个候选。然后做归一化对每个向量找到第一个非零分量用它的逆元去乘整个向量使得第一个非零分量变成 1。这样每个等价类恰好保留一个代表元。from itertools import product P 3 def normalize(v): for x in v: if x % P ! 0: inv pow(x, -1, P) # Python 3.8 支持模逆 return tuple((c * inv) % P for c in v) return None # 全零向量 points set() for v in product(range(P), repeat3): n normalize(v) if n is not None: points.add(n) points sorted(points) print(len(points)) # 13这段代码跑出来正好 13 个点排序后方便后续索引。归一化的意义在于去重——如果不归一化26 个向量里每个点会出现两次后续建关联矩阵时就会重复计数。我一开始图省事没做归一化结果关联矩阵的行数变成 26验证每条线 4 个点时怎么都对不上排查了好一阵才发现是等价类没合并。归一化还有个副作用值得注意它让点的表示变得规范但也意味着你不能直接用原始坐标做线性组合必须先还原到等价类再运算。比如两个归一化后的点相加结果可能不在归一化形式里需要重新归一化。这个细节在写叉积求线的时候特别关键。2.2 用叉积生成对偶线给定两个不同的点 p1 和 p2它们确定的线就是叉积 p1 × p2在 GF(3) 上做模运算。叉积公式和平常一样线的三个分量分别是两个点坐标的交叉相乘再相减最后对 3 取模。得到的线向量同样要归一化保证和点的表示规范一致。def cross(a, b): return ( (a[1]*b[2] - a[2]*b[1]) % P, (a[2]*b[0] - a[0]*b[2]) % P, (a[0]*b[1] - a[1]*b[0]) % P, ) lines set() for i in range(len(points)): for j in range(i1, len(points)): l normalize(cross(points[i], points[j])) lines.add(l) lines sorted(lines) print(len(lines)) # 1313 个点两两组合有 C(13,2)78 对但生成的线只有 13 条因为每条线包含 4 个点C(4,2)6 对点确定同一条线78/613数字完全吻合。这个除法关系是验证实现正确性的第二个检查点。如果跑出来不是 13 条线要么是叉积算错了要么是归一化漏了。这里有个 GF(3) 特有的坑叉积结果可能全为零。当两个点线性相关时在射影平面上这意味着它们是同一个点叉积为零向量。由于我们只对不同的点做叉积理论上不会出现零向量但如果归一化逻辑有 bug 导致两个不同的点其实等价就会冒出零向量。我在代码里加了个断言assert l is not None一旦触发就能立刻定位问题。2.3 关联矩阵的构建与自检有了 13 个点和 13 条线就可以建关联矩阵了。矩阵是 13×13 的第 i 行第 j 列为 1 表示第 i 个点落在第 j 条线上否则为 0。判断依据就是点线坐标的点积模 3 是否为零。import numpy as np incidence np.zeros((13, 13), dtypeint) for i, pt in enumerate(points): for j, ln in enumerate(lines): if sum(pt[k] * ln[k] for k in range(3)) % P 0: incidence[i, j] 1 # 自检每行每列和都应为 4 assert all(incidence.sum(axis1) 4) assert all(incidence.sum(axis0) 4)这个关联矩阵是射影平面的核心数据结构。行和列的和都是 4对应每个点在 4 条线上、每条线有 4 个点。更进一步任意两行的内积对应两个点共线的次数应该恰好为 1任意两列的内积也是 1。这两个性质是射影平面公理的矩阵化表达我通常把它们作为最终的正确性验证。# 任意两点恰好共线一次 gram incidence incidence.T assert all(gram[i][j] 1 for i in range(13) for j in range(13) if i ! j)这个gram矩阵的对角线是 4每个点在自己所在的 4 条线上非对角线全是 1。看到这个漂亮的矩阵结构基本可以确信实现是对的。我建议每个做这类构造的人都把这一步当成必做的自检比肉眼看图靠谱得多。3. 把 13 点 13 线画成能看懂的结构图3.1 为什么不能直接按坐标画最直觉的画法是把每个点的三维坐标投影到二维平面上画出来但这样会立刻遇到问题射影平面里的线在欧氏平面上不是直线。比如无穷远线上的 4 个点在普通平面里根本没有有限位置。强行投影会导致大量线交叉、点重叠图完全没法看。我试过几种方案。第一种是按 (x/z, y/z) 做透视投影z0 的点无穷远点单独处理结果无穷远点挤在一起线也扭曲得认不出来。第二种是用三维散点图把 13 个点放在三维空间里但 13 个点在一个立方体里分布遮挡严重关联关系还是看不清。真正有效的方案是放弃几何位置的执念改用组合布局。射影平面的价值在于关联结构不在于点的实际坐标。所以可视化的目标是让哪些点在同一条线上一目了然而不是还原几何形状。3.2 基于关联矩阵的二分图布局最清晰的表示是二分图左边一列 13 个点节点右边一列 13 条线节点如果点 i 在线上 j 上就画一条边。这样每个点节点度数为 4每个线节点度数也是 4图的结构完全对称。import matplotlib.pyplot as plt import networkx as nx 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 i in range(13): for j in range(13): if incidence[i][j]: 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_color[lightblue]*13 [lightgreen]*13, node_size400, font_size8) plt.show()这个图有 26 个节点、52 条边13×452左右对称每个节点度数都是 4。缺点是边太多视觉上有点密但结构关系是准确的。如果想突出某条线可以把对应的 4 条边高亮这样能清楚看到一条线穿过哪 4 个点。3.3 用颜色编码展示线的划分另一种更紧凑的方式是给 13 条线各分配一种颜色然后画 13 个点每个点用它所落的 4 条线的颜色做分段圆环。这样一张图就能同时展示所有关联关系不需要画边。具体做法是每个点画成一个圆圆环分成 4 段每段颜色对应一条经过它的线。13 个点排成圆形或网格颜色图例放在旁边。这种表示的信息密度很高但需要读者理解颜色编码规则。我在实际项目里更倾向二分图因为直观不需要额外解释。还有一种折中方案把 13 个点按某种规则排成圆环对每条线把它经过的 4 个点用同色弧线连起来。13 条线就是 13 组弧线用不同颜色区分。这种弦图在展示循环结构时特别有效因为 PG(2,3) 的关联结构本身有一定的循环对称性。不过弦图在点较多时弧线会重叠13 个点还算可控再多就不行了。4. 验证与调试那些数字对不上的时刻4.1 点数不是 13 的三种可能原因第一次跑枚举代码时我得到的点数是 14 而不是 13。排查下来是归一化逻辑写错了我用pow(x, -1, P)求逆元但当第一个非零分量是 2 时2 的逆元在模 3 下是 2乘回去得到 4 mod 3 1逻辑没错。问题出在遍历顺序上——我用的是product(range(P), repeat3)生成的向量顺序是 (0,0,1)、(0,0,2)、(0,1,0)……归一化后 (0,0,1) 和 (0,0,2) 都变成 (0,0,1)正确去重。但我在某次重构时把set换成了list加手动去重手动去重的条件写成了第一个分量相同漏掉了第一个分量为零的情况导致 (0,1,0) 和 (0,2,0) 没被合并。这个坑的教训是归一化后的去重必须基于完整的三元组不能只看部分分量。用set是最省心的Python 的元组哈希天然处理这个。如果非要用列表去重前先排序再相邻比较或者直接用dict.fromkeys保序去重。另外两种导致点数不对的原因一是忘了跳过全零向量会多出一个点二是模运算写成了普通减法负数没处理。Python 的%对负数返回非负结果这点比 C 系语言友好但如果你从别的语言移植代码要特别注意-1 % 3在 Python 里是 2 而不是 -1。4.2 线数偏多时的叉积排查线数跑出 14 条或更多通常是叉积的模运算漏了某一项。叉积三个分量每个都要% P少写一个就会产生不在 GF(3) 里的坐标归一化时行为异常。我建议把叉积单独封装成函数内部统一做模运算外部调用时不用操心。还有一种隐蔽情况两个点叉积得到零向量归一化返回None如果代码里没过滤None它会被当成一条线加进集合导致线数偏多且后续关联计算崩溃。防御性写法是在加入集合前显式检查l normalize(cross(points[i], points[j])) if l is not None: lines.add(l)虽然理论上不同点的叉积不会为零但代码健壮性不能依赖理论上。我在生产代码里一律加这个检查成本几乎为零省下的调试时间很可观。4.3 关联矩阵行和不为 4 的定位方法关联矩阵某行和不为 4说明某个点落在的线数不对。定位方法是先找出哪一行异常然后打印该点的坐标再逐条检查它和 13 条线的点积。正常情况下应该恰好 4 条线点积为零。如果多于 4 条可能是线集合里有重复归一化没做好如果少于 4 条可能是某条线的坐标算错了。我遇到过一次行和为 3 的情况查下来是某条线的第三个分量在叉积时算错——两个点的第三个坐标都是 0叉积的第三分量应该是 0但我的临时变量名写混了把第二分量的中间结果赋给了第三分量。这种错误靠肉眼看代码很难发现但关联矩阵的自检一跑就暴露了。所以自检断言不是形式主义是实打实的排错工具。列和不为 4 的排查思路对称找出异常列打印线坐标检查它和 13 个点的点积。由于点和线的表示规范一致很多 bug 是对称出现的修好点的问题线的问题往往跟着消失。5. 从 13 点结构延伸出去的实用场景5.1 对称区组设计与实验安排PG(2,3) 的 13 点 13 线结构本质上是一个 (13, 4, 1) 对称区组设计13 个处理、每个区组 4 个处理、任意两个处理恰好同区组一次。这个设计在实验设计里叫平衡不完全区组设计用来在资源受限时安排比较实验。比如有 13 种配方要两两比较但每次实验只能放 4 种用这个设计可以保证任意两种配方恰好同台比较一次总实验次数 13 次效率很高。我帮一个做农业试验的朋友算过类似的安排13 个品种、每块地种 4 个、要保证任意两品种至少同地块一次。直接用 PG(2,3) 的线作为地块点作为品种13 块地正好覆盖所有两两组合。这种拿来就用的场景是射影平面最实在的价值比纯数学欣赏有用得多。5.2 纠错码构造中的关联应用射影平面的关联矩阵可以拿来构造 LDPC 码或投影几何码。13×13 的关联矩阵行列重都是 4稀疏性不错作为校验矩阵的候选有天然优势。虽然 13 这个规模偏小实际编码会用更大的射影平面比如 n8 对应 73 个点但三阶版本是理解构造逻辑的最佳起点。具体做法是把关联矩阵当作校验矩阵 H码长等于列数 13校验方程数等于行数 13码率接近 0因为方阵满秩的话没有信息位实际用的时候会删掉部分行或做扩展。这里的重点是理解点线关联如何转化为变量节点-校验节点的连接PG(2,3) 的小规模让整个结构可以手工验证是学习编码构造的绝佳教具。5.3 可视化代码的复用与扩展本文的点线枚举和关联矩阵代码改一个参数就能扩展到其他素数阶。把P 3改成P 2得到 7 点 7 线的 Fano 平面改成P 5得到 31 点 31 线的五阶平面。唯一需要注意的是高阶时点数增长很快n²n1n5 是 31n7 是 57n8 是 73可视化的二分图会变得很密这时候更适合用关联矩阵的热力图而不是节点图。热力图的好处是规模无关13×13 和 73×73 看起来是一样的模式只是分辨率不同。用plt.imshow(incidence, cmapBlues)一行就能画非零位置一目了然。我在处理较大规模时基本都用热力图节点图只在 n≤3 时用。扩展时还要注意 GF(p^k) 的情况。n4 对应 GF(4)不是整数模 4模 4 不是域必须用 GF(2²) 的多项式表示。这是从素数阶跨到素数幂阶的关键门槛代码改动量不小建议先把素数阶的版本吃透再动手。6. 几个我反复踩到的实现细节坐标归一化时第一个非零分量的选择必须固定。我见过有人用最后一个非零分量做归一化基准结果和第一个非零分量的版本混用导致同一个点出现两种表示去重失效。团队协作时一定要在文档里写死这个约定或者干脆封装成函数所有人只调函数不手写归一化。模逆的计算在 GF(3) 里可以硬编码但为了代码通用性用pow(x, -1, P)更好。Python 3.8 之前不支持负数指数求模逆老版本要用扩展欧几里得算法自己写。如果你的运行环境版本较老记得加个兼容分支否则pow会抛异常。关联矩阵用 numpy 的 int 类型而不是 bool是因为后续可能要做矩阵乘法统计共线次数bool 矩阵相乘在 numpy 里行为有坑会做逻辑与而不是计数。用 int 类型运算符就是标准的矩阵乘法gram矩阵直接算出来省心。可视化时节点标签的字体大小要调小13 个点加 13 条线的标签在默认字号下会重叠。我一般设font_size8节点大小 400 左右画布尺寸调到 10×10 英寸这样标签能看清又不挤。如果还嫌乱可以只标点不标线线的身份靠颜色区分。最后一点所有断言和自检代码不要在上线版本里删掉用if __name__ __main__包起来或者放到单独的测试函数里。射影平面的构造一旦参数化很容易在扩展时引入回归 bug保留自检能在第一时间发现问题。我自己就吃过删掉断言后改代码、结果关联矩阵悄悄出错、直到可视化阶段才发现图不对劲的亏回头补自检花的时间比当初保留多得多。