有一回我帮人看一段C语言代码,他只想把字符串逆序输出,结果程序一跑就崩。折腾了半天,发现他把char *p = "hello"当成可修改的字符数组来用,逆序时直接往只读区写数据。C语言数组操作就是这样:表面上就是方括号和下标,真正写起来,内存布局、指针退化、边界条件,每个细节都能让人崩溃。这篇文章是我这些年写C、带新人、刷OJ题攒下来的数组操作心得,从初始化、指针与数组的关系,到字符串处理、二维与动态数组,再到排序查找和调试排错,一次讲透。适合正在学C语言的学生、准备刷PAT乙级题目的朋友,以及工作中需要补基础的开发者。
1. 数组的内存模型与初始化细节
1.1 三种初始化方式,哪一种场合用哪一种
数组在C语言里的本质是一块连续内存,声明的时候必须告诉编译器元素的类型和个数。但很多初学者在初始化这一步就开始踩坑。看下面这段代码:
int a[10]; // 未初始化,里面是随机值 int b[10] = {0}; // 全部清零 int c[5] = {1,2,3}; // 前三个元素赋值,后面自动补0 int d[] = {1,2,3,4}; // 编译器根据列表推断长度为4 int e[10] = { [2] = 8 }; // C99指定初始化器,e[2]=8,其余为0第一行的a[10]是最容易出问题的写法。如果是局部变量,这块内存是栈上残留的数据,打印出来什么妖魔鬼怪都有;如果是全局变量,ISO C标准保证初始化为0。所以我的习惯是:局部数组一定显式初始化,哪怕只是int a[10] = {0};。这不只是让程序好跑,更是让行为可以预测。
第二行{0}是清零数组最省事的写法,但注意它只保证把所有元素初始化为0,如果你想要把数组填成其他值,比如全部填成1,{1}并不管用,它只会把第一个元素设为1,后面依然补0。这种场景要么写循环,要么用memset。
int d[] = {1,2,3,4};这种写法适合元素个数由列表决定的情况,但要记住sizeof(d) / sizeof(d[0])才是元素个数,很多人直接拿sizeof(d)当元素个数去遍历,结果遍历范围被严重放大。C99的指定初始化器在嵌入式开发里很实用,比如初始化寄存器映射表,但在日常业务代码里用得不多,知道有这个东西就行。
1.2 索引、偏移量与sizeof:地址加一到底加了多少
很多新手把数组下标理解成“第几个元素”,其实C语言的下标完全是偏移量概念。a[i]等价于*(a + i),意思是“从首地址向后移动 i 个元素步长再解引用”。这个“步长”由元素类型决定,int数组移动一次偏移4字节,char数组移动一次只偏移1字节。
看一个实际输出:
#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; printf("arr = %p\n", (void *)arr); printf("&arr[0] = %p\n", (void *)&arr[0]); printf("arr+1 = %p\n", (void *)(arr + 1)); printf("sizeof(arr) = %zu\n", sizeof(arr)); return 0; }在我的64位Linux机器上,arr和&arr[0]打印出的地址一样,arr+1比它多4字节,sizeof(arr)输出20,因为5个int一共20字节。这个输出说明了三件事:
| 表达式 | 类型含义 | 64位平台sizeof结果 | 加1后的地址偏移 |
|---|---|---|---|
arr | 数组名,多数表达式里退化为首元素指针 | 整个数组大小20字节 | 4字节 |
&arr | 指向整个数组的指针,类型int(*)[5] | 指针本身8字节 | 20字节 |
&arr[0] | 指向首元素的指针,类型int * | 指针本身8字节 | 4字节 |
这里的重点不是背表格,而是理解:同一个符号在不同上下文里类型不同。arr+1的偏移是4,因为编译器把arr当成了int *;&arr+1的偏移是20,因为&arr的类型是“指向5个int数组的指针”。有个经典面试题就是问*(&arr + 1) - arr等于多少,其实就是两个地址之差除以4,结果是5。
还有一个常见误区是把sizeof(arr)用在函数参数里。后面第2节我会细讲,这里先记住:数组名在sizeof操作符里代表整个数组,在表达式运算里退化成首元素指针,这是C语言里少有的“上下文相关”语法。
2. 指针与数组的纠缠:数组名到底是不是指针
2.1 数组名的三种身份,最容易踩坑的是&arr
“数组名是不是指针”这个问题,知乎上能吵几百层楼。准确说法是:数组名不是指针,但在绝大多数表达式中会退化为指向首元素的指针。例外只有两个:一是sizeof(arr),二是&arr。
用代码验证:
#include <stdio.h> int main(void) { int arr[5] = {1,2,3,4,5}; int *p = arr; // 正确:arr退化为int* // int **pp = &arr; // 错误:类型不匹配,编译告警 int (*pa)[5] = &arr; // 正确:pa是指向整个数组的指针 printf("pa = %p, pa+1 = %p\n", (void *)pa, (void *)(pa + 1)); return 0; }pa加1之后,地址直接跳过了20字节,也就是一整个数组。这就是“指向数组的指针”和“指向数组首元素的指针”的本质区别。很多人写代码时会用int *p = &arr;,这在C语言里会得到警告,因为&arr的类型和int *不兼容,虽然在地址数值上一样,但语义完全不同。我见过有人用int **接收arr,这也是错的,因为arr退化后的类型是int *,不是int **。指针类型错了,后面做指针运算时偏移量全乱套。
2.2 参数退化的本质,以及“移动指定位输出字符”的实操
再看函数传参。你写:
void print_array(int a[], int n) { printf("sizeof(a) = %zu\n", sizeof(a)); // 输出8,不是40 for (int i = 0; i < n; i++) { printf("%d ", a[i]); } putchar('\n'); }调用方传一个int a[10]进去,函数里sizeof(a)却只得到8字节,这就是数组参数退化成了指针。编译器看到函数参数列表里的int a[],直接当成int *a处理。所以函数内部永远无法通过sizeof得知原数组大小,必须额外传一个长度参数。
这个规则在你用“指针移动指定位输出字符”时特别有用。比如这样一段文字操作需求:从一个字符串的第N个字符开始输出指定长度的内容。用数组下标写很直白:
#include <stdio.h> #include <string.h> void print_substr(const char *s, int start, int len) { size_t slen = strlen(s); if (start < 0 || start + len > slen) { printf("越界\n"); return; } for (int i = 0; i < len; i++) { putchar(s[start + i]); } putchar('\n'); } int main(void) { char msg[] = "C language array"; print_substr(msg, 2, 9); // 输出 language,注意下标从0开始 return 0; }也可以直接用指针偏移:
char *p = msg + 2; printf("%s\n", p); // 从第3个字符开始一直输出到结尾如果只要中间一段,就得控制长度,或者临时把字符写进另一个数组再补'\0'。这个操作在字符串解析场景里非常常见,比如日志文件里截取时间字段、协议解析时提取报文子串。动手前先算好边界,尤其是start + len不能超过字符串有效长度,不然就会越界读。
3. 数组的日常操作:拷贝、比较、逆序,一个比一个容易翻车
3.1 为什么不能直接a=b,拷贝和比较要用什么函数
数组不能整体赋值,这是很多从Python转过来的朋友第一反应接受不了的。int a[5], b[5]; a = b;在C语言里非法,因为a作为表达式退化成指针,给指针赋另一个数组名,语法层面就过不去。你要拷贝,只能逐元素复制,或者用库函数。
#include <string.h> int a[5] = {1,2,3,4,5}; int b[5]; memcpy(b, a, sizeof(a)); // 整个数组拷贝 memcpy(b, a, 5 * sizeof(int)); // 效果相同,但写得啰嗦 char s1[10], s2[10] = "hello"; memcpy(s1, s2, sizeof(s2)); // 拷贝10字节,包括末尾的'\0'memcpy按字节数拷贝,不关心你拷的是int还是char,所以第三个参数必须用字节数。一个常见错误是写memcpy(b, a, 5),只拷5字节,int数组至少20字节,结果只拷了一小段数据。另一个常见错误是拷完字符串后忘记补'\0',用printf("%s", s1)打印时就会越界读,直到遇到某个内存里的0字节才停。
数组比较也一样。if (a == b)比较的是两个首地址是否相同,不是数组内容是否相等。要比较内容,可以用循环逐元素比较,也可以用memcmp(a, b, sizeof(a))。注意memcmp返回0表示相等,返回值正负只代表比较结果,不要当布尔值直接乱用。
memcpy和memmove的区别也要搞清楚:如果目标地址和源地址区间重叠,memcpy的行为是未定义的,memmove则保证像先用临时buffer一样正确处理重叠。比如把数组元素整体右移一位:
int arr[5] = {1,2,3,4,5}; memmove(arr + 1, arr, 4 * sizeof(int)); // arr变成 {1,1,2,3,4}如果这里用memcpy,结果在不同平台可能不一样,甚至直接覆盖出错。所以处理重叠拷贝时,老老实实用memmove。
3.2 字符串逆序:从PTA题到日常处理的核心思路
字符串逆序是PTA乙级常见的入门题,也是数组操作的经典练习。核心思路是“双指针收尾交换”:一个指针从头往尾走,一个从尾往头走,每次交换两个字符,直到相遇。
#include <stdio.h> #include <string.h> void reverse(char s[]) { int i = 0; int j = (int)strlen(s) - 1; while (i < j) { char tmp = s[i]; s[i] = s[j]; s[j] = tmp; i++; j--; } } int main(void) { char str[] = "hello"; reverse(str); printf("%s\n", str); // 输出 olleh return 0; }这里有几个细节一定得说清楚。
第一,strlen(s)返回的是字符串长度,不包含结尾的'\0',所以最后一个有效字符的下标是strlen(s)-1。如果你直接用sizeof(s),在函数内部它已经退化成了指针大小,结果完全不对。
第二,交换的循环条件用i < j,不要写成i <= j,那样中间那个字符会被自己交换一次,虽然结果不变,但逻辑上没必要。
第三,也是最容易莫名其妙的:如果传入的是字符串字面量,比如reverse("hello"),程序可能直接段错误。因为字符串字面量在C标准里是存于只读存储区的,修改它属于未定义行为。只要你的函数可能修改字符内容,就必须保证传入的是可写的字符数组。
日常工作中,字符串逆序很少只针对英文。如果处理UTF-8编码的中文,一个汉字占3字节,直接按字节双指针交换会把汉字内部字节拆散,输出乱码。这种场景要么用专门的宽字符函数,要么按“字符边界”处理。刷题遇到测试数据只有ASCII时不用管,但做真正的产品功能前一定要意识到这个问题。
4. 二维数组与动态数组:布局、传参和内存释放
4.1 二维数组的连续存储与函数传参方式
二维数组在C语言里本质是一维数组的数组,内存上依然是连续排列的。比如int matrix[3][4],是12个int连续排成一行,按行优先存储:先存第0行的4个,再存第1行的4个,最后是第2行的4个。matrix[1][2]的地址偏移是(1 * 4 + 2) * sizeof(int)。
想验证这一点很简单,用printf把所有元素的地址打出来看,或者用一个int *p = &matrix[0][0],直接用p[i * 4 + j]访问二维数组的内容:
#include <stdio.h> int main(void) { int matrix[3][4] = { {1,2,3,4}, {5,6,7,8}, {9,10,11,12} }; int *p = &matrix[0][0]; for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%2d ", p[i * 4 + j]); } putchar('\n'); } return 0; }这个技巧在需要把一个二维数组当作一维buffer传给某些API时很有用。但要注意:你只是把二维数组“看成”一维的,类型本身没有变化。真正在函数间传递二维数组时,必须指定第二维:
void print_matrix(int a[][4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", a[i][j]); } putchar('\n'); } }函数参数里的int a[][4]会被编译器解释为int (*a)[4],类型是“指向含4个int数组的指针”。所以第二维必须写,否则编译器不知道每一行有多长,没法计算a[i][j]的地址。如果你希望函数对不同列数都通用,那不能直接用二维数组参数,得改为“手动计算偏移”的方式,也就是传一个一维指针、行数和列数,在函数内用a[i * cols + j]访问。
九九乘法表也是典型的二维数组应用。你可以先把结果存进int table[10][10],再按行输出,这样表数据可以反复查,比直接在循环里打印更灵活:
int table[10][10] = {0}; for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { table[i][j] = i * j; } }4.2 malloc实现的“数组”到底该怎么分配和释放
栈上的数组大小在编译期就要确定,但很多时候运行时才知道用户要多少数据,比如读取一张图片的像素数组。这时候需要动态分配。
一维动态数组最简单:
#include <stdio.h> #include <stdlib.h> int main(void) { int n = 10; int *arr = (int *)malloc(n * sizeof(*arr)); if (arr == NULL) { perror("malloc"); return 1; } for (int i = 0; i < n; i++) { arr[i] = i * i; } free(arr); return 0; }很多人问为什么要malloc(n * sizeof(*arr))而不是malloc(n * 4)。因为sizeof(*arr)是根据指针指向类型自动求的,如果以后int改成short或long,代码不用逐个改,不容易出错。
二维动态数组有两种常见写法。第一种是分配一大块连续内存:
int rows = 3, cols = 4; int *mat = (int *)malloc(rows * cols * sizeof(*mat)); if (mat == NULL) { return 1; } // 用 mat[i * cols + j] 访问 free(mat);第二种是指针数组:
int **mat = (int **)malloc(rows * sizeof(int *)); if (mat == NULL) { return 1; } for (int i = 0; i < rows; i++) { mat[i] = (int *)malloc(cols * sizeof(int)); if (mat[i] == NULL) { // 需要释放前面已分配的行 } } // 用 mat[i][j] 访问 // 释放时先释放每行,再释放mat本身 for (int i = 0; i < rows; i++) { free(mat[i]); } free(mat);这两种方案各有适用场景。连续内存块访问效率高、缓存友好、释放简单,但下标要手动算;指针数组写起来更接近二维数组,每一行地址独立,释放却很繁琐,而且每一行单独malloc容易产生内存碎片。以我写嵌入式和普通服务端代码的经验,能使用连续分配就不要用指针数组,尤其是数据量大时,连续分配的遍历速度明显更快。
还要注意,C语言里malloc的返回值其实不用强转,void *能自动转换到任意对象指针。但如果你打算用C++编译器编译C代码,就需要强转。我个人的习惯是写C的时候不强转,写C++时强转,避免两种标准混淆。
另外,C99支持变长数组(VLA),可以这样写:
int rows = 3, cols = 4; int mat[rows][cols];看起来方便,但它是分配在栈上的,rows * cols一大,很容易爆栈。嵌入式开发里经常限制栈大小,所以我对VLA的态度是:测试环境用用可以,生产代码尽量别依赖。
5. 排序与查找:数组最经典的两种应用场景
5.1 冒泡排序的实现、优化和qsort库函数
排序是数组操作绕不开的经典话题,冒泡排序更是C语言课的标配。我见过太多人在纸上写冒泡没问题,一到电脑上就下标越界。先看一个带优化的稳定版本:
void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } if (!swapped) { break; } } }两个边界是重点。
内层循环的n - 1 - i为每一轮完成后,最大的元素已经沉到末尾,下一轮不需要再比较它。如果你写成j < n - 1,功能不会错,但多做了很多无意义比较。
加swapped标志是一个经典优化。如果一整轮都没有发生交换,说明数组已经有序,直接跳出,最好情况下的时间复杂度能从O(n^2)降到O(n)。
不过在实际项目中,很少直接手写冒泡排序处理大量数据。C标准库提供的qsort功能更强大,只是比较函数的写法对新手不太友好:
#include <stdlib.h> int cmp_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } int main(void) { int arr[] = {4, 2, 7, 1, 9}; size_t n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(arr[0]), cmp_int); return 0; }为什么比较函数里用(x > y) - (x < y)?因为直接返回x - y在极端情况下可能溢出。比如x是INT_MAX,y是-1,x-y直接溢出成未定义行为。用两个比较表达式的结果相减,返回值只可能是-1、0、1,安全且逻辑清楚。
数组排序的另一个隐性坑是:排序函数里传进来的数组名已经退化成指针,所以qsort必须显式传入元素个数和元素大小。这一点和之前说的函数传参退化完全一致。
5.2 二分查找的边界条件与防溢出写法
数组有序之后,查找就变成了一个高频操作。顺序查找很简单,但数据量大时效率太低,二分查找把时间复杂度降到O(log n)。代码本身没几行,边界条件却经常写错。
int binary_search(int a[], int n, int target) { int left = 0; int right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) { return mid; } else if (a[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }为什么mid要写left + (right - left) / 2,而不是(left + right) / 2?因为当left和right都很大时,相加可能溢出整数范围。老生常谈,但确实在刷题和实际工程里都出现过。
边界更新的+1和-1也不能省。如果你写了left = mid而不是left = mid + 1,当target不存在且left和right相差1时,mid永远等于left,循环可能永远跳不出去。同理,right = mid - 1保证每次搜索区间都严格缩小。
while (left <= right)和while (left < right)对应的right初始化也不同。<=配合right = n-1是闭区间;<配合right = n是左闭右开区间。很多人看网上的代码混着抄,一会儿<=一会儿right = n,结果边界全乱。我的建议是:固定使用一种写法,并始终写清楚你的区间是闭还是开。
顺带一提,数组在算法题里也常用来存临时结果。比如“完数”的问题,需要先把因子收集到数组里,最后判断因子和是否等于原数。这个场景里数组的作用就是“先攒数据、后统一处理”,比边遍历边拼接字符串优雅得多。
6. 调试与避坑:数组越界、未初始化、自增边界
6.1 数组越界为什么是最难发现的bug
越界读可能只是打印出垃圾数据,越界写则可能神不知鬼不觉地改掉别的变量,甚至改掉函数返回地址。最典型的例子:
#include <stdio.h> int main(void) { int arr[3] = {0, 0, 0}; int check = 100; for (int i = 0; i <= 3; i++) { // 问题就在 <= arr[i] = i; } printf("check = %d\n", check); return 0; }在我的栈布局下,arr[3] = 3很可能把紧挨着的内存写成了3,而那个位置正好是check变量,结果输出check = 3而不是100。程序没崩,但结果完全错了。这就是数组越界最阴险的地方:它不保证每次都崩溃,反而让你以为只是普通逻辑错误。
解决方法首先是写代码时心里有边界:数组长度为n时,合法下标是0到n-1,循环条件习惯性写成i < n。其次是借助工具,编译器开上-Wall -Wextra,能抓到很多明显问题。再进一步,可以用地址消毒器:
gcc -g -fsanitize=address -o prog prog.c ./progASan会在运行到越界写的那一刻直接报错,并指出是哪一行代码越界。这种工具在开发阶段开着,能省下大量排查时间。
6.2 快速定位数组问题的手段:gdb、VSCode和自检清单
如果程序已经跑偏,但没有开启ASan,可以用调试器定位。gdb用得最多的三个命令是:
break main # 在main函数入口暂停 print arr[2] # 查看某个元素 x/10dw arr # 以10个4字节单位查看arr内存块调试时看到的内存块往往比打印出的数组信息更直观。比如arr长度明明是5,你用x/10dw一看,后面还跟着其他局部变量的值,马上能意识到“这附近的变量可能被越界覆盖”。
在VSCode里调试C语言也很顺手。先配置好tasks.json负责编译(gcc -g),再配置launch.json选择gdb调试器,然后在代码行号左侧点断点,运行后左侧变量区能直接展开数组,监视窗口里还可以写arr[0]@5表示连续查看5个元素。配置本身一两次就熟了,关键是调试思维:数组一旦不对,先看地址和长度,别急着改算法。
我自己的排查经验是,数组相关的bug九成离不开下面这几个原因:
- 把
i < n写成了i <= n,多跑了一次; - 字符串数组忘记留
'\0'的位置,strlen到最后读到越界; - 函数内
sizeof(a)当成数组大小,实际退化成指针大小; arr[i++]和arr[++i]的下标边界没分清,自增放前面和放后面差一个位置;- 动态分配的数组用完后忘了
free,或者free之后还用。
这里特别说一下arr[i++]和arr[++i]。初学者常常在循环里纠结。arr[i++] = x等价于arr[i] = x; i = i + 1;,也就是先把值写到当前位置,再把下标加1;arr[++i] = x则是先让i加1,再写到新位置。如果你本来想顺序填充数组,结果用了arr[++i],第一个元素arr[0]永远没机会被写,而且最后一次会越界。这类问题我会在代码里尽量避免写得太隐晦,宁可拆成两行,给后来读代码的人留条活路。
调试过程中还有一个实用技巧,就是用fprintf把关键中间状态输出到日志文件。比如某个循环里数组的值不对,与其只在控制台看几个printf,不如直接:
FILE *fp = fopen("debug.log", "a"); for (int i = 0; i < n; i++) { fprintf(fp, "arr[%d] = %d\n", i, arr[i]); } fclose(fp);这样一轮一轮的具体数据都留着,能对比是第几轮开始错的,尤其是数据量大、控制台刷屏的情况,这个方法比逐步断点更高效。
我个人在后来的C语言实践中有一个习惯:写数组操作前先画一张内存图,把数组的起始地址、每个元素的偏移、循环的下标范围都标出来,再动手写代码。每次越界事故,几乎都能追溯到“多一个元素”“少一个'\0'”“把字符串字面量当数组改”这三件事上。数组操作没什么玄学,无非是心里时刻记得三件事:内存是连续的,下标从0开始,边界不由编译器兜底。如果你也在被数组折腾,把我的这些土办法回去试一遍,应该能少走不少弯路。