☰
从if-else到格与布尔代数:复杂条件、权限与SQL化简的工程视角
2026/11/3 4:37:48 网站建设 项目流程

写业务代码写到第五年,我对“条件判断”这件事的容忍度越来越低。一个权限模块里堆了十几层 if-else,产品经理再加一条“高级用户可以临时越权访问某类资源,但不能修改归属人”的规则,整个函数就彻底没人敢动了。后来我把这一坨条件全画到纸上,发现它们其实是一个偏序集上的上下确界运算——那一刻我才意识到,当年计算机专业必修的“格和布尔代数”这一章,不是拿来考试的,是拿来拆这种东西的。

这一章在离散数学里位置很尴尬:前面讲集合、关系、函数,后面讲图论、代数系统,格和布尔代数被夹在中间,教材往往用一堆 Hesse 图(哈斯图)和定义砸过来,考试靠背,考完就忘。但它其实是整个离散数学里工程味最重的一章:布尔代数直接对应数字电路的逻辑门、SQL 的 where 条件、编译器的常量折叠、权限系统的角色偏序。把这章吃透,你在写复杂条件、设计权限模型、优化查询语句时,脑子里会多一层结构化的视角。

下面我按自己重学这一章的顺序,从最底层的偏序讲起,一路推到布尔函数和范式,中间穿插能直接跑起来的代码和踩过的坑。适合两类人看:正在学这门课、想把定义和实际对上号的同学;以及工作几年、被复杂布尔条件折磨过、想补一补底层抽象的开发者。

1. 格的两种定义:从“取大取小”到“吸收律”

很多人第一次学格,卡在同一个地方:教材先给一个偏序定义,说“任意两个元素都有最小上界和最大下界”,过两页又给一个代数定义,说“满足交换律、结合律、吸收律的代数系统叫格”。两套定义摆在一起,学生不知道哪个才是“真”的定义,考试考哪个。我当时的做法是把两套定义各写一遍,然后自己证一遍等价性,这一证,很多模糊的地方就通了。

1.1 偏序定义:格是“任意两点都有上下确界”的偏序集

先把偏序关系摆出来。集合 L 上定义一个关系 ≤,满足自反性、反对称性、传递性,就叫偏序集 (L, ≤)。日常例子:实数上的小于等于、集合上的包含关系、正整数上的整除关系,都是偏序。

在偏序集里,给两个元素 a 和 b,考虑所有比它们俩都大的元素组成的集合 {x | a ≤ x 且 b ≤ x},这个集合叫上界集。如果上界集里有一个最小元,这个最小元就叫 a 和 b 的最小上界,记作 a ∨ b,读作“a 并 b”。对称地,下界集的最大元叫最大下界,记作 a ∧ b,读作“a 交 b”。

定义摆出来:如果偏序集 L 里任意两个元素都存在最小上界和最大下界,L 就叫格。

注意“任意”这两个字。有些偏序集看起来很像格,但就是有一对元素不满足。最经典的坑是:集合 {2, 3, 12} 在整除关系下,2 和 3 的上界是 6 的倍数里属于集合的元素,集合里只有 12,但 12 不是最小上界(真正的最小上界是 6,不在集合里),所以这对元素没有最小上界,不构成格。要构成格,必须把 6 补进去。

这个定义的好处是几何直观强。画哈斯图的时候,a ∨ b 就是从 a、b 往上走能遇到的“最近汇合点”,a ∧ b 是往下走的“最近分叉点”。坏处是验证起来麻烦,你得对每一对元素都检查一遍,n 个元素要检查 n(n-1)/2 对。

我会用一个具体的例子来固定这个直觉:正整数集在整除关系下,a ∧ b 就是最大公约数,a ∨ b 就是最小公倍数。这个例子好用到什么程度?后面讲分配格、有界格,我全用它来验算。

from math import gcd def meet(a, b): """整除偏序下的最大下界:最大公约数""" return gcd(a, b) def join(a, b): """整除偏序下的最小上界:最小公倍数""" return a * b // gcd(a, b) # 验算吸收律:a ∧ (a ∨ b) = a for a, b in [(12, 18), (7, 21), (30, 45), (8, 9)]: assert meet(a, join(a, b)) == a assert join(a, meet(a, b)) == a print("吸收律在整除格上成立")

