香港科技大学程序形式化推理笔记-全-

香港科技大学程序形式化推理笔记(全)

001:操作语义

概述

在本节课中,我们将学习如何为程序定义形式化语义。我们将从一个简单的编程语言开始,理解其语法语义的区别,并重点学习操作语义的定义方法。操作语义通过描述程序执行的每一步来赋予程序意义。我们将通过定义规则来形式化描述程序状态如何转换,并最终尝试手动证明一个简单程序的终止性,以体会形式化推理的过程与挑战。

语法与语义

每个程序都包含两个部分:语法语义

  • 语法指的是程序作为字符串的书写形式,即你在编辑器中输入的代码。
  • 语义指的是程序的含义,即如何执行程序、程序具有哪些性质。

本课程将花很少时间讨论语法,而将重点放在如何形式化地定义语义上。我们需要在人类可理解和机器可自动化推理之间找到平衡。

一个简单的编程语言

为了开始,我们设计一个最简单的命令式编程语言。它的语法由以下规则定义:

  • 程序 (P) 可以是:

    • skip:一个不执行任何操作的语句。
    • x := E:将表达式 E 的值赋给变量 x
    • P; Q:顺序执行程序 PQ
    • if E then P else Q end:如果表达式 E 为真(非零),则执行 P,否则执行 Q
    • while E do P end:只要表达式 E 为真,就重复执行 P
  • 表达式 (E) 可以是:

    • 常量 c(整数)。
    • 变量 x
    • 算术运算:E1 + E2, E1 - E2, E1 * E2, E1 / E2, E1 % E2
    • 比较运算:E1 > E2 等。
    • 逻辑运算:E1 and E2 等。

我们假设所有变量都是整数类型,且取值范围无界(不考虑溢出)。表达式应具有正确的括号,但在书写时可以省略,默认已正确括号化。

任何能由该语法生成的字符串都是一个有效的程序。目前,这些程序还没有任何含义。

为什么需要形式化语义?

即使对于如此简单的语言,形式化语义也是必要的,原因如下:

  1. 消除歧义:不同的编程语言对相同语法结构可能有不同解释。例如:
    • 变量初始化:未初始化的变量值是未定义的、零还是会导致错误?
    • 短路求值:在 if (condition1 and condition2) 中,如果 condition1 为假,还会计算 condition2 吗?
    • 类型转换与溢出:当运算结果超出类型范围时会发生什么?
    • 副作用:函数调用是否会修改外部状态?
    • 除零错误:除以零时程序是崩溃、抛出异常还是忽略?

  1. 编译器验证:我们需要证明编译器生成的代码 (P‘) 与源代码 (P) 在语义上等价。优化也必须在不改变程序语义的前提下进行。

因此,我们需要一个精确的、数学化的语义定义,以便进行严格的推理。

表达式的语义

我们首先为表达式定义语义。我们用双括号 [[E]] 表示表达式 E 的语义。

一个估值 V 是一个函数,它为程序中的每个变量 x 赋予一个整数值。表达式 E 的语义是一个函数,它接收一个估值 V,并返回 E 在该估值下计算得到的整数值。即:
[[E]] : Valuation -> Integer

我们可以用基于规则的方式定义各种表达式的语义:

  • 常量:对于任何估值 V[[c]]V = c

    ---------------
    [[c]]V = c
    
  • 变量[[x]]V = V(x),即变量 x 在估值 V 下的值。

    ---------------
    [[x]]V = V(x)
    
  • 加法:如果 [[E1]]V = a1[[E2]]V = a2,那么 [[E1 + E2]]V = a1 + a2

    [[E1]]V = a1    [[E2]]V = a2
    ------------------------------
    [[E1 + E2]]V = a1 + a2
    
  • 整数除法:如果 [[E1]]V = a1[[E2]]V = a2,且 a2 ≠ 0,那么 [[E1 / E2]]V = a1 / a2(整数除)。

    [[E1]]V = a1    [[E2]]V = a2    a2 ≠ 0
    ---------------------------------------
    [[E1 / E2]]V = a1 / a2
    
  • 取模运算:如果 [[E1]]V = a1[[E2]]V = a2a2 > 0,且存在整数 cb 使得 a1 = c * a2 + b0 ≤ b < a2,那么 [[E1 % E2]]V = b

    [[E1]]V = a1    [[E2]]V = a2    a2 > 0    a1 = c*a2 + b    0 ≤ b < a2
    --------------------------------------------------------------------
    [[E1 % E2]]V = b
    
  • 比较运算:如果 [[E1]]V = a1[[E2]]V = a2,且 a1 > a2,那么 [[E1 > E2]]V = 1(真);否则为 0(假)。

    [[E1]]V = a1    [[E2]]V = a2    a1 > a2
    ---------------------------------------
    [[E1 > E2]]V = 1
    
    [[E1]]V = a1    [[E2]]V = a2    a1 ≤ a2
    ---------------------------------------
    [[E1 > E2]]V = 0
    
  • 逻辑与(短路求值):如果 [[E1]]V = 0(假),那么 [[E1 and E2]]V = 0。如果 [[E1]]V ≠ 0(真)且 [[E2]]V ≠ 0(真),那么 [[E1 and E2]]V = 1

    [[E1]]V = 0
    -----------------
    [[E1 and E2]]V = 0
    
    [[E1]]V ≠ 0    [[E2]]V ≠ 0
    ---------------------------
    [[E1 and E2]]V = 1
    

    (注:这里简化了,通常还需要处理 E1 为真但 E2 为假的情况)

注意,除法和取模的语义是部分函数,并非对所有输入都有定义(例如除数为零时)。

程序的操作语义

上一节我们定义了表达式的语义,现在我们来定义程序的语义。我们将使用操作语义,特别是小步操作语义。这种方法通过描述程序执行的单步转换来定义语义。

程序状态

我们定义程序状态为一个二元组 (P, V)

  • P待执行的程序(即尚未执行的部分)。
  • V:当前的估值,给出了所有变量的当前值。

初始状态是 (完整程序, 初始估值)。当状态变为 (skip, V‘)(, V‘)(空程序)时,程序终止,V‘ 是最终估值。

我们用 (P, V) -> (P‘, V‘) 表示从状态 (P, V) 经过一步执行,转换到状态 (P‘, V‘)

语义规则

现在,我们为语言中的每种语句定义转换规则。

  • skip 语句skip 语句不执行任何操作,直接终止。

    ----------------------
    (skip, V) -> (, V)
    
  • 赋值语句:执行 x := E。首先计算表达式 E 在当前估值 V 下的值 a,然后将变量 x 的值更新为 a,得到新估值 V[x -> a],程序变为空。

    [[E]]V = a
    --------------------------------
    (x := E, V) -> (, V[x -> a])
    
  • 顺序组合:执行 P; Q 的第一步,就是执行 P 的第一步。如果 P 经过一步变成 P‘,且估值变为 V‘,那么整个程序就变成 P‘; Q,估值变为 V‘。注意,这里要求 P 不是空程序。

    (P, V) -> (P‘, V‘)    P ≠ 
    -------------------------------------
    (P; Q, V) -> (P‘; Q, V‘)
    
  • 条件语句:执行 if E then P else Q end。首先计算条件 E 的值。如果值非零(真),则待执行程序变为 P,估值不变;如果值为零(假),则待执行程序变为 Q

    [[E]]V ≠ 0
    ------------------------------------------
    (if E then P else Q end, V) -> (P, V)
    
    [[E]]V = 0
    ------------------------------------------
    (if E then P else Q end, V) -> (Q, V)
    
  • 循环语句:执行 while E do P end。首先检查条件 E。如果为假(零),则循环体一次都不执行,直接终止,状态变为 (skip, V)。如果为真(非零),则循环体 P 需要执行一次,执行完后还需要再次检查循环条件,因此状态变为 (P; while E do P end, V)。这体现了循环的展开

    [[E]]V = 0
    ----------------------------------------
    (while E do P end, V) -> (skip, V)
    
    [[E]]V ≠ 0
    ---------------------------------------------------------
    (while E do P end, V) -> (P; while E do P end, V)
    

这些规则共同定义了程序所有可能的单步执行路径。

大步语义简介

除了小步语义,还有大步语义(也称自然语义)。大步语义不描述中间步骤,而是直接定义程序从初始状态到最终状态的整体执行结果

我们用 (P, V) ⇓ V‘ 表示程序 P 从估值 V 开始执行,最终终止于估值 V‘

其规则示例如下:

  • skip: (skip, V) ⇓ V
  • 赋值: 如果 [[E]]V = a,则 (x := E, V) ⇓ V[x -> a]
  • 顺序组合: 如果 (P, V) ⇓ V‘(Q, V‘) ⇓ V‘‘,则 (P; Q, V) ⇓ V‘‘
  • 条件语句: 如果 [[E]]V ≠ 0(P, V) ⇓ V‘,则 (if E then P else Q end, V) ⇓ V‘;如果 [[E]]V = 0(Q, V) ⇓ V‘,则 (if E then P else Q end, V) ⇓ V‘
  • 循环语句: 如果 [[E]]V = 0,则 (while E do P end, V) ⇓ V。如果 [[E]]V ≠ 0,且 (P, V) ⇓ V‘,且 (while E do P end, V‘) ⇓ V‘‘,则 (while E do P end, V) ⇓ V‘‘。或者,可以使用展开规则:(while E do P end, V) ⇓ V‘ 当且仅当 (if E then P; while E do P end else skip end, V) ⇓ V‘

大步语义更简洁,但小步语义能提供更细致的执行过程信息,对于某些分析(如并发)更有用。

程序终止性的形式化证明

现在,我们尝试使用定义好的语义规则,手动证明一个具体程序的属性。我们以证明下面这个计算两数最大公约数(GCD)的欧几里得算法程序对于所有整数输入都会终止为例。

程序 P:

while (x > 0 and y > 0) do
    if x > y then
        x := x - y
    else
        y := y - x
    end
end

定义终止性

基于小步语义,我们定义程序终止。首先定义多步转换传递闭包 ->*

  1. 零步:对任何状态 S,有 S ->* S
  2. 一步:如果 S -> S‘,那么 S ->* S‘
  3. 多步:如果 S ->* S‘S‘ ->* S‘‘,那么 S ->* S‘‘

程序 P 在初始估值 V终止,记作 Term(P, V),当且仅当存在某个估值 V‘,使得 (P, V) ->* (, V‘)。即,可以从初始状态经过有限步转换到空程序状态。

证明思路

我们希望对所有整数 xy 的初始值,证明 Term(P, V) 成立。证明采用分类讨论和数学归纳法。

  1. 情况1:x ≤ 0

    • 根据语义,x > 0 为假。
    • 根据短路求值规则,x > 0 and y > 0 为假。
    • 根据while规则(条件为假),程序一步转换为 (skip, V),进而转换为 (, V)
    • 因此 Term(P, V) 成立。
  2. 情况2:y ≤ 0。证明与情况1对称。

  3. 情况3:x > 0y > 0。这是需要归纳的核心情况。

    • 令度量 N = x + y(初始估值下的和)。
    • 归纳假设:对于所有满足 x‘ + y‘ < N 的估值 V‘Term(P, V‘) 成立。
    • 归纳步骤:我们需要证明对于满足 x+y = N 的初始估值 VTerm(P, V) 成立。
      • 由于 x>0y>0,循环条件 x>0 and y>0 为真。
      • 根据while规则(条件为真),程序一步转换为:
        (if x>y then x:=x-y else y:=y-x end; while ... end, V)
      • 现在需要证明这个新程序终止。根据顺序组合规则,我们需要证明:
        1. 第一部分的 if 语句从 V 开始执行终止于某个 V‘‘
        2. 第二部分的 while 循环从 V‘‘ 开始执行终止。
      • 对于第1点:if 语句会根据 x>y 的真假选择分支,执行相应的赋值语句。赋值语句显然一步终止。关键在于,无论执行哪个分支,执行后的新估值 V‘‘ 都满足 x‘‘ + y‘‘ < x + y = N(因为正数减去正数,和严格减小)。
      • 对于第2点:由于 V‘‘ 满足 x‘‘+y‘‘ < N,根据我们的归纳假设Term(P, V‘‘) 成立,即 while 循环从 V‘‘ 开始会终止。
      • 结合顺序组合规则,整个程序从 V 开始终止。

通过以上分类和归纳,我们证明了对于所有可能的初始值,程序 P 都会终止。

证明的挑战与启示

这个手动证明虽然逻辑清晰,但若完全展开,将涉及大量琐碎的规则应用(如表达式求值、比较运算、短路求值、赋值、顺序组合、循环展开等),以及整数算术性质的引用(如正数相减和减小)。写成一个机器可检查的完整证明会非常冗长。

这正体现了手动进行形式化推理的繁琐和易错性。本课程的目标,就是引入并学习使用自动化工具(如证明助手Coq)来帮助我们完成这类推理。我们可以将语义规则和要证明的属性输入工具,然后通过交互方式指导工具构建证明,最终由工具确保证明的每一步都符合规则,从而得到严谨可靠的结论。

总结

本节课我们一起学习了程序形式化推理的基础:

  1. 区分了程序的语法(形式)和语义(含义)。
  2. 理解了为什么需要形式化语义来精确描述程序行为、消除歧义并支持编译器验证。
  3. 为一个简单的编程语言定义了表达式的语义,使用基于规则的表示法。
  4. 重点学习了操作语义,特别是小步操作语义,通过定义程序状态单步转换规则来描述程序的执行过程。
  5. 了解了大步语义作为另一种描述程序整体行为的方式。
  6. 通过一个具体的例子,演示了如何利用已定义的语义规则,手动进行程序终止性的形式化证明,并体会了其复杂性和对自动化工具的迫切需求。

从下一节课开始,我们将探索如何将这些理论应用于实践,利用自动化工具来减轻形式化推理的负担。

002:Coq入门教程

在本节课中,我们将学习如何使用Coq定理证明器来形式化地证明逻辑命题。我们将从基础开始,逐步介绍Coq的界面、基本语法和核心证明策略。

概述

Coq是一个交互式定理证明器。它允许我们编写形式化的数学陈述,并通过一系列称为“策略”的指令来指导它构建证明。本节课我们将学习如何编写简单的命题逻辑证明。

界面与基础

打开你的Coq IDE。如果你没有在笔记本电脑上安装,可以打开Coq JS网站,那里有网页版本。

Coq编辑器分为三个部分:

  • 代码编辑区:你在这里编写代码。
  • 目标面板:显示当前需要证明的子目标。
  • 消息面板:显示语法错误或执行状态等信息。

在Coq中,每个命令都以一个点 . 结束。

我们可以定义“节”(Section)来组织代码,它限定了变量的作用域。

Section A.

执行这行代码(例如,点击向下的箭头或按Ctrl+向下箭头),如果看到绿色提示,则表示执行成功。

要结束一个节,可以这样写:

End A.

第一个证明:恒等蕴含

现在,让我们证明第一个定理。首先,我们声明一个命题变量 P

Variable P : Prop.

然后,我们陈述一个定理:P 蕴含 P

Theorem my_thm : P -> P.

执行这行后,目标面板会显示我们需要证明的目标:P -> P。同时,假设列表中会显示 P : Prop,这仅表示 P 是一个命题类型,并不假定 P 为真。

我们通过 Proof. 命令开始证明。

Proof.

现在,我们需要指导Coq完成证明。我们使用“策略”(Tactics)。对于蕴含式 A -> B,标准的策略是 intro,它将前提 A 作为假设引入。

intro H.

执行后,假设列表中出现 H : P,而目标变为 P。由于目标 P 现在已经是假设之一,我们可以使用 assumption 策略来完成证明。

assumption.

此时,目标面板显示“No more goals.”,表示所有子目标都已证明完毕。我们用 Qed. 命令结束证明。

Qed.

Qed 通过后,意味着Coq已经验证了我们构建的证明是正确的。

我们可以检查或打印已证明的定理:

Check my_thm.
Print my_thm.

到目前为止,我们学习了三个策略:

  1. intro:用于证明蕴含式,将前提引入为假设。
  2. assumption:当要证明的目标已经是假设之一时使用。
  3. split:用于证明合取式(A /\ B),将其拆分为两个需要分别证明的子目标。

introsintro 的复数形式,可以连续应用 intro 策略直到无法应用为止。

练习:链式蕴含

让我们进入一个新的节,并尝试一个练习。

Section C.
Variables P Q R : Prop.
Theorem exercise1 : (P -> Q) -> (Q -> R) -> P -> R.

我们的目标是证明这个链式蕴含。我们可以使用 intros 策略一次性引入所有前提。

Proof.
intros H1 H2 H3.

现在,假设为 H1: P -> Q, H2: Q -> R, H3: P,目标是 R

为了证明 R,我们可以使用 H2,但需要先证明 Q。我们可以使用 assert 策略来声明并证明一个中间引理 Q

assert (HQ : Q).

这创建了一个新的子目标:证明 Q。要证明 Q,我们可以使用 H1H3apply H1 策略表示:要证明 H1 的结论 Q,需要先证明其前提 P

apply H1.
assumption.

现在,中间引理 HQ : Q 被证明,并加入到假设中。最后,要证明目标 R,我们应用 H2

apply H2.
assumption.
Qed.

auto 策略可以自动处理一些简单的推理步骤(如应用蕴含式)。tauto 策略专门用于证明命题逻辑的重言式。

合取与析取的证明

上一节我们处理了蕴含,本节我们来看看如何处理合取(与)和析取(或)。

首先,证明一个析取式:P -> P \/ Q\/ 表示“或”)。

Theorem or_example : P -> P \/ Q.
Proof.
intro H.

