快速位置查找的数据结构

7
寻找一种数据结构,逻辑上表示由唯一标识符(为了简单起见,我们将其视为字符串或至少是可哈希对象) 键入的元素序列。每个元素只能出现一次,没有间隙,第一个位置为0。
应支持以下操作(用单字母字符串演示):
- `insert(id, position)` - 在偏移量`position`处将键入`id`的元素添加到序列中。自然情况下,序列中稍后的每个元素的位置都会增加1。例如: `[S E L F].insert(H,1) -> [S H E L F]` - `remove(position)` - 移除偏移量`position`处的元素。将序列中稍后的每个元素的位置减1。例如: `[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`足够频繁,以致于不要线性(哈希图+数组/列表的简单组合会让你获得这些功能)。
在完美的世界中,它将是O(1)`lookup`,O(log n)`insert` / `remove`,但我实际上怀疑这不会从纯信息论的角度起作用(尽管我还没有尝试),因此O(log n)`lookup`仍然很好。
3个回答

4
一种trie哈希表的组合使得查找/插入/删除的时间复杂度为O(log n)。 Trie的每个节点都包含id以及有效元素计数器,由该节点作为根节点并且最多有两个子指针。从根节点到给定节点遍历trie时决定左(0)或右(1)转的位串是存储在对应idhash map中的一部分值。 Remove操作将trie节点标记为无效,并更新从删除节点到根节点路径上所有有效元素的计数器。同时删除对应的hash map条目。 Insert操作应使用position参数和每个trie节点的有效元素计数器来搜索新节点的前驱和后继节点。如果从前驱到后继的顺序遍历包含任何已删除的节点,则选择具有最低等级的节点并重用它。否则,选择前驱或后继之一,并向其添加一个新的子节点(前驱的右子节点或后继的左子节点)。然后更新从此节点到根节点的所有有效元素计数器,并添加相应的hash map条目。 Lookup操作从hash map中获取位串,并使用它从trie根节点到相应的节点,同时将该路径左侧所有有效元素的计数器总和起来。
所有这些都允许每个操作的期望时间复杂度为O(log n),如果插入/删除序列足够随机。否则,每个操作的最坏时间复杂度为O(n)。为了将其恢复为摊销时间复杂度为O(log n),请注意树的稀疏性和平衡因子,如果有太多已删除的节点,请重新创建一个完美平衡和密集的树;如果树过于不平衡,请重建最不平衡的子树。
可以使用一些二叉搜索树或任何字典数据结构代替hash map。在hash map中存储指向trie中相应节点的指针,而不是用于标识trie路径的位串。
此数据结构中使用trie的其他替代方案是可索引跳表
对于每个操作的O(log N)时间复杂度是可以接受的,但并不完美。正如Kevin所解释的那样,可以使用具有O(1)查找复杂度的算法来换取其他操作的更大复杂度:O(sqrt(N))。但是这可以改进。
如果为每个查找操作选择某些内存访问次数(M),则其他操作可以在O(M * N ^ 1 / M)时间内完成。这种算法的思路在这个相关问题的答案中提出。那里描述的Trie结构允许轻松地将位置转换为数组索引和反向转换。该数组的每个非空元素都包含id,而哈希映射的每个元素都将此id映射回数组索引。
为了使插入元素到这个数据结构成为可能,连续数组元素的每个块都应与一些空间相间隔。当其中一个块用尽了所有可用的空间时,我们应重建与Trie中某个元素相关的最小组块,该组块具有50%以上的空间。当总空间不足50%或超过75%时,我们应重建整个结构。
这种再平衡方案仅针对随机和均匀分布的插入/删除具有O(M * N ^ 1 / M)摊销复杂度。M > 2的最坏情况复杂度(例如,如果我们总是在最左侧位置插入)要大得多。为了保证O(M * N ^ 1 / M)最坏情况,我们需要预留更多的内存并更改再平衡方案,使其保持如下不变式:保留至少50%全结构的空闲空间,保留至少75%与顶部Trie节点相关的所有数据的空闲空间,对于下一个级别的Trie节点-87.5%等。
当M = 2时,我们具有O(1)的查找时间和O(sqrt(N))的其他操作时间。
当M = log(N)时,我们对于每个操作具有O(log(N))时间。
但是在实践中,小的M值(例如2 .. 5)更可取。这可以视为O(1)查找时间,并且允许此结构(在执行典型插入/删除操作时)以良好的向量化可能性和高速缓存友好的方式使用多达5个相对较小的连续内存块。此外,如果要求良好的最坏情况复杂度,则限制了内存需求。

3
你可以在O(sqrt(n))时间内完成所有操作,但我要警告你需要做一些工作。
首先看一下我写的博客ThriftyList。 ThriftyList是我实现的数据结构,它描述了Resizable Arrays in Optimal Time and Space中的数据结构,并添加了一些自定义功能以维护大小为O(sqrt(n))的循环子列表。使用循环子列表,可以通过在包含子列表中进行标准的插入/删除-然后移位,然后在循环子列表本身上进行一系列push/pop操作,从而实现O(sqrt(n))时间的插入/删除。
现在,要获取查询值所在的索引,您需要维护一个从值到子列表/绝对索引的映射。也就是说,给定一个值映射到包含该值的子列表,再加上该值所在的绝对索引(如果列表非循环,则该项将落在哪个索引处)。通过这些数据,您可以通过从循环子列表头部的偏移量和落后于包含子列表的元素数量相加来计算值的相对索引。维护此映射需要每次插入/删除O(sqrt(n))操作。

-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


我喜欢Clojure的向量,但它们没有针对最重要的操作进行优化,即从值到索引。 (从索引到值确实是O(log_32 n),但我需要反向操作。)据我所知,该操作(值->索引)为O(n)。 :-( - agnoster

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接