F# List Arrays Generic List
Modify Elements No Yes Yes
Add New Elements No No Yes
Element Lookup O(n) slow O(1) fast O(1) fast

看完这个表格,或许你会有这样的疑惑:为什么还要使用F# 链表呢?元素查询慢并且一旦此链表被创建了你甚至无法修改链表中的元素,那么是什么让我们这么困惑呢?原因就是:F#中的链表是不可变的(immutable。不同于arrays和范型lists,F#链表一旦被创建,那么它从此无法被改变。如果你有一个返回array或者list<T>的类型,那么你无法确定此类型的外部究竟对它会做些什么(很难跟踪外部操作对它的影响),详细请看Jomo的博客Barrel of Bugs.

 

这是个严肃地问题:这样的安全措施是否值得?答案是:是的。那么在什么情况下正确地使用它,F#链表会比.NET中的Arrays和范型Lists更有效率呢。那么,让我深入点了解F# 链表的工作机制吧。

 

链型的链表

 

F#中链表的表现形式如同数据结构中的链表,是一种计算机学科中基础的,被抽象为一个链接的链表形式的数据结构。链接中每个单独的节点都有一块与之相关联的数据块,并且可以被链接到其它的节点。链接的最后一个节点不会连接到其它任何东西,因此,它指向的是”空值”或者空链接list[](不好意思,在下图中这个空链接看起来像是一个方块一样。)

clip_image001[1]

在链表中,有两个主要的运算操作:Cons(添加单个元素)和Concat(拼接两个链表)。

Cons

Cons是用来向链表的头部添加新元素。在F#中,cons函数的类型变化如下:

