为啥使用双向链表删除哈希表的元素是 O(1)?

Posted

技术标签:

【中文标题】为啥使用双向链表删除哈希表的元素是 O(1)?【英文标题】:Why deletion of elements of hash table using doubly-linked list is O(1)?为什么使用双向链表删除哈希表的元素是 O(1)? 【发布时间】:2011-12-27 16:47:22 【问题描述】:

在CLRS的教科书“Introduction to Algorithm”上,pg上有这样一段。 258.

如果列表是双向链接的,我们可以在 O(1) 时间内删除一个元素。 (注意,CHAINED-HASH-DELETE 将元素 x 而不是它的键 k 作为输入,这样我们就不必先搜索 x。如果哈希表支持删除,那么它的链表应该是双向链接的,这样我们可以快速删除一个项目。如果列表只是单链接的,那么要删除元素 x,我们首先必须在列表中找到 x,以便我们可以更新 x 的前任的 next 属性。对于单链表,删除和搜索都将具有相同的渐近运行时间)。

让我困惑的是这个大括号,我无法理解它的逻辑。使用双向链表,仍然需要找到 x 才能删除它,这与单链表有什么不同?请帮助我理解它!

【问题讨论】:

【参考方案1】:

这里提出的问题是:假设您正在查看哈希表的特定元素。删除它的成本是多少?

假设你有一个简单的链表:

v ----> w ----> x ----> y ----> z
                |
            you're here

现在,如果您删除 x,您需要将 w 连接到 y 以保持您的列表链接。你需要访问w 并告诉它指向y(你想拥有w ----> y)。但是您无法从x 访问w,因为它只是链接!因此,您必须遍历所有列表以在 O(n) 操作中找到 w,然后告诉它链接到 y。这很糟糕。

那么,假设你是双向链接的:

v <---> w <---> x <---> y <---> z
                |
            you're here

酷,你可以从这里访问 w 和 y,所以你可以在 O(1) 操作中连接两个 (w &lt;---&gt; y)!

【讨论】:

在您的解释中,您假设您知道指向 x 的指针,而不仅仅是 x,但教科书并没有这么说!还是在教科书的某处暗示? Note that CHAINED-HASH-DELETE takes as input an element x and not its key k。是的,教科书说你已经在那里了=)。假设您知道指向x 的指针。这就是为什么我在答案的第一行重新写了这个问题,因为我认为你忽略了这一点。 (这也意味着您通常是对的,如果您不知道 x,则需要花费 O(n) 次操作才能找到 x,单链接或双链接) 如果您不知道 x,大约需要 O(1) 才能找到它,而不是 O(n)。毕竟它是一个哈希表。 虽然我认为这个答案是有道理的。我仍然认为教科书在这里做得不好。各方面都不是很清楚,让人摸不着头脑。想想我们在哈希表中有键值 x 对 (key, value x)。元素 X 可以是任何东西,它不一定是指针或包含链表的指针。教科书假设元素是“链表中的元素”,但在任何地方都没有提到这一点。教科书上真的把元素x的数据结构定义为一个不仅包含值还包含指针的结构就好了。 我不确定如何在不搜索链接列表的情况下获取元素 x。这里的上下文是我们试图删除一个具有键 k 的对象 v,并且哈希表使用链接作为其冲突解决机制。如果我有元素 x(它包装了对象 v 和指向其前一个和下一个元素的指针),那么它很有帮助,但实际上我们只有 v,所以在最坏的情况下删除仍然需要 O(n),因为你必须先找到 x .我不知道我错过了什么,但我没有看到双向链表有帮助。【参考方案2】:

在我看来,其中的哈希表部分主要是红鲱鱼。真正的问题是:“我们可以在恒定时间内从链表中删除当前元素吗?如果可以,如何删除?”

答案是:这有点棘手,但实际上是的,我们可以——至少通常是这样。我们确实(通常)必须遍历整个链表才能找到前一个元素。相反,我们可以在当前元素和下一个元素之间交换数据,然后删除下一个元素。

唯一的例外是当/如果我们需要/想要删除列表中的最后一个项。在这种情况下, 没有要交换的下一个元素。如果你真的必须这样做,没有真正的方法可以避免找到前一个元素。然而,通常有一些方法可以避免这种情况——一种是使用哨兵而不是空指针来终止列表。在这种情况下,由于我们永远不会删除具有哨兵值的节点,因此我们永远不必处理删除列表中的最后一项。这给我们留下了相对简单的代码,如下所示:

template <class key, class data>
struct node 
    key k;
    data d;
    node *next;
;

void delete_node(node *item) 
    node *temp = item->next;
    swap(item->key, temp->key);
    swap(item->data, temp->data);
    item ->next = temp->next;
    delete temp;

【讨论】:

【参考方案3】:

一般来说你是对的 - 你发布的算法将 element 本身作为输入,而不仅仅是它的键:

请注意,CHAINED-HASH-DELETE 将元素 x 而非其作为输入 键 k,这样我们就不必先搜索 x

你有元素 x - 因为它是一个双链表,你有指向前任和后继者的指针,所以你可以在 O(1) 中修复这些元素 - 使用单个链表只有后继者可用,所以你将不得不在 O(n) 中搜索前任。

【讨论】:

【参考方案4】:

假设你想删除一个元素 x ,通过使用双向链表你可以很容易地将 x 的前一个元素连接到 x 的下一个元素。所以不需要遍历所有列表,它将在 O(1) 中。

【讨论】:

【参考方案5】:

Find(x) 通常对于链式哈希表来说是 O(1) - 无论您使用单链表还是双链表都无关紧要。它们的性能相同。

