setlists,member,subset_union,subset_trans

9 minute read Published: 2026-08-31

setlists.dfy:全局图景(Lecture 06)

lset 就是 list<int>:拿一条整数列表冒充"集合"。 文件分上下两半:上半是 function/predicate——集合操作的实现;下半是 lemma——证明这些实现之间满足该有的数学关系。

每条 lemma 都长成同一个模样:member(x, 某操作的结果) ⟺ 用 member 和原料表达的等价说法。也就是说,member 是裁判:一个操作做得对不对,全看它对"谁是成员"这件事的影响是否符合集合论的定义。

※ 原文件里 12 条中有 2 条过不了验证(subset_union 空体、subset_trans 缺一步),修复处均已标注。全文件在 Dafny 4.11.0 下验证通过:14 verified, 0 errors

member:裁判本人

i 在 s 里吗?空表里谁都不在;非空表:要么 i 就是头,要么去尾巴里继续找。

include "../core-list.dfy"

predicate member(i:int, s:lset)
{
    match s
    case Nil => false
    case Cons(hd, tl) => hd == i || member(i, tl)
}

if b then true else false 这类模式暴露的是一个思维习惯:你还在把 bool 当"需要用 if 检查的东西",而不是本身就可以直接返回、直接组合的值。布尔表达式是一等公民,||、&&、! 就是它的运算,如同 +、* 之于整数——你不会写 if x + y == x + y then x + y

四个操作:insert、union、delete、intersect

insert 不查重——列表里允许重复元素,只要 member 的行为对,"集合"的门面就没破。delete 因此要删掉 i 的所有出现,删一次不够:命中头之后丢弃头、继续在尾里删。

// insert:直接把 i 摞在最前面。
function insert(i:int, s:lset) : lset
{
    Cons(i, s)
}

// union(并集):把 A 的元素逐个搬到 B 前面。
function union (A : lset, B : lset) : lset
{
    match A
    case Nil => B
    case Cons(hd, tl) => Cons(hd, union(tl, B))
}

// delete:删掉 i 的所有出现。
function delete(i:int, A : lset) : lset
{
    match A
    case Nil => Nil
    case Cons(hd, tl) =>
        if i == hd then delete(i, tl)
        else Cons(hd, delete(i, tl))
}

// intersect(交集):A 里逐个检查,只留下同时也在 B 里的。
function intersect(A : lset, B : lset) : lset
{
    match A
    case Nil => Nil
    case Cons(hd, tl) =>
        if member(hd, B) then Cons(hd, intersect(tl, B))
        else intersect(tl, B)
}

"A 列表里除了 hd 的剩下部分"——就是 tl。case Cons(hd, tl) 这个模式匹配干的事:把 A 拆成头 hd 和尾 tl,两个名字当场就绑好了,伸手就能用。不需要递归去"剥",match 这一下已经剥完了——递归不是剥的工具,match 才是;递归是拿着剥出来的 tl 继续干活。

subset:空真打底

A 的每个元素都在 B 里。空集是任何集合的子集(空真)。

predicate subset(A : lset, B : lset)
{
    match A
    case Nil => true
    case Cons(hd, tl) => member(hd, B) && subset(tl, B)
}

下半场:定理的统一套路

{} 能过的,是 Dafny 自动归纳够得着的;需要写证明体的,都遵循同一个套路——对 A 做 match,Nil 分支留空,Cons 分支递归调用自己(= 归纳假设),必要时再搬一条别的 lemma 来补缺的那块事实。

member_insert:调用 lemma = 实例化定理

读作:x 在 insert(y, A) 里 ⟺ x 就是 y,或 x 本来就在 A 里。

lemma member_insert(x : int, y:int, A : lset)
  //ensures member(i , s) ==> member(i, insert(i , s))  ← 旧版:单向蕴含太弱,拦不住错误实现
  ensures member(x, insert(y, A)) <==> x == y || member(x, A)
{}

先注意:没有返回类型。lemma 不计算任何东西,它是一条定理。参数也不是"输入",而是数学陈述里的全称变量——整条 lemma 读作:对任意 int x、对任意 int y、对任意 lset A,都有 member(x, insert(y, A)) ⟺ x == y ∨ member(x, A)。参数表就是"对任意……"那一串。

调用 lemma = 实例化定理。 function 调用是"算一下";lemma 调用是"把一般定理套到具体对象上"。在证明体里写 member_insert(3, 5, myset);,意思是:把那条 ∀ 定理里的 x、y、A 分别替换成 3、5、myset,于是此后 solver 就知道了 member(3, insert(5, myset)) <==> 3 == 5 || member(3, myset) 这个具体事实。没有任何计算发生,发生的是"一条知识进入了当前证明的视野"。

每见一个 lemma 的三问:量化了谁?(参数表)有没有前提?(requires)谈论的是谁和谁的关系?(ensures 里出现了哪几个函数)

member_delete:i != j 站错位置

读作:删完 i 之后 j 还在 ⟺ j 不是被删的那个,且 j 本来就在。

旧版写成 member(j, A) <==> member(j, delete(i, A)) && i != j——取 i = j = 3、A = [3] 即翻车。i != j 站错了位置:它被放在右边当结论的一部分,于是这条 lemma 在主张"j 在 A 里就能推出 i ≠ j";它该去约束左边

