8.4. More Inequalities
Lean의 내장 증명 자동화는 arrayMapHelper와 findHelper가 종료함을 검사하기에 충분하다.
재귀 호출마다 값이 감소하는 표현식을 제공하기만 하면 된다.
그러나 Lean의 내장 자동화가 마법은 아니므로 도움이 필요한 경우가 많다.
8.4.1. Merge Sort
종료 증명이 간단하지 않은 함수의 한 예는 List의 병합 정렬이다.
병합 정렬은 두 단계로 이루어진다. 먼저 리스트를 반으로 나눈다.
각 절반을 병합 정렬로 정렬한 다음 두 정렬 리스트를 더 큰 정렬 리스트로 합치는 함수로 결과를 병합한다.
기본 경우는 빈 리스트와 원소 하나인 리스트이며, 둘 다 이미 정렬된 것으로 본다.
두 정렬 리스트를 병합할 때 고려할 기본 경우는 두 가지다.
-
입력 리스트 중 하나가 비어 있으면 결과는 다른 리스트다.
-
두 리스트가 모두 비어 있지 않으면 머리를 비교해야 한다. 함수의 결과는 두 머리 중 더 작은 값에 이어 두 리스트의 나머지 원소를 병합한 결과다.
이는 어느 리스트에 대해서도 구조적으로 재귀적이지 않다. 각 재귀 호출에서 두 리스트 중 하나의 원소가 제거되므로 재귀가 종료되지만, 어느 리스트에서 제거될지는 다를 수 있다. Lean은 내부적으로 이 사실을 사용해 종료를 증명한다.
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'리스트를 나누는 간단한 방법은 입력 리스트의 각 원소를 두 출력 리스트에 번갈아 추가하는 것이다.
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로 원소가 둘보다 많은 리스트를 배제할 수 있음을 안다. 따라서 “missing cases” 오류가 없다.
그러나 이 프로그램은 항상 종료하더라도 구조적으로 재귀적이지 않으므로 Lean이 감소하는 측정값을 자동으로 찾지 못한다.
종료하는 이유는 splitList가 입력에 원소가 적어도 두 개 있을 때 항상 입력보다 짧은 리스트를 반환하기 때문이다.
따라서 halves.fst와 halves.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 xs.length
이 절을 추가하면 오류 메시지가 바뀐다.
함수가 구조적으로 재귀적이지 않다고 불평하는 대신, Lean은 (splitList xs).fst.length < xs.length을 자동으로 증명하지 못했다고 알려 준다.
8.4.2. Splitting a List Makes it Shorter
(splitList xs).snd.length < xs.length도 증명해야 한다.
splitList는 두 리스트에 번갈아 원소를 추가하므로 두 명제를 한 번에 증명하는 것이 가장 쉽다. 그러면 증명의 구조가 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"])로 평가되고, 이 두 출력 리스트는 모두 입력 리스트보다 짧다.
출력 리스트의 길이는 항상 입력 리스트의 길이보다 작거나 같지만, 입력 리스트에 원소가 적어도 두 개 있을 때만 엄밀히 더 짧다. 먼저 전자의 명제를 증명한 다음 후자의 명제로 확장하는 것이 가장 쉽다. 정리 명제부터 시작하자.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
splitList는 리스트에 대해 구조적으로 재귀하므로 증명에는 귀납법을 사용해야 한다.
splitList의 구조적 재귀는 귀납 증명에 정확히 들어맞는다. 귀납의 기본 경우는 재귀의 기본 경우와 맞고, 귀납 단계는 재귀 호출과 맞는다.
induction 전술은 두 목표를 만든다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length
| cons x xs ih => skip cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
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 α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
| nil => nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length simp [splitList] All goals completed! 🐙
| cons x xs ih =>
simp [splitList] cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1 cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
이는 List.length 호출이 x :: xs 리스트의 머리를 소비해 입력 리스트의 길이와 첫 출력 리스트의 길이를 모두 Nat.succ 형태로 바꾸기 때문이다.
Lean에서 A ∧ B라고 쓰는 것은 And A B의 줄임말이다.
And는 Prop 유니버스의 구조체 타입이다.
structure And (a b : Prop) : Prop where
intro ::
left : a
right : b
다시 말해 A ∧ B의 증명은 left 필드에 A의 증명을, right 필드에 B의 증명을 넣어 And.intro 생성자를 적용한 것이다.
cases 전술을 사용하면 증명에서 데이터 타입의 각 생성자나 명제의 가능한 각 증명을 차례로 살펴볼 수 있다.
이는 재귀가 없는 match 표현식에 해당한다.
구조체에 cases를 사용하면 구조체가 분해되고 각 필드에 대한 가정이 추가된다. 패턴 매칭 표현식이 프로그램에서 사용하도록 구조체의 필드를 꺼내는 것과 같다.
구조체에는 생성자가 하나뿐이므로 구조체에 cases를 사용해도 추가 목표가 생기지 않는다.
ih는 List.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 α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
| nil => nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length simp [splitList] All goals completed! 🐙
| cons x xs ih =>
simp [splitList] cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
cases ih cons.intro α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1 cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
증명의 목표도 And이므로 constructor 전술로 And.intro를 적용할 수 있고, 각 인자에 대한 목표가 생긴다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := by α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
| nil => nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length simp [splitList] All goals completed! 🐙
| cons x xs ih =>
simp [splitList] cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
cases ih cons.intro α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
constructor cons.intro.left α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.lengthcons.intro.right α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).fst.length ≤ xs.length + 1 cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
left 목표는 left✝ 가정과 같으므로 assumption 전술로 해결할 수 있다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := by α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
| nil => nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length simp [splitList] All goals completed! 🐙
| cons x xs ih =>
simp [splitList] cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
cases ih cons.intro α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
constructor cons.intro.left α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.lengthcons.intro.right α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).fst.length ≤ xs.length + 1
case left => α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length assumption All goals completed! 🐙 cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
right 목표는 right✝ 가정과 비슷하지만, 목표에서는 입력 리스트 길이에만 + 1이 추가되어 있다.
이제 부등식이 성립함을 증명할 차례다.
8.4.2.1. Adding One to the Greater Side
splitList_shorter_le을 증명하는 데 필요한 부등식은 ∀(n m : Nat), n ≤ m → n ≤ m + 1이다.
들어온 가정 n ≤ m은 n과 m의 차이를 Nat.le.step 생성자의 개수로 추적한다.
따라서 기본 경우에 Nat.le.step을 하나 더 추가해야 한다.
처음 명제는 다음과 같다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
skip n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
첫 단계는 n ≤ m이라는 가정에 이름을 붙이는 것이다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1이 가정에 대해 귀납법으로 증명한다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => skip refl n:Natm:Nat⊢ n ≤ n + 1
| step _ ih => skip step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1
refl 경우에는 n = m이므로 n ≤ n + 1을 증명해야 한다.
step 경우에는 n ≤ m이라는 가정 아래 n ≤ m + 1을 증명해야 한다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => constructor refl n:Natm:Nat⊢ n.le n refl n:Natm:Nat⊢ n ≤ n + 1
| step _ ih => skip step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1
step 뒤에는 refl을 사용할 수 있으며, 그러면 step에 대한 목표만 남는다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => refl n:Natm:Nat⊢ n ≤ n + 1 constructor refl n:Natm:Nat⊢ n.le n; constructor All goals completed! 🐙
| step _ ih => skip step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1
step 경우에 step 생성자를 적용하면 목표가 귀납 가설로 바뀐다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => refl n:Natm:Nat⊢ n ≤ n + 1 constructor refl n:Natm:Nat⊢ n.le n; constructor All goals completed! 🐙
| step _ ih => constructor step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n.le (m✝ + 1) step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1최종 증명은 다음과 같다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => refl n:Natm:Nat⊢ n ≤ n + 1 constructor refl n:Natm:Nat⊢ n.le n; constructor All goals completed! 🐙
| step => step n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1 constructor step n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n ≤ m✝ + 1⊢ n.le (m✝ + 1); assumption All goals completed! 🐙
내부에서 일어나는 일을 드러내려면 apply와 exact 전술로 정확히 어떤 생성자를 적용하는지 나타낼 수 있다.
apply 전술은 반환 타입이 일치하는 함수나 생성자를 적용해 현재 목표를 해결하고, 제공하지 않은 각 인자에 대해 새 목표를 만든다. 반면 exact는 새 목표가 필요하면 실패한다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1 := by n:Natm:Nat⊢ n ≤ m → n ≤ m + 1
intro h n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h with
| refl => refl n:Natm:Nat⊢ n ≤ n + 1 apply Nat.le.step refl n:Natm:Nat⊢ n.le n; exact Nat.le.refl All goals completed! 🐙
| step _ ih => step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1 apply Nat.le.step step n:Natm:Natm✝:Nata✝:n.le m✝ih:n ≤ m✝ + 1⊢ n.le (m✝ + 1); exact ih All goals completed! 🐙증명을 더 짧게 줄일 수 있다.
theorem Nat.le_succ_of_le (h : n ≤ m) : n ≤ m + 1:= by n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
induction h refl n:Natm:Nat⊢ n ≤ n + 1step n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1 <;> refl n:Natm:Nat⊢ n ≤ n + 1step n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n ≤ m✝ + 1⊢ n ≤ m✝.succ + 1 repeat (first | constructor step n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n ≤ m✝ + 1⊢ n.le m✝ | assumption All goals completed! 🐙)
이 짧은 전술 스크립트에서는 induction이 만든 두 목표를 모두 repeat (first | constructor | assumption)으로 처리한다.
first | T1 | T2 | ... | Tn 전술은 T1부터 Tn까지 차례로 시도하고, 성공하는 첫 전술을 사용한다.
다시 말해 repeat (first | constructor | assumption)은 가능한 동안 생성자를 적용한 다음 가정으로 목표를 풀려고 한다.
선형 산술 해결기를 포함하는 grind를 사용하면 증명을 더 줄일 수 있다.
theorem Nat.le_succ_of_le (h : n ≤ m) : n ≤ m + 1:= by n:Natm:Nath:n ≤ m⊢ n ≤ m + 1
grind All goals completed! 🐙마지막으로 증명을 재귀 함수로 작성할 수도 있다.
theorem Nat.le_succ_of_le : n ≤ m → n ≤ m + 1
| .refl => .step .refl
| .step h => .step (Nat.le_succ_of_le h)각 증명 방식은 상황에 따라 적절할 수 있다. 자세한 증명 스크립트는 초보자가 코드를 읽거나 증명 단계가 통찰을 제공하는 경우에 유용하다. 짧고 고도로 자동화된 증명 스크립트는 정의와 데이터 타입이 조금 바뀌어도 자동화가 유연하고 견고한 경우가 많으므로 대체로 유지 관리가 쉽다. 재귀 함수는 수학적 증명의 관점에서 이해하기 어렵고 유지 관리도 어려운 경우가 많지만, 대화형 정리 증명을 시작하는 프로그래머에게 유용한 다리가 될 수 있다.
8.4.2.2. Finishing the Proof
두 도우미 정리를 모두 증명했으므로 이제 splitList_shorter_le의 나머지를 빠르게 완성할 수 있다.
현재 증명 상태에는 목표가 하나 남아 있다.
right✝ 가정과 함께 Nat.le_succ_of_le을 사용하면 증명이 완성된다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := by α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
induction lst with
| nil => nil α:Type u_1⊢ (splitList []).fst.length ≤ [].length ∧ (splitList []).snd.length ≤ [].length simp [splitList] All goals completed! 🐙
| cons x xs ih => cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList (x :: xs)).fst.length ≤ (x :: xs).length ∧ (splitList (x :: xs)).snd.length ≤ (x :: xs).length
simp [splitList] cons α:Type u_1x:αxs:List αih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
cases ih cons.intro α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length ∧ (splitList xs).fst.length ≤ xs.length + 1
constructor cons.intro.left α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.lengthcons.intro.right α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).fst.length ≤ xs.length + 1
case left => α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).snd.length ≤ xs.length assumption All goals completed! 🐙
case right => α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).fst.length ≤ xs.length + 1
apply Nat.le_succ_of_le α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length ≤ xs.lengthright✝:(splitList xs).snd.length ≤ xs.length⊢ (splitList xs).fst.length ≤ xs.length
assumption All goals completed! 🐙다음 단계는 병합 정렬의 종료를 증명하는 데 실제로 필요한 정리로 돌아가는 것이다. 리스트에 원소가 적어도 두 개 있는 한 리스트를 나눈 두 결과가 모두 엄밀히 더 짧다는 정리다.
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
(splitList lst).fst.length < lst.length ∧
(splitList lst).snd.length < lst.length := by α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
skip α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
패턴 매칭은 프로그램에서처럼 전술 스크립트에서도 똑같이 작동한다.
lst에 원소가 적어도 두 개 있으므로 match로 이를 드러낼 수 있고, 종속 패턴 매칭을 통해 타입도 정제한다.
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
(splitList lst).fst.length < lst.length ∧
(splitList lst).snd.length < lst.length := by α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
match lst with
| x :: y :: xs =>
skip α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList (x :: y :: xs)).fst.length < (x :: y :: xs).length ∧
(splitList (x :: y :: xs)).snd.length < (x :: y :: xs).length
splitList를 사용해 단순화하면 x와 y가 제거되고 계산된 각 리스트 길이에 + 1이 붙는다.
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
(splitList lst).fst.length < lst.length ∧
(splitList lst).snd.length < lst.length := by α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
match lst with
| x :: y :: xs =>
simp [splitList] α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList xs).fst.length < xs.length + 1 ∧ (splitList xs).snd.length < xs.length + 1 α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList (x :: y :: xs)).fst.length < (x :: y :: xs).length ∧
(splitList (x :: y :: xs)).snd.length < (x :: y :: xs).length
simp를 simp +arith로 바꾸면 이 + 1들이 제거된다. simp +arith가 n + 1 < m + 1이면 n < m이라는 사실을 사용하기 때문이다.
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
(splitList lst).fst.length < lst.length ∧
(splitList lst).snd.length < lst.length := by α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
match lst with
| x :: y :: xs =>
simp +arith [splitList] α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList (x :: y :: xs)).fst.length < (x :: y :: xs).length ∧
(splitList (x :: y :: xs)).snd.length < (x :: y :: xs).length
이제 목표가 splitList_shorter_le과 일치하므로 이를 사용해 증명을 끝낼 수 있다.
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) :
(splitList lst).fst.length < lst.length ∧
(splitList lst).snd.length < lst.length := by α:Type u_1lst:List αx✝:lst.length ≥ 2⊢ (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
match lst with
| x :: y :: xs => α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList (x :: y :: xs)).fst.length < (x :: y :: xs).length ∧
(splitList (x :: y :: xs)).snd.length < (x :: y :: xs).length
simp +arith [splitList] α:Type u_1lst:List αx:αy:αxs:List αx✝:(x :: y :: xs).length ≥ 2⊢ (splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length
apply splitList_shorter_le All goals completed! 🐙
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 |>.right8.4.2.3. A Simpler Proof
일반 귀납법 대신 함수 귀납법을 사용해 splitList_shorter_le을 증명할 수 있다. 그러면 splitList의 각 분기에 하나씩 경우가 생긴다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := by α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
fun_induction splitList with
| case1 => skip case1 α:Type u_1⊢ ([], []).fst.length ≤ [].length ∧ ([], []).snd.length ≤ [].length
| case2 x xs a b splitEq ih => skip case2 α:Type u_1x:αxs:List αa:List αb:List αsplitEq:splitList xs = (a, b)ih:(splitList xs).fst.length ≤ xs.length ∧ (splitList xs).snd.length ≤ xs.length⊢ (x :: b, a).fst.length ≤ (x :: xs).length ∧ (x :: b, a).snd.length ≤ (x :: xs).length
첫 번째 경우는 splitList의 기본 경우와 일치한다.
splitList의 두 호출이 모두 이 첫 번째 분기의 결과로 바뀌었다.
두 번째 경우는 splitList의 재귀 분기와 일치한다.
귀납 가설과 함께 splitList의 let 값도 가정으로 추적한다.
두 번째 경우가 조금 복잡해 보이지만 증명을 완성하는 데 필요한 내용이 모두 들어 있다.
실제로 grind는 두 목표를 즉시 증명할 수 있다.
theorem splitList_shorter_le (lst : List α) :
(splitList lst).fst.length ≤ lst.length ∧
(splitList lst).snd.length ≤ lst.length := by α:Type u_1lst:List α⊢ (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length
fun_induction splitList case1 α:Type u_1⊢ ([], []).fst.length ≤ [].length ∧ ([], []).snd.length ≤ [].lengthcase2 α:Type u_1x✝¹:αxs✝:List αa✝:List αb✝:List αx✝:splitList xs✝ = (a✝, b✝)ih1✝:(splitList xs✝).fst.length ≤ xs✝.length ∧ (splitList xs✝).snd.length ≤ xs✝.length⊢ (x✝¹ :: b✝, a✝).fst.length ≤ (x✝¹ :: xs✝).length ∧ (x✝¹ :: b✝, a✝).snd.length ≤ (x✝¹ :: xs✝).length <;> case1 α:Type u_1⊢ ([], []).fst.length ≤ [].length ∧ ([], []).snd.length ≤ [].lengthcase2 α:Type u_1x✝¹:αxs✝:List αa✝:List αb✝:List αx✝:splitList xs✝ = (a✝, b✝)ih1✝:(splitList xs✝).fst.length ≤ xs✝.length ∧ (splitList xs✝).snd.length ≤ xs✝.length⊢ (x✝¹ :: b✝, a✝).fst.length ≤ (x✝¹ :: xs✝).length ∧ (x✝¹ :: b✝, a✝).snd.length ≤ (x✝¹ :: xs✝).length grind All goals completed! 🐙8.4.3. Merge Sort Terminates
병합 정렬에는 splitList가 반환한 각 부분 리스트에 대한 재귀 호출이 하나씩, 총 두 개 있다.
각 재귀 호출에는 전달하는 리스트의 길이가 입력 리스트의 길이보다 짧다는 증명이 필요하다.
종료 증명은 보통 두 단계로 작성하는 것이 편리하다. 먼저 Lean이 종료를 확인할 수 있게 하는 명제를 적고, 그 다음 이를 증명한다.
그렇지 않으면 명제를 증명하는 데 많은 노력을 들이고도 재귀 호출이 더 작은 입력에 대해 이루어짐을 보이는 데 필요한 명제가 아니었다는 사실을 알게 될 수 있다.
sorry 전술은 거짓인 목표를 포함해 어떤 목표든 증명할 수 있다.
프로덕션 코드나 최종 증명에 사용하도록 만든 것은 아니지만, 증명이나 프로그램의 윤곽을 미리 “스케치”하는 편리한 방법이다.
sorry를 사용하는 정의나 정리에는 경고가 붙는다.
sorry를 사용하는 mergeSort의 종료 논증 초안은 Lean이 증명하지 못한 목표를 have 표현식으로 옮겨 적어 작성할 수 있다.
Lean에서 have는 let과 비슷하다.
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 α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xs⊢ halves.fst.length < xs.length
sorry All goals completed! 🐙
have : halves.snd.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.length⊢ halves.snd.length < xs.length
sorry All goals completed! 🐙
merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by xs.length
경고는 mergeSort 이름에 표시된다.
오류가 없으므로 제안한 명제만으로 종료를 확립하기에 충분하다.
증명은 도우미 정리를 적용하는 것으로 시작한다.
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 α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xs⊢ halves.fst.length < xs.length
apply splitList_shorter_fst α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xs⊢ xs.length ≥ 2
have : halves.snd.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.length⊢ halves.snd.length < xs.length
apply splitList_shorter_snd α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.length⊢ xs.length ≥ 2
merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by xs.length
두 증명 모두 실패한다. splitList_shorter_fst와 splitList_shorter_snd가 모두 xs.length ≥ 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 α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xs⊢ xs.length ≥ 2 sorry All goals completed! 🐙
have : halves.fst.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length ≥ 2⊢ halves.fst.length < xs.length
apply splitList_shorter_fst α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length ≥ 2⊢ xs.length ≥ 2
assumption All goals completed! 🐙
have : halves.snd.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length ≥ 2this:halves.fst.length < xs.length⊢ halves.snd.length < xs.length
apply splitList_shorter_snd α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length ≥ 2this:halves.fst.length < xs.length⊢ xs.length ≥ 2
assumption All goals completed! 🐙
merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by xs.length이번에도 경고만 표시된다.
if에서 나온 유망한 가정이 하나 있다. 바로 h : ¬List.length xs < 2다.
분명히 xs.length < 2가 아니라면 xs.length ≥ 2다.
grind 전술이 이 목표를 해결하므로 프로그램이 완성된다.
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 α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xs⊢ xs.length ≥ 2
grind All goals completed! 🐙
have : halves.fst.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length ≥ 2⊢ halves.fst.length < xs.length
apply splitList_shorter_fst α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length ≥ 2⊢ xs.length ≥ 2
assumption All goals completed! 🐙
have : halves.snd.length < xs.length := by α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length ≥ 2this:halves.fst.length < xs.length⊢ halves.snd.length < xs.length
apply splitList_shorter_snd α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length ≥ 2this:halves.fst.length < xs.length⊢ xs.length ≥ 2
assumption All goals completed! 🐙
merge (mergeSort halves.fst) (mergeSort halves.snd)
termination_by xs.length이 함수는 예제로 테스트할 수 있다.
#eval mergeSort ["soapstone", "geode", "mica", "limestone"]#eval mergeSort [5, 3, 22, 15]8.4.4. Division as Iterated Subtraction
곱셈이 반복된 덧셈이고 거듭제곱이 반복된 곱셈인 것처럼 나눗셈은 반복된 뺄셈으로 이해할 수 있다. 이 책에서 재귀 함수를 처음 설명한 부분에서는 제수가 0이 아니면 종료하지만 Lean이 받아들이지 않는 나눗셈 버전을 소개한다. 나눗셈의 종료를 증명하려면 부등식에 관한 사실을 사용해야 한다.
Lean은 이 나눗셈 정의가 종료함을 증명할 수 없다.
def div (n k : Nat) : Nat :=
if n < k then
0
else
1 + div (n - k) k
이는 다행이다. 실제로 종료하지 않기 때문이다.
k가 0이면 n의 값이 감소하지 않으므로 프로그램은 무한 루프가 된다.
k가 0이 아니라는 증거를 받도록 함수를 다시 작성하면 Lean이 자동으로 종료를 증명할 수 있다.
def div (n k : Nat) (ok : k ≠ 0) : Nat :=
if h : n < k then
0
else
1 + div (n - k) k ok
이 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 n8.4.5. Exercises
grind를 사용하지 않고 다음 정리들을 증명하라:
-
모든 자연수
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임을 증명하라.