【问题标题】:A data structure supporting O(1) random access and worst-case O(1) append?支持 O(1) 随机访问和最坏情况 O(1) 追加的数据结构?
【发布时间】:2011-06-17 14:18:27
【问题描述】:

我实现了一个可调整大小的索引集合,它使用数组来存储其元素(如 .NET 中的 List<T> 或 Java 中的 ArrayList)在集合末尾有 amortized O(1) insertion time。但是,在集合刚刚达到其容量并且下一次插入需要将内部数组中的所有元素的完整副本复制到新的(大概是原来的两倍)。

一个常见的错误(在我看来)是使用链表来“修复”这个问题;但我相信为每个元素分配一个节点的开销可能是相当浪费的,事实上,在数组插入代价高昂的罕见情况下,保证 O(1) 插入的好处会相形见绌——事实上,每个 other 数组插入要便宜得多(而且速度更快)。

我认为可能有意义的是一种由数组链表组成的混合方法,每次当前“头”数组达到其容量时,都会将两倍大的新数组添加到链表中。然后不需要复制,因为链表仍然有原始数组。本质上,这似乎类似于(对我而言)List<T>ArrayList 方法,除了以前在任何地方都会产生复制内部数组的所有元素的成本,在这里你只会产生分配 new 数组加上单个节点插入。

当然,如果需要,这会使其他功能复杂化(例如,在集合中间插入/删除);但正如我在标题中所表达的,我真的只是在寻找一个add-only(和迭代)集合。

是否有任何数据结构非常适合此目的?或者,你能自己想一个吗?

【问题讨论】:

    标签: arrays performance data-structures language-agnostic big-o


    【解决方案1】:

    有一个漂亮的结构称为可扩展数组,它有最坏情况 O(1) 的插入和 O(n) 内存开销(也就是说,它与动态数组渐近可比,但最坏情况有 O(1)插入)。诀窍是采用向量使用的方法——加倍和复制——但要使复制变得懒惰。例如,假设您有一个由四个元素组成的数组,如下所示:

    [1] [2] [3] [4]
    

    如果你想添加一个新数字,比如 5,你首先分配一个两倍大的数组:

    [1] [2] [3] [4]
    [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
    

    接下来,将 5 插入到新数组中:

    [1] [2] [3] [4]
    [ ] [ ] [ ] [ ] [5] [ ] [ ] [ ]
    

    最后,将旧数组中的4下拉到新数组中:

    [1] [2] [3] [ ]
    [ ] [ ] [ ] [4] [5] [ ] [ ] [ ]
    

    从现在开始,每当您进行插入时,将元素添加到新数组并从旧数组中再拉下一个元素。例如,加 6 后,我们会得到

    [1] [2] [ ] [ ]
    [ ] [ ] [3] [4] [5] [6] [ ] [ ]
    

    再插入两个值后,我们就到这里了:

    [ ] [ ] [ ] [ ]
    [1] [2] [3] [4] [5] [6] [7] [8]
    

    如果我们现在需要再添加一个元素,我们丢弃现在为空的旧数组并分配一个两倍于当前数组的数组(能够容纳 16 个元素):

    [1] [2] [3] [4] [5] [6] [7] [8]
    [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
    

    然后重复这个过程。扣除内存分配的成本(通常在数组大小上是次线性的),每次插入最多可以做 O(1) 工作。

    查找仍然是 O(1),因为您只需决定要查找两个数组中的哪一个,而中间的插入是 O(n),因为洗牌。

    如果你好奇,我有a Java implementation of this structure on my personal site.我不知道你会发现它有多大用处,但非常欢迎你尝试一下。

    如果你想花一点时间阅读一篇研究论文并尝试实现一个相当复杂的数据结构,你可以在 O(√n) 空间中得到相同的结果(最坏情况 O(1) 追加)使用the ideas in this paper. 的开销(顺便说一句,这可以证明是最佳的)我从来没有真正实现过这个,但是如果内存是一种超级稀缺的资源,那么它肯定是值得一读的。有趣的是,它使用上面的结构作为子程序!

    【讨论】:

    • 哈哈,这确实太棒了——正是我所希望的那种新颖的想法!谢谢!
    • @Dan Tao-没问题!实际上,我在阅读一篇需要将其作为快速优先级队列中的子程序的研究论文时遇到了这个问题。他们在脚注中提到了它,我觉得讽刺的是,这是论文中最重要的结果!
    • 你知道,在接受这个答案后一段时间我发生了一件悲伤的事情......@ 987654323@ in .NET 因为数组分配本身就是 O(n)——所有元素都已初始化。你知道它在 Java 中是否也这样工作吗?
    • @Dan Tao- 你说得对,需要 O(n) 才能将内存清零。我现在很好奇的是你是否可以在实践中看到这一点,或者 GC 是否足够聪明,可以将它回收的内存归零(因为当你分配类对象时,一切都默认为零)。在这种情况下,它实际上可能不会花费 O(n) 来进行分配,因为它已经在后台归零了。但你是对的......我完全没有想到这一点。
    • @templatetypedef:分配的摊销成本至少是 O(N),如果没有别的,因为在垃圾收集器必须分配多少内存之前有一个有限的限制 L运行,因此分配 N 个字节的摊销成本必须至少为 N/L 乘以 GC 周期的最小成本。如果成本至少为 O(1),则摊销成本必须是 N/L 倍,即 O(N)。
    【解决方案2】:

    当我需要这样的容器时,我会使用我在"Resizeable Arrays in Optimal Time and Space" 中描述的结构的实现

    【讨论】:

      【解决方案3】:

      好的。您所描述的几乎与 C++ 标准库中的 std::deque 完全相同。不同之处在于(通常)使用数组来保存指向子数组的指针,而不是使用链表。

      【讨论】:

      • 谢谢;我以前听说过一个双端队列,但由于某种原因,我只是假设它通常是作为一个循环队列实现的(就像 .NET 中的 Queue<T> ——对不起,我在 C++ 方面没有太多经验),它暴露了头部和尾部推/弹出操作。很高兴知道!
      • @Dan Tao:告诉你,C++ 的std::dequeue 像数组一样支持O(1) 随机访问。
      【解决方案4】:

      一个想法是创建一个包含几个元素的列表,例如:

      struct item
      {
          int data[NUM_ITEMS];
          item *next;
      }
      

      在这种情况下,插入将占用O(1),如果达到限制,只需创建一个新块并将其附加到列表末尾

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2012-05-13
        • 2013-11-16
        • 2011-08-26
        • 1970-01-01
        • 2010-10-02
        • 1970-01-01
        相关资源
        最近更新 更多