为什么列表的连接需要O(n)?

时间:2015-02-09 09:32:11

标签: haskell functional-programming complexity-theory algebraic-data-types

根据ADT(代数数据类型)理论,两个列表的连接必须采用O(n),其中n是第一个列表的长度。基本上,你必须递归迭代第一个列表,直到找到结束。

从不同的角度来看,可以说第二个列表可以简单地链接到第一个元素的最后一个元素。如果知道第一个列表的结尾,这将花费恒定的时间。

我在这里缺少什么?

4 个答案:

答案 0 :(得分:6)

在操作上,Haskell列表通常由指向单链表的第一个单元格的指针(粗略地)表示。这样,tail只返回指向下一个单元格的指针(它不需要复制任何东西),并且在列表前面的x :分配一个新的单元格,使它指向旧的list,并返回新指针。旧指针访问的列表没有变化,因此无需复制它。

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

x = [1..5]
n = length (x ++ [6]) + length x

如果您在执行x时修改x++[6],则n的值将变为12,这是错误的。最后一个x引用了未更改的列表,其长度为5,因此n的结果必须为11.

实际上,您无法期望编译器对此进行优化,即使在x不再使用的情况下也是如此,从理论上讲,它可以在适当的位置更新(a"线性& #34;使用)。会发生的事情是x++[6]的评估必须为之后重复使用x的最坏情况做好准备,因此必须复制整个列表x

正如@Ben所说,"列表被复制"是不精确的。实际发生的是具有指针的单元格被复制(所谓的"脊柱"在列表上),但元素不是。例如,

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

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

答案 1 :(得分:4)

您要求的内容与我在一段时间内为TCS Stackexchange编写的问题有关:支持功能列表的常量时间连接的数据结构是difference list

Yasuhiko Minamide in the 90s制定了一种用函数式编程语言处理这种列表的方法。我有效地rediscovered it了一会儿。但是,良好的运行时保证需要在Haskell中不可用的语言级支持。

答案 2 :(得分:3)

这是因为不可改变的状态。列表是一个对象+一个指针,所以如果我们将一个列表想象成一个元组,它可能看起来像这样:

let tupleList = ("a", ("b", ("c", [])))

现在让我们使用“head”函数获取此“列表”中的第一项。这个head函数需要O(1)时间,因为我们可以使用fst:

> fst tupleList

如果我们想要将列表中的第一项替换为另一项,我们可以这样做:

let tupleList2 = ("x",snd tupleList)

也可以在O(1)中完成。为什么?因为列表中绝对没有其他元素存储对第一个条目的引用。由于不可变状态,我们现在有两个列表tupleListtupleList2。当我们制作tupleList2时,我们没有复制整个列表。因为原始指针是不可变的,所以我们可以继续引用它们,但在列表的开头使用其他东西。

现在让我们尝试获取3个项目列表的最后一个元素:

> snd . snd $ fst tupleList

发生在O(3)中,它等于我们列表的长度,即O(n)。

但是我们不能存储指向列表中最后一个元素的指针并访问O(1)中的那个元素吗?要做到这一点,我们需要一个数组,而不是一个列表。数组允许任何元素的O(1)查找时间,因为它是在寄存器级别上实现的原始数据结构。

(ASIDE:如果你不确定为什么我们会使用链接列表而不是数组,那么你应该多做一些关于数据结构,数据结构算法和各种操作的Big-O时间复杂度的阅读,比如get,轮询,插入,删除,排序等)。

现在我们已经建立了这个,让我们来看看连接。让我们使用新列表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)中。但是为了做到这一点,我们需要修改列表中的最后一个条目以给它一个新的指针。更新指针会改变纯功能语言中不允许的状态。希望我的帖子能够清楚地表明这一点。

答案 3 :(得分:0)

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

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

示例:

xs
|
v
1 -> 2 -> 3

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