maximum
函数取一组可排序的 List(属于 Ord Typeclass) 做参数,并回传其中的最大值。想想,在命令式风格中这一函数该怎么实现。很可能你会设一个变量来存储当前的最大值,然后用循环遍历该 List,若存在比这个值更大的元素,则修改变量为这一元素的值。到最后,变量的值就是运算结果。唔!描述如此简单的算法还颇费了点口舌呢!
现在看看递归的思路是如何:我们先定下一个边界条件,即处理单个元素的 List 时,回传该元素。如果该 List 的头部大于尾部的最大值,我们就可以假定较长的 List 的最大值就是它的头部。而尾部若存在比它更大的元素,它就是尾部的最大值。就这么简单!现在,我们在 Haskell 中实现它
maximum' :: (Ord a) => [a] -> a
maximum' [] = error "maximum of empty list"
maximum' [x] = x
maximum' (x:xs)
| x > maxTail = x
| otherwise = maxTail
where maxTail = maximum' xs
如你所见,模式匹配与递归简直就是天造地设!大多数命令式语言中都没有模式匹配,于是你就得造一堆 if-else 来测试边界条件。而在这里,我们仅需要使用模式将其表示出来。第一个模式说,如果该 List 为空,崩溃!就该这样,一个空 List 的最大值能是啥?我不知道。第二个模式也表示一个边缘条件,它说, 如果这个 List 仅包含单个元素,就回传该元素的值。
改用 max
函数会使代码更加清晰。如果你还记得,max
函数取两个值做参数并回传其中较大的值。如下便是用 max
函数重写的 maximun'
maximum' :: (Ord a) => [a] -> a
maximum' [] = error "maximum of empty list"
maximum' [x] = x
maximum' (x:xs) = max x (maximum' xs)
太漂亮了!一个 List 的最大值就是它的首个元素与它尾部中最大值相比较所得的结果,简明扼要。
现在我们已经了解了递归的思路,接下来就使用递归来实现几个函数. 先实现下 replicate
函数, 它取一个 Int
值和一个元素做参数, 回传一个包含多个重复元素的 List, 如 replicate 3 5
回传 [5,5,5]
. 考虑一下, 我觉得它的边界条件应该是负数. 如果要 replicate
重复某元素零次, 那就是空 List. 负数也是同样, 不靠谱.
replicate' :: (Num i, Ord i) => i -> a -> [a]
replicate' n x
| n <= 0 = []
| otherwise = x:replicate' (n-1) x
在这里我们使用了 guard 而非模式匹配, 是因为这里做的是布林判断. 如果 n
小于 0 就回传一个空 List, 否则, 回传以 x
作首个元素并后接重复 n-1
次 x
的 List. 最后, (n-1)
的那部分就会令函数抵达边缘条件.
Note: Num 不是 Ord 的子集, 表示数字不一定得拘泥于排序, 这就是在做加减法比较时要将 Num 与 Ord 类型约束区别开来的原因.
接下来实现 take
函数, 它可以从一个 List 取出一定数量的元素. 如 take 3 [5,4,3,2,1]
, 得 [5,4,3]
. 若要取零或负数个的话就会得到一个空 List. 同样, 若是从一个空 List中取值, 它会得到一个空 List. 注意, 这儿有两个边界条件, 写出来:
take' :: (Num i, Ord i) => i -> [a] -> [a]
take' n _
| n <= 0 = []
take' _ [] = []
take' n (x:xs) = x : take' (n-1) xs 首个模式辨认若为 0 或负数, 回传空 List. 同时注意这里用了一个 guard 却没有指定otherwise
部分, 这就表示n
若大于 0, 会转入下一模式. 第二个模式指明了若试图从一个空 List 中取值, 则回传空 List. 第三个模式将 List 分割为头部和尾部, 然后表明从一个 List 中取多个元素等同于令x
作头部后接从尾部取n-1
个元素所得的 List. 假如我们要从[4,3,2,1]
中取 3 个元素, 试着从纸上写出它的推导过程
reverse' :: [a] -> [a]
reverse' [] = []
reverse' (x:xs) = reverse' xs ++ [x]
继续下去!
Haskell 支持无限 List,所以我们的递归就不必添加边界条件。这样一来,它可以对某值计算个没完, 也可以产生一个无限的数据结构,如无限 List。而无限 List 的好处就在于我们可以在任意位置将它断开.
repeat
函数取一个元素作参数, 回传一个仅包含该元素的无限 List. 它的递归实现简单的很, 看:
repeat' :: a -> [a]
repeat' x = x:repeat' x
zip
取两个 List 作参数并将其捆在一起。zip [1,2,3] [2,3]
回传 [(1,2),(2,3)]
, 它会把较长的 List 从中间断开, 以匹配较短的 List. 用 zip
处理一个 List 与空 List 又会怎样? 嗯, 会得一个空 List, 这便是我们的限制条件, 由于 zip
取两个参数, 所以要有两个边缘条件
zip' :: [a] -> [b] -> [(a,b)]
zip' _ [] = []
zip' [] _ = []
zip' (x:xs) (y:ys) = (x,y):zip' xs ys
前两个模式表示两个 List 中若存在空 List, 则回传空 List. 第三个模式表示将两个 List 捆绑的行为, 即将其头部配对并后跟捆绑的尾部. 用 zip
处理 [1,2,3]
与 ['a','b']
的话, 就会在 [3]
与 []
时触及边界条件, 得到(1,'a'):(2,'b'):[]
的结果,与 [(1,'a'),(2,'b')]
等价.
再实现一个标准库函数 -- elem
! 它取一个元素与一个 List 作参数, 并检测该元素是否包含于此 List. 而边缘条件就与大多数情况相同, 空 List. 大家都知道空 List 中不包含任何元素, 便不必再做任何判断
elem' :: (Eq a) => a -> [a] -> Bool
elem' a [] = False
elem' a (x:xs)
| a == x = True
| otherwise = a `elem'` xs
这很简单明了。若头部不是该元素, 就检测尾部, 若为空 List 就回传 False
.
快速"排序
假定我们有一个可排序的 List, 其中元素的类型为 Ord Typeclass 的成员. 现在我们要给它排序! 有个排序算法非常的酷, 就是快速排序 (quick sort), 睿智的排序方法. 尽管它在命令式语言中也不过 10 行, 但在 Haskell 下边要更短, 更漂亮, 俨然已经成了 Haskell 的招牌了. 嗯, 我们在这里也实现一下. 或许会显得很俗气, 因为每个人都用它来展示 Haskell 究竟有多优雅!
它的类型声明应为 quicksort :: (Ord a) => [a] -> [a]
, 没啥奇怪的. 边界条件呢? 如料,空 List。排过序的空 List 还是空 List。接下来便是算法的定义:排过序的 List 就是令所有小于等于头部的元素在先(它们已经排过了序), 后跟大于头部的元素(它们同样已经拍过了序)。 注意定义中有两次排序,所以就得递归两次!同时也需要注意算法定义的动词为"是"什么而非"做"这个, "做"那个, 再"做"那个...这便是函数式编程之美!如何才能从 List 中取得比头部小的那些元素呢?List Comprehension。好,动手写出这个函数!
quicksort :: (Ord a) => [a] -> [a]
quicksort [] = []
quicksort (x:xs) =
let smallerSorted = quicksort [a | a <- xs, a <= x]
biggerSorted = quicksort [a | a <- xs, a > x]
in smallerSorted ++ [x] ++ biggerSorted
我们已经写了不少递归了,也许你已经发觉了其中的固定模式:先定义一个边界条件,再定义个函数,让它从一堆元素中取一个并做点事情后,把余下的元素重新交给这个函数。 这一模式对 List、Tree 等数据结构都是适用的。例如,sum
函数就是一个 List 头部与其尾部的 sum
的和。一个 List 的积便是该 List 的头与其尾部的积相乘的积,一个 List 的长度就是 1 与其尾部长度的和. 等等
再者就是边界条件。一般而言,边界条件就是为避免进程出错而设置的保护措施,处理 List 时的边界条件大部分都是空 List,而处理 Tree 时的边界条件就是没有子元素的节点。
处理数字时也与之相似。函数一般都得接受一个值并修改它。早些时候我们编写过一个计算 Fibonacci 的函数,它便是某数与它减一的 Fibonacci 数的积。让它乘以零就不行了, Fibonacci 数又都是非负数,边界条件便可以定为 1,即乘法的单比特。 因为任何数乘以 1 的结果还是这个数。而在 sum
中,加法的单比特就是 0。在快速排序中,边界条件和单比特都是空 List,因为任一 List 与空 List 相加的结果依然是原 List。
使用递归来解决问题时应当先考虑递归会在什么样的条件下不可用, 然后再找出它的边界条件和单比特, 考虑参数应该在何时切开(如对 List 使用模式匹配), 以及在何处执行递归.
转自:http://learnyouahaskell-zh-tw.csie.org/zh-cn/recursion.html