如果你刚学到C语言的数组,大概率会困惑:int a[5];这个a到底是一个数组还是一个指针?为什么访问a[5]不报错却能打印出奇怪的值?为什么同一个数组,在主函数里能用sizeof算出长度,换个函数就不行?
这篇是C语言学习系列的第一篇,专门把数组这件事讲透。我打算从内存布局讲起,再依次展开数组的声明与初始化、一维二维操作、字符串数组、函数传参,最后落到变长数组和动态数组。内容定位是“新手能看懂,老手能查漏”:如果你刚开始学数组,可以顺着读;如果你学完指针想回来填坑,直接跳到第5章和第6章看最实用的部分。读完之后,你至少能回答:数组名为什么能当指针用、下标运算符到底做了什么、数组长度为什么会被“弄丢”、动态内存怎么配合数组使用。
1. 先搞清楚数组在内存里到底占了多少地方
1.1 连续内存和下标偏移:数组名只是首地址
看这一段代码:
int a[5] = {10, 20, 30, 40, 50};假设你的机器上int占 4 字节,那a会在栈上分配sizeof(int) * 5,也就是 20 字节的连续空间。a[0]的地址最低,a[4]的地址最高,5 个元素一个挨一个排在一起。你可以打印每个元素的地址验证,会发现它们正好相差sizeof(int)。
下标访问在C语言里本质是一个语法糖:a[i]等价于*(a + i)。意思是“从首地址出发,向后偏移i个元素步长,再解引用”。这也是为什么数组下标从 0 开始:下标值恰好等于偏移量,a[0]就是首地址处那个元素,不需要额外减 1。
我一般喜欢用储物柜来类比:一排编号从 0 到 4 的柜子,每个柜子大小一样。你想找 3 号柜,只需要知道第一个柜子在哪,然后往前迈 3 步。数组元素类型必须相同,正是因为每个“柜子”宽度一致,编译器才能用同一个步长i * sizeof(type)计算任意元素的地址。如果数组里混着int和double,步长就无法统一,按地址偏移的方案就彻底失效了。
1.2 用变量代替数组的问题:数组解决的是“批量 + 索引”
有人问,数组不就是一堆变量吗,那我写 5 个变量行不行?行,但极其痛苦。比如求 5 个分数的平均分,不写数组是这样:
double score1, score2, score3, score4, score5; scanf("%lf %lf %lf %lf %lf", &score1, &score2, &score3, &score4, &score5); double avg = (score1 + score2 + score3 + score4 + score5) / 5;如果变成 100 个分数呢?变量名没有办法动态生成,循环也用不上。数组的价值就在这里:把同类型数据放进一段连续内存,用同一个名字加上下标去访问,代码才能写成循环结构。数组提供了两个核心能力:批量存储同类型元素,以及通过下标进行 O(1) 随机访问。
这里顺便提一个关键点:C语言不会检查数组越界。原因很简单——a[i]最终只是被编译器翻译成“计算地址并访问”,它不会在运行时去判断i是否在[0, n)范围内。这种设计把检查责任完全交给了程序员,换来了极低的访问开销。后面我会专门讲越界的坑,这里先有这个概念。
1.3 用 sizeof 计算数组长度:只在“数组变量”上有效
在数组所在的声明作用域里,sizeof(a)返回的是整个数组占用的字节数。计算元素个数有一个经典写法:
int len = sizeof(a) / sizeof(a[0]);这个写法在数组变量所在的地方是对的。比如int a[5],sizeof(a)是 20,sizeof(a[0])是 4,相除得到 5。但这句话只对“真数组变量”成立。一旦把数组作为参数传给函数,形参那边的sizeof就不再是数组大小了,这个“长度丢失”问题我放到第5章细说。现在你只要记住:sizeof(a) / sizeof(a[0])在函数外部用很香,在函数内部用会翻车。
2. 声明与初始化:C语言新手最容易翻车的几个写法
2.1 初始化的不同形式与默认值
数组声明可以写出各种花样,我见过最多的问题都出在初始化和越界这两件事上。先看几种常见写法:
int a1[5]; // 局部变量,未初始化,里面是垃圾值 int a2[5] = {0}; // 全部初始化为 0 int a3[5] = {1, 2, 3}; // 前 3 个是 1,2,3,后 2 个自动补 0 int a4[] = {1,2,3,4,5}; // 编译器自动推导长度为 5 int a5[5] = {[2] = 9}; // C99 指定初始化,第 3 个元素是 9,其余是 0特别注意a2[5] = {0}并不是“把第 0 个元素写成 0,其他随便”,而是C语言里一个约定:只要初始化列表里的值不足数组长度,剩余元素会被补零。很多人以为{0}只初始化第一个元素,然后去读a2[1]发现是 0,又开始怀疑自己是不是运气好。其实规则就是补零,放心用。
a4[]这种写法依赖编译器从初始化列表推导长度,适合那些“初始化时已经知道内容”的场景。a5的指定初始化器在嵌入式代码里很常见,比如只想设置某个特殊位,其他保持 0,但初学者不熟悉的话容易看懵,可以先了解。
还有一个默认值规律:局部数组如果不初始化,里面是“不确定值”,本质是栈上残留的旧数据;而全局数组和static修饰的局部数组会默认清零。不要在程序里依赖“局部数组碰巧是 0”,那是未定义行为级别的赌博。
2.2 char 数组初始化的特殊点:字符串结束符
字符数组是数组初始化里最容易踩坑的一块。看这几行:
char s1[5] = "hello"; // 危险!"hello" 实际占 6 字节 char s2[6] = "hello"; // 正确:h e l l o \0 char s3[10] = "hello"; // 可以,后面自动补 0 char s4[] = "hello"; // 编译器推导长度 6C语言里字符串字面量"hello"并不是 5 个字符,而是 6 个字符,最后面还有一个\0结束符。C语言没有内置的字符串类型,只能靠字符数组加结束符来模拟。strlen统计的是\0之前的字符数,所以strlen(s4)是 5;sizeof(s4)是 6,它算的是数组总字节数。
新手最容易犯的错误是写char s[5];然后strcpy(s, "hello");。这相当于往一个只能装 5 个字节的盒子里塞 6 个字节,第 6 个字节的\0会写到数组外面。程序可能不崩,因为越界写入的只是相邻栈空间,但你已经破坏了别人的数据。这种错最难查,因为它不会当场报错。
2.3 越界不报错是未定义行为,不是“碰巧能跑”
来看一个经典危险代码:
#include <stdio.h> int main(void) { int a[3] = {1, 2, 3}; printf("%d\n", a[3]); return 0; }a[3]越界了,但很多编译器编译时不会警告,运行时也不一定会崩。它可能打印 0,可能打印乱七八糟的数,也可能因为破坏了关键数据直接段错误。C标准里这种情况叫“未定义行为”,意思是你无法预测它接下来会干什么。
C语言不像 Java 会在运行时抛数组越界异常,因为数组内部根本不保存长度元数据,访问时不做边界检查。这是性能换安全的典型场景。我在实际调程序时见过不少这样的问题:一个循环多写了一个元素,数组后面紧挨着的某个变量值被改掉,程序表现变得时好时坏。排查这类问题有两个很实用的工具:GCC 编译时加-fsanitize=address,运行时会直接报告越界位置;或者用 Valgrind 检测内存非法访问。建议学完数组之后就练会这两把刀,后面写任何C程序都会省力。
3. 一维数组的五个基础操作:遍历、逆置、查找、排序、拷贝
3.1 遍历与累加:先写对循环边界
数组操作逃不开遍历,而遍历最容易错的是边界。正确的区间是[0, n),也就是从 0 开始,到n - 1结束。求和的例子:
int arr[] = {15, 8, 23, 6, 42}; int n = sizeof(arr) / sizeof(arr[0]); int sum = 0; for (int i = 0; i < n; i++) { sum += arr[i]; } double avg = (double)sum / n; printf("sum=%d, avg=%.2f\n", sum, avg);求最大值也类似,但初始值要注意:如果直接int max = arr[0];,那么数组为空时arr[0]本身就越界了。更稳妥的做法是用int max = INT_MIN;然后遍历,再把INT_MIN包含进来,所以需要包含<limits.h>。实际刷题时数组通常非空,但养成考虑空数组的习惯能避免不少线上崩溃。
关于循环边界,有一个典型错误是for (i = 1; i <= n; i++)访问a[i]。这种写法少访问第一个元素、多访问最后一个越界元素。每当程序运行结果“差一点点正常”,第一反应就去查循环边界是不是多了 1 或者少了 1。
3.2 逆置与二分查找:边界条件是重点
逆置数组的意思是让首尾交换。用两个下标从两端往中间走:
int left = 0, right = n - 1; while (left < right) { int tmp = arr[left]; arr[left] = arr[right]; arr[right] = tmp; left++; right--; }这里终止条件是left < right。如果写成left <= right,对于奇数长度的数组,中间那个元素会和自己交换一次,不影响结果,但逻辑上不够干净;对于偶数长度,左右下标会交错,必须在循环体里额外判断,否则会出现重复交换。所以用left < right是最稳妥的写法。
二分查找是数组操作的进阶题,要求数组必须有序。闭区间写法如下:
int binary_search(int a[], int n, int target) { int low = 0, high = n - 1; while (low <= high) { int mid = low + (high - low) / 2; if (a[mid] == target) { return mid; } else if (a[mid] < target) { low = mid + 1; } else { high = mid - 1; } } return -1; }细节有三个。第一,mid要用low + (high - low) / 2,不要写(low + high) / 2,后者在数字大时可能整数溢出。第二,while条件是low <= high,因为闭区间内只剩一个元素时还需要再判断一次;如果写成<,会漏掉目标恰好在最后一个位置的情况。第三,更新边界时是low = mid + 1和high = mid - 1,直接写low = mid会遇到死循环,因为区间没有缩小。
3.3 冒泡排序与选择排序:先会写,再谈优化
排序是数组操作的分水岭。冒泡排序最直观,代码也短:
void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { 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; } } } }每一轮会把当前未排序部分的最大值“冒”到最后面,所以内层循环范围每轮减少一个。j < n - 1 - i里的- i很容易写漏,漏掉之后逻辑仍然勉强能跑,但会多做很多无效比较。冒泡排序是稳定排序,相同元素的相对顺序不会变,这一点在某些工程场景很重要。
选择排序的思路是每轮找最小值的下标,然后放到前面:
void selection_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[min_idx]) { min_idx = j; } } if (min_idx != i) { int tmp = a[i]; a[i] = a[min_idx]; a[min_idx] = tmp; } } }选择排序的交换次数比冒泡少,每次外层循环最多交换一次,所以对“写入成本高”的数据结构更友好。但它不稳定,比如数组[2a, 1, 2b]排序后会变成[1, 2b, 2a],两个 2 的相对顺序可能改变。入门阶段不用太纠结稳定性,但要知道有这回事。
3.4 数组插入与删除的本质:移动数据,而不是改变长度
你可能听说过“数组增加一个元素”“数组删除一个元素”,但C语言的固定大小数组本身长度不变,所谓插入和删除,本质是移动元素。例如在下标pos处插入value:
int arr[10] = {1, 2, 3, 4, 5}; int n = 5; // 当前有效元素个数 int pos = 2; int value = 99; for (int i = n; i > pos; i--) { arr[i] = arr[i - 1]; } arr[pos] = value; n++;移动必须从后往前,否则前面的元素会覆盖后面的元素。删除则反过来,从前往后覆盖:
for (int i = pos; i < n - 1; i++) { arr[i] = arr[i + 1]; } n--;这两个操作在数据结构里属于基础中的基础。你要始终记住:数组的物理容量不变,实际元素个数靠你自己维护一个n。这也是为什么后面出现动态数组时,大家会强调“容量”和“有效长度”是两个概念。
4. 二维数组与字符串数组:它们不是随便嵌套
4.1 内存布局和下标计算:为什么第二维不能省略
二维数组int b[2][3]的准确理解是:一个有 2 个元素的数组,每个元素又是一个int[3]数组。它在内存里依然是连续排列的,按行优先存储:
int b[2][3] = {{1, 2, 3}, {4, 5, 6}};物理顺序是b[0][0], b[0][1], b[0][2], b[1][0], b[1][1], b[1][2],一共 6 个int。访问b[i][j]时,编译器按公式计算地址:首地址 +(i * 列数 + j) * sizeof(int)。这就是为什么声明二维数组时第二维必须写:没有列数,编译器就算不出每一行从哪里开始、b[i]该跳到哪个地址。
第一维可以省略,比如:
int d[][3] = {{1,2,3}, {4,5,6}, {7,8,9}};编译器看到每行是 3 个int,一共 9 个元素,自动推断第一维是 3。但是写成int e[2][]就是错的,因为第二维未知,连一行有多长都不知道。
二维数组的遍历一般是双重循环:
int rows = sizeof(b) / sizeof(b[0]); int cols = sizeof(b[0]) / sizeof(b[0][0]); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { printf("%d ", b[i][j]); } printf("\n"); }其中sizeof(b[0])是第一行那一个一维数组的总字节数,再除以单个元素大小就是列数。这个技巧在二维数组上很好用,但同样只在数组变量声明所在作用域内有效。
4.2 指针数组与数组指针:名字像,差别很大
热词里经常看到“指针数组”和“数组指针”,这俩名字看着像,含义完全相反。
int *p[3]; // 指针数组:p[0], p[1], p[2] 都是 int* int (*q)[3]; // 数组指针:q 指向“含 3 个 int 的一维数组”int *p[3]可以这样理解:p先是一个数组,数组里每个元素是int*。它经常用来做字符串数组:
char *colors[] = {"red", "green", "blue"}; printf("%s\n", colors[1]); // green printf("%c\n", colors[1][2]); // ecolors[1]是char*,指向字符串"green"的首字符'g';再加一个下标[2],就访问到该字符串的第 2 个字符'e'。这就是“指针数组”读字符串的典型方式。
int (*q)[3]中,q先被括号限定是一个指针,它指向一个含 3 个int的数组。它最常用于二维数组的行指针:
int a[2][3] = {{1,2,3}, {4,5,6}}; int (*q)[3] = a; q++; // 跳过一整行,也就是跨过 3 个 int printf("%d\n", (*q)[1]); // 指向第二行,所以输出 5字符串数组还有一种形式是固定行宽的二维字符数组:
char names[][10] = {"Alice", "Bob"};这种方式每行固定 10 字节,不管字符串实际多短都会占满行宽,但好处是内容存在自己数组里,可以随时修改。指针数组的形式更省内存,但指向的是字符串字面量,修改colors[0][0]属于未定义行为。实际项目里,需要频繁改字符串内容就选二维字符数组,只需要保存和遍历字符串就选指针数组。
4.3 &数组名与数组名的区别:+1 之后差了多远
这个问题被问过无数次,也是数组和指针关系里最核心的考点。看代码:
int a[5] = {1, 2, 3, 4, 5}; printf("%p\n", (void *)a); // 首元素地址 printf("%p\n", (void *)&a); // 整个数组的地址,数值和 a 相同 printf("%p\n", (void *)(a + 1)); // 比 a 大 sizeof(int) printf("%p\n", (void *)(&a + 1)); // 比 &a 大 5 * sizeof(int)原因在于类型:a的类型是int[5],在绝大多数表达式里会“退化”成指向首元素的int*,所以a + 1只移动一个int。而&a的类型是int (*)[5],它是指向整个数组的指针,所以&a + 1会跳过整个数组 20 字节。两个地址打印出来数值一样,但指针算术的步长完全不同。
这个概念搞不清楚,后面学二维数组、动态二维数组、函数指针时会反复踩坑。我的建议是写个程序实际打印一遍,一次就能记住。
5. 数组作为函数参数:传参时的退化陷阱
5.1 为什么函数里的 sizeof 不是数组长度
新手最容易疑惑的代码是这段:
#include <stdio.h> void print_len(int arr[]) { printf("%zu\n", sizeof(arr)); // 输出 8,不是 20 } int main(void) { int a[5] = {1, 2, 3, 4, 5}; printf("%zu\n", sizeof(a)); // 输出 20 print_len(a); return 0; }原因在C标准里有明确规则:当数组作为函数参数时,形参int arr[]会被自动调整为int *arr。也就是说,你写int arr[]和写int *arr完全等价。调用时传入的数组名a会“退化”成指向首元素的指针,函数里sizeof统计的就是指针大小,64位系统上是 8。
所以不要在函数里用sizeof(arr) / sizeof(arr[0])计算长度,否则会得到8 / 4 = 2这种离谱结果。正确做法很简单:额外传一个长度参数。这也是C语言里为什么排序函数普遍长这样:
void sort(int a[], int n);n必须由调用者提供,因为函数自己没有任何手段知道数组有多长。
5.2 函数参数的三种写法其实是同一种
看三个函数原型:
void f(int a[]); void f(int a[10]); void f(int *a);这三个在C语言里完全等价。第二个写法里的10会被编译器直接忽略,不表示只能传长度为 10 的数组。很多人误以为在形参里写int a[10]能起到“长度约束”作用,实际上传一个长度为 3 的数组过去照样编译通过,运行时照样可能越界。
二维数组作为参数时,列数必须写清楚:
void print_matrix(int m[][4], int rows);可以等价写成:
void print_matrix(int (*m)[4], int rows);因为函数内部要计算m[i][j]的地址,必须知道一行有多少元素。第一维可以省略,因为调用时可以靠传入的行数来确定。
5.3 返回数组的三种常用方式
函数能不能直接返回一个数组?语法上不能。最常见的错误是返回局部数组的地址:
int *bad(void) { int a[10] = {0}; return a; // 悬垂指针,函数返回后栈内存失效 }a是函数内的局部数组,函数返回后这块栈内存就不再有效。地址值可能没变,但里面的内容随时会被其他函数调用覆盖。如果你打印出乱码,多半就是这个原因。
实际工程里常用三种解法:
- 调用方提供缓冲区,函数填充数据,比如
void fill(int *buf, int n)。 - 使用
static局部数组,比如return static int a[10],但这种方式不是线程安全的,且函数每次调用只维护同一份数据。 - 用
malloc动态分配内存,返回堆地址,由调用方负责free。
第三种方式衔接了下一章的内容:如果你需要的是真正“可变大小”的数组,绕不开动态内存。
6. 变长数组与动态数组:突破固定大小限制的两种思路
6.1 C99 变长数组 VLA:运行时才知道长度也可以用
很多初学者以为数组长度必须是常量,其实C99 开始支持变长数组,允许用变量指定数组长度:
int n; scanf("%d", &n); int a[n]; // VLA,运行时确定长度 for (int i = 0; i < n; i++) { a[i] = i * i; }这种数组分配在栈上,用起来和普通数组一样。但有几个注意点:第一,n太大时可能直接栈溢出,默认栈大小通常是几MB,别想着开一个 100 万元素的int数组;第二,n为负数或 0 时行为未定义,使用前最好检查;第三,C11 把 VLA 标记为可选特性,微软的 MSVC 一直不支持,如果你写跨平台代码,依赖 VLA 会有风险。刷算法题用 GCC 或 Clang 没问题,但工程代码里要谨慎。
VLA 也可以出现在函数形参里:
void foo(int n, int a[n]);这种写法能保留一部分“数组感”,sizeof(a)在函数内有效,值为n * sizeof(int)。不过它本质仍然是指针传参,只是编译器多记住了长度信息,使用范围有限。
6.2 malloc 动态数组:真正自己管理生命周期
如果数据规模在运行时才确定,并且可能很大,正确姿势是用malloc:
#include <stdio.h> #include <stdlib.h> int main(void) { int n; scanf("%d", &n); int *p = (int *)malloc(n * sizeof(int)); if (p == NULL) { perror("malloc"); return 1; } for (int i = 0; i < n; i++) { p[i] = i; } // 使用 p[0] 到 p[n-1] free(p); return 0; }malloc返回的是一块连续内存的首地址,所以它能像数组一样用p[i]下标访问。它是堆上的内存,不会在函数返回时自动释放,必须free,否则内存泄漏。动态数组和固定数组的区别可以用一个表列出来:
| 对比项 | 固定数组 | malloc 动态数组 |
|---|---|---|
| 内存位置 | 栈(或全局区) | 堆 |
| 长度确定时机 | 编译期或栈帧创建时 | 运行时 |
| 是否需要 free | 否 | 是 |
| 数组名能否重新赋值 | 否 | 指针变量可以 |
| 长度信息 | sizeof 可查(作用域内) | 必须自己记录 |
动态数组真正强大的地方在于可以扩容。realloc能在保留原数据的前提下调整内存大小。一个简单的“可变数组追加元素”函数可以这样写:
#include <stdlib.h> int *append(int *data, int *size, int *cap, int val) { if (*size == *cap) { int new_cap = (*cap == 0) ? 4 : (*cap) * 2; int *tmp = (int *)realloc(data, new_cap * sizeof(int)); if (tmp == NULL) { return NULL; // 原 data 仍然有效,不能直接覆盖 } data = tmp; *cap = new_cap; } data[(*size)++] = val; return data; }注意一点:realloc失败时会返回NULL,但原来的内存块依然有效。如果直接写data = realloc(data, new_cap * sizeof(int)),一旦失败,原指针就被改成NULL,原有内存既没法用也没法释放,直接泄漏。所以必须用临时变量先接收返回值,判断成功后再赋值。这个细节我在代码评审里见过很多次。
扩容策略通常按倍数扩展,比如翻倍,这样平均每次追加的代价是 O(1),而不是每加一个元素就重新分配一次。这也是很多动态数组库的基本实现思路。C语言标准库没有内置动态数组,但用malloc/realloc/free自己实现一个并不难。
6.3 个人选择与建议
如果你现在手边有编译器,我建议把下面这段代码敲一遍,观察输出:
int a[5] = {1, 2, 3, 4, 5}; printf("%p\n", (void *)a); printf("%p\n", (void *)(a + 1)); printf("%p\n", (void *)&a); printf("%p\n", (void *)(&a + 1));再写一个函数,把a传进去,打印函数内部的sizeof(a),对比主函数里的sizeof(a)。数组的很多坑,看完不如跑一遍。我在实际带新人的时候,发现大家最容易在数组和指针混着用时犯迷糊。下一篇我准备写指针,把a[i]、*(a + i)、&a[i]这些彻底拆开。在那之前,把数组的内存布局和初始化细节吃透,后面的路会顺很多。