その他の不等式

arrayMapHelperfindHelper が停止することをチェックするにはLeanの組み込みの証明自動化だけで十分です。必要なものは再帰的に呼び出すたびに値が減少する式を提供することだけです。しかし、Leanの組み込みの自動化は魔法ではないため、しばしば手助けが必要になります。

マージソート

停止証明が自明でない関数の一例として、List のマージソートがあります。マージソートは2つのフェーズから構成されます。まずリストが半分に分割されます。半分になったそれぞれがマージソートでソートされ、その結果を結合してより大きなソート済みリストにする関数を使ってマージされます。基本ケースは空リストと要素が1つのリストで、どちらもすでにソート済みであると見なされます。

2つのソート済みのリストをマージするには2つの基本ケースを考慮する必要があります:

  1. どちらかのリストが空であれば、結果はもう片方のリストになります。
  2. どちらのリストも空でない場合、それらの先頭を比較します。この関数の結果は2つのリストのどちらか小さい方の先頭に両方のリストの残りの要素をマージした結果が続きます。

これはどちらのリストに対しても構造的に再帰的ではありません。再帰呼び出しのたびに2つのリストのどちらかから1つの要素が削除されるため再帰は終了します。termination_by 節は両方のリストの長さの合計を減少する値として使用します:

def merge [Ord α] (xs : List α) (ys : List α) : List α :=
  match xs, ys with
  | [], _ => ys
  | _, [] => xs
  | x'::xs', y'::ys' =>
    match Ord.compare x' y' with
    | .lt | .eq => x' :: merge xs' (y' :: ys')
    | .gt => y' :: merge (x'::xs') ys'
termination_by merge xs ys => xs.length + ys.length

リストの長さを使うだけでなく、両方のリストを含むペアを提供することもできます:

def merge [Ord α] (xs : List α) (ys : List α) : List α :=
  match xs, ys with
  | [], _ => ys
  | _, [] => xs
  | x'::xs', y'::ys' =>
    match Ord.compare x' y' with
    | .lt | .eq => x' :: merge xs' (y' :: ys')
    | .gt => y' :: merge (x'::xs') ys'
termination_by merge xs ys => (xs, ys)

これはLeanが WellFoundedRelation という型クラスで表現されるデータのサイズに関する概念を組み込んでいるため機能します。この型クラスのペアへのインスタンスでは、ペアの1つ目か2つ目のアイテムのどちらかが縮小すると、自動的に小さくなったと見なされます。

リストを分割する簡単な方法は、入力リストの各要素を2つの出力リストに楮に追加することです:

def splitList (lst : List α) : (List α × List α) :=
  match lst with
  | [] => ([], [])
  | x :: xs =>
    let (a, b) := splitList xs
    (x :: b, a)

マージソートでは基本ケースに到達したかどうかをチェックします。もしそうであれば入力リストを返します。そうでない場合は、入力を分割し、それぞれの半分をソートした結果をマージします:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    merge (mergeSort halves.fst) (mergeSort halves.snd)

Leanのパターンマッチのコンパイラは xs.length < 2 かどうかをテストする if によって導入された仮定 h が、1つ以上の要素を除外していることを見分けることができます。そのため「場合分けの考慮もれ」エラーは発生しません。しかし、このプログラムは常に終了するにも関わらず、構造的には再帰的ではありません:

fail to show termination for
  mergeSort
with errors
argument #3 was not used for structural recursion
  failed to eliminate recursive application
    mergeSort halves.fst

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

これが停止する理由は splitList が常に入力よりも短いリストを返すからです。したがって、halves.fsthalves.snd の長さは xs の長さ未満となります。これは termination_by 節を使って表現することができます:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

この節によってエラーメッセージが変化します。Leanは関数が構造的に再帰的でないと文句を言う代わりに、(splitList xs).fst.length < xs.length を自動的に証明できなかった旨を指摘します:

failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
α : Type u_1
xs : List α
h : ¬List.length xs < 2
halves : List α × List α := splitList xs
⊢ List.length (splitList xs).fst < List.length xs

