线性是编程中非常重要且常用的数据结构,许多算法都是基于列表实现的。在Go语言中我们有数组和切片两种内置内型,当然切片底层也是数组。在Haskell中也有列表,但它不是数组,更像是链表。
定义
列表有两个构造函数,:
和[]
。[]
用来构造一个空列表,:
用来连接一个元素和一个列表生成一个新列表。
a = []
b = 1 : 2 : 3 : []
用:
构造列表还有另一种语法糖,也是定义列表主要语法,:
一般只会出现在模式匹配和向列表头部插入元素的场景。
c = [1, 2, 3]
另一个有趣的语法糖是字符串其实是Char
列表的语法糖。
> "abc" == ['a', 'b', 'c']
True
列表是一个递归定义的数据结构,我们可以通过data
模拟一个列表。
infixr 5 :.
data List a = a :. List a | Nil
deriving (Show, Read, Eq, Ord)
d = 1 :. 2 :. 3 :. Nil
由于没有语法糖,我们的List
只能展开来书写。
range
range语法可以让列表按某个规则自动推断补齐剩余元素。举个例子,我说需要所有食物构成的列表,我起个头,蒸羊羔,然后Haskell就会自动帮我补齐列表其他元素:蒸熊掌,蒸鹿尾儿,烧花鸭,烧雏鸡,烧子鹅,炉猪,炉鸭,酱鸡,腊肉,松花,小肚儿…
看上去似乎神奇的很,但如果我起的头是蓝天,草地,这时Haskell就不知道怎么往后接了。并不是因为没有规律,Haskell也不会找规律,找规律是人工智能该干的事儿。
Haskell的range语法其实是基于Enum
类型类实现的。它有两个重要的方法:succ
和pred
,前者用来找后继,后者用来找前驱。学Haskell没点词汇量还真是难。像Int
、Char
、Bool
这些基本类型都是Enum
的实例。
> succ False
True
> pred True
False
> succ 'a'
'b'
> pred 'b'
'a'
> succ 1
2
> pred 2
1
range的语法是..
,它有三个要素:开始、步长、结束。其中步长和结束不是必须的。如果没有步长,那么一直取后继;如果没有结束,则会将枚举类型列举完。下面是一些例子。
-- 所有正整数
e = [1..]
-- [1,2,3,4,5,6,7]
f = [1..7]
-- [2,4,6,8,10]
g = [2,4..10]
-- "阿伯吃的鹅佛鸽"
h = ['a'..'g']
-- [False,True]
i = [False ..]
range默认是取后继,所谓步长也就是给它打个样,让Haskell知道取后继的时候要隔多少个元素。同样,你也可以打样告诉它取前驱。如果步长为0,那么它会一直重复自己,无穷无尽。
-- [7,6,5,4,3,2,1]
j = [7,6..1]
-- "geca"
k = ['g','e'..'a']
-- [True, False]
l = [True, False ..]
-- 无限个1
m = [1,1..]
由于Haskell的惰性求值,我们是可以定义无限长列表的。这就好比我们可以在纸上写下如下的式子:
π
−
1
=
8
9801
∑
k
=
0
∞
(
4
k
!
)
(
1103
+
26390
k
)
(
k
!
)
4
(
396
)
4
k
π^{-1}=\frac{\sqrt{8}}{9801}\sum_{k=0}^{\infty}\frac{(4k!)(1103+26390k)}{(k!)^4(396)^{4k}}
π−1=98018k=0∑∞(k!)4(396)4k(4k!)(1103+26390k)
这就是著名的拉马努金公式!只有当你真正要精确计算它的结果时,才会陷入无尽的计算中。
模式匹配
许多循环和递归的算法都会用到列表的模式匹配,一般我们是x:xs
这样匹配,取出第一个元素和剩余列表。但列表是递归定义的,在模式匹配上,我们也可以匹配更多。另外需要注意的是:
的优先级比函数低,因此列表做模式匹配时有些地方需要加上()
来提高优先级。我们来看一个将列表元素两两打包的例子。
pack [] = []
pack [x] = [[x,x]]
pack (x:y:xs) = [x,y] : pack xs
pack' x = case x of [] -> []
[x] -> [[x,x]]
x:y:xs -> [x,y] : pack' xs
列表的模式匹配也可以匹配长度,比如我们写一个计算两点之间距离的函数,但是由于接口没对齐,导致参数是通过列表传递的,此时我们可以匹配列表长度必须是4。
dist [a,b,c,d] = sqrt $ (a-c)^2 + (b-d)^2
dist _ = -1
操作
Haskell中操作列表的函数十分丰富,又到了考验词汇量的时候了。
拼接
++
用来拼接两个列表。
> [1,2] ++ [3,4]
[1,2,3,4]
> "Hello" ++ [' '] ++ "Haskell"
"Hello Haskell"
通过列表的定义我们知道,列表只有两个构造函数,一是通过[]
构造空列表,二是通过:
在一个列表头部插入一个元素构成一个新列表。由于这一特性,++
拼接两个列表是不得不通过递归遍历左边的列表,依次将每个元素插入右边列表的头部。就像在Go语言中连接两个链表,我们也不得不通过遍历找到一个链表的尾节点。
下标
Haskell列表的下标运算符是!!
,明显也是一个递归实现的中缀函数。
> [1,2,3] !! 0
1
> "abcd" !! 3
'd'
Haskell列表的下标也是从0开始的,如果下标超出界限,!!
就会抛出一个异常。
比较
比较运算符>
>=
<
<=
==
/=
也可以用于列表比较,前提是列表中的原始是可比较的,也就是说必须是Ord
类型类的实例。Haskell会依次比较列表的每一个元素,如果相等就继续比较下一个,否则这就是结果。
> [1,2,3] == [1,2]
False
> [1,2,3] > [1,2]
True
> [1,2,3] >= [1,2]
True
> [1,2,3] < [1,2]
False
> [1,2,3] <= [1,2]
False
> [1,2,3] /= [1,2]
True
头与尾
Haskell有两对函数可以取列表的头和尾。
第一对是head
和tail
,前者取列表头,后者取除去列表头剩余的部分,也就是[head , tail] = list
。
> head [1..5]
1
> tail [1..5]
[2,3,4,5]
第二对是init
和last
,前者取除去列表最后一个元素剩余部分,后者取列表最后一个元素,[init, last] = list
。
> init [1..5]
[1,2,3,4]
> last [1..5]
5
这四个函数的关系如下图。
长度
length
函数用来获取列表长度。
> length [1..10]
10
判空
除了通过模式匹配[]
,null
函数也可以用来判断一个列表是否为空。
> null [1..5]
False
> null ""
True
反转
reverse
函数可以将一个列表反转。它并不会改变原来的列表。
> reverse [1..5]
[5,4,3,2,1]
取舍
take
和drop
这对函数前者用来取列表的前 n 个元素,后者用来丢弃列表的前 n 个元素,它们都不会改变原列表。对于无限长的列表,我们常用take
来取前几个元素。
take 5 [1..]
[1,2,3,4,5]
> drop 5 [1..10]
[6,7,8,9,10]
大小
minimum
和maximum
分别用来获取列表的最小值和最大值,也许你会疑惑像Haskell这样追求书写体验的语言怎么会用maximum
而不是max
这样的简称呢?那是因为min
和max
也是一对函数,用来求两个值之间最小或最大的那个。
> minimum [1..10]
1
> maximum [True, False]
True
和与积
sum
函数用来对列表的元素求和。product
函数用来对列表元素求积。
> sum [1..100]
5050
> product [1..10]
3628800
利用product
我们可以非常方便的求阶乘。
包含
elem
函数用来判断一个元素是否在列表中。
> elem 'e' "Hello Haskell"
True
重复
cycle
可以无限重复一个列表,得到一个无限长列表;repeat
可以无限重复一个元素,得到一个无限长列表;replicate
可以重复一个列表指定次数,得到一个有限长列表。
> take 10 $ cycle "hahaha "
"hahaha hah"
> take 10 $ repeat 1
[1,1,1,1,1,1,1,1,1,1]
> replicate 5 1
[1,1,1,1,1]
骚操作
基本操作介绍完,下面就是骚操作了,至少我第一次看到这样的操作的时候不禁竖起大拇指夸了一句:真滴骚。
映射
map
可以将一个列表映射为另一个列表,而这个映射的桥梁是一个a->b
的函数,map
会将这个函数依次作用于列表的每一个元素,最终得到一个新的列表,在这个过程中,原列表不会被改变。
> :t map
map :: (a -> b) -> [a] -> [b]
>
> map (*2) [1..5]
[2,4,6,8,10]
> map show [1..5]
["1","2","3","4","5"]
过滤
filter
会将一个a->Bool
的函数作用于列表的每一个元素,并将那些返回True
的元素组成一个新的列表,整个过程中,原列表也不会别改变。
> :t filter
filter :: (a -> Bool) -> [a] -> [a]
>
> filter even [1..10]
[2,4,6,8,10]
> filter (' ' /=) "hello haskell, hello world."
"hellohaskell,helloworld."
折叠
折叠是把列表合并成一个结果,折叠共有5个函数:foldl
foldl1
foldr
foldr1
foldMap
。折叠的功能非常强大,通过折叠可以实现很多本文讲到的函数。
foldl
和foldl1
是左折叠,它们的区别是前者需要一个初始值,而后者不需要。foldr
和foldr1
是右折叠,它们的区别也是前者需要初始值,后者不需要要。
> foldl (+) 0 [1..5]
15
> foldl1 (+) [1..5]
15
> foldr (+) 0 [1..5]
15
> foldr1 (+) [1..5]
15
扫描
扫描和折叠唯一的区别是扫描会保留下每次折叠的结果,形成一个列表,它只有4个函数scanl
scanl1
scanr
scanr1
。和折叠一样,带l
的是左折叠,带r
的是右折叠,不带1
的需要初始值,带1
的不需要初始值。
> scanl (+) 0 [1..5]
[0,1,3,6,10,15]
> scanl1 (+) [1..5]
[1,3,6,10,15]
> scanr (+) 0 [1..5]
[15,14,12,9,5,0]
> scanr1 (+) [1..5]
[15,14,12,9,5]
zip/zipWith/zipWith3
zip
函数可以将两个列表合并成一个列表,合并的方式是两个列表对应下标的元素组成一个元组,最终结果以短的列表为准。
> :t zip
zip :: [a] -> [b] -> [(a, b)]
> zip [65..] ['A'..'E']
[(65,'A'),(66,'B'),(67,'C'),(68,'D'),(69,'E')]
zipWith
的功能和zip
是一样的,但是zipWith
可以支持自定义合并的函数。
> :t zipWith
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
> zipWith (,) [65..] ['A'..'E']
[(65,'A'),(66,'B'),(67,'C'),(68,'D'),(69,'E')]
> zipWith (\a b -> a * 10 + b) [1..] [1..9]
[11,22,33,44,55,66,77,88,99]
zipWith3
顾名思义,它可以合并3个列表。
> :t zipWith3
zipWith3 :: (a -> b -> c -> d) -> [a] -> [b] -> [c] -> [d]
> zipWith3 (,,) [1..] [65..] "ABCDE"
[(1,65,'A'),(2,66,'B'),(3,67,'C'),(4,68,'D'),(5,69,'E')]
> zipWith3 (\a b c -> a*100+b*10+c) [1..] [1..] [1..9]
[111,222,333,444,555,666,777,888,999]
集合语法
集合语法和数学上的集合表示非常相似,基本结构是[表达式 | 条件]
。比如10以内的奇数,数学上表示为
{
2
x
∣
x
∈
N
,
x
≤
5
}
\lbrace 2x | x \in N,x \leq 5 \rbrace
{2x∣x∈N,x≤5},利用Haskell的集合语法表示为
> [2*x | x <- [1..5]]
[2,4,6,8,10]
集合语法其实很像数学上函数的定义,从自变量集合到值集合的映射。比如我们写一个函数来输出区间[a,b]
上函数
1
x
\frac{1}{x}
x1的图像点,精度为0.01。
f1 a b = [(x, 1/x) | x <- [a, a+0.01..b], x /= 0]
集合语法的条件分为两类,一类是限定自变量的取值范围,另一类是限定自变量满足条件。需要注意的是自变量的取值范围和自变量需要满足的条件是有先后顺序的,首先需要确定自变量的取值范围,然后剔除不满足条件的自变量。比如[2*x | x <- [1..5]]
和[2*x | x <- [1..], x < 6]
是不一样的,虽然后者也只会打印5个偶数,但是它的计算是无法停止的。
如果有两个自变量,则它们之间会排列组合。比如我们可以像下面这样求两个骰子的点数的所有情况。
> [ (x, y) | x <- [1..6], y <- [1..6] ]
[(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(2,1),(2,2),(2,3),(2,4),(2,5),(2,6),(3,1),(3,2),(3,3),(3,4),(3,5),(3,6),(4,1),(4,2),(4,3),(4,4),(4,5),(4,6),(5,1),(5,2),(5,3),(5,4),(5,5),(5,6),(6,1),(6,2),(6,3),(6,4),(6,5),(6,6)]
假如我们的骰子是全同的,也就是说(1,2)
和(2,1)
是相同的结果。此时我们只要让自变量y
每次都从x
往后取值就行了。
> [ (x, y) | x <- [1..6], y <- [x..6] ]
[(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(2,2),(2,3),(2,4),(2,5),(2,6),(3,3),(3,4),(3,5),(3,6),(4,4),(4,5),(4,6),(5,5),(5,6),(6,6)]
如果是在Go语言中,对应的是下面这样一个循环。
for x := 1; x <= 6; x++ {
for y := x; y <= 6; y++ {
fmt.printf("(%d,%d)\n", x, y)
}
}
又假如我们只关心点数之和有哪些情况,就需要先定义一个去重的函数。
distinct [] = []
distinct [x] = [x]
distinct (x:xs) = if x `elem` xs
then distinct xs
else x : distinct xs
然后我们就可以输出两个骰子的点数之和了。
> distinct [ x + y | x <- [1..6], y <- [1..6] ]
[2,3,4,5,6,7,8,9,10,11,12]
其实,如果你深谙Haskell的精髓,应该能看出来集合语法其实是单子运算。事实上,它也的确是单子运算的语法糖。
关于列表,Haskell在Data.List
模块提供了很多有趣的函数,熟练掌握这些函数可以解决许多实际问题,不过限于篇幅,我们会在下一篇继续介绍列表的函数。