这段代码看起来平淡,但你跑一遍会发现一件有意思的事:吸收律的成立跟 gcd、lcm 的具体算法无关,只跟“整除”这个偏序的结构有关。这就是代数定义想要表达的东西。

1.2 代数定义:把“序”扔掉,只留运算

第二套定义不预设偏序,直接在集合上定义两个二元运算 ∧ 和 ∨,要求它们满足三条(有的教材写四条,把幂等律单独列):

定律表达式
交换律a ∧ b = b ∧ a,a ∨ b = b ∨ a
结合律(a ∧ b) ∧ c = a ∧ (b ∧ c),(a ∨ b) ∨ c = a ∨ (b ∨ c)
吸收律a ∧ (a ∨ b) = a,a ∨ (a ∧ b) = a
幂等律(可由吸收律推出)a ∧ a = a,a ∨ a = a

满足这三条的 (L, ∧, ∨) 就叫格。

为什么代数定义里没有提“序”?因为序可以被运算重新造出来:定义 a ≤ b 当且仅当 a ∧ b = a(等价地 a ∨ b = b)。你可以验证这个关系确实满足自反、反对称、传递,而且在这个序下 a ∧ b 恰好是最小下界、a ∨ b 恰好是最大上界。两套定义就此闭环。

我当时的困惑是:既然等价,为什么教材要讲两遍?后来在工程里找到答案了。偏序定义适合分析和建模——你拿到一个业务场景,先想清楚谁比谁“更强”“更宽”“更具体”,画出偏序,再看它是不是格。代数定义适合计算和推导——你不需要知道序,只要运算满足那几条律,所有格论定理都能用,编译器做常量折叠、数据库做条件化简,走的都是这条路。

这两套定义的关系,用一句大白话概括:偏序定义告诉你格“长什么样”,代数定义告诉你格“能怎么算”。学的时候一定要自己动手把“由序造运算”和“由运算造序”两个方向各推一遍,推完你对这一章的免疫能力会强很多,后面有补格、布尔代数的定义全是这个套路的叠加。

1.3 子格与同态:别把“子集”当“子格”

这里提前说一个后面会反复踩的坑。给定格 L,取一个子集 S ⊆ L,S 在同样的运算下封闭,才叫 L 的子格。封闭的意思是:对任意 a、b ∈ S,a ∧ b 和 a ∨ b 都得落在 S 里。

反例极其容易构造。考虑整除格上集合 {1, 2, 3, 6, 12},取子集 {2, 3, 6}。2 ∧ 3 = 1,1 不在子集里,所以 {2, 3, 6} 不是子格,尽管它自己画出来也像个有上下界的偏序集。这是学生最容易丢分的地方,我当年也在上面栽过:子集不一定继承父格的最小上界和最大下界,因为在子集里“上界”的候选范围变小了,原来的最小上界可能被排除在外,也可能冒出一个新的更小的上界。

格同态则是另一回事:映射 f: L₁ → L₂,要求 f(a ∧ b) = f(a) ∧ f(b) 且 f(a ∨ b) = f(a) ∨ f(b)。双射的同态叫同构。后面讲布尔代数时,有限布尔代数的结构定理就是靠同构来说的——所有有限布尔代数都同构于某个幂集代数。这句话听起来抽象,实际意思是:你在有限世界里遇到的布尔代数,本质上都是“集合的并交补”换了个马甲。

2. 哈斯图上怎么快速判断“是不是格”

教材上的哈斯图往往画得很干净,考试也爱考“判断下图是否为格”。我总结了一套看图流程,比死记定义快得多,写在这里供参考。

2.1 画图三原则与常见错误

哈斯图的画法有三条规矩:省掉自环(自己到自己)、省掉传递边(a ≤ b ≤ c 就不要画 a 到 c 的边)、上层元素画在下层元素上方。这三条决定了哈斯图天然就是“偏序的骨架”。

判断是不是格,我用的流程是这样的:

  1. 先看有没有孤立的两条“平行链”。如果有两个元素互相不可比,且它们往上没有共同的最近汇合点,那就不是格。
  2. 重点检查“极大元”和“极小元”那一层。多个极大元的时候,任意两个极大元必须有一个共同的最小上界,否则不是格。
  3. 反过来的坑也一样:多个极小元的时候,任意两个必须有共同的最大下界。

用这个流程看几个经典图形:

