Haskell 为什么列表的串联需要O(n)?

Haskell 为什么列表的串联需要O(n)?,haskell,functional-programming,complexity-theory,algebraic-data-types,Haskell,Functional Programming,Complexity Theory,Algebraic Data Types,根据ADTs(代数数据类型)理论,两个列表的串联必须采用O(n),其中n是第一个列表的长度。基本上,您必须递归地遍历第一个列表,直到找到结尾 从另一个角度来看,可以认为第二个列表可以简单地链接到第一个列表的最后一个元素。如果知道第一个列表的结尾,这将需要固定的时间 我错过了什么 为了连接两个列表(称它们为xs和ys),我们需要修改xs中的最后一个节点,以便将其链接到ys的第一个节点(即指向) 但是Haskell列表是不可变的,因此我们必须首先创建xs的副本。此操作是O(n)(其中n是xs的长度)

根据ADTs(代数数据类型)理论,两个列表的串联必须采用
O(n)
,其中
n
是第一个列表的长度。基本上,您必须递归地遍历第一个列表,直到找到结尾

从另一个角度来看,可以认为第二个列表可以简单地链接到第一个列表的最后一个元素。如果知道第一个列表的结尾,这将需要固定的时间


我错过了什么

为了连接两个列表(称它们为
xs
ys
),我们需要修改
xs
中的最后一个节点,以便将其链接到
ys
的第一个节点(即指向)

但是Haskell列表是不可变的,因此我们必须首先创建
xs
的副本。此操作是
O(n)
(其中
n
xs
的长度)

例如:

xs
|
v
1 -> 2 -> 3

1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
^              ^
|              |
xs ++ ys       ys

在操作上,Haskell列表通常由指向单个链表的第一个单元格的指针表示(大致)。这样,
tail
只返回指向下一个单元格的指针(它不必复制任何内容),考虑到列表前面的
x:
分配一个新单元格,使其指向旧列表,并返回新指针。旧指针访问的列表是不变的,因此不需要复制它

如果改为使用
+[x]
附加值,则无法通过更改原始列表的最后一个指针来修改原始列表,除非您知道原始列表将永远不会被访问。更具体地说,考虑

x = [1..5]
n = length (x ++ [6]) + length x
如果在执行
x++[6]
时修改
x
,则
n
的值将显示为12,这是错误的。最后一个
x
是指长度为
5
的未更改列表,因此
n
的结果必须为11

实际上,您不能期望编译器对此进行优化,即使在不再使用
x
的情况下,从理论上讲,它可以就地更新(“线性”使用)。发生的情况是,
x++[6]
的求值必须为最坏的情况做好准备,在这种情况下,
x
在以后被重用,因此它必须复制整个列表
x

正如@Ben所指出的,说“名单被复制了”是不准确的。实际发生的情况是,带指针的单元格被复制(列表上的所谓“脊椎”),但元素没有被复制。比如说,

x = [[1,2],[2,3]]
y = x ++ [[3,4]]

只需要分配一次
[1,2],[2,3],[3,4]
。列表的列表
x,y
将共享指向整数列表的指针,整数列表不必重复。

这是因为不可变状态。列表是一个对象+一个指针,因此如果我们将列表想象成一个元组,它可能如下所示:

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、poll、insert、delete、sort等)的大O时间复杂性)

现在我们已经确定了这一点,让我们看看串联。让我们用一个新的列表,
(“e”,“f”,“[])
来连接
tupleList
。为此,我们必须遍历整个列表,就像获取最后一个元素一样:

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不必做的原因