分割によってリストが短くなること

(splitList xs).snd.length < xs.length もまた証明する必要があります。splitList は2つのリストに交互に要素を追加するため、一度に両方の文を証明することが最も簡単であり、そのため証明の構造は splitList 実装に用いられたアルゴリズムに従います。つまり ∀(lst : List), (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length を証明することが最も簡単です。

しかし残念なことに、この文は偽です。特に、splitList []([], []) となります。出力のリストはどちらも長さが 0 で、入力のリストの長さである 0 未満ではありません。同様に、splitList ["basalt"](["basalt"], []) に評価され、["basalt"] の長さは ["basalt"] 未満ではありません。しかし、splitList ["basalt", "granite"](["basalt"], ["granite"]) に評価され、出力のリストは入力リストよりも短くなります。

したがって出力されるリストの長さは必ず入力リストの長さ以下である一方で、入力リストが2つ以上要素を持つときだけ出力リストの長さが入力リストより短くなることがわかります。前者を証明し、後者に拡張することが簡単です。まず次の定理から始めます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  skip
unsolved goals
α : Type u_1
lst : List α
⊢ List.length (splitList lst).fst ≤ List.length lst ∧ List.length (splitList lst).snd ≤ List.length lst

splitList はリスト上において構造的に再帰的であるため、証明は帰納法を使うべきです。splitList についての構造的な再帰は帰納法による証明に完璧に合致します:帰納法の基本ケースは再帰の基本ケースに、帰納法のステップは再帰呼び出しに一致します。induction タクティクは2つのゴールを与えます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => skip
  | cons x xs ih => skip
unsolved goals
case nil
α : Type u_1
⊢ List.length (splitList []).fst ≤ List.length [] ∧ List.length (splitList []).snd ≤ List.length []
unsolved goals
case cons
α : Type u_1
x : α
xs : List α
ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList (x :: xs)).fst ≤ List.length (x :: xs) ∧
    List.length (splitList (x :: xs)).snd ≤ List.length (x :: xs)

空リストの長さは空リストの長さ以下であるため、nil のケースについてのゴールは単純化器を起動して splitList の定義を展開するよう指示することで証明できます。同様に、cons の場合に splitList を用いて単純化するとゴールの長さを Nat.succ が包みます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
unsolved goals
case cons
α : Type u_1
x : α
xs : List α
ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧
    List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

これは List.length の呼び出しがリストの先頭 x :: xs を消費し、入力リストの長さと最初の出力リストの長さの両方で Nat.succ に変換するからです。

Leanにおいて A ∧ BAnd A B の略記です。AndProp 宇宙での構造体型です。

structure And (a b : Prop) : Prop where
  intro ::
  left : a
  right : b

言いかえれば、A ∧ B の証明は And.intro コンストラクタを left フィールドの A の証明と right フィールドの B の証明に適用したものです。

cases タクティクを使うと、証明をデータ型の各コンストラクタや命題の各証明に対して順番に考慮することができます。これは再帰のない match 式に相当します。構造体に対して cases を使用すると、プログラムで使用するためにパターンマッチ式が構造体のフィールドを展開するのと同じように、構造体が分解され構造体の各フィールドに対して仮定が追加されます。構造体にはコンストラクタが1つしかないため、構造体に対して cases を使用してもゴールが追加されることはありません。

ihList.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs の証明であるため、cases ih を使うと仮定 List.length (splitList xs).fst ≤ List.length xs と仮定 List.length (splitList xs).snd ≤ List.length xs が得られます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
unsolved goals
case cons.intro
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧
    List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

