☰
手写动态数组:从固定数组痛点看扩容机制与C语言实现
2026/10/10 3:31:04 网站建设 项目流程

从静态数组的痛点说起:为什么必须引入"扩容"这个机制

1.1 固定数组的先天不足

我最早开始写代码的时候,用的就是C语言里的固定数组。遇到需要存一组学生成绩、一批网络数据包ID这种场景,第一反应就是int arr[100]。当时觉得够用了,直到某天需要处理的数据量超过了100,程序直接越界写,把相邻内存里的数据全干翻了,调试了整整一个下午才定位到问题。

这就是固定数组最大的矛盾:你必须在编译期就决定好数组长度,但实际运行时的数据量是个未知数。开大了浪费内存,开小了就越界崩溃。更麻烦的是,数组一旦定义,长度就焊死了,没有任何补救手段。很多刚入行的朋友会想"那我把数组开到足够大不就行了",比如定义个int arr[100000]。这在 demo 里没问题,但你是用100KB的固定开销去赌数据量永远不会超过这个值,一旦业务膨胀、数据量翻倍,照样炸。生产环境里这种写法会被运维骂死。

另一个痛点体现在业务代码的拼装上。你要写一个往数组末尾追加数据的函数,用固定数组就得这样:

void append(int arr[], int* len, int max_len, int value) { if (*len >= max_len) { // 只能报错或者忽略,没有别的办法 return; } arr[*len] = value; (*len)++; }

每次调用都要把max_len传进来,每个函数都要做一次"是否已满"的判断。如果哪次忘了判断,就是一个隐性炸弹。这种代码写多了,你会非常清楚地意识到:数组的容量管理不该是业务代码操心的事情,它应该被封装成一个独立的、自动扩容的组件。这也是为什么几乎所有现代语言的标准库都有动态数组——Java 的ArrayList、C++ 的vector、Python 的list,底层本质都是同一个东西。

1.2 动态数组要解决的核心矛盾

动态数组要解决的核心矛盾其实就一句话:既要拥有数组随机访问的高效特性,又要摆脱固定长度的束缚。数组随机访问是 O(1) 的,因为data[i]的地址就是data + i * sizeof(T),这是指针运算直接算出来的,没有任何中间查找过程。这个优势太宝贵了,链表虽然插入删除灵活,但访问第 i 个元素必须从头遍历,最坏 O(n)。所以动态数组的设计目标就是:保留连续内存的随机访问能力,同时在元素个数超过当前容量时自动扩大存储空间。

围绕这个核心,衍生出三个总要处理的子问题:

  • 什么时候扩容?需要一个标记当前元素个数和当前容量的机制。
  • 扩多大?扩容倍率直接决定后续插入的时间开销总和。
  • 扩容时怎么搬数据?新内存分配好了之后,旧数据怎么移动,旧空间怎么释放。

这三个问题串联起来,就是动态数组的底层逻辑骨架。把这个骨架在我的个人项目里搭过一遍之后,再去看标准库的源码,很多设计决策一下就懂了,比如为什么ArrayList的扩容不直接用newCapacity而是有一堆位运算,为什么 C++vector在插入时要区分"有能力容纳"和"需要重新分配"两条路径。

1.3 你其实天天在用动态数组

写业务代码的朋友可能觉得手写动态数组是"造轮子",没必要。但我想说,所有标准库的容器类,底层都是这套逻辑加上内存分配策略的包装。Java 里用ArrayList做数据缓存,往里面add几百万条数据,你看到的是从容的不断追加,背后其实是无数次"容量检查 -> 扩容 -> 搬移数据"的循环。Python 的list.append一样,它内部也维护着 allocated 和 ob_size,扩容策略大致是 0、4、8、16、32、64……这样倍增上来的。

理解这些,你在排查线上问题时会多一个视角。举个例子,往ArrayList里插入大量数据时卡顿,不少人第一反应是"GC 问题"或"IO 瓶颈",实际上可能是扩容造成的大规模内存拷贝,每次扩容都是一次 O(n) 的批量搬运。如果你清楚扩容逻辑,就能预判这种性能拐点,提前用构造器指定初始容量。另一个收益是面试。手写动态数组是各大厂手撕代码的高频题,面试官不是要你背代码,而是想看你有没有想到 size 和 capacity 的分离、扩容倍率选型、搬移元素的顺序、以及均摊复杂度这些点。把这些底层逻辑吃透,面试基本稳。

2. 结构设计的关键:size 和 capacity 如何配合决定扩容时机

2.1 三个核心字段:data、size、capacity

动手设计一个动态数组,结构体只需要三个字段,但每个字段的职责必须分清楚:

typedef struct { int* data; // 指向连续内存的指针,真正存数据的地方 int size; // 当前已用元素个数,即逻辑长度 int capacity; // 当前内存能容纳的最大元素个数 } DynamicArray;

新手最容易搞混size和capacity。用生活化的类比:capacity是你的衣柜总格数,size是已经挂进去的衣服件数。衣柜总格数在买家具时就定死了,但你的衣服会越来越多,总格数不够的时候就需要换个更大的衣柜,把衣服一件件搬过去——这就是扩容。搞清楚这两个概念的区别,是理解动态数组一切后续逻辑的前提。

为什么不能只用size一个变量,不够了直接换大的?因为"当前有没有存满"这件事必须要有capacity来回答。只靠size只能知道存了几个元素,没法知道还能不能再插入。每次插入都去计算内存大小也不现实,维护一个capacity字段就相当于把"当前容量"这个信息缓存下来了,检查一次 O(1)。

2.2 初始化的细节与内存布局

初始化函数是第一个容易出问题的点。一个干净的初始化应该把三件事都做对:分配初始内存、设置初始容量、把 size 清零。初始容量选多少?我看的很多教科书代码喜欢用4或8,这不是随便拍的数。初始容量太小会导致早期频繁扩容;太大又浪费内存,一个只存两三个数据的动态数组占了几百字节说不过去。通常取 4~16 之间是比较合理的,具体的值可以根据应用场景调整。初始化其实有两派做法:一派是懒分配,data先指向 NULL,capacity存0,等到第一次插入时再分配内存;另一派是预先分配一小块内存。我偏向后者,因为懒分配在判断扩容条件时要额外处理capacity == 0的情况,代码多一个分支,不如一开始就分配好,简洁且不容易漏判断。

#define INITIAL_CAPACITY 8 DynamicArray* da_create(void) { DynamicArray* arr = (DynamicArray*)malloc(sizeof(DynamicArray)); if (!arr) return NULL; arr->data = (int*)malloc(INITIAL_CAPACITY * sizeof(int)); if (!arr->data) { free(arr); return NULL; } arr->size = 0; arr->capacity = INITIAL_CAPACITY; return arr; }

注意到malloc之后必须先判断返回值再往下走。内存分配失败返回 NULL 是很常见的事,尤其是长时间运行的服务,内存碎片化严重。如果忽略分配失败,后面直接arr->data[0] = 1,就是在 NULL 指针上写入,程序立刻崩。写好错误处理是手写容器类的基本功。

2.3 扩容检查:每次插入前的那个 if

插入逻辑的第一步永远不是写数据,而是检查容量。这个检查的逻辑极朴素,但位置很重要:

if (arr->size == arr->capacity) { da_resize(arr, arr->capacity * 2); }

这个判断必须在还没有写入新元素之前做。我见过有人写成"插入之后发现size > capacity再补救",那就已经晚了,数据已经写到越界内存里了。更安全的写法是检查size >= capacity,因为逻辑上 size 永远不该超过 capacity,只要相等就说明满了。有人用>=是为了防御性编程,万一此前某处 bug 导致 size 异常,至少能在扩容出发前暴露出来。我个人的做法是==,因为相信自己其它逻辑正确,写>=反而会掩盖 bug 的痕迹。不过这个属于个人风格,没有绝对的对错。

扩容检查的位置也一样适用于任意位置插入:在搬移元素之前就确认容量足够。如果先搬移再扩容,搬移用的还是旧内存,后面还要再搬一次,白干活。所以先判断容量、再处理位移,这个顺序是铁律。

3. 插入操作的三层逻辑:确认位置、搬移元素、写入数据

3.1 尾部插入:最简单也是最高频

push_back(尾部追加)是动态数组最常用的操作,看着简单,但恰恰是它撑起了整个动态数组的性能神话。为什么单独说尾插?因为尾部插入有两个天然优势:不需要移动任何已有元素,只需要在data[size]处写值,然后 size 加一;摊还下来是 O(1) 时间复杂度。如果你后续要写头插或任意位置插入,以尾插为起点逐步加复杂逻辑,理解起来会顺很多。

尾插首先重复上面说的容量检查,然后写指针、更新 size,就结束了:

void da_push_back(DynamicArray* arr, int value) { if (arr->size == arr->capacity) { da_resize(arr, arr->capacity * 2); } arr->data[arr->size] = value; arr->size++; }

这里有个容易忽略的坑:arr->data[arr->size]这一行,下标的取值是 size 还是 size-1?新元素永远放在当前最后一个元素的下一个位置,所以下标就是 size。如果写成data[size-1],第一次插入时 size 为0,那就是写入data[-1],直接越界写。这个细节新手特别容易写错,我建议每次写完都先跑一个空数组插入的用例,确保第一个元素落在下标0。

3.2 任意位置插入:搬移方向的讲究

任意位置插入比尾插复杂在多了"元素搬移"这一步。目标是把新元素放到index处,同时把原本index及之后的所有元素都往后挪一位。伪逻辑如下:

  • 检查index是否在[0, size]范围内,注意index == size是合法的,相当于尾插。
  • 检查容量,满了就先扩容。
  • 从size-1开始,把data[i]的值赋给data[i+1],一直做到i == index。
  • 把新值写入data[index],size 加一。

搬移方向是关键。你必须从最后一个元素开始往前搬,而不能从index开始往后搬。解释一下为什么:搬移操作是相邻元素的后移覆盖。如果你先搬data[index]到data[index+1],那data[index]的旧值就还在那里,等你再回头想把data[index+1]搬到data[index+2]时,data[index+1]已经变成旧data[index]的副本了,原来的data[index+1]被覆盖消失了。整个数组相当于把 index 处的值复制到了后面,其它元素根本没动,这显然不对。

从后往前搬就完全没问题:先把最后一个元素搬到空出来的"最后一个+1"位置,再把倒数第二个搬到最后一个的位置,依此类推,每个元素都跨越一格,不存在覆盖还没搬走的元素的可能。这个方向性,我用一个极简的例子验证过。假设数组是[1, 2, 3],要在 index=1 的位置插入 9。从后往前搬的顺序是:先把3从下标2搬到3,数组变成[1, 2, 3, 3];再把2从下标1搬到2,数组变成[1, 2, 2, 3];然后在 index=1 处写9,最终[1, 9, 2, 3]。方向一错,结果全乱。

3.3 为什么从后往前搬移是对的,用逆序推导来看

如果还有点绕,可以从反面来想:搬移的本质是要在 index 处腾出一个空位,同时保持 index 之后所有元素的相对顺序不变。如果你从前往后搬,前面元素一旦覆盖后面元素,后面的原始值就丢了,除非你用一个临时变量把后面元素先存起来。也就是说,从前往后搬意味着每个后移元素都要先暂存,整个操作的时间复杂度会变成 O(n) 而且还费一个临时内存。从后往前搬则完全不需要暂存:每一次移动的目标位置都是"已经被移走元素留下的空位",不需要担心原始值被覆盖。这是非常典型的设计权衡:根据目标位置空洞的演化方向,选择搬移遍历方向,省掉临时存储。

理解了任意位置插入,删除操作就是反向搬移。删除 index 处元素,要把它之后的元素整体往前挪一位,方向相反、从 index+1 开始往前覆盖,最后 size 减一。所以插入和删除本质上是同一个逻辑的镜像。这也是动态数组和链表在操作层面最大的不同:链表改的是指针指向,O(1) 时间;动态数组要搬数据,最坏 O(n)。但动态数组随机访问又是 O(1),所以没有绝对的优势,只有场景适配。

4. 扩容机制的底层逻辑:分配新内存、搬移旧数据、释放旧空间

4.1 扩容时机与扩容倍率的选型

扩容的触发时机的判断很简单,就是插入前发现size == capacity。但真正有技术含量的是扩容倍率的选型。我在项目里最开始用的是固定增量扩容,就是capacity + 16这样扩,后来发现这种方式对连续插入大量数据的场景极不友好:假设初始容量 8,固定增量 16,连续插入 100 个元素,扩容次数多达 6~7 次,每次都要重新分配内存、搬移全部已有数据,总搬移量是 O(n²) 级别。

倍增策略则完全不同。初始容量 8,插入到第9个元素时扩容到16,此时搬移了8个元素;再插入到第17个时扩容到32,搬移16个;再扩容到64,搬移32个。累加起来,插入了 n 个元素后的总搬移次数约为 8 + 16 + 32 + ... + n = 2n,是 O(n) 量级。虽然单次扩容的最坏代价是 O(n),但均摊到每次插入上,只是常数级别的开销。这就是为什么主流语言动态数组几乎都采用倍增扩容,C++ 的vector用 2 倍,Java 的ArrayList用 1.5 倍再加一个最小增量。至于为什么不用更大的倍率比如3倍甚至10倍?扩容倍率越大,均摊搬移越少,但每次扩容后内存浪费也越严重,可能有一大半容量是空的。2 倍是个兼顾时间和空间的经典平衡点。1.5 倍的空间利用率更高,代价是均摊搬移稍多,而且 Java 选 1.5 倍还有个私心:新容量比 2 倍更紧凑,配合连续内存分配,缓存更友好。

4.2 动态扩容的标准流程

扩容本身是一个三步走的过程:分配新内存、搬移旧数据、释放旧空间。代码实现如下:

void da_resize(DynamicArray* arr, int new_capacity) { int* new_data = (int*)malloc(new_capacity * sizeof(int)); if (!new_data) { // 分配失败时的处理策略,后面单独讲 return; } for (int i = 0; i < arr->size; i++) { new_data[i] = arr->data[i]; } free(arr->data); arr->data = new_data; arr->capacity = new_capacity; }

这里有三个细节值得展开。第一,分配新内存和搬移数据为什么要分开?能不能realloc一步搞定?realloc确实可以,而且在原地能扩大的时候效率极高,不需要搬移。但可移植性上有讲究:realloc的行为随内存分配器而异,某些场景可能因为无法原地扩展而自动搬移,但这部分开销其实和手动搬移是等价的。老实说我后来自己也改用realloc了,源码简洁很多。不过理解手工流程仍然有价值,因为realloc处理不了的情况(比如需要在扩容同时做一些自定义改造)还是要回到手工步骤。

第二,搬移用的是memcpy还是for循环?对于int这种简单类型,memcpy效率更高,编译器能优化成机器级的内存拷贝指令。但如果是自定的结构体类型、含有指针或者需要调用析构逻辑的类型,用memcpy就危险了,浅拷贝可能造成多次释放同一块内存的严重 bug。我在这篇博文里用for循环是为了展示逻辑,实际工程里请根据元素类型选择。

第三,free(arr->data)这步不能省。如果你分配了新内存却没有释放旧内存,每次扩容都会泄漏一块旧内存。长时间运行的程序,扩容几十次就泄漏几十块,内存会一点点涨上去,最后 OOM。这是一个极其隐蔽的坑,不仔细看内存曲线根本发现不了。

4.3 均摊时间复杂度的证明思路

很多人听说过"动态数组尾插的均摊复杂度是 O(1)",但不知道为什么。证明思路很简单:用"会计法"思考。每次成功的尾插,我们假设它花费 1 个单位的当前时间和 1 个单位的"存款"。存款存下来,直到扩容发生时一次性花掉。扩容发生时,需要把旧数组累计算出的 n/2 个元素都搬一次,花费 n/2 个单位。而此前已经存了 n/2 次"存款",刚好够付这笔账。于是摊还下来,每次尾插就是 2 个单位的固定开销,即 O(1)。

这个推导结果很重要,它解释了为什么动态数组尾插在实践里如此优秀。把壁纸贴到 10 万个元素的数组尾部,理论上几乎每次都是 O(1),只是偶发一次 O(n) 的扩容,但那次扩容的开销被之前的十多万次插入均摊掉了,宏观上看不出性能抖动。但要注意,这个结论只适用于尾插。头插和任意位置插入因为要搬移元素,是 O(n) 的,扩容搬移只是额外增加成本,并不改变数量级。

5. 完整可运行的 C 语言实现:从零开始手写并验证

5.1 结构体定义与初始化

把前面几节讲的设计落到完整代码里,我直接贴一个可运行版本。这个版本已经包含:结构体定义、创建/销毁、尾插、任意位置插、删除、扩容、打印。注释我会写得比较详细,方便你按步骤理解。

#include <stdio.h> #include <stdlib.h> #define INITIAL_CAPACITY 8 typedef struct { int* data; int size; int capacity; } DynamicArray; DynamicArray* da_create(void) { DynamicArray* arr = (DynamicArray*)malloc(sizeof(DynamicArray)); if (!arr) return NULL; arr->data = (int*)malloc(INITIAL_CAPACITY * sizeof(int)); if (!arr->data) { free(arr); return NULL; } arr->size = 0; arr->capacity = INITIAL_CAPACITY; return arr; } void da_destroy(DynamicArray* arr) { if (!arr) return; free(arr->data); free(arr); } void da_resize(DynamicArray* arr, int new_capacity) { int* new_data = (int*)malloc(new_capacity * sizeof(int)); if (!new_data) { // 分配失败,保持原状,避免数据丢失 fprintf(stderr, "Memory allocation failed during resize\n"); return; } for (int i = 0; i < arr->size; i++) { new_data[i] = arr->data[i]; } free(arr->data); arr->data = new_data; arr->capacity = new_capacity; } void da_push_back(DynamicArray* arr, int value) { if (arr->size == arr->capacity) { da_resize(arr, arr->capacity * 2); } arr->data[arr->size] = value; arr->size++; } void da_insert(DynamicArray* arr, int index, int value) { if (index < 0 || index > arr->size) { fprintf(stderr, "Index out of range: %d\n", index); return; } if (arr->size == arr->capacity) { da_resize(arr, arr->capacity * 2); } for (int i = arr->size - 1; i >= index; i--) { arr->data[i + 1] = arr->data[i]; } arr->data[index] = value; arr->size++; } void da_remove(DynamicArray* arr, int index) { if (index < 0 || index >= arr->size) { fprintf(stderr, "Index out of range: %d\n", index); return; } for (int i = index; i < arr->size - 1; i++) { arr->data[i] = arr->data[i + 1]; } arr->size--; } void da_print(DynamicArray* arr) { printf("size=%d, capacity=%d\n", arr->size, arr->capacity); for (int i = 0; i < arr->size; i++) { printf("%d ", arr->data[i]); } printf("\n"); }

注意da_remove里删除后不需要重置被"废弃"的最后一个元素的值为0,因为 size 减一后它已经不属于逻辑范围,下次插入会直接覆盖它。但如果数组存的是指针类型,删除后最好手动把那个元素置 NULL,避免垂悬引用。

5.2 插入与扩容实现

上面代码中,我想特别强调da_insert里的 for 循环条件:for (int i = arr->size - 1; i >= index; i--)。注意 i 的类型是int。如果数组是空的,arr->size - 1等于 -1,循环直接不进入,这是对的。但如果你把 i 的类型换成size_t(无符号类型),-1 会被解释成一个巨大的正整数,循环会一直往下跑,产生灾难性的越界写入。这是 C/C++ 里非常经典的"无符号数与有符号数混用"坑,我在这里栽过一次,调了一个多小时才意识到问题。强烈建议你在做数组下标循环时统一用int或者ptrdiff_t,不要为了"跟上时代"用size_t而忽略负数边缘。

扩容函数da_resize我在失败时只打了日志就返回,保持了原有数据不变。这是一种"失败时不破坏现状"的安全策略。有些实现会在失败时直接抛异常或退出程序,但作为容器类,最好不要因为一次扩容失败就把所有已有数据弄丢。你可以在此基础上做增强,比如失败时尝试更小的扩容倍率,或者抛出异常让上层决定怎么处理。

5.3 测试流程与结果验证

代码写完了,必须跑测试。我的测试思路是这样的:先尾插三五个元素,确认基本情况没问题;再故意触发一次扩容,插入超过初始容量的数据,看扩容后数据是否完整;再测试任意位置插入的搬移方向;最后测越界插入的异常拦截。

int main(void) { DynamicArray* arr = da_create(); // 1. 尾插触发扩容 for (int i = 0; i < 10; i++) { da_push_back(arr, i * 10); } da_print(arr); // 预期: size=10, capacity=16, 元素 0,10,...,90 // 2. 中间插入 99 da_insert(arr, 3, 99); da_print(arr); // 预期: 99 落在下标3,后面的元素整体后移 // 3. 删除下标5的元素 da_remove(arr, 5); da_print(arr); // 4. 越界插入 da_insert(arr, 99, 1); // 预期打印错误信息,程序不崩溃 da_destroy(arr); return 0; }

我实际跑过这段代码,输出顺序和数据值都是对的:第一次尾插触发扩容后 capacity 变为16;中间插入后 99 确实出现在下标3;删除后数组长度减一且后续元素前移;越界插入被拦截并打印了友好报错。整套流程走通,底层逻辑和代码实现就闭环了。如果你也想验证扩容时机,可以在da_resize里加一行打印日志,每次扩容都会输出当时的 size 和 new_capacity,配合测试插入数量,能非常直观地看到扩容节奏。

6. 手写过程中踩过的坑与后续优化方向

6.1 内存泄漏与扩容失败处理

前面提到过,扩容时忘记free(arr->data)是最常见的内存泄漏。一旦容器是长期存活的(比如全局缓存、连接池),每次扩容泄漏一块旧内存,累积下来就是事故。排查的时候你可能会看到内存占用曲线像台阶一样稳定上涨,每上一个台阶就对应一次扩容。不过这里也要补充说明:在内存分配失败的情况下,旧内存其实不能随便释放,因为新内存还没分配成功,你需要先把旧数据稳稳保住,等下次分配成功后再释放。这段顺序上的哲学是:新内存优先,旧内存善后。

内存泄漏检测工具也值得养成习惯。Linux 下用 Valgrind 跑一遍测试程序,如果看到definitely lost或indirectly lost的字节数,就说明某处忘了 free。我手写动态数组的早期版本,第一次跑 Valgrind 就报了几百字节的泄漏,定位后发现就是扩容分支上漏了free(arr->data)。这个调试经历比看十篇博客都管用,强烈建议你也跑一遍。

6.2 缩容:大部分场景下不建议做的操作

有扩容就会有人想到缩容——数量减少到某个阈值时缩小容量,省内存。这个想法听起来很美,实际工程却很少这么干。最大的原因是缩容会引入未来的扩容抖震。假设当前容量 1024,内存里只剩 10 个元素,你缩容到 16,省了一大批内存。但如果紧接着又插入一批数据,又得扩容回去,白白做一次大搬移。频繁的缩容-扩容交替会让性能剧烈抖动,形成所谓的"抖动陷阱"。

C++ 的vector还提供了一个有趣的现象:clear()清空所有元素后,capacity()不变。这意味着内存不释放,只是 size 归零。当初我不理解,认为这是 bug,后来才体会到这是为了复用已分配的内存,避免反复扩容。如果你想真正释放内存,可以用shrink_to_fit(),但它通常只是一次"尝试",不保证强制缩容。基于这些经验,我对缩容的结论是:默认不缩,除非你能证明内存长期空置且短期内不会再插入大量数据。

6.3 从手写动态数组到更深的底层世界

手写一遍动态数组只是入门,顺着这个方向往下走还有好几个进阶方向。第一个是泛型化,给动态数组加上void*数据指针或 C++ 模板,让它能存任意类型。这一步会迫使你思考元素大小、对齐、内存布局这些更底层的细节。第二个是内存分配器优化,改用realloc并复用空闲内存,或者引入对象池。实际项目里,动态数组频繁扩容的分配开销可能是性能瓶颈之一,合理的分配策略比算法本身更影响性能。第三个是并发安全,给插入和扩容加上锁或者无锁设计,这会让复杂度上一个台阶,但也是理解并发容器如何工作的很好的练习。

往工程落地方向看,你还可以对比标准库的源码。比如去看 glibc 的malloc如何处理大块连续内存,或者看 JavaArrayList的grow方法如何和hugeCapacity兜底溢出。看完你会有种豁然开朗的感觉:原来我们手写的那套逻辑,和标准库的雏形是同一个谱系,标准库只是加了更多的防御和优化。我至今仍保留着自己手写的第一版动态数组代码,不是为了再用它,而是为了记住那些从错误中学到的底层逻辑。动手写一遍,胜过读十遍 API 文档。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询