图形是否为格卡在哪
一条链 a < b < c是任意两点可比,上确界就是大的那个
一个“V”形:a、b 在下面,c 在上面是a ∨ b = c,a ∧ b 不存在——等等,这里要小心
一个“Λ”形:c 在上面,a、b 在下面否a 和 b 没有下界
钻石形:底 0,顶 1,中间两个不可比元素是任意两点都有上下确界
五角形(N5)是但它是非模格,后面会讲

第二行那个“V”形需要特别说明。a 和 b 都在下面、c 在上面,a 和 b 之间不可比。a ∨ b = c,没问题;a ∧ b 呢?a 和 b 的下界集是空的,没有最大元,所以不存在最大下界。因此“V”形不是格。很多同学画图时凭感觉觉得“下面有个底”,其实那个底根本不存在。同理,“Λ”形也不是格。真正的最小格结构是“钻石形”:底、顶,中间可以有若干不可比元素,且任意两点都有确界。

这个细节我强调是因为它直接影响后面有界格的定义。有界格要求存在全局最小元 0 和全局最大元 1,注意是“全局”,不是“局部”。一个格可以每对元素都有确界,但整体没有最小元——比如整数集在通常序下就是格(a ∧ b = min,a ∨ b = max),但它没有最小元和最大元,所以不是有界格。

2.2 用哈斯图理解“并”和“交”的实际含义

我后来发现,哈斯图最大的价值不是做判断题,而是帮你把业务里的“偏序”可视化。举个我实际遇到过的场景:一个内容平台的权限设计,权限之间是有强弱关系的——能“删除”必然能“编辑”,能“编辑”必然能“查看”。这些权限画出来是一条链:查看 < 编辑 < 删除 < 管理。

然后角色出现了:作者、审核员、管理员。作者有查看和编辑,审核员有查看和删除,管理员全有。把角色画进同一个哈斯图里,作者和审核员不可比,它们的“并”是管理员,“交”是查看。这就是一个标准的格。

用这个视角看,很多权限系统的设计问题就暴露出来了:如果你设计出的角色集合在“并”运算下不封闭,意味着总有一些组合权限没有对应的角色,用户要完成某个操作就得手工拼权限,这就是设计缺陷。格的性质——封闭性——直接对应权限模型的可维护性。这个观点后面第 6 节还会展开。

3. 分配格、有界格、有补格:一层层加约束

格的层次是这一章的主线:格 → 分配格 → 有界格 → 有补格 → 布尔代数。每加一层,能表达的结构更强,能用的定理更多,但满足条件的实例更少。我学的时候画了一张表贴在书桌前,效果比反复翻书好。

层次额外要求典型例子典型反例
格任意两点有确界整除格、子群格“V”形、“Λ”形偏序
分配格∧ 对 ∨ 分配,反之亦然幂集格、整除格、链钻石格 M3、五角格 N5
有界格存在全局 0 和 1幂集格、有限链整数在通常序下
有补格每个元素有补元幂集格有限链(长度 > 1 时中间元素无补)
布尔代数有补 + 分配幂集格、命题代数、开关代数钻石格(有补但非分配)

3.1 分配律为什么这么“金贵”

分配律写出来是两条:a ∧ (b ∨ c) = (a ∧ b) ∨ (a ∧ c),以及它的对偶形式 a ∨ (b ∧ c) = (a ∨ b) ∧ (a ∨ c)。

在幂集格上验证这两条,就是集合的交对并分配、并对交分配,非常自然,以至于很多人以为所有格都满足。错。钻石格 M3 就是反例:底 0,顶 1,中间三个元素 a、b、c 两两不可比。取 a ∧ (b ∨ c),b ∨ c = 1,所以左边 = a ∧ 1 = a;右边 (a ∧ b) ∨ (a ∧ c) = 0 ∨ 0 = 0。a ≠ 0,分配律破了。

这个反例的意义在于:它告诉你分配律不是格的自带属性,而是一个额外约束。它在工程上对应什么?对应“条件可以随意重排”这件事。你写 SQL 的 where 条件,把 a AND (b OR c) 改写成 (a AND b) OR (a AND c),查询优化器敢不敢做这个变换、变换后语义是否等价,底层依据就是这套布尔代数的分配律。如果底层的代数结构不是分配格,这种改写就是错的。

我用整除格再验一遍,因为它的运算最直观:

from math import gcd def meet(a, b): return gcd(a, b) def join(a, b): return a * b // gcd(a, b) # 分配律:a ∧ (b ∨ c) = (a ∧ b) ∨ (a ∧ c) for a in range(1, 13): for b in range(1, 13): for c in range(1, 13): lhs = meet(a, join(b, c)) rhs = join(meet(a, b), meet(a, c)) assert lhs == rhs, (a, b, c, lhs, rhs) print("整除格上分配律全部成立")

这段暴力验算跑完,你对“整除格是分配格”这件事就有体感了。顺带一提,这个例子还说明了一个更强的结论:任何全序集(链)都是分配格,因为链上 a ∧ b 就是两者较小者、a ∨ b 是较大者,分配律退化成实数上 min 和 max 的分配律,永远成立。

判断分配格的正式判据(Birkhoff 定理):一个格是分配格,当且仅当它不含与 M3(钻石格)或 N5(五角格)同构的子格。这条定理在考试里出现频率极高,画图判断题基本靠它。我的记忆方式是:M3 是“三个并列的中间元素”,N5 是“一个元素夹在中间、另一个在旁边”,看到这两种图形,直接判非分配。

3.2 有界格与补元:0 和 1 的来源

有界格的额外要求是存在全局最小元 0 和全局最大元 1,使得对任意 a 都有 0 ≤ a ≤ 1。用运算的语言说:a ∧ 1 = a,a ∨ 0 = a。

这个要求看着简单,但很多“像格的东西”过不了这关。前面说的整数在通常序下就是典型:min、max 运算满足所有格律,整数集本身有无穷多个元素,但既没有最小整数也没有最大整数,所以不是有界格。相比之下,任何一个有限格都是有界格——因为元素有限,所有元素取一遍并就是 1、取一遍交就是 0。这条结论很实用:考试里给有限格,直接默认有界。

有了界之后才能定义补元。给定有界格 L 里的元素 a,如果存在 b 使得 a ∧ b = 0 且 a ∨ b = 1,b 就叫 a 的补元。注意这里有两个坑:

第一,补元不一定存在。长度为 3 的链 0 < a < 1,a 的补元存在吗?a ∧ b = 0 要求 b ≤ ... 只有 0 满足,但 a ∨ 0 = a ≠ 1,所以 a 没有补元。有限链长度大于 2 时,中间元素一律无补。

第二,补元不一定唯一。钻石格 M3 里,a 的补元可以是 b 也可以是 c,因为 a ∧ b = 0、a ∨ b = 1,a ∧ c = 0、a ∨ c = 1,两个都成立。补元不唯一恰恰是 M3 非分配导致的结果——这引出一个关键定理:在有补分配格里,每个元素的补元唯一。

这就是为什么布尔代数的定义要求“有补 + 分配”。分配保证了补元唯一,唯一性保证了 ¬ 可以当成一个运算(函数必须单值),而一旦有了单值的补运算,德摩根律、双重否定律这些才谈得上成立。

3.3 模格:介于格和分配格之间的过渡层

教材对模格的着墨不多,但它是理解 M3 和 N5 差异的关键。模律写出来:若 a ≤ b,则 a ∨ (b ∧ c) = b ∧ (a ∨ c)。

所有的分配格都是模格,反之不然(M3 是模格但不是分配格)。N5 则连模格都不是。所以格的分类可以画成这样:格 ⊃ 模格 ⊃ 分配格 ⊃ 布尔代数。

对工程来说,模格这个层级基本用不上,但它在理论上解释了“分配律失效”的两种不同方式:M3 是“补元不唯一”式的失效,N5 是更严重的结构扭曲。考试里如果让你判断一个格是不是模格,最快的办法是看它能不能嵌入 N5,因为模格的一个等价刻画就是“不含 N5 型子格”。

4. 布尔代数:把“逻辑”抽象成一个代数系统

到了这一节,前面所有铺垫开始收网。布尔代数的标准定义是一个代数系统 (B, ∧, ∨, ¬, 0, 1),满足:

  1. (B, ∧, ∨) 是分配格;
  2. 存在 0 和 1,使得 a ∧ 1 = a,a ∨ 0 = a;
  3. 每个元素 a 都有补元 ¬a,满足 a ∧ ¬a = 0,a ∨ ¬a = 1。

