我想知道链接列表与C中的连续数组相比有什么优缺点。因此,我读了一篇关于链表的维基百科文章。 https://en.wikipedia.org/wiki/Linked_list#Disadvantages
根据这篇文章,缺点如下:
- 由于指针使用的存储空间,它们使用的内存多于数组。
- 链接列表中的节点必须从头开始按顺序读取,因为链接列表本身就是顺序访问。
在反向遍历方面,链表中出现了困难。例如,单个链表很难向后导航,而双链表更容易阅读,分配时浪费了内存。
节点存储不明确,大大增加了访问列表中各个元素所需的时间,尤其是CPU缓存。
我理解前3分,但我在最后一分中遇到困难:
节点存储不明确,大大增加了访问列表中各个元素所需的时间,尤其是CPU缓存。
关于CPU Cache的文章没有提到任何关于非连续内存阵列的内容。据我所知,CPU缓存仅缓存经常使用的地址,总共10 ^ -6缓存未命中。
因此,我不明白为什么CPU缓存在非连续内存阵列方面的效率会降低。
答案 0 :(得分:9)
CPU缓存实际上做了两件事。
你提到的那个是缓存最近使用过的内存。
然而另一个是预测将来会使用哪种内存。该算法通常非常简单 - 它假定程序处理大量数据,每当它访问某些内存时,它将预取更多的字节。
这不适用于链表,因为节点随机放在内存中。
此外,CPU加载更大的内存块(64,128字节)。同样,对于具有单个读取的int64数组,它具有用于处理8或16个元素的数据。对于链表,它会读取一个块,其余的可能会被浪费,因为下一个节点可能位于完全不同的内存块中。
最后但并非最不重要的是,与上一节相关 - 链接列表为其管理带来了更多内存,最简单的版本将至少为指向下一个节点的指针增加sizeof(指针)字节。但它不再是CPU缓存了。
答案 1 :(得分:3)
这篇文章只是表面上看,并且有些不对劲(或至少有问题),但整体结果通常大致相同:链接列表要慢得多。
需要注意的一点是"节点是无条件存储的[原文如此]"是一个过于强烈的主张。确实,通常由例如malloc
返回的节点可以在内存中传播,特别是如果节点在不同时间或不同线程分配。但是,在实践中,许多节点通常同时分配在同一个线程上,并且这些节点通常最终会在内存中相当连续,因为良好的malloc
实现很好!此外,当性能受到关注时,您可能经常在每个对象的基础上使用特殊的分配器,它从一个或多个连续的内存块中分配固定大小的注释,这将提供很好的空间局部性。
因此,您可以假设至少在某些情况下,链接列表会为您提供合理的良好空间局部性。这在很大程度上取决于您是否一次添加了大部分列表元素(链接列表可以正常),或者是在更长的时间内不断添加元素(链接列表的空间局部性差)。
现在,在列表缓慢的情况下,链接列表掩盖的主要问题之一是与相对于数组变体的某些操作相关联的大常数因子。每个人都知道,如果链接列表中的索引为O(n)
,而数组中的O(1)
,则访问该元素,因此如果您要进行大量访问,则不要使用链接列表按索引。类似地,每个人都知道在列表中间添加一个元素在链表中需要O(1)
时间,在数组中需要O(n)
时间,因此前者在该场景中获胜。
他们没有解决的问题是,在一个实现中,即使具有相同算法复杂度的操作也可能 更慢......
让我们迭代列表中的所有元素(也许寻找特定的值)。无论您使用链接还是数组表示,这都是O(n)
操作。所以这是一个平局,对吧?
不是那么快!实际表现可能差异很大! Here is what典型的find()
实现在x86 gcc中以-O2
优化级别编译时看起来像这样,这要归功于使用Godbolt实现这一点。
int find_array(int val, int *array, unsigned int size) {
for (unsigned int i=0; i < size; i++) {
if (array[i] == val)
return i;
}
return -1;
}
.L6:
add rsi, 4
cmp DWORD PTR [rsi-4], edi
je .done
add eax, 1
cmp edx, eax
jne .notfound
struct Node {
struct Node *next;
int item;
};
Node * find_list(int val, Node *listptr) {
while (listptr) {
if (listptr->item == val)
return listptr;
listptr = listptr->next;
}
return 0;
}
.L20:
cmp DWORD PTR [rax+8], edi
je .done
mov rax, QWORD PTR [rax]
test rax, rax
jne .notfound
只要注意C代码,这两种方法看起来都很有竞争力。数组方法将增加i
,进行几次比较,并获得一次内存访问以从数组中读取值。链接列表版本,如果要进行几次(相邻)内存访问以阅读Node.val
和Node.next
成员,以及几个比较。
程序集似乎证明了这一点:链表版本有5个指令,数组版本 2 有6个。所有指令都是简单的,每个周期吞吐量为1或更多在现代硬件上。
如果您测试它 - 两个列表完全驻留在L1 中,您会发现阵列版本每次迭代执行大约1.5个字节,而链表版本大约需要4个!这是因为链表版本受到listptr
上的循环依赖限制。一行listptr = listptr->next
归结为指令,但是一条指令永远不会每4个周期执行一次以上,因为每次执行都取决于前一个指令的完成(您需要完成读取listptr->next
在你计算listptr->next->next
之前。尽管现代CPU可以在每个周期执行2次加载循环,但这些加载需要大约4个周期才能完成,因此这里会出现串行瓶颈。
阵列版本也有负载,但地址并不依赖于先前的负载:
add rsi, 4
cmp DWORD PTR [rsi-4], edi
它仅取决于rsi
,它只是通过每次迭代添加4来计算。 add
在现代硬件上具有一个周期的延迟,因此这不会产生瓶颈(除非您得到低于1个周期/迭代)。因此阵列循环能够使用CPU的全部功能,并行执行许多指令。链表版本不是。
这并不是&#34;发现&#34; - 任何需要迭代多个元素的链接操作都会有指针追逐行为,这在现代硬件上本质上很慢。
1 我省略了每个汇编函数的结语和序言,因为它确实没有做任何有趣的事情。这两个版本都没有真正的结局,并且两者的长度非常相似,剥离了第一次迭代并跳到了循环的中间。无论如何,完整代码为available for inspection。
2 值得注意的是,gcc并没有真正做到这一点,因为它保持rsi
作为指向数组的指针和eax
作为索引i
。这意味着两个单独的cmp
指令和两个增量。更好的方法是只在循环中维护指针rsi
,并与(array + 4*size)
进行比较,因为&#34;未找到&#34;条件。这将消除一个增量。此外,您可以通过cmp
从rsi
运行到零来消除一个-4*size
,并使用[rdi + rsi]
索引到数组,其中rdi是array + 4*size
。表明即使在今天优化编译器也无法使一切正确!
答案 2 :(得分:1)
CPU缓存通常会占用一定大小的页面,例如(常见的) 4096字节或 4kB ,并从中访问所需的信息。要获取页面,需要花费相当多的时间来说1000个周期。如果我们有一个4096字节的数组是连续的,我们将从高速缓冲存储器中获取一个4096字节的页面,并且可能大部分数据都在那里。如果不是,我们可能需要获取另一个页面来获取其余数据。
示例:我们有2页0-8191,数组介于2048和6244之间,然后我们将从0-4095获取页面#1以获取所需的元素然后页面#2从4096-8191获取我们想要的所有数组元素。这导致从内存中获取2页到我们的缓存以获取我们的数据。
虽然列表中会发生什么?在列表中,数据是非连续的,这意味着元素不在内存中的连续位置,因此它们可能分散在各种页面中。这意味着CPU必须从内存中获取大量页面到缓存以获取所需数据。
示例:节点#1 mem_address = 1000,节点#2 mem_address = 5000,节点#3 mem_address = 18000.如果CPU能够看到4k页面大小,那么它必须获取3来自内存的不同页面,以找到它想要的数据。
此外,内存使用预取技术在需要之前获取内存页面,因此如果链表很小,请说A - &gt; B - &gt; C,那么第一个周期将会很慢,因为预取器无法预测下一个要获取的块。但是,在下一个循环中,我们说预取器已经预热,它可以开始预测链表的路径并按时获取正确的块。
汇总数据很容易通过硬件预测,并且位于一个位置,因此很容易获取,而链表是不可预测的,并且分散在整个内存中,这使预测器和CPU的生命更加困难。
答案 3 :(得分:1)
BeeOnRope的答案很好,并且强调了遍历链表和遍历数组的周期计数开销,但是正如他明确指出的那样,它假设“两个列表都完全驻留在L1中”。但是,数组在L1中比在链表中更适合的可能性更大,并且当您开始对缓存进行更改时,性能差异将变得巨大。 RAM可能比L1慢100倍以上,而L2和L3(如果有CPU的话)要慢3到14倍。
在64位体系结构上,每个指针占用8个字节,而双链表则需要其中两个或16个字节的开销。如果每个条目只需要一个4字节的uint32,则意味着dlist的存储量是数组的5倍。数组保证局部性,尽管如果按正确的顺序一起分配内容,尽管malloc可以在局部性上做得很好,但通常不能。通过说它占用了2倍的空间来近似表示局部性,因此dlist使用的“局部性空间”是数组的10倍。这足以使您从安装L1到溢出到L3,甚至从L2到RAM变得更糟。