现在目标是 P \/ Q。要证明一个析取式,我们可以选择证明左边或右边。left 策略表示我们选择证明左分支。

left.
assumption.
Qed.

类似地,right 策略用于选择证明右分支。

对于合取式 P /\ Q -> Q /\ P,证明开始时,我们引入前提。

Theorem and_example : P /\ Q -> Q /\ P.
Proof.
intro H. (* H : P /\ Q *)

假设 H 是一个合取式。destruct H as [HP HQ]. 策略可以分解合取式,将其两个组成部分分别命名为 HPHQ

destruct H as [HP HQ].

现在假设变为 HP : PHQ : Q,目标是 Q /\ P。我们使用 split 策略将目标拆分为两个子目标:QP

split.
- apply HQ. (* 证明 Q *)
- apply HP. (* 证明 P *)
Qed.

使用全称量词

现在,我们来看看如何处理带有全称量词 forall 的命题。

首先,证明一个全称命题:forall P : Prop, P -> P

Theorem forall_example : forall P : Prop, P -> P.
Proof.
intro P.    (* 引入命题 P *)
intro H.    (* 引入假设 H: P *)
assumption.
Qed.

这里,第一个 intro 引入了量词 forall P 中的变量 P(及其类型 Prop)。第二个 intro 引入了蕴含式的前提。

如果我们已经证明了一个定理,可以在另一个定理中引用它。例如:

Theorem T1 : forall P : Prop, P -> P.
Proof. ... Qed.

Variables Q R : Prop.
Theorem T2 : (Q -> R) -> (Q -> R).
Proof.
pose proof T1 as H.

pose proof T1 as H 将已证明的定理 T1 作为假设 H 引入。H 的类型是 forall P : Prop, P -> P

为了在当前上下文中使用 H,我们需要用具体的命题(如 Q -> R)来实例化其中的全称量词 P。这可以使用 specialize 策略。

specialize (H (Q -> R)).

现在 H 变为 (Q -> R) -> (Q -> R)。要证明目标 (Q -> R) -> (Q -> R),直接应用假设 H 即可。

apply H.
Qed.

经典逻辑定律

在经典逻辑中,排中律(Excluded Middle)和皮尔士律(Peirce‘s Law)是等价的。排中律表述为:forall P, P \/ ~P。皮尔士律表述为:forall P Q, ((P -> Q) -> P) -> P

在Coq中,默认的直觉主义逻辑不承认排中律。但我们可以将其作为公理引入,然后证明它与皮尔士律等价。

Axiom excluded_middle : forall P : Prop, P \/ ~P.
Definition peirce : Prop := forall P Q : Prop, ((P -> Q) -> P) -> P.

Theorem equiv : excluded_middle <-> peirce.
Proof.
unfold peirce. split.
- intro H_em. (* 假设排中律成立 *)
  intros P Q H. (* 引入 P, Q 和假设 H: ((P -> Q) -> P) *)
  (* 证明思路:对 Q 使用排中律进行分情况讨论 *)
  pose proof (H_em Q) as [HQ | HnQ]. (* Q 为真或假 *)
  + (* 情况1: HQ : Q *)
    (* 此时易证 P *)
    admit. (* 此处省略具体证明步骤 *)
  + (* 情况2: HnQ : ~Q *)
    (* 利用 H 和 ~Q 证明 P *)
    admit. (* 此处省略具体证明步骤 *)
- intro H_peirce. (* 假设皮尔士律成立 *)
  intro P. (* 引入命题 P *)
  (* 利用皮尔士律证明 P \/ ~P *)
  specialize (H_peirce P (P \/ ~P)).
  admit. (* 此处省略具体证明步骤 *)
Admitted. (* 由于证明较长,我们用 Admitted 暂时跳过 *)

这个证明展示了如何在Coq中处理复杂的逻辑等价性,并使用了公理、实例化、分情况讨论等策略。

量词分配律:弗罗贝尼乌斯定理

最后,我们看一个涉及谓词和量词的例子:弗罗贝尼乌斯定理。它描述了存在量词与合取式的分配关系。

Variables (A : Type) (P : A -> Prop) (Q : Prop).
Theorem frobenius :
  (exists x : A, Q /\ P x) <-> (Q /\ exists x : A, P x).
Proof.
split.
- intro H_ex. (* -> 方向 *)
  destruct H_ex as [x [HQ HPx]]. (* 分解存在量词和合取式 *)
  split.
  + assumption. (* 证明 Q *)
  + exists x. assumption. (* 证明 exists x, P x *)
- intro H_and. (* <- 方向 *)
  destruct H_and as [HQ H_ex]. (* 分解合取式 *)
  destruct H_ex as [x HPx]. (* 分解存在量词 *)
  exists x. split; assumption. (* 证明 exists x, Q /\ P x *)
Qed.

这个证明清晰地展示了如何分解和构造存在量词与合取式。分号 ; 用于将同一个策略(如 assumption)应用于当前生成的所有子目标。

其对偶形式涉及全称量词与析取式,并且其证明需要用到排中律公理,这再次体现了经典逻辑与直觉主义逻辑的差异。

总结

本节课中,我们一起学习了Coq定理证明器的基础知识。我们了解了其界面,学会了如何编写命题和定理。我们重点掌握了一系列核心证明策略:

  • intros / intro:引入假设或全称变量。
  • apply:应用一个蕴含式或假设来证明当前目标。
  • split:分解合取式目标。
  • left / right:选择证明析取式的某一侧。
  • destruct:分解假设中的合取式、析取式或存在量词。
  • assert:声明并证明一个中间引理。
  • assumption:使用当前假设之一来证明目标。
  • exists:构造存在量词证明。
  • specialize:实例化一个全称量词假设。
  • unfold:展开一个定义。

我们还通过例子看到了 autotauto 等自动化策略的用途,并初步接触了公理的使用。这些是使用Coq进行形式化推理的基础,后续我们将运用这些工具来证明程序的性质。

003:指称语义

在本节课中,我们将学习程序语义的另一种定义方式——指称语义。与之前讨论的操作语义不同,指称语义将程序视为数学函数,通过结构归纳的方式,为每个程序赋予一个从输入状态到输出状态的(部分)函数作为其含义。我们将重点学习如何为包含循环的程序定义指称语义,并理解“最小不动点”这一核心概念。

回顾:操作语义

上一节我们介绍了操作语义,它通过一系列规则定义了程序如何一步步执行。我们定义了两种操作语义:

  • 小步操作语义:定义了程序单步执行的规则。
  • 大步操作语义:定义了程序从初始状态到终止状态的完整执行过程。

我们还扩展了语法,引入了非确定性选择P * Q)和停机命令halt)。非确定性选择意味着程序可以执行PQ,但具体选择哪一个是不确定的。这可以用来模拟不受程序控制的用户输入或环境因素。

以下是处理非确定性选择的规则:

  • 如果 (P, Val) -> (P‘, Val’),那么 (P * Q, Val) -> (P‘, Val’)
  • 如果 (Q, Val) -> (Q‘, Val’),那么 (P * Q, Val) -> (Q‘, Val’)

指称语义的核心思想

本节中,我们来看看指称语义。其核心思想是将程序视为数学对象——具体来说,是函数。每个程序P的语义 [[P]] 被定义为一个部分函数,它将一个输入状态(变量估值)映射到一个输出状态。

我们用 Sigma 表示所有可能状态的集合。状态在这里被简化为估值,即一个将程序中的每个变量映射到一个整数的函数。因此,语义函数的类型是:
[[P]] : Sigma -> Sigma

这是一个部分函数,因为对于某些输入,程序可能不会终止,此时函数在该输入处无定义。

表达式和布尔条件的语义

在定义程序语义之前,我们需要先定义程序中出现的算术表达式布尔条件的语义。它们的语义也是函数:

  • 算术表达式 E 的语义 [[E]] 是一个从状态到整数的函数:[[E]] : Sigma -> Z
  • 布尔条件 B 的语义 [[B]] 是一个从状态到布尔值(真或假)的函数:[[B]] : Sigma -> Bool

定义是结构归纳的:

  • 变量[[x]](Val) = Val(x)
  • 常量[[c]](Val) = c
  • 加法[[a1 + a2]](Val) = [[a1]](Val) + [[a2]](Val)
  • 比较[[a1 > a2]](Val) = ([[a1]](Val) > [[a2]](Val))
  • (其他运算类似定义)

我们还需要一个语义条件运算符,它将在定义ifwhile时用到。给定一个布尔条件B和两个语义函数FG(代表两个子程序的含义),条件运算符定义为:
(B -> F | G)(Val) = { F(Val), 如果 [[B]](Val) 为真; G(Val), 如果 [[B]](Val) 为假 }

基本命令的指称语义

现在,我们可以为基本程序结构定义指称语义。以下是核心定义:

  • skip[[skip]](Val) = Val
  • 赋值[[x := a]](Val) = Val[x -> [[a]](Val)] (将Valx的值更新为[[a]](Val)
  • 顺序组合[[P; Q]](Val) = [[Q]]( [[P]](Val) ) (先应用P的语义,再将结果作为Q语义的输入)
  • 条件语句[[if B then P else Q]](Val) = ([[B]] -> [[P]] | [[Q]])(Val)

这些定义都非常直观,直接反映了命令的意图。

循环与不动点

最复杂的情况是while循环。我们想为 while B do P 定义语义 [[while B do P]]。直觉上,这个循环等价于:
if B then (P; while B do P) else skip

但是,我们不能直接在定义右侧引用 [[while B do P]] 本身,这会形成循环定义,在数学上是不允许的。

为了解决这个问题,我们引入一个变换子 TT 接受一个语义函数 F(我们暂时将其视为“某个可能的while循环语义”),并返回一个新的语义函数:
T(F) = ([[B]] -> (F ◦ [[P]]) | [[skip]])

这里,F ◦ [[P]] 表示先执行P的语义,再执行FT 的作用可以理解为:将while循环“展开”一次。如果条件B成立,就先执行一次循环体P,然后再考虑循环F;否则就跳过。

我们的目标是找到一个函数 F,使得 T(F) = F。这样的 F 称为变换子 T不动点。这个不动点函数恰好满足我们直觉上的等式,因此我们自然地将while循环的语义定义为:
[[while B do P]] = 满足 T(F) = F 的某个不动点 F

存在性、唯一性与最小不动点

然而,这引出了两个问题:

  1. 存在性:这样的不动点一定存在吗?
  2. 唯一性:如果存在,它是唯一的吗?

存在性可以通过参考我们已经定义的操作语义来证明。操作语义为每个程序(包括while循环)定义了一个确定的输入-输出关系,这个关系对应的函数可以被验证是变换子T的一个不动点。因此,不动点是存在的。

唯一性则不然。可以构造出T的多个不动点。例如,对于某个永不终止的while循环,操作语义给出的函数在该输入处是无定义的。但我们可以定义一个“坏”的不动点,它在所有输入上都有定义(例如,总是返回全零状态),并且它也是T的不动点。

为了得到与操作语义一致、符合直觉的语义,我们需要在所有不动点中挑选出最精确的那个,即最小不动点。我们通过比较函数的定义域来定义“最小”:函数F小于等于函数G,当且仅当F的定义域是G的定义域的子集,且在两者共同的定义域上FG取值相同。

最小不动点就是所有不动点中,定义域最小的那个(即在最多的地方是“无定义”的)。可以证明,这个最小不动点:

  1. 是唯一的
  2. 恰好对应于操作语义(对于终止的输入给出结果,对于不终止的输入无定义)。

因此,while循环最终的指称语义定义为:
[[while B do P]] = T 的最小不动点

非确定性的挑战

最后,我们简要探讨一下如果将非确定性引入指称语义会带来的挑战。在非确定性下,一个程序对于一个输入可能产生多个输出,也可能不终止。此时,程序的语义不能再是一个简单的 Sigma -> Sigma 部分函数。

一种可能的类型是:[[P]] : Sigma -> P(Sigma ∪ {⊥}),其中 P 表示幂集, 是一个表示“不终止”的特殊符号,并且结果集不能为空(因为至少有一种执行可能性)。这大大复杂化了语义域和不动点的理论。在这种情况下,通常使用操作语义来处理非确定性更为方便。

总结

本节课中我们一起学习了指称语义。我们了解到:

  • 指称语义将程序视为从输入状态到输出状态的函数
  • 通过结构归纳,可以简洁地定义skip、赋值、顺序组合和条件语句的语义。
  • 处理while循环需要引入变换子不动点的概念。
  • 为了保证语义的合理性和与操作语义的一致性,我们选择变换子的最小不动点作为循环的语义。
  • 指称语义提供了另一种强大而数学化的程序含义描述方式,为后续的程序推理和验证奠定了基础。

004:霍尔逻辑

在本节课中,我们将学习霍尔逻辑,这是一种用于推理程序正确性的形式化方法。我们将了解如何通过前置条件、后置条件和循环不变式来证明程序满足特定性质。

概述

在前面的课程中,我们学习了操作语义和指称语义,它们描述了程序如何执行。然而,直接使用这些语义来证明程序的特定性质(例如,程序结束后某个变量具有特定值)通常是困难或不切实际的。霍尔逻辑提供了一套基于逻辑推理的规则,使我们能够系统地推导出关于程序行为的断言,即“霍尔三元组”。一个霍尔三元组 {A} P {B} 表示:如果程序 P 开始执行时断言 A(前置条件)为真,并且 P 终止,那么当 P 终止时,断言 B(后置条件)将为真。

从示例程序到霍尔逻辑

让我们通过一个具体的程序来理解为什么需要霍尔逻辑,以及它是如何工作的。

考虑以下计算 2^n - 1 的程序:

i := 1
s := 0
m := 0
while m < n do
    s := s + i
    i := i * 2
    m := m + 1
end

我们想要证明的程序后置条件是:s = 2^n - 1。为了证明这一点,我们需要一个前置条件:n > 0

使用指称语义的困难

指称语义将程序视为从初始状态到最终状态的函数。要证明后置条件,我们需要计算这个函数并检查其输出是否满足条件。然而,对于包含循环的程序,其指称语义是某个变换函数的最小不动点,而计算这个不动点通常是不可行的。实际上,由于停机问题的不可判定性,我们无法有一个通用算法来计算任意程序的完整指称语义函数。

使用操作语义与归纳推理

使用小步操作语义,我们可以通过归纳法来手动推理。例如,我们可以证明在 while 循环的每次迭代后,某个不变式(例如 s = 2^m - 1i = 2^m)都成立。然而,这种推理是特定的、非形式化的,难以自动化或由机器检查。

引入程序不变式

一种更系统的方法是在程序的各个点标注“不变式断言”。一个不变式是在程序执行到该点时总是为真的断言。对于我们的示例,我们可以在循环开始前标注一个关键的不变式:

n > 0 ∧ m ≥ 0 ∧ n ≥ m ∧ i = 2^m ∧ s = 2^m - 1

通过证明这个断言在第一次到达循环前(基础情况)成立,并且每次循环迭代都保持成立(归纳步骤),我们就可以证明它始终成立。当循环结束时,我们有 m ≥ n 和不变式,结合 m < n 为假,可以推导出 m = n,从而得到最终的后置条件 s = 2^n - 1

这种方法的核心思想是:为程序中的每个控制流转移证明,如果转移前的断言成立,则执行转移后的代码,转移后的断言也成立。

然而,这里仍然存在挑战:

  1. 不变式的生成:如何自动找到合适的不变式是一个难题。
  2. 形式化与自动化:如何将这种推理过程形式化,以便像 Coq 这样的自动化工具能够验证证明的正确性?

霍尔逻辑正是为了解决第二个挑战而设计的。

霍尔逻辑:公理与规则

霍尔逻辑的核心是定义一组证明规则,用于从简单的霍尔三元组组合出复杂程序的霍尔三元组。一个霍尔三元组 {A} P {B} 的形式化含义是:
对于所有状态 σ,如果 σ ⊨ A(状态 σ 满足断言 A),并且程序 Pσ 开始执行并终止于状态 σ',那么必有 σ' ⊨ B

以下是霍尔逻辑的基本规则:

基本规则

Skip 公理skip 语句不执行任何操作。

{ A } skip { A }

赋值公理模式:对于赋值语句 x := E

{ A[E/x] } x := E { A }

其中 A[E/x] 表示在断言 A 中将所有变量 x 的出现替换为表达式 E。这意味着,为了在赋值后使 A 成立,在赋值前必须满足将 x 替换为 E 后的 A

组合规则

顺序组合规则:如果程序 P 执行后满足 C,并且接着执行 Q 能从 C 达到 B,那么顺序执行 P; Q 能从 A 达到 B

{ A } P { C }    { C } Q { B }
—————————————————————————————————
        { A } P; Q { B }

条件语句规则:对于 if B then P else Q end

{ A ∧ B } P { C }    { A ∧ ¬B } Q { C }
———————————————————————————————————————————
        { A } if B then P else Q end { C }

强化与弱化规则

后承规则:这是霍尔逻辑中非常关键的一条规则,它允许我们强化前置条件或弱化后置条件。

A' ⇒ A    { A } P { B }    B ⇒ B'
———————————————————————————————————
           { A' } P { B' }

如果 A'A 更强(即 A' 蕴含 A),并且 B 蕴含 B',那么我们可以将三元组的前置条件替换为更强的 A',后置条件替换为更弱的 B'

循环规则

While 循环规则:对于 while B do P end。这里需要一个循环不变式 I,它是一个在循环每次迭代前后都保持为真的断言。

{ I ∧ B } P { I }
——————————————————————————————————————————————
{ I } while B do P end { I ∧ ¬B }

规则解读:如果当不变式 I 和循环条件 B 都成立时,执行循环体 P 能保持 I 成立,那么我们可以断言:只要循环开始前 I 成立,那么循环结束后,I 仍然成立,并且循环条件 B 为假。

