动态数组的实现:用malloc玩转可变长数组

动态数组,说白了就是能自动长大的数组。C语言里普通数组的大小是编译时就定死的,你没法在程序跑起来之后说“哎,再给我加俩位置”。但实际项目中,数据量往往是运行时才知道的——比如你从网络收包,鬼知道会来多少字节?这时候就得靠动态数组了。

我个人习惯把动态数组叫做“穷人版vector”,毕竟C++里vector用起来太爽了,但C语言里我们得自己造轮子。不过别怕,这个轮子其实不难,而且造一遍之后,你对内存管理的理解会上一个台阶。

1. 动态数组的核心结构

先定义个结构体,把数组的元信息存下来:

typedef struct {
    int *data;      // 指向堆内存的指针
    size_t size;    // 当前元素个数
    size_t capacity; // 当前容量(最多能存多少个)
} DynamicArray;

这里有个关键点:size和capacity是两码事。size是实际用了多少,capacity是分配了多少空间。我见过新手把这两个混为一谈,结果扩容时直接懵逼。

2. 初始化与销毁

初始化时,我建议先分配一小块初始容量,别一上来就搞很大。比如:

DynamicArray* da_create(size_t initial_capacity) {
    DynamicArray *arr = malloc(sizeof(DynamicArray));
    if (!arr) return NULL;

    arr->data = malloc(initial_capacity * sizeof(int));
    if (!arr->data) {
        free(arr);
        return NULL;
    }

    arr->size = 0;
    arr->capacity = initial_capacity;
    return arr;
}

销毁时记得先释放data,再释放结构体本身。顺序反了你就泄漏内存了——我曾经在项目里犯过这个错,排查了半天才发现是free顺序搞反了。

void da_destroy(DynamicArray *arr) {
    if (arr) {
        free(arr->data);
        free(arr);
    }
}

3. 扩容策略:别一次只扩一个

动态数组最核心的就是扩容策略。你想想看,如果每次添加一个元素就realloc一次,那效率得多低?我建议采用倍数扩容,最常见的是2倍增长。

核心原则: 扩容要“批量”,缩容要“谨慎”。

为什么是2倍?因为均摊下来,每次插入的时间复杂度是O(1)。如果每次只加1个,那插入n个元素就是O(n²),数据量一大直接爆炸。

int da_append(DynamicArray *arr, int value) {
    if (arr->size >= arr->capacity) {
        // 扩容为原来的2倍
        size_t new_cap = arr->capacity * 2;
        int *new_data = realloc(arr->data, new_cap * sizeof(int));
        if (!new_data) return -1; // 扩容失败

        arr->data = new_data;
        arr->capacity = new_cap;
    }

    arr->data[arr->size++] = value;
    return 0;
}

这里有个细节:realloc可能会移动内存块,所以一定要用返回值更新指针。直接realloc(arr->data, ...)然后继续用arr->data?嗯,这是典型的坑——如果realloc失败返回NULL,你原来的指针就丢了。

4. 缩容策略:别急着缩

缩容比扩容更讲究。我见过有人每次删除元素就缩容,结果频繁realloc导致性能雪崩。正确的做法是:当size降到capacity的1/4时,才缩容到一半。这样避免了“添加-删除-添加”时的反复震荡。

int da_pop(DynamicArray *arr) {
    if (arr->size == 0) return -1;

    int val = arr->data[--arr->size];

    // 缩容条件:size <= capacity/4 且 capacity > 初始容量
    if (arr->size <= arr->capacity / 4 && arr->capacity > 4) {
        size_t new_cap = arr->capacity / 2;
        int *new_data = realloc(arr->data, new_cap * sizeof(int));
        if (new_data) { // 缩容失败也没关系,继续用旧的
            arr->data = new_data;
            arr->capacity = new_cap;
        }
    }

    return val;
}
注意: 缩容时realloc可能失败,但此时数据还在旧内存里,所以不要直接return -1,而是继续用旧容量。这是“优雅降级”的思路。

5. 完整扩容缩容流程图

下面这张图把整个生命周期串起来了,你看一眼就能明白什么时候扩、什么时候缩:

初始分配 capacity = 4 添加元素 size++ size >= capacity? 扩容 ×2 capacity *= 2 继续添加 删除元素 size-- size <= cap/4? 缩容 ÷2 capacity /= 2 继续删除 扩容:size达到capacity时触发 | 缩容:size降到capacity/4以下时触发

6. 避坑指南:我踩过的三个雷

  • realloc后指针没更新:我早期写代码时直接 realloc(ptr, new_size) 然后继续用ptr,结果realloc移动了内存块,ptr变成野指针。记住:ptr = realloc(ptr, new_size); 才是正确写法。
  • 忘记检查realloc返回值:realloc可能返回NULL,这时候原来的内存块还在,但你没保存原指针,就泄漏了。正确做法是用临时变量接收返回值。
  • 缩容太激进:有个项目里我每次pop都缩容,结果用户频繁增删时,程序卡成PPT。后来改成“1/4缩容到1/2”,世界清净了。
小技巧: 如果你不确定初始容量设多少,可以用4或8。太小会导致频繁扩容,太大浪费内存。对于大多数场景,4~16是个合理的范围。

7. 完整代码骨架

最后给个完整的头文件声明,方便你直接复用:

// dynamic_array.h
#ifndef DYNAMIC_ARRAY_H
#define DYNAMIC_ARRAY_H

#include <stddef.h>

typedef struct {
    int *data;
    size_t size;
    size_t capacity;
} DynamicArray;

DynamicArray* da_create(size_t initial_capacity);
void da_destroy(DynamicArray *arr);
int da_append(DynamicArray *arr, int value);
int da_pop(DynamicArray *arr);
int da_get(const DynamicArray *arr, size_t index);
void da_set(DynamicArray *arr, size_t index, int value);

#endif

实现文件里,get和set记得做边界检查。我习惯在debug模式下用assert,release模式下返回错误码。


动态数组的实现,说白了就是管理好size和capacity这对兄弟。扩容时大方点(×2),缩容时保守点(1/4触发)。记住这个原则,你的动态数组就能在各种场景下跑得稳。

嗯,如果你在实际项目中遇到内存相关的诡异问题,多半是扩容缩容策略没调好。别问我怎么知道的——我曾经在一个网络库里因为缩容太频繁,导致线上服务时不时卡顿,查了两天才找到根儿。

公众号:蓝海资料掘金营,微信deep3321