这篇文章只是触及了皮毛,并且出现了一些错误(或至少是有问题的),但总体结果通常大致相同:链表要慢得多。
需要注意的一点是,“节点存储不连续 [原文如此]”是一个过于强烈的主张。确实,通常由 malloc 返回的节点可能会分布在内存中,特别是如果节点是在不同时间或从不同线程分配的。然而,在实践中,许多节点通常同时分配在同一个线程上,并且这些节点通常会在内存中非常连续,因为好的 malloc 实现非常好!此外,当性能受到关注时,您可能经常基于每个对象使用特殊分配器,它从一个或多个连续的内存块中分配固定大小的音符,这将提供很好的空间局部性。
因此,您可以假设,至少在某些情况下,链表会为您提供合理到良好的空间局部性。这在很大程度上取决于您是一次添加大部分列表元素(链表可以),还是在较长时间内不断添加元素(链表的空间局部性较差)。
现在,在列表变慢的一边,链表掩盖的主要问题之一是与数组变体相关的某些操作相关的大常数因子。每个人都知道在给定索引的情况下访问一个元素在链表中是O(n),在数组中是O(1),所以如果你要通过索引进行大量访问,就不要使用链表。同样,大家都知道在链表中添加元素需要O(1)时间,在数组中需要O(n)时间,所以前者胜出。
他们没有解决的是,即使是具有相同算法复杂度的操作,在一种实现中实际上也可能慢得多...
让我们遍历列表中的所有元素(也许是寻找一个特定的值)。无论您使用链接表示还是数组表示,这都是 O(n) 操作。所以这是平局,对吧?
没那么快!实际表现可能会有很大差异! Here is what 典型的 find() 实现在 x86 gcc 中以 -O2 优化级别编译时看起来像,这要归功于 Godbolt,它使这变得简单。
数组
C 代码
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;
}
组装(仅循环)1
.L6:
add rsi, 4
cmp DWORD PTR [rsi-4], edi
je .done
add eax, 1
cmp edx, eax
jne .notfound
链表
C 代码
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 归结为 on 指令,但一条指令每 4 个周期执行一次不会超过一次,因为每次执行都取决于前一条的完成(您需要阅读完 listptr->next 才能计算listptr->next->next)。尽管现代 CPU 每个周期可以执行 2 个加载周期,但这些加载需要大约 4 个周期才能完成,因此您会遇到串行瓶颈。
数组版本也有加载,但地址不依赖于之前的加载:
add rsi, 4
cmp DWORD PTR [rsi-4], edi
它只取决于rsi,它只是通过每次迭代加4来计算的。 add 在现代硬件上具有一个周期的延迟,因此这不会造成瓶颈(除非您低于 1 个周期/迭代)。因此数组循环能够利用 CPU 的全部功能,并行执行许多指令。链表版本不是。
这不是“查找”所独有的 - 任何需要迭代许多元素的链接操作都将具有这种 指针追逐 行为,这在现代硬件上天生就很慢。
1我省略了每个汇编函数的结尾和序言,因为它确实没有做任何有趣的事情。两个版本实际上都没有尾声,而且两个版本的序言都非常相似,剥离了第一次迭代并跳到了循环的中间。无论如何,完整的代码是available for inspection。
2值得注意的是,gcc 并没有真正做到这一点,因为它同时维护了 rsi 作为指向数组的指针,以及 eax 作为索引i。这意味着两个单独的cmp 指令和两个增量。最好只在循环中维护指针rsi,并将(array + 4*size) 作为“未找到”条件进行比较。那将消除一个增量。此外,您可以消除一个cmp,方法是让rsi 从-4*size 运行到零,并使用[rdi + rsi] 对数组进行索引,其中rdi 是array + 4*size。表明即使在今天,优化编译器也并非一切都正确!