规则应用示例

让我们使用这些规则来证明一个简单条件语句的霍尔三元组。
我们想证明:

{ n > 1 } if x < n then x := n else x := x + 1 end { x > 0 }

以下是证明步骤:

  1. 应用条件语句规则:目标拆分为两个子目标。

    • { n > 1 ∧ x < n } x := n { x > 0 }
    • { n > 1 ∧ x ≥ n } x := x + 1 { x > 0 }
  2. 证明第一个子目标 (x := n)

    • 应用赋值公理:要得到后置条件 x > 0,赋值前需满足 n > 0。所以我们有 { n > 0 } x := n { x > 0 }
    • 应用后承规则:我们需要证明 (n > 1 ∧ x < n) ⇒ n > 0。这显然成立,因为 n > 1 蕴含 n > 0。因此,{ n > 1 ∧ x < n } x := n { x > 0 } 得证。
  3. 证明第二个子目标 (x := x + 1)

    • 应用赋值公理:要得到后置条件 x > 0,赋值前需满足 x + 1 > 0,即 x > -1。所以我们有 { x > -1 } x := x + 1 { x > 0 }
    • 应用后承规则:我们需要证明 (n > 1 ∧ x ≥ n) ⇒ x > -1。由于 n > 1x ≥ n,所以 x ≥ n > 1,因此 x > 1 > -1 成立。因此,{ n > 1 ∧ x ≥ n } x := x + 1 { x > 0 } 得证。
  4. 结论:根据条件语句规则,原霍尔三元组成立。

这个证明过程可以构建成一个证明树,其中每一步都对应一个霍尔逻辑规则的应用。

关于霍尔逻辑的进一步讨论

  • 合取规则:虽然可以添加规则 {A}P{B}{A}P{C} 可推出 {A}P{B∧C},但这并非必需,因为它可以通过在其他规则中直接证明 {A}P{B∧C} 来实现。不过,该规则可以使证明更模块化。
  • 非确定性选择规则:对于非确定性选择 P or Q,规则如下:
    { A } P { B }    { A } Q { B }
    ————————————————————————————————
        { A } P or Q { B }
    
    因为要保证无论选择哪条路径,后置条件 B 都成立。
  • 霍尔逻辑的完备性与可计算性:霍尔逻辑是相对完备的,即所有有效的霍尔三元组都存在一个形式化证明。然而,自动生成这个证明的过程是不可判定的。例如,三元组 {true} P {false} 有效当且仅当程序 P 永不终止(部分正确性视角)。判断程序是否永不停机本身就是不可判定的问题。因此,在实践中,我们依赖像 Coq 这样的半自动化工具,由用户提供关键的不变式和证明思路(如应用哪条规则、如何强化前置条件),然后由工具完成细节验证。

总结

本节课我们一起学习了霍尔逻辑的基础知识。霍尔逻辑通过定义一组形式化规则(如 Skip、赋值、顺序、条件、循环规则和后承规则),为我们提供了一种系统化、可机器检查的方法来推理程序的正确性。其核心是围绕前置条件、后置条件,特别是循环不变式进行推理。虽然自动找到证明(尤其是循环不变式)是困难的,但霍尔逻辑框架使得我们可以将人类的高层推理与机器的精确验证结合起来,这对于确保关键软件的正确性至关重要。

005:图上的游戏 🎮

在本节课中,我们将学习无限时长双人图游戏。这些游戏是程序验证的有用工具,但今天我们将专注于其理论部分。我们将定义游戏图、目标、策略等核心概念,并学习如何求解可达性目标。

游戏图与目标 🎲

上一节我们介绍了课程安排,本节中我们来看看什么是图游戏。

一个游戏图(或称竞技场)本质上是一个有向图,包含顶点集合 V 和边集合 E。其特殊之处在于,顶点集合 V 被划分为两个子集:V1V2,分别属于玩家1和玩家2。

游戏从某个初始顶点开始。在每一步,当前顶点的所有者玩家从该顶点的出边中选择一条,游戏移动到该边指向的顶点。我们假设每个顶点至少有一条出边,以保证游戏可以无限进行下去。

游戏进行产生的顶点序列称为路径。一个无限长的路径称为游戏的结果

一个目标定义了玩家希望达成的结果集合。例如,玩家1的目标 O1 是某个无限路径的集合。如果玩家2的目标 O2O1 的补集,即 O2 = 所有无限路径 \ O1,则称该游戏是零和的。

我们主要关注几种经典的目标类型。以下是它们的定义:

  • 可达性:玩家希望至少访问一次目标顶点集合 T
    • Reach(T) = { 路径 ρ | ρ 访问的顶点集合 ∩ T ≠ ∅ }
  • 安全性:玩家希望始终停留在安全顶点集合 T 内。
    • Safe(T) = { 路径 ρ | ρ 访问的所有顶点 ∈ T }
  • 布奇:玩家希望无限多次访问目标顶点集合 T
    • Büchi(T) = { 路径 ρ | ρ 被无限次访问的顶点集合 ∩ T ≠ ∅ }
  • 科布奇:玩家希望所有被无限次访问的顶点都在集合 T 内。
    • CoBüchi(T) = { 路径 ρ | ρ 被无限次访问的顶点集合 ⊆ T }

可达性与安全性互为对偶,布奇与科布奇互为对偶。这意味着求解一个目标的问题可以转化为求解其对偶目标。

奇偶目标与尾目标 🎯

上一节我们介绍了四种经典目标,本节中我们来看看更一般化的目标:奇偶目标。

一个奇偶目标由一个优先级函数 P: V -> {0, 1, ..., d} 定义。玩家1的目标是确保在结果路径中,被无限次访问的顶点里,最小的优先级是偶数

  • Parity(P) = { 路径 ρ | min{ P(v) | v 在 ρ 中被无限次访问 } 是偶数 }

奇偶目标非常强大,它可以表达布奇和科布奇目标。

  • 对于布奇目标 Büchi(T),可以定义 P(v) = 0v ∈ T,否则 P(v) = 1
  • 对于科布奇目标 CoBüchi(T),可以定义 P(v) = 2v ∈ T,否则 P(v) = 1

奇偶目标的对偶也是奇偶目标。如果原目标由函数 P 定义,则其对偶目标由函数 P'(v) = P(v) + 1 定义。

如果一个目标的性质不依赖于路径的任意有限前缀,则称其为尾目标。形式化地说,目标 O 是尾目标,当且仅当:如果路径 ρO 中,那么 ρ 的任意无限后缀也在 O 中。布奇、科布奇和奇偶目标都是尾目标,但可达性目标不是。

不过,我们可以通过修改游戏图,将可达性目标转化为布奇目标(进而成为奇偶目标)。方法是将所有目标顶点 T 的所有出边移除,并为每个 t ∈ T 添加一条指向自身的自环。这样,一旦到达目标顶点,就会永远停留在那里,相当于无限次访问了它。

策略与获胜集 ⚔️

上一节我们定义了游戏的目标,本节中我们来看看玩家如何通过策略来达成目标。

玩家 i 的一个策略 σ_i 是一个函数,它根据当前游戏的历史(已访问的顶点序列)和当前顶点,决定下一步移动到哪个顶点。策略必须遵守图的边。

如果给定了初始顶点 v、玩家1的策略 σ1 和玩家2的策略 σ2,则游戏会产生一个唯一的结果路径,记为 Outcome(v, σ1, σ2)

我们特别关注两种简单的策略:

  • 无记忆策略:策略决策仅依赖于当前顶点,与历史无关。可以表示为 σ_i: V_i -> V
  • 持久策略:如果玩家在某个顶点做出过一次选择,那么之后每次回到该顶点,都会做出同样的选择。

给定玩家1的目标 O1 和初始顶点 v,策略 σ1 称为一个获胜策略,如果对于玩家2的任何策略 σ2,结果 Outcome(v, σ1, σ2) 都在 O1 中。反之,如果存在一个玩家2的策略 σ2 使得结果不在 O1 中,则称 σ2 破坏σ1

玩家 i获胜集 Win_i 是指从这些顶点开始,玩家 i 拥有一个获胜策略的所有顶点的集合。
如果对于所有顶点 v,都有 v ∈ Win_1 当且仅当 v ∉ Win_2,则称该游戏是确定的
“零和”与“确定”是两个不同的概念。零和关乎单次游戏结果的归属,而确定关乎是否存在应对所有对手策略的必胜策略。

求解可达性目标 🧮

上一节我们介绍了策略的抽象概念,本节中我们来看一个具体的算法,用于求解玩家1在可达性目标 Reach(T) 下的获胜集。

算法通过迭代计算一个称为吸引子的集合 AA 包含了所有玩家1可以强制游戏到达目标集 T 的顶点。
计算过程如下:

  1. 初始化 A0 = T。(如果起点就在目标里,直接获胜)
  2. 对于 k = 1, 2, ...,计算 Ak = Ak-1 ∪ { v ∈ V1 | 存在边 v->v' 且 v' ∈ Ak-1 } ∪ { v ∈ V2 | 所有边 v->v' 都满足 v' ∈ Ak-1 }
    • 如果顶点属于玩家1,并且有一个后继已经在获胜集里,那么玩家1可以选择去那里,从而获胜。
    • 如果顶点属于玩家2,并且所有后继都在玩家的获胜集里,那么无论玩家2怎么走,下一步都会进入玩家的获胜集。
  3. 当集合不再增长时停止,即找到不动点 A = ∪_{k>=0} Ak

可以证明,集合 A 就是玩家1在目标 Reach(T) 下的获胜集 Win_1。并且,玩家1可以使用一个简单的无记忆获胜策略:在任意顶点 v ∈ A \ T,如果 v ∈ V1,则选择一条指向 Ak-1 集合的后继边;如果 v ∈ V2,则任意选择(因为所有选择都对玩家1有利)。

此外,集合 A 的补集 V \ A 是玩家2的一个陷阱。陷阱 C 满足:

  1. C ∩ T = ∅。(陷阱里没有目标)
  2. 对于所有 v ∈ C ∩ V1v 的所有后继都在 C 中。(玩家1无法逃出陷阱)
  3. 对于所有 v ∈ C ∩ V2v 至少有一个后继在 C 中。(玩家2可以选择留在陷阱里)

如果游戏是零和的(玩家2的目标是 Safe(V \ T)),那么从 V \ A 中的任何顶点开始,玩家2都有一个获胜策略:始终选择留在陷阱 V \ A 内的边。这保证了游戏永远不会进入 T

这个算法可以在 O(|V| + |E|) 的线性时间内实现,类似于使用队列的广度优先搜索。

与程序验证的联系 🔗

上一节我们给出了求解可达性目标的具体算法,本节中我们探讨这些图游戏概念如何应用于程序验证。

在程序语义中,我们常遇到非确定性。在图游戏的框架下,我们可以将两种非确定性分别建模为两个玩家的选择:

  • 玩家1:可能代表程序本身或用户输入。
  • 玩家2:可能代表环境或其他不可控因素。

于是,程序验证的许多问题可以转化为图游戏问题:

  • 证明程序终止:相当于玩家1(程序)希望到达表示终止状态的集合 T(可达性目标)。
  • 证明存在漏洞:相当于玩家2(环境)可以强制程序到达表示崩溃的错误状态集合 T(可达性目标)。
  • 证明安全性(无漏洞):相当于玩家1希望始终停留在安全状态集合 T 内(安全性目标)。
  • 证明非终止:相当于玩家2希望始终停留在非终止状态集合内(安全性目标)。

然而,程序的状态空间通常是无限或极其庞大的(例如,包含整数变量)。我们之前给出的线性时间算法无法直接处理无限图。

有两种主要方法处理大规模状态空间:

  1. 抽象解释:将具体状态聚合成有限的抽象状态,对程序行为进行过近似。这样得到的抽象模型是有限的,可以应用图游戏算法。这种方法保持可靠性(如果抽象模型安全,则原程序也安全),但会丧失完备性(抽象模型报错,原程序未必真有错)。
  2. 符号执行:不显式枚举状态集合,而是用逻辑公式等符号形式来表示和计算像吸引子 A 这样的集合。这种方法可能面临不终止的问题,即计算无法到达一个不动点。

根据图灵机和莱斯定理的不可判定性,在程序验证中,我们无法同时获得可靠性、完备性和算法终止性。必须在三者之间进行权衡。

总结 📝

本节课中我们一起学习了无限时长双人图游戏的理论基础。
我们首先定义了游戏图、路径、结果和各类目标(可达性、安全性、布奇、科布奇),并介绍了更具一般性的奇偶目标。
接着,我们探讨了玩家的策略,特别是无记忆策略和获胜策略的概念。
然后,我们详细讲解并证明了求解可达性目标获胜集的吸引子算法,该算法能产生无记忆获胜策略,并在线性时间内运行。
最后,我们将图游戏与程序验证联系起来,展示了如何将程序正确性问题建模为游戏求解问题,并讨论了处理无限状态空间所面临的挑战及抽象解释等近似方法。
这些概念为后续使用形式化工具进行程序推理奠定了重要的理论基础。

006:求解Büchi与Parity游戏

概述

在本节课中,我们将学习如何求解具有Büchi和Parity目标的图游戏。我们将回顾图游戏的基本定义,然后深入探讨求解Büchi目标的经典算法及其优化版本,最后将算法扩展到更一般的Parity目标。我们将证明这些游戏不仅是确定的,而且存在无记忆的必胜策略。

图游戏回顾

上一节我们介绍了图游戏,本节中我们来看看其核心概念。

首先,我们定义了图游戏。我们说一个竞技场本质上是一个图,其中顶点被划分为两个集合:由玩家1控制的顶点集合和由玩家2控制的顶点集合。这是一个划分,因此每个顶点只有一个所有者。

为了使游戏能够无限进行,我们假设每个顶点至少有一条出边。这个假设在本节中非常重要。

我们讨论了游戏的玩法:从起始顶点开始,每当处于特定顶点时,该顶点的所有者选择走哪条出边。他们无限地进行下去,从而在图上产生一条无限路径,这被称为一个玩法或游戏的结果。

我们定义了目标:玩家的目标本质上是一组无限路径。玩家I希望游戏的结果在其目标集合 OI 中。

我们定义了零和的概念:如果一个玩家的目标是另一个玩家目标的补集,则游戏是零和的。这意味着给定游戏的任何结果,你都可以判断玩家1还是玩家2获胜。

我们定义了一些经典目标。以下是这些目标的简要回顾:

  • 可达性:玩家希望至少访问一次目标集合 T 中的顶点。
  • 安全性:玩家希望始终停留在集合 T 中,永不离开。
  • Büchi:玩家希望无限多次访问目标集合 T 中的顶点。
  • Co-Büchi:玩家希望确保最终进入集合 T 并永远留在其中,即集合 T 之外的顶点只被访问有限次。
  • Parity:这是一个更复杂的条件。我们为每个顶点分配一个优先级(数字),然后观察被无限多次访问的顶点的优先级,并希望这些优先级中的最小值是偶数。

我们证明了Büchi和Co-Büchi是Parity的特殊情况,因此我们将更关注Parity目标。

我们讨论了策略的概念。策略本质上是一个函数,它接收游戏的历史和当前顶点,并告诉玩家从当前顶点应该去哪里。它只是扩展游戏的配方。我们总是使用下标来表示策略、目标等属于哪个玩家。

我们讨论了无记忆策略:如果策略只依赖于游戏的当前状态,而不依赖于游戏的历史,则它是无记忆的。例如,井字棋有无记忆策略,但国际象棋没有,因为存在三次重复局面判和规则。

我们还讨论了,如果为每个玩家固定一个策略并给定初始状态,我们将得到一个唯一的结果。如果玩家1无论玩家2采取什么策略,都能确保游戏结果在其目标集合中,那么该策略就是玩家1的必胜策略。

玩家的必胜集是从该玩家拥有必胜策略的所有状态集合。我们定义了一个游戏是确定的:如果从每个顶点开始,恰好有一个玩家拥有必胜策略。这与零和游戏是不同的概念。

可达性与安全性求解

上一节我们介绍了可达性目标,本节中我们来看看其求解算法。

可达性目标是指玩家1希望到达特定集合 T。我们通过吸引子计算来求解。吸引子 Attr1(T) 是所有玩家1可以确保游戏最终到达 T 的状态集合。

以下是计算吸引子的算法步骤:

  1. 初始化 A0 = T。
  2. 对于每个不在当前集合中的顶点:
    • 如果它是玩家1的顶点,并且有一条边指向当前集合,则将其加入。
    • 如果它是玩家2的顶点,并且其所有出边都指向当前集合,则将其加入。
  3. 重复步骤2,直到没有新顶点可以加入。最终得到的集合 A 就是玩家1的必胜集。

集合 B = V \ A 是玩家1的一个陷阱。在 B 中,玩家2可以确保游戏永远不会到达 T。因此,游戏是确定的,并且双方在各自的必胜集中都有无记忆策略。

求解可达性的算法可以在线性时间 O(n+m) 内完成,其中 n 是顶点数,m 是边数。安全性目标是可达性目标的对偶,因此也可以用线性时间求解。

Büchi目标求解

现在,让我们进入今天的主要内容:求解Büchi目标。

Büchi目标是指玩家1希望无限多次访问目标集合 T。这不仅仅是到达 T 一次,而是需要反复到达。

我们首先定义一些符号。设 A = Attr1(T),即玩家1可以确保到达 T 的状态集合。设 B = V \ A,即玩家1的陷阱。设 C = Attr2(B),即玩家2可以确保最终到达 B 的状态集合。

