try ai
科普
编辑
分享
反馈
  • 动态数组

动态数组

SciencePedia玻尔百科
核心要点
  • 动态数组通过在调整大小时将容量乘以一个常数因子(几何增长)来实现高效增长,从而使插入操作的摊还成本达到 O(1)。
  • 摊还分析表明,偶尔的、高成本的扩容操作,其成本被大量的、低成本的简单插入操作分摊,从而使得长期平均成本是可预测的常数。
  • 为避免性能“颠簸”,动态数组必须使用滞后效应:缩容的触发条件(例如,使用率降至 1/4)必须不同于扩容触发条件的逆操作(例如,满员)。
  • 调整大小的一个关键副作用是迭代器失效,即在数组内存被重新分配后,指向数组元素的指针会变成悬空或不正确的指针,从而导致严重的错误。
  • 由于其连续的内存布局(空间局部性),动态数组在顺序访问方面比链表具有显著更好的 CPU 缓存性能。

引言

数组是计算的基石,因其能以瞬时、常数时间访问任何元素而备受推崇。然而,其固定的大小带来了一个根本性的冲突:当我们不知道将要处理多少数据时,该如何使用这种快速的结构呢?这个悖论由​​动态数组​​解决。它是一种巧妙的数据结构,既提供了列表的灵活性,又具备了连续内存块的底层性能优势。本文将揭开动态数组的神秘面纱,探讨如何在不牺牲效率的前提下,让一个“固定大小”的结构实现增长这一关键挑战。

本文的探索分为两个部分。首先,在“原理与机制”一章中,我们将剖析动态数组的引擎。我们会揭示为何朴素的加法增长策略会导致计算灾难,以及大胆的几何增长策略如何通过摊还分析这一优雅概念被证明是高效的解决方案。我们还将直面优雅缩容的实际挑战以及迭代器失效的潜在危险。随后,“应用与跨学科联系”一章将展示动态数组的无处不在,说明从简单的“撤销”命令、高性能算法,到复杂的科学模拟乃至计算机操作系统的核心,这一高效增长的基本原理是如何驱动着这一切的。

原理与机制

我们如何构建一个可以增长的“数组”?这个问题听起来似乎自相矛盾。数组的本质是一个连续的、固定大小的内存块。这种固定性是其最大优势的来源:如果你想要第一百万个元素,计算机不需要搜索它,只需简单地计算其地址(base_address + 1,000,000 * element_size)并立即跳转过去。这就是我们所说的 O(1)O(1)O(1) 或常数时间访问。但如果我们事先不知道需要多少元素怎么办?如果我们在收集传感器数据、记录用户行为或构建一个素数列表呢?我们需要数组的速度,但又需要一种能够按需扩展的灵活性。这正是​​动态数组​​要解决的悖论。

初次尝试:加法增长的谬误

让我们从最直接的想法开始探索之旅。我们有一个数组,它满了。我们该怎么办?我们分配一块新的、稍大一点的内存块,将旧块中的所有内容复制到新块中,然后添加我们的新元素。旧块则被丢弃。

但是新块应该大多少呢?最简单的想法是每次空间用完时,增加固定数量的槽位,比如 kkk 个。如果我们有一个容量为 100 的数组并且它满了,我们就创建一个容量为 100+k100+k100+k 的新数组。这看起来很合理,甚至很经济。我们只在需要时增加一点点额外的空间。

不幸的是,这个听起来合理的想法会导致灾难。让我们分析一下成本。大多数时候,添加一个元素是廉价的——我们只需将其放入下一个可用槽位。但每隔一段时间,我们就必须付出沉重的代价:复制所有现有元素。假设我们执行 nnn 次插入。当数组大小为 k,2k,3k,…k, 2k, 3k, \dotsk,2k,3k,… 时,就会发生扩容。我们需要复制的元素总数大致是一个算术级数的和:k+2k+3k+…k + 2k + 3k + \dotsk+2k+3k+…。对于大量的插入操作 nnn,这个和与 n2n^2n2 成正比。因此,完成 nnn 次插入的总工作量为 O(n2)O(n^2)O(n2)。这意味着每次插入的平均或​​摊还成本​​为 O(n)O(n)O(n)。随着数组变大,每次插入都变得越来越慢! 这完全不是我们想要的。我们看似经济的选择导致了计算上的破产。

几何飞跃:以乘法实现效率

加法增长策略的失败迫使我们更大胆地思考。如果每次调整大小时,我们不是增加一个固定的量,而是将容量乘以一个常数因子呢?比方说,我们将其加倍。当我们容量为 CCC 的数组满了,我们就重新分配一个容量为 2C2C2C 的新数组。

