【Haskell】列表

线性是编程中非常重要且常用的数据结构,许多算法都是基于列表实现的。在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类型类实现的。它有两个重要的方法:succpred,前者用来找后继,后者用来找前驱。学Haskell没点词汇量还真是难。像IntCharBool这些基本类型都是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=98018 k=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有两对函数可以取列表的头和尾。

第一对是headtail,前者取列表头,后者取除去列表头剩余的部分,也就是[head , tail] = list

> head [1..5]
1
> tail [1..5]
[2,3,4,5]

第二对是initlast,前者取除去列表最后一个元素剩余部分,后者取列表最后一个元素,[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]

取舍

takedrop这对函数前者用来取列表的前 n 个元素,后者用来丢弃列表的前 n 个元素,它们都不会改变原列表。对于无限长的列表,我们常用take来取前几个元素。

 take 5 [1..]
[1,2,3,4,5]
> drop 5 [1..10]
[6,7,8,9,10]

大小

minimummaximum分别用来获取列表的最小值和最大值,也许你会疑惑像Haskell这样追求书写体验的语言怎么会用maximum而不是max这样的简称呢?那是因为minmax也是一对函数,用来求两个值之间最小或最大的那个。

> 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。折叠的功能非常强大,通过折叠可以实现很多本文讲到的函数。

foldlfoldl1是左折叠,它们的区别是前者需要一个初始值,而后者不需要。foldrfoldr1是右折叠,它们的区别也是前者需要初始值,后者不需要要。

> 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 {2xxN,x5},利用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模块提供了很多有趣的函数,熟练掌握这些函数可以解决许多实际问题,不过限于篇幅,我们会在下一篇继续介绍列表的函数。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值