
搞了这么多年C语言我越来越觉得数组是那种“看起来人畜无害、背地里全是细节”的东西。很多新手觉得数组嘛不就是开一块连续内存存一堆数么可真到了写代码的时候数组名到底是不是指针、三维指针怎么取偏移、多维数组初始化少了一对大括号会怎样随便一个问题都能把人问懵。我见过面试时能把数组和指针关系讲清楚的人真的不多。所以这篇东西我就打算把C数组这个概念从底层内存模型到实操写法再到排序、逆序、去重、动态数组这些高频场景完整梳理一遍把这些年踩过的坑和总结出来的经验一并放出来。无论是刚学C语言的朋友还是写了几年C想查漏补缺的老手都能从这里翻到点东西。1. C数组到底是个什么玩意儿1.1 从内存视角看数组数组在C里本质就是一段连续的内存块。这句话值得反复琢磨。什么叫连续就是你在栈上声明int a[5]编译器会给你分配 5 个int大小的空间这 5 个int在内存里是紧挨着的一个接一个。第一个元素a[0]地址最低最后一个a[4]地址最高中间没有任何空隙。为什么C偏偏要选“连续”这个方案答案很简单位置计算快。只要知道数组首地址和每个元素占的字节数任何一个元素的位置都能在 O(1) 时间内算出来。比如a[i]的地址就是(char *)a i * sizeof(int)。这个计算在底层就是一次加法和一次乘法极快。你看Java、Python里数组也是这个思路但C把这一切直接暴露给了你你能看到、能操作也因此更容易出错。这里要强调一个新手特别容易忽略的点数组下标从0开始。为什么从0不从1因为偏移量就是从0开始的。a[0]是偏移0个元素也就是首地址本身a[i]是偏移 i 个元素。如果你从1开始每次定位都得减一多一次运算。C的设计哲学是“不为不需要的东西买单”所以它选择从0开始。这个习惯一定得养成很多人写着写着就for (int i 1; i n; i)然后访问a[i]结果开了a[n]数组却把最后一个元素写到数组外面去了这就是越界。1.2 数组名和数组名别再混为一谈了这是数组概念里最经典的一个坑。数组名a在大多数表达式里会“退化”成指向首元素的指针也就是int *但它本身并不是指针变量它是数组的首地址是个常量。所以你不能a不能a other原因很简单a不是一个左值变量它是数组这个对象的名字。再看a这个很多人都写错过。a的类型是“指向整个数组的指针”即int (*)[5]不是int *。区别在哪看指针加减int a[5] {1, 2, 3, 4, 5}; printf(a %p\n, a); // 比如输出 0x1000 printf(a 1 %p\n, a 1); // 0x1004跳过一个int printf(a %p\n, a); // 0x1000 printf(a 1 %p\n, a 1); // 0x1014跳过整个数组看到没有a 1只是移动了一个元素而a 1移动了整整5个int。这两个东西在数值上一样含义完全不同。我经常看到有人用a去初始化一个int *变量编译器会报警告这不是类型匹配的。想要正确的指针类型要么用int *p a;要么用int (*p)[5] a;。注意数组名是个常量地址不是指针变量。做sizeof(a)得到的是整个数组占用的字节数而sizeof(p)只是一个指针的大小两个结果完全不同。2. 声明与初始化这些细节很多人栽过跟头2.1 各种初始化方式详解数组初始化的写法五花八门但每种写法背后都有讲究。最朴素的写法是全部列举int a[5] {1, 2, 3, 4, 5};如果只给部分初值剩下的自动补0int a[5] {1, 2}; // a {1, 2, 0, 0, 0}这一点特别有用你写int a[5] {0};就能把整个数组清零。注意这不是语法糖而是C标准明确规定的初始化列表中没写到的元素按静态初始化规则补0。所以int a[5] {0};几乎是全零数组的标准写法。如果你想省略数组大小让编译器数一下元素个数int a[] {1, 3, 5, 7, 9}; // 自动推断为5个元素这个写法有个好处是后续修改元素时不用手动调整大小但坏处是一旦元素多了代码上不太直观。还有一种比较新的写法是C99引入的指定初始化器这种写法在配置表、寄存器映射这类场景里很实用int a[10] {[0] 1, [3] 2, [9] 10}; // 其余为0这种写法最大的价值在于你不需要按顺序填跳着写也行可读性很好尤其是定义单片机引脚映射表或者解析配置协议时。实测下来 GCC 和 Clang 都支持得很好VC 的兼容性差一点但如果你的项目主要在 Linux 环境跑这招值得用。2.2 数组大小和越界的那些坑C语言数组不做越界检查这是老生常谈但很多人依旧不以为意。原因在于运行时越界不一定会崩溃。你访问a[5]时如果那片内存恰好还被你的程序占用着程序照跑不误可数据已经被你改了。这种“沉默的破坏”是最可怕的它可能等几小时之后才在其他逻辑里爆发出问题让你根本无从排查。所以从习惯上我给自己立了几条规矩循环边界一律用 长度不要写从0开始时这会自然规避越界。凡是涉及数组下标的地方第一反应就是检查边界条件。如果能用宏定义或者常量表示数组长度绝不在循环里写裸数字。还有一个隐含坑数组太大时会栈溢出。你要在函数内声明一个int a[1000000]大概 4MB在很多系统上直接段错误。这是因为局部数组分配在栈上栈空间是受限的通常也就几MB。大数组要么声明成全局要么用下一节要讲的动态内存分配。void bad_func() { int a[1000000] {0}; // 栈可能直接爆掉 }我最初学C时还真干过这种事当时一脸懵明明代码看起来没问题怎么跑一个崩一个。后来才知道栈空间是有限的。2.3 变长数组C99的弹性特征C99标准引入了变长数组允许用变量定义数组大小int n 10; int a[n]; // 变长数组刚看到这个的人可能会觉得“这不就是动态数组吗”其实不是。变长数组依然分配在栈上只不过大小是在运行期确定的编译器没法静态分配只能在栈上动态调整。它解决了“数组大小必须编译期确定”的痛点但也引来了两个麻烦如果 n 很大照样栈溢出。C 标准至今没有完全支持VL同意这个特性如果你的代码要在 C 编译器里过就得小心。所以我的建议是如果你需要运行期确定大小的数组直接用下一节的malloc更稳妥变长数组在写小型算法题时可以用但别把它当万能灵药。3. 二维数组和多维数组内存布局决定一切3.1 二维数组是怎么存内存的二维数组int matrix[3][4]在内存里也还是连续的一段空间共 12 个 int。C语言按行优先存储先放第0行的4个元素再放第1行的4个元素最后放第2行的。所以在内存里这个数组的排列顺序是matrix[0][0], matrix[0][1], matrix[0][2], matrix[0][3], matrix[1][0], matrix[1][1], matrix[1][2], matrix[1][3], matrix[2][0], matrix[2][1], matrix[2][2], matrix[2][3]理解行优先这事不只是为了应付考试。在缓存优化里知道行优先就意味着你遍历二维数组时应该按行来走也就是最外层循环用行下标内层用列下标。因为按行访问时相邻地址连续CPU缓存命中率极高如果按列访问每读一个元素就跳到几个int之外缓存会有大量缺失性能可能差出好几倍。我整理了一个简单的性能对比思路。访问顺序内存访问模式缓存友好性按行遍历连续地址极好按列遍历跳跃地址较差实际测试中一个1024×1024的 int 矩阵按行和按列遍历在旧机器上能差出两倍以上的耗时。对性能敏感的程序这个细节不能忽略。3.2 二维数组的初始化技巧二维数组初始化也有一堆细节。最常见的写法是int matrix[2][3] { {1, 2, 3}, {4, 5, 6} };如果省略第一维大小编译器能根据初始化器推断出来int matrix[][3] { {1, 2, 3}, {4, 5, 6} }; // 由此推断行数为2注意只能省略第一维后面的维度都必须写明。原因很简单只有知道完整的一行有多少个元素编译器才能算出一行占多少字节进而定位matrix[i][j]的地址。如果写成int matrix[][];编译器等差数列就没法算了。还有一个容易踩的坑初始化器大括号数量不对。看下面这个例子int matrix[2][3] {1, 2, 3, 4, 5, 6};编译器不会报错它会按内存连续排列的顺序把6个数依次填进去等价于{1, 2, 3, 4, 5, 6}分为两行。这个行为虽然能“歪打正着”但可读性极差。特别是缺元素的时候默认补0的规则同样生效int matrix[2][3] { {1}, {4, 5} }; // 结果是 // {1, 0, 0} // {4, 5, 0}我自己的习惯是二维数组初始化永远写完整的大括号嵌套即使全部补0也写{{0}, {0}}避免后续维护时看错行。4. 指针数组与数组指针别再搞混了4.1 指针数组存字符串为什么这么流行指针数组意思是“元素是指针的数组”。最经典的应用就是字符串表const char *week[] { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };这里week是一个有7个元素的数组每个元素是一个const char *指针指向各自字符串字面量的首字符。这套写法比用二维字符数组char week[7][10]有巨大的优势不同字符串长度不同用二维数组就得按最长字符串来开辟空间造成大量内存浪费而且复制字符串有长度风险。用指针数组则每个字符串字面量占自己刚刚好的空间。搜索热词“指针数组存放字符串”其实就是在问这个用法。我还想补充一个细节sizeof(week)是 7 个指针的大小不是所有字符串长度的总和。如果要遍历一般这样int count sizeof(week) / sizeof(week[0]); // 元素个数 for (int i 0; i count; i) { printf(%s\n, week[i]); }这个sizeof的写法在数组语境里非常常用可以避免硬编码7。4.2 数组指针到底怎么用数组指针是“指向数组的指针”定义形式让人眼前一晕int (*p)[5]; // p 是指向 int[5] 类型数组的指针注意括号位置很关键。如果去掉括号写成int *p[5]那就是指针数组——5个int *元素。这对哥俩常常被拿来做笔试陷阱。数组指针最常见的场景是二维数组作为函数参数。看这段代码void print_matrix(int rows, int (*matrix)[4]) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , matrix[i][j]); } printf(\n); } }matrix参数的类型是“指向含4个int数组的指针”正好能接收一个int[][4]类型的二维数组。为什么这里不能用int **因为二维数组名的退化规则是退化到“指向第一行数组的指针”也就是int (*)[4]而不是退化成二重指针int **。int **是“指向int指针的指针”和这个数组的内存布局根本对不上。很多人写函数参数时随口就是int **arr结果传二维数组过去编译就警告。这个知识点值得刻在脑子里。5. 数组的经典操作排序、逆序、去重、区间最大值5.1 字符串逆序输出字符串在C里就是字符数组所以很多数组操作的题都会拿字符串当载体。字符串逆序输出是最常见的入门题说白了就是双指针交换void reverse_string(char *s) { int left 0; int right strlen(s) - 1; // 注意去掉字符串末尾的\0 while (left right) { char tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } }这里有一个关键细节strlen返回的是字符串长度不含结尾的\0所以s[strlen(s)]是字符串终结符不能参与交换。如果错误地将right初始化为strlen(s)会把\0换到字符串开头整个字符串就变空了。还有一种不改变原字符串、只做逆序输出的做法for (int i strlen(s) - 1; i 0; i--) { putchar(s[i]); }面试时如果只要求“字符串逆序输出”而没要求修改原串问清楚再动手。5.2 冒泡排序及优化冒泡排序几乎是C语言练习里绕不开的存在。最基础的版本void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } }外层循环控制轮数内层循环做相邻比较。每轮结束后最大的元素会像气泡一样“冒”到末尾所以内层循环的右边界会每轮减1也就是n - 1 - i。很多人以为冒泡只能这么写其实还能加个优化如果某一轮内层循环一次交换都没发生说明数组已经有序可以直接退出。加一个标志位void bubble_sort_opt(int *arr, int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (!swapped) break; } }在对近似有序数组排序时这个优化会让复杂度从 O(n²) 直接降到 O(n)。我做过测试一个10000个元素的有序数组不带优化的冒泡跑了几十毫秒带优化的几乎是瞬时完成。5.3 数组去重数据去重是个高频需求。如果是已排序数组去重很简单一趟遍历就够int remove_duplicates(int *arr, int n) { if (n 0) return 0; int write 1; for (int read 1; read n; read) { if (arr[read] ! arr[write - 1]) { arr[write] arr[read]; write; } } return write; // 返回去重后长度 }这个思路是双指针read负责遍历原数组write指向下一个不重复元素应该写入的位置。因为数组已排序只要当前读到的元素和前一个写入的元素不相等就说明遇到新的不同值。如果是未排序的数组就用“空间换时间”的思路用一个辅助标记数组记录某个值是否出现过然后遍历原数组筛选。前提是你知道数据的取值范围这个值域不要太大。5.4 数组求区间最大值的经典算法热词里有“数组求区间最大值的算法题”这确实是很多算法题的基础。最简单的区间最大值实现是预处理一个二维表max_table[i][j]表示从 i 到 j 的最大值但空间占用是 O(n²) 的。另一种常见做法是稀疏表用于处理静态数组的区间最大值查询预处理st[i][k]表示从位置 i 开始、长度为 2^k 这段区间的最大值。查询区间 [l, r] 时取长度 k 使得 2^k r - l 1再查两个重叠区间再取最大值。#define MAX_N 100000 #define LOG 17 int st[MAX_N][LOG]; void build_st(int *arr, int n) { for (int i 0; i n; i) st[i][0] arr[i]; for (int k 1; (1 k) n; k) { for (int i 0; i (1 k) - 1 n; i) { st[i][k] st[i][k - 1] st[i (1 (k - 1))][k - 1] ? st[i][k - 1] : st[i (1 (k - 1))][k - 1]; } } } int query_max(int l, int r, int n) { int len r - l 1; int k 0; while ((1 (k 1)) len) k; // 找最大的2^k int left st[l][k]; int right st[r - (1 k) 1][k]; return left right ? left : right; }这个算法适合那种“数组内容固定不变、但区间查询特别多”的场景一次预处理后每个查询都是 O(1)。我当年刷题时愣是把它当成区间问题的标准起手式。6. 动态数组malloc让数组活起来6.1 定长数组的局限前面提过栈上定长数组存在两个硬伤大小编译期就固定死且栈空间有限。当你不知道数据量会有多大的时候更合理的方案是到堆上动态分配。C语言里最基础的手段就是malloc和free。6.2 malloc分配一维数组与二维数组一维动态数组很简单int *arr (int *)malloc(n * sizeof(int)); if (arr NULL) { // 处理分配失败 } // 使用数组 free(arr);注意两点第一malloc返回void *C语言里其实可以不加强制转换但C里必须转很多代码风格要求显式转换我也建议转上可读性好第二malloc可能返回NULL所以一定要检空否则后续写入空指针会段错误。二维动态数组稍微麻烦。有两种常见思路一种是用“数组指针”指向一整块二维空间一种是用“指针数组”模拟二维结构。整块分配法int (*matrix)[4] (int (*)[4])malloc(rows * sizeof(int[4]));这种写法要求列数必须编译期确定此处为4好处是内存连续和静态二维数组的布局一致访问性能最好。缺点是列数不能是运行期变量。如果行数和列数都是运行期才知道通常用指针数组int **matrix (int **)malloc(rows * sizeof(int *)); for (int i 0; i rows; i) { matrix[i] (int *)malloc(cols * sizeof(int)); }这种分配方式每行是单独 malloc 出来的内存在堆上不一定连续访问时要解两次指针性能略慢但灵活性高。释放时也要反过来一层一层释放for (int i 0; i rows; i) { free(matrix[i]); } free(matrix);很多人写完free(matrix)就以为完事了结果内存泄漏。每一行也是单独分配的内存必须逐一释放。这个习惯一定要养成我甚至建议每一步都写注释标明配对关系。6.3 动态数组扩容动态数组不能真的无限增长初始分配不够用了就需要扩容。一个实用的扩容策略是“倍增”当元素数量达到容量上限时申请一块原来两倍大小的新内存把原有数据搬过去再释放旧内存。typedef struct { int *data; int size; int capacity; } IntArray; void array_push(IntArray *arr, int value) { if (arr-size arr-capacity) { int new_cap arr-capacity 0 ? 4 : arr-capacity * 2; int *new_data (int *)realloc(arr-data, new_cap * sizeof(int)); if (new_data NULL) { // 处理失败 return; } arr-data new_data; arr-capacity new_cap; } arr-data[arr-size] value; }为什么用两倍而不是“加100”因为等差数列扩容的均摊复杂度是 O(n²)倍增扩容的均摊复杂度是 O(1)。这个摊还分析是所有动态数组实现的核心Java的ArrayList、C的vector扩容策略本质上都是这套思路。realloc这个函数值得一提它可以在原地扩容如果不能原地扩就会重新分配并复制旧数据。所以用realloc之后原来的指针不要继续引用要用它返回的新指针。上面代码里我用new_data接收返回值更新后再赋给arr-data这点很重要。7. 数组常见问题速查表我把这些年在实际项目中遇到的数组相关高频问题整理成一个速查表遇到同类问题可以直接对着排查。报错/现象常见原因解决办法段错误崩溃数组越界写入 / 栈空间不足检查边界条件大数组改用malloc数据莫名其妙被改越界写出了内存边界用 AddressSanitizer编译排查传递二维数组报警告函数参数用了int **改写成int (*)[列数]sizeof(arr)不对数组作为函数参数退化为指针传递长度参数或使用宏计算数组初始化全零失败只写了int a[5]未初始化写int a[5] {0};malloc后内存泄漏没有配对free / 二维数组只释放外层检查每个malloc必须配对freerealloc后旧指针失效原内存被重新分配一律使用返回值更新指针字符串逆序结果为空串\0被交换到开头right初始化为strlen(s)-1排查数组问题最有效的工具我强烈推荐 AddressSanitizer。编译时加-fsanitizeaddress -g运行时它会精确报告是哪一行、哪个下标越界。这个工具拯救过我好几次比肉眼盯着代码看强太多。gcc -g -fsanitizeaddress -o program program.c ./program出现越界时它会输出类似heap-buffer-overflow或stack-buffer-overflow的定位信息跟着信息去改很快就搞定。另外再分享一个小技巧调试时多打印地址和偏移。我之前排查一个疑难的二维数组问题时就是在每个关键节点打印变量的地址和指针的数值才定位到是行指针偏移算错了。地址本身就是信息数组问题尤其如此。最后说说我个人的经验吧。数组之所以容易出问题恰恰因为它太灵活、太贴近底层了。顺手把边界条件写清楚、把初始化写完整、把malloc的配对关系理清楚这些习惯远比知道几个语法细节重要。我把这套规矩用在项目里之后数组相关的bug出现频率直线下降。希望这篇梳理也能帮你把数组这块基础补扎实。