让我们来追踪一下这个过程。我们从一个小的容量开始,比如 4。我们添加元素。 1, 2, 3, 4... 数组满了。 为了添加第 5 个元素,我们必须调整大小。我们创建一个容量为 2×4=82 \times 4 = 82×4=8 的新数组,复制前 4 个元素,然后添加第 5 个。 现在我们可以顺利添加第 6, 7, 8 个元素。 为了添加第 9 个元素,我们再次调整大小。我们创建一个容量为 2×8=162 \times 8 = 162×8=16 的新数组,复制现有的 8 个元素,并添加第 9 个。 下一次调整大小要等到我们尝试添加第 17 个元素时才会发生。

注意到有趣之处了吗?调整大小的操作非常昂贵,但随着数组的增长,它们变得越来越不频繁。两次扩容之间的“间隔”每次都加倍。这感觉很有希望,但那些复制操作的成本是巨大的。复制 8 个元素是一回事,但复制一百万个呢?或者十亿个?我们是不是只是用一个问题换了另一个问题?这个策略真的高效吗?

会计师的技巧:摊还分析

要回答这个问题,我们需要引入算法分析中最优美的思想之一:​​摊还分析​​。让我们不要按单个操作来考虑成本,而是像会计师管理预算一样思考。

想象一下,每次插入操作都附带一笔小费用。一次不引起扩容的简单插入,其实际成本为 1 个单位(用于写入新元素)。我们将收取一笔费用,或者说​​摊还成本​​,比如 4 个单位。为什么是 4?我们稍后会看到。因此,对于一次廉价的插入,我们支付 1 个单位来完成工作,并将剩余的 3 个单位“信用”存入一个储蓄账户。我们对每次廉价的插入都这样做。储蓄账户的金额不断增长。

然后,不可避免地,我们会遇到一次扩容。假设数组有 nnn 个元素并且已经满了。我们需要执行一个昂贵的操作,成本为 nnn 个单位(用于复制)加上 1 个单位(用于新元素),总共 n+1n+1n+1 个单位。这是最坏情况下的成本。但是等等!我们的储蓄账户里有钱。我们能用它来支付这笔费用吗?

让我们看看一次扩容刚结束时数组的状态。它的使用率刚刚超过一半。例如,当我们从容量 CCC 扩容到 2C2C2C 时,我们有 CCC 个元素并多添加了一个,总共是 C+1C+1C+1 个元素在一个容量为 2C2C2C 的数组中。要再次填满它,我们需要再添加近 C−1C-1C−1 个元素。每一次廉价的插入都会将信用存入我们的储蓄账户。到下一次扩容发生时,我们能攒够钱吗?

答案是肯定的!对于一个增长因子 g>1g > 1g>1,可以证明一个常数摊还成本 c^\hat{c}c^ 就足够了。这个常数由一个优雅的公式给出: c^=2g−1g−1\hat{c} = \frac{2g-1}{g-1}c^=g−12g−1​ 对于我们把容量加倍的常见情况(g=2g=2g=2),摊还成本为 c^=2(2)−12−1=3\hat{c} = \frac{2(2)-1}{2-1} = 3c^=2−12(2)−1​=3。如果我们使用一个稍微保守一点的增长因子,比如 g=1.5g=1.5g=1.5,摊还成本为 c^=2(1.5)−11.5−1=20.5=4\hat{c} = \frac{2(1.5)-1}{1.5-1} = \frac{2}{0.5} = 4c^=1.5−12(1.5)−1​=0.52​=4。

这意味着,通过为每个操作收取一个小的、固定的费用,我们可以保证在灾难性昂贵的扩容操作发生时,我们总有足够的“信用”来支付。实际成本的剧烈波动——从非常便宜到非常昂贵——被平滑成一个可预测的、恒定的摊还成本。即使我们必须处理容量必须是整数这个棘手的现实(例如,通过取 ⌈1.5C⌉\lceil 1.5 C \rceil⌈1.5C⌉),这个优美的结论仍然成立。该分析是稳健的。

所以,几何增长策略不仅比加法增长策略好,而且是极好地、根本性地更好。它实现了插入操作的摊还 O(1)O(1)O(1) 成本,这是我们所能期望的最好结果。

从不同角度看问题

