2 歸納定義與證明

2.4 串列與其歸納定義

如同第1.8 節所述,「元素型別為a的串列」可定義成如下的資料型別:Haskell 中「元素型別為a的串列」寫成[a]. 由於這樣的符號在教學中遇到許多困難,本書中寫成List a.

data List a = [] | a : List a 

這個定義可以理解為

  1. [] 是一個串列,

  2. 若 xs 是一個元素型別為a的串列,x 型別為 a, 則 x:xs 也是一個元素型別為a的串列,

  3. 此外沒有其他元素型別為a的串列。

我們不難發現 List a 和 Nat 是相當類似的資料結構:[] 相當於 Zero, (:) 則類似 Suc, 只是此處我們不只「加一」,添加的那個東西多了一些資訊,是一個型別為a的元素。 或著我們可反過來說,串列「只是」在每個Suc上都添了一些資訊的自然數! 既然自然數與串列有類似的結構,不難想像許多自然上的函數、自然數的性質,都有串列上的類似版本,

2.4.1 串列上之歸納定義

和自然數類似,許多串列上的函數可歸納地定義出來。 由於串列只可能由[]或(:)做出,定義串列上的函數時也分別處理這兩個情況。 基底情況為[], 而欲定義 f (x:xs) 的值時,可假設 f xs 已算出來了:

f :: List a -> b
f []      = e
f (x:xs)  = ... f xs ...

來看些例子吧!「算一個陣列的和」可能是許多人學到陣列後得寫的頭幾個練習程式。串列版的和可以這麼寫:

sum :: List Int -> Int
sum []      = 0
sum (x:xs)  = x + sum xs 

基底狀況中,空串列的和應是0。歸納步驟中,我們要算x:xs 的和,可假設我們已算出xs 的和,再加上x 即可。計算串列長度的 length 有很類似的定義 :

length :: List a -> Nat
length []      = Zero
length (x:xs)  = Suc (length xs) 

在歸納步驟中,我們想計算x:xs 的長度,只需假設我們已知xs 的長度,然後加一。 事實上,length 剛好體現了前述「List a 只是在每個Suc上添了資訊的自然數」一事: length 把串列走過一遍,將 [] 代換成 Zero,並將每個 (_:) 中附加的資訊拋棄,代換成 Suc。

函數 map f :: List a -> List b,也就是 map 給定函數 f 的結果,也可在串列上歸納定義:

map :: (a -> b) -> List a -> List b
map f []      = []
map f (x:xs)  = f x : map f xs 

基底狀況的合理結果是[]. 歸納步驟中,要對 x:xs 中的每個元素都做 f, 我們可假設已經知道如何對 xs 中的每個元素都做 f, 把其結果接上 f x 即可。

函數 (++) 把兩個串列接起來。如果我們在其左邊的參數上做歸納定義,可得到: 依照 Haskell 的運算元優先順序,x : (xs ++ ys) 其實可寫成 x : xs ++ ys, 一般也常如此寫。此處為了清楚而加上括號。

(++) :: List a -> List a -> List a
[]      ++ ys  = ys
(x:xs)  ++ ys  = x : (xs ++ ys) 

空串列接上 ys 仍是 ys. 歸納步驟中,要把 x:xs 接上 ys, 我們可假設已有辦法把 xs 接上 ys, 然後只需添上 x 即可。

請讀者比較一下(++)與自然數加法(+)的定義,會發現兩者的結構一模一樣! 如果串列是在每個Suc中加上資料的自然數,(++)就是串列上的加法了。 若要形式化地把 List a, Nat, (++), 與 (+) 牽上關係,連接他們的橋樑就是 length --- xs ++ ys 的長度,應是 xs 與 ys 的長度之和!意即:

  length (xs ++ ys) = length xs + length ys     (2.2 )

習題 2.10 中將證明此性質。

最後,(++) 是反覆使用 (:), 函數 concat 則是反覆使用 (++):

concat :: List (List a) -> List a
concat [] = []
concat (xs:xss) = xs ++ concat xss 

2.4.2 串列上之歸納證明

如果 List a 是一個歸納定義出的資料結構,我們應可以在 List a 之上做歸納證明。確實,串列上的歸納法可寫成:

  (∀ xs . P xs)  <==   P [] ⋀ (∀ x xs . P (x:xs) <== P xs) 

以文字敘述的話:給定一個述語 P :: List a -> Bool, 若要證明 P xs 對所有 xs 都成立,只需證明 P [] 和「對所有 x 和 xs, 若 P xs 則 P (x:xs)」。

下述的 map 融合定理(map-fusion theorem) 是關於 map 極常用的定理之一。所謂「融合」在此處是把兩個 map 融合為一。 我們日後會見到更多的融合定理。

定理 2.2 |map| 融合定理

對任何 f 與 g, map f . map g = map (f.g).

作為一個例子,我們試著證明定理2.2 。 我們目前只會用歸納證明,但是 map f . map g = map (f.g) 的左右邊都是函數, 沒有出現串列也沒有出現自然數。該拿什麼東西來歸納呢?

回顧外延相等(定義1.8 ): 當h, k 均是函數,h = k 的意思是對任何參數 x, h x = k x. 因此,將待證式左右邊各補上參數,並將 (.) 展開,可得知其意義為對任何 xs,

    map f (map g xs) = map (f.g) xs \mbox{~ ~.}

我們便可以在xs上做歸納了!

證明

當 xs := [],等式兩邊皆歸約為 []. 考慮 xs := x:xs 的情況:

  map f (map g (x:xs))
=   {- map 之定義 -}
  map f (g x : map g xs)
=   {- map 之定義 -}
  f (g x) : map f (map g xs)
=   {- (.) 之定義 -}
  (f.g) x : map f (map g xs)
