资讯详情

资讯详情

C语言二维数组与指针:数组指针、指针数组、二级指针全解析

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 个inta 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 个inta[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[j1]) 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 调试方法先把“地址和步长”打出来我在排查二维数组指针问题时第一件事就是打印地址和sizeofprintf(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]第一反应不是“好吓人”而是“哦这是一个指向固定宽度行的指针”。我个人在实际操作里反而越来越倾向于用扁平化一维数组处理数值矩阵因为类型简单、释放简单、缓存友好代码也没多几行。二维数组和数组指针的知识我仍然会认真掌握因为很多现有代码和教材都基于这套写法看不懂它们等于看不懂别人写的老项目。把这两种思维都装进脑子里遇到哪种代码都不虚。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →