寻找逻辑上代表由唯一ID 键入的元素的序列的数据结构(为了简单起见,我们将它们视为字符串,或者至少是可清除的对象)。每个元素只能出现一次,没有间隙,第一个位置为0。
应支持以下操作(使用单字母字符串演示):
insert(id, position)
- 将id
键入的元素添加到偏移position
的序列中。当然,序列中稍后的每个元素的位置现在增加1。示例:[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)lookup
,remove
和insert
。
在实践中,lookup
可能会被最多使用,insert
和remove
经常发生,以至于不是线性的(这是一个简单的hashmap组合) +数组/列表会得到你。)
在一个完美的世界中,它将是O(1)lookup
,O(log n)insert
/ remove
,但我实际上怀疑这不会从纯粹的信息中起作用 - 理论观点(虽然我没有尝试过),所以O(log n)lookup
仍然会很好。
答案 0 :(得分:4)
trie 和哈希映射的组合允许O(log n)查找/插入/删除。
trie 的每个节点都包含 id 以及有效元素的计数器,以此节点为根,最多包含两个子指针。由 trie 从其根到达给定节点时,由左(0)或右(1)确定的位字符串是值的一部分,存储在哈希映射中用于相应的 id 。
删除操作将 trie 节点标记为无效,并更新从已删除节点到根节点的路径上的有效元素的所有计数器。它还会删除相应的哈希映射条目。
插入操作应使用 position 参数和每个 trie 节点中有效元素的计数器来搜索新节点的前任和后继节点。如果从前任到后继的按顺序遍历包含任何已删除的节点,请选择具有最低排名的节点并重新使用它。否则选择前任或后继,并为其添加一个新的子节点(前任子项为右子项,后续项目为左侧子项)。然后更新从该节点到根的路径上的有效元素的所有计数器,并添加相应的哈希映射条目。
查找操作从哈希映射获取一个位字符串,并使用它从 trie root到相应的节点,同时汇总所有计数器此路径左侧的有效元素。
如果插入/移除的序列足够随机,则所有这些允许每个操作的O(log n)预期时间。如果不是,则每个操作的最坏情况复杂度是O(n)。为了使其回到O(log n)摊销的复杂性,请注意树的稀疏性和平衡因素,如果删除的节点太多,则重新创建一个新的完美平衡和密集的树;如果树太不平衡,重建最不平衡的子树。
可以使用某些二叉搜索树或任何字典数据结构,而不是哈希映射。用于识别 trie 中的路径的位串而不是位字符串,哈希映射可以存储指向 trie 中相应节点的指针。
在此数据结构中使用 trie 的其他替代方法是Indexable skiplist。
每次操作的O(log N)时间是可以接受的,但并不完美。正如Kevin所解释的那样,可以使用具有O(1)查找复杂度的算法来交换其他操作的更大复杂度:O(sqrt(N))。但这可以改善。
如果为每个查找操作选择一定数量的存储器访问(M),则可以在O(M * N 1 / M )时间内完成其他操作。这种算法的想法在this answer to related question中提出。在那里描述的Trie结构允许轻松地将位置转换为数组索引并返回。此数组的每个非空元素都包含 id ,哈希映射的每个元素都将此 id 映射回数组索引。
为了能够将元素插入到该数据结构中,每个连续数组元素块应该与一些空白空间交错。当其中一个块耗尽所有可用的空白空间时,我们应该重建与trie的某些元素相关的最小块块,其具有超过50%的空白空间。当空白空间总数小于50%或大于75%时,我们应该重建整个结构。
此重新平衡方案仅为随机和均匀分布的插入/移除提供O(M N 1 / M )摊销的复杂性。最坏的情况复杂性(例如,如果我们总是插入最左边的位置)对于M>来说要大得多。 2.为了保证O(M N 1 / M )最坏的情况,我们需要保留更多内存并更改重新平衡方案,以便它保持不变,如下所示:保留整个空间结构至少为50%,为与顶级特里节点相关的所有数据保留至少75%的空闲空间,用于下一级节点 - 87.5%等。
当M = 2时,我们有查询的O(1)时间和其他操作的O(sqrt(N))时间。
当M = log(N)时,我们每次操作都有O(log(N))时间。
但实际上,M值(如2 ... 5)的小值是优选的。这可以被视为O(1)查找时间并且允许该结构(在执行典型的插入/移除操作时)以高速缓存友好的方式与多达5个相对小的连续存储器块一起工作,具有良好的矢量化可能性。如果我们需要良好的最坏情况复杂性,这也会限制内存需求。
答案 1 :(得分:3)
你可以在O(sqrt(n))时间内完成所有事情,但我会警告你这需要一些工作。
首先看看我在ThriftyList上写的博客文章。 ThriftyList是我在Resizable Arrays in Optimal Time and Space中描述的数据结构的实现,以及一些自定义来维护O(sqrt(n))循环子列表,每个子列表的大小为O(sqrt(n))。使用循环子列表,可以通过包含子列表中的标准插入/移除然后移位实现O(sqrt(n))时间插入/移除,然后在循环子列表本身上执行一系列推/弹操作。
现在,要获取查询值所在的索引,您需要维护从value到sublist / absolute-index的映射。也就是说,给定值映射到包含值的子列表,加上值下降的绝对索引(项目将落入的索引是列表非循环)。根据这些数据,您可以通过从圆形子列表的头部获取偏移量并与包含子列表后面的元素数量相加来计算值的相对索引。要维护此映射,每次插入/删除需要O(sqrt(n))次操作。
答案 2 :(得分:-1)
听起来大致像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