2.4 串列與其歸納定義
如同第1.8 節所述,「元素型別為a的串列」可定義成如下的資料型別:Haskell 中「元素型別為a的串列」寫成[a]. 由於這樣的符號在教學中遇到許多困難,本書中寫成List a.
data List a = [] | a : List a
這個定義可以理解為
[]是一個串列,若
xs是一個元素型別為a的串列,x型別為a, 則x:xs也是一個元素型別為a的串列,此外沒有其他元素型別為
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.