C语言指针和二维数组,是很多人学C时绕不过去的一道坎。我在教学和实际项目里见过太多人卡在这:数组名到底是不是指针?为什么int (*p)[4]赋值给int *p编译就报警告?int **p又怎么就跟a[3][4]合不来?这些问题表面上是语法,骨子里是对「内存布局」和「指针类型决定步长」这两件事没吃透。
这篇文章我打算把二维数组、数组指针、指针数组、二级指针、函数传参、动态分配串起来讲一遍。不只讲规则,也讲我踩过的坑和调试方法。适合刚学完指针基础、正在被二维数组折磨的读者,也适合那些刷题时一遇到二维数组传参就报警告、需要查资料的开发者。看完之后,你会发现这些问题其实有同一条主线:搞清楚每一层指针指向什么、类型是什么、一次跳多远,就全通了。
1. 二维数组的本质:先搞清楚它在内存里到底是什么
1.1 一维数组先复习到位:数组名和指针的关系
很多混乱其实从一维数组就开始埋下了。先看int a[10]:
a的类型是int[10],不是int*更不是int**;- 但在绝大多数表达式中,
a会“退化”成指向首元素的指针int*,也就是a的值等于&a[0]; - 例外情况有两个:
sizeof(a)得到的是整个数组的字节数,而不是指针大小;&a取到的是指向整个数组的指针,类型是int(*)[10]。
所以a + 1代表跳过 10 个int,&a + 1跳过整整 10 个 int 的长度,而(int*)a + 1才等于&a[1]。这里的关键是:同样一个数值地址,挂上不同的指针类型,加减法的步长完全不同。
记住这句话:数组名不是指针,而是“会退化成指针的数组标识符”。它退化成什么指针,完全由它首元素的类型决定。比如a首元素是int,就退化成int*;如果是一个二维数组a[3][4],首元素是int[4]数组,退化成int(*)[4]。
1.2 二维数组的真实存储:不是“二维”而是“连续的一维”
int a[3][4]可以想象成 3 个长度是 4 的一维数组排在一起。内存里没有任何“行”和“列”的概念,它就是一整段连续的 12 个int:
a[0][0] a[0][1] a[0][2] a[0][3] a[1][0] a[1][1] a[1][2] a[1][3] a[2][0] a[2][1] a[2][2] a[2][3]实际上,&a[0][3]的下一个内存地址就是&a[1][0],行和行之间没有空洞。C 语言采用行优先存储,也就是先把第一行所有列放完,再放下第二行。对一个3行 x 4列的int数组,sizeof(a)应该是3 * 4 * sizeof(int),在我的机器上是 48 字节。
二维数组可以看作“数组的数组”:a[0]本身是一个长度为 4 的int数组,a[1]也是,a[2]也是。正因为每一行都是一个一维数组,所以行下标取出来之后,还能继续用列下标取元素。
这就带来第一个容易混淆的地方:a、a[0]、&a[0]、&a[0][0],它们的数值地址可能一模一样,但类型完全不同。这是很多新手连编译警告都读不懂的根源。
1.3 行地址、行首元素地址:数值相同但类型天差地别
用一个最简单的例子来验证:
int a[3][4] = {0}; printf("a : %p\n", a); printf("&a[0] : %p\n", &a[0]); printf("a[0] : %p\n", a[0]); printf("&a[0][0] : %p\n", &a[0][0]); printf("a + 1 : %p\n", a + 1); printf("a[0] + 1 : %p\n", a[0] + 1); printf("&a[0][0] + 1: %p\n", &a[0][0] + 1);在 64 位系统、int占 4 字节时,输出大致是:
| 表达式 | 数值地址 | 类型 | 加 1 后跳多远 |
|---|---|---|---|
a | 基地址 | int(*)[4] | 16 字节(一整行) |
&a[0] | 基地址 | int(*)[4] | 16 字节(一整行) |
a[0] | 基地址 | int*(退化的) | 4 字节(一个元素) |
&a[0][0] | 基地址 | int* | 4 字节(一个元素) |
这里最直观的是,前两行是“行地址”,后两行是“元素地址”。它们打印出的数字可能完全一样,但是+1的结果完全不同:行地址加一跨过 4 个整数,元素地址加一跨过 1 个整数。我给初学者讲的时候,经常说“地址是门牌号,类型是门洞大小。门口看起来一样,钻进去的空间大小不一样。”
如果一开始就忽略类型直接算偏移量,后面十个 bug 里有九个都是从这里长出来的。
2. 数组指针:让一个指针变量指向“一整行”
2.1 声明与初始化:int (*p)[4]该怎么读
要指向二维数组的某一行,不能用int*,得用数组指针。声明写法是:
int a[3][4]; int (*p)[4] = a;int (*p)[4]的读法是:先看括号,p是一个指针;再看右边,[4]表示它指向的对象是“一个长度为 4 的 int 数组”;所以整体含义是“指向int[4]数组的指针”。这个[4]必须和二维数组的列数一致,否则类型不匹配。
这里有一个常见的错误读法,把int *p[4]当成int (*p)[4]。事实上int *p[4]是一个数组,里面装了 4 个int*。稍后第 3 节专门对比。
初始化时,a退化成int(*)[4],所以直接写p = a;就行。有些教材建议写p = &a[0];,效果一样,类型也一致。千万不要写p = &a;,因为&a的类型是int(*)[3][4],指向的是整个 3行4列数组,编译器会直接报警告或错误。
如果p指向一个int[4],那p[i]等价于a[i],类型是int[4]再退化到int*;p[i][j]等价于a[i][j]。这个用法后面遍历时直接受益。
2.2 指针运算的步长:p + 1到底跨了多少字节
数组指针最大的特点就是步长和“数组长度”绑定。p指向int[4],所以p + 1是真的“跳过一整行”,而不是跳过 1 个int。
int a[3][4]; int (*p)[4] = a; printf("%p\n", p); printf("%p\n", p + 1);在我这里,两个地址之差是 16 字节,也就是4 * sizeof(int)。这一步想通了,后面二维数组转置、鞍点、冒泡排序这类题,就没有任何神秘感了:p + i就是第 i 行的起始地址,*(p + i)拿到了第 i 行的首元素地址,*(p + i) + j再跳到第 i 行第 j 列。
为什么必须知道列数才能算步长?因为p指向的是一个完整的int[4],编译器据这个类型算出每一步偏移 4 个元素;如果你只给int*,它只会偏移 1 个元素。这就是“类型决定步长”的最佳证明。
2.3 用数组指针遍历二维数组的完整套路
实际写遍历时,常见两种风格:
void traverse_v1(int (*p)[4], int rows) { for (int i = 0; i < rows; ++i) { for (int j = 0; j < 4; ++j) { printf("%d ", p[i][j]); } printf("\n"); } }或者不嫌麻烦地写完整指针形态:
void traverse_v2(int (*p)[4], int rows) { for (int i = 0; i < rows; ++i) { for (int j = 0; j < 4; ++j) { printf("%d ", *(*(p + i) + j)); } printf("\n"); } }*(*(p + i) + j)可以拆开看:p + i跳到第 i 行的地址;*(p + i)是这一行的数组名,类型退化成int*;再+ j跳到这一行的第 j 个元素;最后取*得到整数。
很多课程题目里会有“计算 5x5 矩阵鞍点”或者“求周边元素之和”这类任务。它们的本质就是在做循环下标,只不过你可能被题目包装的“矩阵”“行”“列”吓住了。用数组指针写,逻辑更接近数学里的“行地址”思维:外层循环管行,内层循环管列。
3. 指针数组和数组指针:长得像但完全是两回事
3.1 定义与内存布局对比:优先级是唯一裁判
int *p[4]和int (*p)[4]只差一对括号,含义天壤之别。判断依据是 C 语言运算符优先级:[]的下标优先级高于*解引用。
int *p[4]:p先和[4]结合,所以它是一个长度 4 的数组;数组元素类型是int*。这叫指针数组。int (*p)[4]:括号让*先和p结合,所以p是一个指针;它指向一个长度为 4 的 int 数组。这叫数组指针。
| 声明 | 本质 | sizeof(p)(64位,int占4字节) | 常用场景 |
|---|---|---|---|
int *p[4] | 数组,元素是 4 个int* | 32 字节 | 保存字符串指针、动态行指针 |
int (*p)[4] | 指针,指向长度为 4 的 int 数组 | 8 字节 | 遍历固定列数的二维数组 |
新手最容易看混,是因为int *p[4]里也能写成int* p[4],视觉上好像p是个指针。其实它是数组。如果p是指针数组,那么p[0]是一个指针,p[0][0]才能访问到它指向对象的第一个元素;而数组指针p本身只有一个指针,p[0]是整行数组,p[0][0]是行内第一个元素。
3.2 指针数组操作二维字符数组:一行代码就能交换两行
二维字符数组按行存储字符串,比如:
char week[][8] = {"Mon", "Tue", "Wed", "Thu", "Fri"};每一行是 8 个字符的数组,如果你想把Thu和Tue交换,用strcpy来回拷贝,效率不高,而且必须保证目标行空间够大。但如果改用指针数组:
const char *week[] = {"Mon", "Tue", "Wed", "Thu", "Fri"};每个元素是指向字符串字面量的指针。交换两行只需要交换两个指针:
const char *tmp = week[3]; week[3] = week[1]; week[1] = tmp;这一步交换的是 8 字节的指针,不是字符串内容。这个技巧在处理“按字符串排序”的题目中非常常见,比如冒泡排序时比较strcmp(week[j], week[j+1]) > 0,如果顺序不对就交换指针,整个过程不用动字符串本身。
需要注意,const char *指向的是字符串字面量,内容不可修改。如果你需要修改字符串内容,还是得用二维char数组,比如char week[][8],那种情况下交换行就得逐个字符拷。这是很多初学者在作业里被坑过的地方:想用指针数组省内存,但又想直接改字符串,结果段错误。
3.3 二维字符数组和指针数组的差异要放在一起看
我再列一个对比,帮你判断什么时候选哪种:
| 需求 | 建议方案 | 理由 |
|---|---|---|
| 字符串内容要修改 | char a[][N] | 每行有实际存储空间,可直接写 |
| 只读字符串、大量排序/交换 | const char *a[] | 交换指针便宜,省内存 |
| 行数不固定、长度不统一 | 动态分配 + 二级指针 | 最灵活,按需分配 |
| 固定行列的数值矩阵 | int a[M][N] | 连续内存,缓存友好 |
一旦想清楚“二维字符数组”其实是固定行宽的字符块,“指针数组存字符串”其实是若干独立指针,就明白为什么sizeof结果是天壤之别了。前者每一行都占据等量空间,后者每个字符串字面量的内存地址完全可能零散分布。
4. 指针的指针与动态二维数组
4.1 为什么int **p不能直接指向int a[3][4]
这是我在 C 语言交流群里看到频率最高的错误之一。有人觉得“二维数组可以用二级指针”,然后写:
int a[3][4]; int **p = a; // 错误编译时会有警告,类型不兼容。运行时如果强行访问p[0][0],大概率段错误。原因是:a是一整块连续的 12 个 int,不存在“每一行是一个独立指针”的结构;而int**要求p指向一个“元素为int*的数组”或者“指向一个指针”。你把它赋成一个整数数组的首地址,它会把a[0][0]的值误当成指针去寻址,自然崩。
正确理解是:二级指针是两个层级的内存跳转,二维数组是一次性分配的连续内存。两者不能互相替代。真的要用二级指针模拟二维数组的时候,需要自己构建第一层指针数组。
4.2 动态分配:二级指针的正确用法
当行数或者列数在运行期才知道时,可以这样:
int **m = (int**)malloc(rows * sizeof(int*)); if (!m) return; for (int i = 0; i < rows; ++i) { m[i] = (int*)malloc(cols * sizeof(int)); if (!m[i]) { /* 释放已分配的行,再返回 */ } }分配关键是两级分配:先分配有rows个一级指针的空间,再让每一个指针指向这一行的cols个整数。访问m[i][j]时,实际上是*(*(m + i) + j):m + i跳到第 i 个指针,*(m + i)取出这一行的首地址,然后+ j偏移到列。
释放时次序必须反过来:
for (int i = 0; i < rows; ++i) { free(m[i]); } free(m);先释放每一行的内存,再释放存放行指针的那块内存。反过来的话,你就再也找不到那些行指针,造成泄漏。很多刷题平台里内存超限或者测试报错,不是算法本身问题,就是分配后没释放干净。
4.3 更高效的一种选择:用一维数组模拟二维数组
对于固定行数列数,我其实更推荐直接用一维数组模拟:
int *m = (int*)malloc(rows * cols * sizeof(int));访问m[i][j]就写成m[i * cols + j]。这样实现有几个好处:
- 内存完全连续,遍历时缓存命中率高;
- 释放只有一次
free(m),不会遗忘行指针; - 指针语义更简单,只用
int*,不用二级指针; - 在函数传参时可以直接传递
int*加行列参数,避免复杂的int**类型匹配。
代价是需要手动计算i * cols + j的偏移。坦白讲,刷题和写业务逻辑时这点偏移计算完全可以接受。很多图形图像库把二维矩阵摊平成width * height的缓冲区,也是这么做的。核心思想是把“二维下标”换算成“一维偏移”,本质还是第一节说的连续内存布局。
5. 二维数组作为函数参数:类型匹配是第一要务
5.1 三种写法其实只对应两类东西
写一个函数接收int a[3][4]时,可以这样声明:
void func1(int a[3][4]); void func2(int a[][4]); void func3(int (*a)[4]);这三个声明对编译器来说几乎一样,因为数组参数会被“调整”成指针参数。int a[3][4]里的第一维大小会被忽略,真正生效的是int (*a)[4]。所以void func(int a[][4])和void func(int (*a)[4])是同一回事。
但void func(int **a)不是同一回事。如果函数参数是int**,它接收的是动态分配出来的二级指针,或者你能构造出一个一级指针数组传进去。直接传int a[3][4],类型不匹配,编译警告,运行时访问还可能崩。
最稳妥的办法是,函数声明和使用者保持一致:固定二维数组就传int (*a)[N]或者int a[][N];动态分配就传int**;扁平化一维模拟就传int*。
5.2 为什么必须写列数:不写列数编译器没法算步长
int a[][4]里的 4 绝对不能省。因为数组指针的步长取决于“指向的数组长度”。函数里执行a[i][j]时,编译器需要知道a[i]是从第 i 个元素开始还是第 i 行开始;如果只知道行数不知道列数,它不知道每行到底跨多少个int。
比如一个int a[3][4],a[1]必须在开头地址上偏移4 * sizeof(int)字节。如果把声明写成int a[][],编译器无法确定每行多宽,所以这是语法错误。
这里有一个特殊小细节:C99 之后可以用变长数组(VLA)作为参数,让列数由另一个参数传入:
void func(int rows, int cols, int a[rows][cols]) { // ... }这样函数就通用了。注意 C11 之后 VLA 变成可选特性,有些编译器默认支持不够好;在要求严格可移植的代码里,我一般不用 VLA,而是直接用int*加i * cols + j。
5.3 完整例子:把二维数组和指针结合的常见刷题函数
假设要写一个函数,把矩阵转置存到另一个矩阵:
void transpose(int dst[][4], int src[][4], int rows) { for (int i = 0; i < rows; ++i) { for (int j = 0; j < 4; ++j) { dst[j][i] = src[i][j]; } } }这里的参数int dst[][4]也说明了列数是 4。调用时直接transpose(b, a, 3);,两个数组名都退化成int(*)[4],类型正好对上。
如果采用扁平化数组:
void transpose_flat(int *dst, int *src, int rows, int cols) { for (int i = 0; i < rows; ++i) for (int j = 0; j < cols; ++j) dst[j * cols + i] = src[i * cols + j]; }你会发现它更“刻板但安全”,不需要关心列数在类型上的固定,因为所有步长都是手工乘出来的。这恰恰适合动态二维数组场景。
6. 实操心得体会与常见错误排查
6.1 我见过最多的四种错误
第一个是int *p = a;然后试图用p[i][j]访问二维数组。这编译必报错,因为p是int*,它后面只能接一个下标。就算强转,访问逻辑也错。正确做法要么用数组指针,要么手动p[i * cols + j]。
第二个是动态分配二维数组只free了最外层指针。很多人写:
int **p = malloc(rows * sizeof(int*)); for (int i = 0; i < rows; ++i) p[i] = malloc(cols * sizeof(int)); // ... free(p); // 只释放了指针数组,行内存泄漏了必须循环释放p[i]后再free(p)。
第三个是把普通二维数组传进int**形参。也许在某些老师给的旧代码里强转过,能跑,但这是未定义行为。尤其当你对p[0]赋值时,它会把数组前几个 int 当成指针覆盖掉,后果不堪设想。
第四个是把&a和a混用。&a是int(*)[3][4],a + 1和&a + 1的步长不同。打印地址大家都一样,一旦指针加减就露馅。
6.2 调试方法:先把“地址和步长”打出来
我在排查二维数组指针问题时,第一件事就是打印地址和sizeof:
printf("sizeof(a) = %zu\n", sizeof(a)); printf("sizeof(a[0]) = %zu\n", sizeof(a[0])); printf("a = %p\n", (void*)a); printf("a + 1 = %p\n", (void*)(a + 1)); printf("a[0] = %p\n", (void*)a[0]); printf("a[0] + 1 = %p\n", (void*)(a[0] + 1));如果a + 1和a[0] + 1的差值没有按预期出现(一个是一行,一个是一个元素),说明你对类型的判断出了问题。这个方法比单纯看报错信息更高效,因为报错可能只说“incompatible pointer type”,却不告诉你应该用什么类型。
用%zu打印sizeof时要注意格式,否则会得到乱码。地址打印统一用(void*)强转,避免格式警告。
6.3 刷题和训练建议:从这几个练习开始
如果让我推荐几个能快速建立“指针+二维数组”直觉的练习:
- 用三种方式遍历同一个
int a[3][4]:下标法、数组指针法、扁平化指针法,并把结果打印出来对比。 - 写一个函数,接收二维数组和行列数,返回最大值及其下标。这能逼你理解参数怎么传。
- 做矩阵转置。这是二维数组下标访问频率最高的题。
- 写字符串排序,用指针数组存多个字符串,用
strcmp决定是否交换指针。 - 自己实现一个动态二维数组,要求能随机赋值、遍历、释放,并在释放后把指针置为
NULL,防止二次释放。
这些练习做完,你会对“行指针”和“列指针”的区别形成肌肉记忆。后面看到int (*p)[N],第一反应不是“好吓人”,而是“哦,这是一个指向固定宽度行的指针”。
我个人在实际操作里,反而越来越倾向于用扁平化一维数组处理数值矩阵,因为类型简单、释放简单、缓存友好,代码也没多几行。二维数组和数组指针的知识我仍然会认真掌握,因为很多现有代码和教材都基于这套写法,看不懂它们等于看不懂别人写的老项目。把这两种思维都装进脑子里,遇到哪种代码都不虚。