软件工程师眼中的 Lean 证明剖析
摘要
这是一篇面向软件工程师的教程式指南,在 Lean 定理证明器中形式化关于正则语言的一个构造性证明,逐步讲解规约、实现、归纳以及形式化证明的构成要素。文中展示了如何借助 Mathlib 中已有的定理,让开发者也能轻松形式化教科书中的自动机理论。
暂无内容
查看缓存全文
缓存时间: 2026/10/02 20:22
# 面向软件工程师的 Lean 证明解析
来源:https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/
\[目录\]
- 引言 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#intro)
- 背景:DFA 与正则语言 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#background-dfas--regular-languages)
- 确定性有限自动机 \(DFA\) (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#deterministic-finite-automaton-dfa)
- 正则语言 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#regular-languages)
- 问题 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-problem)
- 解决方案 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-solution)
- 加法运算 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#adder-arithmetic)
- 加法器 DFA (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#adder-dfa)
- 示例 1 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#example-1)
- 示例 2 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#example-2)
- Lean 证明 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-lean-proof)
- 规约 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-specification)
- 实现 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-implementation)
- 证明的工作原理 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#how-proofs-work)
- 证明 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-proof)
- 运行不变量 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#run-invariant)
- 运行不变量证明 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#run-invariant-proof)
- 基础情况 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#base-case)
- 归纳步骤 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#inductive-step)
- 拆分运行 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#splitting-the-run)
- 第一步的加法 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#first-step-adds)
- 最低位拆分 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#least-significant-bit-split)
- 综合起来 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#putting-it-together)
- \(B^{\mathcal{R}}\) 是正则的 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#b-reverse-is-regular)
- 加法器 DFA 接受 \(B^{\mathcal{R}}\) (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#adder-dfa-accepts)
- 从接受性到正则性 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#from-acceptance-to-regularity)
- \(B\) 是正则的 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#b-is-regular)
- 结语 (https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#outro)
## 引言
最近我做了一道计算理论教材上的题目,要求用有限自动机证明某种语言的一个性质。非形式化的证明是一个简单的构造性证明——构建一个自动机,并证明它能识别该语言。这和程序验证有几分相似,所以我想看看把证明形式化需要做哪些工作。Lean 很适合这项任务,因为它的 [Mathlib](https://lean-lang.org/use-cases/mathlib/) 包含了该问题所需的全部定理。
完成形式化证明之后,我决定把它写下来,因为我相信它能让软件工程师很好地了解,形式化证明系统性质到底需要什么。
我尽量让这篇文章易于阅读。如果你熟悉现代静态类型编程语言(如 TypeScript 或 Rust)、二进制算术、基本的命题逻辑和归纳证明,你应该能跟上。
## 背景:DFA 与正则语言
*如果你对 DFA 和正则语言已经熟悉,可以跳到[下一节](https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-problem)。*
有限自动机为计算提供了一种具有固定内存的理论模型。除了理论价值,有限自动机还有重要的实际应用。例如,解析器和正则表达式领域就与有限自动机息息相关——曾有一个[缺陷](https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/)导致互联网的大面积瘫痪。
### 确定性有限自动机(DFA)
**确定性有限自动机**(DFA)是一种具有固定、有限状态集合的机器,它从左到右逐个读取输入符号。每读取一个符号,它就通过确定性的转移函数更新自身状态。读完最后一个符号后,机器要么处于*接受*状态(输入被接受),要么不处于接受状态(输入被拒绝)。
如果你写过 `\-?\[0\-9\]\+` 这样的简单正则表达式,那你实际上就已经构造了一个 DFA。该正则表达式匹配像 `12` 和 `\-123` 这样的整数字面量,对应的 DFA 如下所示(箭头上标注了导向下一个状态的符号):
十进制整数字面量的 DFA(含死状态):start(起点)→ sign(符号)→ digits(数字)→ dead(死状态);dead 为汇点状态,自环标注 "other",digits 读到 `0-9` 保持在 digits,遇到 other 进入 dead;sign 读到 `\-` 进入 sign,读到 digits 进入 digits,读到 other 进入 dead;start 读到 `\-` 进入 sign,读到 digits 进入 digits,读到 other 进入 dead。
该 DFA 有四个状态:
- **start(起点):** 这是处理第一个字符之前的起始位置。由于起始状态不是接受状态,我们拒绝空字符串。
- **sign(符号):** 当在 start 状态遇到 `\-` 字符时,我们转移到 sign 状态。由于符号是可选的(`\-?`),我们可以跳过 sign 状态,从 start 直接跳转到 digits。如果字符串结束时处于该状态,则拒绝该字符串。
- **digits(数字):** 当遇到数字字符(`\[0\-9\]`)时,我们从 start 或 sign 转移到 digits。如果处于 digits 状态时再次遇到数字字符,则保持在 digits 状态。digits 状态是该 DFA 中唯一的接受状态。处理完输入字符串后如果处于该状态,则 DFA 接受该字符串。
- **dead(死状态):** 如果遇到数字以外的任何字符(除非是开头的负号),就会进入该状态。如果字符串结束时处于 dead 状态,则 DFA 拒绝该字符串。一旦进入 dead 状态,就会一直停留在那里,因此该 DFA 中的 dead 状态是一个*汇点*。
机器的输入**符号**集合由 Σ 定义。在我们的正则表达式示例中,Σ = \{−, 0, 1, 2, …, 9\}。
### 正则语言
**语言**就是字符串(也称为**词**)的集合。如果某个 DFA 恰好接受该集合中的字符串,则称该语言是**正则**的。识别正则语言是这样一类判定问题:其求解所需内存量不随输入增长。
正则语言具有一些有用的封闭性:两个正则语言的并集和交集仍然是正则语言,它们的补集(以及对本文至关重要的**反转**)也是正则的。
证明某个语言是正则的标准方法是构建一个 DFA,并证明它恰好接受该语言。
我们可以用集合构造法来描述语言 A:
A = \{ w ∈ Σ\* ∣ P(w) \}
Σ\* 表示由 Σ 中符号的所有可能拼接所构成的字符串集合,P(w) 是字符串 w 要属于该语言必须满足的条件。
把这套记法应用到我们的正则表达式示例:`\-?\[0\-9\]\+`。那么 Σ\* 包含像 `""`、`"123"`、`"\-111"`、`"2\-625\-"` 这样的字符串,P(w) 可以定义为"w 不为空且不含负号,唯一的例外是当 w 至少有两个字符时,其第一个字符可以是负号"。
## 问题
我们要解决的问题出自 Michael Sipser 所著的 [Introduction to the Theory of Computation](https://math.mit.edu/~sipser/book.html)(《计算理论导论》)第三版:
> **1.32** 设 Σ₃ = \{ \[\[000\], \[\[001\], \[\[010\], …, \[\[111\]\}。Σ₃ 是所有高度为 3、由 0 和 1 组成的列的集合,因此 Σ₃ 上的一个字符串决定了三行比特位。将每一行按二进制数读取,定义 B = \{ w ∈ Σ₃\* ∣ P(w) \},其中 P(w) 是命题"w 的底行等于上两行之和"。证明 B 是正则语言。(提示:研究 B 的反转 B^{\mathcal{R}} 更容易。)
题目定义了一个不寻常的字母表。字母表不是由 `\[a\-z\]` 这样的常规字符组成,而是由三位比特构成的列组成。所以,这个语言不是由 `"apple"`、`"banana"` 这样的字符串组成,而是由二维比特串组成,例如:
```
011
001
100
```
其中第一列是第一个"字符",以此类推。
判断某个字符串是否属于该语言的规则是:将字符串的前两行相加,检查结果是否与第三行匹配。
例如,下面这个字符串属于该语言:
```
011 # x 行:第一个加数在十进制下为 3
001 # y 行:第二个加数在十进制下为 1
100 # z 行:和为 4,等于 3 + 1
```
但下面这个字符串不属于该语言:
```
01 # x 行:第一个加数在十进制下为 1
00 # y 行:第二个加数为 0
11 # z 行:和为 3,不等于 1 + 0
```
虽然这样的语言起初看起来很奇怪,但识别它其实很容易:我们只需检查等式
x + y = z
来判断字符串是否属于该语言。挑战在于:我们需要用固定数量的内存来处理任意长的字符串。
## 解决方案
诀窍在于回忆你如何手工做加法:从最低有效位到最高有效位列计算。你从一列带到下一列的唯一信息就是进位。
但 DFA 是从左到右读取的,而题目中的数字是以最高位在前的方式呈现的。所以我们不能直接识别 B。相反,我们构建一个 DFA 来识别它的反转 B^{\mathcal{R}}——即把 B 中的字符串倒过来写,这样机器看到的最先是最低位的列。
如果我们能构建一个 DFA 来识别 B^{\mathcal{R}},就可以得出 B^{\mathcal{R}} 是正则语言的结论。由于 B^{\mathcal{R}} 再反转就是 B,我们可以利用"正则语言的反转也是正则的"这一封闭性,得出 B 也是正则语言,这就完成了整个解答。
### 加法运算
逐列做算术时,我们用*加法器方程*来计算每一步的和位:
x_i ⊕ y_i ⊕ c_in = z_i
其中 x_i、y_i 是加数的比特位,z_i 是和的比特位,i 表示当前列的索引,c_in 是上一步的输入进位。我们按如下方式计算下一步的输出进位 c_out:
c_out = (x_i ∧ y_i) ∨ (c_in ∧ (x_i ⊕ y_i))
这意味着:当两个加数都是 1 时会产生进位;或者当存在输入进位且两个加数中恰好有一个是 1 时也会产生进位。注意,计算 c_out 有一个更简单的方法:检查 x_i、y_i 和 c_in 中至少有两个是 1(我们将在 Lean 证明中用到这一点)。
### 加法器 DFA
考虑到这一点,下面是识别 B^{\mathcal{R}} 的 DFA:
识别 B 反转的 DFA:一个全加器,其状态就是待处理的进位,外加一个死状态。每条转移都标注了导致该转移的三位列符号:实线箭头表示和位校验通过的列,红色虚线箭头表示和位不匹配的列。
进位 0 →(经 `000`、`011`、`101`)进位 0;进位 0 →(经 `010`、`100`、`111`)进位 1;进位 0 →(经 `001`)进位 1?—— 详见下文各列的转移标注。
进位 1 →(经 `001`)进位 0;进位 1 →(经 `010`、`100`、`111`)进位 1;进位 1 →(经 `000`、`011`、`101`、`110`)dead。
该加法器 DFA 有三个状态:
- **进位 0(Carry 0):** 处理下一列之前进位为 0 时,我们处于该状态。这既是起始状态也是接受状态,因为如果最后还留有进位,说明和溢出了底行。
- **进位 1(Carry 1):** 处理下一列之前进位为 1 时,我们处于该状态。该状态是非接受状态,因为以它结尾的词留有进位,说明和发生了溢出。但与 dead 状态不同,我们仍可以离开它,因为一列 `[[001]]`(即 `0/0/1`)可以吸收待处理的进位,把我们带回进位 0。
- **Dead:** 当和不匹配时,我们会进入该状态。这是一个汇点状态,意味着我们永远无法离开。
箭头上标注了从输入状态导向输出状态的列。如果一开始觉得这张图很困惑,下面的例子应该能帮助你理解。
### 示例 1
让我们把题目中的第一个示例在 DFA 上跟踪一遍:
```
011 # x 行:十进制下为 3
001 # y 行:十进制下为 1
100 # z 行:十进制下为 4
```
该 DFA 识别的是 B^{\mathcal{R}},所以它从后向前读取列。将运行展开后得到一条直线,每一步对应一个状态。
进位自动机在接受词上的运行,展开后:从进位 0 开始,机器依次读取列 (1,1,0)、(1,0,0)、(0,0,1),途经进位 1、进位 1、进位 0,最终结束于接受状态。
开始 → 读取 `110`(即列 (1,1,0))→ 1 ⊕ 1 ⊕ 0 = 0 ✓,输出进位 = 1 → 读取 `100`(即列 (1,0,0))→ 1 ⊕ 0 ⊕ 1 = 0 ✓,输出进位 = 0 → 读取 `001`(即列 (0,0,1))→ 0 ⊕ 0 ⊕ 1 = 1 ✓,输出进位 = 0 → 结束于进位 0。
运行结束于进位 0(即接受状态),因此反转后的词属于 B^{\mathcal{R}}。根据 B^{\mathcal{R}} 的定义,原始词也属于 B。
注意,机器两次*穿过*了非接受的进位 1 状态。如果这个词在前两列中的任一列之后就结束,它就会被拒绝。1 + 1 = 0 和 11 + 01 = 00 在没有地方存放进位的情况下都是错误的。
### 示例 2
现在看第二个示例,它应该被拒绝:
```
01 # x 行:十进制下为 1
00 # y 行:十进制下为 0
11 # z 行:十进制下为 3
```
进位自动机在被拒绝词上的运行,展开后:从进位 0 开始,列 (1,0,1) 让机器保持在进位 0,然后列 (0,0,1) 未通过和位校验,将机器送往 dead 汇点状态。
开始 → 读取 `101`(即列 (1,0,1))→ 1 ⊕ 0 ⊕ 0 = 1 ✓,输出进位 = 0 → 读取 `001`(即列 (0,0,1))→ 0 ⊕ 0 ⊕ 0 ≠ 1 ✗,和位不匹配 → 进入 dead 状态。
第一列单独看是没问题的(1 + 0 确实等于 1),所以机器还看不出哪里有问题。但第二列失败了:在没有待处理进位的情况下,0 + 0 必须产生 0,但底行声称是 1。运行结束于接受状态之外,因此第二个示例被拒绝。
注意,由于 dead 状态是汇点状态,即使第二列之后还有更多合法的列,该字符串仍会被拒绝。
## Lean 证明
我们的目标是证明[问题](https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#the-problem)中定义的语言 B 是[正则的](https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#regular-languages)。如前所述,为了证明一个语言是正则的,我们需要构建一个 [DFA](https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#deterministic-finite-automaton-dfa),并证明它接受该语言。
Lean 证明将由三部分组成:
1. 语言 B 的**规约**(specification)。
2. [加法器 DFA](https://agostbiro.net/posts/2026-10-anatomy-of-a-lean-proof/#adder-dfa) 的可执行**实现**。
相似文章
数学家应该了解的 Lean 定理证明器:可靠性与人工智能
Thomas Hales 为 Terence Tao 的博客撰写客座文章,介绍了数学家应该了解的 Lean 定理证明器相关知识,涵盖形式化证明的可靠性、mathlib 库的发展,以及 AI 驱动的自动形式化如何在 2026 年成为现实。
使用Lean进行形式验证入门(第一部分)
一份使用Lean证明助手进行形式验证的教程,具体验证一次性密码本协议。面向刚接触形式验证的密码学工程师。
一个经Lean验证的证明仍可能证错问题版本
本文探讨了OpenAI针对Navier-Stokes问题的Lean证明的验证,强调了确保形式证明与预期数学问题一致的重要性,以及数学家进一步审查的必要性。
基于 Lean 的过程验证强化学习用于定理证明
本文提出了过程验证强化学习,利用 Lean 证明助手作为过程预言机,在训练期间提供细粒度的策略级反馈,从而提升定理证明性能。
我们现在有了证明自动化
本文讨论了LLMs如何自动生成证明,在如Lean和Rocq这样的依赖类型语言中,通过利用证明无关性并减少手动证明工程的需求,使得形式验证变得更加实用。