如果一段程序只有三个布尔输入,我们可以把八种情况逐一跑完。但输入增加后,真正有用的问题就变成了:能否不列出整张真值表,仍然知道表达式表示什么、有哪些冗余,以及是否存在满足全部条件的输入?
7.1.1 给出了几条不同的路线:用代数改写函数,用子立方体理解化简,用闭包性质识别容易求解的约束,再用中数和阈值研究函数的结构。它们最后都会落到同一个问题上:输入的表示方式,究竟保留了哪些可以利用的信息?
阅读定位:本篇依据资料包中的《计算机程序设计艺术》第4卷第0册双语版《组合算法与布尔函数概论》,§7.1.1“布尔基础”。中文正文为书内274—302页,对应PDF第285—313页;本节习题为书内303—320页,对应PDF第314—331页。以下按正文主线重新组织推导,例子与Python实现为配套讲解,不逐题复述习题答案。原先的7.1篇是总览,本篇单独展开7.1.1。
文章目录
1. 真值表是函数本身,表达式只是它的一种表示
一个 n n n元布尔函数是
f : { 0 , 1 } n → { 0 , 1 } . f:\{0,1\}^n\to\{0,1\}. f:{0,1}n→{0,1}.
输入有 2 n 2^n 2n种,各行的输出可独立选择0或1,所以函数共有
2 2 n 2^{2^n} 22n
个。两个输入时是16个,三个输入时是256个,四个输入时就有65536个。这里按固定变量顺序计数;交换变量后真值表改变,就算另一个函数。
规定两个变量的输入顺序为 00 , 01 , 10 , 11 00,01,10,11 00,01,10,11,常见运算的真值表如下。
| 运算 | 表达式 | 输出串 |
|---|---|---|
| 与 | x ∧ y x\land y x∧y | 0001 |
| 或 | x ∨ y x\lor y x∨y | 0111 |
| 异或 | x ⊕ y x\oplus y x⊕y | 0110 |
| 蕴涵 | x ⇒ y = ¬ x ∨ y x\Rightarrow y=\neg x\lor y x⇒y=¬x∨y | 1101 |
| 等价 | ¬ ( x ⊕ y ) \neg(x\oplus y) ¬(x⊕y) | 1001 |
| 与非 | ¬ ( x ∧ y ) \neg(x\land y) ¬(x∧y) | 1110 |
| 或非 | ¬ ( x ∨ y ) \neg(x\lor y) ¬(x∨y) | 1000 |
蕴涵不是程序中的时间顺序,也不表示因果关系。它只排除“前件为真、后件为假”这一行。把自然语言条件翻译成公式时,先写清楚要排除哪些输入,通常比背运算符更可靠。
下面一直使用一个三输入选择函数: s s s是选择位, s = 0 s=0 s=0时返回 a a a, s = 1 s=1 s=1时返回 b b b。
F ( s , a , b ) = ( ¬ s ∧ a ) ∨ ( s ∧ b ) . F(s,a,b)=(\neg s\land a)\lor(s\land b). F(s,a,b)=(¬s∧a)∨(s∧b).
| s s s | a a a | b b b | F F F |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 1 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 |
输出串为00110101。后面每次改变表达式,都必须保留这八个输出。
2. 布尔代数里,两种“加法”不能混用
在整数算术中, 1 + 1 = 2 1+1=2 1+1=2;在逻辑或中, 1 ∨ 1 = 1 1\lor1=1 1∨1=1;在异或中, 1 ⊕ 1 = 0 1\oplus1=0 1⊕1=0。因此本文始终区分 ∨ \lor ∨、 ⊕ \oplus ⊕与普通加号。
最常用的几组规则是:
x ∧ x = x , x ∨ x = x , x ⊕ x = 0 , x ∨ ( x ∧ y ) = x , x ∧ ( x ∨ y ) = x , ¬ ( x ∧ y ) = ¬ x ∨ ¬ y , ¬ ( x ∨ y ) = ¬ x ∧ ¬ y . \begin{aligned} x\land x&=x,&x\lor x&=x,&x\oplus x&=0,\\ x\lor(x\land y)&=x,&x\land(x\lor y)&=x,\\ \neg(x\land y)&=\neg x\lor\neg y,& \neg(x\lor y)&=\neg x\land\neg y. \end{aligned} x∧xx∨(x∧y)¬(x∧y)=x,=x,=¬x∨¬y,x∨xx∧(x∨y)¬(x∨y)=x,=x,=¬x∧¬y.x⊕x=0,
吸收律说明,表达式中有一个条件已经保证输出为真时,再添加更严格的同类条件,不会增加新的真值点。例如 a ∨ ( a ∧ b ) = a a\lor(a\land b)=a a∨(a∧b)=a。
布尔代数还有两条分配律:
x ∧ ( y ∨ z ) = ( x ∧ y ) ∨ ( x ∧ z ) , x\land(y\lor z)=(x\land y)\lor(x\land z), x∧(y∨z)=(x∧y)∨(x∧z),
x ∨ ( y ∧ z ) = ( x ∨ y ) ∧ ( x ∨ z ) . x\lor(y\land z)=(x\lor y)\land(x\lor z). x∨(y∧z)=(x∨y)∧(x∨z).
第二条在普通加法、乘法里没有直接对应物。另一方面, ∧ \land ∧可以对 ⊕ \oplus ⊕分配,但不能随意把上式中的 ∨ \lor ∨都换成 ⊕ \oplus ⊕。
异或与与构成了模2算术:
x ⊕ y = ( x + y ) m o d 2 , x ∧ y = x y . x\oplus y=(x+y)\bmod2,\qquad x\land y=xy. x⊕y=(x+y)mod2,x∧y=xy.
因此异或特别适合处理奇偶性和“变化量”。若知道 x x x和 x ⊕ y x\oplus y x⊕y,再次异或 x x x即可恢复 y y y。
再看运算集合是否足够表达所有函数。定义与非 N ( x , y ) = ¬ ( x ∧ y ) N(x,y)=\neg(x\land y) N(x,y)=¬(x∧y),则
¬ x = N ( x , x ) , x ∧ y = N ( N ( x , y ) , N ( x , y ) ) , x ∨ y = N ( N ( x , x ) , N ( y , y ) ) . \begin{aligned} \neg x&=N(x,x),\\ x\land y&=N(N(x,y),N(x,y)),\\ x\lor y&=N(N(x,x),N(y,y)). \end{aligned} ¬xx∧yx∨y=N(x,x),=N(N(x,y),N(x,y)),=N(N(x,x),N(y,y)).
只用与非就能构造与、或、非,再借助下一节的范式表示任意布尔函数。这说明了“能够表示”,还没有说明表达式短、门电路少或者求值快;这些代价要到7.1.2继续研究。
3. 固定一个变量,得到两种不同的展开
对任意函数,固定选择变量 s s s,记
f 0 ( x ) = f ( 0 , x ) , f 1 ( x ) = f ( 1 , x ) . f_0(\boldsymbol x)=f(0,\boldsymbol x),\qquad f_1(\boldsymbol x)=f(1,\boldsymbol x). f0(x)=f(0,x),f1(x)=f(1,x).
按 s s s的两种取值分类,有布尔展开式
f = ( ¬ s ∧ f 0 ) ∨ ( s ∧ f 1 ) . f=(\neg s\land f_0)\lor(s\land f_1). f=(¬s∧f0)∨(s∧f1).
因为两个分支不会同时为真,这个式子的外层 ∨ \lor ∨也可以替换成 ⊕ \oplus ⊕。但这个替换依赖“分支互斥”,对一般DNF不成立。
还可以把 f 0 f_0 f0看成默认输出,只在 s = 1 s=1 s=1且两个分支不同时翻转它:
f = f 0 ⊕ ( s ∧ ( f 0 ⊕ f 1 ) ) . f=f_0\oplus\bigl(s\land(f_0\oplus f_1)\bigr). f=f0⊕(s∧(f0⊕f1)).
证明只需代入: s = 0 s=0 s=0得到 f 0 f_0 f0; s = 1 s=1 s=1得到 f 0 ⊕ f 0 ⊕ f 1 = f 1 f_0\oplus f_0\oplus f_1=f_1 f0⊕f0⊕f1=f1。两种展开使用相同的分支函数,但继续递归后会产生不同的表示。
3.1 满DNF:为每一个1写一项
给定一行输入 u = ( u 1 , … , u n ) u=(u_1,\ldots,u_n) u=(u1,…,un),构造一个只在该行取1的极小项: u i = 1 u_i=1 ui=1就用 x i x_i xi, u i = 0 u_i=0 ui=0就用 ¬ x i \neg x_i ¬xi,再把它们全部与起来。
把 f ( u ) = 1 f(u)=1 f(u)=1的所有行对应的极小项或起来,就是满析取范式,也常称主析取范式。对选择函数,四个真值点为 010 , 011 , 101 , 111 010,011,101,111 010,011,101,111,所以
F = ( ¬ s ∧ a ∧ ¬ b ) ∨ ( ¬ s ∧ a ∧ b ) ∨ ( s ∧ ¬ a ∧ b ) ∨ ( s ∧ a ∧ b ) . \begin{aligned} F={}&(\neg s\land a\land\neg b) \lor(\neg s\land a\land b)\\ &\lor(s\land\neg a\land b) \lor(s\land a\land b). \end{aligned} F=(¬s∧a∧¬b)∨(¬s∧a∧b)∨(s∧¬a∧b)∨(s∧a∧b).
前两项合并为 ¬ s ∧ a \neg s\land a ¬s∧a,后两项合并为 s ∧ b s\land b s∧b,恢复原式。一般DNF只要求“若干合取项的或”,不要求每项包含全部变量。
3.2 满CNF:为每一个0写一个禁止条件
对一行假输入 u u u,构造一个只在该行取0的子句。此时极性与极小项相反: u i = 0 u_i=0 ui=0用 x i x_i xi, u i = 1 u_i=1 ui=1用 ¬ x i \neg x_i ¬xi,再或起来。
选择函数的假值点为 000 , 001 , 100 , 110 000,001,100,110 000,001,100,110,因此
F = ( s ∨ a ∨ b ) ∧ ( s ∨ a ∨ ¬ b ) ∧ ( ¬ s ∨ a ∨ b ) ∧ ( ¬ s ∨ ¬ a ∨ b ) . \begin{aligned} F={}&(s\lor a\lor b) \land(s\lor a\lor\neg b)\\ &\land(\neg s\lor a\lor b) \land(\neg s\lor\neg a\lor b). \end{aligned} F=(s∨a∨b)∧(s∨a∨¬b)∧(¬s∨a∨b)∧(¬s∨¬a∨b).
按固定 s s s的两组分别化简,得到
F = ( s ∨ a ) ∧ ( ¬ s ∨ b ) . F=(s\lor a)\land(\neg s\lor b). F=(s∨a)∧(¬s∨b).
代入 s = 0 s=0 s=0,第一子句要求 a = 1 a=1 a=1,第二子句自动满足;代入 s = 1 s=1 s=1则相反。这也直接说明了CNF和选择行为的关系。
边界约定同样重要:空析取为0,空合取为1。因而恒假函数的满DNF没有项;恒真函数的满CNF没有子句。一个空子句本身则为假。
3.3 多重线性表示:用异或拼出函数
重复使用异或展开,所有变量的指数都可以压到1,因为 x i 2 = x i x_i^2=x_i xi2=xi。最终得到
f ( x ) = ⨁ S ⊆ { 1 , … , n } c S ∏ i ∈ S x i , c S ∈ { 0 , 1 } . f(\boldsymbol x)=\bigoplus_{S\subseteq\{1,\ldots,n\}} c_S\prod_{i\in S}x_i,\qquad c_S\in\{0,1\}. f(x)=S⊆{1,…,n}⨁cSi∈S∏xi,cS∈{0,1}.
这是模2的多重线性表示,也称代数范式(ANF)。空乘积等于1,负责常数项。
令 1 T 1_T 1T表示恰好在集合 T T T中的位置取1的输入。只有 S ⊆ T S\subseteq T S⊆T的单项式在该输入上为1,因此
f ( 1 T ) = ⨁ S ⊆ T c S . f(1_T)=\bigoplus_{S\subseteq T}c_S. f(1T)=S⊆T⨁cS.
反过来,
c S = ⨁ T ⊆ S f ( 1 T ) . c_S=\bigoplus_{T\subseteq S}f(1_T). cS=T⊆S⨁f(1T).
把前一个式子代入后, c U c_U cU出现 2 ∣ S ∖ U ∣ 2^{|S\setminus U|} 2∣S∖U∣次;在模2意义下,除 U = S U=S U=S外全部消去。因此系数被真值表唯一确定。
对选择函数, f 0 = a , f 1 = b f_0=a,f_1=b f0=a,f1=b,于是
F = a ⊕ ( s ∧ a ) ⊕ ( s ∧ b ) . F=a\oplus(s\land a)\oplus(s\land b). F=a⊕(s∧a)⊕(s∧b).
这里三个乘积可以同时为1。输入 111 111 111时, 1 ⊕ 1 ⊕ 1 = 1 1\oplus1\oplus1=1 1⊕1⊕1=1。如果把这三个异或换成逻辑或,输入 110 110 110就会算错。
唯一性属于这个固定的ANF表示,不属于一般DNF。满DNF虽也由真值表唯一确定,但化简后的DNF可能不唯一,长度也可能相差很大。
4. 化简的几何意义:从单个点扩大成子立方体
用*表示不关心某个变量,模式01*代表
010
,
011
010,011
010,011两个输入,对应
¬
s
∧
a
\neg s\land a
¬s∧a。模式中每删掉一个文字,就把覆盖的输入数扩大一倍。
一个合取项是 f f f的蕴涵项,意思是:只要该项为真, f f f就为真。几何上,它代表的整个子立方体必须落在 f f f的真值点集合内。
若再也不能把某个固定的0或1改成*而仍保持这个性质,该项就是基本蕴涵项,也常称素蕴涵项。这里是按包含关系“无法继续扩大”,不同基本蕴涵项的大小可以不同。
选择函数有三个基本蕴涵项:
| 模式 | 表达式 | 覆盖的真值点 |
|---|---|---|
01* | ¬ s ∧ a \neg s\land a ¬s∧a | 010、011 |
1*1 | s ∧ b s\land b s∧b | 101、111 |
*11 | a ∧ b a\land b a∧b | 011、111 |
第三项无法单独扩大,却可以从整个表达式里删掉,因为它覆盖的点已经由前两项覆盖。
( ¬ s ∧ a ) ∨ ( s ∧ b ) ∨ ( a ∧ b ) = ( ¬ s ∧ a ) ∨ ( s ∧ b ) . (\neg s\land a)\lor(s\land b)\lor(a\land b) =(\neg s\land a)\lor(s\land b). (¬s∧a)∨(s∧b)∨(a∧b)=(¬s∧a)∨(s∧b).
所以“每一项都已经是基本的”,并不意味着“整个DNF已经最短”。前者是局部扩大问题,后者还要选择哪些子立方体共同覆盖全部真值点。
在这个例子里,
010
010
010只能由01*覆盖,
101
101
101只能由1*1覆盖,因此这两项都必须保留,也已经覆盖全部真值点。这给出了两项最优的证明。一般情况下不能靠同样简单的目测解决。
单调函数为什么特殊
若逐位有 x i ≤ y i x_i\le y_i xi≤yi,总有 f ( x ) ≤ f ( y ) f(\boldsymbol x)\le f(\boldsymbol y) f(x)≤f(y),则 f f f是单调函数。增加输入中的1不会让输出从1降到0。
对单调函数,一个基本蕴涵项不可能含有 ¬ x i \neg x_i ¬xi:去掉这个负文字后,新增加的输入只是把 x i x_i xi从0改成1,单调性保证输出仍为1。这样原项就不是基本的了。
再取一个基本蕴涵项,把它要求的变量设为1,其余全设为0。任何其他基本蕴涵项若也覆盖这个输入,其正文字集合就包含于前者;除非两项相同,否则会破坏基本性。因此每个基本蕴涵项都有自己不可替代的见证输入。原书定理Q据此说明,单调函数的全部基本蕴涵项给出其最短DNF。
选择函数并不单调: 010 ≤ 110 010\le110 010≤110,却有 F ( 010 ) = 1 , F ( 110 ) = 0 F(010)=1,F(110)=0 F(010)=1,F(110)=0。它正好展示了为何一般函数会出现多余的基本蕴涵项。
5. 第一组Python:转换表示,枚举基本蕴涵项
下面采用二进制整数索引真值表:i的最高位对应第一个变量。coeff[m]表示由掩码m选中的变量乘积的ANF系数。特别是m=0对应常数1。
from itertools import product
def anf(table):
"""真值表到ANF系数;输入长度必须是2的幂。"""
size = len(table)
if size == 0 or size & (size - 1):
raise ValueError("table length must be a power of two")
if any(v not in (0, 1) for v in table):
raise ValueError("table values must be 0 or 1")
c = list(table)
for k in range(size.bit_length() - 1):
bit = 1 << k
for mask in range(size):
if mask & bit:
c[mask] ^= c[mask ^ bit]
return c
def eval_anf(coeff, assignment):
value = 0
for mask, c in enumerate(coeff):
if c and (assignment & mask) == mask:
value ^= 1
return value
def cube_points(pattern):
n = len(pattern)
return {
i for i in range(1 << n)
if all(v == "*" or int(v) == ((i >> (n - 1 - j)) & 1)
for j, v in enumerate(pattern))
}
def prime_cubes(table):
n = len(table).bit_length() - 1
if len(table) != 1 << n or any(v not in (0, 1) for v in table):
raise ValueError("invalid truth table")
ones = {i for i, v in enumerate(table) if v}
valid = {
"".join(p): cube_points(p)
for p in product("01*", repeat=n)
if cube_points(p) <= ones
}
primes = []
for p in valid:
# 一次删掉一个文字即可检验是否还能扩大。
if not any(p[:j] + "*" + p[j + 1:] in valid
for j, v in enumerate(p) if v != "*"):
primes.append(p)
return sorted(primes)
def eval_full_dnf(table, i):
return int(any(value and i == row for row, value in enumerate(table)))
def eval_full_cnf(table, i):
return int(all(i != row for row, value in enumerate(table) if not value))
choice = [a if s == 0 else b for s, a, b in product((0, 1), repeat=3)]
assert "".join(map(str, choice)) == "00110101"
assert [m for m, c in enumerate(anf(choice)) if c] == [2, 5, 6]
assert prime_cubes(choice) == ["*11", "01*", "1*1"]
checked = 0
for n in range(4):
size = 1 << n
for code in range(1 << size):
table = [(code >> i) & 1 for i in range(size)]
coeff = anf(table)
covered = set()
for p in prime_cubes(table):
covered.update(cube_points(p))
assert covered == {i for i, v in enumerate(table) if v}
for i in range(size):
assert eval_anf(coeff, i) == table[i]
assert eval_full_dnf(table, i) == table[i]
assert eval_full_cnf(table, i) == table[i]
checked += 1
print("representation checks:", checked) # 278
ANF转换对每个变量扫描一次长度 2 n 2^n 2n的表,时间为 O ( n 2 n ) O(n2^n) O(n2n),额外空间为 O ( 2 n ) O(2^n) O(2n)。循环每处理一个坐标,就把“是否包含该坐标”的两组系数做一次异或,逐步实现前面的子集求和公式。
prime_cubes故意枚举全部
3
n
3^n
3n个模式,再检查每个模式覆盖的点,适合小规模观察与核验;它不是大规模逻辑最小化工具,也没有执行最小覆盖搜索。输出基本蕴涵项后,还要另行选择覆盖。
6. Horn子句:从真假枚举变成必然结论的传播
一个Horn子句至多含一个正文字。例如
¬ a ∨ ¬ b ∨ c \neg a\lor\neg b\lor c ¬a∨¬b∨c
可以写成规则
a ∧ b ⇒ c . a\land b\Rightarrow c. a∧b⇒c.
没有正文字时,写成 a ∧ b ⇒ ⊥ a\land b\Rightarrow\bot a∧b⇒⊥,表示这个前提组合不允许出现。没有前提而结论为 a a a的规则就是事实 a a a。
先看全都有正结论的确切Horn规则。取事实
⇒ a , a ⇒ b , a ∧ b ⇒ c , c ⇒ d . \Rightarrow a,\qquad a\Rightarrow b,\qquad a\land b\Rightarrow c,\qquad c\Rightarrow d. ⇒a,a⇒b,a∧b⇒c,c⇒d.
从空集合出发,依次必须加入 a , b , c , d a,b,c,d a,b,c,d。如果另有 d ⇒ ⊥ d\Rightarrow\bot d⇒⊥,就得到矛盾。
这里每一次加入的变量都在所有满足赋值中为真:事实必须成立;若一条规则的全部前提在所有模型中为真,它的结论也必须为真。传播结束后,把加入的变量设为真,其余设为假:未触发的规则至少有一个假前提,已触发的规则已有真结论。因此只要没有触发矛盾,就得到了最小满足赋值。
原书算法C使用“尚未成立的前提数”与反向出现表。下面用队列实现同样的思想,并允许None代表矛盾结论。
from collections import deque
def horn_model(n, rules):
"""变量编号0..n-1;规则为(前提列表, 结论或None)。"""
rules = [(tuple(sorted(set(body))), head) for body, head in rules]
waiting = [[] for _ in range(n)]
remaining = [len(body) for body, _ in rules]
true = set()
queue = deque()
def infer(head):
if head is None:
return False
if head not in true:
true.add(head)
queue.append(head)
return True
for r, (body, head) in enumerate(rules):
if any(v < 0 or v >= n for v in body):
raise ValueError("invalid premise")
if head is not None and not 0 <= head < n:
raise ValueError("invalid conclusion")
for v in body:
waiting[v].append(r)
if not body and not infer(head):
return None
while queue:
v = queue.popleft()
for r in waiting[v]:
remaining[r] -= 1
if remaining[r] == 0 and not infer(rules[r][1]):
return None
return true
rules = [((), 0), ((0,), 1), ((0, 1), 2), ((2,), 3)]
assert horn_model(4, rules) == {0, 1, 2, 3}
assert horn_model(4, rules + [((3,), None)]) is None
# 只有循环,没有事实,不会凭空推出任何真变量。
assert horn_model(2, [((0,), 1), ((1,), 0)]) == set()
print("Horn propagation: passed")
每个变量最多入队一次,每个前提出现在传播阶段最多处理一次。因此在前提已去重的输入上,建表与传播为 O ( n + L ) O(n+L) O(n+L), L L L是规则总长度。代码开头的排序去重属于额外预处理,不计入这个线性传播界。
Horn还有一个语义刻画:若 u , v u,v u,v都是满足赋值,则逐位与 u ∧ v u\land v u∧v仍然满足。原书定理H进一步证明,这个闭包性质也足以刻画能够写成Horn CNF的布尔函数。
要检查必要性,考虑一条规则。如果 u ∧ v u\land v u∧v使所有前提为真,则 u u u与 v v v分别都使前提为真,因而分别都使结论为真,交集中的结论仍为真。这个解释也说明了最小模型为何自然出现。
7. 2-SAT:把一个子句变成两条有向边
另一类容易处理的公式是每个子句至多有两个文字的CNF,也就是2-SAT;原书称这类子句为Krom子句。
对于 ( u ∨ v ) (u\lor v) (u∨v),有
¬ u ⇒ v , ¬ v ⇒ u . \neg u\Rightarrow v,\qquad \neg v\Rightarrow u. ¬u⇒v,¬v⇒u.
建图时两条边都要加入。图的顶点是每个变量及其否定,共 2 n 2n 2n个。单文字子句 ( u ) (u) (u)可以写成 ( u ∨ u ) (u\lor u) (u∨u);空子句立即导致无解。
如果某个 x x x与 ¬ x \neg x ¬x属于同一个强连通分量,就有 x ⇒ ¬ x x\Rightarrow\neg x x⇒¬x以及 ¬ x ⇒ x \neg x\Rightarrow x ¬x⇒x。无论把 x x x设成哪一种值都会矛盾,因此公式不可满足。
反过来,若没有这种分量,把强连通分量缩点后得到有向无环图。每条边都有相应的逆否边;选择一个汇分量设为真、其取补分量设为假,再删除这对分量并继续,就能构造满足赋值。这给出了原书定理K的两个方向。
例如
( a ∨ b ) ∧ ( ¬ a ∨ b ) ∧ ( a ∨ ¬ b ) (a\lor b)\land(\neg a\lor b)\land(a\lor\neg b) (a∨b)∧(¬a∨b)∧(a∨¬b)
只能取 a = b = 1 a=b=1 a=b=1;再加 ( ¬ a ∨ ¬ b ) (\neg a\lor\neg b) (¬a∨¬b)就变成无解。后面程序会验证这两个例子。
下面用Kosaraju的两遍图遍历实现强连通分量。它是对定理K的独立实现;原书正文提到的是Tarjan方法。为避免大图上的Python递归深度问题,两遍遍历都使用显式栈。
def two_sat(n, clauses):
"""文字用±1..±n表示;返回0/1赋值,或None。"""
graph = [[] for _ in range(2 * n)]
reverse = [[] for _ in range(2 * n)]
def node(lit):
if lit == 0 or abs(lit) > n:
raise ValueError("invalid literal")
return 2 * (abs(lit) - 1) + int(lit < 0)
for clause in clauses:
if len(clause) == 0:
return None
if len(clause) > 2:
raise ValueError("not a 2-CNF clause")
u = node(clause[0])
v = node(clause[-1])
for a, b in ((u ^ 1, v), (v ^ 1, u)):
graph[a].append(b)
reverse[b].append(a)
seen = [False] * (2 * n)
order = []
for start in range(2 * n):
if seen[start]:
continue
seen[start] = True
stack = [(start, 0)]
while stack:
v, k = stack[-1]
if k == len(graph[v]):
order.append(v)
stack.pop()
else:
stack[-1] = (v, k + 1)
w = graph[v][k]
if not seen[w]:
seen[w] = True
stack.append((w, 0))
component = [-1] * (2 * n)
cid = 0
for start in reversed(order):
if component[start] != -1:
continue
component[start] = cid
stack = [start]
while stack:
v = stack.pop()
for w in reverse[v]:
if component[w] == -1:
component[w] = cid
stack.append(w)
cid += 1
if any(component[2 * i] == component[2 * i + 1] for i in range(n)):
return None
# 本实现的分量编号按原图的源到汇方向增长。
return [int(component[2 * i] > component[2 * i + 1])
for i in range(n)]
example = [(1, 2), (-1, 2), (1, -2)]
assert two_sat(2, example) == [1, 1]
assert two_sat(2, example + [(-1, -2)]) is None
assert two_sat(0, []) == []
assert two_sat(0, [()]) is None
print("2-SAT examples: passed")
两个遍历都是 O ( n + m ) O(n+m) O(n+m), m m m是子句数。代码最后的分量大小比较依赖编号方向:换一个SCC实现时,不能直接照搬比较符号。
这两类可满足性算法利用的是不同结构。Horn允许很长的子句,但限制正文字数量;2-SAT允许任意正负搭配,但限制每个子句的长度。把任意长子句直接拆成多个二元子句,通常会改变满足赋值,不能据此把一般SAT变成2-SAT。
8. 中数把布尔公式与几何结构连起来
三个比特的中数就是多数值:
μ ( x , y , z ) = ( x ∧ y ) ∨ ( x ∧ z ) ∨ ( y ∧ z ) . \mu(x,y,z)=(x\land y)\lor(x\land z)\lor(y\land z). μ(x,y,z)=(x∧y)∨(x∧z)∨(y∧z).
它也等于
( x ∨ y ) ∧ ( x ∨ z ) ∧ ( y ∨ z ) . (x\lor y)\land(x\lor z)\land(y\lor z). (x∨y)∧(x∨z)∧(y∨z).
固定一个参数,可以恢复与、或:
μ ( x , 0 , y ) = x ∧ y , μ ( x , 1 , y ) = x ∨ y . \mu(x,0,y)=x\land y,\qquad \mu(x,1,y)=x\lor y. μ(x,0,y)=x∧y,μ(x,1,y)=x∨y.
中数既单调,又满足自对偶性:
μ ( ¬ x , ¬ y , ¬ z ) = ¬ μ ( x , y , z ) . \mu(\neg x,\neg y,\neg z)=\neg\mu(x,y,z). μ(¬x,¬y,¬z)=¬μ(x,y,z).
因此只用变量和中数复合、不引入常数或取反,得到的函数仍然单调且自对偶。原书定理P说明反向也成立:所有单调且自对偶的布尔函数都能这样表示。允许常数0、1后,则可以表示所有单调函数。
8.1 为什么2-SAT的解对逐位中数封闭
取同一个2-CNF的三个满足赋值 A , B , C A,B,C A,B,C,对每个变量取多数,得到 M M M。假设某个二元子句 ( u ∨ v ) (u\lor v) (u∨v)在 M M M上为假,则:
- 至少两个原赋值使 u u u为假;
- 至少两个原赋值使 v v v为假。
在三个赋值里,这两组必然相交,于是至少一个原赋值同时使 u , v u,v u,v为假,与它满足该子句矛盾。否定文字同样适用,因为三个值的多数运算与逐位取反可交换。
所以2-CNF的满足赋值集合对逐位中数封闭。原书定理S证明这个条件也充分。它与Horn解集对逐位与封闭形成了有用的对照:可以通过解集的运算性质研究公式的类型。
8.2 从比特立方体到中数图
在二进制立方体中,相差一位的两个点相连。距离就是汉明距离。记 I ( u , v ) I(u,v) I(u,v)为位于 u u u到 v v v某条最短路径上的全部顶点,则对任意三点
I ( x , y ) ∩ I ( y , z ) ∩ I ( z , x ) = { μ ( x , y , z ) } , I(x,y)\cap I(y,z)\cap I(z,x)=\{\mu(x,y,z)\}, I(x,y)∩I(y,z)∩I(z,x)={μ(x,y,z)},
右边的中数按坐标计算。例如 x = 000 , y = 110 , z = 101 x=000,y=110,z=101 x=000,y=110,z=101,三个区间的唯一公共点为 100 100 100。
原因可以逐位解释:若某个坐标在两个端点中相同,最短路径不能先改掉它再改回来;若不同,则可以在两值之间选择。三个端点里总有两个坐标相同,公共点只能选多数值。
树也有类似结构:任意三点之间的三条唯一路径交于一个点。原书由此推广到中数代数和中数图,并研究怎样给图的顶点分配比特标记,使图距离对应标记的汉明距离。
在树上,删除一条边会把顶点分成两侧;选择一个根,把根一侧标0,另一侧标1。对每条边都做一次,就得到一个直接的标记方法。两点的标记在哪些位置不同,恰好对应连接它们的路径经过了哪些边。
一般中数图有四边形:同一四边形的相对边对应同一个坐标方向。原书算法H通过按距离访问顶点、识别四边形诱导的边等价类、合并这些类,再沿选定的边给顶点翻转相应标记位。忽略显式输出所有标记的代价,其讨论的运行时间界为 O ( V ( log V ) 2 ) O(V(\log V)^2) O(V(logV)2)。这里介绍其结构用途,不把上面的SCC代码当作算法H的实现。
还要区分“中数封闭集合”和“在原坐标下直接连边得到的中数图”。集合 { 00 , 11 } \{00,11\} {00,11}对逐位中数封闭,但按“只差一位才相邻”连边时,两点不连通。原书引入精简标记来处理这类冗余坐标,不能跳过这个条件。
正文随后把这种表示与比较器、反相器网络联系起来。比较器把 ( x , y ) (x,y) (x,y)变成 ( x ∧ y , x ∨ y ) (x\land y,x\lor y) (x∧y,x∨y),反相器把 x x x变成 ¬ x \neg x ¬x。在相应中数标记条件下,可以构造一个网络,把整个比特立方体映入目标集合,并让目标集合中的点保持不变。这种“映过去以后不再移动”的性质称为回缩。它说明中数结构也能落实成有限操作序列。
9. 阈函数:一条加权不等式能表示什么
用Iverson括号 [ P ] [P] [P]表示命题 P P P成立时取1,否则取0。阈函数写成
f ( x ) = [ ∑ i = 1 n w i x i ≥ t ] . f(\boldsymbol x)=\left[\sum_{i=1}^n w_ix_i\ge t\right]. f(x)=[i=1∑nwixi≥t].
与、或、奇数个输入的多数函数,分别对应“至少 n n n个1”“至少1个1”“超过半数为1”。有负权重时,把对应输入改写成它的补,可以转成非负方向的权重,同时调整阈值。
但一个阈值门不能表示所有布尔函数。例如二元异或要求
0 < t , w 1 ≥ t , w 2 ≥ t , w 1 + w 2 < t . 0<t,\quad w_1\ge t,\quad w_2\ge t,\quad w_1+w_2<t. 0<t,w1≥t,w2≥t,w1+w2<t.
中间两式推出 w 1 + w 2 ≥ 2 t > t w_1+w_2\ge2t>t w1+w2≥2t>t,与最后一式矛盾。多层阈值组合可能表示异或,这与“单个阈函数能否表示”是不同问题。
原书定理T给出一个精巧结论。对布尔函数 f f f,令
N ( f ) = ∣ { x : f ( x ) = 1 } ∣ , Σ ( f ) = ∑ f ( x ) = 1 x . N(f)=|\{x:f(x)=1\}|,\qquad \Sigma(f)=\sum_{f(x)=1}x. N(f)=∣{x:f(x)=1}∣,Σ(f)=f(x)=1∑x.
若 f f f是阈函数,而任意布尔函数 g g g满足 N ( f ) = N ( g ) N(f)=N(g) N(f)=N(g)且 Σ ( f ) = Σ ( g ) \Sigma(f)=\Sigma(g) Σ(f)=Σ(g),就一定有 f = g f=g f=g。这里只要求 f f f是阈函数。
证明可以看两个函数不同意的地方:设 A = { x : f ( x ) = 1 , g ( x ) = 0 } A=\{x:f(x)=1,g(x)=0\} A={x:f(x)=1,g(x)=0}, B = { x : f ( x ) = 0 , g ( x ) = 1 } B=\{x:f(x)=0,g(x)=1\} B={x:f(x)=0,g(x)=1}。计数相等说明 ∣ A ∣ = ∣ B ∣ = k |A|=|B|=k ∣A∣=∣B∣=k,坐标和相等说明
∑ x ∈ A x = ∑ x ∈ B x . \sum_{x\in A}x=\sum_{x\in B}x. x∈A∑x=x∈B∑x.
若 k > 0 k>0 k>0,阈函数的定义又给出
∑ x ∈ A w ⋅ x ≥ k t , ∑ x ∈ B w ⋅ x < k t , \sum_{x\in A}w\cdot x\ge kt, \qquad \sum_{x\in B}w\cdot x<kt, x∈A∑w⋅x≥kt,x∈B∑w⋅x<kt,
与前面的向量和相等矛盾。因此 k = 0 k=0 k=0。
这个结论不能无条件推广到一般布尔函数。二元异或与二元等价函数都有两个真值点,真值点的坐标和都是 ( 1 , 1 ) (1,1) (1,1),却是两个不同函数;它们都不是阈函数。
10. 对称、渠限化与计数:压缩函数的另一种方式
对称函数在任意变量置换下不变。对布尔输入而言,这等价于只依赖输入中1的个数。重量可能是 0 , 1 , … , n 0,1,\ldots,n 0,1,…,n,每个重量层独立选0或1,因此对称函数共有
2 n + 1 2^{n+1} 2n+1
个。“恰有 k k k个1”可以写成两个阈条件的组合:
[ ∑ i x i = k ] = [ ∑ i x i ≥ k ] ∧ ¬ [ ∑ i x i ≥ k + 1 ] . \left[\sum_i x_i=k\right] =\left[\sum_i x_i\ge k\right] \land\neg\left[\sum_i x_i\ge k+1\right]. [i∑xi=k]=[i∑xi≥k]∧¬[i∑xi≥k+1].
**渠限化函数(canalizing function)**则存在某个输入位置及某个取值,固定它就能使整个函数成为常数。例如 f ( x , y , z ) = ¬ x ∨ ( y ⊕ z ) f(x,y,z)=\neg x\lor(y\oplus z) f(x,y,z)=¬x∨(y⊕z),只要 x = 0 x=0 x=0,输出就必为1; x = 1 x=1 x=1时还需继续看 y , z y,z y,z。这是一种可以支持提前返回的结构。
选择函数没有这种性质。固定 s s s后仍需读取一个数据位;固定 a a a或 b b b后,也不能让所有剩余输入都产生相同输出。
自对偶函数还可以直接计数。对 n ≥ 1 n\ge1 n≥1,输入按 x x x与 ¬ x \neg x ¬x组成 2 n − 1 2^{n-1} 2n−1对;每对选择一个输出,另一个输出被迫取补。因此自对偶函数共有
2 2 n − 1 2^{2^{n-1}} 22n−1
个。这种成对约束使真值表能保存一半,但它并不保证函数单调、可写成短公式或容易求解。
原书最后分别按“固定变量名”“允许变量置换”“再允许输入输出取反”统计函数。三种计数回答的是不同问题,不应把它们混在一起。本文程序始终采用固定变量顺序。
对三个变量逐一枚举256张真值表,可以复核以下计数。这些类别相互交叠,不能把各行相加。
| 三元函数类别 | 数量 |
|---|---|
| 全部布尔函数 | 256 |
| 自对偶 | 16 |
| 单调 | 20 |
| 同时单调且自对偶 | 4 |
| 满足赋值对逐位与封闭,即可表示为Horn CNF | 122 |
| 满足赋值对逐位中数封闭,即可表示为2-CNF | 166 |
| 对称 | 16 |
| 渠限化 | 120 |
11. 本篇的核验范围与接下来的阅读
将文中的三个Python代码块按顺序放进一个文件,可直接运行,标准库即可。输出为:
representation checks: 278
Horn propagation: passed
2-SAT examples: passed
278包含零元至三元的全部布尔函数: 2 + 4 + 16 + 256 2+4+16+256 2+4+16+256。零元函数只有常数0、1,也用于检查空合取、空析取和常数ANF的边界。
配套扩展核验还独立穷举了零至两个变量的266组规范Horn规则集合、2066组规范2-CNF子句集合,以枚举满足赋值作为对照,检查有解/无解、Horn最小模型以及2-SAT返回赋值。规范化时去掉重复文字,Horn集合不计前提已经包含结论的恒真规则;2-CNF集合保留空子句与恒真子句。因此这些数字计数的是被测试的公式集合,不是不同函数的数量。
扩展核验也检查了2500个变量的蕴涵链、三维立方体的全部512组三点区间,以及上表的三元函数分类计数。它验证的是这些有限范围,不把小规模穷举代替一般证明。
回到本节的几个实用区别:
| 想解决的问题 | 本节给出的表示或结构 |
|---|---|
| 精确识别一个小函数 | 固定输入顺序的真值表 |
| 从真假输入系统地构造公式 | 满DNF、满CNF |
| 利用奇偶性与模2代数 | 唯一的多重线性表示/ANF |
| 寻找可以删掉的文字与项 | 子立方体、基本蕴涵项、覆盖 |
| 传播必须成立的结论 | Horn规则、计数器与出现表 |
| 求解二元子句约束 | 蕴涵图、强连通分量 |
| 理解解集和图的几何结构 | 逐位中数、标记与回缩 |
| 利用加权比较或交换对称性 | 阈函数、重量层表示 |
下一篇进入§7.1.2“布尔求值”:当多个表达式都表示同一个函数时,怎样比较计算步骤、复用中间结果,以及判断一个实现到底有多好。
原书阅读索引
- 书内274—281页:基本运算、恒等式、展开、范式、基本蕴涵项与单调性。
- 书内282—288页:可满足性、Horn规则及算法C、2-SAT与定理K。
- 书内288—298页:中数、自对偶性、中数图与标记、算法H、比较器—反相器网络。
- 书内298—302页:阈函数、定理T、对称函数、渠限化函数及分类计数。
以上页码属于所提供的第0册双语版中文部分;其他语言或后续合订版的页码可能不同。
326



被折叠的 条评论
为什么被折叠?



