上一篇:(三)怎么证明它是对的——讲怎么确认程序是对的。 这一篇:讲有些事,程序根本做不了。
耗时:读完约 45 分钟,动手约 1.5 小时。
开头:先给你一个"不可能"的任务
写一个程序,它读入任意另一个程序,然后回答:
这个程序会停下来吗?
这个任务有个名字,叫停机问题(Halting Problem)。
直觉上你觉得它能做——“我跑一遍不就知道了吗?“但如果那个程序是:
while True:
pass
你跑它,它不停。可是你等了 10 分钟,它还在跑——你怎么知道它是"永远不会停”,还是"再等 1 分钟就停”?
你永远无法确定。 因为可能有个程序要跑 10¹⁰⁰ 步才停。你把等待上限设成多少都没用。
这一篇要做的,是把"我觉得做不了"变成"数学上证明做不了"。而且这个证明会一路推出一个惊人的结论:
能计算的函数,在数学上存在的所有函数里,占比是 0。
也就是说,绝大多数数学上定义清楚的问题,根本没有任何程序能解决。
这不是算力不够,不是程序员不行,不是 AI 还不够强。这是数学上的不可能,跟"2 是偶数还是奇数"一样确定。
一、先建立直觉:两个无穷,“一样大"吗
要理解停机问题,得先理解一件事:无穷和无穷不一样大。
这反直觉。你会觉得"无穷就是无穷”。但康托尔(Cantor)证明了:有些无穷,比别的无穷严格更大。
可数:能排成一列的无穷
自然数 0, 1, 2, 3, ... 是最小的一种无穷,叫可数无穷。
关键判断标准:能不能把全体元素排成一列(编号 0, 1, 2, 3, …),一个不漏。
整数和自然数一样多
看起来整数比自然数多一倍(多了负数),其实一样多。配对:
| 自然数 n | 整数 |
|---|---|
| 0 | 0 |
| 1 | −1 |
| 2 | 1 |
| 3 | −2 |
| 4 | 2 |
| 5 | −3 |
| … | … |
n 是偶数 → n/2
n 是奇数 → −(n+1)/2
每一个自然数,恰好对应一个整数,一个不重、一个不漏。 所以两者"一样多"。
(实测:n=0..11 配对到 0, −1, 1, −2, 2, −3, 3, −4, 4, −5, 5, −6,回代无误。)
有理数也和自然数一样多
有理数是"两个整数之比"。它稠密——任意两个有理数之间还有无穷多个有理数。你会觉得它"更多"。
但它也可数。方法叫康托尔配对(Cantor pairing):把分数 (分子, 分母) 排成对角线:
0/0 → 0
0/1 → 2 1/0 → 1
0/2 → 5 1/1 → 4 2/0 → 3
0/3 → 9 1/2 → 8 2/1 → 7 3/0 → 6
...
每个正有理数都能拿到一个唯一的自然数编号。 所以有理数也可数。
到这里,你可能会以为"无穷就是无穷,都一样"。别急,下一个就翻了。
二、康托尔的对角线:实数不可数
现在问:实数(0 到 1 之间的所有小数)可数吗?
康托尔说:不可数。 他用了一个漂亮到惊人的方法——对角线法。
证明(反证法):假设 0 到 1 之间的实数可以排成一列,编号 0, 1, 2, …。把它每个写成无限小数,摆成一个表:
第 0 个: 0.a₀₀ a₀₁ a₀₂ ...
第 1 个: 0.a₁₀ a₁₁ a₁₂ ...
第 2 个: 0.a₂₀ a₂₁ a₂₂ ...
...
现在取对角线上的数字 a₀₀, a₁₁, a₂₂, ...,把每一个都改一下(比如 0 改 1,非 0 改 0),得到一个新数:
0.(改a₀₀)(改a₁₁)(改a₂₂)...
这个新数不在表里。 因为它跟第 0 个数在第 0 位不同,跟第 1 个数在第 1 位不同,跟第 n 个数在第 n 位不同……
它跟表里的每一个数都不一样。 所以这个"列出了所有实数"的表,漏掉了至少一个。
矛盾。 所以实数不可数。
我用 0/1 序列实测了一遍(原理相同,数字换成 0/1):
第 0条: 1101000011... 对角线第0位=1 翻转=0 ← 第0位不同
第 1条: 0011000011... 对角线第1位=0 翻转=1 ← 第1位不同
第 2条: 0010110111... 对角线第2位=1 翻转=0 ← 第2位不同
...
翻转后的序列: 0100001111
它跟第 0 条不同、跟第 1 条不同、跟每一条都不同。
康托尔对角线法一句话:给定任何"声称列全了"的列表,我都能造出一个不在列表里的东西。
这个"我都能造出一个"的技巧,接下来要再出场两次——它是整个"不可能"系列的核心武器。
三、程序是可数的,函数是不可数的
现在把刚才的结论用到计算机上。
程序:可数
一个程序,本质是有限长的字符串(一堆字符)。字符串可以唯一编码成自然数:
"while True: pass" → 158720268612639616323219323269696025459(39 位)
这叫哥德尔编号。所以:
所有程序可以排成一列:程序₀, 程序₁, 程序₂, … 程序的数量 = 可数无穷。
(有限长字符串按"长度 + 字典序"枚举,长度 0 有 1 个,长度 1 有 2 个,长度 2 有 4 个……全部有限长字符串 = 可数。)
函数 N→N:不可数
一个"函数",是把每个自然数映射到一个自然数的规则。这种规则有多少?
用对角线法:假设所有函数 f₀, f₁, f₂, ... 排成一列。构造:
g(x) = fₓ(x) + 1
g 跟 f₀ 在点 0 不同,跟 f₁ 在点 1 不同……跟每一个 fᵢ 至少在一个点上不同。
所以 g 不在列表里。函数不可数。
(实测:g(3) = f₃(3)+1 = 13,而 f₃(3) = 12,确实不同。)
两边一拼,结论出来了
程序的数量 = 可数
函数的数量 = 不可数
所以:函数远比程序多。
绝大多数函数,没有任何程序能计算它。 能算的东西,在数学上存在的东西里,占比是 0(用测度的语言说)。
这不是"还没发明出算法",是"不可能存在算法"。 因为程序根本不够用——函数有不可数多个,程序只有可数多个,你怎么排都不可能一一对应上。
四、停机问题:正式证明
现在可以证明开头那个问题了。
要证明的
不存在一个程序 H(P, x),能对任意程序 P 和输入 x,正确回答"P 在 x 上是否停机"。
证明(反证法 + 对角线)
假设存在这样的 H。
用 H 构造一个新程序 G:
G(x):
if H(G, x) == "会停":
while True: pass # 偏不停
else:
return 0 # 偏停
现在问一个致命的问题:G(G) 会停吗?
- 如果
H(G, G)说"会停",那么 G(G) 进入while True,不停。→ H 错了。 - 如果
H(G, G)说"不停",那么 G(G) 直接return,停了。→ H 又错了。
两种情况下,H 都错。
所以假设"存在完美的 H"推出矛盾。结论:H 不存在。
这一步为什么这么强
注意这个证明的结构:它没有说"H 很难写",它说的是"H 在逻辑上不可能存在"。
因为它用了一个"自指"——让程序 G 去问 H"我会怎么样",然后反着做。
这个"自指 + 反着做"的技巧,就是康托尔对角线的程序版。
康托尔造一个"不在列表里的数",图灵造一个"让 H 无法判断的程序"。同一个武器,两个战场。
为什么"跑一遍"不行
你可能会说:“我可以设个超时,跑 10 秒没停就算它不停。”
问题:10 秒只是你选的任意上限。
- 有些程序 10 秒内停,你能正确判断"停"
- 有些程序 10 秒内没停,但 11 秒时停 → 你误判了
- 有些程序永远不停,你永远等不到
“步数上限"这个方法,永远只能回答"上限内停了"或"上限内没停”,它永远无法区分"上限内没停"和"永远不会停"。
我实测了一个天真的判断器:
程序1(有限求和): 3 秒内停了 → 判断"会停" ✅
程序2(while True): 3 秒内没停 → 判断"不停机(上限内没停)" ← 它其实没说"永远不会停"
看到区别了吗:它只能诚实地说"上限内没停",不能真的证明"永远不停"。 你把上限加到 10 年,问题原样存在,只是推迟了。
五、这跟 AI 有什么关系
这是这篇最想说的一句话:
停机问题的不可判定性,对任何"智能"都成立。AI 也不例外。
因为证明里没用任何关于"人类"“程序"“图灵机"的特殊性质。它只说了一件事:
只要你的判断器能用程序表达(能输入、能输出、按规则运行),就能用"自指"构造出让它自相矛盾的问题。
AI 再强,它也是一个程序(或者等价于程序的东西)。所以同样存在 AI 永远回答不了的问题。
这不是说 AI 没用。这是说:“无所不能的 AI"在数学上是不存在的。 跟"永动机在物理上不存在"一样,是硬约束,不是暂时的技术瓶颈。
理解这一条,你就不会再被"AI 会不会取代一切"这种问题带偏。 会取代的是"能算的那部分”。不能算的那部分,从数学上就没人能取代,包括人自己。
六、Rice 定理:不是停机问题一个,是一整片
停机问题只是开头。Rice 定理把它推广成一个通用结论:
程序行为的任何"非平凡"性质,都不可判定。
“非平凡” = 有的程序有这个性质,有的程序没有。
这意味着下面这些问题全部不可判定(不存在任何算法):
| 你想问的 | 可判定吗 |
|---|---|
| 这个程序会停吗? | ❌ 停机问题 |
| 这个程序会不会打印出 “hello”? | ❌ |
| 这个程序会不会崩溃? | ❌ |
| 这个程序有没有 bug? | ❌ |
| 这个程序是不是永远返回正数? | ❌ |
| 两个程序是不是干同一件事? | ❌ |
| 这段代码里有没有死代码? | ❌ |
注意"这个程序有没有 bug"也在里面。 这是最扎心的一条——它意味着:不存在一个能检查所有 bug 的完美工具。
(这不是说静态分析、测试、lint 没用。它们是"可判定的子集"上做近似——它们能查出一部分问题,但永远查不出全部,而且这个"查不全"是数学保证的,不是工具不够好。)
对照:哪些是可判定的
不是所有问题都不可判定。下面是可判定的(有确定答案的算法):
| 问题 | 可判定吗 |
|---|---|
| 一个数是不是素数 | ✅ |
| 两个正则表达式是否等价 | ✅ |
| 一个命题逻辑公式是否可满足(SAT) | ✅(虽然可能很慢) |
| 图灵机在 n 步内会不会停 | ✅(有限模拟 n 步) |
看出规律了吗:
- 加一个"有限"限制,往往就可判定了。(“n 步内"就是有限)
- 去掉"有限”、问"任意"情况,往往就不可判定了。(“任意输入"“任意步数”)
这是整个计算理论的一条暗线:有限的世界可判定,无限的世界常常不可判定。 这跟第二篇的"离散 vs 连续"是同一个思想在更高一层的回声。
七、忙海狸:一个"定义明确但算不出来"的数
停机问题还有一个惊人的副产品,叫忙海狸函数(Busy Beaver)。
定义:BB(n) = 一个 n 状态的图灵机,在停机之前,最多能走多少步。
定义完全清楚,没有任何模糊。但它的值:
BB(1) = 1
BB(2) = 6
BB(3) = 21
BB(4) = 107
BB(5) ≥ 47,176,870
BB(6) ≥ 10^36534
BB(6) 已经比宇宙里的原子总数(约 10⁸⁰)还大得多。
关键性质:
BB(n) 增长得比任何可计算函数都快。
为什么?因为如果 BB(n) 可计算,你就能用它解停机问题(模拟 n 状态图灵机 BB(n) 步,停了就是会停,没停就是永不)。而停机问题不可解。所以 BB(n) 不可计算。
BB(n) 是"存在、明确、但永远算不出来"的完美例子。
它帮你理解一个深刻的区分:
- 可计算 ≠ 被定义
- 一个东西可以定义得清清楚楚,但没有程序能算出它
八、三层"不可能”,回顾整个专题
到这里,四篇讲完了一条完整的线。把它们叠起来看:
| 篇 | 讲的是 | 结论 |
|---|---|---|
| (一) | 一个数 | 数是格点,会溢出、会漂 |
| (二) | 一堆东西 | 离散化有确定规律,可算可防 |
| (三) | 对错 | 测试覆盖有限,证明覆盖全部 |
| (四) | 能不能算 | 有些东西根本没有程序能算 |
注意第三篇和第四篇的关系,这是一个漂亮的递进:
- 第三篇说:测试覆盖不了所有情况——但至少,程序存在,你可以证明它对。
- 第四篇说:有些函数,程序根本不存在,你想证也没东西可证。
第三篇是"难”,第四篇是"不可能”。这是两个量级。
所以整个"计算机数学"的骨架,可以收成一句话:
先搞清楚"能不能算"(第四篇),再搞清楚"算得多快"(下一篇),然后才谈得上"算得对不对"(第三篇)。 顺序反了,就是白忙。
九、动手:五个实验
实验 1:对角线造一个"不在列表里"的数
import random
random.seed(5)
seqs = [[random.randint(0,1) for _ in range(20)] for _ in range(8)]
翻转 = [1 - seqs[i][i] for i in range(8)]
print("8 条 0/1 序列,对角线翻转后:")
print("".join(map(str, 翻转)))
for i in range(8):
same = seqs[i][i] == 翻转[i]
print(f" 第 {i} 条的第 {i} 位 = {seqs[i][i]}, 翻转后 = {翻转[i]} → {'相同' if same else '不同'}")
print("→ 它跟每一条都在对角线位置不同,所以不在列表里")
亲手看到"造出来的那个不在列表里",你就理解了康托尔。
实验 2:自然数 ↔ 整数 配对
def 自然数转整数(n):
return n//2 if n % 2 == 0 else -(n+1)//2
for n in range(12):
print(f"自然数 {n:2d} ↔ 整数 {自然数转整数(n):3d}")
看到负数也被一一配到了自然数,你就理解"可数"了。
实验 3:程序可数、函数不可数
# 程序可数:有限长字符串能编号
s = "while True: pass"
print(f"'{s}' → {int.from_bytes(s.encode(), 'big')}")
# 一个 39 位的数 —— 每个程序都有一个编号
# 函数不可数:对角线
fs = [lambda x, i=i: i*x + i for i in range(20)]
g = lambda x: fs[x](x) + 1
print(f"g(3) = {g(3)}, 但 f_3(3) = {fs[3](3)} → 不同")
print("g 跟每个 f_i 至少一点不同 → 函数不可数")
实验 4:自指程序打败任何判断器
# 停机问题的核心:自指 + 反着做
# 假设有一个判断器 H(程序, 输入),返回"会停"或"不停"
# 构造 G:如果 H 说我会停,我就死循环;否则我停
# 则 H(G, G) 无论怎么答都错。
# 用文字形式走一遍逻辑:
print("G(G) 停机 ⟺ H(G,G) 说'不停' ⟺ G(G) 不停机 ← 自相矛盾")
print("所以 H 不存在")
实验 5:忙海狸为什么不可计算
# BB(n) 增长比任何可计算函数都快
print("BB(1..6) ≈ 1, 6, 21, 107, ≥4.7×10^7, ≥10^36534")
print("宇宙原子数 ≈ 10^80")
print("BB(6) = 10^36534 >> 10^80")
print("如果 BB 可计算 → 能解停机问题 → 矛盾 → 所以不可计算")
十、带走的东西
一句话:
能算的东西是少数——程序只有可数无穷个,函数有不可数无穷个,所以绝大多数问题没有程序能解,而且这是数学保证的,不是技术不够。
三句话:
- 无穷分大小:自然数、整数、有理数都可数(一样多),实数不可数(严格更多)。判断标准是"能不能排成一列"。
- 停机问题不可判定:用"自指 + 反着做"(康托尔对角线的程序版)可以证明,不存在能判断所有程序是否停机的程序。
- Rice 定理是通用版:程序行为的任何非平凡性质都不可判定——包括"有没有 bug"。所以不存在完美 bug 检查器,这不是工具不行,是数学不行。
一条关于 AI 的结论(这篇最想让你记住的)
“无所不能的 AI"在数学上不存在。 停机问题对任何"能用程序表达的判断器"都成立,AI 也不例外。 会取代的是"能算的那部分”;不能算的那部分,人取代不了,AI 也取代不了。
下一篇
(五)算得多快——这一篇其实在 /cs/analysis/ 已经讲过一部分(大 O 和复杂度),所以这里不重复。
我会写的是它没覆盖的那块:P vs NP、为什么"验证一个解"和"找到一个解"是两件完全不同的事、以及为什么这个问题值 100 万美元。
想先看哪条线,你定:直接写(六)概率,还是补一篇(五)的 P/NP 深度版?
深水区
- 计算理论主干:Michael Sipser《Introduction to the Theory of Computation》——停机问题、可判定性、复杂度全在里面,是标准教材。
- 停机问题原始论文:Alan Turing, On Computable Numbers, with an Application to the Entscheidungsproblem(1936)。这一篇奠定了整个计算机科学,值得知道它存在,哪怕不读原文。
- 康托尔对角线:Georg Cantor 1891 年那篇关于实数不可数的短文。是"对角线法"的起点。
- Rice 定理:Henry Gordon Rice 1953 年的论文,一句话把停机问题推广成一大片不可判定性。
这些我不做页码引用——不同版本页码不同,写了就是骗你。按书名和章节找。
附:本篇所有数字的来源
全部本机跑出来:Ubuntu / Python 3.8 / x86-64。
- 康托尔对角线:8 条随机 0/1 序列,翻转后跟每一条都在对角线位置不同(构造成功)
- 自然数↔整数配对:n=0..11 ↔ 0,−1,1,−2,2,−3,3,−4,4,−5,5,−6(回代无误)
- 康托尔配对函数:
(a,b) → (a+b)(a+b+1)/2 + b,把正有理数一一编号 - 函数对角线:
g(x)=fₓ(x)+1,g(3)=13 vs f₃(3)=12,确实不同 - 程序哥德尔编号:
"while True: pass"→ 158720268612639616323219323269696025459(39 位) - 停机判断器演示:有限求和程序 3 秒内停;
while True3 秒内没停(只能回答"上限内没停",无法证明"永不") - 忙海狸:BB(1..6) = 1, 6, 21, 107, ≥47,176,870, ≥10^36534;宇宙原子数 ≈ 10⁸⁰
数字中忙海狸的已知值来自公开文献(BB(1)–BB(4) 已严格确定,BB(5) 于 2024 年确定为 47,176,870,BB(6) 是下界)。 其余是实测。
你自己机器上跑出来的数字会略有不同。差一点不影响结论——结论是结构性的。