为什么使用数组实现Stack<T>和Queue<T>?

30

我正在阅读Albahari兄弟的《C# 4.0权威指南》,看到这段话:

堆栈(Stack)使用一个内部实现了动态扩容的数组,和队列(Queue)以及列表(List)一样。(第288页,第4段)

我不禁想问为什么。链表(LinkedList)提供了 O(1) 的头尾插入和删除操作(这对于堆栈或队列来说应该很好用)。可调整大小的数组在平均情况下具有 O(1) 的插入操作(如果我没记错的话),但最坏情况下是 O(n)(我不确定删除操作)。对于大型堆栈/队列,它可能会使用更多的空间。

这里是否有比这更多的考虑?双向链表实现的缺点是什么?


另一个要点是底层数组以循环方式使用,因此当头部和尾部移动时(如果未超出边界),数组元素将被回收利用。 - Tim Lloyd
内存管理开销。 - user541686
@SebastianNegraszus 谢谢。你是怎么找到的?我搜了很多,什么都没找到。 - KooKoo
@KooKoo 这是在此页面“相关”下面的顶部链接之一。我无法确定是否通过搜索会找到它。 - Sebastian Negraszus
3个回答

24

但最坏情况下是 O(n)。

虽然插入和删除操作的时间有长有短,但是平摊分析的整体最坏情况仍然是O(1),这就是平摊分析的全部意义。此外,数组使用的空间也比链表少(毕竟每个元素都要存储一个额外的指针)。

此外,与链表相比,数组的开销更小。总之,在几乎所有的用例中,基于数组的实现方式都比基于链表的实现方式更加高效(事实上,队列可以通过利用以链表形式管理的页面来稍微更有效地实现 - 参见C++的`std :: deque` 实现)。


5
@Femaref:不,它真正被称为deque,而不是dequeue - Konrad Rudolph
6
使用数组可以获得局部性的好处。 - Brian Rasmussen
哦,抱歉。拼写“queue”是一个很常见的错误,所以我以为你犯了这个错误,没有冒犯之意。 - Femaref

23

以下是一个大致的估算,用于描述存储100个System.Int32类型的栈所需的内存资源:

如果使用数组实现,则需要以下内容:

type designator                          4 bytes
object lock                              4
pointer to the array                     4 (or 8)
array type designator                    4
array lock                               4
int array                              400
stack head index                         4
                                       ---
Total                                  424 bytes  (in 2 managed heap objects)

链表的实现需要以下内容:

type designator                          4 bytes
object lock                              4
pointer to the last node                 4 (or 8)
node type designator         4 * 100 = 400
node lock                    4 * 100 = 400
int value                    4 * 100 = 400
pointer to next node  4 (or 8) * 100 = 400 (or 800)
                                     -----
Total                                1,612 bytes  (in 101 managed heap objects)

数组实现的主要缺点是在需要扩展时复制数组。忽略其他因素,这将是一个O(n)操作,其中n是堆栈中项的数量。除了两个因素之外,这似乎是一件非常糟糕的事情:它几乎从不发生,因为每次增量都会使扩展加倍,而数组复制操作已经高度优化并且非常快速。因此,在实践中,扩展很容易被其他堆栈操作淹没。

队列同理。


你的假设只适用于值类型。如果T是引用类型,那么两种实现方式所需的资源几乎相同。因为每个项的数组条目将是指向堆元素的引用。 - Michael Barabash
@MichaelBarabash - 这取决于你所说的“几乎相同”的含义。如果将我给出的示例从Int32转换为引用类型,则除了要将引用类型值的存储添加到两者中外,其他所有内容都是相同的,这将完全相同。如果您正在使用64位,则还需要将存储的值大小加倍以容纳更大的引用,但无论哪种方式,两种方法的总大小都会增加完全相同的量。因此,链表使用的额外存储仍将是额外的。(续...) - Jeffrey L Whitledge
然而,你正确的意思是链表开销将构成总体的较小比例。 - Jeffrey L Whitledge
嗨,杰弗里,完全同意。 - Michael Barabash

16
这是因为.NET被设计用于运行在现代处理器上,这些处理器比内存总线要快得多得多。处理器的运行速度约为2 GHz,而您机器上的RAM的时钟速度通常只有几百兆赫。从RAM中读取一个字节需要超过一百个时钟周期。
这使得CPU缓存对于现代处理器非常重要,大量的芯片空间被用于尽可能地扩大缓存大小。L1缓存的典型大小为64 KB,是最快的内存,并且物理上非常靠近处理器核心;L2缓存的大小为256 KB,较慢且离核心更远;L3缓存的大小约为8 MB,更慢且距离最远,在芯片上所有核心都共享。
为了使缓存有效,顺序访问内存非常重要。如果需要进行L3或RAM内存访问,则读取第一个字节可能非常昂贵,接下来的63个字节非常便宜。这就是“缓存行”的大小,它是内存总线的数据传输单位。
这使得数组成为迄今为止最有效的数据结构,因为其元素在内存中按顺序存储。而链表则成为可能最糟糕的数据结构,其元素自然地分散在内存中,可能每个元素都会产生非常昂贵的缓存未命中。
因此,除了LinkedList<>之外,所有.NET集合在内部都是以数组实现的。请注意,Stack<>已经自然地实现为数组,因为您只能从数组的末尾推入和弹出一个元素,这是O(1)的操作。调整数组大小的平均时间复杂度为O(logN)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接