lemma member_delete(i:int, j:int, A:lset)
  //ensures member(j, A) <==> member(j, delete(i, A)) && i != j  ← 旧版:i != j 站错位置
  ensures member(j, delete(i, A)) <==> i != j && member(j, A)
{}

member_inter 与 member_union:空体即过

x 在交集里 ⟺ x 同时在 A 和 B 里;x 在 A∪B 里 ⟺ x 在 A 里或在 B 里。定理说的就是"我们的实现兑现了教科书定义"——自动归纳够得着,空体即过。

lemma member_inter(x:int, A:lset, B:lset)
  ensures member(x, intersect(A, B)) <==> member (x, A) && member(x, B)
{}

lemma member_union(x : int, A : lset, B : lset)
  ensures member(x, union(A, B)) <==> member(x, A) || member(x, B)
{}

member_subset:第一条带 requires

读作:若 A ⊆ B,则 x ∈ A ⟹ x ∈ B——这其实就是"子集"的语义。第一条带 requires 的:这是条件真理

为什么空 {} 不够、要写证明体? subset 是沿着 A 递归定义的,所以证明也得沿着 A 归纳。Cons 分支里递归调用自己,传入更小的 tl——这一步的含义是"援引归纳假设"

lemma member_subset(x:int, A:lset, B:lset)
  requires subset(A, B)
  ensures member(x, A) ==> member(x, B)
{
    match A
    case Nil =>
    case Cons(hd, tl) => member_subset(x, tl, B);
    // 拼图:若 x == hd,requires 展开后直接有 member(hd,B);
    // 若 x 在 tl 里,归纳假设(上面那行调用)给出 x 在 B 里。两路都通。
}

case Nil => 什么都不写:A 空,member(x, Nil) 是 false,蕴含空真,solver 一眼看穿,分支留空即可。凭什么允许 lemma 调用自己?和函数递归同一条纪律——参数必须变小。

subset_union:A 出现两次,自动归纳使不上劲

读作:A 永远是 A∪B 的子集。 ※ 原文件此处是空 {}——验证失败(postcondition could not be proved)。

为什么空体不行? 结论 subset(A, union(A,B)) 里,A 出现了两次,归纳时两处得同步变小,Dafny 的自动归纳在这种形状上使不上劲,得手写。

Cons 分支的账,目标展开为两块:member(hd, Cons(hd, union(tl,B)))——头就在头上,显然真;subset(tl, Cons(hd, union(tl,B)))——这块是缺口。归纳假设(递归调用)只给到 subset(tl, union(tl,B)),右边少了个 Cons(hd,·)。于是搬来下面的 subset_widen(先用后证,Dafny 不在乎文件顺序):"子集关系不怕右边多塞一个元素"。两步接上,缺口闭合。

参数 C 通篇未用——老师的 stub 就长这样,保持原样,不删。

lemma subset_union(A : lset, B : lset, C : lset)
  ensures subset(A, union(A, B))
{
    match A
    case Nil =>
    case Cons(hd, tl) =>
        subset_union(tl, B, C);              // 归纳假设:subset(tl, union(tl,B))
        subset_widen(tl, hd, union(tl, B));  // 加宽右侧:subset(tl, Cons(hd, union(tl,B)))
}

subset_widen 与 subset_refl:互相搭肩膀

subset_widen 读作:若 A ⊆ B,往 B 头上再塞一个 y,A 仍是其子集——右边只会变大,子集关系不会因此坏掉。沿 A 归纳,标准套路。

subset_refl 读作:任何集合都是自己的子集(自反性)。Cons 分支要两步:归纳假设给 subset(tl, tl),但目标需要 subset(tl, Cons(hd, tl))——右边比左边多个 hd,正是 subset_widen 管的事。lemma 之间就这样互相搭肩膀。

lemma subset_widen(A: lset, y: int, B: lset)
  requires subset(A, B)
  ensures subset(A, Cons(y, B))
{
    match A
    case Nil =>
    case Cons(hd, tl) => subset_widen(tl, y, B);
}

lemma subset_refl(A : lset)
  ensures subset(A, A)
{
    match A
    case Nil =>
    case Cons(hd, tl) => subset_refl(tl); subset_widen(tl, hd, tl);
}

subset_trans:缺的是 member_subset

读作:子集关系可传递——A ⊆ B 且 B ⊆ C,则 A ⊆ C。 ※ 原文件的 Cons 分支只有 subset_trans(tl, B, C) 一行——验证失败。

差在哪? 目标 subset(Cons(hd,tl), C) 展开为两块:member(hd, C)——缺的就是它;subset(tl, C)——归纳假设已覆盖。手里有什么?requires 展开给出 member(hd, B),以及 subset(B, C)。从"hd ∈ B 且 B ⊆ C"到"hd ∈ C"——这正是 member_subset 那条定理。实例化 member_subset(hd, B, C),缺口闭合。

回看 member_subset 的三问:量化了 x、A、B;前提 subset(A,B);连接 member 与 subset。此刻正是它登场的形状。

lemma subset_trans(A : lset, B : lset, C : lset)
  requires subset(A, B) && subset(B, C)
  ensures subset(A, C)
{
    match A
    case Nil =>
    case Cons(hd, tl) =>
        member_subset(hd, B, C);  // 修复:补上 hd ∈ C 这块拼图
        subset_trans(tl, B, C);   // 归纳假设:subset(tl, C)
}