多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手写动态数组:深入解析扩容与插入的底层机制

手写动态数组:深入解析扩容与插入的底层机制 动态数组标准库里一抓一大把现成的vector、ArrayList用起来比手写香多了这是实话。但我在实际带项目、看代码和面试别人的过程中发现真到了扩容、插入、内存重分配这些细节上很多人其实只停留在“会用”的程度说白了你知道尾部push_back很快但不知道它为什么快你知道扩容会搬数据但不知道搬的时候还有可能把老数据搬丢。这篇文章就是陪你把手写动态数组这条路走一遍重点拆解扩容和插入背后的底层逻辑——它依赖哪些机制设计时为什么做那些取舍以及 realloc 失败、插入位置错乱、外部指针失效这些坑要怎么避。适合刚把 C 语言学完、准备进阶数据结构的人也适合平时只用现成容器但想把底层补明白的同学。1. 为什么还要手写动态数组先搞清楚它解决什么问题1.1 动态数组的定位定长数组、链表与动态数组的三方博弈要理解动态数组先得弄清楚它站在什么位置。定长数组的问题很直接大小固定满了就满了你想多塞一个数据进去编辑器直接给你越界。链表呢可以随意增长但代价是按下标访问的复杂度变成 O(n)而且每个节点散落在内存各处遍历时缓存命中率很差。动态数组本质上还是数组底层就是一块连续内存但它额外维护了“当前有多少元素”和“当前最多能装多少元素”两个信息当容量不足时就换一块更大的内存把旧数据整体搬过去。这样既保留了数组按下标 O(1) 访问的优势又让长度可以随数据量增长。数据结构按下标访问尾部插入任意位置插入内存连续性定长数组O(1)O(1)满了就崩O(n)连续链表O(n)O(1)O(1)不连续动态数组O(1)均摊 O(1)O(n)连续内存连续性这一点经常被低估。CPU 在遍历连续内存时会把后面的数据提前加载到缓存里所以同样都是遍历十万个整数动态数组比链表快出一个数量级是常事。这也是动态数组在绝大多数场景下成为默认容器的根本原因它用一个“偶尔变慢”的扩容操作换来日常访问的极致效率。1.2 手写一遍到底能收获什么很多人说手写动态数组是重复造轮子这话我不太认同。我自己的体会是手写一个最小实现就像把一台天天在用的电器拆开看一眼你会突然明白很多原本靠背的结论是从哪来的。比如为什么迭代器在push_back之后可能失效因为你往数组里塞数据可能触发扩容扩容意味着底层内存重新分配旧地址就作废了。再比如很多语言里List和动态数组的性能差异为什么如此明显因为用户看到的是同样的“追加”底层却是完全不同的内存策略。手写一遍之后你就会习惯性地思考“这个操作会不会改变容量”而不是等问题出现时对着调试器干瞪眼。下面开始正式搭建我们从数据结构设计入手。2. 核心结构设计动态数组的基本盘2.1 三个成员变量先摆清楚data、size、capacity手写动态数组的第一个决定就是结构体怎么定义。我见过不少人一上来就想得很复杂什么自动缩容、迭代器、内存池全塞进去结果越写越乱。最小可用版本只需要三个成员typedef struct { int *data; // 指向底层堆内存 size_t size; // 当前实际存储的元素个数 size_t capacity; // 当前分配的内存最多能容纳的元素个数 } DArray;这里有几个关键点值得展开。第一size_t是无符号整数这个类型选错会在后面留坑比如插入位置为负数时会变成一个极大的正数稍后我会专门讲这个坑。第二data是指向堆内存的指针也就是说数组本体不直接放在结构体里这是为了扩容时能够整体搬迁。第三capacity和size是两回事容量大不代表元素多这两个值差距越大说明内存浪费越多这也是后文讨论增长因子时要反复提到的概念。动态数组的很多“反直觉”行为根源都在size和capacity的分离上。比如你往一个容量为 100 的数组里塞 50 个元素此时插入是纯内存写不需要任何耗时操作但塞到第 101 个时一次扩容就把前面省的时间全部找补回来了。所以动态数组的复杂度不能只看单次操作要看“均摊”。2.2 初始化与销毁懒分配并不是偷懒初始化看起来没什么技术含量但方案细节会直接影响后续的扩容代码。我比较推荐“懒加载”策略一开始把三个成员全部清零堆内存先不分配等第一次插入时再分配。void darray_init(DArray *arr) { arr-data NULL; arr-size 0; arr-capacity 0; }对应的销毁操作也必须配套void darray_destroy(DArray *arr) { free(arr-data); arr-data NULL; arr-size 0; arr-capacity 0; }有人问为什么初始化时不让data指向一块固定的内存答案很简单避免无谓分配。一个动态数组可能被创建后一直不插入数据或者只插入少量数据如果初始化时就预留一大片内存那就是白交房租。懒分配的另一个好处是后续用realloc(NULL, size)可以直接代替malloc逻辑上少一个分支非常顺滑。销毁时把指针置空也是一种好习惯虽然释放之后你不一定会再读它但保留一个悬空指针总归是隐患。2.3 下标访问与赋值和普通数组写起来一样原理其实有差异既然底层是连续内存按下标访问就非常简单。直接通过arr-data[i]就能完成读写编译器会把它翻译成“基地址 索引 × 元素大小”的寻址方式。为了简单起见可以封装两个小接口int darray_get(const DArray *arr, size_t index) { return arr-data[index]; } void darray_set(DArray *arr, size_t index, int value) { arr-data[index] value; }这里要注意一个细节如果index arr-size上面这段代码会直接越过边界属于未定义行为。实际上数组越界访问往往不会立刻崩溃而是悄悄读到相邻内存这种问题在线上最难排查。所以更稳妥的做法是在调试版本里加断言发布版本再关闭校验类似标准库里 debug 模式的做法。我写教学代码时习惯在darray_get里加一句assert(index arr-size)宁可崩溃也别让错误静默传播。3. 扩容机制深度拆解底层逻辑的底牌全在这里3.1 扩容判断为什么“满了才扩”还不够动态数组最常见的触发条件是size capacity但严谨来说扩容判断应该基于“需要多少容量”而不是“现在满了没有”。比如你要批量插入 10 万个元素如果每次插入都等满了再扩就可能触发好几次扩容每次都是全量搬移性能惨不忍睹。所以内部应该提供一个容量保障函数叫reserve或者ensure_capacity它的职责是确保容量至少达到某个值不够才扩容够就用一次分配要够int darray_reserve(DArray *arr, size_t need) { if (need arr-capacity) return 0; // 容量不够才走到这一步 }这个函数的封装会让后续所有插入逻辑变得非常干净。不管是尾插还是中间插入口统一都是“先保证容量够再做搬移”不需要每个函数里各写一套扩容判断。这也是工程上和教学实现的一个重要差别把变化点收敛到一个地方。3.2 增长因子选 2 还是选 1.5用算账的方式理解扩容时新容量定为多少是动态数组设计里最有讲究的一个决策。常见选择是 2 倍其次是 1.5 倍。为什么不能每次扩容只加一个固定值比如每次多分配 100 个元素的空间我们直接算一笔账。假设容量从 1 开始每次多分配固定 100 个元素的空间。那么第 1 次扩容时要搬 100 个第 2 次搬 200 个第 3 次搬 300 个。如果最终数组里有 n 个元素扩容次数大约 n/100总搬移量就是 100 200 300 ... n这个等差数列求和的结果是大约 n²/200。平摊到每次插入成本是 O(n)这意味着数组越大单次插入的“隐形开销”就越大。反过来如果每次扩容翻倍容量序列是 1、2、4、8、16……最后一次需要装下 n 个元素。那么总搬移量是 1 2 4 8 ... n也就是大约 2n。平均到 n 次插入每次的均摊成本就是常数O(1)。这就是为什么倍增能成为事实标准它不是随便选的而是数学上保证摊还复杂度最优的简单策略。那为什么还有人用 1.5 倍因为翻倍有一个非常明显的毛病空间利用率低。翻倍之后如果数组刚扩容完只塞了一个元素那容量就浪费了一半最低利用率只有 50%。而 1.5 倍扩容后最低利用率可以做到约 67%在内存敏感的环境里能省不少。代价是实现时要处理浮点数或者整数溢出以及稍微复杂一点的容量计算。我的建议是初学阶段无脑用翻倍逻辑简单不易错如果将来你在嵌入式或大内存服务里遇到空间紧张再考虑把因子改成可配置项。3.3 扩容的完整流程分配、拷贝、释放三步走无论新容量怎么算扩容的实际动作都逃不过三步分配新内存、把旧数据搬过去、释放旧内存。在 C 语言里这三个步骤恰好对应一个库函数realloc它做的事情比你想象的多static int darray_grow(DArray *arr, size_t new_capacity) { int *new_data realloc(arr-data, new_capacity * sizeof(int)); if (!new_data) return -1; arr-data new_data; arr-capacity new_capacity; return 0; }realloc内部首先是尝试在原来的内存块后面追加空间如果能追加就直接返回原指针如果后面空间不够就另找一块更大的内存把旧数据按字节拷贝过去再释放掉旧内存。这段流程对调用方是透明的但理解它至关重要。这里埋着一个经典的坑很多人写完代码第一版是这样写的arr-data realloc(arr-data, new_capacity * sizeof(int));看起来没毛病实际上一旦realloc失败返回NULL这个 NULL 会直接覆盖掉原来的arr-data旧内存指针就永久丢失了而且旧数据是无法找回的这就是典型的泄露加数据损坏。正确写法必须先把返回值存到临时变量里判空成功后再赋值就是上面darray_grow那种写法。这个习惯用到任何涉及realloc的代码里都成立。3.4 缩容策略要不要缩怎么缩才不“发抖”扩容是往里加数据时的问题那删除数据减到很小时要不要缩容很多初学者会直接“大小降到容量一半就缩”结果出现了抖动问题你插入一个元素触发扩容删除一个元素触发缩容再插入又扩容反复横跳扩容缩容都是 O(n) 的搬移性能直接被拖垮。工业界普遍采用的思路是“滞后区间”我给一个常用方案当size capacity / 4时才缩容而且只缩到capacity / 2。这样容量和实际大小之间始终保持一个缓冲带插入删除都不会立刻触发反方向的容量调整。举例来说容量是 16元素个数从 8 一直缩到 3也就是不足 4 了才缩成容量 8从 3 又往上涨则要涨到超过 8 才可能扩回 16。中间这段区间是安全的过渡带。static void darray_shrink_if_needed(DArray *arr) { if (arr-capacity 8 arr-size * 4 arr-capacity) { size_t new_capacity arr-capacity / 2; int *new_data realloc(arr-data, new_capacity * sizeof(int)); if (new_data) { arr-data new_data; arr-capacity new_capacity; } } }这里还设了一个容量下限 8防止元素个数归零时把容量缩到 0又是一次没必要的小动作。缩容的核心原则就一句话宁可浪费一点空间也不要让容量频繁波动。4. 插入操作的底层逻辑不只是“塞进去”而已4.1 尾部插入 push_back三个环节一个都不能少尾部插入是动态数组最常用的操作表面上看就是给最后一个位置赋值实际上流程要拆成三步判断容量是否够、扩容如果需要、写入并更新size。int darray_push_back(DArray *arr, int value) { if (darray_reserve(arr, arr-size 1) ! 0) return -1; arr-data[arr-size] value; arr-size 1; return 0; }注意写入发生在arr-data[arr-size]也就是当前末尾的下一个位置然后size才自增。这个顺序必须固定如果先自增再写入极端情况下会覆盖还没用到的内存更重要的是会影响后续的中间插入逻辑。这个函数的时间复杂度是均摊 O(1)只在容量不够时才做一次 O(n) 的搬移其余时候都是纯常数操作。4.2 中间插入搬移为什么必须“从后往前”中间插入就没有尾部插入那么温柔了。你需要把插入位置及后面的所有元素整体向后挪一位然后空出来的位置放新值。很多人第一次写这里时容易犯一个方向性错误从前往后搬结果后面的数据被覆盖掉。原因很简单。数组是连续内存如果你从前往后搬比如先把data[2]挪到data[3]接着要把data[3]挪到data[4]但这个时候data[3]已经被你改了你搬过去的其实是错误的数据。正确做法是从后往前搬先把尾巴上的元素挪到最后一个空位再往前逐个覆盖这样每个元素在被读取之前都没有被破坏。int darray_insert(DArray *arr, size_t pos, int value) { if (pos arr-size) return -1; if (darray_reserve(arr, arr-size 1) ! 0) return -1; for (size_t i arr-size; i pos; --i) { arr-data[i] arr-data[i - 1]; } arr-data[pos] value; arr-size 1; return 0; }这段代码里的循环条件i pos用size_t实现是安全的因为当pos为 0 时i到达 0 后循环条件0 0为假不会继续执行也就不会发生无符号整数下溢的问题。这也是写底层代码时要特别小心的地方很多循环在int下调得好好的一换size_t就出现死循环或者越界访问。4.3 批量插入memmove 才是搬移的最终正确姿势手动循环搬移虽然教学上很直观但真实代码里更推荐标准库的memmove尤其在做批量插入时两者的差异简直是天壤之别。批量插入的场景是把一段连续数据一次性插入到数组的某个位置。如果每次都调darray_insert那每一个新元素都要造成一次后移搬移次数出现乘法级别的放大。正确做法是先darray_reserve(arr, arr-size count)一次性保证容量然后把插入点后面的旧数据整体后移count位再拷入新数据int darray_insert_range(DArray *arr, size_t pos, const int *values, size_t count) { if (pos arr-size) return -1; if (darray_reserve(arr, arr-size count) ! 0) return -1; memmove(arr-data pos count, arr-data pos, (arr-size - pos) * sizeof(int)); memcpy(arr-data pos, values, count * sizeof(int)); arr-size count; return 0; }这里特别要强调不能用memcpy搬移旧数据必须用memmove。两者的区别在 C 标准里写得很清楚——memcpy不允许源和目的内存重叠一旦重叠就是未定义行为而memmove专门处理重叠拷贝。我们这里的操作源区和目的区就是同一块内存的两个重叠区间用memcpy在大多数编译器上可能碰巧没问题但这是运气不是正确性。4.4 从性能面试题看数组与链表没有绝对赢家聊到插入很多人会本能地拿出“数组插入 O(n)、链表插入 O(1)”的结论。这个结论在纯算法复杂度上没问题但到了真实场景就会显得过于天真。链表在头部插入时确实不用搬移但它每一步都要先找到正确的节点还要做一次堆内存分配这两个操作的内存访问模式对 CPU 缓存非常不友好。动态数组虽然在中间插入时要搬移数据但搬移的操作是连续内存拷贝编译器可以用 SIMD 指令优化得飞快实际跑起来在小数据量下反而常常更快。所以我在做技术方案选型时的经验是数据规模小、访问频繁优先动态数组元素本身极大、且高频在头部插入才值得考虑链表。这背后不是固定公式而是需要你对“搬移成本”和“寻址成本”有真实的体感而这种体感只有亲手实现过动态数组的人才能建立起来。5. 一个完整可运行的最小实现拿来就能跑5.1 数据结构与公共接口下面我把前面拆解过的内容拼成一份最小但完整的实现。先定义结构体和公共接口为了简单我直接用int作为元素类型理解之后换成任意结构体都只是内存大小的问题。#include stdio.h #include stdlib.h #include string.h typedef struct { int *data; size_t size; size_t capacity; } DArray; void darray_init(DArray *arr); void darray_destroy(DArray *arr); int darray_reserve(DArray *arr, size_t need); int darray_push_back(DArray *arr, int value); int darray_insert(DArray *arr, size_t pos, int value); int darray_insert_range(DArray *arr, size_t pos, const int *values, size_t count);接口要小能少暴露就少暴露这是写底层容器的一个基本工程素养。用户不需要关心你扩容的具体算法他们只需要“能追加、能插入、能释放”就够了。5.2 核心函数的完整拼接void darray_init(DArray *arr) { arr-data NULL; arr-size 0; arr-capacity 0; } void darray_destroy(DArray *arr) { free(arr-data); arr-data NULL; arr-size 0; arr-capacity 0; } int darray_reserve(DArray *arr, size_t need) { if (need arr-capacity) return 0; size_t new_capacity arr-capacity ? arr-capacity : 1; while (new_capacity need) { new_capacity * 2; } int *new_data realloc(arr-data, new_capacity * sizeof(int)); if (!new_data) return -1; arr-data new_data; arr-capacity new_capacity; return 0; } int darray_push_back(DArray *arr, int value) { if (darray_reserve(arr, arr-size 1) ! 0) return -1; arr-data[arr-size] value; arr-size 1; return 0; } int darray_insert(DArray *arr, size_t pos, int value) { if (pos arr-size) return -1; if (darray_reserve(arr, arr-size 1) ! 0) return -1; for (size_t i arr-size; i pos; --i) { arr-data[i] arr-data[i - 1]; } arr-data[pos] value; arr-size 1; return 0; } int darray_insert_range(DArray *arr, size_t pos, const int *values, size_t count) { if (pos arr-size) return -1; if (darray_reserve(arr, arr-size count) ! 0) return -1; memmove(arr-data pos count, arr-data pos, (arr-size - pos) * sizeof(int)); memcpy(arr-data pos, values, count * sizeof(int)); arr-size count; return 0; }这段代码看起来不长但每一行都经过了前面那些“为什么”的推敲。比如darray_reserve里capacity ? capacity : 1是为了处理懒加载下的空数组循环翻倍是为了保证均摊复杂度realloc返回值先存临时变量是为了失败时不丢旧指针。代码是思想的具象如果直接抄走而不理解这些细节下一次你依然会在自己的项目里栽跟头。再补一个简单的测试用例int main(void) { DArray arr; darray_init(arr); for (int i 0; i 20; i) { darray_push_back(arr, i * 10); } darray_insert(arr, 0, 999); darray_insert(arr, arr.size, -1); for (size_t i 0; i arr.size; i) { printf(%d , arr.data[i]); } printf(\n); darray_destroy(arr); return 0; }第二个插入用的是arr.size作为位置这等价于尾部插入。我在实际代码里经常这样复用插入接口写起来也顺。5.3 真实业务中必须注意的三件事第一并发访问。上面所有函数都没有加锁如果多个线程同时对同一个动态数组做插入和访问扩容会让data指针发生变化其他线程持有的旧指针瞬间失效。要么外部加锁调度要么用无锁容器千万不要在排查问题时忽略这一点。第二元素类型膨胀。这里的例子是int拷贝成本可以忽略。但如果元素是一个很大的结构体每次 realloc 按字节搬移的成本就会变得很高。建议把元素改成指针类型或者在结构体层面实现移动语义否则大数据体量的动态数组会慢到让你怀疑人生。第三不要频繁创建和销毁小数组。堆内存分配是有代价的如果你在循环里不断initpushdestroy性能往往被内存分配器吃掉而不是被算法吃掉。这时候复用同一个数组并手动改size往往收益非常明显。6. 常见问题排查与避坑实录6.1 realloc 失败导致老数据悬空这是我在 code review 里见最多的问题。错误写法前面已经展示过这里再抄一遍加深印象// 错误示范 arr-data realloc(arr-data, new_capacity * sizeof(int));如果realloc失败返回的NULL会覆盖原来的指针。你以为只是“扩容失败”实际上旧数据整个丢失内存也泄露了因为已经没有指针指向那块旧内存。这种 bug 的可怕之处在于它有偶发性——内存够时一辈子不触发一旦内存紧张就随机崩且复现困难。排查时多留意所有realloc/malloc返回值是否被直接赋给原指针这是最有效的扫雷方式。6.2 插入位置参数带来的 size_t 陷阱再看darray_insert的参数类型size_t是无符号的。如果你在业务代码里传入-1编译期不会报任何错误这个-1会被转换成一个非常大的正整数然后直接击穿pos arr-size的检查吗不会因为巨大的pos大于size所以会返回错误。但如果调用方忽略返回值程序就会在魔幻状态下继续运行这就是隐患。更隐蔽的情况是pos恰好比size大一点点。比如数组有 5 个元素你想要“插到第 2 个和第 6 个之间”语义上越界了但底层代码不知道你的意图只能根据pos size拒绝。所以在实现和接口文档里要明确合法位置是0 pos size其中pos size等价于尾插。有一致性的接口约定调用方才能写出可预测的代码。6.3 扩容后外部指针悄然失效很多人会把int *p arr.data存起来往后继续用p[i]访问。在容量没变化时一切正常可一旦push_back触发扩容arr.data可能指向新内存旧的p就是悬空指针了。这不是编译器 bug也不是内存写坏而是动态数组的基本语义决定了所有内部指针在扩容后都会失效。应对策略也很简单不要长期持有动态数组内部的裸指针需要访问时就重新取arr.data或者用下标访问接口。如果你确实需要一个稳定地址可以先reserve足够大的容量在容量不再变化的前提下再持有指针但要清楚这是“自己保证的稳定”不是容器的天然属性。6.4 排查内存错误的几个实用工具手写容器的代码量不大但越界、悬空、重复释放这类问题很隐蔽。我自己的经验是别硬着头皮读代码直接上工具。Linux 下用-fsanitizeaddress重新编译运行时一旦出现越界它就会报出具体位置Valgrind 更全面能查出内存泄露和使用未初始化内存代价是程序会慢几十倍适合小规模测试。别人问我调这类代码最快的路径是什么我的答案永远是先让工具帮你缩小范围再靠你对扩容和插入逻辑的理解去读那几十行代码。聊到这儿扩容和插入的核心逻辑基本都过了一遍。我个人的体会是手写动态数组最大的价值不在代码本身而在于写完之后你再回头看那些标准库容器会突然有“原来它在这里做了这么多事”的顿悟。最后再分享一个我常用的实验方法在一个临时工程里分别实现 1.5 倍和 2 倍扩容然后往里面插入几十万个随机数打印总搬移次数和最终容量自己亲眼看看均摊成本到底差多少。这种亲手验证过的东西比任何博客里的结论都记得牢。
返回列表