我们声称,从集合 C 中的任何顶点开始,玩家2都有必胜策略。策略如下:首先使用可达性策略到达 B,然后一旦进入 B,就使用陷阱策略永远留在 B 中。由于在到达 B 的路径上可能有限次访问 T,但这不影响Büchi目标(玩家1需要无限次访问 T),所以玩家2获胜。

因此,我们可以将 C 标记为玩家2的必胜集的一部分。对于剩余的图 V' = V \ C,我们可以递归地求解。因为玩家1不会主动进入 C(否则会输),而玩家2也无法从 V' 强制进入 C(否则 C 会更大),所以 V' 构成一个子游戏。

以下是求解Büchi目标的经典算法伪代码:

W2 = ∅ // 玩家2的必胜集
G_current = G // 当前游戏图
T_current = T // 当前目标集

while True:
    A = Attr1(T_current) in G_current
    B = V_current \ A
    C = Attr2(B) in G_current
    if C is empty:
        break
    W2 = W2 ∪ C
    V_current = V_current \ C
    T_current = T_current \ C
    // 更新 G_current 为在 V_current 上的子游戏

W1 = V \ W2 // 玩家1的必胜集

该算法的时间复杂度为 O(n * m)。每次循环需要计算两个吸引子,时间为 O(m),最多循环 n 次。

更快的Büchi求解算法

上一节我们介绍了Büchi的经典算法,本节中我们来看看一个更快的算法,目标是将时间复杂度降至 O(n²)。

经典算法的瓶颈在于,每次迭代可能只找到一个很小的陷阱,却要扫描整个图(O(m)时间)。优化思路是:用 O(n * k) 的时间找到一个大小为 k 的陷阱,这样将所有迭代的 k 累加(最多为 n),总时间就是 O(n²)。

为此,我们引入分层分解和子图陷阱的概念。我们构建一系列稀疏子图 G0, G1, ..., G_{log n},其中 Gi 的边数约为 n * 2^i。我们尝试在 Gi 中寻找一个陷阱,该陷阱不仅能将玩家1困在顶点集 S 中,还能将其困在边集 E(Gi) 内(即玩家无法使用 Gi 之外的边)。

关键结论是:如果在 Gi 中找到了一个陷阱 Si,那么 Si 的大小至少为 2^{i-1}。而处理 Gi 的时间是 O(n * 2^i)。因此,找到大小为 k 的陷阱所需时间为 O(n * k)。

将这个高效的找陷阱算法嵌入到之前的Büchi算法框架中(用找到的任意陷阱代替原来的 B),我们就得到了一个 O(n²) 的Büchi求解算法。

Parity目标求解

最后,我们将算法扩展到更一般的Parity目标。

Parity目标中,每个顶点有一个优先级 p(v) ∈ {0, 1, ..., d}。玩家1获胜当且仅当在无限玩法中,出现无限多次的顶点的最小优先级是偶数。

我们通过归纳法来证明Parity游戏不仅是确定的,而且存在无记忆必胜策略,同时这也给出了一个求解算法。

归纳基础:当优先级数量为1或2时,游戏退化为平凡的或Büchi/Co-Büchi游戏,结论成立。

归纳步骤:假设对于优先级数量少于 d+1 的游戏结论成立。考虑一个具有优先级 0 到 d 的游戏。

  1. 令 P0 为优先级为 0 的顶点集合。计算 A = Attr1(P0) 和 B = V \ A。
  2. 由于 B 是陷阱,可以在 B 上定义子游戏。该子游戏没有优先级0,因此优先级数量 ≤ d。根据归纳假设,该子游戏是确定且有无记忆策略的。设 B1 和 B2 分别是子游戏中玩家1和玩家2的必胜集。
  3. 声明1:在原始游戏中,从 B2 中的任何顶点开始,玩家2有必胜(无记忆)策略。策略就是采用在子游戏 B 中的必胜策略。因为 B 是陷阱,玩家1无法离开 B 进入 A,所以游戏实际上被限制在子游戏 B 中。
  4. 声明2:令 C = Attr2(B2)。在原始游戏中,从 C 中的任何顶点开始,玩家2有必胜(无记忆)策略。策略是:首先使用可达性策略到达 B2,然后切换到声明1中的策略。由于Parity是“尾属性”,有限前缀不影响最终胜负,所以该策略有效。
  5. 现在,集合 C 是玩家2的必胜集。考虑剩余的子游戏 V' = V \ C。由于 C 是吸引子,V' 是其补集,因此是一个陷阱,可以定义子游戏。
  6. 我们在 V' 上递归地应用此过程。该过程最终会停止。
  7. 当算法无法再找到非空的 C(即 B2 为空)时,意味着在子游戏 B 中,玩家1处处必胜。此时,我们可以为玩家1构造一个全局必胜策略:
    • 当游戏在 A 中时,玩家1采用到达 P0 的可达性策略。
    • 当游戏在 B 中时,玩家1采用在子游戏 B 中的必胜策略。
    • 如果玩家2有限次进入 A,则游戏最终永远留在 B 中,玩家1因在 B 中的策略而获胜。
    • 如果玩家2无限次进入 A,则玩家1无限次访问 P0(优先级0),从而满足Parity条件(最小偶优先级)而获胜。

该算法的时间复杂度递归式为:T(n, m, d) ≤ n * [T(n, m, d-1) + O(m)]。解之可得时间复杂度约为 O(n^{d-1} * m)。

总结

本节课中我们一起学习了:

  1. 回顾了图游戏、目标、策略、确定性和无记忆策略等基本概念。
  2. 学习了求解可达性和安全性目标的线性时间算法。
  3. 深入探讨了求解Büchi目标的经典 O(n*m) 算法及其优化后的 O(n²) 算法。
  4. 将算法扩展到Parity目标,证明了Parity游戏的确定性和无记忆策略存在性,并给出了一个时间复杂度为 O(n^{d-1} * m) 的求解算法。

这些算法是形式化验证和自动机理论中的重要工具,为分析系统属性(如活性、公平性)提供了基础。

007:Coq中的归纳推理与类型

概述

在本节课中,我们将学习Coq作为一种函数式编程语言的基础知识。我们将从定义新类型和函数开始,逐步深入到使用归纳定义、模式匹配和递归函数。最后,我们将学习如何在Coq中证明关于这些定义的定理,包括使用归纳法进行证明。


定义新类型

在Coq中,我们可以使用Inductive关键字来定义新的类型。这类似于在C语言中使用struct或在C++/Java中使用class,但Coq的类型定义是归纳式的。

以下是定义一个表示星期几的新类型day的示例:

Inductive day : Type :=
  | Monday
  | Tuesday
  | Wednesday
  | Thursday
  | Friday
  | Saturday
  | Sunday.

执行此定义后,Coq会声明day是一个已定义的类型,并创建了相应的构造函数。

定义函数与模式匹配

定义好类型后,我们可以定义操作该类型的函数。在函数式编程中,我们通常使用模式匹配来定义函数。

以下是一个函数next_workday的示例,它接收一个day类型的参数并返回下一个工作日:

Definition next_workday (d: day) : day :=
  match d with
  | Monday => Tuesday
  | Tuesday => Wednesday
  | Wednesday => Thursday
  | Thursday => Friday
  | Friday => Monday
  | Saturday => Monday
  | Sunday => Monday
  end.

在Coq中进行模式匹配时,必须覆盖所有可能的情况,否则Coq会报错“模式匹配不完整”。

我们可以使用Compute命令来执行计算:

Compute (next_workday Wednesday). (* 结果为 Thursday *)
Compute (next_workday (next_workday Tuesday)). (* 结果为 Thursday *)

定理、引理与示例

在Coq中,TheoremLemmaExample关键字本质上是相同的,都用于陈述需要证明的命题。我们通常使用Example来编写测试。

以下是一个示例,它陈述了一个关于next_workday的事实:

Example test_next_workday:
  (next_workday (next_workday Tuesday)) = Thursday.

当我们写下这个Example时,Coq会进入证明模式,我们需要提供证明。对于这个简单的等式,我们可以使用compute策略来计算左边,然后使用reflexivity策略来证明两边相等。

Proof.
  compute.
  reflexivity.
Qed.

reflexivity策略足够智能,它会在尝试证明等式前自动进行计算。

定义布尔类型与操作

现在,让我们在一个新模块中定义自己的布尔类型和基本操作,以避免与标准库冲突。

我们首先定义布尔类型bool

Module MyBool.
Inductive bool : Type :=
  | true
  | false.

接着,我们定义布尔取反操作negb

Definition negb (b: bool) : bool :=
  match b with
  | true => false
  | false => true
  end.

使用Check命令可以查看表达式的类型,使用Compute命令可以计算表达式的值。

Check (negb true). (* 类型为 bool *)
Compute (negb true). (* 结果为 false *)

现在,我们来定义合取(与)操作andb。有多种定义方式,例如使用短路求值:

Definition andb (b1 b2: bool) : bool :=
  match b1 with
  | false => false
  | true => b2
  end.

或者,通过同时匹配两个参数来定义:

Definition andb' (b1 b2: bool) : bool :=
  match b1, b2 with
  | true, true => true
  | _, _ => false
  end.

我们也可以为操作符定义中缀记号,使其使用起来更自然:

Notation "x && y" := (andb x y).

定义更复杂的类型

Coq的归纳类型定义可以包含参数,从而构建更复杂的数据结构。

例如,我们可以先定义一个基本颜色类型rgb,然后定义一个扩展的颜色类型color,它除了包含黑白两色,还能包含一个rgb类型的基本色:

Inductive rgb : Type :=
  | red
  | green
  | blue.

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/hkust-prog-fmlrsn/img/b91fd178918432bd80196357f902b4d5_9.png)

Inductive color : Type :=
  | white
  | black
  | primary (p : rgb).

这里,primary是一个构造函数,它接受一个rgb类型的参数。因此,primary redprimary green等都是color类型的合法值。

我们可以定义一个函数来判断一个颜色是否为黑白(单色):

Definition monochrome (c : color) : bool :=
  match c with
  | white => true
  | black => true
  | primary _ => false
  end.

定义自然数与递归函数

自然数在Coq中是通过归纳法定义的:一个自然数要么是零(O),要么是另一个自然数的后继(S n)。

Module MyNat.
Inductive nat : Type :=
  | O
  | S (n : nat).

这里,O代表零,S代表后继函数。因此,S O表示1,S (S O)表示2,依此类推。

现在,我们可以定义操作自然数的函数。例如,定义前驱函数pred

Definition pred (a : nat) : nat :=
  match a with
  | O => O
  | S n' => n'
  end.

对于更复杂的函数,如判断一个数是否为偶数的函数my_even,我们需要使用递归定义。在Coq中,我们使用Fixpoint关键字来定义递归函数。

Fixpoint my_even (a : nat) : bool :=
  match a with
  | O => true
  | S O => false
  | S (S n') => my_even n'
  end.

然后,我们可以基于my_even定义判断奇数的函数my_odd,而无需再次使用递归:

Definition my_odd (a : nat) : bool :=
  negb (my_even a).

定义算术运算

现在,让我们定义加法、乘法等算术运算。我们将使用Coq标准库中已定义的nat类型。

首先定义加法plus。加法的定义是递归的:如果第一个参数是O,则结果为第二个参数;否则,结果为第一个参数的前驱加第二个参数的后继。

Fixpoint plus (n m : nat) : nat :=
  match n with
  | O => m
  | S n' => S (plus n' m)
  end.

类似地,我们可以定义乘法mult

Fixpoint mult (n m : nat) : nat :=
  match n with
  | O => O
  | S n' => plus m (mult n' m)
  end.

以及减法minus(这里定义的是截断减法,即如果结果小于零则返回零):

Fixpoint minus (n m : nat) : nat :=
  match n, m with
  | O, _ => O
  | S _, O => n
  | S n', S m' => minus n' m'
  end.

证明关于自然数的定理

有了这些定义,我们就可以开始证明定理了。Coq中的证明是通过一系列策略(tactics)来完成的。

第一个简单的定理是:对于任意自然数n0 + n = n

Theorem plus_O_n : forall n : nat, 0 + n = n.
Proof.
  intros n.
  compute.
  reflexivity.
Qed.

证明过程如下:

  1. intros n:将全称量词forall n引入上下文,得到一个假设n : nat,目标变为0 + n = n
  2. compute:计算目标左边0 + n,根据plus的定义,结果就是n。目标变为n = n
  3. reflexivity:根据自反性,证明n = n

然而,如果我们交换加数的顺序,证明就不会这么简单了。考虑定理:对于任意自然数nn + 0 = n

Theorem plus_n_O : forall n : nat, n + 0 = n.
Proof.
  intros n.
  compute. (* 这里计算不会简化目标,因为plus的定义是对第一个参数进行模式匹配 *)