摊还常数时间的魔力也可以从其他角度来看。

  • ​​复制的一生:单个元素的故事:​​ 让我们关注我们插入数组的第一个元素的命运。它从一开始就在那里。每次数组扩容时,这个可怜的元素都会被复制。它会被复制一百万次吗?不会。相对于数组的最终大小 NNN,扩容的次数是对数级的。如果数组增长到十亿个元素,我们的第一个元素大约只会被复制 30 次(因为 230≈1092^{30} \approx 10^9230≈109)。后面插入的元素被复制的次数会更少。没有哪个元素会承担不合理的负担。

  • ​​所有工作的总和:​​ 如果我们把 NNN 次插入过程中所有复制操作的总数加起来,我们会发现总数与 NNN 成正比,而不是 N2N^2N2 或更差。这证实了我们的聚合分析:总工作量是线性的,所以每次操作的平均工作量是常数。

缩容困境:如何优雅地收缩

那么删除元素呢?如果我们删除了许多元素,我们的数组可能会变得大部分是空的,浪费大量内存。制定一个缩容规则似乎是很自然的。例如,如果数组的使用率降至四分之一,我们可以将其大小调整到一个更小的容量。

但这里有一个微妙的陷阱。假设我们的增长因子为 α=2\alpha=2α=2,并且我们决定在数组半满时缩容,即缩减到其一半大小。现在考虑一个正好满的数组。

  1. 我们添加一个元素。​​砰!​​ 数组大小加倍。现在它的使用率刚过一半。
  2. 我们删除同一个元素。​​砰!​​ 数组现在正好半满,触发缩容,回到原来的大小。

我们回到了起点,但我们刚刚执行了两次极其昂贵的复制操作。一个简单的压入和弹出序列就可能导致系统在扩容和缩容之间剧烈颠簸。

解决方案非常简单:我们需要引入一个间隙,即​​滞后效应​​。缩容的条件必须比扩容条件的逆操作更严格。对于增长因子 α\alphaα 和缩容因子 σ\sigmaσ,我们必须确保 σ>α\sigma > \alphaσ>α。例如,一个常见且稳定的策略是,当数组满时将容量加倍(α=2\alpha=2α=2),但仅当使用率降至四分之一时才将容量减半(σ=4\sigma=4σ=4)。这确保了在一次扩容操作之后,你必须删除许多元素才会触发缩容;而在一次缩容之后,你必须添加许多元素才会触发扩容。这个间隙可以防止颠簸,并使删除操作的摊还成本也保持为常数。

现实的残酷:指针、拷贝和悬空指针错误

我们讨论的原理很优雅,但它们在现实世界中的应用伴随着一些重要的细节。

  • ​​拷贝的是什么?指针 vs. 数据:​​ 我们的分析表明摊还成本是 O(1)O(1)O(1)。但 O(1)O(1)O(1) 的是什么?它相对于元素数量是常数,但实际时间取决于拷贝一个元素的成本。如果我们的数组存储的是简单的整数,那成本很小。但如果它存储的是指向堆上大型复杂对象的指针呢?

    • ​​浅拷贝​​(策略 P)意味着我们只拷贝指针。成本很小,与指针大小 cpc_pcp​ 成正比。每次压入的摊还成本是 Θ(cp)\Theta(c_p)Θ(cp​)。
    • ​​深拷贝​​(策略 D)意味着在调整大小时,我们不仅拷贝指针,还复制它们指向的大型对象。成本巨大,与指针大小加上对象大小成正比,即 cp+Lcbc_p + L c_bcp​+Lcb​。每次压入的摊还成本是 Θ(cp+Lcb)\Theta(c_p + L c_b)Θ(cp​+Lcb​)。 摊还分析的美妙之处在于,无论哪种情况,每次压入的成本都是常数;然而,这个“常数”的大小可能因拷贝语义的不同而有天壤之别。
  • ​​别跟踪那个指针!迭代器失效的危险:​​ 也许动态数组扩容机制在现实世界中最危险的后果是​​迭代器失效​​。想象一下你有一个指向动态数组中某个元素的指针(一个“迭代器”)。一切安好。然后,有人向数组中添加了另一个元素,而这恰好触发了一次扩容。动态数组分配了一整块新的内存,将所有元素复制过去,并释放了旧的内存块。

你的指针现在指向了被释放的内存。它成了一个​​悬空指针​​。使用它将导致未定义行为、程序崩溃以及极难追踪的错误。即使是一次不导致扩容的插入或删除也可能成为问题。如果你在数组的开头插入一个元素,所有后续元素都会向后移动。你指向第 5 个元素的指针现在指向了原本是第 4 个元素的位置。相对于你正在追踪的逻辑元素,它不再有效。

