Haskell簡明教程(一):從遞迴說起
這一系列是我學習Learn You a Haskell For Great Good
之後,總結,編寫的學習筆記。
這個系列主要分為五個部分:
- ofollow,noindex" target="_blank">從命令式語言進行抽象
- Haskell初步:語法
- Haskell進階:Monoid, Applicative, Monad
- 實戰:Haskell和JSON
雖然我們最終的目標是初窺Haskell
,但是就我本人的學習經歷來說,日常是學習命令式
程式設計為主,相信絕大部分同學也是一樣,而不是先學會了函數語言程式設計之後才開始學習指令式程式設計。
所以這一系列教程最開始會從命令式語言進行切入,逐漸過度到Haskell
這一函式式語言。
最開始講述例子所用的語言包括但不限於Python
,Golang
,使用這兩門語言的原因很簡單,
因為我目前比較熟悉的語言是這兩門 :)
此外講解的時候會用一些基本的,簡單地資料結構,為什麼不一如往常,從現實業務切入 慢慢慢慢抽象到這些呢?因為:如上所說,現實業務一步步抽象之後,就能轉化成資料結構, 此外,如果我們再加上這樣一步的話,這個系列怕是要再長不少,我更願意把這些獨立成一個 新的系列來講,如果大家有興趣的話,可以在最下面留言,或者發郵件給我,或者其他方式 告知我 :)
先不用遞迴
我們來看看列表,或者陣列,或者切片(slice),在命令式語言裡我們要怎麼遍歷。
package main import ( "fmt" ) func main() { var simpleArray = [3]int{1, 2, 3} for i, v := range simpleArray { fmt.Printf("index: %d, value: %d\n", i, v) } }
輸出:
$ go run t.go index: 0, value: 1 index: 1, value: 2 index: 2, value: 3
我們的遍歷是,從左往右,一個一個來,數組裡的元素就像是一個一個連著的多米諾骨牌。
好,我們按下不表,再看一個例子,在二叉查詢樹上找子節點。
package main import ( "fmt" ) type Node struct { vint lchild *Node rchild *Node } type Tree *Node func build(array []int) Tree { if len(array) < 1 { return nil } var t Tree = &Node{array[0], nil, nil} for _, v := range array[1:len(array)] { insert(t, v) } return t } func insert(t *Node, v int) { var cursor = t for { if v <= cursor.v { if cursor.lchild == nil { cursor.lchild = &Node{v, nil, nil} return } cursor = cursor.lchild } else { if cursor.rchild == nil { cursor.rchild = &Node{v, nil, nil} return } cursor = cursor.rchild } } } func query(t *Node, v int) (found bool, n *Node) { var cursor = t for cursor.lchild != nil && cursor.rchild != nil { if cursor.v == v { return true, cursor } else if v < cursor.v { cursor = cursor.lchild } else { cursor = cursor.rchild } } return false, nil } func main() { var t = build([]int{7, 3, 6, 8, 1}) found, addr := query(t, 3) fmt.Printf("found? %t, addr: %v\n", found, addr) found, addr = query(t, 10) fmt.Printf("found? %t, addr: %v\n", found, addr) }
我們是如何查詢子節點的呢?如果發現當前值和要查詢的值相同,那麼就返回,如果 要查詢的值比當前值更小,就往左走,否則往右走。
平時我們程式設計就是這樣,仔細的檢查每一個邊界情況,然後控制下一步怎麼走。這就是所謂 的指令式程式設計,我們描述的是每一步該怎麼走。
初探遞迴
在Thinking Recursively 中,簡略的介紹了一下遞迴。
我們現在換一種角度來看上面的問題。什麼是遞迴呢,我們來看看維基百科的解釋,https://en.wikipedia.org/wiki/Recursion_(computer_science)。
Recursion in computer science is a method where the solution to a problem
depends on solutions to smaller instances of the same problem (as opposed to iteration).
將問題分解成相同的子問題。相同,也就是說,我們有一個大的箱子,現在我們把它變成 無數個小箱子。
我們先來看看列表該怎麼抽象。遵循上面的規則,我們把列表拆成更小的相同的子問題。
也就是說我們可以把列表拆成1個節點和剩下的列表,也可以把列表拆成n個節點。沒錯,這
都是抽象,我們先來看看後面這種,當我們把列表拆成n個節點,怎麼進行遍歷呢?沒錯,
其實就是從左往右一個一個來,好像回到了上一節。那如果我們把粒度放大呢?兩個兩個?
試想一下。其實還是一樣,得進行遍歷。但是有一種特殊情況我們不用遍歷,試想,我們
把列表拆成一個子節點,和一個列表。會是怎樣?例如[1, 2, 3, 4, 5]
我們拆成1
和[2, 3, 4, 5]
。首先我們列印1
,然後我們處理後面的這個列表。
似乎可行,用Golang
試試。
package main import ( "fmt" ) func printArray(array []int) { fmt.Printf("%d", array[0]) printArray(array[1:len(array)]) } func main() { var array = []int{6, 5, 4, 7, 8, 9} printArray(array) }
$ go run t.go 654789panic: runtime error: index out of range goroutine 1 [running]: main.printArray(0xc420043f68, 0x0, 0x0) /home/jiajun/tests/t.go:8 +0xec main.printArray(0xc420043f68, 0x1, 0x1) /home/jiajun/tests/t.go:9 +0xdd main.printArray(0xc420043f60, 0x2, 0x2) /home/jiajun/tests/t.go:9 +0xdd main.printArray(0xc420043f58, 0x3, 0x3) /home/jiajun/tests/t.go:9 +0xdd main.printArray(0xc420043f50, 0x4, 0x4) /home/jiajun/tests/t.go:9 +0xdd main.printArray(0xc420043f48, 0x5, 0x5) /home/jiajun/tests/t.go:9 +0xdd main.printArray(0xc420043f40, 0x6, 0x6) /home/jiajun/tests/t.go:9 +0xdd main.main() /home/jiajun/tests/t.go:14 +0x5c exit status 2
為啥會這樣呢?我們來模擬一下程式執行時的情況,首先[6, 5, 4, 7, 8, 9]
執行
printArray時,傳入[6, 5, 4, 7, 8, 9]
,列印 6,然後呼叫 printArray,傳入的
是[5, 4, 7, 8, 9]
... 一直到 最後只剩下[9]
的時候,接下來呼叫 printArray
傳入[]
,然後呼叫array[0]
結果就panic了。
所以遞迴我們需要判斷一下特殊條件。如果傳入的是空陣列,就啥也不幹,退出。
package main import ( "fmt" ) func printArray(array []int) { if len(array) == 0 { return } fmt.Printf("%d", array[0]) printArray(array[1:len(array)]) } func main() { var array = []int{6, 5, 4, 7, 8, 9} printArray(array) }
這樣就好了。
$ go run t.go 654789
那二叉查詢樹該怎麼抽象呢?同樣,就是本節點和左邊的樹,右邊的樹。這就留作思考吧 :)
總結
遞迴是什麼呢?分拆成相同的子問題,把剔出來的那一部分解決之後,再去解決子問題。 通過這一篇,我們從實際程式碼看指令式程式設計是怎樣一步一步操作,然後跳過來從另一個 角度看,瞭解了什麼是遞迴。下一篇,我們繼續看指令式程式設計,看我們如何從實際業務 程式碼脫身,進行抽象。下一篇我們講述一個比較簡單的問題,就是移動端推送(雖然我 已經講過好幾遍了,但這個還真是一個用來講抽象的好例子)。