我想提供一个可以归结为“是与否”的替代答案。
首先,如果您想获得每个节点只有一个指针的双向链表的全部好处,那“有点不可能”。
异或列表
这里还引用了 XOR 链表。它保留了一个主要优点,即通过有损压缩将两个指针放入一个您因单链表而丢失的指针:反向遍历它的能力。它不能在仅给定节点地址的情况下以恒定时间从列表中间删除元素,并且能够在前向迭代中返回到前一个元素并在线性时间内删除任意元素,如果没有XOR 列表(您同样在其中保留两个节点指针:previous 和 current)。
性能
在 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 个元素的内存。与上述任何解决方案相比,它的实施往往更复杂一些。但在性能关键的场景中,它是一个非常有用的解决方案,而且通常可能值得更多关注。它在计算机科学中并没有被太多提及,因为从算法的角度来看,它并没有比常规链表做得更好,但是在实际场景中,引用的局部性对性能也有重大影响。