C ++中的高效链表?
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C ++中的高效链表?相关的知识,希望对你有一定的参考价值。
这个document说std::list
效率低下:
std :: list是一个非常低效的类,很少有用。它为插入其中的每个元素执行堆分配,因此具有极高的常数因子,特别是对于小数据类型。
评论:这让我感到惊讶。 std::list
是一个双向链表,因此尽管它在元素构造方面效率低,但它支持O(1)时间复杂度的插入/删除,但在这个引用的段落中完全忽略了这个特性。
我的问题:我需要一个用于小型同类元素的顺序容器,这个容器应该支持O(1)复杂度中的元素插入/删除,并且不需要随机访问(虽然支持随机访问很好,但它不是必须的这里)。我也不希望堆分配为每个元素的构造引入高常量因子,至少当元素的数量很小时。最后,只有在删除相应的元素时,迭代器才会失效。显然我需要一个自定义容器类,它可能(或可能不)是双向链表的变体。我该如何设计这个容器?
如果无法实现上述规范,那么也许我应该有一个自定义内存分配器,比如说,指针分配器?我知道std::list
将分配器作为其第二个模板参数。
编辑:我知道从工程的角度来看,我不应该太关心这个问题 - 足够快就足够了。这只是一个假设的问题,所以我没有更详细的用例。随意放松一些要求!
编辑2:据我所知,O(1)复杂度的两种算法由于其常数因子的不同而具有完全不同的性能。
我看到满足您所有要求的最简单方式:
- 恒定时间插入/移除(希望摊销的常数时间可以插入)。
- 每个元素没有堆分配/释放。
- 删除时没有迭代器失效。
......会是这样的,只是利用std::vector
:
template <class T>
struct Node
{
// Stores the memory for an instance of 'T'.
// Use placement new to construct the object and
// manually invoke its dtor as necessary.
typename std::aligned_storage<sizeof(T), alignof(T)>::type element;
// Points to the next element or the next free
// element if this node has been removed.
int next;
// Points to the previous element.
int prev;
};
template <class T>
class NodeIterator
{
public:
...
private:
std::vector<Node<T>>* nodes;
int index;
};
template <class T>
class Nodes
{
public:
...
private:
// Stores all the nodes.
std::vector<Node> nodes;
// Points to the first free node or -1 if the free list
// is empty. Initially this starts out as -1.
int free_head;
};
...希望有一个比Nodes
更好的名字(我现在有点醉意,而且不太擅长提出名字)。我会把实现留给你,但这是一般的想法。删除元素时,只需使用索引删除双向链表并将其推送到空闲头。迭代器不会失效,因为它将索引存储到向量。插入时,检查自由头是否为-1。如果没有,请覆盖该位置的节点并弹出。否则push_back
到向量。
插图
图(节点连续存储在std::vector
中,我们只需使用索引链接以允许以无分支方式跳过元素以及在任何地方进行常量时间删除和插入):
假设我们要删除一个节点。这是你的标准双链表删除,除了我们使用索引而不是指针,你还将节点推送到空闲列表(这只涉及操作整数):
链接的移除调整:
将已删除的节点推送到空闲列表:
现在让我们说你插入这个列表。在这种情况下,您弹出自由头并覆盖该位置的节点。
插入后:
在恒定时间插入中间也应该很容易理解。基本上,如果自由堆栈为空,您只需向自由头或push_back
插入向量。然后你做标准的双链表插入。免费列表的逻辑(虽然我为其他人制作了这个图,它涉及到一个SLL,但你应该明白这个想法):
确保在插入/移除时使用对dtor的新放置和手动调用来正确构造和销毁元素。如果你真的想要概括它,你还需要考虑异常安全性,我们还需要一个只读的const迭代器。
优点和缺点
这种结构的好处是它允许从列表中的任何位置进行非常快速的插入/删除(即使对于一个巨大的列表),插入顺序被保留用于遍历,并且它永远不会使迭代器无效而不能直接删除(虽然它会使指针无效;如果你不希望指针失效,请使用deque
)。就个人而言,我发现它比std::list
(我几乎从不使用)更多。
对于足够大的列表(比如,比你的整个L3缓存更大,你应该肯定期望有一个巨大的优势),这应该大大超过std::vector
的中间和前面的删除和插入。从矢量中删除元素对于小元素来说可能非常快,但是尝试从矢量中删除一百万个元素并从后面开始向后移动。事情将开始爬行,而这一切将在眨眼间完成。当人们开始使用其std::vector
方法从跨越10k或更多元素的向量中间移除元素时,erase
的IMO总是如此轻微过度,尽管我认为这仍然优于人们天真地使用链接列表到处都是节点是针对通用分配器单独分配的,同时导致缓存未命中。
缺点是它只支持顺序访问,每个元素需要两个整数的开销,正如你在上图中看到的那样,如果你不断地偶尔删除东西,它的空间局部性就会降低。
空间位置退化
当您开始从中间移除或向中间插入大量空间局部性时,将导致锯齿形的内存访问模式,可能会从缓存行中逐出数据,以便在单个顺序循环期间返回并重新加载它。这通常是不可避免的,任何数据结构都允许在恒定时间从中间移除,同时允许在保留插入顺序的同时回收该空间。但是,您可以通过提供某种方法来恢复空间局部性,也可以复制/交换列表。复制构造函数可以以遍历源列表的方式复制列表,并插入所有元素,这些元素为您提供了一个完美连续的,缓存友好的向量,没有漏洞(尽管这样做会使迭代器无效)。
替代方案:免费列表分配器
满足您要求的替代方案是实现符合std::allocator
的免费列表并将其与std::list
一起使用。我从来不喜欢到达数据结构并乱用自定义分配器,并且通过使用指针而不是32位索引,将使64位链接的内存使用量加倍,所以我更喜欢上面的解决方案,使用std::vector
基本上你的类比内存分配器和索引而不是指针(如果我们使用std::vector
,它们都会减小大小并成为一个要求,因为当向量保留新容量时指针将无效)。
索引链接列表
我把这种东西称为“索引链表”,因为链表实际上不是一个容器,而是将已存储在数组中的东西链接在一起的方式。我发现这些索引链接列表指数级更有用,因为您不必深入内存池以避免每个节点的堆分配/解除分配,并且仍然可以保持合理的引用位置(如果您能负担得起,则可以获得很好的LOR)处理事物以及恢复空间局部性。
如果向节点迭代器添加一个更多的整数来存储前一个节点索引(在64位时没有内存费用,假设int
为32位对齐要求,指针为64位),也可以单独链接。但是,您失去了添加反向迭代器并使所有迭代器双向的能力。
基准
我掀起了上面的快速版本,因为你似乎对它们感兴趣:发布版本,MSVC 2012,没有检查迭代器或类似的东西:
--------------------------------------------
- test_vector_linked
--------------------------------------------
Inserting 200000 elements...
time passed for 'inserting': {0.000015 secs}
Erasing half the list...
time passed for 'erasing': {0.000021 secs}
time passed for 'iterating': {0.000002 secs}
time passed for 'copying': {0.000003 secs}
Results (up to 10 elements displayed):
[ 11 13 15 17 19 21 23 25 27 29 ]
finished test_vector_linked: {0.062000 secs}
--------------------------------------------
- test_vector
--------------------------------------------
Inserting 200000 elements...
time passed for 'inserting': {0.000012 secs}
Erasing half the vector...
time passed for 'erasing': {5.320000 secs}
time passed for 'iterating': {0.000000 secs}
time passed for 'copying': {0.000000 secs}
Results (up to 10 elements displayed):
[ 11 13 15 17 19 21 23 25 27 29 ]
finished test_vector: {5.320000 secs}
懒得使用高精度定时器,但希望这能让人知道为什么人们不应该在关键路径中使用vector's
线性时间erase
方法来获得非常重要的输入大小,其中vector
需要大约86倍(以指数方式)更糟糕的是输入大小越大 - 我最初尝试了200万个元素但是在等待了将近10分钟之后就放弃了)以及为什么我认为vector
对于这种用途来说有点过于夸张。也就是说,我们可以将中间删除转换为一个非常快速的恒定时间操作,而不会改变元素的顺序,而不会使索引和迭代器存储它们失效,同时仍然使用vector
...我们所要做的就是简单地制作它存储带有prev/next
索引的链接节点,以允许跳过已删除的元素。
为了删除,我使用了随机改组的偶数索引源向量来确定要删除的元素以及以什么顺序删除。这有点模仿一个真实世界的用例,你通过之前获得的索引/迭代器从这些容器的中间移除,比如删除用户以前用删除按钮后用选框工具选择的元素(再次,你真的不应该使用标量vector::erase
这个非平凡的大小;建立一组索引以删除和使用remove_if
甚至更好 - 仍然比vector::erase
一次要求一个迭代器更好。
请注意,链接节点的迭代确实变得稍微慢一些,这与迭代逻辑没有关系,因为向量中的每个条目都随着链接的添加而变大(顺序处理的内存越多,等同于更多缓存)遗漏和页面错误)。然而,如果你正在做一些事情,比如从非常大的输入中删除元素,那么线性时间和恒定时间去除之间的大容器的性能偏差是如此的史诗般的,这往往是值得交换的。
我只想对你的选择做一个小评论。我是矢量的忠实粉丝,因为它具有读取速度,你可以直接访问任何元素,并在需要时进行排序。 (例如,类/结构的向量)。
但无论如何我离题了,我想透露两个漂亮的提示。使用矢量插入可能是昂贵的,所以一个巧妙的技巧,如果你可以逃避不做,不要插入。做一个正常的push_back(放在最后),然后用你想要的元素交换元素。
与删除相同。它们是昂贵的。所以将它与最后一个元素交换,删除它。
感谢所有的答案。这是一个简单 - 虽然不严谨 - 的基准。
// list.cc
#include <list>
using namespace std;
int main() {
for (size_t k = 0; k < 1e5; k++) {
list<size_t> ln;
for (size_t i = 0; i < 200; i++) {
ln.insert(ln.begin(), i);
if (i != 0 && i % 20 == 0) {
ln.erase(++++++++++ln.begin());
}
}
}
}
和
// vector.cc
#include <vector>
using namespace std;
int main() {
for (size_t k = 0; k < 1e5; k++) {
vector<size_t> vn;
for (size_t i = 0; i < 200; i++) {
vn.insert(vn.begin(), i);
if (i != 0 && i % 20 == 0) {
vn.erase(++++++++++vn.begin());
}
}
}
}
这个测试旨在测试std::list
声称擅长什么 - O(1)插入和擦除。并且,由于我要求插入/删除的位置,这个种族严重偏向于std::vector
,因为它必须移动所有以下元素(因此O(n)),而std::list
不需要这样做。
现在我编译它们。
clang++ list.cc -o list
clang++ vector.cc -o vector
并测试运行时。结果是:
time ./list
./list 4.01s user 0.05s system 91% cpu 4.455 total
time ./vector
./vector 1.93s user 0.04s system 78% cpu 2.506 total
std::vector
赢了。
编译与优化O3
,std::vector
仍然获胜。
time ./list
./list 2.36s user 0.01s system 91% cpu 2.598 total
time ./vector
./vector 0.58s user 0.00s system 50% cpu 1.168 total
std::list
必须为每个元素调用堆分配,而std::vector
可以批量分配堆内存(尽管它可能依赖于实现),因此std::list
的插入/删除具有更高的常量因子,尽管它
以上是关于C ++中的高效链表?的主要内容,如果未能解决你的问题,请参考以下文章
ZZC 语言中的指针和内存泄漏 & 编写高效的C程序与C代码优化