直接计算无法简化目标,因为plus的定义递归地依赖于第一个参数。我们需要对n进行归纳证明。

  induction n as [| n' IHn'].
  - (* 情况 1: n = O *)
    compute.
    reflexivity.
  - (* 情况 2: n = S n' *)
    simpl.
    rewrite -> IHn'.
    reflexivity.
Qed.

证明过程如下:

  1. intros n:引入n
  2. induction n as [| n' IHn']:对n进行归纳。这会产生两个子目标:
    • 基础情况:当nO时,证明O + 0 = O
    • 归纳步骤:假设对于某个n'n' + 0 = n'(归纳假设IHn'),需要证明(S n') + 0 = S n'
  3. 基础情况:compute后使用reflexivity
  4. 归纳步骤:
    • simpl:简化目标左边(S n') + 0,根据plus的定义,它变成S (n' + 0)。目标变为S (n' + 0) = S n'
    • rewrite -> IHn':使用归纳假设IHn',将n' + 0重写为n'。目标变为S n' = S n'
    • reflexivity:根据自反性完成证明。

证明更复杂的定理

使用归纳法和已有的引理,我们可以证明加法的交换律和结合律等更复杂的性质。

例如,证明加法交换律plus_comm

Theorem plus_comm : forall n m : nat, n + m = m + n.
Proof.
  intros n m.
  induction n as [| n' IHn'].
  - (* 基础情况: n = O *)
    simpl.
    rewrite <- plus_n_O. (* 使用定理 plus_n_O: forall n, n + 0 = n *)
    reflexivity.
  - (* 归纳步骤: n = S n' *)
    simpl.
    rewrite -> IHn'.
    (* 此时需要证明 S (m + n') = m + (S n') *)
    (* 这本身需要另一个引理 plus_n_Sm : forall n m, S (n + m) = n + S m *)
    rewrite -> plus_n_Sm.
    reflexivity.
Qed.

其中,引理plus_n_Sm也需要通过归纳法单独证明。

总结

本节课我们一起学习了Coq编程语言的基础。我们了解了如何使用Inductive关键字归纳地定义新类型,如何使用DefinitionFixpoint通过模式匹配来定义函数和递归函数。我们掌握了基本的证明策略,如introscompute/simplreflexivityrewritedestruct(用于分情况讨论)。最重要的是,我们学习了如何使用induction策略进行归纳证明,这是证明关于递归定义的性质的核心工具。通过这些基础,我们已经可以开始形式化地定义数据结构并证明其属性。

008:无限词上的Büchi自动机

在本节课中,我们将学习一种处理无限序列的自动机模型——Büchi自动机。我们将从回顾有限自动机的基本概念开始,逐步扩展到无限词上的非确定性Büchi自动机,并探讨其表达能力、闭包性质以及相关的判定问题。

概述:从有限自动机到无限自动机

上一节我们介绍了游戏与无限路径的关系。本节中,我们将学习一种专门处理无限输入序列的自动机模型。这种模型是理解程序在无限时间上行为的关键工具。

首先,让我们回顾一下确定性有限自动机的基本组成部分。

确定性有限自动机回顾

一个确定性有限自动机由以下几个部分组成:

  • 一个有限的字母表 Σ,用于书写单词。
  • 一个有限的状态集合 Q。
  • 一个初始状态 q₀ ∈ Q。
  • 一个转移函数 δ: Q × Σ → Q,它定义了在给定状态和输入字母下,自动机将转移到哪个状态。
  • 一个接受状态集合 T ⊆ Q。

自动机从初始状态 q₀ 开始,逐字母读取输入词。对于每个字母,它根据转移函数移动到下一个状态。当整个词读取完毕后,如果最终状态在集合 T 中,则该词被接受;否则被拒绝。自动机接受的所有词的集合称为它的语言。

例如,考虑以下自动机:

  • 状态:Q =
  • 初始状态:q₀
  • 接受状态:T =
  • 转移:δ(q₀, 0) = q₀, δ(q₀, 1) = q₁, δ(q₁, 0) = q₁, δ(q₁, 1) = q₁

这个自动机接受所有至少包含一个字母“1”的有限词。

非确定性有限自动机回顾

非确定性有限自动机扩展了确定性模型,允许在状态转移和起始状态上存在选择。

一个非确定性有限自动机定义为:

  • 字母表 Σ。
  • 状态集合 Q。
  • 初始状态集合 I ⊆ Q,而不仅是一个状态。
  • 转移函数 δ: Q × Σ → 2^Q,它给出一个可能的下一个状态集合。
  • 接受状态集合 T ⊆ Q。

对于一个输入词,非确定性自动机可以有多条不同的运行路径。我们定义:一个词被接受,当且仅当存在至少一条从某个初始状态开始、最终停在某个接受状态的运行路径。

无限词与Büchi自动机

现在,我们将关注点从有限词转移到无限词上。我们希望自动机能够读取一个无限的输入序列。这被称为ω-自动机。

如果我们简单地将一个有限自动机应用于无限输入,会遇到一个问题:运行永远不会结束,因此无法根据“最终状态”来判断是否接受。我们需要一个新的接受条件。

以下是两种重要的接受条件:

  1. Büchi条件:给定一个接受状态集合 T。一个无限运行是接受的,当且仅当它无限次地访问 T 中的状态。形式化地,设 Inf(ρ) 为运行 ρ 中无限次出现的状态集合,则接受条件为 Inf(ρ) ∩ T ≠ ∅
  2. Parity条件:为每个状态分配一个整数优先级。一个无限运行是接受的,当且仅当在运行中无限次出现的最小优先级是偶数。

我们主要关注Büchi自动机。根据其转移函数是确定性的还是非确定性的,我们分别记作 DBWNBW

Büchi自动机示例

让我们通过几个例子来理解Büchi自动机接受的语言。

以下是几个Büchi自动机及其接受的语言分析:

  • 自动机A1:接受所有包含无限多个字母“1”的无限词。
  • 自动机A2:接受所有“最终全是1”的无限词,即存在一个位置,在此之后只出现字母“1”。其语言可表示为 (0+1)*1^ω
  • 自动机A3:接受所有包含无限多个“1”,或者包含有限个“1”但个数为奇数的无限词。
  • 自动机A4:接受所有同时包含无限多个“1”和无限多个“0”的无限词。

这些例子展示了Büchi自动机强大的表达能力,能够描述各种复杂的无限行为模式。

确定性 vs. 非确定性Büchi自动机

在有限自动机理论中,非确定性有限自动机与确定性有限自动机具有同等的表达能力。然而,对于Büchi自动机,情况并非如此。

定理:非确定性Büchi自动机严格比确定性Büchi自动机更强大。存在一种语言,可以被一个NBW识别,但没有任何DBW可以识别它。

证明思路(反证法)
考虑语言 L = { w ∈ {0,1}^ω | w 中只包含有限个0 },即“最终全是1”的语言。可以构造一个NBW识别L。假设存在一个DBW A 也识别L。利用A状态数有限的性质,可以构造一个特殊的无限词,它被A接受(根据假设),但其中包含无限多个0,这与L的定义矛盾。因此,这样的DBW A不存在。

这个结论非常重要:当我们谈论ω-正则语言(由Büchi自动机识别的语言)时,默认允许非确定性。

Büchi自动机的闭包性质

我们研究Büchi自动机在集合运算下的封闭性。

对于确定性Büchi自动机

  • 对并运算封闭:给定两个DBW A1和A2,可以通过构造乘积自动机来模拟两者,并定义新的接受条件为“A1接受或A2接受”。
  • 对交运算封闭:同样构造乘积自动机,但接受条件更复杂。我们需要运行无限次访问A1和A2各自的接受集。可以通过一个“交替检查”的构造来实现:创建两个副本,当在第一个副本中看到A1的接受状态时,切换到第二个副本;当在第二个副本中看到A2的接受状态时,切换回第一个副本;并将最终接受集定义为第二个副本中A2的接受状态。这样,要无限次访问最终接受集,就必须无限次访问A1和A2的接受集。
  • 对补运算不封闭:这是DBW与NBW表达能力不同的直接体现。

对于非确定性Büchi自动机

  • 对并运算封闭:构造非常简单,将两个自动机并列,让非确定性选择从哪个自动机开始运行即可。
  • 对交运算封闭:可以采用与DBW类似的“交替检查”乘积构造,但由于基础自动机是非确定性的,乘积自动机也是非确定性的。
  • 对补运算封闭:这是一个深刻的结果,其构造远比并和交复杂。我们将在后面详细讨论。

从DBW到NBW的补运算

虽然DBW对补运算不封闭,但给定一个DBW A,我们可以构造一个NBW来识别其补语言。这个构造相对直观。

构造思路

  1. 创建自动机A的两个副本:副本1(原始A)和副本2(从A中删除了所有接受状态T)。
  2. 在副本1中,对于每个状态q和字母a,除了原有的转移到δ(q,a)(仍在副本1),增加一条非确定性转移到副本2中的状态δ(q,a)。
  3. 选择这条新增的转移,意味着“承诺”从此不再看到接受状态。
  4. 将副本2中的所有状态设为新的接受状态。

原理:原DBW A接受一个词w,当且仅当w的唯一运行无限次访问T。在构造的NBW中,要接受w,必须在某个有限前缀后,通过非确定性选择进入副本2,并永远停留在那里(因为副本2没有T中的状态)。这正好对应了原运行只有限次访问T的情况,即补语言。

注意:这个构造严重依赖于A的确定性(每个词有唯一运行)。对于NBW,由于一个词可能对应多个运行,此构造失效。

Büchi自动机的判定问题

现在我们来探讨几个关于Büchi自动机的核心判定问题。

  1. 空性问题:给定一个NBW(或DBW)A,问 L(A) 是否为空集?

    • 解法:将A视为一个单玩家Büchi游戏图(所有状态由玩家1控制)。空性问题等价于问:玩家1是否存在一条从初始状态出发、无限次访问接受状态T的路径?这是一个Büchi游戏的可解性问题,可以在 O(n+m) 时间内解决(n为状态数,m为转移数),例如通过寻找包含接受状态的、非平凡的、可达的强连通分量。
  2. 全集性问题:给定一个NBW A,问 L(A) 是否为所有无限词的集合 Σ^ω?

    • 对于DBW,这个问题可以在多项式时间内解决。因为词与运行一一对应,全集性等价于问:在自动机图中,是否所有从初始状态出发的无限路径都无限次访问T?这可以通过检查是否每个可达的环都包含T中的状态来解决,同样是线性时间。
    • 对于NBW,全集性问题要困难得多。事实上,它的补问题(非全集性问题)是 NP-难的。这可以通过从经典的NP完全问题“图的三着色问题”进行归约来证明。因此,除非P=NP,否则不存在多项式时间的NBW全集性判定算法。
  3. 包含问题:给定两个NBW A和B,问是否有 L(A) ⊆ L(B)?

    • 如果我们已经解决了补运算和空性问题,那么包含问题可以归约到它们:L(A) ⊆ L(B) 当且仅当 L(A) ∩ (Σ^ω \ L(B)) = ∅。因此,先构造B的补自动机B'(NBW),再构造A与B'交的自动机C,最后检查C是否为空即可。
  4. 补运算问题(针对NBW):给定一个NBW A,构造一个NBW B,使得 L(B) = Σ^ω \ L(A)。

    • 如前所述,这是可能的,但构造复杂。已知的Büchi原始构造是双重指数时间的。下面概述其核心思想。

NBW补运算的构造(Büchi构造概述)

Büchi的补运算构造非常精巧,它利用了拉姆齐定理这一组合数学工具。以下是构造步骤的概要:

  1. 定义有限词的等价关系:对于两个非空有限词u和v,定义它们等价(u ~ v),如果对于自动机A中的任意状态对(p, q),满足:

    • 存在一条从p到q、标记为u的路径,当且仅当存在一条从p到q、标记为v的路径。
    • 存在一条从p到q、标记为u且经过至少一个接受状态的路径,当且仅当存在一条从p到q、标记为v且经过至少一个接受状态的路径。
    • 这个等价关系只有有限多个等价类,记某个等价类为 [w],其对应的语言(有限词集合)是正则的。
  2. 应用拉姆齐定理:考虑任意一个无限词w。将自然数索引视为完全图的顶点。对于顶点 i < j,用子词 w[i,j) 所属的等价类来给边(i, j)着色。由于等价类有限,根据拉姆齐定理,存在一个无限的单色顶点子集 i₁ < i₂ < i₃ < ...。这意味着所有子词 w[i₁, i₂), w[i₂, i₃), ... 都属于同一个等价类,记作 [v]。而前缀 w[0, i₁) 属于另一个等价类 [u]。因此,任何无限词w都可以写成形式 w ∈ L_u L_v^ω,其中 L_u, L_v 是某个等价类对应的正则语言。

  3. 关键性质:对于任意两个等价类 [u][v],集合 L_u L_v^ω 要么与 L(A) 不相交,要么完全包含在 L(A) 中。这是因为,如果有一个词 w = u v v v ... 被A接受,那么利用等价关系的定义,可以将任何 u' ∈ L_uv' ∈ L_v 进行替换,得到的词 u' v' v' v' ... 也会被A接受。

  4. 构造补自动机:由于等价类有限,形如 L_u L_v^ω 的集合也只有有限多个。补自动机B就是所有那些不包含在 L(A) 中的 L_u L_v^ω 集合的并集。对于每个这样的集合,由于 L_uL_v 是正则的,我们可以构造一个NBW来识别 L_u L_v^ω(例如,用NFA识别 L_u,然后连接到识别 L_v 的NFA,并将后者的接受状态连回其初始状态以形成无限循环)。最后,取这些NBW的并集,就得到了识别补语言的NBW B。

这个构造证明了ω-正则语言在补运算下是封闭的,尽管构造本身在计算上非常昂贵。

总结

本节课中我们一起学习了Büchi自动机及其理论。

  • 我们首先回顾了有限自动机,然后将其扩展到无限词上,引入了以Büchi条件和Parity条件作为接受条件的ω-自动机。
  • 我们明确了非确定性Büchi自动机比确定性Büchi自动机具有更强的表达能力,并且ω-正则语言通常由NBW定义。
  • 我们探讨了DBW和NBW在并、交、补运算下的封闭性:DBW对补运算不封闭;NBW对所有三种运算都封闭,但补运算的构造非常复杂。
  • 我们研究了几个判定问题:空性问题可在多项式时间内解决;全集性问题对于NBW是NP-难的;包含问题可以归约到补运算和空性问题。
  • 最后,我们概述了Büchi提出的NBW补运算构造,该构造巧妙地运用了拉姆齐定理,证明了ω-正则语言对补运算的封闭性,尽管其时间复杂度很高。

理解Büchi自动机是进行程序形式化验证,特别是针对线性时序性质验证的基础。在接下来的课程中,我们将看到如何将这些自动机理论与逻辑和验证任务结合起来。

009:线性时序逻辑 (LTL)

概述

在本节课中,我们将学习线性时序逻辑。这是一种用于描述程序运行时行为的逻辑语言。我们将看到如何用它来形式化地表达程序应满足的时序性质,并最终将其与之前学习的自动机和游戏理论联系起来,构建完整的模型检查流程。


从游戏与自动机到逻辑

上一节我们介绍了模型检查的两种形式化方法:无限时长双人游戏和无限字上的自动机。本节中我们来看看它们如何解决同一类问题。

这两种方法本质上都在处理语言包含问题。在游戏中,玩家一的目标是确保所有可能的游戏进程都属于一个可接受的无限字集合。在自动机中,我们希望证明程序所有可能的运行都包含在满足规范的运行集合中。这同样是检查一个语言是否是另一个语言的子集。

现在,我们将看到第三种形式化方法:线性时序逻辑。它使用逻辑公式而非自动机来描述规范,为模型检查提供了另一种视角。


线性时序逻辑简介

线性时序逻辑是一种可以描述时间相关性质的逻辑。“线性时间”意味着我们只考虑单一的、线性的未来,而非分支的多种可能性。

LTL 公式用于指定无限字的集合。与使用自动机 B 来定义可接受的运行不同,我们使用一个逻辑公式来描述程序运行应满足的性质。


基本定义:迹与原子命题

首先,我们定义一些基本概念。

  • 原子命题集 P:一个有限的命题集合,每个命题在任意时刻可以为真或假。
  • 字母表 Σ:所有 P 的子集构成的集合,即 Σ = 2^P。
  • 迹 τ:字母表 Σ 上的一个无限字,即 τ ∈ Σ^ω。一个迹表示一个无限的运行时序,其中每个时刻 τ_i 指明了哪些原子命题成立。

示例:交通灯系统
假设原子命题为 {R, G, Y},分别代表红灯、绿灯、黄灯亮。一个正确的迹可能只在每个时刻恰好有一个灯亮,例如序列 {R}, {G}, {Y}, {R}, ...。而不正确的迹可能包含 {R, G}(红绿灯同时亮)或 ∅(所有灯都灭)。

我们希望用逻辑公式来表达“任何时刻都恰好有一个灯亮”这一性质。


LTL 语法

LTL 公式 φ 的语法归纳定义如下:

  1. true:恒真公式。
  2. p:若 p ∈ P 是一个原子命题,则 p 是公式,表示“命题 p 在当前时刻(时间0)成立”。
  3. ¬φ:若 φ 是公式,则 ¬φ 是公式,表示逻辑非。
  4. φ₁ ∨ φ₂:若 φ₁, φ₂ 是公式,则 φ₁ ∨ φ₂ 是公式,表示逻辑或。
  5. X φ:若 φ 是公式,则 X φ 是公式,表示“下一个时刻 φ 成立”。
  6. φ₁ U φ₂:若 φ₁, φ₂ 是公式,则 φ₁ U φ₂ 是公式,表示“φ₁ 一直成立,直到 φ₂ 成立为止”。

其他逻辑连接词(如 ∧, →, false)和时序算子(F, G)可作为语法糖定义:

  • F φ (eventually):定义为 true U φ,表示“未来某个时刻 φ 成立”。
  • G φ (always/globally):定义为 ¬F ¬φ,表示“从现在起所有时刻 φ 都成立”。

LTL 语义

一个 LTL 公式 φ 的语义是一个无限迹的集合,即所有满足该公式的迹的集合。我们定义迹 τ 满足公式 φ,记作 τ ⊨ φ。

以下是满足关系的归纳定义规则:

  • τ ⊨ true:恒成立。
  • τ ⊨ p:当且仅当 p ∈ τ₀(原子命题 p 在迹的第一个时刻成立)。
  • τ ⊨ ¬φ:当且仅当 τ ⊭ φ。
  • τ ⊨ φ₁ ∨ φ₂:当且仅当 τ ⊨ φ₁ 或 τ ⊨ φ₂。
  • τ ⊨ X φ:当且仅当 τ¹ ⊨ φ,其中 τ¹ 是 τ 去掉第一个时刻后的后缀。
  • τ ⊨ φ₁ U φ₂:当且仅当存在某个 j ≥ 0,使得 τʲ ⊨ φ₂,并且对于所有 0 ≤ i < j,有 τⁱ ⊨ φ₁。
  • τ ⊨ F φ:当且仅当存在某个 i ≥ 0,使得 τⁱ ⊨ φ。
  • τ ⊨ G φ:当且仅当对于所有 i ≥ 0,都有 τⁱ ⊨ φ。

表达经典目标

我们可以用 LTL 公式来表达之前在游戏中定义的经典目标(如可达性、安全性、Büchi、Co-Büchi、奇偶性)。

设 A_P 是所有包含原子命题 p 的字母(即状态)的集合。

  • 可达性 (Reach(A_P)):公式为 F p。表示最终会到达一个 p 成立的状态。
  • 安全性 (Safe(A_P)):公式为 G p。表示始终不会进入 p 不成立的状态。
  • Büchi (Büchi(A_P)):公式为 G F p。表示无限经常地访问 p 成立的状态。
  • Co-Büchi (CoBüchi(A_P)):公式为 F G p。表示从某个时刻起,p 永远成立。
  • 奇偶性 (Parity):可以通过嵌套的 G F 和 ¬ 组合来表达。例如,最小无限经常出现的优先级为偶数,可以表达为 (GF p₀) ∨ (¬GF p₀ ∧ ¬GF p₁ ∧ GF p₂) ∨ ...。

LTL 公式的等价性

两个 LTL 公式 φ₁ 和 φ₂ 是等价的,当且仅当它们具有相同的语义(即满足它们的迹集合相同)。

以下是一些重要的等价关系:

  • ¬ X φ ≡ X ¬φ
  • F (φ₁ ∨ φ₂) ≡ F φ₁ ∨ F φ₂
  • F (φ₁ ∧ φ₂) 不等价于 F φ₁ ∧ F φ₂ (因为满足时间点可能不同)
  • G (φ₁ ∧ φ₂) ≡ G φ₁ ∧ G φ₂
  • G (φ₁ ∨ φ₂) 不等价于 G φ₁ ∨ G φ₂
  • φ₁ U φ₂ ≡ φ₂ ∨ (φ₁ ∧ X (φ₁ U φ₂)) (这是“直到”算子的展开式)
  • F φ ≡ φ ∨ X F φ
  • G φ ≡ φ ∧ X G φ

最后三个等价式揭示了 LTL 公式与程序语义中 while 循环的相似性,都可以通过不动点来定义其指称语义。


从 LTL 到广义 Büchi 自动机

模型检查的关键步骤是将 LTL 公式转换为等价的自动机。由于某些性质(如 Co-Büchi)无法用确定性 Büchi 自动机表达,我们使用非确定性广义 Büchi 自动机。

广义 Büchi 自动机 (GNBW) 与普通 Büchi 自动机 (NBW) 类似,但接受条件是一组目标状态集 {T₁, T₂, ..., T_k}。一个运行被接受,当且仅当它无限经常地访问每一个 T_i。

定理:任何广义 Büchi 自动机都可以转换为一个接受相同语言的普通 Büchi 自动机。构造方法是创建 k 个自动机副本,并在访问完一个目标集后切换到下一个副本。


LTL 到广义 Büchi 自动机的构造

对于任意 LTL 公式 φ,我们可以构造一个广义非确定性 Büchi 自动机 A,使得 L(A) 正是满足 φ 的所有迹的集合。

构造的核心思想是:自动机的每个状态代表公式 φ 的闭包中一组可能成立的子公式。闭包包含 φ 的所有子公式及其否定。

构造步骤

  1. 计算闭包:找出公式 φ 的所有子公式及其否定。
  2. 定义状态:状态是闭包的初等子集。一个初等子集 q 是满足以下条件的最大一致子集:
    • 命题一致性:对于每个子公式 ψ,ψ 和 ¬ψ 有且仅有一个在 q 中;连接词(如 ∧)的处理符合命题逻辑。
    • 时序一致性:满足“直到”算子的语义约束。例如,如果 (ψ₁ U ψ₂) ∈ q 但 ψ₂ ∉ q,则必须有 ψ₁ ∈ q。
  3. 初始状态:初始状态是所有包含 φ 本身的初等子集。
  4. 转移关系:从状态 q 在读入字母 a 后,可以转移到状态 q‘,当且仅当:
    • 原子命题匹配:a 必须恰好等于 q 中所有原子命题的集合。
    • 下一时刻公式:如果 X ψ ∈ q,则必须有 ψ ∈ q‘。
    • 直到公式:如果 (ψ₁ U ψ₂) ∈ q,那么要么 ψ₂ ∈ q,要么 (ψ₁ ∈ q 且 (ψ₁ U ψ₂) ∈ q‘)。
  5. 广义接受条件:对于闭包中每一个形如 (ψ₁ U ψ₂) 的公式,定义一个接受集 T_(ψ₁Uψ₂)。该接受集包含所有不包含 (ψ₁ U ψ₂) 的状态,以及所有包含 ψ₂ 的状态。一个运行被接受,需要无限经常地访问所有这样的接受集。这保证了任何承诺要满足 (ψ₁ U ψ₂) 的状态,最终都必须到达一个真正满足 ψ₂ 的状态。

此构造产生的自动机状态数最多为 2^(|Closure(φ)|),即相对于公式长度是指数级的。


LTL 模型检查流程

现在,我们可以将整个模型检查流程串联起来:

  1. 程序建模:将待验证的程序 P 建模为一个非确定性 Büchi 自动机 A_P,使得 L(A_P) 是程序所有可能运行的集合。
  2. 规范描述:将期望的程序性质(规范)描述为一个 LTL 公式 φ。
  3. 规范转换:使用上述构造,将 LTL 公式 φ 转换为一个非确定性 Büchi 自动机 A_¬φ,使其接受所有不满足 φ 的迹。这通常先构造广义 Büchi 自动机,再转换为普通 Büchi 自动机,最后利用 Büchi 补全算法得到 A_¬φ。
  4. 语言包含检查:验证是否 L(A_P) ⊆ L(A_φ)。这等价于检查 L(A_P) ∩ L(A_¬φ) 是否为空集。
    • 计算自动机交集 A = A_P ∩ A_¬φ。
    • 检查 A 的语言是否为空(即是否存在可接受的运行)。这可以通过在 A 的状态图上寻找满足接受条件的环来完成。
  5. 得出结论
    • 如果 L(A) 为空,则程序 P 满足规范 φ。
    • 如果 L(A) 非空,则 A 中任何可接受的运行都是程序 P 违反规范 φ 的一个反例。

总结

本节课我们一起学习了线性时序逻辑。我们了解了其语法和语义,看到了它如何用于描述程序的时序性质。更重要的是,我们掌握了将 LTL 公式转换为等价的 Büchi 自动机的核心构造方法。结合之前学习的自动机补全、交集和空性检查算法,这最终形成了一套完整的、自动化的 LTL 模型检查流程,使我们能够形式化地验证程序是否满足其规约。

010:Coq中的列表、选项与多态性

在本节课中,我们将学习Coq中函数式编程的基础知识,包括如何定义和使用列表、选项类型以及多态性。这些概念是后续在Coq中进行自动化或半自动化逻辑推理的基础。

概述

上一节我们介绍了线性时序逻辑和霍尔逻辑。本节中,我们来看看如何在Coq中定义和使用基本的数据结构,如列表和选项类型,并引入多态性的概念,以便编写更通用的代码。

定义自然数对

首先,我们定义一个由两个自然数组成的类型,称为 NatProd

Inductive NatProd : Type :=
| pair (n1 n2 : nat).

接下来,我们定义两个函数来获取对中的第一个和第二个元素。

Definition first (p : NatProd) : nat :=
  match p with
  | pair x y => x
  end.

Definition second (p : NatProd) : nat :=
  match p with
  | pair x y => y
  end.

为了简化书写,我们可以定义一种记号,使用括号和逗号来表示对。

Notation "( x , y )" := (pair x y).

现在,我们可以轻松地创建和操作自然数对。

Compute first (2, 3). (* 结果为 2 *)

定义自然数列表

接下来,我们定义一个自然数列表的类型 NatList

Inductive NatList : Type :=
| nil
| cons (head : nat) (tail : NatList).

为了简化列表的表示,我们定义一些记号。

Notation "x :: l" := (cons x l) (at level 60, right associativity).
Notation "[ ]" := nil.
Notation "[ x ; .. ; y ]" := (cons x .. (cons y nil) ..).

现在,我们可以方便地创建和操作列表。

Definition example_list := [1; 2; 3].

列表的基本操作

以下是列表的一些基本操作函数。

重复元素函数

repeat 函数将一个自然数重复指定次数,生成一个列表。

Fixpoint repeat (n : nat) (count : nat) : NatList :=
  match count with
  | O => []
  | S count' => n :: (repeat n count')
  end.

计算列表长度

length 函数计算列表的长度。

Fixpoint length (l : NatList) : nat :=
  match l with
  | [] => O
  | h :: t => S (length t)
  end.

列表连接

append 函数将两个列表连接在一起。

Fixpoint append (l1 l2 : NatList) : NatList :=
  match l1 with
  | [] => l2
  | h :: t => h :: (append t l2)
  end.

Notation "l1 ++ l2" := (append l1 l2) (at level 60, right associativity).

获取列表头部和尾部

hd 函数返回列表的第一个元素,需要一个默认值来处理空列表的情况。tl 函数返回列表的尾部。

Definition hd (default : nat) (l : NatList) : nat :=
  match l with
  | [] => default
  | h :: _ => h
  end.

Definition tl (l : NatList) : NatList :=
  match l with
  | [] => []
  | _ :: t => t
  end.

获取列表最后一个元素

last_element 函数返回列表的最后一个元素,同样需要一个默认值。

Fixpoint last_element (l : NatList) (default : nat) : nat :=
  match l with
  | [] => default
  | [n] => n
  | _ :: t => last_element t default
  end.

列表的定理证明

现在,我们证明一些关于列表的基本定理。

定理:空列表连接

首先,证明连接空列表不会改变原列表。

Theorem append_nil : forall l : NatList,
  [] ++ l = l.
Proof.
  intros l.
  simpl.
  reflexivity.
Qed.

定理:列表长度与尾部

证明列表尾部的长度是原列表长度的前驱。

Theorem length_tl : forall l : NatList,
  length (tl l) = pred (length l).
Proof.
  intros l.
  destruct l as [| h t].
  - simpl. reflexivity.
  - simpl. reflexivity.
Qed.

定理:连接操作的结合律

证明列表连接操作是结合的。

Theorem append_assoc : forall l1 l2 l3 : NatList,
  (l1 ++ l2) ++ l3 = l1 ++ (l2 ++ l3).
Proof.
  intros l1 l2 l3.
  induction l1 as [| h1 t1 IH].
  - simpl. reflexivity.
  - simpl. rewrite IH. reflexivity.
Qed.

列表反转

定义 reverse 函数来反转列表。

Fixpoint reverse (l : NatList) : NatList :=
  match l with
  | [] => []
  | h :: t => (reverse t) ++ [h]
  end.

定理:反转列表的长度不变

证明反转列表的长度与原列表相同。

Theorem length_reverse : forall l : NatList,
  length (reverse l) = length l.
Proof.
  intros l.
  induction l as [| h t IH].
  - simpl. reflexivity.
  - simpl.
    rewrite -> length_append.
    simpl.
    rewrite -> IH.
    rewrite -> nat_add_comm.
    simpl.
    reflexivity.
Qed.

选项类型

在处理可能不存在的值时,我们使用选项类型 NatOption

Inductive NatOption : Type :=
| none
| some (n : nat).

安全获取列表元素

使用选项类型,我们可以安全地获取列表的第 n 个元素。

Fixpoint nth_error (l : NatList) (n : nat) : NatOption :=
  match l with
  | [] => none
  | h :: t => match n with
              | O => some h
              | S k => nth_error t k
              end
  end.

部分映射

我们还可以定义部分映射类型 PartialMap,用于将键映射到值。

Inductive PartialMap : Type :=
| empty
| binding (k : nat) (v : nat) (m : PartialMap).

更新和查找映射

定义 update 函数来更新映射,以及 find 函数来查找键对应的值。

Definition update (k : nat) (v : nat) (m : PartialMap) : PartialMap :=
  binding k v m.

Fixpoint find (k : nat) (m : PartialMap) : NatOption :=
  match m with
  | empty => none
  | binding k' v m' => if k =? k' then some v else find k m'
  end.

定理:更新后查找

证明在映射中更新键值后,查找该键会返回更新后的值。

Theorem find_update : forall (m : PartialMap) (k v : nat),
  find k (update k v m) = some v.
Proof.
  intros m k v.
  simpl.
  rewrite <- eq_refl.
  reflexivity.
Qed.

多态性

为了使代码更通用,我们引入多态性。例如,定义多态列表类型 List

Inductive List (X : Type) : Type :=
| nil
| cons (x : X) (l : List X).

Arguments nil {X}.
Arguments cons {X} _ _.

多态重复函数

定义多态的 repeat 函数,可以重复任何类型的元素。

Fixpoint repeat {X : Type} (x : X) (count : nat) : List X :=
  match count with
  | O => nil
  | S count' => cons x (repeat x count')
  end.

多态连接函数

定义多态的 append 函数,可以连接任何类型的列表。

Fixpoint append {X : Type} (l1 l2 : List X) : List X :=
  match l1 with
  | nil => l2
  | cons h t => cons h (append t l2)
  end.

多态定理证明

证明多态列表的连接操作结合律。

Theorem append_assoc_poly : forall (X : Type) (l1 l2 l3 : List X),
  (l1 ++ l2) ++ l3 = l1 ++ (l2 ++ l3).
Proof.
  intros X l1 l2 l3.
  induction l1 as [| h1 t1 IH].
  - simpl. reflexivity.
  - simpl. rewrite IH. reflexivity.
Qed.

多态选项和乘积类型

我们还可以定义多态的选项类型 Option 和乘积类型 Prod

Inductive Option (X : Type) : Type :=
| none
| some (x : X).

Inductive Prod (X Y : Type) : Type :=
| pair (x : X) (y : Y).

Arguments pair {X} {Y} _ _.

多态 zip 函数

定义 zip 函数,将两个列表的元素配对。

Fixpoint zip {X Y : Type} (lx : List X) (ly : List Y) : List (Prod X Y) :=
  match lx, ly with
  | [], _ => nil
  | _, [] => nil
  | cons hx tx, cons hy ty => cons (pair hx hy) (zip tx ty)
  end.

总结

本节课中,我们一起学习了Coq中列表、选项类型和多态性的基本概念与操作。我们定义了自然数对、列表及其基本操作函数,如重复、长度计算、连接、反转等。通过选项类型,我们能够安全地处理可能不存在的值。引入多态性后,我们可以编写更通用、可重用的代码,定义多态列表、选项、乘积类型及相关函数。最后,我们通过定理证明巩固了对这些概念的理解,为后续在Coq中进行形式化推理打下了坚实的基础。

011:Coq中的高阶函数与进阶策略

在本节课中,我们将学习多态性的更多示例,然后探讨证明新定理的技巧。我们将看到已学策略的新用例,并学习一些新的策略。

多态类型与函数

上一节我们介绍了多态类型,本节中我们来看看如何定义和使用多态函数。

积类型(Product Type)

我们定义了一个积类型,它接受两个类型 XY,并提供一个构造器 pair,用于创建一个包含类型 X 的元素 x 和类型 Y 的元素 y 的对。

Inductive prod (X Y : Type) : Type :=
| pair (x : X) (y : Y).

为了使代码更简洁,我们将类型参数 XY 设为隐式参数,这样在写 pair 时只需提供具体的值 xy

Arguments pair {X} {Y} _ _.

我们还可以定义方便的记法,使用括号和逗号表示对,用乘号表示积类型。

Notation "( x , y )" := (pair x y).
Notation "X * Y" := (prod X Y) : type_scope.

多态函数示例

以下是两个简单的多态函数,分别返回一个对中的第一个和第二个元素。

Definition first {X Y : Type} (p : X * Y) : X :=
  match p with
  | (x, y) => x
  end.

Definition second {X Y : Type} (p : X * Y) : Y :=
  match p with
  | (x, y) => y
  end.

拉链函数(Zip Function)

拉链函数接受两个列表,一个类型为 X 的元素列表和一个类型为 Y 的元素列表,并将它们组合成一个对列表。

Fixpoint zip {X Y : Type} (lx : list X) (ly : list Y) : list (X * Y) :=
  match lx, ly with
  | [], _ => []
  | _, [] => []
  | hx::tx, hy::ty => (hx, hy) :: zip tx ty
  end.

如果两个列表长度不同,该函数会忽略较长列表中多余的元素。

多态选项类型(Polymorphic Option Type)

选项类型允许我们表示一个值可能存在也可能不存在。我们定义了一个多态选项类型。

Inductive option (X : Type) : Type :=
| Some (x : X)
| None.

同样,我们将类型参数 X 设为隐式参数。

Arguments Some {X} _.
Arguments None {X}.

列表索引查找函数

我们定义了一个函数 nth_error,它接受一个列表和一个索引 n,返回列表中第 n 个元素(索引从0开始)。如果索引超出范围,则返回 None

Fixpoint nth_error {X : Type} (l : list X) (n : nat) : option X :=
  match l with
  | [] => None
  | a :: l' => match n with
               | 0 => Some a
               | S n' => nth_error l' n'
               end
  end.

高阶函数(Higher-Order Functions)

高阶函数是指接受其他函数作为参数或返回函数的函数。在Coq中,许多函数实际上都是高阶函数。

简单加法函数

考虑一个简单的加法函数 simple_add,它接受两个自然数并返回它们的和。

Definition simple_add (n m : nat) : nat := n + m.

检查其类型,你会发现它实际上是一个接受一个自然数并返回一个函数(该函数再接受一个自然数并返回自然数)的函数。

Check simple_add. (* simple_add : nat -> nat -> nat *)

这意味着我们可以部分应用这个函数。

Definition plus10 := simple_add 10.
Compute plus10 5. (* 15 *)

应用三次函数

定义一个函数 apply_thrice,它接受一个函数 f 和一个值 n,并将 f 应用三次于 n

Definition apply_thrice {X : Type} (f : X -> X) (n : X) : X := f (f (f n)).

这个函数是多态的,可以用于任何类型 X

Compute apply_thrice (fun n => n * n) 2. (* 256 *)

过滤函数(Filter Function)

过滤函数接受一个测试函数和一个列表,返回列表中所有通过测试的元素。

Fixpoint filter {X : Type} (test : X -> bool) (l : list X) : list X :=
  match l with
  | [] => []
  | h :: t => if test h then h :: filter test t else filter test t
  end.

例如,我们可以过滤出一个列表中的所有偶数。

Compute filter even [1; 2; 4; 5]. (* [2; 4] *)

映射函数(Map Function)

映射函数接受一个函数 f 和一个列表,将 f 应用于列表中的每个元素。

Fixpoint map {X Y : Type} (f : X -> Y) (l : list X) : list Y :=
  match l with
  | [] => []
  | h :: t => f h :: map f t
  end.

例如,将列表中的每个元素加3。

Compute map (fun x => x + 3) [1; 2; 3]. (* [4; 5; 6] *)

折叠函数(Fold Function)

折叠函数将一个列表“折叠”成一个单一的值。它接受一个二元函数 f、一个初始值 b 和一个列表,然后递归地将 f 应用于列表的元素和累积值。

Fixpoint fold {X Y : Type} (f : X -> Y -> Y) (l : list X) (b : Y) : Y :=
  match l with
  | [] => b
  | h :: t => f h (fold f t b)
  end.

例如,使用折叠函数计算列表中所有元素的乘积。

Compute fold mult [3; 5; 2] 1. (* 30 *)

常量函数

定义一个函数 constf,它接受一个值 x,返回一个常量函数,该函数总是返回 x

Definition constf {X : Type} (x : X) : nat -> X := fun k => x.

证明策略(Proof Tactics)

上一节我们介绍了一些基本的证明策略,本节中我们来看看更多高级的策略。

应用策略(Apply Tactic)

apply 策略用于将当前目标与某个假设或已证明定理的结论进行匹配。如果匹配成功,目标会被替换为该假设或定理的前提。

考虑一个简单定理:对于所有自然数 nm,如果 n = m,则 n = n

Theorem trivial_example : forall n m : nat, n = m -> n = n.
Proof.
  intros n m H.
  apply H.
Qed.

对称策略(Symmetry Tactic)

symmetry 策略用于交换等式两边的位置。例如,证明如果 n = m,则 m = n

Theorem symmetry_example : forall n m : nat, n = m -> m = n.
Proof.
  intros n m H.
  symmetry.
  apply H.
Qed.

传递性定理(Transitivity Theorem)

我们证明一个一般性的传递性定理:对于任何类型 X 和元素 nmo,如果 n = mm = o,则 n = o

Theorem trans_eq : forall (X : Type) (n m o : X), n = m -> m = o -> n = o.
Proof.
  intros X n m o H1 H2.
  rewrite H1.
  apply H2.
Qed.

使用 apply with 策略

有时,apply 策略无法自动推断出所有参数。这时可以使用 apply with 来显式指定参数。

例如,证明如果列表 [a, b] 等于 [c, d][c, d] 等于 [e, f],则 [a, b] 等于 [e, f]

Theorem example_with : forall a b c d e f : nat,
  [a; b] = [c; d] -> [c; d] = [e; f] -> [a; b] = [e; f].
Proof.
  intros a b c d e f H1 H2.
  apply trans_eq with (m := [c; d]).
  - apply H1.
  - apply H2.
Qed.

注入策略(Injection Tactic)

injection 策略用于利用构造器的单射性。如果两个由相同构造器创建的值相等,那么它们的参数也相等。

例如,证明自然数的后继构造器 S 是单射的。

Theorem S_injective : forall n m : nat, S n = S m -> n = m.
Proof.
  intros n m H.
  injection H as H0.
  apply H0.
Qed.

判别策略(Discriminate Tactic)

discriminate 策略用于处理由不同构造器创建的值不可能相等的情况。例如,证明 false = true 蕴含任何结论。

Theorem discriminate_example : forall n m : nat, false = true -> n = m.
Proof.
  intros n m H.
  discriminate H.
Qed.

函数相等策略(F_equal Tactic)

f_equal 策略用于证明如果两个值相等,那么将它们应用于同一个函数后结果也相等。

例如,证明如果 n = m,则 S n = S m

Theorem f_equal_example : forall n m : nat, n = m -> S n = S m.
Proof.
  intros n m H.
  f_equal.
  apply H.
Qed.

在假设中应用策略

我们可以在假设中应用策略,而不仅仅是在目标中。例如,使用 simpl in H 简化假设 H,或使用 apply H in H0 在假设中进行前向推理。

Theorem forward_reasoning : forall n m p q : nat,
  (n = m -> p = q) -> m = n -> q = p.
Proof.
  intros n m p q H H0.
  symmetry in H0.
  apply H in H0.
  symmetry.
  apply H0.
Qed.

总结

本节课中我们一起学习了多态类型和高阶函数的定义与使用,包括积类型、选项类型、拉链函数、过滤函数、映射函数和折叠函数。我们还探讨了多种证明策略,如 applysymmetrytransitivityinjectiondiscriminatef_equal,并学习了如何在假设中应用这些策略。这些概念和技巧是进行程序形式化推理的基础,帮助我们更有效地在Coq中构建和证明定理。

012:Coq进阶训练

在本节课中,我们将深入学习Coq证明助手,通过一系列具体的定理证明练习,掌握处理全称量词和归纳证明时的关键技巧。我们将重点关注如何避免过早引入变量,以及如何有效地组织归纳证明。

1️⃣ 过早引入变量的问题

上一节我们介绍了基本的证明策略。本节中我们来看看一个常见的陷阱:过早地使用 intros 策略引入所有变量。

我们首先导入必要的库并定义一个简单的函数。

Require Import Bool Arith.

Fixpoint double (n : nat) : nat :=
  match n with
  | O => O
  | S n' => S (S (double n'))
  end.