这三条一摆,很多人第一反应是“这不就是集合的并交补吗”。是的,而且更精确地说:每一个有限布尔代数都同构于某个幂集代数,这就是著名的表示定理。所以从结构上看,布尔代数只有一种,区别只在于底层集合有多少个元素。

4.1 幂集代数、命题代数、开关代数:三个马甲

同一个结构在三个领域里的化身,值得逐一对照,因为工程里的直觉全靠这个对照建立。

抽象元素幂集代数 P(S)命题逻辑开关代数
∧交集 ∩合取 AND串联
∨并集 ∪析取 OR并联
¬补集 ∁否定 NOT常开/常闭切换
0空集 ∅永假 F断路
1全集 S永真 T通路

第一列是我用来做抽象推理的工具,第二列对应集合类库里的操作,第三列对应 if 条件,第四列对应硬件电路。它们共享同一套定理,所以你在命题逻辑里推出来的化简规则,搬到电路上依然成立,搬到 SQL 查询改写上依然成立。

这个“同构”不是比喻,是严格的数学结论。理解这一点之后,很多“为什么电路能用电平表示逻辑”的困惑就消失了——不是因为工程师聪明地想出了对应关系,而是因为这两个系统在代数结构上根本就是同一个东西。

举个非常实用的例子。命题逻辑里的蕴含式 p → q 等价于 ¬p ∨ q,这个等价关系在开关代数里对应“常闭触点与 q 并联”,在 SQL 里对应NOT p OR q。当你在代码里写if (!a || b)的时候,你写的就是一个布尔代数表达式,语义上等价于a implies b。

// 三个写法在布尔代数里完全等价 const f1 = (a, b) => !a || b; // 析取形式 const f2 = (a, b) => !(a && !b); // 德摩根后的合取否定形式 const f3 = (a, b) => (a && b) || !a; // 展开形式 // 穷举真值表验证等价 for (const a of [true, false]) { for (const b of [true, false]) { const r = [f1(a, b), f2(a, b), f3(a, b)]; const allSame = r.every(v => v === r[0]); console.log(`a=${a} b=${b} -> ${r.join(' / ')} ${allSame ? 'OK' : 'FAIL'}`); } }

这段代码跑出来的真值表就是布尔函数,而“等价”的判定标准就是真值表逐行相同。布尔代数里所有化简技巧,本质都是在不改变真值表的前提下减少运算次数,这个视角一旦立住,后面范式和卡诺图的部分就顺理成章了。

4.2 对偶原理:让工作量直接减半的那条规则

布尔代数里有一条极其好用的原理:把表达式里的 ∧ 换成 ∨、∨ 换成 ∧、0 换成 1、1 换成 0、≤ 换成 ≥,得到的命题依然成立,这叫对偶原理。

它的价值在考试和推导里非常直接:你只需要证明一半的定理。比如你证了 a ∧ (b ∨ c) = (a ∧ b) ∨ (a ∧ c),那它的对偶形式 a ∨ (b ∧ c) = (a ∨ b) ∧ (a ∨ c) 自动成立,不用再证。同理,德摩根律的两条形式只需要证一条。

德摩根律本身值得单拎出来。它的两条形式是:

  • ¬(a ∧ b) = ¬a ∨ ¬b
  • ¬(a ∨ b) = ¬a ∧ ¬b

在集合论里就是“交的补等于补的并、并的补等于补的交”,在代码里就是把!(a && b)改写成!a || !b。这个改写看起来是纯粹的形式变换,但它有一个工程上的实际意义:它决定了你在什么位置做短路求值。a && b在 a 为假时不求值 b,!(a && b)在 a 为假时直接返回真,而等价的!a || !b在 a 为假时也直接返回真——两条路径的短路点一致,所以改写是安全的。但如果涉及副作用(比如函数调用),改写的顺序就可能改变执行结果,这是实战里必须警惕的。

提醒一句:布尔代数的所有化简律,成立的前提是表达式里的每个子表达式纯函数、无副作用。涉及数据库查询、远程调用、随机数的时候,先算清楚副作用,再谈化简。

4.3 补元的唯一性与“反证法式”的推理

在布尔代数里,补元唯一这条性质带来了一个非常实用的推理工具:要证 a = b,可以证 a ∧ ¬b = 0 且 a ∨ ¬b = 1,或者更常见地用“假设某元素有两个补元,推出它们相等”的套路。

