链表与动态数组用于使用向量类实现堆栈

Linked list vs dynamic array for implementing a stack using vector class

本文关键字:向量 实现 堆栈 动态 数组 用于 链表      更新时间:2023-10-16

我阅读了实现堆栈的两种不同方法:链表和动态数组。链表相对于动态数组的主要优点是,链表不必调整大小,而如果插入了太多元素,则动态数组必须调整大小,从而浪费了大量时间和内存。

这让我想知道C++是否真的是这样(因为有一个向量类,每当插入新元素时都会自动调整大小)?

很难将两者进行比较,因为它们的内存使用模式截然不同。

矢量大小调整

矢量根据需要动态调整自身大小。它通过分配一个新的内存块,将数据从旧块移动(或复制)到新块,释放旧块来实现这一点。在一个典型的例子中,新块的大小是旧块的1.5倍(与流行的观点相反,2倍在实践中似乎很不寻常)。这意味着在短时间内,在重新分配时,它所需的内存大约是实际存储数据的2.5倍。在剩下的时间里,正在使用的"块"最小为2/3rds满,最大为完全满。如果所有尺寸的可能性都一样,我们可以预计它的平均值约为5/6英寸。从另一个方向来看,我们可以预期在任何给定的时间都会"浪费"大约1/6th,或者大约17%的空间。

当我们像那样通过常数因子调整大小时(而不是总是添加特定大小的块,例如以4Kb的增量增长),我们得到了所谓的摊余常数时间加法。换句话说,随着数组的增长,调整大小的频率呈指数级降低。数组中项目的平均复制次数趋于恒定(通常在3左右,但取决于您使用的增长因子)。

链表分配

使用链表,情况就大不相同了。我们从来没有看到调整大小,所以我们没有看到一些插入的额外时间或内存使用。同时,我们确实看到了额外的时间和内存基本上所有时间都在使用。特别是,链表中的每个节点都需要包含指向下一个节点的指针。根据节点中数据的大小与指针的大小相比,这可能会导致显著的开销。例如,假设您需要一个intS堆栈。在int与指针大小相同的典型情况下,这将意味着50%的开销——一直如此。指针比int大越来越常见;两倍的大小是相当常见的(64位指针,32位int)。在这种情况下,您有大约67%的开销——也就是说,显然足够了,每个节点为指针占用的空间是存储数据的两倍。

不幸的是,这通常只是冰山一角。在典型的链表中,每个节点都是单独动态分配的。至少,如果您存储的是小数据项(如int),则为节点分配的内存可能(通常)甚至大于您实际请求的量。所以——你要求12字节的内存来容纳一个int和一个指针——但你得到的内存块可能会四舍五入到16或32字节。现在你看到的开销至少是75%,很可能是88%。

就速度而言,情况非常相似:动态分配和释放内存通常非常缓慢。堆管理器通常有可用内存块,并且必须花时间搜索它们,以找到最适合您要求的大小的块。然后,它(通常)必须将该块分为两部分,一部分用于满足您的分配,另一部分则用于满足其他分配。同样,当你释放内存时,它通常会返回到相同的可用块列表,并检查是否有相邻的内存块已经空闲,这样它就可以将两者重新连接在一起。

分配和管理大量内存块是非常昂贵的。

缓存使用

最后,对于最近的处理器,我们遇到了另一个重要因素:缓存使用率。在向量的情况下,我们所有的数据都紧挨着。然后,在向量的使用部分结束后,我们有一些空内存。这导致了出色的缓存使用率——我们正在使用的数据被缓存;我们没有使用的数据对缓存几乎没有影响。

通过链表,指针(以及每个节点中可能的开销)分布在整个列表中。也就是说,我们关心的每一条数据都有指针的开销,以及分配给我们没有使用的节点的空白空间。简言之,缓存的有效大小减少了大约与列表中每个节点的总体开销相同的因素——即,我们可能很容易看到缓存中只有1/8th存储我们关心的日期,而7/8ths用于存储指针和/或纯垃圾。

摘要

当节点数量相对较少时,链表可以很好地工作,每个节点都相当大。如果(对于堆栈来说更为典型)您处理的项目数量相对较大,每个项目都很小,那么您就不太可能节省时间或内存使用。恰恰相反,对于这种情况,链表基本上更可能浪费大量的时间和内存。

是的,您所说的对C++来说是正确的。因此,作为C++中的标准堆栈类的std::stack中的默认容器既不是向量也不是链表,而是双端队列(deque)。这几乎具有向量的所有优点,但它的大小调整得更好。

基本上,std::deque是内部排序的数组的链表。这样,当它需要调整大小时,只需添加另一个数组。

