Lean으로 하는 함수형 프로그래밍

8.4. 더 많은 부등식🔗

Lean에 내장된 증명 자동화만으로도 arrayMapHelperfindHelper가 종료함을 확인하기에 충분합니다. 필요했던 것은 재귀 호출마다 그 값이 감소하는 표현식을 제공하는 것뿐이었습니다. 하지만 Lean에 내장된 자동화는 마법이 아니며, 종종 도움이 필요합니다.

8.4.1. 병합 정렬🔗

종료 증명이 자명하지 않은 함수의 한 예로 List에 대한 병합 정렬이 있습니다. 병합 정렬은 두 단계로 구성됩니다. 먼저 리스트를 절반으로 나눕니다. 각 절반은 병합 정렬(merge sort)을 사용하여 정렬된 후, 두 개의 정렬된 리스트를 더 큰 정렬된 리스트로 합치는 함수를 사용하여 결과가 병합됩니다. 기저 사례는 빈 리스트와 단일 원소 리스트이며, 둘 다 이미 정렬된 것으로 간주됩니다.

정렬된 두 리스트를 병합하려면 고려해야 할 두 가지 기본 경우가 있습니다:

  1. 입력 목록 중 하나가 비어 있으면, 결과는 다른 목록이 됩니다.

  2. 두 리스트가 모두 비어 있지 않다면, 두 리스트의 머리를 비교해야 합니다. 함수의 결과는 두 머리 중 더 작은 것 다음에 두 리스트의 나머지 항목들을 병합한 결과가 이어진 것입니다.

이것은 두 리스트 중 어느 쪽에 대해서도 구조적으로 재귀적이지 않습니다. 이 재귀는 각 재귀 호출마다 두 리스트 중 하나에서 항목이 제거되기 때문에 종료되지만, 어느 리스트에서 제거될지는 정해져 있지 않습니다. 내부적으로 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 fail to show termination for mergeSort with errors failed to infer structural recursion: Not considering parameter α of mergeSort: it is unchanged in the recursive calls Not considering parameter #2 of mergeSort: it is unchanged in the recursive calls Cannot use parameter xs: failed to eliminate recursive application mergeSort halves.fst Could not find a decreasing measure. The basic measures relate at each recursive call as follows: (<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted) xs #1 1) 70:11-31 ? ? 2) 70:34-54 _ _ #1: xs.length Please use `termination_by` to specify a decreasing measure.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가 항목이 하나보다 긴 리스트를 배제한다는 것을 알아낼 수 있으므로, "누락된 경우" 오류가 발생하지 않습니다. 하지만 이 프로그램이 항상 종료하더라도, 이는 구조적 재귀가 아니며 Lean은 감소하는 척도를 자동으로 찾아낼 수 없습니다:

fail to show termination for
  mergeSort
with errors
failed to infer structural recursion:
Not considering parameter α of mergeSort:
  it is unchanged in the recursive calls
Not considering parameter #2 of mergeSort:
  it is unchanged in the recursive calls
Cannot use parameter xs:
  failed to eliminate recursive application
    mergeSort halves.fst


Could not find a decreasing measure.
The basic measures relate at each recursive call as follows:
(<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted)
            xs #1
1) 70:11-31  ?  ?
2) 70:34-54  _  _

#1: xs.length

Please use `termination_by` to specify a decreasing measure.

이것이 종료되는 이유는 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 (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_1xs:List αh:¬xs.length < 2halves:List α × List α := splitList xs(splitList xs).fst.length < xs.lengthmergeSort halves.fst) (mergeSort halves.snd) termination_by 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_1xs:List αh:¬xs.length < 2halves:List α × List α := splitList xs(splitList xs).fst.length < xs.length

8.4.2. 리스트를 나누면 더 짧아집니다🔗