我自己最喜欢用的一个恒等式是 a ∨ (¬a ∧ b) = a ∨ b。这个式子看起来不起眼,但在做条件化简时特别好用:它表示“如果 a 已经为真,那么 ¬a ∧ b 这一坨可以整体丢掉,直接换成 b”。举个实际例子——某个鉴权逻辑写成:

允许访问 = 是管理员 OR (不是管理员 AND 是资源所有者)

用这条恒等式化简,直接变成“允许访问 = 是管理员 OR 是资源所有者”。少了一次取反、一次与运算,而且可读性大幅提升。这类化简在编译器的常量传播、查询优化器的谓词下推里,是每天都要跑的步骤。

5. 布尔函数与范式:把任意逻辑压成标准形状

前面讲的都是“已有的布尔代数有什么性质”,这一节转向“如何构造”。给定任意一个逻辑需求,怎么把它变成一个标准的、机器能处理的表达式?答案就是范式。这部分是这一章最工程化的地方,也是我工作中用得最多的地方。

5.1 极小项、最大项与真值表的一一对应

先把概念理清楚。n 个变元 x₁ 到 xₙ,每个变元在极小项里出现且仅出现一次,要么原样、要么取反,所有变元取合取,这样的表达式叫极小项(minterm)。

性质非常漂亮:n 个变元恰好有 2ⁿ 个极小项,每一个极小项只在一组赋值下取值为 1,其余全部为 0。这个一一对应关系是整个范式理论的基石。

举例,三个变元 x、y、z,极小项 ¬x ∧ y ∧ ¬z 只在 (0, 1, 0) 这一组赋值下为 1,其他七组都是 0。

最大项是对偶概念:变元取析取,性质是只在一组赋值下取值为 0,其余全为 1。

有了这个性质,任意布尔函数都可以唯一地写成“所有使其值为 1 的极小项之析取”,这就是主析取范式(也叫极小项之和、DNF 的标准形式)。对偶地,写成“所有使其值为 0 的最大项之合取”,就是主合取范式。

概念运算形式唯一性对应真值表的
主析取范式极小项的析取在变元顺序固定后唯一值为 1 的行
主合取范式最大项的合取在变元顺序固定后唯一值为 0 的行

考试里最常考的是“给真值表求主析取范式”。我用的手算流程:

  1. 列出所有使函数为 1 的赋值行;
  2. 每一行写一个极小项,变量取值为 1 就写原变量,取 0 就写否定;
  3. 把所有极小项用 ∨ 连起来;
  4. 顺手按变元字典序排一下,方便对照标准答案。

这个流程看着机械,但一定要自己动手做几道,因为第 2 步的“1 写原变量、0 写否定”是极高频的出错点,很多人第二次做还是会写反。我的记忆口诀是:“真留假反”,最小项里值为真的变量保留原样,值为假的变量取反。

5.2 用代码生成真值表和主析取范式

手算几道之后,我写了个小脚本自动生成,一是核对答案,二是后来真的用在了一个配置系统的规则校验上——业务方给的规则表,我需要验证它是不是覆盖了所有情况、有没有自相矛盾。

from itertools import product def truth_table(vars_, func): """生成真值表,返回 [(赋值元组, 结果), ...]""" return [(assign, func(*assign)) for assign in product([0, 1], repeat=len(vars_))] def minterm(assign, vars_): """根据赋值生成极小项字符串""" terms = [] for val, name in zip(assign, vars_): terms.append(name if val == 1 else f"¬{name}") return " ∧ ".join(terms) # 例:三变元多数表决函数,至少两个为 1 则输出 1 vars_ = ["x", "y", "z"] f = lambda x, y, z: 1 if (x + y + z) >= 2 else 0 table = truth_table(vars_, f) ones = [mt for assign, val in table if val == 1 for mt in [minterm(assign, vars_)]] zeros = [mt for assign, val in table if val == 0 for mt in [minterm(assign, vars_)]] print("主析取范式:") print(" ∨ ".join(f"({m})" for m in ones)) print("\n使函数为 0 的赋值对应极小项(用于求主合取范式):") print(", ".join(zeros))

跑出来会发现,多数表决函数的主析取范式有 4 个极小项,分别是 xyz、xy¬z、x¬yz、¬xyz 对应的那几行。而如果你手工化简,其实可以写成 xy ∨ xz ∨ yz,三项就够了。主析取范式保证了“存在且唯一”,但不保证“最简”,这个区别是很多人混淆的地方,也是后面卡诺图存在的理由。

