在某些情况下,使用std::vector 为链表存储节点可能是一种非常有用且有效的策略,例如您需要能够在恒定时间内从中间删除元素,但仍可回收空白空间,以恒定时间将元素插入到前面/中间,遍历顺序匹配插入顺序,保留合理的缓存友好访问模式,并将 64 位链接的内存使用减半,如下所示:
template <class T>
struct Node
{
// Stores the memory for the element stored in the node.
typename std::aligned_storage<sizeof(T), alignof(T)>::type data;
// Points to previous node in the array or previous
// free node in the array if the node has been removed.
// Stores -1 if there is no previous node.
int32_t prev;
// Points to next node in the array or next free
// node in the array if the node has been removed.
// Stores -1 if there is no next node.
int32_t next;
};
template <class T>
struct List
{
// Stores all the nodes contiguously.
std::vector<Node<T>> nodes;
// Points to the first node in the list.
// Stores -1 if the list is empty.
int32_t head;
// Points to the first free node in the list.
// Stores -1 if the free list is empty.
int32_t free_head;
};
std::vector 作为内存分配器
在这种情况下,我们实际上是将std::vector 转换为节点内存分配器,并将存储绝对地址的 64 位指针转换为存储数组相对索引的 32 位索引。
但是,您可以在上图中看到此解决方案的一个缺点(抱歉,如果有点混乱,该图表示擦除和重新插入后发生的情况)是,如果您开始从中间擦除元素并重新插入和回收空位,虽然您可以继续以原始插入顺序遍历元素,但您开始招致更多的缓存未命中,因为跟随链接可以让您开始在内存中来回曲折(不再以完美的顺序访问遍历数组图案)。当您插入到中间时也会发生同样的事情(这允许在恒定时间内完成,但中间的节点可能被分配到数组的后面,从而降低了引用的局部性)。这可能会导致在缓存行中加载一个内存区域,只是为了在所有内存区域用于返回相同的内存区域并再次加载之前将其逐出。
优化通道
因此,这些类型的“混合”数组/链表解决方案往往具有降低空间局部性的缺点,您从/到中间擦除和插入元素的次数越多。一种缓解这种情况的方法是偶尔对列表进行“优化复制/交换”,这会恢复空间局部性并让您回到每个prev 链接都指向数组中的前一个索引的点,并且每个next 链接都指向下一个。
还是比平时好很多
尽管如此,即使没有这些“优化通道”,与使用通用分配器分配节点的链表相比,即使在从中间多次删除和重新插入之后,它仍然倾向于产生更多、更少的缓存未命中。在后一种情况下,节点可能分散在内存中的各个位置,以至于您访问的每个节点都可能导致缓存未命中,这就是当您遇到链表在许多使用中效率特别低的恶名时案例。您还可以在 64 位机器上使用 32 位索引(除非您实际上需要数十亿个节点)而不是 64 位指针,从而将链接的内存使用量减半。
索引链接列表
我经常使用链表,但他们总是使用这样的解决方案,将节点存储在连续数组中(一个连续缓冲区存储所有节点,或者一系列连续缓冲区,每个缓冲区存储 256 个节点,例如) , 并且经常使用相对索引而不是绝对指针来指向节点。当像这样使用链表时,它们在实践中会变得更加高效。
内存池
在 32 位时代,我曾经只是为此使用内存池,就像一个符合 std::allocator 的空闲列表一样,但是在 64 位硬件变得流行之后,指针的大小在内存使用中翻了一番,并且我发现开始使用随机访问数据结构作为类比的“内存分配器”和相对的 32 位索引更有用。如果您存储在列表中的元素只是 3 个单精度浮点数(12 个字节),那么将指针大小减半绝非微不足道。我发现最大的实际麻烦是只需要处理所有内容的索引,而不能直接获取指针数据,因为这会使链接的内存使用加倍,并且如果我们使用 std::vector 作为我们的类比内存,这将不起作用分配器,因为它每次重新分配内存时都会使指针无效。
swap-and-pop_back
请注意,如果您不关心遍历顺序,不关心索引失效,并且不需要能够在恒定时间内插入任何地方,那么这种数据结构就不是那么有用了。在这种情况下,更有用的只是使用一个向量,在其中将要删除的中间元素与最后一个和pop_back 交换。这种结构的主要好处是保持从列表中任何位置的恒定时间删除,恒定时间插入到列表中的任何位置,同时允许您以原始插入顺序和合理的缓存友好方式遍历。