想象你是一家快递仓库的调度员。仓库里有一排用水泥固定在地面的储物柜,柜门从 0 号开始依次编号,每个柜子宽窄一样、紧紧挨在一起。你知道排头在哪,也知道每格多宽,所以顾客要取 3 号还是 97 号的包裹,你都能一步走过去打开柜门——这就是数组的 O(1) 随机访问。但这排柜子一旦浇筑成型,长度就永远固定了。今天包裹爆仓,你只能另找一块更大的场地,雇人把包裹一个个搬进新柜,再拆掉旧柜。聪明的经理发现,与其每次满仓只加一格柜子,不如直接翻倍扩建,这样把所有“搬家成本”摊到每次存放上,每次存包的平均开销几乎没有变化。更妙的是,柜子紧挨着,搬运工推着小车顺路一趟就能取完一排包裹;而包裹少到一定程度时,及时搬回小场子还能省下租金。这套逻辑,就是数组与动态数组的完整故事。
对 AI 从业者而言,数组与动态数组是向量、张量、嵌入表等一切数据流的底层容器;理解它的访问成本与扩容规律,才能写出真正高效的推理与训练代码。
目录
可视化演示
展示数组连续内存与O(1)随机访问、动态数组倍增扩容过程、以及均摊分析append常数复杂度证明。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+_captions.memory+'
数组基础特性:一排钉死的编号柜
还记得仓库里那排水泥浇筑的柜子吗?施工队进场时,先量好场地,再按统一宽度一个挨一个地浇筑。柜子之间的间距分毫不差,编号从 0 开始递增。这样的设计有个巨大的好处:你不需要挨个查看柜门上的号码,只要知道排头的坐标和每个柜子的宽度,心里一算就能直达任意一个柜子。这就是数组最核心的直觉——“算着走”而不是“找着走”。
把这个场景搬到计算机里:内存就是一长条连续编址的“仓库走廊”。数组在创建时,向操作系统一次性申请一段连续的内存空间,并记录下这段空间的起始地址。与水泥柜排一样,数组有三个硬性约束:空间连续、长度固定、所有元素占用的字节数相同(即元素类型统一)。正因为这三个约束,访问第 i 个元素才能变成一次纯粹的数学计算。
数组的随机访问之所以是 O(1),秘密全在下面这个寻址公式里。它把“找柜子”变成“算地址”,一次乘加搞定:
水泥柜排的优点是“一步直达”,代价却是长度固定。一旦浇筑成型,想多塞一个柜子就得推倒重建。包裹少的时候还好,可一旦爆仓,你就必须面对“搬家”这个新问题了——这正是动态数组登场的理由。
- base(起始地址)数组在内存中的起点,相当于柜子排最左端那根定位柱。
- i(元素下标)数组下标从 0 开始,i 就是“第 i 个柜子”的编号。
- elementSize(元素宽度)每个元素占用的字节数,比如 int 占 4 字节,相当于每个柜子的统一宽度。
- 整体寻址把“起点 + 偏移量”算出来,直接定位到目标内存。
动态数组原理:会自己扩建的移动柜墙
爆仓那天,经理当机立断:在旁边空地搭一排更大的新柜,把旧柜里的包裹全部搬过去,然后拆掉旧柜。整个流程分四步——找新场地、搬包裹、拆旧柜、更新门牌记录。而且经理规定:以后每次快满仓时都自动重复这套流程,不用你操心。这就是动态数组:一个“长了脑子”的数组,它记得自己还剩多少空位,满了就自动扩容。
动态数组内部维护三个关键信息:一块指向堆内存的指针(相当于新场地的地址)、当前容量 capacity(柜子总数)、当前长度 size(已用柜子数)。每次执行 append(尾部追加包裹)时,它先检查 size 是否等于 capacity。如果没满,直接把包裹放进 data[size],然后 size 加一;如果满了,就触发扩容流程。
扩容的具体动作是:先向操作系统申请一块更大的连续内存(新场地),把旧内存里的 size 个元素逐个拷贝过去(搬包裹),释放旧内存(拆旧柜),最后更新指针和 capacity(改门牌)。关键在于扩容时容量翻倍,而不是只加一格:
等到新柜搭好,你会惊喜地发现:虽然每次搬家都要折腾一番,但搬家的频率越来越低。4→8 搬一次,8→16 搬一次,16→32 搬一次……包裹越多,两次搬家之间的“安稳期”越长。这就是倍增策略的巧妙之处——把昂贵的搬家摊薄到漫长的日常存取里。
- oldCapacity(旧容量)当前柜子的总格数,即包裹放满时触发扩容的临界点。
- 倍增因子乘以 2,让扩容次数随着规模增长呈指数级下降。
- newCapacity(新容量)新申请内存块的格数,之后 size 可以继续增长到这个新上限。
均摊分析:搬家费平摊到每次存件
经理要求你记账:每次搬家雇搬运工都要花一大笔钱,这钱该算在哪个包裹头上?如果算在搬家当天的包裹上,那天的成本高得吓人;如果把这笔钱平摊到从上次搬家到这次搬家之间存进来的所有包裹上,每单成本其实微乎其微。这种“把偶尔的高额开销摊到日常操作上”的记账方式,就是均摊分析的思想。
均摊分析不纠结某一次操作的最坏情况,而是看“连续执行 n 次操作,总成本除以 n 的平均值”。对动态数组来说,append 偶尔会触发 O(n) 的扩容拷贝,但绝大多数时候只是 O(1) 的原地写入。我们需要证明:把 n 次 append 的总成本加在一起,平均下来仍然是常数。
假设容量从 1 开始,每次翻倍。第 k 次扩容时,容量从 2^k 变成 2^{k+1},需要搬运 2^k 个元素。把这些搬运量加起来,恰好是一个几何级数:
对比一下反面教材:如果每次满仓只加 1 格(增量扩容),那么第 m 次扩容要搬 m 个元素,n 次操作的总搬运量约为 n²/2,均摊下来是 O(n)——每存一个包裹平均要搬半个仓库的东西,这显然不可接受。所以倍增不是偷懒,而是数学上最优的选择,它用“偶尔大搬家”换来了“永远很轻松”。
- 搬运成本序列每次扩容搬运的元素数量依次是 1、2、4、8……构成几何级数。
- 几何级数求和前 k 项的和等于 2 的 (k+1) 次方减 1。
- 总成本上界因为 2^{k+1} < 2n,所以总搬运量永远小于 2n。
- 均摊结论总成本除以操作次数 n,得到常数级别。
空间局部性:紧挨的柜子搬运起来快
扩容问题解决了,经理又发现一个省钱秘诀:由于柜子紧挨在一起,搬运工推着小车从 0 号柜走到 97 号柜,一路顺过去就能把整排包裹搜刮干净,小车上的“临时货架”(缓存)装得满满当当。反过来,如果柜子东一个西一个散落在仓库各处,搬运工每取一件都要推车跑一趟远路,效率天差地别。这正是空间局部性的威力。
CPU 访问内存时,并不是一个字节一个字节地取,而是以缓存行(cache line,通常 64 字节)为单位,一次性成块加载到高速缓存里。数组元素在内存中连续排列,意味着当你访问 data[i] 时,CPU 已经把 data[i] 附近的几十个字节一起搬进了缓存。接下来访问 data[i+1]、data[i+2] 时,数据已经在缓存里等着了,无需再访问慢速主存。
假设数组元素是 4 字节的 int,那么一个 64 字节的缓存行正好能装下 16 个元素。顺序遍历数组时,每拉取一行缓存,16 次访问全部命中:
对比链表:链表的每个节点是单独申请的内存,地址七零八落。访问链表第 i 个节点时,往往每走一步都要重新拉一次缓存行,利用率可能跌到几十分之一。这也是为什么在 AI 场景中,处理连续的张量数据时数组几乎总是首选——连续本身就是一种性能优化。
- 缓存行大小CPU 与内存之间的传输单位,常见为 64 字节。
- 每行可装元素个数一个 int 占 4 字节,一行缓存能装 16 个元素。
- 利用率顺序访问时,缓存行里的 16 个元素全都会被用到。
缩容策略:包裹少了就搬进小仓
旺季过去,包裹一天天减少。经理看着那排巨大的柜子直皱眉:场地租金是按面积算的,空着 3/4 的柜子纯属烧钱。于是他定下规矩——当包裹数量降到某个阈值以下,就主动搬回小场地,把容量缩下来。但这里有个陷阱:如果每次少一个包裹就立刻缩一次容,就会陷入“刚搬完小仓又爆仓、刚搬完大仓又空旷”的来回折腾,搬运费反而更贵。这种现象叫抖动(thrashing)。
正确的缩容策略必须与扩容策略“对称”。既然扩容是“满到 100% 时翻倍”,那么缩容就应该“空到 25% 时减半”。也就是说,当元素个数 size 小于等于容量的四分之一时,才把容量缩为原来的一半:
这个“四分之一阈值”是工程上的经典约定,比如 Java 的 ArrayList 和 Rust 的 Vec 在缩容时都参考了类似的逻辑。它体现了一种重要的思维:在时间(搬运开销)与空间(浪费的内存)之间做权衡——不能只顾一头,而是找一个让两头都舒服的平衡点。
- 触发条件只有元素个数降到容量的四分之一以下才考虑缩容。
- 缩容动作容量更新为原来的一半,与扩容的“翻倍”形成对称。
- 对称性收益缩为一半后,size 至少是容量的 1/2,距下次扩容(size = cap)和下次缩容(size ≤ cap/4)都还有充足缓冲,避免来回搬家。
复杂度对比:不同操作的成本账本
现在给仓库的各类操作列一张成本账本。取某号柜的包裹:一步直达,O(1)。往尾部存包裹:平时放下就走,偶尔搬一次家,均摊 O(1)。往头部塞包裹:所有柜子都要往后挪一格,O(n)。在乱柜里翻找某个特定包裹:最坏得翻遍所有柜子,O(n)。这张账本决定了数组适合什么场景、不适合什么场景。
回到仓库的比喻:数组就像那排整齐划一的水泥柜,适合“按号取件、尾仓入库”的高效流水线;动态数组则是它的进阶版——水泥柜学会了自动扩建、自动缩编,却依然保持整齐排列的所有优点。理解了这套存储逻辑,你就掌握了几乎所有高性能数据结构的底层地基:向量、张量、嵌入表、日志缓冲……它们都是这排“会自我扩建的编号储物柜”在不同场景下的化身。
- 随机访问按下标直接寻址,与长度无关,永远是常数时间。
- 尾部追加普通 append 是 O(1),触发扩容的那次是 O(n),但均摊下来仍是常数。
- 头部插入插入位置之后的所有元素都要后移一位,成本随长度线性增长。
- 线性查找无序数组中找一个值,最坏要扫描全部元素。
AI 应用场景
嵌入向量表:推荐系统的 O(1) 取数
阶段:特征工程 / 模型推理
解决的问题:推荐系统要把用户 ID、商品 ID 映射成稠密向量(嵌入)。传统上用哈希表存,但哈希冲突和指针跳转会拖慢高并发推理;用连续数组按下标(ID 映射后的整数索引)直接取向量,一次寻址就到,配合缓存行批量读取,能把吞吐提升一个量级。
具体案例:某短视频推荐服务,用户 ID 经哈希映射到固定区间后,用动态数组存储 1000 万条 128 维 float 向量。推理时按索引连续读取用户与候选视频的嵌入,再送入点击率模型。数组的 O(1) 访问让单机 QPS 从 2 万提升到 6 万,且无锁无哈希冲突。
日志写入缓冲:数据库的 append-only 队列
阶段:数据管道 / 训练数据预处理
解决的问题:训练样本流水线需要把海量日志先写入缓冲区再批量落盘。如果用链表存日志,节点分散、缓存不友好;用动态数组做 append-only 缓冲,尾部追加均摊 O(1),扩容时批量拷贝,还能一次性把连续内存整块刷盘,显著减少磁盘 IO 次数。
具体案例:某大模型训练平台的数据采集服务,每秒钟涌入 10 万条样本日志。采集端用动态数组缓冲 1 秒的日志后批量写入对象存储。由于缓冲区分摊了扩容成本,且顺序遍历时缓存命中率高,CPU 占用比此前用链表方案下降了 40%。
图像像素缓冲区:卷积算子的缓存友好遍历
阶段:图像预处理 / 视觉模型推理
解决的问题:图像在内存中就是一张连续的一维数组(按行存储的像素值)。做卷积、归一化、数据增强时,要按固定步长顺序扫描像素。数组的连续内存让每次缓存行加载都覆盖整行像素,而缩容策略则让视频流处理中动态增长的帧缓冲区不会长期浪费内存。
具体案例:智能安防摄像头在边缘设备上跑目标检测,视频帧缓冲区用动态数组管理。连续内存配合 NEON 向量化指令按行读取像素,预处理耗时从 12ms 降到 5ms,满足实时性要求。
Python 演示
# dynamic array demo: doubling expansion and quarter shrinkclass DynamicArray: def __init__(self): self.capacity = 4 self.size = 0 self.data = [None] * self.capacity self.copy_count = 0 def append(self, value): if self.size == self.capacity: self._resize(self.capacity * 2) self.data[self.size] = value self.size += 1 def pop(self): if self.size == 0: raise IndexError("empty array") value = self.data[self.size - 1] self.size -= 1 if self.size <= self.capacity // 4 and self.capacity > 4: self._resize(self.capacity // 2) return value def _resize(self, new_capacity): new_data = [None] * new_capacity for i in range(self.size): new_data[i] = self.data[i] self.copy_count += self.size self.data = new_data self.capacity = new_capacity def __getitem__(self, index): if 0 <= index < self.size: return self.data[index] raise IndexError("index out of range") def __len__(self): return self.size if __name__ == "__main__": arr = DynamicArray() for i in range(10): arr.append(i) print("elements:", [arr[i] for i in range(10)]) print("capacity:", arr.capacity, "size:", len(arr)) for _ in range(6): arr.pop() print("after 6 pops -> capacity:", arr.capacity, "size:", len(arr)) big = DynamicArray() for i in range(100000): big.append(i) print("100k appends total copy:", big.copy_count) print("amortized copy per append:", big.copy_count / 100000)
if self.size == self.capacity:判断柜子是否已满,满了才触发扩容。self._resize(self.capacity * 2)扩容时容量直接翻倍,而不是加一。for i in range(self.size): new_data[i] = self.data[i]把旧柜里的包裹逐个搬进新柜。self.copy_count += self.size记录搬运次数,用于验证均摊结论。if self.size <= self.capacity // 4 and self.capacity > 4:元素少于容量四分之一时才缩容,防止抖动。self._resize(self.capacity // 2)缩容只缩一半,与倍增对称。big.copy_count / 100000观察均摊值:10 万次 append 平均每次搬运约 2 个元素,印证 O(1)。
常见误区
误区我以为 append 满了之后,程序会在原来那排柜子后面直接加几格,数组还是原来的地址,只是变长了。
正解现实是,数组要求的“连续内存”很难在原地扩展——相邻地址可能已被别的数据占用。所以扩容必须“另找新场地”:申请一块更大的新内存,把所有元素拷贝过去,再释放旧内存。整个过程数组的起始地址会变,只是外部的使用方式不变而已。这就像搬家后你的门牌号变了,但快递员仍然只要说“放到我柜子里”就行。
为何常见:因为从使用者的 API 角度看,append 前后的代码一模一样,让人误以为底层也是原地操作的。
误区老师说 append 是 O(1),那就是每次都 O(1),一次都不会慢。
正解准确说法是“均摊 O(1)”。绝大多数 append 确实是放下就走,但偶尔触发扩容的那一次,需要拷贝 n 个元素,最坏是 O(n)。只是因为扩容越来越不频繁,把总成本摊到每次操作上才是常数。这就好比搬家那天累得半死,但把搬家费摊到两年里每天的房租上,每天几乎没涨。
为何常见:教材和面试都强调“均摊 O(1)”,很多人只记住了结论,忽略了“均摊”两个字的前提。
误区删一个元素就缩一次容,内存一直很紧凑,这样最节省空间。
正解这样做会引发灾难性的抖动:删一个就缩,紧接着 append 一个又扩容,一缩一扩之间来回搬运全部元素,时间成本远超省下的那点内存。正确做法是“滞后缩容”——等到 size 降到容量的四分之一再缩为一半,给波动留出缓冲。就像你不会因为今天少了一个包裹就把整个仓库退租,而是等空置率到了一个警戒线再换小仓。
为何常见:直觉上“省空间”是好事,但没意识到扩容和缩容本身都有搬运成本,频繁触发反而因小失大。
误区数组访问是 O(1),链表是 O(n),那数组全面碾压链表,啥场景都用数组。
正解数组只是“读取和尾插”快。头部插入要移动 n 个元素,是 O(n);链表头插只要改几个指针,是 O(1)。而且链表扩容不需要整块连续内存,节点可以分散在内存各处。缓存局部性上数组也占优,但数组付出的代价是头部操作慢、固定容量、缩扩容需要搬运。选择哪种结构,取决于你的操作画像——读多尾插多选数组,头插删多选链表。
为何常见:只对比了“随机访问”这一项指标,误把局部优势当成了全局优势。
面试题
Q1. 已知数组首地址为 base,每个元素占 8 字节,那么下标为 3 的元素地址是?
- A.base + 8
- B.base + 16
- C.base + 24← 正确答案
- D.base + 32
答案:C。数组下标从 0 开始,第 3 个元素实际上是跳过 3 个元素宽度,即 base + 3 × 8 = base + 24。选项 A 是第 1 个元素,B 是第 2 个元素,D 是第 4 个元素。就像从柜排起点走 3 个柜宽才是 3 号柜。
Q2. 动态数组容量从 1 开始,每次满时翻倍扩容,向其中连续插入 n 个元素(n 为 2 的幂),总共发生多少次元素拷贝?
- A.n
- B.n / 2
- C.n - 1← 正确答案
- D.log₂n
答案:C。扩容发生在容量 1→2、2→4、4→8……各搬运 1、2、4……直到 n/2 个元素,总搬运量 = 1 + 2 + 4 + … + n/2 = n - 1。这就是几何级数求和的直观体现。选项 D 只算了扩容次数,忘了每次搬运的量。
Q3. 关于动态数组扩容,下列说法正确的有?
- A.扩容需要分配新内存并逐个拷贝旧元素← 正确答案
- B.倍增策略使得 append 的均摊复杂度为 O(1)← 正确答案
- C.扩容时旧元素会被拷回原来的内存位置
- D.容量翻倍不会造成任何空间浪费
答案:AB。A 正确,扩容的四个步骤就是申请新内存、拷贝、释放旧内存、更新指针;B 正确,几何级数求和推出均摊 O(1);C 错误,新内存是另一块连续区域,不是原位置;D 错误,翻倍后可能有一半空位,这是空间换时间的代价。
Q4. 动态数组采用“四分之一缩容”策略,触发缩容的条件是?
- A.每删除一个元素就立刻缩容一半
- B.当容量缩到一半时把容量减为四分之一
- C.当元素个数降到容量的四分之一时,把容量缩为一半← 正确答案
- D.动态数组永不缩容
答案:C。四分之一缩容指 size ≤ capacity/4 时,capacity 减半。A 会引发抖动,B 描述顺序颠倒,D 会导致内存长期浪费。正确策略与倍增对称,兼顾空间利用与搬运成本。
Q5. 数组相比链表,最突出的优势是?
- A.任意位置插入都是 O(1)
- B.随机访问 O(1) 且顺序遍历缓存友好← 正确答案
- C.删除元素不需要移动数据
- D.容量可以无限增长
答案:B。数组连续内存带来两个核心优势:按下标寻址 O(1),以及空间局部性好、缓存命中率高。A、C 是链表的优势;D 错误,普通数组容量固定,动态数组扩容也需要搬运。
Q6. 关于均摊分析的描述,正确的是?
- A.均摊成本 = 总成本 ÷ 操作次数← 正确答案
- B.append 的最坏情况是 O(n),均摊是 O(1)← 正确答案
- C.均摊分析统计的是单次操作的最坏时间
- D.每次扩容只加一个格子的策略下,append 均摊仍为 O(1)
答案:AB。A 是均摊分析的定义;B 正确,触发扩容那一次是 O(n),但摊到 n 次操作上是 O(1);C 错误,均摊看的是平均而非单次最坏;D 错误,增量扩容总搬运量约 n²/2,均摊是 O(n)。
Q7. 某机器缓存行大小为 64 字节,数组元素为 4 字节的 int,顺序遍历时一个缓存行的有效数据利用率约为?
- A.25%
- B.50%
- C.100%← 正确答案
- D.与数组长度无关,恒为 6.25%
答案:C。64 字节缓存行正好装 16 个 int,顺序遍历时这 16 个元素全都会被访问,利用率接近 100%。这与数组长度无关。因为数组连续存放,CPU 拉一行缓存就能喂饱 16 次访问,这正是空间局部性的威力。
Q8. 以下哪些操作在数组上的最坏时间复杂度是 O(n)?
- A.在头部插入一个元素← 正确答案
- B.尾部 append 且恰好触发扩容← 正确答案
- C.通过下标访问任意元素
- D.在无序数组中线性查找一个值← 正确答案
答案:ABD。头部插入要移动所有元素,O(n);扩容那次 append 要拷贝 n 个元素,O(n);线性查找最坏扫全表,O(n)。C 是 O(1),这正是数组的招牌优势——按下标直达,无需遍历。
Q9. 动态数组“装满→扩容”的核心操作顺序是?
- A.在原内存上把元素往后推,腾出新空间
- B.分配新内存、拷贝旧元素、释放旧内存、更新指针和容量← 正确答案
- C.把元素转移到链表里继续存
- D.丢弃最早的元素,为新元素腾位
答案:B。动态数组扩容正是“找新场地、搬包裹、拆旧柜、改门牌”四步:分配新内存块,把 size 个旧元素拷贝过去,释放旧内存,再更新 data 指针与 capacity。A 不可行,因为相邻地址可能被别人占用;C、D 违背数组的连续存储语义。
Q10. 关于空间局部性与缓存,说法正确的有?
- A.数组元素紧挨存放,顺序访问能充分利用缓存行← 正确答案
- B.链表节点地址分散,缓存命中率通常低于数组← 正确答案
- C.随机访问数组与顺序访问数组的性能完全一样
- D.动态数组扩容后依然保持整块连续内存← 正确答案
答案:ABD。A 正确,连续内存让缓存行满载;B 正确,链表节点分散导致每次访问可能重新拉缓存;C 错误,随机访问会打乱缓存行利用,通常比顺序访问慢;D 正确,扩容的本质就是换一块更大的连续内存,连续性不变。