半群、子群、商群这三个词摆在一起的时候,很多人第一反应是把它们当成三个并列的知识点去背定义,背完发现做题还是不会,更别提用在真实问题里了。我一开始也是这么干,直到后来在做一个分布式状态合并的小工具时被卡住——两个节点的状态合并结果依赖合并顺序,同样一批数据换个顺序跑出来的结果不一样,翻资料翻到最后,发现答案就藏在“半群是不是交换的、有没有单位元、商掉一个等价关系之后运算还良不良定义”这几个问题上。从那以后我就换了个学法:不把它们当成三个孤立名词,而是当成一条流水线——先有一个带运算的集合,检查它够不够格(半群),再往里挑出结构完整的子系统(子群),最后把这些子系统当成新的“点”重新组装成一个更小的结构(商群)。这条线走通了,群论里一大半的构造你都能自己推出来,而不是靠记忆。这篇文章写给三类人:正在啃抽象代数、定义看得懂但例子举不出来的学生;写代码时被类型类、合并函数、模运算折磨过、想搞清底层规律的工程师;以及需要通过“有限群的程序验证”来做形式化建模的人。下面所有内容都围绕这条流水线展开,代码可以直接跑,例子可以用手算复现。
1. 先把半群、子群、商群当成一条流水线来看
1.1 三个概念各自回答一个什么样的问题
我习惯给这三个概念各配一个提问。半群回答的是“什么样的运算才算靠谱”;子群回答的是“在一个靠谱的体系里,哪些子集自己就能独立成体系”;商群回答的是“如果把某些元素当成同一个东西糊在一起,剩下的结构还成不成立”。这三个问题的顺序不能乱,因为后一个问题的成立与否,完全依赖前一个问题的答案。
举个具体场景你就明白了。假设你有一批任务要并行归约求总和,每个任务算一部分,最后合并。你希望“先合并前一半、再合并后一半”和“按任意顺序逐个合并”得到完全一样的结果。这个诉求翻译成数学语言就是:合并运算必须是结合的,也就是半群;如果还要求存在一个“什么都不做”的元素,使得任何东西跟它合并都保持原样,那就是幺半群。你根本没提逆元的事,因为求总和这个场景不需要撤销操作。但如果你做的是类似“撤销上一次编辑”的功能,那你就需要逆元,这时候才升级成群。
我在实际项目里见过太多人一上来就说“我要用一个群”,其实他要的只是个幺半群。选了群,就得额外交付逆元存在性的证明成本,还要处理“某些元素不可逆”的尴尬。搞清楚这条流水线,第一个收益就是知道自己在哪一层停手。
1.2 为什么我坚持从“有限运算表”入手建模
抽象代数的教材通常从公理出发,先给定义再给例子。这个顺序对证明友好,对理解不友好。我更推荐反过来:拿一个元素个数不多的有限集合,把运算表整张画出来,然后拿公理一条一条去卡。卡住了就是反例,卡通了就是例子,比看十遍定义管用。
元素个数选在 3 到 8 之间最舒服。3 个元素有 9 个格子,8 个元素有 64 个格子,手画一张表最多半小时,但这半小时里你会把封闭性、结合律、单位元、逆元全部过一遍,而且会亲眼看到“为什么结合律不能省”。后面所有关于子群和商群的操作,本质上都是在这张表上做行和列的重新排列与合并,表在你脑子里越具体,抽象概念就越不容易飘。
我后面给的 Python 脚本,核心思路也是这个:把运算做成一个可调用的函数,然后用暴力枚举把公理逐条验一遍。有限群的优势在于,任何命题都可以用穷举来验证,你不需要“想到一个巧妙证明”,跑一遍就知道对错,这对建立直觉极其有效。
2. 半群:封闭与结合是整个体系的地基
2.1 半群的最小门槛只有两条
一个非空集合 S,配上一个二元运算 ·,如果满足两条,就叫半群:第一,封闭性,任意两个元素运算的结果还在 S 里;第二,结合律,对任意三个元素都有 (a·b)·c = a·(b·c)。
就这两条,没有单位元,没有逆元,也没有交换律。很多人觉得这门槛低得离谱,什么都算半群,没意思。恰恰相反,门槛低意味着覆盖面广,而覆盖面广的东西往往是最难满足的——你以为随手抓一个运算就是半群,实际上一抓一个准地违反结合律。
几个真半群:正整数在加法下,任意两个正整数相加还是正整数,结合律显然成立,这是个半群,但它没有单位元,因为 0 不在正整数集合里。字符串在拼接下,任意两个字符串拼起来还是字符串,拼接满足结合律,单位元是空串,所以这是个幺半群。方阵在乘法下,同阶方阵相乘还是同阶方阵,矩阵乘法满足结合律,单位元是单位阵,这也是幺半群,但只有可逆矩阵才构成群。集合在对称差下,两个集合的对称差还是集合,对称差满足结合律,单位元是空集,而且每个集合的逆元就是它自己,所以它是群,而且是个交换群。
注意最后一句话的推论:集合的对称差运算里,每个元素都是自己的逆元。这个性质在工程里很有用——如果你把一个状态用集合表示,合并操作定义为对称差,那么合并两次相同的内容就等于没合并,天然具备“撤销”能力,不需要额外记录历史。这就是“群比半群多了什么”的直观体现。
2.2 结合律破掉的时候,事情会变得非常难看
我特别推荐用浮点数加法当反面教材,因为它离工程最近,而且杀伤力最大。
在标准的浮点表示下,加法是封闭的,但结合律不成立。你可以直接在解释器里敲:先算 0.1 加 0.2,再加 0.3;换一种顺序,先算 0.2 加 0.3,再加 0.1。两次结果在小数点后十几位上会出现差异。这不是实现的 bug,是浮点表示本身的舍入误差在累加顺序不同时被放大的必然结果。
这个事实的工程后果很实在:并行归约的合并树形状会影响最终结果的最后几位;数据库里对一批浮点数求和,按不同的索引顺序扫描可能得到不同的账;如果你用浮点做累加计数器,跑得越久误差越大。所以正经的数值计算会采用补偿求和、先按绝对值排序再求和、或者直接用定点/有理数表示。
这里的教训是:封闭性很容易满足,结合律才是真正的分水岭。一个运算只要封闭,就能定义出来,但只有满足结合律,你才能“随意重新分组”,才能并行、才能分治、才能缓存中间结果。我后来判断一个合并函数能不能用于并行计算,第一件事就是看它结不结合,而不是看它快不快。
还有一个更隐蔽的坑:定长整数加法。看起来 32 位整数相加是封闭的,实际上溢出会回绕,回绕之后的语义变成了模 2 的 32 次方的加法,而这个结构恰恰是个群,每个元素都有逆元(相反数),单位元是 0。这解释了一个每天在用的现象:补码的加减法可以完全不管符号位,因为它们本来就是在模 2 的 32 次方的加法群里做运算。你看,连最基础的整数运算都是个群,只是平时没人这么讲。
2.3 幺半群:多一个单位元,换来一整套折叠能力
在半群上加一个单位元 e,要求对任意 a 都有 e·a = a·e = a,就成了幺半群。这个要求看起来微不足道,但它带来的能力是质变的:你可以定义“折叠”。
折叠的意思是,把一串元素用运算一个个合并起来。没有单位元的时候,空序列没法折叠,你只能规定“至少有一个元素”。有了单位元,空序列的折叠结果就是单位元本身,这让递归定义变得干净:fold([]) = e,fold([x] + xs) = x · fold(xs)。所有列表求和、求积、拼接、最大值、最小值,背后都是这个模式。
我见过最典型的设计失误,是把某个业务的合并操作设计成半群,然后在上层到处写“如果列表为空就特判返回空”。一旦你在十处地方写这种特判,就说明底层结构选错了,应该补上单位元升级成幺半群。补单位元的成本通常很低——加法和补个 0,乘法补个 1,拼接补个空串,集合合并补个空集——但它能把调用方从“边界情况处理”里彻底解放出来。
再往上还有一层:如果运算还满足交换律,那这个幺半群就可以做无序归约,也就是并行计算时不用关心元素顺序,任意切分、任意合并都行。分布式系统里做状态合并的那类数据结构,本质上就是这个性质在撑腰——两个节点各自合并本地的一批更新,再把结果合并,等价于把两批更新混在一起合并,前提就是运算既结合又交换,而且幂等(同一个更新合并两次和合并一次结果一样)。这三个性质对应到代数结构上,就是交换幂等幺半群,也叫半格。你会发现,一个分布式系统的合并语义能不能做对,答案全在半群那一层就已经定了。
3. 子群:从局部封闭到陪集划分
3.1 子群判定的三条线和一个高频误判
群 G 的非空子集 H,如果在 G 的运算下自己也构成群,就叫子群。判定的时候不要真的去验证四条公理,用下面这个等价条件最快:H 非空,且对任意 a、b ∈ H,都有 a·b⁻¹ ∈ H。这一条同时打包了封闭性和逆元存在性,证明起来也干净。
高频误判是把“封闭”当成“子群”。在有限群里,一个非空子集如果对运算封闭,那它自动就是子群,因为每个元素的幂次序列 a、a²、a³…… 在有限集合里必然重复,重复之后能推出单位元和逆元都在里面。但在无限群里,这个推论直接失效。
最干净的反例:取整数加法群,它的子集正整数集合对加法封闭——两个正整数加起来还是正整数——但它不是子群,因为正整数里没有 0,也没有负号。很多人脑子里“封闭就是子群”的印象就是被有限群的例子惯出来的。所以我在代码里检查子群时,一定会显式验证逆元,不会偷懒只查封闭,除非我明确知道群是有限的并且验证过这个捷径成立。
3.2 陪集是把群切成等大的块
给定群 G 和子群 H,取一个元素 g,把 g 和 H 里每个元素乘一遍,得到集合 gH,叫 H 的一个左陪集。把所有 g 都试一遍,得到的这些左陪集有一个非常漂亮的性质:任意两个左陪集要么完全相等,要么完全不相交,而且它们把所有元素不重不漏地盖住一遍。
这等价于在群上定义了一个等价关系:a 和 b 等价,当且仅当 a⁻¹b ∈ H。等价类就是左陪集。这一步的思想很关键——陪集不是“随便切一刀”,它是由子群诱导出来的一个等价关系,而等价关系意味着你可以“把同一类的东西看成同一个对象”。
由此得到那个用得最多的结论:子群的阶整除群的阶,商叫指数,记作 [G:H]。这个结论的用法是反向的:如果某个整数不整除群的阶,那这个群里绝对不存在这个阶的子群,连找都不用找。一个 6 阶群里去找 4 阶子群,纯属浪费时间。这个筛子在实际计算里能省掉大量枚举。
但请注意,整除只是必要条件,不是充分条件。存在阶能整除、但确实没有对应阶子群的群。经典反例是四元交错群,阶是 12,按整除性它“应该”有 6 阶子群,实际上没有。我第一次遇到这个反例的时候挺意外,因为它打破了“阶整除就有子群”的朴素直觉。所以正确的用法是:不整除一定没有,整除也不保证有,得进一步分析。
3.3 正规子群:左右陪集什么时候能对上
有了左陪集,自然有右陪集 Hg。一般情况下 gH 和 Hg 不相等。什么时候相等?对所有 g 都相等的时候,这个子群就叫正规子群。
正规子群有三种等价的刻画,我按实用程度排序。第一种,共轭不变:对任意 g ∈ G 和 h ∈ H,都有 g h g⁻¹ ∈ H。这个最适合写成代码,两层循环暴力验证就行。第二种,它等于某个同态的核。第三种,它的所有左陪集和右陪集一一重合。
还有两个能秒判的技巧。第一,指数为 2 的子群一定正规。理由是陪集只有两个,左陪集划分出来的两块和右陪集划分出来的两块都是“H 和它的补集”,划分方式唯一,自然重合。所以任何 6 阶群里的 3 阶子群、8 阶群里的 4 阶子群,闭着眼睛都知道正规。第二,交换群的所有子群都正规,因为交换律让 g h g⁻¹ 直接化简成 h。
非正规的例子必须手算一遍才有感觉。取三个元素的全排列群,考虑只交换前两个元素生成的那个二阶子群,元素是恒等和这个对换。拿第三个元素参与的对换去共轭它,结果会变成另一个对换,而那个对换不在原来的二阶子群里。所以这个子群不正规。这个计算我建议每个人都亲手做一遍,看着元素“跑出集合”的那一下,比看定义有说服力得多。
正规性为什么重要?因为它决定了你能不能把子群“除掉”。不正规的子群,你连陪集乘法都定义不出来,后面全崩。
4. 商群:在等价类上做运算到底靠不靠得住
4.1 商群的四步构造与良定义的那一步
商群的构造有固定套路,我拆成四步。
第一步,取正规子群 N。第二步,把 G 中所有 N 的左陪集收集起来,作为新集合的元素,记作 G/N。第三步,定义运算:(aN)·(bN) = (ab)N,也就是取两个陪集的代表元,乘完再取陪集。第四步,验证这个定义与代表元的选取无关。
第四步是全部难点所在,术语叫良定义。因为一个陪集有无穷多个代表元(只要 N 不是平凡子群),你换一个代表元,比如把 a 换成 a n₁,把 b 换成 b n₂,乘积变成 a n₁ b n₂。你要证明它和 a b 落在同一个陪集里。
证明的关键一步是把 n₁ 从 a 的左边“挪”到 b 的右边去,而这需要借助正规性:b⁻¹ n₁ b 仍然属于 N。你把这个因子插进去再提出来,整体就变成 a b 乘以一个 N 中的元素,落在 abN 里。到这里良定义才算成立。
现在你可以看清正规性到底在干什么了:它保证了共轭这个动作把 N 映射回 N 自己,从而让“挪动因子”合法。如果 N 不正规,b⁻¹ n₁ b 跑出去了,整个证明在第一步就断了。这就是为什么教材要先讲正规子群再讲商群,顺序不是随便排的。
商群本身也是个群:单位元是 N 自己(也就是 eN),aN 的逆元是 a⁻¹N,结合律从原群继承。另外还有一个自然的映射,把每个 g 映到 gN,叫自然投影,它是个满同态,核恰好是 N。这个映射的存在让商群不只是个抽象对象,而是一个可以从原群“算出来”的东西。
4.2 用同态核反向造正规子群,比正向找省事
实践中比“先找正规子群再造商群”更常用的路径,是先有一个同态,然后取它的核。因为核必然是正规子群,这是免费的。
道理很简单:如果 φ(g h g⁻¹) = φ(g) φ(h) φ(g)⁻¹,而 h 在核里意味着 φ(h) 是单位元,那么整个式子算出来还是单位元,所以 g h g⁻¹ 也在核里。共轭不变成立,核自动正规。
举个例子:整数加法群到模 12 剩余类加法群的映射,把每个整数映成它除以 12 的余数。这是个同态,核是所有 12 的倍数。这个核是正规子群(交换群里当然正规),商群就是模 12 剩余类群。
再举一个例子,用三个元素的全排列群到二阶循环群的符号同态,把偶排列映到 0、奇排列映到 1。核是全体偶排列,构成 3 阶子群,商群是 2 阶群。这同时用上了前面那个“指数为 2 必正规”的技巧,两条路殊途同归,可以互相验证。
同构基本定理把这件事总结成一句话:任何同态,商掉它的核,就同构于它的像。我习惯把它当成一个“因式分解”工具:一个复杂的群通过某个同态被压扁,压扁后的形状就是原群除以核。你想知道压扁损失了多少信息,看核有多大;你想知道压扁后剩什么,看像是什么。这个视角在处理具体问题时比定义好用得多。
4.3 代码实录:从暴力枚举子群到自动构造商群
下面这段脚本我自己用了很久,跑的是有限群,元素个数不超过 8 的时候速度完全够。核心思路就是把公理逐条翻译成循环。
from itertools import combinations, permutations def compose(p, q): """置换复合 p∘q:先作用 q,再作用 p""" return tuple(p[q[i]] for i in range(len(q))) class Group: def __init__(self, name, elems, mul): self.name = name self.elems = list(elems) self.S = set(self.elems) self.mul = mul self.e = self._identity() def _identity(self): for e in self.elems: if all(self.mul(e, a) == a and self.mul(a, e) == a for a in self.elems): return e raise ValueError(f"{self.name}: 无单位元,只能算半群/幺半群") def inv(self, a): for b in self.elems: if self.mul(a, b) == self.e and self.mul(b, a) == self.e: return b raise ValueError(f"{a} 无逆元") def check(self): for a in self.elems: for b in self.elems: if self.mul(a, b) not in self.S: return False, f"不封闭:{a},{b}" for a in self.elems: for b in self.elems: for c in self.elems: if self.mul(self.mul(a, b), c) != self.mul(a, self.mul(b, c)): return False, f"不结合:{a},{b},{c}" for a in self.elems: self.inv(a) # 找不到逆元会抛异常 return True, "满足群公理"先验半群,再验群。下面这条是我单独拆出来的,因为它能直接告诉你“结合律在哪一步破的”,调试浮点或自定义运算时特别有用:
def check_semigroup(elems, mul): S = set(elems) for a in elems: for b in elems: if mul(a, b) not in S: return False, f"不封闭:{a} * {b} = {mul(a, b)}" for a in elems: for b in elems: for c in elems: left = mul(mul(a, b), c) right = mul(a, mul(b, c)) if left != right: return False, f"不结合:({a}*{b})*{c}={left},{a}*({b}*{c})={right}" return True, "半群成立"子群枚举用组合暴力,只对有限群有效:
def all_subgroups(G): res = [] for k in range(1, len(G.elems) + 1): for sub in combinations(G.elems, k): s = set(sub) if G.e not in s: continue if all(G.mul(a, b) in s for a in sub for b in sub) and \ all(G.inv(a) in s for a in sub): res.append(s) return res正规性判定,严格按共轭定义来,不做任何优化:
def is_normal(G, H): for g in G.elems: gi = G.inv(g) for h in H: if G.mul(G.mul(g, h), gi) not in H: return False return True最后是商群,先把每个元素对应的左陪集算好,用字典缓存,避免重复计算:
def quotient(G, N): if not is_normal(G, N): raise ValueError("N 不正规,商群不成立") table = {g: frozenset(G.mul(g, n) for n in N) for g in G.elems} cs = set(table.values()) rep = {c: next(g for g in G.elems if table[g] == c) for c in cs} def mul(x, y): return table[G.mul(rep[x], rep[y])] return Group(f"{G.name}/{len(N)}", list(cs), mul)拿三个元素的全排列群跑一遍,输出大致是这样:
G = S3, |G| = 6 子群总数:6 阶 1:1 个 阶 2:3 个 阶 3:1 个 阶 6:1 个 正规子群:{e}、A3、S3 商群 S3/A3 的阶 = 2,与 C2 同构 阶 2 子群 <(01)> 正规性:False这里有个值得注意的细节:三个 2 阶子群全都不正规,唯一非平凡正规子群是那个 3 阶子群。这不是巧合,6 阶群里 3 阶子群的指数是 2,必须正规;2 阶子群指数是 3,没有这种强制力。手动用共轭去算,也能得到同样的结论。
5. 常见错误与排查实录
5.1 症状对照表:八成问题落在这些地方
| 症状 | 真实原因 | 处理办法 |
|---|---|---|
| 子集对运算封闭,但算不出逆元 | 在无限群里封闭推不出子群 | 显式验证逆元,或用 a·b⁻¹ ∈ H 判据 |
| 商群的运算结果依赖代表元 | 拿非正规子群去构造 | 先做共轭检验,不正规就别谈商群 |
| 左右陪集对不上,划分出更多块 | 子群不正规 | 算 gH 和 Hg 比较,或直接验共轭 |
| 枚举了所有阶找子群,一无所获 | 阶不整除群阶 | 先用整除性筛掉不可能的情况 |
| 阶整除但确实没有该阶子群 | 整除只是必要条件 | 换判据,或先看正规子群格子 |
| 结合律“看着”成立,跑起来结果飘 | 浮点舍入破坏结合律 | 换定点/有理数,或固定归约顺序 |
| 置换复合结果与预期相反 | 复合顺序约定不一致 | 全程固定“先右后左”,写进注释 |
| 折叠空列表时报错 | 只到半群,没有单位元 | 补单位元升级成幺半群 |
表里最容易被忽略的是最后一行。业务代码里到处写“列表为空就返回默认值”,本质上是把单位元的位置藏在了调用方,每多一个调用方就多一份重复。把单位元提到类型层面,那些特判就能一次性消掉。
5.2 一套可复用的排查流程
我现在的习惯是固定顺序:先验封闭,再验结合,再找单位元,再找逆元,最后才谈子群和商群。这个顺序不能反,因为后一步的结论全部依赖前一步成立。
封闭性出错最常见的原因是用错了元素集合,比如只把生成元放进去而忘了闭包。结合律出错最常见的原因是自定义运算里混入了顺序敏感的操作,比如依赖哈希表遍历顺序的合并函数。单位元找不到,往往是因为集合不完整,或者单位元其实存在但被错误地排除在集合之外。逆元找不到,那就说明这个结构只是幺半群,你应该回头想清楚业务上到底需不需要撤销能力。
到了子群这一层,先别急着枚举,先用整除性把所有不可能的阶划掉,能砍掉一大半工作量。剩下的候选阶里,先看有没有指数为 2 的子群,有的话它一定正规,可以先拿来构造商群试试水。如果商群构造失败,那问题一定出在正规性上,回头验共轭就完了。
到了商群这一层,最容易踩的坑是把代表元当成了元素本身。写出abN的时候要在心里默念一句“这里每个字母都是整个陪集”,一旦忘了这一点,后面算出来的东西很容易自相矛盾。
提示:验证正规性时不要只测一个共轭元就下结论。我在早期代码里写过“取第一个元素试一下”的偷懒写法,结果在某个 8 阶群上漏判,商群构造出来之后单位元不唯一,排查了整整一个下午才发现是判定函数的问题。
6. 这些概念在工程里到底落在哪
6.1 类型类里的半群与幺半群不是装饰
很多主流语言的函数式库都直接提供了半群和幺半群的类型类,名字可能叫 Semigroup、Monoid,也可能藏在别的抽象后面。它们的约束写得非常直白:半群只要求一个结合的结合运算,幺半群再额外要求一个单位元。
这个抽象的真正价值在于,一旦某个类型被声明成幺半群,你就免费获得了一整套通用函数:折叠、归约、并行聚合、批量合并。而且这些函数的正确性不依赖具体类型,只依赖那两条公理。这就是为什么并行集合库敢把归约操作做成无序的——它只要求元素构成交换幺半群,剩下的随你怎么切。
反过来,声明的时候要慎重。如果你把一个实际不满足结合律的操作声明成半群,编译器不会拦你,但所有依赖这个假设的并行函数都会给出错误结果,而且是那种时对时错、难以复现的错误。我在一个自定义的近似算法里就吃过这个亏:合并两个近似结果本身就有舍入,而且不满足结合律,硬塞进归约函数之后,并行和串行结果不一致,定位了很久。后来老老实实退回到串行,或者把合并操作改成先规约到精确表示再合并。
6.2 固定宽度整数其实是商群在打工
前面提过一句,这里展开说。32 位无符号整数的加法,本质上是在模 2 的 32 次方的剩余类群里做运算。这个群有 2 的 32 次方个元素,单位元是 0,每个元素的逆元是它的补数加一。
理解这一点之后,很多“看起来奇怪”的行为就顺了。溢出回绕不是错误,是模运算的自然结果;减法可以直接用加补数实现;有符号数和无符号数的加减法可以用同一套电路。这些设计选择都建立在一个事实之上:模 2 的幂的加法构成群,而且这个群的结构极其规整——它的子群是那些模 2 的 k 次方的倍数构成的,商群还是同类型的群。
这个“子群和商群同构于同类结构”的现象,在整数模运算里普遍存在。取模 12 的剩余类群,它有个 4 阶子群(0、3、6、9),商掉之后得到的是模 3 的剩余类群;它还有个 3 阶子群(0、4、8),商掉得到模 4 的剩余类群。你完全可以在代码里把这个过程跑出来,看到 12 阶群被压成 3 阶和 4 阶两个小群,这就是商群最具体的模样。
6.3 对称性分析里的子群就是“允许的操作”
凡是涉及对称性的地方,群就一定会出现,而子群对应的是“在整体对称性里,有哪些操作能保持某个局部特征不变”。
举个几何上的例子。正方形的对称操作一共 8 个:4 个旋转加 4 个翻转。这 8 个操作构成一个群。现在你关注的是“只旋转不翻转”的那些操作,它们构成 4 阶子群,指数为 2,所以一定正规,商群是 2 阶群——这个商群的元素分别对应“保持手性”和“反转手性”两类操作。你不需要知道 8 个操作的具体细节,只要知道商群是 2 阶的,就能得出“手性只有两种状态”这个结论。
再比如,一个几何体如果同时具备正方形和三角形的对称性,那它的对称群必须同时包含这两组操作以及它们的组合,而包含关系在子群格上就是“向上取闭包”。做形状匹配、分子结构分类、晶体点群分析的时候,实际在做的操作就是:先算出候选的对称操作集合,验证它闭合成群,再找出所有子群,用子群的阶和指数来给结构分类。这里面没有一步需要解方程,全是集合运算加公理验证,正好适合写成程序跑。
我见过一个做图案生成的思路,就是把基本图形按某个子群的作用铺开,然后改变子群就换了一种铺法。旋转子群给出同心式的排列,翻转子群给出镜像式的排列,全群给出最自由的那种。这套玩法门槛不高,但前提是你得真的理解子群和陪集在几何上是什么样子。
6.4 状态合并为什么非得是交换幂等幺半群
回到开头那个让我转变学习方式的场景。分布式环境下,两个节点各自处理了一批更新,需要把状态合并成一致的结果。这个合并函数要满足什么?
结合律必须有,否则“先合哪两个”会影响结果,节点之间没法达成一致。交换律必须有,因为网络到达顺序不可控,A 先到还是 B 先到必须结果相同。单位元必须有,因为“没有任何更新”也得是一个合法状态,不然空节点没法参与合并。幂等最好有,因为消息可能重复投递,同一个更新合并两次和合并一次应该一样。
这四条凑齐,对应的代数结构是交换幂等幺半群,也叫半格。它的好处是:合并操作的语义可以完全由“取上确界”来刻画,实现上通常就是集合求并、取最大值、按位或这类操作。凡是能用半格建模的状态,做最终一致性都非常省事,因为最终状态只和“收到了哪些更新”有关,和顺序、次数完全无关。
而那些不满足这些性质的状态,比如需要保序的计数器、需要精确线性累加的金额,就必须引入额外的机制。你看,选什么样的数据结构,其实在数学层面早就被这几个公理框死了。理解半群、幺半群、群这条链,最大的收获不是会做几道证明题,而是面对一个合并逻辑时,能立刻判断出它能不能并行、能不能重放、能不能撤销。
我自己踩过最深的坑,是在一个需要“撤销上一步”的功能里,把状态设计成了只支持合并的幺半群,结果每加一个撤销操作就得回放整条历史。后来把状态改成支持逆元的群结构,撤销就变成了一次逆元运算,代价小了一个量级。那次之后我再设计状态模型,第一件事就是问自己:这个东西需要逆元吗,如果需要,那我就得从一开始把它当成群来设计,而不是事后打补丁。