把“更相减损术”这种老算法放进范畴论,再拉上圆上点的定向映射一起建模,三个听着互不相干的数学对象,硬是在“云藏山鹰代数信息系统”里凑成了一台戏。这个项目的产出不是新定理,也不是什么高性能算法,而是一套可复现的建模思路加验证流程。它解决的问题很具体:如何让你在调试一个朴素算法的过程中,同时看见几何轨迹、结构约束和终止性证据。
如果你正在做算法可视化、形式化建模,或者只是对“怎么把一个经典算法玩出新花样”这件事感兴趣,这篇文章值得花几分钟读完。我会把从设计拆解到代码落地、再到排查教训的全过程都说清楚,所有步骤都按我在云藏山鹰里实际跑通的方式写,可以直接照着抄。
1. 项目整体设计与思路拆解
1.1 核心需求解析
更相减损术的算法过程并不复杂:两个正整数,谁大就从谁里面减掉小的,反复执行直到两个数相等,最后得到的相等数就是最大公约数。算法好写,但它有一个明显的问题——它的“结构性格局”很少被认真对待。常规讲法是在数轴上画几个箭头,告诉你“减着减着就到终点了”,至于为什么一定到、方向为什么不会绕晕,解释得往往很含糊。
云藏山鹰这个系统想补上的正是这部分。整个项目有三个明确诉求:
- 每一步状态转移必须有明确方向,不能用“任意选一个方向”这种模糊描述;
- 状态与状态之间的关系要能构成一种代数结构,而不只是一张流程图;
- 这种代数结构要能和一个直观的几何对象建立映射,方便人眼观察。
这三个诉求分别对应了项目标题里的三个词:圆上点的定向映射、更相减损术、范畴论建模。换句话说,题目不是把三个关键词生硬拼在一起,而是先定需求,再选工具,最后落地。
1.2 三个关键词是怎么凑到一起的
先说算法本身。更相减损术的状态是一个二元组 (a,b),每次操作要么把 a 变成 a-b,要么把 b 变成 b-a。这个过程天然有一个单调的方向性:如果把“较大者”看成当前状态的指针,那么指针每次都指向被减的那一项,直到两个数相等时指针失去意义。
但二元组不好直接画。人类对二元组没有空间直觉,对圆有。所以系统第一步要做投影,把 (a,b) 变成一个圆上的点。这里选了一个非常自然的比例 a/(a+b),它的取值范围是 0 到 1,把 0 和 1 两头粘起来就是一个圆周。这样一来,终止条件 a=b 就对应比例 0.5,也就是圆的“西点”,一个所有路径最终聚集的标记点。
再说范畴论。范畴论擅长处理“对象+箭头”的结构。更相减损术的每一步都是一个箭头,从当前状态指向下一步状态;整条执行路径就是首尾相接的箭头链。这种结构放进范畴论的框架里几乎是量身定做的:对象是什么、态射是什么、组合是什么、终对象是什么,全部对得上号。所以范畴论不是硬套,它只是把算法里本来就存在的结构用更抽象的语言讲清楚了。
最终,系统把三部分串成一条链路:更相减损术负责产生状态序列,圆上点的定向映射负责把状态序列变成几何轨迹,范畴论负责统一描述状态序列的代数性质。三者各司其职,缺一个都会导致整个模型不完整。
2. 圆上点的定向映射:几何直觉与算法结构
2.1 为什么把状态放到圆上
常见的做法是把状态放在数轴上,但这有个毛病:数轴是无限延伸的,而更相减损术的状态比例 a/(a+b) 始终落在开区间 (0,1) 里。有限区间两端如果能“粘”起来,就成了圆。圆的好处是闭的、紧的、自带方向感。
圆上的每个点对应一个角度。把比例映射到角度的方式很简单:
θ = 2π × a / (a+b)
当 a 和 b 相等时,θ 正好等于 π。也就是说,更相减损术的任何一条状态路径,最终都落在同一个角度上。这在几何上给了算法一个非常漂亮的“终点标记”。
而且,圆有天然的定向结构。如果我们约好逆时针为正方向,那么圆上任何连续运动都可以被界定为“正向走”或“反向走”。这正是“定向映射”这个说法的来源。
2.2 定向映射的核心性质
定向映射严格说是指圆到自身的、保持逆时针方向的连续映射。最典型的例子是旋转映射:
R_α(θ) = θ + α (mod 2π)
它把每个点沿逆时针方向整体转一个固定角度 α。所有这类映射有一个共同特征:不会把正向序列反转成负向序列。
更相减损术的单步操作不是严格意义上的一个固定旋转,而是一个分段映射。用比例 r = a/(a+b) 表示状态的话:
- 当 a > b 时,r > 0.5,新比例是 (a-b)/a,对应角度减去一个与 b 相关的量;
- 当 a < b 时,r < 0.5,新比例是 a/(b-a),对应角度朝另一侧折返。
如果只看原始角度序列,轨迹会在某个区间来回“弹跳”,这跟“定向”两个字表面上有矛盾。但这里有一个关键区分:我们说的定向映射不是指角度序列单调,而是指在圆上画轨迹时,每一步的弧线段都保持同一旋转方向。做法是把每一步的局部弧线按“从当前角度到下一步角度”的最短有向弧来画,如果路径需要跨过 0 点,就按缠绕数累积。这样整个路径就是一条单向推进的有向曲线,不会出现回头折线。
这个设计不是为好看,而是为了保留算法的一个性质:状态转移是确定的、不可逆的。圆上的有向弧天然具有不可逆性,一个带箭头的弧段不能倒着走回去,恰好对应更相减损术永远不会把状态还原到之前某个值。
2.3 终止点与不动点的关系
在圆上,更相减损术的终止点 θ=π 并不是传统意义上的“不动点”——你没法定义一个通用的映射把任意点都一步送到 π。但它是所有状态路径的极限终点。
这里扯出范畴论的一个概念:终对象。在单个输入对应的可达状态集里,终止点所对应的状态 (gcd, gcd) 是唯一的“终点”,并且从任何可达状态出发都能到达它。圆上的几何表现,就是所有有向弧最终都汇入同一点。
我实际画图时发现,只要输入的两个数较小,比如 (30, 12),轨迹会明显地绕着圆逆时针走一大段后落在 π 附近。输入变大后轨迹密得像毛线团,但放大局部看,每一条有向弧的方向仍然不乱。这个视觉特征后来成了系统验证“定向性”是否正确的快速判据。
3. 更相减损术的算法拆解:每一步都可以被看到
3.1 标准流程与伪代码
更相减损术的实现很简单,核心逻辑如下:
def gcd_by_subtraction(a, b): while a != b: if a > b: a = a - b else: b = b - a return a函数结束后返回的 a 就是最大公约数。以 (30, 12) 为例,过程是:
30 - 12 = 18 18 - 12 = 6 12 - 6 = 6
共三步,最后结果为 6。注意到第三轮开始时 12 > 6,所以实际执行的是 b = b - a,也就是从较大的 b 中减去较小的 a。
3.2 选择逐减而不是取模的原因
很多讲算法的文章会特别提醒:更相减损术的性能不如辗转相除法,因为当两个数相差悬殊时,比如 (1, 1000),它要连续减 999 次才能结束。而辗转相除法一次取模就能砍掉一大截。这个批评是对的,但放到云藏山鹰项目里,逐减法反而成了特性。
项目的目的不是算得快,而是要观察每一步转移。取模操作会把很多连续的“小步”一次性合并掉,本质上丢失了中间状态,也就丢失了圆上轨迹的细节。逐减保留了完整的中间状态序列,每一步都对应圆上的一小段有向弧。对于一个可视化建模系统来说,细节完整比计算速度重要得多。
当然,系统里也内置了一个“聚合显示”选项:当步数超过设定阈值时,自动把连续同向的减法合并成一组,用一段短圆弧表示。这是显示层面的优化,不影响底层算法和范畴结构。
3.3 状态空间与终止性
更相减损术每一步都会让 a+b 的总和变小。如果 a > b,那么新的总和是 (a-b)+b = a,显然小于原来的 a+b;另一边同理。这是一个严格递减的度量,因此算法必然在有限步内终止。这不是实验结论,而是结构保证。
在范畴论的框架里,这个递减度量对应了一种“规范化”的感觉:每次沿箭头前进一步,某种势能就下降一次。正因为有这种结构性约束,我们才可以放心地说从任意初始状态出发都存在一条有限长的态射链到达终对象。否则,如果存在环或无限路径,终对象的概念就不可用。
4. 范畴论建模:把算法翻译成结构
4.1 从过程到对象和态射
范畴论建模的第一步,是把算法的所有元素重新翻译成两个集合:对象和态射。
在这个模型里:
- 对象:单个输入 (a,b) 在算法执行过程中经过的所有状态,包含初始状态和最终状态。例如 (30, 12), (18, 12), (6, 12), (6, 6) 就是四个对象;
- 态射:一次更相减损操作。从 (30,12) 到 (18,12) 有一个态射,从 (18,12) 到 (6,12) 有一个态射,等等;
- 态射的组合:连续执行两步操作。例如先做一次 a 方向减损,再做一次 b 方向减损,这条两步路径就是一个组合态射。
如果把这套定义写得更正式一些:固定一个初始输入,取它所有可达状态作为对象集合,取所有单步减损操作作为生成态射,再把这些生成态射的所有有限序列作为态射集合,就得到一个自由范畴。这个范畴的恒等态射是长度为 0 的空序列,结合律来自序列拼接的自然性质。
4.2 为什么减损过程天然构成范畴
很多读者会疑惑:是不是任何有向图都能叫范畴?答案是几乎可以,只要把路径当作态射。有向图的路径天然满足范畴的两条公理:有恒等路径(长度为0),路径拼接满足结合律(先拼后拼结果一样)。所以从有向图构造自由范畴是范畴论里的标准操作。
但更相减损术在这里有一个额外的好性质:形态射链是“唯一路径”。因为算法每一步都是确定的——谁大谁被减,不存在分支。这意味着从初始对象到终止对象之间,有且仅有一条完整的态射链。这在一般的有向图里非常罕见,是算法的确定性带来的。
唯一路径这件事在范畴论中是一个强约束,它直接对应了算法的“结果唯一性”和“过程无歧义”。
4.3 终对象揭示了算法的宿命
把问题限制在固定初始输入的可达状态范畴里时,终止状态 (gcd, gcd) 具有一个特殊地位:从范畴内任意对象出发,都存在一条态射链到达它,并且这条链是唯一的(因为每一步都确定,没有其他分支可选)。这正是范畴论中“终对象”的核心定义。
所以,更相减损术的终止性本质上可以用范畴语言重述:每个可达子范畴都有唯一终对象,这个终对象就是最大公约数所在的状态。
这里有个特别容易踩的坑:如果把“所有正整数对”直接当成范畴对象,终对象就不唯一了——(1,1) 是终对象,(2,2) 也是终对象。很多初学者在这里绕不过去。正确做法是强调“给定初始对象后形成子范畴”,在子范畴的框架讨论终对象才是合法的。
4.4 函子与圆上的轨迹
范畴论建模的第二大块,是建立“算法状态范畴”到“圆上几何范畴”的结构保持映射,也就是函子。
定义一个圆点范畴 D:
- 对象:单位圆上的点,也就是角度 θ 的取值范围 [0, 2π);
- 态射:保定向的旋转映射 R_α,即把点 θ 沿逆时针旋转角度 α;
- 态射组合:旋转角度的叠加,R_α ∘ R_β = R_(α+β mod 2π)。
现在定义函子 F 从状态可达范畴 C 到 D:
- F 把状态 (a,b) 送到角度 θ = 2π × a / (a+b);
- F 把一步减损操作送到一个保定向旋转,旋转角是当前角度到下一步角度的有向弧长。
需要检查函子的组合保持性。假设状态链为 s1 → s2 → s3,在 C 中两步态射的组合是一条从 s1 到 s3 的双步路径。在 D 中,F 把两步分别映射为两步旋转,两步旋转的组合也是从 F(s1) 到 F(s3) 的正向弧线。只要我们都采用“累计有向角度差”来定义弧线,组合必然保持。
实际操作中,为了让轨迹不出现视觉上的“回头”,我额外引入了一个缠绕数记录。每走一步就把角度差累积起来,允许总转角超过 2π。这个缠绕数相当于在圆上多绕了几圈,但每一圈的方向始终是逆时针。这种处理方式让函子的几何表现更加干净。
4.5 自然变换带来的不变量观察
建模过程中,最让我惊喜的是自然变换提供了一个观察不变量的角度。定义两个函子其实是很自然的:F 是前面说的“状态到圆上角度”的函子,另一个函子 G 可以把状态映射为“当前最大公约数”——无视中间状态,只取最终值。
如果把每个状态 (a,b) 都关联到它的最大公约数 g,那么在每一步减损前后,这个 g 始终保持不变。可以说,G 是一个“常值函子”。F 的每一步路径在圆上移动,G 的每一步都按在同一个点上。两个函子之间的这种“每一步箭头都对应同一数值不变量”的关系,在直觉上非常接近自然变换的交换性描述。
当然,我在系统里并没有把这条定义写成严格的形式化证明,而是把它做成一个可视化过滤器:在圆上画轨迹时,同时显示当前数值是否等于初始最大公约数。只要轨迹存在,这个值就永远不变。这个“数值不动,点在动”的对比画面,比任何文字都更有说服力。
5. 在云藏山鹰系统里的落地实现
5.1 模块划分
整个系统我分成了四个模块:
- 算法核心模块:执行更相减损术,记录每一步前后的状态,输出状态序列和操作方向;
- 几何投影模块:把每个状态 (a,b) 映射为单位圆上的坐标,同时计算角度、弧长、缠绕数;
- 轨迹渲染模块:把状态序列渲染成连续的有向弧,支持单步播放、聚合显示、缩放平移;
- 形式化描述模块:把状态序列转换为对象、态射、组合路径的清单,供范畴结构分析和测试断言使用。
每个模块之间只通过普通的数据结构交互,不共享内部可变状态。这样做的原因是,后续如果要扩展成别的算法(比如辗转相除法、扩展欧几里得算法),只需要替换算法核心模块,后面三个模块可以原样复用。
5.2 投影映射的选择与坐标计算
几何投影模块是整个系统最容易出隐性问题的地方。投影公式固定为:
θ = 2π × a / (a+b)
这个选择的依据有两点。第一,比例始终落在 (0,1),不会溢出;第二,终止状态落在 θ = π 处,正好是圆的左端点,几何标记稳定。
坐标计算用单位圆参数方程:
x = cos(θ),y = sin(θ)
核心代码片段如下:
import math def project_state(a, b): theta = 2.0 * math.pi * a / (a + b) x = math.cos(theta) y = math.sin(theta) return theta, x, y def directed_arc(p_from, p_to): diff = (p_to.theta - p_from.theta) % (2.0 * math.pi) if diff == 0: return None # 强制逆时针为正方向 return diff这里最容易被忽略的一点是:用 (p_to - p_from) 对 2π 取模后,diff 必落在 [0, 2π),这本身就是“逆时针长度”。如果状态转移导致原始角度从 350 度跳到 10 度,普通画线会画一条横穿短弧的线,看着好像反向走了。取模后它变成 20 度的逆时针短弧,方向就正了。
5.3 轨迹渲染与定向弧段的组织
渲染层不直接画点,而是按“步”为单位画弧段。每次更相减损产生一对相邻状态,对应一段圆弧。系统把每段圆弧的首尾、曲率、箭头方向都记录下来,并标注一个步号。
为了让轨迹不糊成一团,我做了一个小优化:每个弧段的透明度随步数增加而递减,早期的轨迹淡,后期的轨迹浓。这样整个路径的“流动方向”一眼就能看清楚,从旧到新、从稀到密。
对于特别长的路径,提供聚合模式:连续同向的减法合并为一个“宏弧”,宏弧内置步数备注。这个功能不影响范畴结构,但在处理 (1, 500) 这类输入时,可以避免画出 499 段细碎弧线。
5.4 验证与对拍测试
形式化描述模块输出一份 JSON 结构,内容格式大致如下:
{ "initial": [30, 12], "gcd": 6, "objects": [[30,12], [18,12], [6,12], [6,6]], "morphisms": [ {"from": [30,12], "to": [18,12], "direction": "a_minus_b"}, {"from": [18,12], "to": [6,12], "direction": "a_minus_b"} ], "winding_numbers": [0, 1, 2, 3] }系统的验证脚本会做三件事。第一,把算法核心模块输出的最终结果和 Python 标准库里的内置 gcd 函数对拍,随机生成 1000 组输入,断言全部一致。第二,检查轨迹渲染层输出的每个弧段的方向标记,保证全部为正方向,如果出现负方向就报错。第三,检查形式化描述模块生成的对象序列里,每个态射的起点到终点是否与算法核心模块记录一致。
这三层验证过完一遍,基本可以确定建模没有走样。
6. 常见问题与排查技巧实录
6.1 为什么画出来的圆总在“抖”
这是我调试时遇到的第一个大问题。最初版本直接按原始角度序列画点,用线段把相邻点连起来。输入 (30, 12) 时看着还行,输入 (50, 30) 就发现轨迹在某些位置来回横跳,箭头方向一会儿顺时针一会儿逆时针,视觉效果非常拉胯。
根源在于原始角度序列不是单调的。更相减损术的比例 r = a/(a+b) 在来回横跳,映射到角度后自然也跟着跳。解决办法就是前面说的“取模角度差 + 缠绕数累积”:不计较当前角度到底是多少,只关心从上一个状态到这个状态有向弧走了多少。这相当于用“增量”画图,而不是用“绝对值”画图。
这个坑我印象很深。因为问题不在数学定义,而在于“角度取模”和“运动方向”在视觉上不是一回事。
6.2 范畴化时容易犯的结构性错误
有段时间我想让系统直接输出“这个算法形成了一个范畴”的断言,结果发现怎么也定义不干净。因为如果对象是所有正整数对,那么 (1,1) 和 (2,2) 都满足“没有出箭头”的条件,都可以称为终对象,终对象就不唯一了。
重新翻了定义后,才意识到必须固定初始输入,取可达状态子范畴。在子范畴里,终对象是唯一的。这个错误很隐蔽,因为画图的时候你不会意识到,一旦开始写自动断言,就会暴露。
所以给同样在做范畴建模的人一个建议:写断言之前,先把“对象集合”和“态射集合”用代码显式表达出来,而不是只在文档里写。代码会逼你把定义抠清楚。
6.3 浮点精度导致的角度异常
当 a+b 特别大的时候,a/(a+b) 接近 0 或接近 1,此时 cos 和 sin 的结果对浮点误差极其敏感,极偶尔会出现终止状态画不到 π 点上的情况,差一个像素让人难受。
实际上这是浮点计算的标准问题,不是原理问题。我的处理方式是:渲染层不用浮点坐标做相等判断,而用角度差阈值判断;在状态层面,直接以整数相等 a==b 作为终止条件,不依赖几何坐标。这样几何只是展示,逻辑永远由整数计算保证。
6.4 长路径造成的性能问题
更相减损术本身就是慢算法,输入稍微大一点,比如 (12345, 6789),步数就可能上千。每一帧都重算全部弧段的话,交互渲染会卡顿。
我的优化分两层。第一层是渲染缓存:状态序列和弧段数据只在输入改变时重新计算,拖动缩放时只复用缓存,不重算轨迹。第二层是聚合显示:连续同向减法合并成宏弧,宏弧内部不再逐帧绘制。实测下来,输入为 (100000, 1) 这种极端情况,聚合后的步骤数可以压到几百,渲染流畅度恢复正常。
7. 写在最后的一些体感
我在做这个项目的过程里,最大的收获不是画出了一堆漂亮的圆弧,而是理解了形式化建模的一个朴素道理:模型的价值不在名字多高级,而在于它能不能暴露原本看不见的东西。
更相减损术写了无数遍,以前只觉得它慢,做了云藏山鹰之后才意识到,它的每一步都在一个固定方向的总趋势里前进,最终回到一个结构上注定的终点。圆上轨迹把这个抽象性质变成了肉眼可见的事实,范畴论则把它变成可以写进断言的结构语言。
如果你也想试着复现这个项目,我的实际建议是:别一上来就写范畴论的正式定义,先用代码把状态序列和弧段数据做出来,让画面先跑通,再回过头去问自己“这些对象和态射到底怎么对应”。我自己的开发顺序是先有轨迹动画,后有 JSON 结构,最后才补上范畴论术语。反向做的话,很容易卡在抽象概念里出不来,画出来的图却什么都不是。
最后补一个小技巧:调试定向轨迹时,给每个弧段加一个临时数字标签,标明是第几步。盯着数字看方向,比盯着箭头容易发现问题。我第一次发现轨迹反转问题,就是靠第 13 步弧段的数字标签帮忙定位的。