証明のゴールも And であるため、constructor タクティクを使って And.intro を適用し、各引数のゴールを得ることができます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧
      (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
unsolved goals
case cons.intro.left
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

case cons.intro.right
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

left のゴールは left の仮定にとてもよく似ていますが、ゴールでは不等式の両辺を Nat.succ が包んでいます。同様に、right のゴールは right に似ていますが、Nat.succ が入力リストの長さにのみ追加されています。これらの Nat.succ のラッピングが文の正しさを保持することを証明する時がきました。

両辺に1を足す

left ゴールの場合、証明すべき文は Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m です。言い換えると、もし n ≤ m ならば両辺に1を足してもこの事実は変わらないということです。なぜそうなるのでしょうか?n ≤ m ということの証明は Nat.le.refl コンストラクタに Nat.le.step コンストラクタを m - n 個のインスタンスでくるんだものです。両辺に1を足すことは単純に refl が以前より1だけ大きい数に適用され、同じ数の step コンストラクタを持つことを意味します。

より形式的には、この証明は n ≤ m という根拠に対する帰納法で示されます。もし根拠が refl ならば、n = m であるため Nat.succ n = Nat.succ m となり、再び refl を使うことができます。もし根拠が step ならば、帰納法の仮定からは Nat.succ n ≤ Nat.succ m という根拠が提供され、ゴールは Nat.succ n ≤ Nat.succ (Nat.succ m) を示すことになります。これは step と帰納法の仮定を併用することで可能です。

Leanにおいて、この定理は以下のように表現されます:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  skip

そしてエラーメッセージがこの内容を要約しています:

unsolved goals
n m : Nat
⊢ n ≤ m → Nat.succ n ≤ Nat.succ m

最初のステップは intro タクティクを使うことで、これにより n ≤ m という仮定をスコープ内に導入し、名前を付けます:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
unsolved goals
n m : Nat
h : n ≤ m
⊢ Nat.succ n ≤ Nat.succ m

この証明は n ≤ m についての根拠に対しての帰納法であるため、次に使うタクティクは induction h です:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h

これによりゴールが Nat.le の各コンストラクタそれぞれに1つずつ、合計2つ作られます:

unsolved goals
case refl
n m : Nat
⊢ Nat.succ n ≤ Nat.succ n

case step
n m m✝ : Nat
a✝ : Nat.le n m✝
a_ih✝ : Nat.succ n ≤ Nat.succ m✝
⊢ Nat.succ n ≤ Nat.succ (Nat.succ m✝)

refl についてのゴールは refl を使って解くことができ、これは constructor タクティクによっても選ばれます。step についてのゴールも step コンストラクタを使用する必要があります:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor
  | step h' ih => constructor
unsolved goals
case step.a
n m m✝ : Nat
h' : Nat.le n m✝
ih : Nat.succ n ≤ Nat.succ m✝
⊢ Nat.le (Nat.succ n) (m✝ + 1)

このゴールはもはや 演算子を用いた表示でありませんが、これは帰納法の仮定 ih と等価です。assumption タクティクはゴールを埋める仮定を自動で選択し、これで証明が完了します:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor
  | step h' ih =>
    constructor
    assumption

この証明を再帰関数として書くと以下のようになります:

theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m
  | .refl => .refl
  | .step h' => .step (Nat.succ_le_succ h')

帰納法によるタクティクベースの証明とこの再帰関数を比較することは学びになるでしょう。どの証明ステップがこの定義のどの部分に対応するでしょうか?

大きい辺に1を足す

splitList_shorter_le の証明に必要な2つめの不等式は ∀(n m : Nat), n ≤ m → n ≤ Nat.succ m です。この証明はほとんど Nat.succ_le_succ と同じです。繰り返しになりますが、n ≤ m という仮定が導入されることで Nat.le.step コンストラクタの数によって nm の差を本質的に追跡することができます。したがって、この証明は基本ケースに Nat.le.step を足す必要があります。この証明は次のように書かれます:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => constructor; constructor
  | step => constructor; assumption

裏で何が起こっているかを明らかにするために、applyexact タクティクを使用してどちらのコンストラクタが適用されているかを正確に示すことができます。apply タクティクは戻り値の型が現在のゴールに一致するような関数やコンストラクタを適用することで現在のゴールを解きます。もし関数などに引数を与えなかった場合は exact では失敗してしまいますが、apply では新たなゴールが作られます。:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by
  intro h
  induction h with
  | refl => apply Nat.le.step; exact Nat.le.refl
  | step _ ih => apply Nat.le.step; exact ih

