理论(特别是大 O 表示法)认为,链表的插入和删除复杂度为 O(1),而动态数组的复杂度为 O(n),这是因为擦除/插入需要容器移动。
但这就是理论。计算机不仅仅是理论。在实践中,它大不相同。
现代计算机将它们的内存排列在所谓的内存层次中,即一组按速度排序的不同类型的内存设备:
+---------------+
| CPU registers | ^
+---------------+ |
| L1 cache | |
| ... | | Less apacity
| LN cache | | Faster access
+---------------+ | More expensive hardware
| RAM | |
+---------------+
| HDD |
+---------------+
如图所示,层次结构是通过内存访问的速度来组织的。但请注意,更高的速度意味着更昂贵的硬件,因此这意味着许多慢速访问内存和一些快速内存。
因此,提高程序性能的一种方法是将频繁使用的数据保存在快速内存中,并且仅在必要时才进入慢速内存(程序请求的数据未加载到快速内存中,例如示例)。
这正是硬件所做的,假设有两种行为:
- 如果请求了一条数据,未来可能会请求它附近的数据。这称为Locality of reference。例如,考虑一下我们如何使用数组。
- 如果请求了一条数据,将来可能会再次请求它。这就是所谓的时间局部性。同样,一次又一次地迭代和数组就是一个例子。
当然内存是有限的,所以一个新数据的请求将被加载到层次结构的某个级别,会丢弃之前存在的数据。
那么,为什么这对不同容器的性能很重要?
记住链表(std::list 是链表)是如何工作的:
链表是通过指针在它们之间连接的分离节点链:
+---+ +---+ +---+
| 1 | --> | 2 | --> ... --> | N |
+---+ +---+ +---+
另一方面,动态数组(std::vector 是动态数组)是一块连续的内存:
+---+---+-----+---+
| 1 | 2 | ... | N |
+---+---+-----+---+
正如我上面所说的,理论认为链表插入/删除具有 O(1) 复杂性,因为“只是改变指针”。但是考虑一下如何访问内存来做到这一点。 您是否注意到该过程不满足空间局部性规则?。所以这有很多未命中(缓存未命中),即从快速内存中请求新的内存,性能下降。
事实上,即使理论上说遍历链表具有 O(n) 复杂度,但在实践中,由于缓存未命中而导致的性能持续下降。
现在考虑动态数组是如何工作的:插入/删除确实具有 O(n) 复杂性,因为您必须将数组的一侧移动一个位置以便为新元素留出间隙,或者在以下情况下消除该间隙您正在擦除。
但是请记住,数组是一个连续的内存块,因此如果您正在使用它,可能该数组已完全(或几乎部分)加载到快速内存中(sapce-locality),所以转换过程非常快。
如您所见,在现代架构中,动态数组比链表快得多。
总的来说,std::vector 相对于std::list 有很多优势:
- 它的缓存友好。正如我们所讨论的,
std::list 不是,“缓存友好性”对于当今的性能非常重要。这导致快速插入/移除、快速随机访问和快速复制。
- 链表在每次插入/删除时执行一次动态内存操作。调用
malloc()/free()(即调用操作系统以检索/离开堆内存)需要很多时间。另一方面,动态数组仅以 O(logn) 的平均值进行取消/分配。
但这还不是全部:在少数情况下,std::list 必须是首选,复制/移动元素的成本非常高。 std::vector 性能的要点是基于缓存中完成的廉价移位过程,但如果向量的元素具有某些昂贵的复制/移动语义,则根本没有任何好处,列表的性能优于向量。
阅读this article了解有关该主题的更多信息。