函数 double 递归地将自然数 n 翻倍。接下来,我们尝试证明这个函数是单射的。

定理:对于所有自然数 nm,如果 double n = double m,那么 n = m

一个直观的证明思路是使用归纳法。以下是初学者可能尝试的错误证明:

Theorem double_injective_wrong : forall n m, double n = double m -> n = m.
Proof.
  intros n m. (* 过早地将 n 和 m 都引入上下文 *)
  induction n.
  - (* 情况 n = 0 *)
    intros H.
    destruct m.
    + reflexivity.
    + discriminate H.
  - (* 归纳步骤 *)
    intros H.
    destruct m.
    + discriminate H.
    + (* 此处陷入困境 *)
      (* 归纳假设是:对于特定的 m,若 double n' = double m,则 n' = m *)
      (* 但我们需要证明的是关于 S n' 和 S m' 的结论,无法直接应用归纳假设 *)
Abort.

这个证明在归纳步骤中失败了。问题在于,当我们执行 intros n m 时,m 变成了一个特定的自然数(例如,想象 m 是 10)。然后我们对 n 进行归纳。归纳假设变成了“对于这个特定的 m,如果 double n' = double m,那么 n' = m”。然而,在归纳步骤中,我们需要处理的是 S m',这与归纳假设中固定的 m 不匹配,导致证明无法进行。