如果在运行Find(x) 之后,您决定要删除返回的对象,您会发现,在内部,哈希表可能不得不再次搜索您的对象。它通常仍然是 O(1) 并且没什么大不了的,但是你发现你删除了很多,你可以做得更好。不是直接返回用户的元素,而是返回一个指向底层哈希节点的指针。然后,您可以利用一些内部结构。因此,如果在这种情况下,您选择了双向链表作为表达链的方式,那么在删除过程中,无需重新计算哈希并再次搜索集合——您可以省略这一步。您有足够的信息可以从您所在的位置执行删除操作。如果您提交的节点是头节点,则必须格外小心,因此如果它是链表的头,则可以使用整数来标记您的节点在原始数组中的位置。

权衡是额外指针占用的保证空间与可能更快的删除(以及稍微复杂的代码)。对于现代台式机,空间通常非常便宜,因此这可能是一个合理的权衡。

【讨论】:

【参考方案6】:

编码观点: 可以在 c++ 中使用unordered_map 来实现这一点。

unordered_map<value,node*>mp;

其中node* 是一个指向存储键、左右指针的结构的指针!

使用方法:

如果您有一个值 v 并且您想删除该节点,只需执行以下操作:

    访问该节点值,例如mp[v]

    现在只要让它的左指针指向它右边的节点。

瞧,你已经完成了。

(提醒一下,在 C++ 中 unordered_map 平均需要 O(1) 才能访问存储的特定值。)

【讨论】:

【参考方案7】:

在阅读教科书时,我也对同一主题感到困惑(“x”是指向元素的指针还是元素本身),然后最终落到了这个问题上。但经过上述讨论,再次参考教科书后,我认为书中的“x”隐含假设为“节点”,其可能的属性是“key”,“next”。

教科书里有几行..

1)链式哈希插入(T,x) 在列表 T[h(x.key)]

的头部插入 x

2)如果列表只是单链接的,那么 删除元素 x,我们首先必须在列表 T[h(x.key)] 中找到 x,这样我们 可以更新 x 的前任的 next 属性

因此我们可以假设 指向元素的指针是给定的,我认为 Fezvez 对所提出的问题给出了很好的解释。

【讨论】:

【参考方案8】:

教科书错了。列表的第一个成员没有可用的“前一个”指针,因此如果它恰好是链中的第一个元素,则需要额外的代码来查找和取消链接(通常 30% 的元素是其链的头部,如果N=M,(当将 N 个项目映射到 M 个插槽时;每个插槽都有一个单独的链。)

编辑:

比使用反向链接更好的方法是使用 pointer 指向指向我们的链接(通常是 -> 列表中前一个节点的下一个链接)

struct node 
   struct node **pppar;
   struct node *nxt;
   ...
   

删除则变为:

*(p->pppar) = p->nxt;

这个方法的一个很好的特点是它同样适用于链上的第一个节点(其 pppar 指针指向某个指针,该指针不是节点的一部分。

2011 年 11 月 11 日更新

由于人们看不到我的观点,我将尝试说明。例如,有一个哈希表table(基本上是一个指针数组) 还有一堆节点onetwothree,其中一个必须删除。

    struct node *table[123];
    struct node *one, *two,*three;
    /* Initial situation: the chain one,two,three
    ** is located at slot#31 of the array */
    table[31] = one, one->next = two , two-next = three, three->next = NULL;
                one->prev = NULL, two->prev = one, three->prev = two;


    /* How to delete element one :*/
    if (one->prev == NULL) 
            table[31] = one->next;
            
    else    
            one->prev->next = one->next
            
    if (one->next) 
            one->next->prev = one->prev;
            

现在很明显,上面的代码是 O(1),但有一些讨厌的地方:它仍然需要 array 和索引 31,所以在 大多数情况下 是一个节点是“自包含的”,并且指向节点的指针足以将其从其链中删除,除了,当它恰好是其链中的第一个节点时;然后需要更多信息来查找table31

接下来,考虑具有指向指针的等效结构作为反向链接。

    struct node 
            struct node *next;
            struct node **ppp;
            char payload[43];
            ;

    struct node *table[123];
    struct node *one, *two,*three;
    /* Initial situation: the chain one,two,three
    ** is located at slot#31 of the array */
    table[31] = one, one-next = two , two-next = three, three->next = NULL;
                one->ppp = &table[31], two->ppp = &one->next, three->ppp = &two-next;

    /* How to delete element one */
    *(one->ppp) = one->next;
    if (one->next) one->next->ppp = one->ppp;

注意:没有特殊情况,也不需要知道父表。 (考虑存在多个哈希表但具有相同节点类型的情况:删除操作仍需要知道应该从哪个表中删除节点)。

通常,在 prev,next 场景中,通过在双链表的开头添加一个虚拟节点来避免特殊情况;但这也需要分配和初始化。

【讨论】:

我认为您没有考虑到这一点。想想这个额外的代码在 Big-O 方面付出了多少努力。 您需要一些额外的代码来将head 分配给新的头部,但这仍然是恒定的时间。 (typically 30 % of the elements are the head of their chain, if N=M)我完全不明白这是什么意思……你能解释一下吗? @BrokenGlass:当然,找到头部是 O(1),但是对于这种情况有一个特殊的代码路径只有在链很长的时候才有用。存储和维护 prev 指针也是一个考虑因素。 我们还在讨论双向链表吗?

以上是关于为啥使用双向链表删除哈希表的元素是 O(1)?的主要内容,如果未能解决你的问题,请参考以下文章

在 O(logk) 时间内删除 K 个排序的双向链表的最小值

双向链表的原理与实现

数据结构——双向链表的实现

双向链表的建立插入删除

当我尝试从双向链表中删除最后一个元素时,为啥会收到“信号 SIGSEGV,分段错误”?

双向链表与LinkedHashMap