我们可以用一个指针实现一个双链表吗

Can we implement a doubly-linked list using a single pointer?

本文关键字:一个 链表 实现 指针 我们      更新时间:2023-10-16

我想使用这样的结构:

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

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

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

它被称为XOR链表。(https://en.wikipedia.org/wiki/XOR_linked_list)

每个节点存储nextprev的异或作为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

我想提供一个替代答案,可以归结为"是和否"。

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

异或列表

然而,这里引用的还有XOR链表。它保留了一个主要好处,即将两个指针进行有损压缩,使其与单链表中丢失的指针相匹配:能够反向遍历它。它不能在给定节点地址的情况下,在恒定时间内从列表的中间删除元素,并且在没有XOR列表的情况下(同样在那里保留两个节点指针:previouscurrent),能够在前向迭代中返回到前一个元素并在线性时间内删除任意元素会更简单。

性能

然而,评论中也提到了对性能的渴望。鉴于此,我认为还有一些切实可行的替代方案。

首先,双链表中的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个空闲空间,只有一个元素被占用——节点仍然必须留在内存中,以避免失效,直到它完全变空)。

它的"迭代器"允许在中间插入/删除,通常必须大于指针(除非如注释中所述,为每个元素存储额外的元数据)。它可能会浪费内存(通常没有意义,除非你有非常小的列表),比如说,即使你只有一个元素的列表,也需要32个元素的内存。它的实现确实比上述任何解决方案都要复杂一些。但是,在性能关键的场景中,这是一个非常有用的解决方案,而且通常值得更多关注。这是一个在计算机科学中没有太多提及的问题,因为从算法的角度来看,它并没有比常规链表做得更好,但在现实世界中,引用的位置也对性能有重大影响。

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

根据您的需要,XOR链表可以满足您的需要(请参阅HolyBlackCat的答案)。

另一种选择是通过做一些事情来绕过这个限制,比如在遍历列表时记住处理的最后一个节点。这将允许您在处理过程中返回一步,但不会使列表双重链接。

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

这种列表有时被称为双面列表。

然而,该列表本身将是一个转发列表。

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

如果不调用未定义的行为,就不可能以可移植的方式:XOR链表是否可以在C++中实现而不会导致未定义的行为?