我们可以使用单个指针实现双向链表吗? [复制]

Posted

技术标签:

【中文标题】我们可以使用单个指针实现双向链表吗? [复制]【英文标题】:Can we implement a doubly-linked list using a single pointer? [duplicate] 【发布时间】:2016-02-22 20:09:10 【问题描述】:

我想使用如下结构:

struct node 
   char[10] tag;
   struct node *next;
;

我想用上面的结构来创建一个双向链表。这可能吗?如果可以,我该如何实现?

【问题讨论】:

您接受的答案仅是 C++。请删除 C 标记,因为您似乎在寻找 C++。 虽然示例是在 C++ 中,但问题和答案的核心是相同的。 如果您在列表中详细说明所需的操作 - 可以获得更好的答案。 XOR 方法不提供“从列表中删除仅知道其地址的节点的能力,或者在仅知道现有节点的地址时在现有节点之前或之后插入新节点的能力”。因此,除非您放弃双向链表的某些功能,否则代码无法使用单个指针/数据解决问题。 问得好,因为最初的双向链表似乎已获得专利:link您不会希望被指控窃取他人的知识产权... 你可以轻松地制作一个长度为 2 的双向链表;) 【参考方案1】:

是的,这是可能的,但这是一个肮脏的黑客攻击。

叫做异或链表。 (https://en.wikipedia.org/wiki/XOR_linked_list)

每个节点将nextprev 的XOR 存储为uintptr_t。


这是一个例子:
#include <cstddef>
#include <iostream>

struct Node

    int num;
    uintptr_t ptr;
;

int main()

    Node *arr[4];
    // Here we create a new list.
    int num = 0;
    for (auto &it : arr)
    
        it = new Node;
        it->num = ++num;
    
    arr[0]->ptr =                     (uintptr_t)arr[1];
    arr[1]->ptr = (uintptr_t)arr[0] ^ (uintptr_t)arr[2];
    arr[2]->ptr = (uintptr_t)arr[1] ^ (uintptr_t)arr[3];
    arr[3]->ptr = (uintptr_t)arr[2];

    // And here we iterate over it
    Node *cur = arr[0], *prev = 0;
    do
    
        std::cout << cur->num << ' ';
        prev = (Node *)(cur->ptr ^ (uintptr_t)prev);
        std::swap(cur, prev);
    
    while (cur);
    return 0;

它按预期打印1 2 3 4

【讨论】:

不错的把戏,以前没见过。您确实需要上一个或下一个节点以及当前节点才能遍历列表。 是否有性能问题或其他问题。 @Avi 是的,性能可能是个问题。普通的双链表更快。仅当您需要保持内存使用非常低时,才应使用 XOR 链表。 @Avi 如果性能是一个问题,链表(单或双)或任何链接结构的性能通常在很大程度上取决于其内存分配器为其分配的内存布局。无论是树还是链表,如果你想将这种结构的性能提升到一个新的水平,你想从内存分配的角度来看待它,并找回数组所具有的一些连续特性,这些特性使它们非常好- 适合利用参考位置。 @Deduplicator:啊!我知道了。实际上,它可以例如在单个共享内存区域内工作。【参考方案2】:

我想提供一个可以归结为“是与否”的替代答案。

首先,如果您想获得每个节点只有一个指针的双向链表的全部好处,那“有点不可能”

异或列表

这里还引用了 XOR 链表。它保留了一个主要优点,即通过有损压缩将两个指针放入一个您因单链表而丢失的指针:反向遍历它的能力。它不能在仅给定节点地址的情况下以恒定时间从列表中间删除元素,并且能够在前向迭代中返回到前一个元素并在线性时间内删除任意元素,如果没有XOR 列表(您同样在其中保留两个节点指针:previouscurrent)。

性能

在 cmets 中还提到了对性能的渴望。鉴于此,我认为有一些实用的替代方案。

首先,双向链表中的 next/prev 指针不一定是 64 位系统上的 64 位指针。它可以是 32 位连续地址空间的两个索引。现在你得到了一个指针的内存价格的两个指数。然而,尝试在 64 位上模拟 32 位寻址是相当复杂的,可能不是您想要的。

但是,要获得链接结构(包括树)的全部性能优势,通常需要您重新控制节点在内存中的分配和分布方式。链接结构往往会成为瓶颈,因为如果您只对每个节点使用malloc 或纯operator new,例如,您将失去对内存布局的控制。通常(并非总是如此——取决于内存分配器,以及是否一次分配所有节点)这意味着失去连续性,这意味着失去空间局部性。

这就是为什么面向数据的设计比其他任何东西都更强调数组:链接结构通常对性能不太友好。如果您要在驱逐之前访问同一块(例如缓存行/页)内的相邻数据,则将块从较大的内存移动到更小、更快的内存的过程会很受欢迎。

不常被引用的展开列表

所以这里有一个不经常讨论的混合解决方案,即展开列表。示例:

struct Element

    ...
;

struct UnrolledNode

    struct Element elements[32];
    struct UnrolledNode* prev;
    struct UnrolledNode* next;
;

展开列表将数组和双向链表的特性合二为一。它会为您提供大量空间局部性,而无需查看内存分配器。

它可以向前和向后遍历,它可以在任何给定时间从中间删除任意元素。

它将链表开销降至最低:在这种情况下,我硬编码了每个节点 32 个元素的展开数组大小。这意味着存储列表指针的成本已经缩减到正常大小的 1/32。从列表指针开销的角度来看,这甚至比单链表更便宜,而且通常遍历速度更快(因为缓存局部性)。

它不是双向链表的完美替代品。首先,如果您担心现有的指向列表中元素的指针在移除时失效,那么您必须开始担心留下空置空间(孔/墓碑),这些空间会被回收(可能通过关联每个展开的空闲位)节点)。那时,您正在处理实现内存分配器的许多类似问题,包括一些次要形式的碎片(例如:具有 31 个空位且仅占用一个元素的展开节点 - 该节点仍然必须留在内存中避免失效,直到它完全为空)。