`a -> `a list -> `a list

有一件很重要的事你需要明白:cons的运算次数是常数级的,时间复杂度为O(1)。要向一个不可变的链表添加元素,所有你需要做的就是将那个值放入到一个链表节点中去,并设置此节点的“下一个链表节点”为已知链表的头元素。那么所有后面的元素都会乖乖地链接起来。

clip_image002[1]

> 1 :: [2 .. 4];;

val it : int list = [1; 2; 3; 4]

Concat

Concat拼接两个链表。Concat函数的类型变化如下:

`a list -> `a list` -> `a list

 

下面是此函数的一个示例

 

> [2; 3; 4] @ [5 .. 7];;

val it : int list = [2; 3; 4; 5; 6; 7]

 

为了拼接两个链表,所有你需要做的就是设置第一个链表的最后一个元素的“下一个节点”的值等于第二个链表的第一个节点。这看起来是一个很强大的解决方法,但是请注意你无法修改链表元素。因此,拼接链表时,你实际上需要第一个链表的一个复制,如此你才能改变其最后一个节点能指向其他的。因为这个原因,concat的执行效率为O(n),这里的n是第一个链表中的元素个数。下图演示了此操作:

clip_image003[1]

总结一下:Cons既快又简单而Concat则速度较慢。

链表模块中的函数

 

既然我们已经了解了链表,那么让我们来看看一些用来操作链表的内置函数吧。

 

List.hd(注:F#2.0及以后版本是List.head)

List.hd返回链表的第一个元素或者头。

 

List.tl(注:F#2.0及以后版本是List.tail)

List.tl返回除去第一个元素后的此链表。那就是,除去第一个元素后链表中的其他元素。

> let l = [1; 2; 3];;

val l : int list

> List.hd l;;

val it : int = 1

> List.tl l;;

val it : int list = [2; 3]

List.length

List.length返回次链表的长度,这里没什么特别需要注意的。

> List.length [1; 2; 3; 4; 5];;

val it : int = 5

List.rev

List.rev反转链表。此函数需要复制整个链表,因此在使用时需要注意,因为如果你在一个内部循环中使用List.rev那么程序的性能会受影响。

> List.rev [1 .. 10];;

val it : int list = [10; 9; 8; 7; 6; 5; 4; 3; 2; 1]

List.find

List.find接受一个布尔函数作为参数,返回第一个使此布尔函数为true的链表元素。然而,如果List.find没能”找到”你所想找的元素,那么它将抛出一个异常。因为通常对所有相关人员来说,抛异常是件不开心的事,因此我建议使用List.tryfind来替代它,此函数返回一个Option值。在这个例子中,我们在一个正数链表中查找一个与144的平方根相等的元素。

> List.find (fun i -> i * i = 144) [1 .. 10];;

System.Collections.Generic.KeyNotFoundException: The item was not found in the collection

at Microsoft.FSharp.Core.Operators.not_found[T]()

at <StartupCode$FSI_0014>.$FSI_0014._main()

stopped due to error

> List.tryfind (fun i -> i * i = 144) [1 .. 10];;

val it : int option = None

List.filter

List.filter接受一个函数作为参数,并产生一个新的链表,此链表由那些使作为参数的函数的返回值为true的所有元素组成。这个函数可以过滤元素直到剩下都是你想要的元素。在这个例子中,我们将一个整数链表中的元素过滤到全为偶数。

> List.filter (fun i -> i % 2 = 0) [1 .. 20];;

val it : int list = [2; 4; 6; 8; 10; 12; 14; 16; 18; 20]

聚合运算符

链表模块也支持一组聚合元算符:iter, map, fold。这三个函数提供了所有你所需的功能来解决任何集合类型上的繁杂的遍历。(其实,你会在SetSeqOptionArray模块上发现itermapfold的子集。)

List.Iter

iter函数或许是最简单的聚合元算符了。它所做的就是遍历链表中的每个元素并为每个元素调用指定函数进行处理。注意这个iter函数不会产生一个值。遍历这个链表主要还是用来评估所提供函数产生的作用,例如,在控制台打印字符串。

List.Iter

iter函数或许是最简单的聚合元算符了。它所做的就是遍历链表中的每个元素并为每个元素调用指定函数进行处理。注意这个iter函数不会产生一个值。遍历这个链表主要还是用来评估所提供函数产生的作用,例如,在控制台打印字符串。

> List.iter (fun i -> printfn "List contains %d" i) [1 .. 5];;

List contains 1

List contains 2

List contains 3

List contains 4

List contains 5

val it : unit = ()

List.Map

List.map是用一个给定函数来改变一个链表里的项。List.map的类型是

(‘a –> ‘b) –> ‘a list –> ‘b list

这里我们能够直观地看到这个map用函数f转换的作用:

clip_image004[1]

反向转换一个整数链表

想象下我们要反向转换一个整数链表。我们所要做的是在这个链表里为每个元素调用这个反向函数。

// Invert a list of numbers
let invertNumber x = x * -1

// Inverts numbers 1 through 10
let x = List.map invertNumber [1 .. 10]

// Prints: [-1; -2; -3; -4; -5; -6; ...]
printfn "%A" x

List.Fold

Folds是最强大的聚合运算符,同时也是最复杂的。当你有一个链表值时,你可以用List.fold来提取你想要的一部分数据。链表的折叠有两种形式:fold_left和fold_right。这个决定于你将怎么访问链表的元素。(fold_left是从左到右,fold_right是从右到左。)

[注意:在F#2.0后,没有fold_left和fold_right,相对应的是fold和foldback]

List.fold遍历链表中的每个元素然后建立一个累加值。一旦每个元素都被处理了,List.fold返回这个累加器的最终值。List.fold_left的类型是:

(('a -> 'b -> 'a) -> 'a -> 'b list -> 'a)

有变量名的fold_left是这样的:

List.fold_left :  ([function] accumulator listElement -> accumulator) initialAccumulatorValue theListToFold

List.Fold_left
一个整数链表的求和

最简单的折叠例子是求一个整数链表的和。我们的累加器函数将简单地增加链表的元素"x"到我们的累加器值。

// Sum a list
let accumulate acc x = acc + x

let sumList = List.fold_left accumulate 0 [1 .. 10]

// Notice our accumulator function is identical to the (+) operator,
// so we can rewrite our fold as:
let conciseSum = List.fold_left (+) 0 [1 .. 10]

虽然累加器值并不需要是基础类型的值。或许你想把链表减少到不同的数据片段,你可以用元组(tuple)作为累加器来存储多个值,甚至用记录(record)来存储一个有结构的数据。

计数元音

在我介绍下个例子前,让我快速地回顾下这个优雅的克隆记录(record)语法:

// Cloning Records
type Car = { Make : string; Model : string; Year : int }

let eclipse   = { Make = "Mitsubishi"; Model = "Eclipse"; Year = 2005 }

// You could make a copy like this ...
let nextYears1 = { Make = eclipse.Make; Model = eclipse.Model; Year = 2006 }
// .. but an easier way would be this.
let nextYears2 = { eclipse with Year = 2006 }

这里是另外一个List.fold_left例子,计算一个句子里的元音数。这里我们用记录(record)来作为累加器。

// Counting vowels
type VowelCount = { A : int; E : int; I : int; O : int; U : int }

let countVowels acc c =
    match c with
    | 'a' -> { acc with A = acc.A + 1 }
    | 'e' -> { acc with E = acc.E + 1 }
    | 'i' -> { acc with I = acc.I + 1 }
    | 'o' -> { acc with O = acc.O + 1 }
    | 'u' -> { acc with U = acc.U + 1 }
    | _   -> acc
    
let listOfChars = Seq.to_list "The quick brown fox jumps over the lazy dog."

let accInitVal = { A = 0; E = 0; I = 0; O = 0; U = 0 }
// Evaluates to: {A = 1; E = 3; I = 1; O = 4; U = 2;} 

List.fold_left countVowels accInitVal listOfChars
Fold_right

选择List.fold_leftList.fold_right就像挑选不同的化妆品,但是折叠的顺序会对性能有实质性的影响。如考虑字符串的分割问题,给定一个字符链表,根据空格返回一个代表单词的字符链表。

// List.fold_left (bad)
let listOfChars2 = Seq.to_list "The quick brown fox jumps over the lazy dog"

let breakIntoWords (acc : char list list) c =
    // If the letter isn't a space, add it to our accumulator
    if c <> ' ' then
        // Words are stored in order, so the last word is at the end of the list...
        let revAcc = List.rev acc
        // Now get the first item in the reversed list
        let word = List.hd revAcc
        // And add this character at the end
        let updatedWord = word @ [ c ]
        // Finally put this updated word at the end of our accumulator
        let updatedRevAcc = updatedWord :: (List.tl revAcc)
        List.rev updatedRevAcc
    // If the letter is a space then add a new list of chars
    else 
        acc @ [ [] ]
        
let words = List.fold_left breakIntoWords [ [] ] listOfChars2

(* Prints:
[['T'; 'h'; 'e']; ['q'; 'u'; 'i'; 'c'; 'k']; ['b'; 'r'; 'o'; 'w'; 'n'];
 ['f'; 'o'; 'x']; ['j'; 'u'; 'm'; 'p'; 's']; ['o'; 'v'; 'e'; 'r'];
 ['t'; 'h'; 'e']; ['l'; 'a'; 'z'; 'y']; ['d'; 'o'; 'g']] *)
printfn "%A" words

这个解决方案看起来不是那么糟糕,我们只需要处理反转链表的不便以及把元素添加回去。但是每次调用一次List.rev将会耗掉O(n)的时间,以及每次你调用(@)也将会花费O(n)时间。简而言之,这个折叠就是个缓慢流动的堆。然而,因为这个问题的性质,如果我们能够"逆向"或者从右到左的折叠,那么我们就能从链表的前面添加字符和单词,这样我们就能用快得多的Cons函数了。

List.fold_right来解决这个问题让我们可以不用List.revConcat。这样的结果就是折叠操作更加的效率了。

// List.fold_right (good)
let listOfChars3 = Seq.to_list "The quick brown fox jumps over the lazy dog"

let breakIntoWordsGood c (acc : char list list) =
    // If the letter isn't a space, add it to our accumulator
    if c <> ' ' then
        // Words are stored in reverse order, so get the first word
        let word = List.hd acc
        // And add this character at the beginning
        let updatedWord = c :: word
        // Finally put this updated word at the beginning of our accumulator
        updatedWord :: (List.tl acc)
    // If the letter is a space then add a new list of chars
    else 
        [ [] ] @ acc
        
let words2 = List.fold_right breakIntoWordsGood listOfChars3 [ [] ] 

(* Prints:
[['T'; 'h'; 'e']; ['q'; 'u'; 'i'; 'c'; 'k']; ['b'; 'r'; 'o'; 'w'; 'n'];
 ['f'; 'o'; 'x']; ['j'; 'u'; 'm'; 'p'; 's']; ['o'; 'v'; 'e'; 'r'];
 ['t'; 'h'; 'e']; ['l'; 'a'; 'z'; 'y']; ['d'; 'o'; 'g']] *)
printfn "%A" words2

虽然还有很多关于列的知识要掌握,现在你应该足够的知识来轻松解决Project Euler问题。