我正在阅读实现堆栈的两种不同方式:链表和动态数组。链表在动态数组上的主要优点是链接列表不必调整大小,而如果插入太多元素则必须调整动态数组的大小,从而浪费大量时间和内存。
这让我想知道C ++是否也是如此(因为有一个矢量类会在插入新元素时自动调整大小)?
答案 0 :(得分:10)
很难比较这两者,因为他们的内存使用模式是完全不同的。
矢量调整大小
矢量根据需要动态调整自身大小。它通过分配新的内存块,将数据从旧块移动(或复制)到新块,释放旧块来实现。在一个典型的情况下,新的大小是旧的大小的1.5倍(与流行的看法相反,2x在实践中似乎很不寻常)。这意味着在重新分配时的短时间内,它需要的内存大约相当于您实际存储的数据的2.5倍。其余的时间," chunk"正在使用的是至少2/3 rds 已满,最多为完全满。如果所有尺寸都同样可能,我们可以预期它平均约为5/6 ths 。从另一个方向看,我们可以预期大约1/6 th ,或大约17%的空间被浪费掉#34;在任何给定的时间。
当我们通过这样的常量因子调整大小时(而不是,例如,总是添加特定大小的块,例如以4Kb为增量增长),我们得到了所谓的摊销的恒定时间加法。换句话说,随着阵列的增长,调整大小的次数会少得多。数组中项目的平均复制次数趋于恒定(通常约为3,但取决于您使用的增长因子)。
链接列表分配
使用链表,情况有所不同。我们从未看到调整大小,因此我们没有看到一些插入的额外时间或内存使用情况。与此同时,我们做看到基本上所有时间使用的额外时间和内存。特别是,链表中的每个节点都需要包含指向下一个节点的指针。根据节点中数据的大小与指针的大小相比,这可能导致显着的开销。例如,假设您需要一堆int
s。在int
与指针大小相同的典型情况下,这将意味着50%的开销 - 始终如此。指针大于的指针比int
越来越常见;两倍大小相当常见(64位指针,32位int)。在这种情况下,你有大约67%的开销 - 也就是说,显而易见的是,每个节点在存储数据时投入的空间是指针的两倍。
不幸的是,这通常只是冰山一角。在典型的链表中,每个节点都是单独动态分配的。至少如果您要存储小数据项(例如int
),为节点分配的内存可能(通常会)甚至大于您实际请求的数量。所以 - 你要求12个字节的内存来保存一个int和一个指针 - 但你获得的内存块可能会被舍入到16或32个字节。现在你正在考虑至少75%的开销,很可能是~88%。
就速度而言,情况非常相似:动态分配和释放内存通常很慢。堆管理器通常具有可用内存块,并且必须花时间搜索它们以找到最适合您要求的大小的块。然后它(通常)必须将该块分成两部分,一部分用于满足您的分配,另一部分用于满足其他分配。同样,当你释放内存时,它通常会返回到相同的空闲块列表,并检查相邻的内存块是否已经空闲,因此它可以将两者重新连接在一起。
分配和管理大量内存块非常昂贵。
缓存使用情况
最后,对于最近的处理器,我们遇到了另一个重要因素:缓存使用情况。在矢量的情况下,我们将所有数据彼此相邻。然后,在使用的矢量部分结束之后,我们有一些空的记忆。这导致优秀的缓存使用 - 我们使用的数据被缓存;我们未使用的数据对缓存几乎没有影响。
使用链表,指针(以及每个节点中可能的开销)分布在整个列表中。即,我们关心的每个数据都紧挨着它,指针的开销,以及分配给我们不使用的节点的空白空间。简而言之,缓存的有效大小与列表中每个节点的总开销大致相同 - 即,我们可能很容易看到只有1/8 th < / sup>存储我们关心的日期的缓存,以及7/8 ths 致力于存储指针和/或纯垃圾。
<强>摘要强>
当您拥有相对较少的节点时,链接列表可以很好地工作,每个节点都非常大。如果(对于一个堆栈来说更典型)你处理的是相对大量的项目,每个项目都非常小,那么你很少 节省时间或内存使用量。恰恰相反,对于这种情况,链表很可能基本上浪费了大量的时间和记忆。
答案 1 :(得分:3)
是的,你说的对C ++来说是正确的。出于这个原因,std::stack
中的默认容器(它是C ++中的标准堆栈类)既不是向量也不是链表,而是双端队列(deque
)。这几乎具有矢量的所有优点,但它可以更好地调整大小。
基本上,std::deque
是内部排序的链接列表。这样,当需要调整大小时,它只会添加另一个数组。
答案 2 :(得分:2)
首先,链表和动态数组之间的性能折衷比这更微妙。
根据要求,C ++中的向量类被实现为“动态数组”,这意味着它必须具有用于将元素插入其中的分摊的常量成本。如何做到这一点通常是以几何方式增加阵列的“容量”,也就是说,每当用完时(或接近耗尽),你的容量就会翻倍。最后,这意味着重新分配操作(分配新的内存块并将当前内容复制到其中)只会在少数情况下发生。实际上,这意味着重新分配的开销仅在性能图上显示为对数间隔的小尖峰。这就是具有“摊销 - 常数”成本的意思,因为一旦你忽略了这些小峰值,插入操作的成本基本上是恒定的(在这种情况下是微不足道的。)
在链表实现中,您没有重新分配的开销,但是,您确实需要在freestore(动态内存)上分配每个新元素的开销。因此,开销有点规律(没有加标,有时可能需要),但可能比使用动态数组更重要,特别是如果元素复制相当便宜(尺寸小,而且对象简单)。在我看来,链接列表仅建议用于复制(或移动)非常昂贵的对象。但是在一天结束时,这是你需要在任何特定情况下测试的东西。
最后,重要的是要指出引用的位置通常是任何广泛使用和遍历元素的应用程序的决定因素。当使用动态数组时,元素一个接一个地打包在内存中,并且执行有序遍历非常有效,因为CPU可以在读/写操作之前抢先缓存内存。在vanilla链表实现中,从一个元素到下一个元素的跳转通常涉及在完全不同的存储器位置之间的相当不稳定的跳跃,这有效地禁用了这种“预取”行为。因此,除非列表中的各个元素非常大并且对它们的操作通常很长,否则在使用链表时缺少预取将是主要的性能问题。
正如你猜测的那样,我很少使用链表(std::list
),因为有利的应用程序数量很少而且很少。通常,对于大型且昂贵的复制对象,通常最好只使用指针向量(基本上与链接列表具有相同的性能优势(和缺点),但内存使用量较少(用于链接指针)并且如果需要,您可以获得随机访问功能。)
我能想到的主要情况是,链接列表胜过动态数组(或像std::deque
这样的分段动态数组)是需要经常在中间插入元素的时候(不是结束)。但是,当您保持排序(或以某种方式排序)元素集时,通常会出现这种情况,在这种情况下,您将使用树结构来存储元素(例如,二叉搜索树(BST)),不是链表。通常,这样的树使用动态阵列或分段动态阵列(例如,高速缓存不经意的动态阵列)内的半连续存储器布局(例如,广度优先布局)来存储它们的节点(元素)。
答案 3 :(得分:1)
是的,C++
或任何其他语言都是如此。动态数组是一个概念。 C ++有vector
的事实并没有改变理论。 C++
中的向量实际上在内部调整大小,因此这项任务不是开发人员的责任。使用vector
时,实际成本并没有神奇地消失,只是将其卸载到标准库实现中。
答案 4 :(得分:1)
std::vector
使用动态数组实现,而std::list
实现为链接列表。使用这两种数据结构存在权衡取舍。选择最适合您需求的那个。
正如您所指出的,动态数组如果已满,则可能需要花费更多时间添加项目,因为它必须自行扩展。但是,访问速度更快,因为它的所有成员都在内存中组合在一起。这种紧密的分组通常也使其更易于缓存。
链接列表不需要永远调整大小,但遍历它们需要更长的时间,因为CPU必须在内存中跳转。
答案 5 :(得分:0)
这让我想知道这对c ++是否正确,因为有一个矢量类会在插入新元素时自动调整大小。
是的,它仍然有效,因为vector
调整大小是一项可能很昂贵的操作。在内部,如果达到了向量的预分配大小,并且您尝试添加新元素,则会发生新分配,并将旧数据移动到新的内存位置。
答案 6 :(得分:0)
vector :: push_back - 在末尾添加元素
在向量的末尾添加一个新元素,在其当前的最后一个元素之后。 val的内容被复制(或移动)到新元素。
这有效地将容器大小增加1,如果 - 并且仅在新的向量大小超过当前向量容量时,则会自动重新分配已分配的存储空间。
答案 7 :(得分:0)
http://channel9.msdn.com/Events/GoingNative/GoingNative-2012/Keynote-Bjarne-Stroustrup-Cpp11-Style
跳至44:40。根据视频中的说明,Bjarne本人应尽可能std::vector
std::list
std::vector
。由于std::vector
将所有元素彼此相邻存储在内存中,因此它具有在内存中缓存的优势。这对于添加和删除std::list
中的元素以及搜索也是如此。他声称std::vector
比std::stack
慢50-100倍。
如果你真的想要一个堆栈,你应该使用{{1}}而不是自己创建。