=   {- 歸納假設 -}
  (f.g) x : map (f.g) xs
=   {- map 之定義 -}
  map (f.g) (x:xs) 
等式證明的步驟該多詳細?

本書中目前為止的等式證明相當細:每一個定義展開都成為獨立的步驟。 這是為了教學目的,實務上不一定得如此。 以我而言,自己的研究手稿中可能會將步驟寫得極詳細, 為確保每個細節正確,並讓他人(或幾年後已經忘記細節的自己)在不需知道上下文的情況下也能機械化地檢查每個步驟。 但在論文中,因篇幅有限,及考量讀者一次能處理的資訊量有限, 發表出的證明可能會省略許多步驟。

實務上,被認為簡單、不寫出也不妨礙理解的步驟或說明都可被省略。 但何謂簡單則很依靠作者的判斷與習慣。 一般說來,僅展開定義的步驟用電腦便可自動做到,通常是可精簡掉的。 最好寫出的步驟則可能是決定整個證明之結構的、不易以電腦決定而得靠人類智慧與經驗的,等等。 這可能包括使用歸納假設的那步,或使用較特別的引理時。 例如,性質 (2.2 )的歸納步驟證明可能被精簡如下:

   length ((x:xs) ++ ys)
=  Suc (length (xs ++ ys))
=    {- 歸納假設 -}
   Suc (length xs + length ys)
=  length (x:xs) + length ys 
練習 2.4

證明對所有 xs, xs ++ [] = xs. 比較本題與習題2.3 的證明。

練習 2.5

定義函數 reverse :: List a -> List a, 將輸入的串列反轉。例如 reverse [1,2,3,4,5] = [5,4,3,2,1].

答案
reverse :: List a -> List a
reverse []      = []
reverse (x:xs)  = reverse xs ++ [x] 

另,關於 reverse 效率的討論詳見第 5.2 節。

練習 2.6

證明對所有 f, length . map f = length.

練習 2.7

證明對所有 x, sum . map (x*) = (x*) . sum.

答案

欲證明sum (map (x*) ys) = x * sum ys, 在 ys 上歸納。

情況 ys := [], 兩邊都歸約為 0.

情況 ys := y:ys:

   sum (map (x*) (y:ys))
=    {- map 之定義 -}
   sum (x*y : map (x*) ys)
=    {- sum 之定義 -}
   x * y + sum (map (x*) ys)
=    {- 歸納假設 -}
   x * y + x * sum ys
=    {- 乘法與加法之分配律 -}
   x * (y+sum ys)
=    {- sum 之定義 -}
   x * sum (y:ys) 
練習 2.8

證明對所有 xs, sum (map (Suc) xs) = length xs + sum xs.

練習 2.9

證明對所有 xs 與 y, sum (map (const y) xs) = y * length xs.

討論自然數時,習題2.2 曾請讀者證明加法都滿足結合律。此處示範證明類似定理的串列版:

定理 2.3

(++) 滿足結合律。意即,對任何 xs, ys, 和zs, (xs ++ ys) ++ zs = xs ++ (ys ++ zs).

證明

上述式子中有三個變數,我們怎麼得知該在哪一個變數上做歸納呢? 此時絕對別急著把三個變數都拆開,變成多達八種狀況。 觀察:如果要歸約等號左邊的 (xs ++ ys) ++ zs,根據 (++) 的定義,得對 xs ++ ys 做狀況分析;要歸約 xs ++ ys,又得對 xs 做狀況分析。 同樣地,根據 (++) 的定義,要歸約等號右邊的 xs ++ (ys ++ zs) 得對 xs 做狀況分析。 不論左右邊,最關鍵的值都是 xs. 因此我們在 xs 之上做歸納。

狀況 xs:=[]:

   ([] ++ ys) ++ zs
=   {- (++) 之定義 -}
   ys ++ zs
=   {- (++) 之定義 -}
   [] ++ (ys ++ zs) 

狀況 xs:= x:xs:

   ((x:xs) ++ yz) ++ zs
=    {- (++) 之定義 -}
   (x : (xs ++ ys)) ++ zs
=    {- (++) 之定義 -}
   x : ((xs ++ ys) ++ zs)
=    {- 歸納假設 -}
   x : (xs ++ (ys ++ zs))
=    {- (++) 之定義 -}
   (x : xs) ++ (ys ++ zs) 

基底狀況的證明很簡單。至於歸納步驟,同樣地,前兩步都是為了湊出 (xs ++ ys) ++ zs, 以便使用歸納假設。既然 (++) 滿足結合律,日後我們寫 xs ++ ys ++ zs 就可不加括號了。

練習 2.10

證明性質(2.2 ):對所有 xs 與 ys, length (xs ++ ys) = length xs + length ys.

答案

檢視 length, (++), 與 (+) 的定義,會發現等號兩邊都須對 xs 做分析才能化簡。因此我們對 xs 做歸納。

狀況 xs := [].

      length ([] ++ ys)
 ===    {- (++) 之定義 -}
      length ys
 ===   {- (+) 之定義 -}
      Zero + length ys
 ===   {- length 之定義 -}
      length [] + length ys 

狀況 xs := x : xs.

   length ((x:xs) ++ ys)
 ===    {- (++) 之定義  -}
   length (x : (xs ++ ys))
 ===    {- length 之定義 -}
   Suc (length (xs ++ ys))
 ===    {- 歸納假設 -}
   Suc (length xs + length ys)
 ===    {- (+) 之定義 -}
   (Suc (length xs)) + length ys
 ===    {- length 之定義 -}
   length (x:xs) + length ys 
練習 2.11

證明對所有 f, xs, 與 ys, map f (xs ++ ys) = map f xs ++ map f ys.