Haskell 为什么列表的串联需要O(n)?
根据ADTs(代数数据类型)理论,两个列表的串联必须采用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的长度)
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不必做的原因