☰
Python实现三阶射影平面:13点13线组合设计与可视化
2026/11/6 2:55:31 网站建设 项目流程

射影平面这个词听起来像是纯数学教材里才会出现的东西,但真动手把它画出来、跑起来,你会发现它是一套极其精巧的组合结构。我最初接触 PG(2,3) 是因为一个组合设计的小项目,需要在 13 个元素上构造一个对称的区组设计,翻了一圈资料发现射影平面恰好就是最经典的例子。标题里说的"13点13线",指的就是三阶射影平面——每个点落在 4 条线上,每条线穿过 4 个点,总共 13 个点和 13 条线,任意两点恰好共线一次,任意两条线恰好交于一点。这套结构在编码理论、实验设计、纠错码构造里都有实际用途,而用 Python 把它可视化出来,是理解有限域上射影几何最直接的方式。

这篇内容适合两类人:一类是学过抽象代数或有限域、想把纸面上的定义变成能跑能看的代码的人;另一类是做组合设计、需要快速验证某个关联结构是否满足射影平面公理的人。我会从有限域的构造讲起,把齐次坐标怎么表示点、线怎么由向量叉积生成、关联关系怎么验证,一步步拆开,最后给出完整的可视化方案和几个我实际踩过的坑。全程用 Python,不依赖冷门库,numpy 和 matplotlib 足够。

1. 为什么三阶射影平面值得单独拿出来做

1.1 射影平面的公理与阶的关系

射影平面有一套非常干净的公理体系:任意两点确定唯一一条线,任意两条线确定唯一一个交点,存在四个点使得其中任意三点不共线。从这几条公理出发可以推出,一个有限射影平面必然存在一个整数 n,使得总点数为 n²+n+1,每条线上的点数为 n+1,每个点落在 n+1 条线上。这个 n 就叫射影平面的阶。

三阶射影平面对应 n=3,总点数 3²+3+1=13,每条线 4 个点,每个点落在 4 条线上。为什么偏偏选 n=3 来做可视化?n=2 只有 7 个点,结构太简单,画出来看不出层次;n=4 有 21 个点,虽然也能画但关联关系开始密集,图会显得乱;n=3 的 13 个点刚好在信息量和可读性之间取得平衡,而且 3 是素数,有限域 GF(3) 的运算最直观——只有 0、1、2 三个元素,加减乘除都能心算验证。

这里有个容易被忽略的点:射影平面的阶必须是素数幂。n=6 不存在射影平面,这是著名的 Bruck-Ryser 定理的结论。所以如果你想构造更高阶的例子,只能选 2、3、4、5、7、8、9 这些素数幂,6、10、12 这类都不行。我当初想当然地试过 n=6,代码跑出来关联验证直接失败,查了资料才知道根本不存在,白白浪费半天。

1.2 有限域 GF(3) 的运算规则

构造 PG(2,3) 的底层是三元有限域 GF(3),它的元素是 {0, 1, 2},所有运算对 3 取模。加法表很直观:1+2=0(因为 3 mod 3 = 0),2+2=1(4 mod 3 = 1)。乘法也类似:2×2=1(4 mod 3 = 1),所以 2 的乘法逆元就是它自己。

这个"2 是自逆元"的性质在后续计算里会反复用到。比如求一条线的法向量、做坐标归一化时,除以 2 等价于乘以 2,不需要单独处理逆元表。相比之下 GF(4) 或 GF(5) 就得老老实实建逆元查找表。我在代码里对 GF(3) 做了特化,把逆元直接写成inv = lambda a: a if a != 0 else None,因为 1 的逆是 1,2 的逆是 2,省掉一次查表。

有限域的构造本身也有讲究。GF(p) 对素数 p 就是整数模 p,但 GF(p^k)(k>1)需要多项式环模一个不可约多项式。三阶射影平面只用 GF(3),所以这部分可以简化。但如果你的项目要扩展到 n=4(GF(4)=GF(2²))或 n=8(GF(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³-1=26 个,按标量倍数分组,每组 2 个(因为 GF(3) 的非零元素只有 1 和 2),所以点数 = 26/2 = 13。这个除法正好对上 n²+n+1=13,是验证坐标枚举是否正确的一个快速检查点。

线的表示同样用三维向量 (a, b, c),点 (x, y, z) 落在线上当且仅当 ax+by+cz ≡ 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), repeat=3): 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(i+1, len(points)): l = normalize(cross(points[i], points[j])) lines.add(l) lines = sorted(lines) print(len(lines)) # 13

13 个点两两组合有 C(13,2)=78 对,但生成的线只有 13 条,因为每条线包含 4 个点,C(4,2)=6 对点确定同一条线,78/6=13,数字完全吻合。这个除法关系是验证实现正确性的第二个检查点。如果跑出来不是 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), dtype=int) 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(axis=1) == 4) assert all(incidence.sum(axis=0) == 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) 做透视投影,z=0 的点(无穷远点)单独处理,结果无穷远点挤在一起,线也扭曲得认不出来。第二种是用三维散点图,把 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(f"P{i}", bipartite=0) for j in range(13): G.add_node(f"L{j}", bipartite=1) for i in range(13): for j in range(13): if incidence[i][j]: G.add_edge(f"P{i}", f"L{j}") pos = {} for i in range(13): pos[f"P{i}"] = (0, -i) for j in range(13): pos[f"L{j}"] = (1, -j) nx.draw(G, pos, with_labels=True, node_color=['lightblue']*13 + ['lightgreen']*13, node_size=400, font_size=8) plt.show()

这个图有 26 个节点、52 条边(13×4=52),左右对称,每个节点度数都是 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), repeat=3),生成的向量顺序是 (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 这个规模偏小,实际编码会用更大的射影平面(比如 n=8 对应 73 个点),但三阶版本是理解构造逻辑的最佳起点。

具体做法是把关联矩阵当作校验矩阵 H,码长等于列数 13,校验方程数等于行数 13,码率接近 0(因为方阵满秩的话没有信息位),实际用的时候会删掉部分行或做扩展。这里的重点是理解"点线关联"如何转化为"变量节点-校验节点"的连接,PG(2,3) 的小规模让整个结构可以手工验证,是学习编码构造的绝佳教具。

5.3 可视化代码的复用与扩展

本文的点线枚举和关联矩阵代码,改一个参数就能扩展到其他素数阶。把P = 3改成P = 2得到 7 点 7 线的 Fano 平面,改成P = 5得到 31 点 31 线的五阶平面。唯一需要注意的是高阶时点数增长很快(n²+n+1),n=5 是 31,n=7 是 57,n=8 是 73,可视化的二分图会变得很密,这时候更适合用关联矩阵的热力图而不是节点图。

热力图的好处是规模无关,13×13 和 73×73 看起来是一样的模式,只是分辨率不同。用plt.imshow(incidence, cmap='Blues')一行就能画,非零位置一目了然。我在处理较大规模时基本都用热力图,节点图只在 n≤3 时用。

扩展时还要注意 GF(p^k) 的情况。n=4 对应 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_size=8,节点大小 400 左右,画布尺寸调到 10×10 英寸,这样标签能看清又不挤。如果还嫌乱,可以只标点不标线,线的身份靠颜色区分。

最后一点:所有断言和自检代码不要在上线版本里删掉,用if __name__ == "__main__"包起来或者放到单独的测试函数里。射影平面的构造一旦参数化,很容易在扩展时引入回归 bug,保留自检能在第一时间发现问题。我自己就吃过删掉断言后改代码、结果关联矩阵悄悄出错、直到可视化阶段才发现图不对劲的亏,回头补自检花的时间比当初保留多得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询