这是因为不可变状态。列表是一个对象 + 一个指针,所以如果我们将列表想象为一个元组,它可能看起来像这样:
let tupleList = ("a", ("b", ("c", [])))
现在让我们用“head”函数获取这个“列表”中的第一项。这个 head 函数需要 O(1) 时间,因为我们可以使用 fst:
> fst tupleList
如果我们想用不同的替换列表中的第一项,我们可以这样做:
let tupleList2 = ("x",snd tupleList)
这也可以在 O(1) 中完成。为什么?因为列表中绝对没有其他元素存储对第一个条目的引用。由于状态不可变,我们现在有两个列表,tupleList 和 tupleList2。当我们创建tupleList2 时,我们没有复制整个列表。因为原始指针是不可变的,我们可以继续引用它们,但在列表的开头使用其他东西。
现在让我们尝试获取 3 项列表的最后一个元素:
> snd . snd $ fst tupleList
这发生在 O(3) 中,它等于我们列表的长度,即 O(n)。
但是我们不能存储指向列表中最后一个元素的指针并在 O(1) 中访问它吗?为此,我们需要一个数组,而不是列表。数组允许任何元素的 O(1) 查找时间,因为它是在寄存器级别实现的原始数据结构。
(旁白:如果您不确定我们为什么要使用链表而不是数组,那么您应该多阅读一些有关数据结构、数据结构算法以及各种操作(如 get)的 Big-O 时间复杂度的内容,轮询、插入、删除、排序等)。
现在我们已经确定了这一点,让我们看看串联。让我们将tupleList 与一个新列表("e", ("f", [])) 连接起来。为此,我们必须像获取最后一个元素一样遍历整个列表:
tupleList3 = (fst tupleList, (snd $ fst tupleList, (snd . snd $ fst tupleList, ("e", ("f", [])))
上述操作实际上比 O(n) 时间更糟糕,因为对于列表中的每个元素,我们必须重新读取列表直到该索引。但如果我们暂时忽略这一点并专注于关键方面:为了到达列表中的最后一个元素,我们必须遍历整个结构。
您可能会问,为什么我们不将最后一个列表项存储在内存中呢?这种方式追加到列表的末尾将在 O(1) 中完成。但没那么快,我们不能在不改变整个列表的情况下改变最后一个列表项。为什么?
让我们来看看它的外观:
data Queue a = Queue { last :: Queue a, head :: a, next :: Queue a} | Empty
appendEnd :: a -> Queue a -> Queue a
appendEnd a2 (Queue l, h, n) = ????
如果我修改“last”,它是一个不可变变量,我实际上不会修改队列中最后一项的指针。我将创建最后一项的副本。引用该原始项目的所有其他内容将继续引用原始项目。
因此,为了更新队列中的最后一项,我必须更新所有引用它的内容。这只能在最优的 O(n) 时间内完成。
所以在我们的传统列表中,我们有最后一项:
List a []
但如果我们想改变它,我们就复制它。现在倒数第二个项目引用了旧版本。所以我们需要更新那个项目。
List a (List a [])
但如果我们更新倒数第二个项目,我们会复制它。现在倒数第三项有一个旧的参考。所以我们需要更新它。重复直到我们到达列表的头部。我们绕了一圈。没有任何东西保留对列表头部的引用,因此编辑需要 O(1)。
这就是 Haskell 没有双向链表的原因。这也是为什么不能以传统方式实现“队列”(或至少是 FIFO 队列)的原因。在 Haskell 中创建队列需要对传统数据结构进行一些认真的重新思考。
如果您对这一切的工作原理更加好奇,请考虑购买本书Purely Funtional Data Structures。
编辑:如果您曾经见过:http://visualgo.net/list.html,您可能会注意到在可视化中“插入尾部”发生在 O(1) 中。但是为了做到这一点,我们需要修改列表中的最后一个条目,给它一个新的指针。更新指针会改变状态,这在纯函数式语言中是不允许的。希望在我的其余帖子中能够清楚地说明这一点。