核心问题在于:在对一个变量进行归纳时,另一个变量应保持全称量化状态

2️⃣ 正确的证明策略

基于上述观察,我们调整证明策略:先只引入一个变量,保持另一个变量在全称量词内,再进行归纳。

以下是 double 函数单射性的正确证明:

Theorem double_injective : forall n m, double n = double m -> n = m.
Proof.
  intros n. (* 只引入 n,m 仍然被 forall 量化 *)
  induction n.
  - (* 基本情况:n = 0 *)
    intros m H. (* 现在引入 m 和假设 H *)
    destruct m.
    + reflexivity. (* m = 0,目标 0=0 *)
    + simpl in H. discriminate H. (* m = S m',H 变为 0 = S(...),矛盾 *)
  - (* 归纳步骤:n = S n' *)
    intros m H. (* 引入 m 和假设 H: double (S n') = double m *)
    destruct m.
    + simpl in H. discriminate H. (* m = 0,矛盾 *)
    + (* m = S m' *)
      simpl in H.
      injection H as H1. (* 利用构造子 S 的单射性,得到 double n' = double m' *)
      apply IHn in H1.   (* 应用归纳假设,得到 n' = m' *)
      f_equal.           (* 目标 S n' = S m' 简化为 n' = m' *)
      assumption.
Qed.

证明解析

  1. intros n. 只将 n 引入上下文,m 仍受 forall 约束。
  2. n 进行归纳 (induction n)。
  3. 基本情况 (n=0):引入 m 和假设 H。对 m 分情况讨论。当 m=S m' 时,假设 H 变为 0 = S(...),可通过 discriminate 得出矛盾。
  4. 归纳步骤 (n=S n'):引入 m 和假设 H。对 m 分情况讨论。
    • m=S m' 时,简化 H 得到 S (S (double n')) = S (S (double m'))
    • 使用 injection 策略(因为 S 是构造子,具有单射性),得到新假设 H1: double n' = double m'
    • 应用归纳假设 IHnH1 上,得到 n' = m'
    • 使用 f_equal 将目标 S n' = S m' 简化为 n' = m',然后由假设得证。

这个证明成功的关键是,归纳假设 IHn 的形式是 forall m, double n' = double m -> n' = m。这意味着它对所有自然数 m 都成立,因此我们可以将具体的 m' 实例化进去。

3️⃣ 布尔相等性的证明

让我们将相同的策略应用于另一个定理,证明自然数的布尔相等性 =? 与命题相等性 = 在结果为真时是一致的。

首先,我们查看 =? 的定义(即标准库中的 eqb 函数)。

Print Nat.eqb.

定理:对于所有自然数 nm,如果 n =? m = true,那么 n = m

以下是证明步骤:

Theorem eqb_true : forall n m, (n =? m) = true -> n = m.
Proof.
  intros n.          (* 只引入 n *)
  induction n.
  - (* n = 0 *)
    intros m H.
    destruct m.
    + reflexivity.          (* m=0,目标 0=0 *)
    + simpl in H.           (* H 变为 false = true *)
      discriminate H.       (* 矛盾 *)
  - (* n = S n' *)
    intros m H.
    destruct m.
    + simpl in H. discriminate H. (* m=0,H 为 false=true,矛盾 *)
    + (* m = S m' *)
      simpl in H.                 (* H 变为 n' =? m' = true *)
      apply IHn in H.             (* 应用归纳假设,得到 n' = m' *)
      f_equal.                    (* 目标 S n' = S m' 简化为 n' = m' *)
      assumption.
Qed.

证明解析
这个证明的结构与 double_injective 的证明几乎完全相同。

  1. 只引入 n,对 n 进行归纳。
  2. 在每种情况下,再引入 m 和假设 H
  3. m 进行分情况讨论 (destruct m)。
  4. 在归纳步骤中,当 n=S n'm=S m' 时,假设 H 简化为 n' =? m' = true。直接应用归纳假设 IHn 即可得到 n' = m',从而完成证明。

4️⃣ 加法单射性的证明

现在,我们证明一个关于加法的类似性质:对于所有自然数 nm,如果 n + n = m + m,那么 n = m

这个证明需要用到加法的一个引理 plus_n_Sm

Check plus_n_Sm. (* forall n m : nat, S (n + m) = n + S m *)

定理forall n m, n + n = m + m -> n = m

以下是证明:

Theorem plus_injective : forall n m, n + n = m + m -> n = m.
Proof.
  intros n.
  induction n.
  - (* n = 0 *)
    intros m H.
    destruct m.
    + reflexivity.
    + simpl in H. discriminate H. (* 0 = S(...+...),矛盾 *)
  - (* n = S n' *)
    intros m H.
    destruct m.
    + simpl in H. discriminate H. (* S(...) = 0,矛盾 *)
    + (* m = S m' *)
      (* 目标:S n' = S m' *)
      (* 假设 H: S n' + S n' = S m' + S m' *)
      rewrite <- plus_n_Sm in H. (* 将左边的 S n' + S n' 转化为 S (n' + S n') *)
      rewrite <- plus_n_Sm in H. (* 将右边的 S m' + S m' 转化为 S (m' + S m') *)
      simpl in H.                (* 化简:S (S (n' + n')) = S (S (m' + m')) *)
      injection H as H.          (* 得到 n' + n' = m' + m' *)
      apply IHn in H.            (* 应用归纳假设 *)
      f_equal.
      assumption.
Qed.

证明解析

  1. 标准步骤:intros n. induction n.
  2. 基本情况 (n=0) 与之前类似。
  3. 归纳步骤 (n=S n') 需要更多操作:
    • m=S m' 时,目标为 S n' = S m',假设 HS n' + S n' = S m' + S m'
    • 使用引理 plus_n_Sm 重写假设 H 的两边,将形如 S a + S b 的表达式转化为 S (a + S b)
    • 然后使用 simpl 进一步化简,得到 S (S (n' + n')) = S (S (m' + m'))
    • 利用构造子 S 的单射性 (injection),得到核心等式 n' + n' = m' + m'
    • 应用归纳假设 IHn 即得 n' = m',从而完成证明。

这个证明展示了如何利用已有的引理来重写表达式,从而将问题转化为归纳假设可以直接应用的形式。

5️⃣ 使用 generalize dependent 调整变量顺序

有时,我们可能希望对第二个变量进行归纳,而不是第一个。generalize dependent 策略可以帮助我们调整量化变量的顺序。

回顾 double_injective 定理,假设我们想对 m 进行归纳。直接 intros n m 会将两者都固定,导致归纳假设无效。我们可以这样做:

Theorem double_injective_gen : forall n m, double n = double m -> n = m.
Proof.
  intros n m H.                (* 引入所有变量和假设 *)
  generalize dependent n.      (* 将 n 从上下文中移回目标,重新被 forall 量化 *)
  induction m.
  - (* 基本情况:m = 0 *)
    intros n H.
    destruct n.
    + reflexivity.
    + simpl in H. discriminate H.
  - (* 归纳步骤:m = S m' *)
    intros n H.
    destruct n.
    + simpl in H. discriminate H.
    + simpl in H.
      injection H as H1.
      apply IHm in H1.
      f_equal.
      assumption.
Qed.

策略解析

  • intros n m H.n, m, H 全部引入上下文。
  • generalize dependent n. 是关键。它将上下文中的 n “推广”回目标中,使目标变为 forall n, double n = double m -> n = m,同时 n 从上下文中消失。
  • 现在,我们可以安全地对 m 进行归纳 (induction m),因为此时 n 在归纳假设中仍然是全称量化的。
  • 证明的其余部分与之前对 n 归纳的证明对称。

generalize dependent 是一个强大的工具,当你需要改变归纳顺序或因为过早引入变量而陷入困境时,它可以帮你重新组织目标。


总结:本节课中我们一起学习了在Coq中进行归纳证明时的高级技巧。

  1. 我们认识到,在对一个变量进行归纳时,其他相关变量应保持全称量化状态,避免过早使用 intros 将其固定。
  2. 我们通过 double 函数单射性、布尔相等性 =? 以及加法单射性等多个定理,实践了“先引入一个变量,归纳后再引入另一个”的标准证明模式。
  3. 我们学习了如何使用 injection 策略利用构造子的单射性,以及如何使用 rewrite 结合已有引理(如 plus_n_Sm)来转化等式。
  4. 最后,我们介绍了 generalize dependent 策略,它可以帮助我们调整证明中变量的量化顺序,为归纳证明提供更大的灵活性。掌握这些策略对于构建复杂且正确的形式化证明至关重要。

013:在Coq中定义WHILE语言

在本节课中,我们将学习如何在Coq中定义WHILE编程语言。我们将从定义自定义证明策略开始,然后探讨如何使用归纳命题来定义关系和语义,最后构建一个包含变量、赋值、顺序执行、条件分支和循环的完整编程语言。

自定义证明策略

上一节我们介绍了如何优化算术表达式。本节中,我们来看看如何在Coq中定义自己的证明策略,以简化重复的证明步骤。

以下是定义一个名为 my_tactic 的策略的示例:

Ltac my_tactic H :=
  try injection H; intros; assumption.

这个策略尝试对假设 H 使用 injection,然后引入新假设,最后使用 assumption 完成证明。你可以在证明中这样使用它:

Theorem example: forall A B C: nat, [A; B] = [A; C] -> B = C.
Proof.
  intros.
  my_tactic H.
Qed.

使用 lia 策略处理线性算术

Coq提供了一个强大的策略 lia,用于解决Pressburger算术问题。Pressburger算术是一种只包含自然数、加法、相等和比较运算(没有乘法)的理论,它是可判定的。

例如,要证明以下目标:

Theorem lia_example: forall m n o p: nat,
    n + n <= m + o /\ o + 3 = p + 3 -> m <= p.
Proof.
  intros.
  lia.
Qed.

lia 策略可以自动处理涉及加法、常数乘法和比较的线性算术目标。

归纳定义命题

我们可以使用归纳法来定义命题本身,而不仅仅是证明它们。这类似于基于规则的推导。

例如,我们可以归纳地定义一个自然数为偶数的命题 EV

Inductive EV : nat -> Prop :=
| Ev0 : EV 0
| EvSS : forall n, EV n -> EV (S (S n)).

这里有两个规则:

  1. Ev0: 0是偶数。
  2. EvSS: 如果 n 是偶数,那么 n+2 也是偶数。

我们可以使用这些规则来证明4是偶数:

Theorem ev_4 : EV 4.
Proof.
  apply EvSS. apply EvSS. apply Ev0.
Qed.

另一种定义风格是将假设作为参数直接放入规则中:

Inductive EV‘ : nat -> Prop :=
| Ev0‘ : EV‘ 0
| EvSS‘ : forall n, EV‘ n -> EV‘ (S (S n)).

这两种定义在逻辑上是等价的。归纳定义的核心思想是:一个命题成立,当且仅当它能从给定的规则中推导出来。

处理部分函数与非确定性:规则语义的优势

当我们尝试在Coq中定义可能不终止或结果不唯一的函数时,会遇到困难,因为Coq要求所有函数都是全函数且终止的。

考虑Collatz函数和步数计算:

Definition F (n : nat) : nat :=
  if even n then div2 n else 3 * n + 1.

Fail Fixpoint steps_to_one (n : nat) : nat :=
  if n =? 1 then 0
  else 1 + steps_to_one (F n).

上述定义会被Coq拒绝,因为它无法证明递归调用 steps_to_one (F n) 的参数比原始参数 n 更小(结构上递减)。

然而,我们可以将其定义为一个归纳命题,这只需要描述“如何证明”一个数能达到1,而不需要实际计算步数:

Inductive reaches_one : nat -> Prop :=
| term_done : reaches_one 1
| term_more : forall n, reaches_one (F n) -> reaches_one n.

这个定义是可行的,因为它只是声明了一组证明规则。规则语义(或操作语义)在处理部分性(如除以零)和非确定性时也更具优势。

除以零的情况

对于包含除法的算术表达式,函数式语义需要处理 option 类型,变得复杂:

Definition opt_div (a b : option nat) : option nat :=
  match a, b with
  | Some n1, Some n2 => if n2 =? 0 then None else Some (n1 / n2)
  | _, _ => None
  end.

而规则语义只需在除法规则中添加前提条件即可:

Inductive aevalR : aexp -> nat -> Prop :=
...
| E_Div : forall a1 a2 n1 n2 n3 n4,
    aevalR a1 n1 ->
    aevalR a2 n2 ->
    n2 > 0 ->
    n2 * n3 + n4 = n1 ->
    n4 < n2 ->
    aevalR (ADiv a1 a2) n3.

非确定性的情况

假设我们有一个非确定性表达式 any,它可以求值为任何自然数。函数式语义无法表示这一点,但规则语义只需一条简单规则:

Inductive aevalR : aexp -> nat -> Prop :=
...
| E_Any : forall n, aevalR any n.

定义WHILE语言

现在,让我们正式定义WHILE语言。我们首先需要定义状态(存储变量映射)和表达式。

状态定义

状态是变量名(字符串)到自然数值的映射。我们使用函数来实现一个“全映射”,为每个可能的变量名提供一个默认值。

Definition total_map (A : Type) := string -> A.

Definition t_empty {A : Type} (v : A) : total_map A :=
  fun _ => v.

Definition t_update {A : Type} (m : total_map A)
                    (x : string) (v : A) : total_map A :=
  fun x‘ => if eqb x‘ x then v else m x‘.

Definition state : Type := total_map nat.

表达式语法

算术表达式(aexp)和布尔表达式(bexp)现在可以包含变量。

Inductive aexp : Type :=
  | ANum (n : nat)
  | AId (x : string)        (* 变量 *)
  | APlus (a1 a2 : aexp)
  | AMinus (a1 a2 : aexp)
  | AMult (a1 a2 : aexp).

Inductive bexp : Type :=
  | BTrue
  | BFalse
  | BEq (a1 a2 : aexp)
  | BNe (a1 a2 : aexp)
  | BLe (a1 a2 : aexp)
  | BNot (b : bexp)
  | BAnd (b1 b2 : bexp).

我们定义了一些符号和强制转换,使表达式书写更直观:

Coercion ANum : nat >-> aexp.
Coercion AId : string >-> aexp.
Declare Scope com_scope.
Notation "x + y" := (APlus x y) (at level 50, left associativity) : com_scope.
Notation "x - y" := (AMinus x y) (at level 50, left associativity) : com_scope.
Notation "x * y" := (AMult x y) (at level 40, left associativity) : com_scope.
(* ... 其他符号定义 ... *)
Notation "‘{‘ e ‘}‘" := e (at level 1, e custom com at level 99) : com_scope.

表达式求值函数

有了状态,我们可以定义表达式求值函数:

Fixpoint aeval (st : state) (a : aexp) : nat :=
  match a with
  | ANum n => n
  | AId x => st x
  | APlus a1 a2 => (aeval st a1) + (aeval st a2)
  | AMinus a1 a2 => (aeval st a1) - (aeval st a2)
  | AMult a1 a2 => (aeval st a1) * (aeval st a2)
  end.

Fixpoint beval (st : state) (b : bexp) : bool :=
  match b with
  | BTrue => true
  | BFalse => false
  | BEq a1 a2 => (aeval st a1) =? (aeval st a2)
  (* ... 其他情况 ... *)
  end.

命令语法与语义

WHILE语言的核心是命令(com):

Inductive com : Type :=
  | CSkip
  | CAss (x : string) (a : aexp)
  | CSeq (c1 c2 : com)
  | CIf (b : bexp) (c1 c2 : com)
  | CWhile (b : bexp) (c : com).

我们同样为命令定义了易读的符号:

Notation "‘skip‘" := CSkip (in custom com at level 0) : com_scope.
Notation "x ‘:=‘ a" := (CAss x a) (in custom com at level 0, x constr at level 0, a at level 85) : com_scope.
Notation "c1 ; c2" := (CSeq c1 c2) (in custom com at level 90, right associativity) : com_scope.
Notation "‘if‘ b ‘then‘ c1 ‘else‘ c2 ‘end‘" := (CIf b c1 c2) (in custom com at level 89, b at level 99, c1, c2 at level 99) : com_scope.
Notation "‘while‘ b ‘do‘ c ‘end‘" := (CWhile b c) (in custom com at level 89, b at level 99, c at level 99) : com_scope.

现在我们可以编写WHILE程序了:

Definition factorial_program : com :=
  ‘{ y := 1;
     z := x;
     while ~(z = 0) do
       y := y * z;
       z := z - 1
     end }‘.

指称语义的局限性

我们尝试定义一个求值函数 ceval,它接受初始状态和命令,返回最终状态。这对于 skip、赋值、顺序执行和条件分支都很直接:

Fixpoint ceval (st : state) (c : com) : state :=
  match c with
  | CSkip => st
  | CAss x a => t_update st x (aeval st a)
  | CSeq c1 c2 => let st‘ := ceval st c1 in ceval st‘ c2
  | CIf b c1 c2 => if beval st b then ceval st c1 else ceval st c2
  | CWhile b c => st (* 问题! *)
  end.

对于 while 循环,我们无法给出一个通用的、总是终止的函数实现,因为WHILE语言包含不终止的程序(如 while true do skip end),而Coq不允许定义不终止的函数。因此,这个指称语义定义是不完整的。

操作语义(大步语义)

我们可以通过归纳定义一个大步操作语义关系 eval 来克服这个限制。这个关系描述了命令如何将初始状态转换为最终状态。

Reserved Notation "st ‘=[‘ c ‘]=>‘ st‘" (at level 40, c custom com at level 99).

Inductive eval : com -> state -> state -> Prop :=
  | E_Skip : forall st,
      st =[ skip ]=> st
  | E_Ass : forall st a n x,
      aeval st a = n ->
      st =[ x := a ]=> (t_update st x n)
  | E_Seq : forall c1 c2 st st‘ st‘‘,
      st =[ c1 ]=> st‘ ->
      st‘ =[ c2 ]=> st‘‘ ->
      st =[ c1 ; c2 ]=> st‘‘
  | E_IfTrue : forall st st‘ b c1 c2,
      beval st b = true ->
      st =[ c1 ]=> st‘ ->
      st =[ if b then c1 else c2 end ]=> st‘
  | E_IfFalse : forall st st‘ b c1 c2,
      beval st b = false ->
      st =[ c2 ]=> st‘ ->
      st =[ if b then c1 else c2 end ]=> st‘
  | E_WhileFalse : forall b st c,
      beval st b = false ->
      st =[ while b do c end ]=> st
  | E_WhileTrue : forall st st‘ st‘‘ b c,
      beval st b = true ->
      st =[ c ]=> st‘ ->
      st‘ =[ while b do c end ]=> st‘‘ ->
      st =[ while b do c end ]=> st‘‘

where "st ‘=[‘ c ‘]=>‘ st‘" := (eval c st st‘).

这些规则直接对应我们在理论课中学过的大步操作语义规则。例如:

  • E_WhileTrue 规则表示:如果循环条件 b 在当前状态 st 下为真,执行一次循环体 c 到达状态 st‘,然后从 st‘ 开始执行整个循环能到达状态 st‘‘,那么从 st 开始执行整个循环也能到达 st‘‘

这个归纳定义是完整的,它可以描述所有WHILE程序的行为,包括那些不终止的程序(对于不终止的程序,不存在能推导出 st =[ c ]=> st‘ 的证明)。

总结

本节课中我们一起学习了:

  1. 如何在Coq中定义自定义证明策略(Ltac)来简化证明。
  2. 使用 lia 策略自动解决Pressburger算术问题。
  3. 使用归纳法定义命题(如 EV),这实质上是给出了一组推导规则。
  4. 认识到规则语义(归纳定义)相较于函数式语义(指称语义)在处理部分函数(如除法)和非确定性时的优势。
  5. 在Coq中完整定义了WHILE语言:
    • 使用函数 total_map 定义状态。
    • 定义了包含变量的算术和布尔表达式语法。
    • 定义了表达式求值函数 aevalbeval
    • 定义了命令语法(skip, 赋值 :=, 顺序执行 ;, 条件分支 if, 循环 while)。
    • 指出了在Coq中用函数定义完整指称语义的局限性(由于 while 循环可能不终止)。
    • 使用归纳关系成功定义了大步操作语义 eval,完整刻画了命令的行为。

我们现在有了一个在Coq中形式化定义的编程语言,以及其操作语义。在接下来的课程中,我们将学习如何利用这些定义来形式化地推理程序的属性。

014:Coq中的小步语义与霍尔逻辑

概述

在本节课中,我们将学习如何在Coq中形式化定义编程语言的语义,特别是小步操作语义,并在此基础上构建霍尔逻辑的证明系统。我们将从定义状态和表达式开始,逐步引入命令的语义,最终实现霍尔逻辑的推理规则。

定义状态与表达式

首先,我们需要定义程序运行时的状态。状态可以看作是一个从变量名(字符串)到值(自然数)的映射。我们称之为“全映射”。

Definition total_map (A : Type) := string -> A.

空映射将所有变量映射到一个默认值。更新操作允许我们修改映射中某个变量的值。

Definition t_empty {A : Type} (v : A) : total_map A :=
  (fun _ => v).

Definition t_update {A : Type} (m : total_map A)
                    (x : string) (v : A) :=
  fun x' => if String.eqb x x' then v else m x'.

在我们的编程语言中,状态是变量到自然数的映射。

Definition state := total_map nat.

接下来,我们定义算术表达式和布尔表达式的语法。算术表达式可以是数字、变量,或者由加法、减法、乘法构成的复合表达式。

Inductive aexp : Type :=
  | ANum (n : nat)
  | AId (x : string)
  | APlus (a1 a2 : aexp)
  | AMinus (a1 a2 : aexp)
  | AMult (a1 a2 : aexp).

布尔表达式包括真、假、相等、不等、非、与等。

Inductive bexp : Type :=
  | BTrue
  | BFalse
  | BEq (a1 a2 : aexp)
  | BNeq (a1 a2 : aexp)
  | BLe (a1 a2 : aexp)
  | BNot (b : bexp)
  | BAnd (b1 b2 : bexp).

为了区分Coq代码和我们的编程语言代码,我们使用特定的记号。例如,<< x + 2 >> 表示编程语言中的表达式。

表达式的指称语义

我们定义函数来评估算术表达式和布尔表达式在给定状态下的值。

Fixpoint aeval (st : state) (a : aexp) : nat :=
  match a with
  | ANum n => n
  | AId x => st x
  | APlus a1 a2 => (aeval st a1) + (aeval st a2)
  | AMinus a1 a2 => (aeval st a1) - (aeval st a2)
  | AMult a1 a2 => (aeval st a1) * (aeval st a2)
  end.

Fixpoint beval (st : state) (b : bexp) : bool :=
  match b with
  | BTrue => true
  | BFalse => false
  | BEq a1 a2 => (aeval st a1) =? (aeval st a2)
  | BNeq a1 a2 => negb ((aeval st a1) =? (aeval st a2))
  | BLe a1 a2 => (aeval st a1) <=? (aeval st a2)
  | BNot b1 => negb (beval st b1)
  | BAnd b1 b2 => andb (beval st b1) (beval st b2)
  end.

定义命令

我们的编程语言包含以下命令:跳过、赋值、顺序组合、条件语句和循环。

Inductive com : Type :=
  | CSkip
  | CAss (x : string) (a : aexp)
  | CSeq (c1 c2 : com)
  | CIf (b : bexp) (c1 c2 : com)
  | CWhile (b : bexp) (c : com).

例如,计算阶乘的程序可以表示为:

Definition factorial_program : com :=
  <<
    z := x;
    y := 1;
    WHILE ~(z = 0) DO
      y := y * z;
      z := z - 1
    END
  >>.

大步操作语义

大步操作语义描述程序从初始状态到终止状态的完整执行过程。我们定义一个关系 big_step,表示从状态 st 执行命令 c 终止于状态 st'

Inductive big_step : com * state -> state -> Prop :=
  | E_Skip : forall st,
      big_step (CSkip, st) st
  | E_Ass : forall st a n x,
      aeval st a = n ->
      big_step (CAss x a, st) (t_update st x n)
  | E_Seq : forall c1 c2 st st' st'',
      big_step (c1, st) st' ->
      big_step (c2, st') st'' ->
      big_step (CSeq c1 c2, st) st''
  | E_IfTrue : forall st st' b c1 c2,
      beval st b = true ->
      big_step (c1, st) st' ->
      big_step (CIf b c1 c2, st) st'
  | E_IfFalse : forall st st' b c1 c2,
      beval st b = false ->
      big_step (c2, st) st' ->
      big_step (CIf b c1 c2, st) st'
  | E_WhileFalse : forall b st c,
      beval st b = false ->
      big_step (CWhile b c, st) st
  | E_WhileTrue : forall st st' st'' b c,
      beval st b = true ->
      big_step (c, st) st' ->
      big_step (CWhile b c, st') st'' ->
      big_step (CWhile b c, st) st''.

小步操作语义

小步操作语义描述程序的单步执行过程。我们定义一个关系 small_step,表示从状态 st 和程序 c 经过一步执行到达状态 st' 和剩余程序 c'

Inductive small_step : (com * state) -> (com * state) -> Prop :=
  | ST_Skip : forall st c,
      small_step (CSeq CSkip c, st) (c, st)
  | ST_Ass : forall st a n x,
      aeval st a = n ->
      small_step (CAss x a, st) (CSkip, t_update st x n)
  | ST_Seq : forall c1 c2 st c1' st',
      small_step (c1, st) (c1', st') ->
      small_step (CSeq c1 c2, st) (CSeq c1' c2, st')
  | ST_IfTrue : forall st b c1 c2,
      beval st b = true ->
      small_step (CIf b c1 c2, st) (c1, st)
  | ST_IfFalse : forall st b c1 c2,
      beval st b = false ->
      small_step (CIf b c1 c2, st) (c2, st)
  | ST_WhileTrue : forall st b c,
      beval st b = true ->
      small_step (CWhile b c, st) (CSeq c (CWhile b c), st)
  | ST_WhileFalse : forall st b c,
      beval st b = false ->
      small_step (CWhile b c, st) (CSkip, st).

多步执行

多步执行是小步执行的传递闭包。我们定义关系 multi_step,表示经过零步或多步执行从状态 st 和程序 c 到达状态 st' 和剩余程序 c'

Inductive multi_step : (com * state) -> (com * state) -> Prop :=
  | MS_Base : forall st c st' c',
      small_step (c, st) (c', st') ->
      multi_step (c, st) (c', st')
  | MS_Trans : forall st1 c1 st2 c2 st3 c3,
      small_step (c1, st1) (c2, st2) ->
      multi_step (c2, st2) (c3, st3) ->
      multi_step (c1, st1) (c3, st3)
  | MS_Refl : forall st c,
      multi_step (c, st) (c, st).

替换函数

在霍尔逻辑中,我们需要替换表达式中的变量。我们定义函数 subst_aexpsubst_bexp 来分别替换算术表达式和布尔表达式中的变量。

Fixpoint subst_aexp (x : string) (e : aexp) (a : aexp) : aexp :=
  match a with
  | ANum n => ANum n
  | AId x' => if String.eqb x x' then e else AId x'
  | APlus a1 a2 => APlus (subst_aexp x e a1) (subst_aexp x e a2)
  | AMinus a1 a2 => AMinus (subst_aexp x e a1) (subst_aexp x e a2)
  | AMult a1 a2 => AMult (subst_aexp x e a1) (subst_aexp x e a2)
  end.

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/hkust-prog-fmlrsn/img/4debc5e56b4042b5bf5b836f390d37ca_20.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/hkust-prog-fmlrsn/img/4debc5e56b4042b5bf5b836f390d37ca_21.png)

Fixpoint subst_bexp (x : string) (e : aexp) (b : bexp) : bexp :=
  match b with
  | BTrue => BTrue
  | BFalse => BFalse
  | BEq a1 a2 => BEq (subst_aexp x e a1) (subst_aexp x e a2)
  | BNeq a1 a2 => BNeq (subst_aexp x e a1) (subst_aexp x e a2)
  | BLe a1 a2 => BLe (subst_aexp x e a1) (subst_aexp x e a2)
  | BNot b1 => BNot (subst_bexp x e b1)
  | BAnd b1 b2 => BAnd (subst_bexp x e b1) (subst_bexp x e b2)
  end.

霍尔逻辑

霍尔逻辑用于推理程序的部分正确性。霍尔三元组 {P} c {Q} 表示如果程序 c 在满足前提条件 P 的状态下开始执行,并且终止,那么终止状态满足后置条件 Q

我们首先定义布尔表达式之间的蕴含关系。

Definition entails (P Q : bexp) : Prop :=
  forall st, beval st P = true -> beval st Q = true.

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/hkust-prog-fmlrsn/img/4debc5e56b4042b5bf5b836f390d37ca_23.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/hkust-prog-fmlrsn/img/4debc5e56b4042b5bf5b836f390d37ca_24.png)

Notation "P |== Q" := (entails P Q) (at level 80).

接下来,我们定义霍尔逻辑的推理规则。

Inductive hoare_triple : bexp -> com -> bexp -> Prop :=
  | H_Skip : forall P,
      hoare_triple P CSkip P
  | H_Ass : forall P x e,
      hoare_triple (subst_bexp x e P) (CAss x e) P
  | H_Seq : forall P Q R c1 c2,
      hoare_triple P c1 Q ->
      hoare_triple Q c2 R ->
      hoare_triple P (CSeq c1 c2) R
  | H_If : forall P Q b c1 c2,
      hoare_triple (BAnd P b) c1 Q ->
      hoare_triple (BAnd P (BNot b)) c2 Q ->
      hoare_triple P (CIf b c1 c2) Q
  | H_While : forall P b c,
      hoare_triple (BAnd P b) c P ->
      hoare_triple P (CWhile b c) (BAnd (BNot b) P)
  | H_Consequence : forall P P' Q Q' c,
      P' |== P ->
      Q |== Q' ->
      hoare_triple P c Q ->
      hoare_triple P' c Q'.

霍尔逻辑证明示例

让我们通过一个简单的例子来演示如何在Coq中使用霍尔逻辑进行证明。

定理{True} x := 12 {x = 12}

证明

  1. 应用后果规则,将前提条件改为 12 = 12
  2. 证明 True 蕴含 12 = 12
  3. 证明 x = 12 蕴含自身。
  4. 应用赋值规则,完成证明。
Example hoare_example1 :
  hoare_triple BTrue (CAss "x" (ANum 12)) (BEq (AId "x") (ANum 12)).
Proof.
  apply H_Consequence with (P := BEq (ANum 12) (ANum 12)) (Q := BEq (AId "x") (ANum 12)).
  - unfold entails. intros st H. simpl. reflexivity.
  - unfold entails. intros st H. assumption.
  - apply H_Ass.
Qed.

总结

在本节课中,我们一起学习了如何在Coq中形式化定义编程语言的语义,包括大步操作语义和小步操作语义。我们还实现了霍尔逻辑的推理规则,并通过示例演示了如何使用这些规则进行程序正确性证明。这些工具为形式化验证程序提供了坚实的基础。

posted @ 2026-03-29 09:47  布客飞龙III  阅读(14)  评论(0)    收藏  举报