这种脆弱性是我们为换取动态数组的连续内存和缓存友好性能而付出的代价。创建真正“稳定”的迭代器的唯一方法是增加一个间接层,例如,让数组存储指向永久存在于堆上的对象的指针。这使得迭代器变得稳定,但为每次访问都引入了额外的内存查找成本。正如工程学中常说的那样,这总是一场权衡的游戏,而理解这些基本机制让我们能够明智地进行这场游戏。

应用与跨学科联系

既然我们已经拆解了动态数组,并了解了其引擎的工作原理——即几何增长这一巧妙的技巧为我们带来了摊还常数时间性能——现在就让我们来实际体验一下吧。这个看似简单的装置会出现在哪里?你会发现,答案是无处不在。一个能够高效增长的数组,其原理是如此基础,以至于它几乎出现在现代计算的每一个抽象层级。从将你从失误中拯救出来的“撤销”按钮,到模拟我们宇宙的复杂仿真,动态数组的影子无处不在。这证明了一个简单而优雅的思想所蕴含的强大力量。

日常软件中无形的支柱

我们日常使用的软件中许多最熟悉的功能都依赖于动态数组的逻辑。想一想文本编辑器或图形程序中的命令历史记录。你采取的每一个动作——输入一个词、画一条线、应用一个滤镜——都是一个命令。这些命令按顺序存储,形成一个你工作的线性时间线。这正是动态数组的完美用武之地。

当你按下“撤销”时,程序在这个数组中后退一个位置。如果你按下“重做”,它就前进一步。但如果你撤销了几个步骤,然后执行了一个新的动作会发生什么?你已经撤销的旧的“未来”现在变得无效了;你创建了一个新的历史分支。为了维持一个简单的线性时间线,软件必须丢弃旧的、可重做的命令。在动态数组中,这是一个非常高效的操作:你只需将数组的逻辑“末端”移动到当前位置,从而在常数时间内有效地截断它,然后追加新命令。被作废的未来瞬间干净地消失了,为新的未来让路。这种一次性未来的模式也出现在另一个熟悉的地方:你的网页浏览器的“前进”历史记录。当你按后退按钮时,你刚离开的页面被添加到一个“前进”列表中。但一旦你点击一个新链接,整个前进历史就会被清除,就像旧的重做历史被截断一样。

然而,理解一个工具意味着不仅要了解它的优点,还要了解它的局限性。如果我们试图用一个动态数组来逐个字符地存储一个文档的文本本身,会怎么样?这里,我们就会遇到麻烦。虽然在末尾追加文本很快,但在行的中间插入一个字符却是一项成本高昂的操作。为了腾出空间,数组中每一个后续字符都必须向右移动一个位置。如果你在一个长段落的中间打字,这可能意味着每次按键都要移动数千个字符。经过多次这样的编辑,总成本会变得极其高昂,与插入次数呈二次方关系。这揭示了为什么像 ropes 或 piece tables 这样将文本分解成更小、更易于管理块的专门数据结构会被用于高性能文本编辑器中。动态数组,尽管在管理历史记录方面大放异彩,但却是管理文本本身的错误工具——这是算法设计中一个至关重要的教训。

程序员的乐高积木

除了我们每天看到的面向用户的特性外,动态数组也是程序员工具箱中最值得信赖的工具之一,是一块用于构建更复杂机器的多功能乐高积木。它密集存储和高效追加的结合使其成为一个理想的组件。

一个经典的例子是构建双端队列(或 deque),这种结构允许你从前端和后端添加和移除元素。你该如何构建它?一个优雅的解决方案是使用两个动态数组,像书挡一样相对放置。一个数组处理队列的“前端”(以相反顺序存储元素),另一个处理“后端”。添加到前端或后端只是在相应数组上进行快速的追加操作。巧妙之处在于当你试图从一个空的一端弹出一个元素,而另一端是满的时候。例如,如果前端数组是空的而你请求一个元素,系统会执行一次“再平衡”操作:它会取用那个大的后端数组,将其一分为二,然后用它来重建前端和后端两个数组。这次再平衡操作可能成本很高,但它为接下来大量的廉价操作做好了准备。这是摊还分析在实践中一个优美而具体的展示。

另一项算法艺术是将动态数组与哈希表结合起来,创建一个可以在期望常数时间内插入、删除和检索一个真正随机元素的数据结构。动态数组至关重要,因为其连续、无间隙的存储允许我们通过简单地生成一个随机索引在 O(1)O(1)O(1) 时间内挑选一个随机元素。但这种密集存储使得删除操作变慢。解决方案是什么?当你需要删除位置 iii 的元素时,你不需要移动它之后的所有元素。相反,你取数组中的最后一个元素,将它移动到位置 iii 的槽位,然后将数组缩小一。这个“与末尾交换”的技巧保持了数组的连续性。哈希表的作用是跟踪每个元素的位置,这样你就可以在常数时间内找到它和它的替代品。这是两个简单结构巧妙结合的杰作,创造出比任何单一结构都更强大的东西。