首先,链表和动态数组之间的性能权衡要微妙得多。

根据要求,C++中的向量类被实现为"动态数组",这意味着它必须有一个分摊的恒定成本来向其中插入元素。如何做到这一点通常是通过以几何方式增加数组的"容量",也就是说,每当你用完(或接近用完)时,你就会将容量增加一倍。最后,这意味着重新分配操作(分配新的内存块并将当前内容复制到其中)只会在少数情况下发生。在实践中,这意味着重新分配的开销只在性能图上显示为对数间隔的小尖峰。这就是"摊余不变"成本的含义,因为一旦忽略了这些小峰值,插入操作的成本基本上是不变的(在这种情况下是微不足道的)。

在链表实现中,您没有重新分配的开销,但是,您有在自由存储(动态内存)上分配每个新元素的开销。因此,开销有点规律(不是尖峰,有时可能需要),但可能比使用动态数组更重要,尤其是如果元素复制成本相当低(体积小,对象简单)。在我看来,链接列表只建议用于复制(或移动)成本非常高的对象。但归根结底,这是你在任何特定情况下都需要测试的东西。

最后,需要指出的是,引用的位置性通常是任何广泛使用和遍历元素的应用程序的决定因素。当使用动态数组时,元素一个接一个地被打包在内存中,按顺序遍历非常有效,因为CPU可以在读/写操作之前抢先缓存内存。在一个普通的链表实现中,从一个元素跳到下一个元素通常涉及到在不同的内存位置之间相当不稳定的跳跃,这有效地禁用了这种"预取"行为。因此,除非列表中的单个元素非常大,并且对它们的操作通常执行时间很长,否则在使用链表时缺乏预取将是主要的性能问题。

正如您所猜测的,我很少使用链表(std::list),因为优势应用程序的数量很少。通常,对于大型且昂贵的复制对象,通常最好简单地使用指针向量(您可以获得与链表基本相同的性能优势(和劣势),但使用较少的内存(用于链接指针),如果需要,还可以获得随机访问功能)。

我能想到的链接列表战胜动态数组(或像std::deque这样的分段动态数组)的主要情况是,需要经常在中间(而不是两端)插入元素。然而,当您保留一组排序(或以某种方式排序)的元素时,通常会出现这种情况,在这种情况下,您会使用树结构来存储元素(例如,二进制搜索树(BST)),而不是链表。通常,这样的树使用动态阵列或分段动态阵列(例如,高速缓存遗忘动态阵列)内的半连续存储器布局(例如,广度优先布局)来存储它们的节点(元素)。

是的,适用于C++或任何其他语言。动态数组是一个概念。C++具有CCD_ 11的事实并没有改变这一理论。C++中的矢量实际上在内部进行大小调整,因此此任务不是开发人员的责任。当使用vector时,实际成本并没有神奇地消失,它只是被卸载到标准库实现中。

std::vector是使用动态数组实现的,而std::list是作为链表实现的。使用这两种数据结构都需要权衡取舍。选择最适合你需要的。

  • 正如您所指出的,如果动态数组已满,则添加项目可能需要花费更大的时间,因为它必须自行扩展。然而,它的访问速度更快,因为它的所有成员都在内存中分组在一起。这种紧密的分组通常也会使其对缓存更友好。

  • 链表不需要调整大小,但遍历它们需要更长的时间,因为CPU必须在内存中跳跃。

这让我想知道c++是否真的是这样,因为有一个向量类,每当插入新元素时都会自动调整大小。

是的,它仍然有效,因为vector调整大小可能是一项昂贵的操作。在内部,如果达到了预先分配的向量大小,并且您尝试添加新元素,则会进行新的分配,并将旧数据移动到新的内存位置。

来自C++文档:

vector::push_back-在末尾添加元素

在矢量的末尾,在当前最后一个元素之后添加一个新元素。val的内容被复制(或移动)到新元素中。

这有效地将容器大小增加了一,这将导致在新矢量大小超过当前矢量容量时(且仅当新矢量大小超出当前矢量容量)自动重新分配分配的存储空间。

http://channel9.msdn.com/Events/GoingNative/GoingNative-2012/Keynote-Bjarne-Stroustrup-Cpp11-Style跳到44:40。正如Bjarne自己在视频中解释的那样,只要可能,你应该更喜欢std::vector而不是std::list。由于std::vector将其所有元素并排存储在内存中,因此它将具有缓存在内存中的优势。对于从CCD_ 20中添加和删除元素以及搜索也是如此。他指出std::liststd::vector慢50-100x。

如果你真的想要一个堆栈,你应该真正使用std::stack,而不是自己制作。