この証明をゴルフすることができます:

theorem Nat.le_succ_of_le (h : n ≤ m) : n ≤ Nat.succ m := by
  induction h <;> repeat (first | constructor | assumption)

この短いタクティクのスクリプトでは、induction によって導入された両方のゴールに repeat (first | constructor | assumption) を使って対処しています。タクティク first | T1 | T2 | ... | TnT1 から Tn まで順番に試してみて、最初に成功したタクティクを使うことを意味します。つまり、repeat (first | constructor | assumption) はできる限りコンストラクタを適用し、その後仮定を使ってゴールを解こうとします。

最後に、この証明は再帰関数を使っても書くことができます:

theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m
  | .refl => .step .refl
  | .step h => .step (Nat.le_succ_of_le h)

証明についての各スタイルは適材適所です。詳細な証明のスクリプトは初心者がコードを読む場合や、証明のステップから何らかの洞察が得られるような場合に有用です。短く、高度に自動化された証明スクリプトは一般的に保守が容易です。なぜなら、自動化は定義やデータ型の小さな変更を柔軟に吸収し、かつロバストであることが多いからです。再帰関数は一般的に数学的証明の観点からは理解しにくく、保守もしづらいですが、対話型定理証明に取り組み始めたばかりのプログラマにとっては有用な橋渡しになります。

証明を完成させる

これで補助定理が両方とも証明されたため、splitList_shorter_le の残りの部分はすぐに完了します。現在の証明状態には、And の左辺と右辺の2つのゴールがあります:

unsolved goals
case cons.intro.left
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

case cons.intro.right
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

これらのゴールは And 構造体のフィールド名が付けられています。つまり case タクティク(cases と混同しないように)を使ってそれぞれ順番に焦点を当てることができます:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
    case left => skip
    case right => skip

両方の未解決ゴールを並べた1つのエラーから、それぞれ skip による2つのメッセージが表示されるようになります。left のゴールでは Nat.succ_le_succ を使うことができます:

unsolved goals
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)

右のゴールでは Nat.le_suc_of_le が合致します:

unsolved goals
α : Type u_1
x : α
xs : List α
left✝ : List.length (splitList xs).fst ≤ List.length xs
right✝ : List.length (splitList xs).snd ≤ List.length xs
⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)

どちらの定理も前提条件に n ≤ m を含みます。これは left✝right✝ という仮定として見つけることができ、assumption タクティクによって最終的なゴールが対処されることを意味します:

theorem splitList_shorter_le (lst : List α) :
    (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by
  induction lst with
  | nil => simp [splitList]
  | cons x xs ih =>
    simp [splitList]
    cases ih
    constructor
    case left => apply Nat.succ_le_succ; assumption
    case right => apply Nat.le_succ_of_le; assumption

次のステップはマージソートが終了することを証明するために必要な実際の定理に戻ることです:すなわち、リストが少なくとも2つの要素を持つ限り、それを分割した結果の長さは両方とももとのリスト未満という定理です。

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  skip
unsolved goals
α : Type u_1
lst : List α
x✝ : List.length lst ≥ 2
⊢ List.length (splitList lst).fst < List.length lst ∧ List.length (splitList lst).snd < List.length lst

パターンマッチは通常のプログラムと同様にタクティクスクリプトでもうまく機能します。lst には少なくとも2つの要素があるため、match をつかって展開することができます。同時に依存パターンマッチによって型も絞り込まれます:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    skip
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ List.length (splitList (x :: y :: xs)).fst < List.length (x :: y :: xs) ∧
    List.length (splitList (x :: y :: xs)).snd < List.length (x :: y :: xs)

splitList を使って単純化すると、xy が取り除かれ、それぞれ Nat.succ が増えたリストの長さが計算されます:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp [splitList]
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ Nat.succ (List.length (splitList xs).fst) < Nat.succ (Nat.succ (List.length xs)) ∧
    Nat.succ (List.length (splitList xs).snd) < Nat.succ (Nat.succ (List.length xs))

simpsimp_arith に置き換えると、これらの Nat.succ コンストラクタは削除されます。なぜなら、simp_arithn + 1 < m + 1n < m を意味することを利用するからです:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp_arith [splitList]
unsolved goals
α : Type u_1
lst : List α
x y : α
xs : List α
x✝ : List.length (x :: y :: xs) ≥ 2
⊢ List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs

これでこのゴールは splitList_shorter_le にマッチし、証明が完結します:

theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length ∧
      (splitList lst).snd.length < lst.length := by
  match lst with
  | x :: y :: xs =>
    simp_arith [splitList]
    apply splitList_shorter_le

mergeSort が停止することを証明するために必要な事実は、結果として得られる And から引き出すことができます:

theorem splitList_shorter_fst (lst : List α) (h : lst.length ≥ 2) :
    (splitList lst).fst.length < lst.length :=
  splitList_shorter lst h |>.left

theorem splitList_shorter_snd (lst : List α) (h : lst.length ≥ 2) :
    (splitList lst).snd.length < lst.length :=
  splitList_shorter lst h |>.right

マージソートが停止すること

マージソートは2つの再帰呼び出しを持ち、それぞれ splitList が返すサブリストに対して呼び出されます。それぞれの再帰呼び出しでは渡されるリストの長さが入力リストの長さよりも短いことを証明する必要があります。停止性の証明は通常2つのステップで書くと便利です:まずLeanが停止を検証できる命題を書き出し、それを証明します。そうでないと、命題の証明に多くの労力を費やした結果、それが再帰呼び出しの入力が小さくなることの証明に全く必要ないということだけが判明するということにもなりかねません。

sorry タクティクはどのようなゴール、それこそ偽のものであっても証明することができます。これは本番のコードや最終的な証明に使うことは意図されていませんが、証明やプログラムを前もって「下書き」する便利な方法です。sorry を使った定義や定理には警告が付きます。

sorry を使った mergeSort の停止引数についての最初のスケッチは、Leanが証明できなかったゴールを have 式にコピーすることから書き始められます。Leanにおいて、havelet に似ています。have を使う場合、名前を省略することができます。一般的に、let は興味の対象の値を参照する名前の定義のために使用され、have はLeanが何かしらの根拠を検索した際に見つかる命題を局所的に証明するために使用されます。この根拠は配列の検索が範囲内であることや関数が停止することなどです。

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : halves.fst.length < xs.length := by
      sorry
    have : halves.snd.length < xs.length := by
      sorry
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

名前 mergeSort に対して警告が現れます:

declaration uses 'sorry'

エラーは無いため、この命題の方向性は停止性の証明に十分です。

この証明はまず補助定理の適用からはじまります:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

splitList_shorter_fstsplitList_shorter_snd はどちらも xs.length ≥ 2 の証明を必要とするため、どちらの証明も失敗します:

unsolved goals
case h
α : Type ?u.37732
inst✝ : Ord α
xs : List α
h : ¬List.length xs < 2
halves : List α × List α := splitList xs
⊢ List.length xs ≥ 2

これが証明を完成させることを確認するために、sorry を使いエラーになるかチェックします:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : xs.length ≥ 2 := by sorry
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
      assumption
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
      assumption
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

ここでも警告のみとなります。

declaration uses 'sorry'

ここで有力な仮定が1つあります:if によって得られる h : ¬List.length xs < 2 です。明らかに、xs.length < 2 でなければ xs.length ≥ 2 となります。Leanのライブラリにはこの定理を Nat.ge_of_not_lt という名前で提供しています。これでプログラムが完成します:

def mergeSort [Ord α] (xs : List α) : List α :=
  if h : xs.length < 2 then
    match xs with
    | [] => []
    | [x] => [x]
  else
    let halves := splitList xs
    have : xs.length ≥ 2 := by
      apply Nat.ge_of_not_lt
      assumption
    have : halves.fst.length < xs.length := by
      apply splitList_shorter_fst
      assumption
    have : halves.snd.length < xs.length := by
      apply splitList_shorter_snd
      assumption
    merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by mergeSort xs => xs.length

この関数は以下のような例でテストできます:

#eval mergeSort ["soapstone", "geode", "mica", "limestone"]
["geode", "limestone", "mica", "soapstone"]
#eval mergeSort [5, 3, 22, 15]
[3, 5, 15, 22]

割り算が引き算の繰り返しであること

掛け算が足し算の繰り返しであり、累乗が掛け算の繰り返しであるように、割り算は引き算の繰り返しとして理解することができます。本書における再帰関数の最初の説明 では、除数が0でないときに停止する割り算を紹介していますが、Leanはこれを受け入れません。割り算が終了することを証明するためには、不等式に関する事実を使用する必要があります。

最初のステップは割り算についての定義を改めることで、これによって除数が0でないことの根拠が求められます:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if n < k then
    0
  else
    1 + div (n - k) k ok

このエラーメッセージは追加の引数によっていくぶん長いですが、本質的には以下と同じ内容です:

fail to show termination for
  div
with errors
argument #1 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k ok

argument #2 was not used for structural recursion
  failed to eliminate recursive application
    div (n - k) k ok

argument #3 was not used for structural recursion
  application type mismatch
    @Nat.le.brecOn (Nat.succ 0) fun k ok => Nat → Nat
  argument
    fun k ok => Nat → Nat
  has type
    (k : Nat) → k > 0 → Type : Type 1
  but is expected to have type
    (a : Nat) → Nat.le (Nat.succ 0) a → Prop : Type

structural recursion cannot be used

failed to prove termination, use `termination_by` to specify a well-founded relation

この div の定義は最初の引数 n が再帰呼び出しのたびに小さくなることから停止します。これは termination_by 節を使うことで表現されます:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if h : n < k then
    0
  else
    1 + div (n - k) k ok
termination_by div n k ok => n

これで、エラーは再帰呼び出しに限定されるようになります:

failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal
n k : Nat
ok : k > 0
h : ¬n < k
⊢ n - k < n

これは標準ライブラリの定理 Nat.sub_lt を使って証明することができます。この定理は ∀ {n k : Nat}, 0 < n → 0 < k → n - k < n ということを述べています(波括弧は nk が暗黙の引数であることを示しています)。この定理を使うには nk の両方が0よりも大きいことを証明する必要があります。k > 00 < k の糖衣構文であるため、必要なのは 0 < n を示すことだけです。これには2つの場合があります:n0 であるか、ある他の Nat n' に対して n' + 1 であるかです。しかし n0 ではありえません。if による2つ目の分岐は ¬ n < k を意味しますが、もし n = 0k > 0 であれば nk よりも小さいはずであり、これは矛盾になります。これにより n = Nat.succ n' であり、Nat.succ n' は明らかに 0 より大きくなります。

停止についての証明も含めた div の完全な定義は以下になります:

def div (n k : Nat) (ok : k > 0) : Nat :=
  if h : n < k then
    0
  else
    have : 0 < n := by
      cases n with
      | zero => contradiction
      | succ n' => simp_arith
    have : n - k < n := by
      apply Nat.sub_lt <;> assumption
    1 + div (n - k) k ok
termination_by div n k ok => n

演習問題

以下の定理を証明してください:

  • 全ての整数 \( n \) について \( 0 < n + 1 \) である。
  • 全ての整数 \( n \) について \( 0 \leq n \) である。
  • 全ての整数 \( n \) と \( k \) について \( (n + 1) - (k + 1) = n - k \) である。
  • 全ての整数 \( n \) と \( k \) について、もし \( k < n \) ならば \( n \neq 0 \) である。
  • 全ての整数 \( n \) について \( n - n = 0 \) である。
  • 全ての整数 \( n \) と \( k \) について、もし \( n + 1 < k \) ならば \( n < k \) である。