【问题标题】:Why does concatenation of lists take O(n)?为什么列表的连接需要 O(n)?
【发布时间】:2015-02-11 02:32:41
【问题描述】:

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

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

我在这里缺少什么?

【问题讨论】:

  • 因为您需要复制左侧参数(因为列表在 Haskell 中是不可变的)。
  • @OliverCharlesworth 不,列表已链接。
  • 基于这个参数,在列表的开头插入一个元素也需要 O(n),因为这也意味着复制列表。
  • @josejuan:确实,但这并不能避免复制的需要。如果我做a + b(对不起,我忘记了Haskell语法),那么我不能修改a,所以我不能把它链接到b。相反,我需要创建 a 的副本,并将 that 链接到 b
  • @RaduStoenescu:考虑创建的图;你只需要复制左边的参数。

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


【解决方案1】:

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

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

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

如果在做x++[6]时修改xn的值会变成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 将共享指向整数列表的指针,它们不必重复。

【讨论】:

  • 虽然它只需要复制列表单元格本身。存储在列表中的项目通常是指向其他数据结构的指针,可以在原始列表和新列表之间完美共享。
  • 就我个人而言,我真正想看到的是一个清晰的解释,说明为什么懒惰的列表与o(n) append 不兼容。
【解决方案2】:

您要问的问题与我不久前为 TCS Stackexchange 写的一个问题有关:支持函数列表的恒定时间串联的数据结构是 difference list

Yasuhiko Minamide in the 90s 制定了一种在函数式编程语言中处理此类列表的方法;我有效地rediscovered it 前一阵子。但是,良好的运行时保证需要 Haskell 中不提供的语言级支持。

【讨论】:

  • Yasuhiko Minamide 的带孔数据结构在精神上似乎与 Huet 的 zippers (JFP 97) 有点相似,但拉链纯粹是功能性的,而带孔的结构似乎更有用,在调用中按值语言,在按需调用语言中非常自然的事情。
  • 差异列表是由\x -> (x:) 的值组合而成的函数[a] -> [a]。此类函数可以表示为简单的不平衡叶树,如 cstheory.SE 上的an old post 所示。
  • 它们实际上是相似的,但 Yasuhiko Minamide 的结构具有不同的渐近复杂性,正如 CSTheory 帖子(这是我提出的问题的答案 :))所描述的那样。
【解决方案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) 查找时间,因为它是在寄存器级别实现的原始数据结构。

(旁白:如果您不确定我们为什么要使用链表而不是数组,那么您应该多阅读一些有关数据结构、数据结构算法以及各种操作(如 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) 中。但是为了做到这一点,我们需要修改列表中的最后一个条目,给它一个新的指针。更新指针会改变状态,这在纯函数式语言中是不允许的。希望在我的其余帖子中能够清楚地说明这一点。

【讨论】:

  • 许多通常需要队列的算法仍然可以在 Haskell 中以直接的方式实现,这要归功于打结。
【解决方案4】:

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

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

例子:

xs
|
v
1 -> 2 -> 3

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

【讨论】:

  • 我们是否必须“创建xs 的副本”是一个操作细节。该算法是 O(n),因为我们必须遍历左侧列表的所有 n 元素。
  • @jberryman,如果您使用 data List a = List {theListItself::[a], theLastCons::[a]} 之类的内容表示列表,那么操作细节就变得不那么重要了。
猜你喜欢
  • 1970-01-01
  • 2020-02-21
  • 1970-01-01
  • 2017-09-18
  • 2019-10-23
  • 2014-12-02
  • 2013-07-17
  • 2018-01-14
  • 1970-01-01
相关资源
最近更新 更多