5.3 卡诺图化简:手算时代的最优解,今天的思维训练

主析取范式虽然标准,但项数多、冗余大。化简的标准手工方法是卡诺图。

卡诺图的构造规则:n 个变元画 2^(n/2) × 2^(n/2) 的方格(实际常用 2 变元 2×2、3 变元 2×4、4 变元 4×4),行和列的标号按格雷码排列(相邻只差一位),每个格子填对应赋值的函数值。

化简规则我总结成四句话:

  1. 只圈 1,不圈 0(圈 0 得到的是合取范式的化简,原理对称);
  2. 每圈的元素个数必须是 2 的幂(1、2、4、8……);
  3. 圈要尽量大,圈数要尽量少;
  4. 圈可以重叠,可以跨边界(左右边界、上下边界都算相邻)。

举个具体例子。四变元函数,卡诺图填完发现有四个 1 排成 2×2 的方块,那么这一圈消掉两个变元,剩下两个变元直接写成一项。消元的原理是:如果一圈里的所有格子中,某个变元既取过 0 也取过 1,这个变元就被消掉;如果某个变元始终不变,就保留。这句话是卡诺图的灵魂,理解了它,你就不需要背“相邻格消一个变元”这种经验规则。

卡诺图在工程里还有没有用?直接手画卡诺图的场景已经很少了,因为综合工具会自动做逻辑最小化。但它的思路——找公共子表达式、消掉无关变元——在写代码时依然有效。我审查过一段权限判断代码,里面有十几个条件,用卡诺图的眼光看就是“有一大块区域的条件完全一样,可以合并”,合并后从 40 行降到 8 行。工具变了,思维没变。

6. 落到工程里的三处:权限、查询与配置校验

理论讲完,最后讲三个我实际遇到过的场景,说明这一章的知识在什么地方能直接变现。

6.1 权限模型的偏序视角

前面提过一次,这里展开。一个内容平台的权限设计,最初是平铺的布尔开关:能看、能改、能删、能审。四个开关组合出 16 种角色,配置界面一塌糊涂,运营配错是常态。

改成偏序模型之后:定义“查看 ≤ 编辑 ≤ 删除 ≤ 管理”这条链,角色就是这条链上的区间。作者 = [查看, 编辑],审核员 = [查看, 删除],管理员 = [查看, 管理]。角色集合 {作者, 审核员, 管理员} 在“取交集”和“取并集”下封闭,构成一个格。

这个模型带来的好处很实际:新增权限只需要插进链里某个位置,已有角色的语义自动更新;角色之间的包含关系可以直接用格运算算出来,不用手动维护映射表;判断“角色 A 是否包含角色 B 的全部权限”变成了一个 ≤ 比较,一次运算完事。

// 用位掩码实现链式权限的格运算 const PERM = { VIEW: 1, EDIT: 2, DELETE: 4, ADMIN: 8 }; // 链式权限:高权限自动包含低权限 const author = PERM.VIEW | PERM.EDIT; // 3 const auditor = PERM.VIEW | PERM.DELETE; // 5 const admin = PERM.VIEW | PERM.EDIT | PERM.DELETE | PERM.ADMIN; // 15 // 交运算:两个角色的公共权限 const meet = (a, b) => a & b; // 并运算:两个角色的权限合并 const join = (a, b) => a | b; console.log(meet(author, auditor)); // 1,只有查看 console.log(join(author, auditor)); // 7,查看+编辑+删除 console.log(meet(admin, author) === author); // true,作者是管理员的子集

这段代码里的meet(admin, author) === author就是偏序关系的运算化表达:a ≤ b 等价于 a ∧ b = a。把偏序判断变成一次位运算,这是格论给工程最直接的一份礼物。

6.2 SQL 条件里的布尔代数:优化器在做什么

数据库查询优化器的一个核心步骤是谓词化简,用的就是布尔代数。你在 where 里写的条件会被解析成一棵布尔表达式树,然后反复应用分配律、德摩根律、吸收律、幂等律做等价变换,最后选一个估算代价最低的执行计划。

理解这一点,对写 SQL 有直接帮助。比如:

-- 写法 A SELECT * FROM orders WHERE status = 'paid' AND (channel = 'web' OR channel = 'app'); -- 写法 B,等价的分配形式 SELECT * FROM orders WHERE (status = 'paid' AND channel = 'web') OR (status = 'paid' AND channel = 'app');

在布尔代数上这两种写法完全等价(分配律),优化器一般能识别。但如果你在条件里嵌了函数调用、子查询,等价性就可能被破坏,优化器不敢改写,索引也可能用不上。能不能化简,取决于表达式是否“纯”,这又回到了前面说的副作用问题。

另一个高频场景是吸收律的应用。条件写成(a = 1 AND b = 2) OR (a = 1),第二项吸收了第一项,整体等价于a = 1。写代码时如果你发现自己在一个大条件里手写这种冗余,说明前面的逻辑分层没做好,应该往上抽一层。

6.3 配置规则校验:用范式检查覆盖与冲突

最后一个场景来自一个规则引擎的配置校验。业务方在一个界面上配了几十条“满足什么条件就执行什么动作”的规则,我需要写一个校验器,检查两件事:规则之间有没有冲突(同一个输入可能触发两条动作相反的规则)、有没有遗漏(某些输入没有任何规则覆盖)。

这两件事用布尔代数说就非常清楚:把所有规则的条件写成布尔函数,冲突就是两个函数的合取不恒为假,遗漏就是所有规则条件的析取不恒为真。判定的方法是把条件都化成主析取范式,按极小项比对:

def to_minterm_set(expr_minterms): """把极小项列表转成集合,便于比对""" return set(expr_minterms) def check_conflict(rule1_ones, rule2_ones): """两个规则的条件在某个输入下同时为真 -> 冲突""" return bool(to_minterm_set(rule1_ones) & to_minterm_set(rule2_ones)) def check_coverage(all_rules_ones, universe): """所有规则恰好覆盖全部输入 -> 无遗漏""" covered = set() for ones in all_rules_ones: covered |= set(ones) return covered == set(universe), set(universe) - covered

这套做法的关键洞察是:主析取范式把“逻辑覆盖”问题转化成了“集合运算”问题。集合交并差是 O(n) 的,比逐个输入枚举快得多,而且能直接指出是哪几个极小项没被覆盖——这个信息直接反馈给配置界面,比“校验失败”四个字有用得多。

写这个校验器的时候我踩过一个坑:最初我用抽样测试(随机生成一千组输入跑一遍),结果上线后还是出了漏配。原因很简单,变元多了之后,抽样根本覆盖不到边界组合。改成范式比对之后,校验是完备的,跟输入空间大小无关。这就是“证明”和“测试”的区别,也是离散数学这门课真正教给你的东西。

7. 我学这一章时走过的几条弯路

最后说几条纯粹的个人经验,都是当时卡了很久、后来回头看完全没必要卡的地方。

第一个弯路是把定义当结论背。格的偏序定义和代数定义,我一开始是分开背的,考试能写出来,但一遇到“证明某个偏序集是格”就懵。后来强迫自己把等价性证了一遍,才发现两套定义根本就是一件事的两个侧面,从那时起所有相关证明都能自己推。离散数学这一章,凡是出现“两种定义”的地方,都值得花半小时把等价性推一遍,收益远大于背十个定理。

第二个弯路是轻视哈斯图。我起初觉得画图是低效的辅助手段,习惯直接在集合上做运算。后来做权限模型时才意识到,图是给人看的,运算是给机器算的,建模阶段一定要画图,因为人的直觉在图上反应最快,等你把结构想清楚了再交给代码。现在我设计任何有包含关系的模型,第一步都是画哈斯图。

第三个弯路是把布尔代数和命题逻辑当成两门课学。它们在我脑子里分属两个文件夹,直到有一次做 SQL 改写,突然发现NOT (a AND b)和!a OR !b是同一件事,两个文件夹才合并。合并之后学习效率明显提高,因为一套直觉可以复用到两个场景。

如果让我给正在学这一章的人一条建议,那就是:别把它当成纯理论,找一个你熟悉的领域往里面套。写代码的往条件表达式上套,做硬件的往逻辑门上套,做数据库的往查询条件上套,做权限的往角色偏序上套。这一章的所有定义,都能在一个具体的工程场景里找到对应物,找到之后,它就不再是需要背的东西了。

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

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

立即咨询