【问题标题】:Data Structure for fast position lookup用于快速定位的数据结构
【发布时间】:2012-08-18 10:58:42
【问题描述】:

寻找一种数据结构,该数据结构在逻辑上表示由 unique ids 键控的元素的序列(为了简单起见,让我们将它们视为字符串,或者至少是可散列的对象)。每个元素只能出现一次,没有间隙,第一个位置为0。

应支持以下操作(以单字母​​字符串演示):

  • insert(id, position) - 将id 键入的元素添加到偏移量position 的序列中。自然地,序列中后面的每个元素的位置现在都加一。示例:[S E L F].insert(H, 1) -> [S H E L F]
  • remove(position) - 删除偏移量 position 处的元素。将序列中后面每个元素的位置减一。示例:[S H E L F].remove(2) -> [S H L F]
  • lookup(id) - 查找由id 键入的元素的位置。 [S H L F].lookup(H) -> 1

简单的实现可以是链表或数组。两者都会给出 O(n) lookupremoveinsert

在实践中,lookup 可能被使用得最多,insertremove 发生的频率足够高,如果不是线性的就好了(哈希图 + 数组/列表的简单组合会得到你)。

在一个完美的世界中,它会是 O(1) lookup, O(log n) insert/remove,但我实际上怀疑从纯粹的信息论的角度来看这是行不通的(尽管我没试过),所以 O(log n) lookup 还是不错的。

【问题讨论】:

    标签: data-structures language-agnostic


    【解决方案1】:

    triehash map 的组合允许 O(log n) 查找/插入/删除。

    trie 的每个节点都包含 id 以及有效元素的计数器,以该节点为根和最多两个子指针。一个位串,由左 (0) 或右 (1) 转决定,同时从其根遍历 trie 到给定节点,是值的一部分,存储在 hash map em> 对应的id

    Remove 操作将trie 节点标记为无效,并更新从已删除节点到根的路径上有效元素的所有计数器。它还删除了相应的 hash map 条目。

    插入操作应该使用每个trie节点中有效元素的position参数和计数器来搜索新节点的前任和后继节点。如果从前任到后继的按顺序遍历包含任何已删除的节点,则选择一个排名最低的节点并重用它。否则选择前任或后继,并向其添加一个新的子节点(前任的右子节点或后继的左子节点)。然后更新从该节点到根节点的路径上所有有效元素的计数器,并添加对应的hash map条目。

    Lookup 操作从 hash map 中获取一个位字符串,并使用它从 trie 根到相应的节点,同时对所有计数器求和此路径左侧的有效元素。

    如果插入/删除的顺序足够随机,那么所有这些都允许每个操作的 O(log n) 预期时间。如果不是,则每个操作的最坏情况复杂度为 O(n)。为了让它回到 O(log n) 摊销复杂度,注意树的稀疏和平衡因素,如果删除的节点太多,重新创建一个新的完美平衡和密集的树;如果树太不平衡,重建最不平衡的子树。


    可以使用一些二叉搜索树或任何字典数据结构来代替 散列映射。在 trie 中用于标识路径的比特串,hash map 可以存储指向 trie 中对应节点的指针。

    在此数据结构中使用 trie 的其他替代方法是 Indexable skiplist


    每个操作的 O(log N) 时间是可以接受的,但并不完美。正如 Kevin 所解释的,可以使用具有 O(1) 查找复杂度的算法来换取其他操作的更大复杂度:O(sqrt(N))。但这可以改进。

    如果您为每个查找操作选择一定数量的内存访问 (M),则其他操作可能会在 O(M*N1/M) 时间内完成。这种算法的想法在this answer to related question 中提出。那里描述的 Trie 结构允许轻松地将 position 转换为数组索引并返回。该数组的每个非空元素都包含 idhash map 的每个元素都将这个 id 映射回数组索引。

    为了能够向这个数据结构中插入元素,每个连续数组元素块都应该与一些空白空间交错。当其中一个块耗尽所有可用空间时,我们应该重建与 trie 的某些元素相关的最小块组,它具有超过 50% 的空空间。当空置空间总数小于50%或大于75%时,我们应该重建整个结构。

    这种重新平衡方案仅针对随机和均匀分布的插入/删除提供 O(MN1/M) 分摊复杂度。对于 M > 2,最坏情况复杂度(例如,如果我们总是在最左边插入)要大得多。为了保证 O(MN1/M) 最坏情况,我们需要保留更多内存并更改重新平衡方案,使其保持如下不变:为整个结构保留至少 50% 的空白空间,为与顶级 trie 节点相关的所有数据保留至少 75% 的空白空间,用于下一级 trie 节点- 87.5% 等。

    在 M=2 的情况下,我们有 O(1) 时间进行查找,O(sqrt(N)) 时间进行其他操作。

    在 M=log(N) 的情况下,每个操作都有 O(log(N)) 时间。

    但实际上,较小的 M 值(如 2 .. 5)更可取。这可以被视为 O(1) 查找时间,并允许此结构(在执行典型的插入/删除操作时)以缓存友好的方式使用多达 5 个相对较小的连续内存块,并具有良好的矢量化可能性。如果我们需要良好的最坏情况复杂性,这也会限制内存需求。

    【讨论】:

      【解决方案2】:

      你可以在 O(sqrt(n)) 时间内完成所有事情,但我会警告你,这需要一些工作。

      首先查看我在ThriftyList 上写的博客文章。 ThriftyList 是我对Resizable Arrays in Optimal Time and Space 中描述的数据结构的实现以及一些自定义以维护 O(sqrt(n)) 循环 子列表,每个子列表的大小为 O(sqrt(n))。使用循环子列表,可以通过在包含子列表中的标准插入/删除-然后移位,然后在循环子列表本身上执行一系列推送/弹出操作来实现 O(sqrt(n)) 时间的插入/删除。

      现在,要获取查询值所在的索引,您需要维护一个从值到子列表/绝对索引的映射。也就是说,给定的值映射到包含该值的子列表,加上该值所在的绝对索引(该项目所在的索引是非圆形列表)。从这些数据中,您可以通过从循环子列表的头部获取偏移量并与包含子列表后面的元素数相加来计算值的相对索引。要维护此映射,每次插入/删除需要 O(sqrt(n)) 次操作。

      【讨论】:

        【解决方案3】:

        听起来很像 Clojure 的持久化向量——它们为查找和更新提供 O(log32 n) 成本。对于较小的 n 值,O(log32 n) 与常数一样好......

        基本上它们是数组映射尝试

        不太确定删除和插入的时间复杂度 - 但我很确定您也可以通过 O(log n) 删除和插入获得此数据结构的变体。

        观看此演示文稿/视频:http://www.infoq.com/presentations/Value-Identity-State-Rich-Hickey

        源代码(Java):https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/PersistentVector.java

        【讨论】:

        • 我喜欢 Clojure 的向量,但它们没有针对最重要的操作进行优化,即 fromto 索引。 (从索引到值确实是 O(log_32 n),但我需要相反。)据我所知,该操作(值 -> 索引)是 O(n)。 :-(
        猜你喜欢
        • 2014-03-09
        • 1970-01-01
        • 2011-10-27
        • 1970-01-01
        • 1970-01-01
        • 2011-07-09
        • 2017-01-02
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多