也许该领域最深远的应用来自于将抽象的数据结构与计算机硬件的物理现实联系起来。想象一下用邻接表来表示一个社交网络,其中每个人都有一个他们的朋友列表。这个朋友列表应该是链表还是动态数组?从渐进分析的角度来看,遍历列表花费的时间相同。但在现实世界中,动态数组通常要快得多。为什么?答案是 CPU 缓存。动态数组将其元素存储在一个单一的、连续的内存块中。当 CPU 获取一个元素时,它也会自动将其邻居加载到高速缓存中,预期你接下来会需要它们。这种现象被称为​​空间局部性​​,意味着后续的内存访问会非常快。而链表的节点可能散布在内存各处,享受不到这样的优势。遍历它涉及到“指针追逐”,这是一系列缓慢、不可预测的内存跳转,不断地错过缓存。这一洞见——数据布局至关重要——是连接软件和硬件世界的关键桥梁,也是支持动态数组的有力论据。

驱动科学与系统

动态数组的影响力超越了通用编程,延伸到科学计算和系统计算的结构中,在这些领域,管理大量不可预测的数据是核心挑战。

在科学计算中,动态数组是表示多项式的自然方式。xix^ixi 的系数就存储在数组的索引 iii 处。当你将两个次数分别为 nnn 和 mmm 的多项式相乘时,你会得到一个次数为 n+mn+mn+m 的新多项式。计算乘积的算法会逐一生成新系数,从 c0c_0c0​ 到 cn+mc_{n+m}cn+m​。这需要一个可以增长以容纳最终多项式的结果容器——这正是动态数组的完美应用场景。我们研究的摊还分析向我们保证,即使结果数组多次调整大小,复制元素的总开销仍然与最终大小成正比,而不是呈爆炸性增长。

这个原理可以扩展到更复杂的问题。物理学、工程学和经济学中的许多模拟都涉及巨大的稀疏矩阵——即大部分由零填充的矩阵。为了节省内存,我们只存储非零值。但是,如果非零值的模式随时间变化,就像在模拟相互作用的粒子时那样,该怎么办?我们需要一种动态的稀疏矩阵格式。在这里,动态数组再次充当了构建块。一种方法使用并行的动态数组来存储非零元素的坐标和值,使用惰性删除(墓碑)和定期压缩来高效地处理变化。另一种先进技术,“分块 CSR”,为每个矩阵行提供其自己的小型动态数组集合,将更新局部化并防止全局数据重排。这些方案展示了动态数组概念的模块化特性,将其原理应用于解决计算科学中高度专业化的问题。

动态数组的踪迹将我们引向更深层次,直至计算机操作系统的基础。当你的程序请求内存时,操作系统是如何提供的?在许多系统中,进程的主要内存区域,即堆,是通过一种类似于巨大动态数组的机制来管理的。当堆空间耗尽时,内存分配器会发出一个系统调用(如 sbrk 或 mmap)来向操作系统请求一个更大的连续块,通常按几何因子增长该区域。这是一个美妙的递归:我们程序中的动态数组是建立在一个内存管理系统之上的,而这个系统在其核心处,正遵循着完全相同的几何增长规则。

最后,让我们考虑一个动态数组的唯一弱点——其最坏情况下的调整大小时间——成为关键危险的领域:实时系统。一个音频缓冲区必须向声卡提供连续的样本流,这是动态数组连续内存的理想应用场景。但如果缓冲区在播放期间需要调整大小怎么办?调整大小的操作可能需要几毫秒,冻结音频线程并导致可听见的“毛刺”。这是不可接受的。解决方案是一项巧妙的系统工程,它绕过了这个问题。当需要调整大小时,一个低优先级的后台线程会分配新的、更大的缓冲区,并开始复制旧数据。实时音频线程不受影响。一旦复制完成,音频线程执行一个单一的、瞬时的、无毛刺的操作:一次原子指针交换,将其注意力从旧缓冲区重定向到新缓冲区。这种策略让我们两全其美:既获得了处理所需的连续内存,又避免了因调整大小而带来的不可预测的延迟风险。

从一个简单的撤销命令到操作系统的核心,动态数组不仅仅是一个数据结构。它是一种高效增长的基本原则,一种在计算的每一层都回响的模式,为在一个未来永远未知的世界中管理资源提供了一种简单、强大且通用的方式。