允许从中间插入/删除的“迭代器”通常必须大于指针(除非,如 cmets 中所述,您为每个元素存储额外的元数据)。它可能会浪费内存(除非您的列表非常小,否则通常没有实际意义),例如,即使您只有 1 个元素的列表也需要 32 个元素的内存。与上述任何解决方案相比,它的实施往往更复杂一些。但在性能关键的场景中,它是一个非常有用的解决方案,而且通常可能值得更多关注。它在计算机科学中并没有被太多提及,因为从算法的角度来看,它并没有比常规链表做得更好,但是在实际场景中,引用的局部性对性能也有重大影响。

【讨论】:

您可以做的一件事是获取展开的列表并向每个元素添加一个字段,该字段结合了一个标志,表示该元素是否正在使用中,还有一个数字表示该元素在其父 UnrolledNode 中的哪个索引目的。这将允许您删除一个元素或遍历列表,只给出一个元素的指针。 @Ike:无论如何,出于对齐原因,将墓碑标志打包在元素数组之外的位数组(在您的情况下为单个uint32_t)可能是一个好主意。不想因为每个元素 1 位而意外地增加元素数组的大小......它还暗示在 64 位系统上您可能会瞄准 64 元素数组。 @Ike:请注意,查看 32 位或 64 位是一个常数时间操作,因为它不依赖于序列中元素的数量。关于将“索引”打包到元素中,一个潜在的技巧是使用struct NodeData: Element, Index ,其中后者仅包含一个字节。如果Element 末尾有一个备用字节,它将是免费的……但我个人更倾向于在迭代器中包含元数据。 @Ike:首先,循环 32 或 64 个值是常数时间,因为它不会随列表中元素的数量而变化(即 O(64) = O( 1))。话虽如此,编译器通常为位操作提供内置函数(如果可用,则映射到等效的 CPU 指令)。找到整数的第一个 0 位可以通过在 gcc 上调用 __builtin_clz 来完成(计数前导零)。请参阅gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html 以获取大量内置插件的列表:) @Ike:O(...) 本身并不意味着超过 1 或 2,它只是一个没有单位的数量。为了完全正式,因此应该指定数量,例如在 N 个元素的二叉搜索树中插入是 O(log N) comparisons。恐怕我们之前有点草率,因此最终彼此交谈,因为我们每个人都有自己的单位......超越单位,请注意 O(...), o(...) 和Θ(...) 符号表示 N 趋于无穷大时的算法复杂度。我认为它们不应该真正用于正常的有界数量。【参考方案3】:

这不是完全可能的。双向链表需要两个指针,一个指向每个方向的链接。

根据您的需要,XOR 链表可能会满足您的需求(请参阅 HolyBlackCat 的回答)。

另一种选择是通过做一些事情来解决这个限制,例如在遍历列表时记住您处理的最后一个节点。这将让您在处理过程中后退一步,但不会使列表双向链接。

【讨论】:

【参考方案4】:

您可以声明并支持两个指向节点headtail 的初始指针。在这种情况下,您将能够将节点添加到列表的两端。

这样的列表有时称为双边列表。

但是列表本身将是一个转发列表。

例如,您可以使用这样的列表来模拟队列。

【讨论】:

虽然适用于一些的双向链表应用,但它没有 O(1) 向后迭代,O(1) 在指向位置插入,O(1 ) 在指向的位置删除。 -- 如果想要实现一个有界长度的队列,甚至可以去掉 all 指针... @HagenvonEitzen 它没有向后迭代我在回答中谈到了这一点。所以你的评论没有任何意义。 @HagenvonEitzen:实际上,它在任何位置都有 O(1) 插入/删除...但是您需要将元素的位置 prior 提供给您的位置希望删除。 真的是双端队列。【参考方案5】:

如果不调用未定义的行为,就不可能以可移植的方式进行: Can an XOR linked list be implemented in C++ without causing undefined behavior?

【讨论】:

以上是关于我们可以使用单个指针实现双向链表吗? [复制]的主要内容,如果未能解决你的问题,请参考以下文章

仅使用单个指针字段存储双向链表

使用单指针域实现双向链表

数据结构 链表_双向链表的实现与分析

静态链表循环链表双向链表

双向链表的原理与实现

数据结构05——静态链表循环链表双向链表