또한 (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 := unsolved goals α: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 α:Type u_1lst:List α(splitList lst).fst.length lst.length (splitList lst).snd.length lst.length
unsolved goals
α: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 α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length α: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
unsolved goals
α:Type u_1(splitList []).fst.length  [].length  (splitList []).snd.length  [].length
unsolved goals
α: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 := α:Type u_1lst:List α(splitList lst).fst.length lst.length (splitList lst).snd.length lst.length induction lst with α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length All goals completed! 🐙 α: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
unsolved goals
α: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

이는 List.length 호출이 리스트 x :: xs의 머리를 소비하여 입력 리스트의 길이와 첫 번째 출력 리스트의 길이 모두에서 이를 Nat.succ로 변환하기 때문입니다.

Lean에서 A B를 작성하는 것은 And A B의 축약형입니다. AndProp 유니버스에 있는 구조체 타입입니다:

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

다시 말해, A B의 증명은 left 필드에 있는 A의 증명과 right 필드에 있는 B의 증명에 적용된 And.intro 생성자로 구성됩니다.

cases 택틱은 증명에서 데이터타입의 각 생성자 또는 명제의 각 잠재적 증명을 차례로 고려할 수 있게 해 줍니다. 이는 재귀가 없는 match 표현식에 해당합니다. 구조체에 cases를 사용하면, 마치 패턴 매칭 표현식이 프로그램에서 사용할 구조체의 필드를 추출하는 것처럼, 구조체가 분해되어 구조체의 각 필드에 대한 가정이 추가됩니다. 구조체는 생성자가 하나뿐이므로, 구조체에 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 := α:Type u_1lst:List α(splitList lst).fst.length lst.length (splitList lst).snd.length lst.length induction lst with α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length All goals completed! 🐙 α: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
unsolved goals
α: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

증명의 목표 또한 And이므로, constructor 택틱을 사용하여 And.intro를 적용할 수 있으며, 이는 각 인자에 대한 목표를 생성합니다:

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 α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length All goals completed! 🐙 α: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
unsolved goals
α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length  xs.lengthright✝:(splitList xs).snd.length  xs.length(splitList xs).snd.length  xs.length

α: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

left 목표는 left✝ 가정과 동일하므로, assumption 택틱이 이를 해소합니다:

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 α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length All goals completed! 🐙 α: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
unsolved goals
α: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

right 목표는 right✝ 가정과 유사하지만, 목표는 입력 목록의 길이에만 + 1을 더한다는 점이 다릅니다. 이제 부등식이 성립함을 증명할 차례입니다.

8.4.2.1. 더 큰 쪽에 1 더하기🔗

splitList_shorter_le를 증명하는 데 필요한 부등식은 (n m : Nat), n m n m + 1입니다. n m라는 들어오는 가정은 본질적으로 n과(와) m 사이의 차이를 Nat.le.step 생성자의 개수로 추적합니다. 따라서 증명은 기본 사례(base case)에서 추가로 Nat.le.step을 덧붙여야 합니다.

시작 시점에서 명제문은 다음과 같습니다:

theorem Nat.le_succ_of_le : n m n m + 1 := unsolved goals n m:Natn m n m + 1n:Natm:Natn m n m + 1 n:Natm:Natn m n m + 1
unsolved goals
n m:Natn  m  n  m + 1

첫 번째 단계는 n m이라는 가정에 이름을 붙이는 것입니다:

theorem Nat.le_succ_of_le : n m n m + 1 := unsolved goals n m:Nath:n mn m + 1n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1
unsolved goals
n m:Nath:n  mn  m + 1

이 증명은 이 가정에 대한 귀납법으로 진행됩니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n m✝.succ + 1

refl의 경우, 즉 n = m인 경우, 목표는 n ≤ n + 1임을 증명하는 것입니다:

unsolved goals
n m:Natn  n + 1

step에 대한 경우, 목표는 n m이라는 가정 아래 n m + 1을 증명하는 것입니다:

unsolved goals
n m m✝:Nata✝:n.le m✝ih:n  m✝ + 1n  m✝.succ + 1

refl 경우에는 step 생성자를 적용할 수 있습니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n m✝.succ + 1
unsolved goals
n m:Natn.le n

step 이후, refl을 사용할 수 있으며, 이는 step에 대한 목표만 남깁니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natn.le n; All goals completed! 🐙 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n m✝.succ + 1
unsolved goals
n m m✝:Nata✝:n.le m✝ih:n  m✝ + 1n  m✝.succ + 1

단계(step)의 경우, step 생성자를 적용하면 목표가 귀납 가설로 바뀝니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natn.le n; All goals completed! 🐙 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n m✝.succ + 1
unsolved goals
n m m✝:Nata✝:n.le m✝ih:n  m✝ + 1n.le (m✝ + 1)

최종 증명은 다음과 같습니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natn.le n; All goals completed! 🐙 n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n m✝ + 1n m✝.succ + 1 n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n m✝ + 1n.le (m✝ + 1); All goals completed! 🐙

배후에서 일어나는 일을 드러내려면, applyexact 택틱을 사용하여 정확히 어떤 생성자가 적용되고 있는지 나타낼 수 있습니다. apply 택틱은 반환 타입이 일치하는 함수나 생성자를 적용하여 현재 목표를 해결하며, 제공되지 않은 인자마다 새로운 목표를 생성하는 반면, exact는 새로운 목표가 필요한 경우 실패합니다:

theorem Nat.le_succ_of_le : n m n m + 1 := n:Natm:Natn m n m + 1 n:Natm:Nath:n mn m + 1 induction h with n:Natm:Natn n + 1 n:Natm:Natn.le n; All goals completed! 🐙 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n m✝.succ + 1 n:Natm:Natm✝:Nata✝:n.le m✝ih:n m✝ + 1n.le (m✝ + 1); All goals completed! 🐙

이 증명은 더 간결하게 만들 수 있습니다:

theorem Nat.le_succ_of_le (h : n m) : n m + 1:= n:Natm:Nath:n mn m + 1 n:Natm:Natn n + 1n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n m✝ + 1n m✝.succ + 1 n:Natm:Natn n + 1n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n m✝ + 1n m✝.succ + 1 repeat (first | n:Natm:Natm✝:Nata✝:n.le m✝a_ih✝:n m✝ + 1n.le m✝ | 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:= n:Natm:Nath:n mn m + 1 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. 증명 마무리하기🔗

이제 두 보조 정리가 모두 증명되었으므로, splitList_shorter_le의 나머지 부분은 빠르게 완성될 것입니다. 현재 증명 상태에는 하나의 목표가 남아 있습니다:

unsolved goals
α: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

Nat.le_succ_of_leright✝ 가정과 함께 사용하면 증명이 완성됩니다:

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 α:Type u_1(splitList []).fst.length [].length (splitList []).snd.length [].length All goals completed! 🐙 α: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 α: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 α: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 α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length xs.lengthright✝:(splitList xs).snd.length xs.length(splitList xs).snd.length xs.lengthα: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 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 α:Type u_1x:αxs:List αleft✝:(splitList xs).fst.length xs.lengthright✝:(splitList xs).snd.length xs.length(splitList xs).fst.length xs.length All goals completed! 🐙

다음 단계는 병합 정렬이 종료됨을 증명하는 데 필요한 실제 정리로 돌아가는 것입니다. 즉, 목록에 항목이 최소 두 개 있는 한, 이를 분할한 두 결과 모두 원래 목록보다 엄격하게 더 짧다는 정리입니다.

theorem splitList_shorter (lst : List α) (_ : lst.length 2) : (splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length := unsolved goals α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.lengthα:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length
unsolved goals
α: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 := α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length match lst with α: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
unsolved goals
α: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를 사용해 단순화하면 xy가 제거되며, 그 결과 각 리스트의 계산된 길이는 + 1씩 증가합니다:

theorem splitList_shorter (lst : List α) (_ : lst.length 2) : (splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length := α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length match lst with α: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
unsolved goals
α: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

simpsimp +arith로 대체하면 이 + 1들이 제거되는데, 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 := α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length match lst with α: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
unsolved goals
α: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

이제 이 목표는 증명을 마무리하는 데 사용할 수 있는 splitList_shorter_le와 일치합니다:

theorem splitList_shorter (lst : List α) (_ : lst.length 2) : (splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length := α:Type u_1lst:List αx✝:lst.length 2(splitList lst).fst.length < lst.length (splitList lst).snd.length < lst.length match lst with α: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 α: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 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 |>.right

8.4.2.3. 더 간단한 증명🔗

일반적인 귀납법을 사용하는 대신, splitList_shorter_le는 함수적 귀납법을 사용하여 증명할 수 있으며, 이 경우 splitList의 각 분기마다 하나의 케이스가 생깁니다:

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 fun_induction splitList with α:Type u_1([], []).fst.length [].length ([], []).snd.length [].length α: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 적용 모두 이 첫 번째 분기의 결과로 대체되었습니다:

unsolved goals
α:Type u_1([], []).fst.length  [].length  ([], []).snd.length  [].length

두 번째 경우는 splitList의 재귀 분기와 일치합니다. 귀납 가설 외에도, splitListlet의 값이 가정에서 추적됩니다:

unsolved goals
α:Type u_1x:αxs a 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

두 번째 경우는 다소 복잡해 보이지만, 증명을 완성하는 데 필요한 모든 것이 갖추어져 있습니다. 실제로, grind 택틱은 두 목표를 모두 즉시 증명할 수 있습니다:

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_1([], []).fst.length [].length ([], []).snd.length [].lengthα: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 α:Type u_1([], []).fst.length [].length ([], []).snd.length [].lengthα: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 All goals completed! 🐙

8.4.3. 병합 정렬은 종료합니다🔗

병합 정렬(merge sort)은 두 개의 재귀 호출을 가지며, 각각 splitList가 반환하는 하위 리스트에 대응합니다. 각 재귀 호출은 자신에게 전달되는 목록의 길이가 입력 목록의 길이보다 짧다는 증명을 요구합니다. 종료 증명은 보통 두 단계로 작성하는 것이 편리합니다. 먼저 Lean이 종료를 검증할 수 있게 해줄 명제들을 적어 놓은 다음, 이를 증명합니다. 그렇지 않으면, 명제를 증명하는 데 많은 노력을 들이고 나서야 재귀 호출이 더 작은 입력에 대해 이루어짐을 입증하는 데 그 명제들이 정확히 필요한 것이 아니었음을 알게 될 수 있습니다.

sorry 택틱은 거짓인 것을 포함하여 어떤 목표든 증명할 수 있습니다. 이는 프로덕션 코드나 최종 증명에 사용하도록 의도된 것은 아니지만, 증명이나 프로그램을 미리 “스케치”하는 데 편리한 방법입니다. sorry를 사용하는 정의나 정리에는 경고가 표시됩니다.

sorry를 사용하는 mergeSort의 종료 논증에 대한 초기 스케치는 Lean이 증명하지 못한 목표들을 have-표현식으로 복사하여 작성할 수 있습니다. Lean에서 havelet과 유사합니다. have를 사용할 때, 이름은 선택 사항입니다. 일반적으로 let은 흥미로운 값을 가리키는 이름을 정의하는 데 사용되는 반면, have는 배열 조회가 범위 내에 있다는 근거나 함수가 종료한다는 근거를 Lean이 찾을 때 발견될 수 있는 명제를 지역적으로 증명하는 데 사용됩니다.

declaration uses `sorry`def declaration uses `sorry`declaration uses `sorry`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 := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xshalves.fst.length < xs.length All goals completed! 🐙 have : halves.snd.length < xs.length := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.lengthhalves.snd.length < xs.length All goals completed! 🐙 merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by 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 := unsolved goals α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := xs.length 2α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xshalves.fst.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsxs.length 2 have : halves.snd.length < xs.length := unsolved goals α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := this:halves.fst.length < xs.lengthxs.length 2α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.lengthhalves.snd.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:halves.fst.length < xs.lengthxs.length 2 merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by xs.length

splitList_shorter_fstsplitList_shorter_snd 모두 xs.length 2라는 증명을 요구하기 때문에, 두 증명 모두 실패합니다:

unsolved goals
α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := xs.length  2

이것이 증명을 완성하기에 충분한지 확인하려면, sorry를 사용하여 추가하고 오류를 확인하십시오:

declaration uses `sorry`def declaration uses `sorry`declaration uses `sorry`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 := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsxs.length 2 All goals completed! 🐙 have : halves.fst.length < xs.length := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length 2halves.fst.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length 2xs.length 2 All goals completed! 🐙 have : halves.snd.length < xs.length := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length 2this:halves.fst.length < xs.lengthhalves.snd.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length 2this:halves.fst.length < xs.lengthxs.length 2 All goals completed! 🐙 merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by xs.length

이번에도 경고만 나타납니다.

declaration uses `sorry`

유망해 보이는 가정이 하나 있는데, 바로 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 := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsxs.length 2 All goals completed! 🐙 have : halves.fst.length < xs.length := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length 2halves.fst.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis:xs.length 2xs.length 2 All goals completed! 🐙 have : halves.snd.length < xs.length := α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length 2this:halves.fst.length < xs.lengthhalves.snd.length < xs.length α:Type ?u.3inst✝:Ord αxs:List αh:¬xs.length < 2halves:List α × List α := splitList xsthis✝:xs.length 2this:halves.fst.length < xs.lengthxs.length 2 All goals completed! 🐙 merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by xs.length

이 함수는 예제를 통해 테스트할 수 있습니다:

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

8.4.4. 반복 뺄셈으로서의 나눗셈🔗

곱셈이 덧셈의 반복이고 거듭제곱이 곱셈의 반복이듯, 나눗셈은 뺄셈의 반복으로 이해할 수 있습니다. 이 책에서 재귀 함수를 처음 소개한 부분에서는 제수가 0이 아닐 때 종료하는 나눗셈 버전을 제시하지만, Lean은 이를 받아들이지 않습니다. 나눗셈이 종료됨을 증명하려면 부등식에 관한 사실을 사용해야 합니다.

Lean은 이 나눗셈 정의가 종료됨을 증명할 수 없습니다:

def fail to show termination for div with errors failed to infer structural recursion: Not considering parameter k of div: it is unchanged in the recursive calls Cannot use parameter n: failed to eliminate recursive application div (n - k) k 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 k n:Nath✝:¬n < kn - k < ndiv (n k : Nat) : Nat := if n < k then 0 else 1 + div (n - k) k
fail to show termination for
  div
with errors
failed to infer structural recursion:
Not considering parameter k of div:
  it is unchanged in the recursive calls
Cannot use parameter n:
  failed to eliminate recursive application
    div (n - k) k


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
k n:Nath✝:¬n < kn - k < n

그것은 좋은 일입니다, 왜냐하면 실제로 그렇지 않기 때문입니다! k0일 때 n의 값이 감소하지 않으므로, 이 프로그램은 무한 루프가 됩니다.

k0이 아니라는 증거를 받도록 함수를 다시 작성하면 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 n

8.4.5. 연습 문제🔗

grind를 사용하지 않고 다음 정리들을 증명하십시오:

  • 모든 자연수 n에 대해, 0 < n + 1입니다.

  • 모든 자연수 n에 대해, 0 \leq n입니다.

  • 모든 자연수 nk에 대해, (n + 1) - (k + 1) = n - k입니다.

  • 모든 자연수 nk에 대해, k < n이면 n \neq 0입니다

  • 모든 자연수 n에 대해, n - n = 0입니다

  • 모든 자연수 nk에 대해, n + 1 < k이면 n < k입니다