
学习C语言的人早晚要面对这样一个灵魂拷问一维数组和指针已经够迷糊了二维数组又来了指针还要跟它凑在一起你还真别想轻松过关。这篇笔记是“C语言的学习”系列第十五篇标题末尾那个“难”字不是我加的是无数初学者用编译器的红字和段错误投票投出来的。今天这篇文章就把指针与二维整型数组、二维字符数组这两块放在一起讲透先从内存布局说起再一步步推导下标与指针的等价关系最后给出几个可以直接抄走的函数模板和练习题。适合正在刷翁恺练习题、PTA题目或者准备期末上机的同学如果你只是会读a[i][j]但说不清a到底是什么类型那这篇文章正是为你准备的。1. 二维数组的本质内存里根本没有“表格”1.1 先忘掉行和列看内存里的连续空间很多教材讲到二维数组时喜欢画一张带行线和列线的表格看起来非常直观但也正是这张表格把不少人带沟里去了。你要记住一个底层事实内存是一维的线性空间不存在“第几行第几列”这种结构。所谓的二维数组在内存里就是一段连续的内存元素挨个排过去。定义int a[3][4]编译器实际做的是先分配一块能放下3 * 4 12个int的连续内存然后按照“行优先”的顺序去排放这些元素。什么叫行优先就是先放满第一行的 4 个元素再放第二行的 4 个最后放第三行的 4 个。内存里的排列顺序是a[0][0] a[0][1] a[0][2] a[0][3] a[1][0] ... a[2][3]打个生活化的比方电影院里的座位物理上就是一排连续的空地你把它划分成前区、中区、后区那是你的事硬件不关心。C语言里的二维数组也一样它本质上是“数组的数组”——先有一个容纳 3 个元素的数组每个元素又是一个包含 4 个int的数组。你不需要一开始就把这个定义背下来但脑子里要有这张连续内存的图后面所有指针换算都建立在这张图上。1.2 数组名的真正类型别再说“a 就是地址”一维数组int b[4]里b作为右值使用时是int *类型指向b[0]这个大家比较熟。但二维数组就不同了a不是int *而是int (*)[4]也就是指向“含有 4 个 int 的数组”的指针。读起来很拗口你就把它记成“行指针”它指向一整行而不是指向某一个整数。怎么验证写几行代码把各种地址输出看看就明白了#include stdio.h int main(void) { int a[3][4] {0}; printf(a %p\n, a); 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); return 0; }在 int 占 4 字节、数组起始地址为0x100的机器上输出大概是a 0x100 a[0] 0x100 a[0][0] 0x100 a 1 0x110 a[0] 1 0x104看到没有a、a[0]、a[0][0]打印出来的地址数值一模一样但a 1跳了 16 个字节一整行a[0] 1只跳了 4 个字节一个元素。这就是类型差异带来的行为差异。你甚至可以这样理解数值相等的两个地址因为“类型”不同做加法时步长完全不同。这一步想通了二维数组的指针问题就解决了一大半。2. 指针与二维整型数组从下标到地址的换算2.1 核心公式a[i][j]等价于*(*(a i) j)很多同学背过这个公式但不知道它是怎么来的用的时候也只会机械套。今天我把拆解过程写出来你看完应该就忘不掉了。第一步a i。a是行指针类型int (*)[4]所以a i指向第i行地址值比a大了i * 4 * sizeof(int)。这一点和 1.2 的验证结果是一致的。第二步*(a i)。解引用行指针得到的是“第 i 行的首元素地址”类型是int *。你可以把*(a i)就当成a[i]这俩完全等价。第三步*(a i) j。在int *的基础上偏移j个元素得到第 i 行第 j 列的地址。第四步再解引用一次*(*(a i) j)取到的就是元素值。所以a[i][j] *(*(a i) j)这里有个特别容易迷惑的点*a和a[0]数值相同但类型不同。*a的类型是int *a的类型是int (*)[4]。初学者容易把“数值相同”和“类型相同”混为一谈其实类型决定了你拿它去算地址时步长是多大数值一样不代表一切。2.2 三种访问方式的代码对比光背公式没感觉直接看三段等价代码。假设目标是访问a[1][2]这个元素值为 7#include stdio.h int main(void) { int a[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*row)[4] a; // 行指针 int *col a[0]; // 列指针等价于 *a printf(方式1: a[1][2] %d\n, a[1][2]); printf(方式2: *(*(a1)2) %d\n, *(*(a 1) 2)); printf(方式3: row[1][2] %d\n, row[1][2]); printf(方式4: col[1*42] %d\n, col[1 * 4 2]); return 0; }方式 3 其实和方式 1 是同一件事row和a类型一致方式 4 则把二维数组“拍扁”成一维后手动算偏移。四种写法输出全是 7。说句实在话日常开发里没人天天写*(*(a1)2)这种代码但你必须在脑中完成这一步换算否则后面读别人代码、调指针函数、刷 PTA 题时会非常吃力。2.3 为什么不能直接写int *p a有人图省事上来就写int *p a;然后被编译器警告。为什么不行因为类型不匹配a是int (*)[4]p是int *。如果你强行赋值有些编译器用强制转换能过后面p就只跳 4 个字节而不是 16 个字节整个“行”的概念就丢了。那如果确实想用int *去遍历二维数组里的所有元素呢两种正确写法int *p a[0][0]; // 明确指向第一个 int int *p (int *)a; // 强制转换编译器也不抱怨然后你可以用p i * 4 j这样的式子去访问a[i][j]或者干脆写一个一重循环从头遍历到尾int *p a[0][0]; for (int *end p 12; p end; p) { printf(%d , *p); }因为 12 个int在内存里是连续的所以指向头部的int *一路往前走能把整个二维数组走完。这里要记住一个经验当你打算用“拍扁”的方式访问二维数组时优先写a[0][0]不要写(int *)a。强制转换虽然也能跑但可读性差而且容易掩盖代码里真正的类型问题。2.4 函数参数怎么写编译器才不骂你如果你要写一个函数打印 3 行 4 列的矩阵形参该怎么声明常见两种写法void print_matrix(int a[][4], int rows); void print_matrix(int (*a)[4], int rows);这两种写法完全等价编译器眼里就是同一个东西。但注意int a[][4]里第二维 4 绝对不能省。为什么因为编译器计算a[i][j]的地址时必须知道一行的长度是多少字节才能用“行号乘行长加列号”的公式定位。你可以把行号rows作为参数传进来因为行数不影响寻址计算但列数行长必须先写在类型里。写全了这个函数长这样#include stdio.h void print_matrix(int (*a)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%3d , a[i][j]); } putchar(\n); } } int main(void) { int a[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(a, 3); return 0; }注意调用时实参直接传a不需要取地址a因为数组名在函数传参时已经自动退化为指向其首元素的指针。对二维数组来说首元素就是第一行退化结果正好是int (*)[4]。3. 指针与二维字符数组存字符串的两种思路3.1 字符二维数组char s[3][10]每个格子都给你安排好处理字符串时二维数组最朴素的使用方式就是定义一个固定大小的“字符表格”。例如char names[3][10] {Tom, Jerry, Alice};内存里分配了连续的3 * 10 30个字节分成 3 行每行 10 字节。Tom存进去时占用 4 字节三个字符加一个\0剩下的 6 字节浪费掉Jerry占 6 字节Alice占 6 字节。这就是“固定容量按最大长度预留”的思路。访问时names[i]是第i行的首地址类型是char *。printf(%s, names[1])能直接打印Jerry因为%s接收的就是字符指针。如果你想修改某个字符串直接strcpy(names[0], Bob)就行这块内存本来就是你的想怎么写就怎么写。这种方式的优点是内存连续、读取快、数据可修改适合存储“长度差不多”的一批字符串比如学号、日期、定长编码。缺点是空间浪费严重如果最短的 3 个字符最长的要 100 个字符你却必须按 100 个字符的规格给每行分配空间一堆格子空着。3.2 字符指针数组char *s[3]每个元素只是一个地址另一种思路是干脆不存字符串本体只存每个字符串的起始地址char *names[3] {Tom, Jerry, Alice};这个声明读起来要小心names是一个数组数组里有 3 个元素每个元素是char *类型的指针。在 64 位机器上一个指针占 8 字节所以整个数组只占 24 字节。三个字符串本体实际上放在“字符串字面量”所在的只读区域names[i]保存的只是它们的开头地址。访问方式看起来和 3.1 一模一样printf(%s, names[1])也是打印Jerry。但这两种方式的内部差异非常大。最大的坑是字符串字面量在 C 标准里是不允许修改的你写names[0][0] B这种代码运行时大概率直接段错误。另外如果某天你把指针指向了一个局部数组函数返回后那个局部数组就失效了names里存的地址就成了悬垂指针。3.3 两种方式怎么选一张表讲透对比维度char s[3][10]char *s[3]内存位置栈上连续 30 字节栈上 24 字节 常量区字符串每个字符串最大长度固定 9 字符留\0取决于指向的目标字符串内容可修改可以指向字面量时不可空间利用率短字符串浪费明显按实际长度存放交换两个字符串strcpy慢拷贝交换指针极快典型用途定长记录、矩阵字符字符串排序、动态文本选型逻辑很简单如果你要频繁修改字符串内容或者数据长度参差不齐但总量可控用指针数组更灵活如果数据本来就是定长的比如 8 位的日期、6 位的学号用二维字符数组反而更省事不需要管理一堆指针。3.4 字符串排序实战交换字符串 vs 交换指针字符串排序是二维字符数组和指针数组最经典的“分水岭”题目也是 PTA、翁恺练习题里反复出现的套路。需求是把一组名字按字典序排好。我用两种方式把差异展示出来。先看字符二维数组版本排序时交换内容必须用strcpy#include stdio.h #include string.h void sort_strs(char arr[][10], int n) { char tmp[10]; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (strcmp(arr[j], arr[j 1]) 0) { strcpy(tmp, arr[j]); strcpy(arr[j], arr[j 1]); strcpy(arr[j 1], tmp); } } } } int main(void) { char names[][10] {Tom, Jerry, Alice}; int n 3; sort_strs(names, n); for (int i 0; i n; i) { puts(names[i]); } return 0; }再看指针数组版本排序时交换的是指针一步到位#include stdio.h #include string.h void sort_pointers(char *arr[], int n) { char *tmp; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (strcmp(arr[j], arr[j 1]) 0) { tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } } int main(void) { char *names[] {Tom, Jerry, Alice}; int n 3; sort_pointers(names, n); for (int i 0; i n; i) { puts(names[i]); } return 0; }结果都是Alice Jerry Tom。区别在交换环节strcpy是按字节把字符串整个搬一遍10 字节起步指针交换只碰了char *变量8 字节64 位就完事。如果换成一篇几百字节的文章差距会大得吓人。所以当我说“指针数组适合排序”不是玄学是实打实的性能差异。字符二维数组当然也能排序就是交换代价高除非数据本身很短否则不建议这么写。4. 高频考点与常见坑4.1 优先级辨析int *p[3]与int (*p)[3]这是面试题和期末卷子里出场率最高的一对冤家。[]的优先级高于*所以加不加括号含义天差地别。int *p[3]读法p先和[3]结合说明p是一个数组数组里有 3 个元素每个元素是int *。这叫指针数组本质是数组。int (*p)[3]读法括号改变了结合顺序p先和*结合说明p是一个指针它指向的对象是int[3]类型的数组。这叫数组指针本质是指针。记不住的时候就用这个口诀“带括号的(*p)是指针指向数组不带括号的*p[]是数组数组存指针。” 类似的还有char *f()与char (*f)()一个是返回指针的函数一个是函数指针优先级规则完全一样。4.2 二维数组传参时的类型退化规律数组名在传参时会“退化”为指针一维数组退化成int *二维数组退化成int (*)[N]。这意味着函数形参里的int a[][4]写得多花哨本质都是int (*a)[4]。但退化的只是第一维第二维及以上不能省。原因前面讲过编译器只能丢掉“最外层数组”的大小因为它可以不靠这个大小来寻址但内层大小直接参与偏移计算必须完整保留。这个规则推而广之三维数组传参数时形参可以省略最高维但后面两维一个都不能少。理解了这个原则你就不会在写void f(int a[][])被编译器报错时一脸困惑了。4.3 常见错误速查表错误写法问题所在正确做法int *p a;类型不匹配a是int (*)[4]int (*p)[4] a;或int *p a[0][0];void f(int a[][])第二维缺失编译器无法寻址void f(int a[][4])names[0][0] B;names是指针数组指向字面量修改字符串字面量未定义行为用二维字符数组或初始化时用可修改内存scanf(%s, s[i])搭配二维字符数组s[i]是行指针类型不对scanf(%s, s[i])二维数组名本身会退化函数返回局部二维数组名栈内存销毁指针悬垂在调用方分配内存或使用static数组第三行值得单独强调一下很多人在 Dev-C 或老旧的 VC6 环境里写char *s hello; s[0] H;程序没报错就以为没问题。这纯属侥幸现代编译器把字符串字面量放到只读段这种代码在不少环境下直接崩溃。你写代码时应该有明确意识字面量是不可改的想改就老老实实开一块自己的数组。4.4 真实踩坑记录一次字符矩阵的教训我印象很深的一次踩坑是帮人排查一段“读取 3 个字符串再逆序输出”的代码。那人写的是char s[3][20]; for (int i 0; i 3; i) { scanf(%s, s[i]); }然后逆序输出时用了for (int i 2; i 0; i--) { printf(%s\n, s[i]); }表面看逻辑没问题但输入数据里含空格scanf(%s)读到空格就停了导致后面的字符串全乱套。这种场景应该用fgets读整行然后手动去掉末尾的换行符char s[3][20]; for (int i 0; i 3; i) { fgets(s[i], 20, stdin); s[i][strcspn(s[i], \n)] \0; }strcspn(s[i], \n)的作用是找到换行符的位置然后把它替换成字符串结束符\0。这是 C 语言处理行输入的经典套路比用gets安全得多也比再写一个循环精细地找\n简洁。如果你将来写命令行小工具、处理配置文件会经常用到这个小技巧。5. 动手练习与参考实现5.1 练习一用指针遍历二维整型数组求最大值要求不通过下标访问元素只能用指针遍历 3 行 4 列的数组找出最大值。参考实现#include stdio.h int main(void) { int a[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int *p a[0][0]; int *end a[0][0] 12; int max *p; for (; p end; p) { if (*p max) { max *p; } } printf(max %d\n, max); return 0; }写完这个练习你应该体会到的核心点是二维数组在内存里是连续的所以从头到尾一重指针遍历完全可行。不需要知道当前在第几行第几列只要知道总共多少个元素就能用一个end指针控制循环边界。5.2 练习二逐行逆序字符串要求读入 3 个字符串可能含空格然后把每个字符串内部逆序再按顺序输出。比如输入hello world、c language、pointer输出就是逆序后的内容。这个练习同时训练二维字符数组和字符串处理函数参考实现#include stdio.h #include string.h void reverse(char *p) { int len strlen(p); for (int i 0, j len - 1; i j; i, j--) { char t p[i]; p[i] p[j]; p[j] t; } } int main(void) { char s[3][30]; for (int i 0; i 3; i) { fgets(s[i], 30, stdin); s[i][strcspn(s[i], \n)] \0; reverse(s[i]); } for (int i 0; i 3; i) { puts(s[i]); } return 0; }fgets加strcspn的组合比scanf(%s)稳妥太多希望你不要偷懒跳过这一步。逆序函数本身没什么难度注意双指针从两端往中间走循环终止条件是i j别写成i j不然字符串长度为奇数时中间那个字符会被自己交换一次虽然结果没毛病但代码不够干净。5.3 练习三指针数组版本的姓名排序要求定义char *names[]用选择排序或冒泡排序按字典序排序并输出结果。这个练习主要考察你是否真的理解了“交换指针比交换字符串快”的本质。参考实现我在 3.4 已经给出过这里给出升级版动态输入名字数量使用指针数组和一个临时字符缓冲区来接收输入。#include stdio.h #include stdlib.h #include string.h #define MAX_LEN 100 #define MAX_COUNT 20 int main(void) { char *names[MAX_COUNT]; char buffer[MAX_LEN]; int n 0; printf(How many names? ); scanf(%d, n); getchar(); for (int i 0; i n; i) { fgets(buffer, MAX_LEN, stdin); buffer[strcspn(buffer, \n)] \0; names[i] malloc(strlen(buffer) 1); strcpy(names[i], buffer); } for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (strcmp(names[j], names[j 1]) 0) { char *tmp names[j]; names[j] names[j 1]; names[j 1] tmp; } } } for (int i 0; i n; i) { puts(names[i]); free(names[i]); } return 0; }这个版本里每个名字的存储空间是根据实际长度用malloc动态分配的彻底避免了二维数组固定行长的浪费也避开了字符串字面量不可修改的坑。排序时交换指针代价恒定。我在实际处理配置文件列表、命令行参数列表时用的就是这个套路。最后再说几句指针和二维数组的难点说到底是“类型”和“步长”两个概念没吃透。a、a[0]、a[0][0]打印出来可能是同一个地址但它们分属不同指针类型加减操作的结果完全不同。我自己的学习习惯是凡是遇到数组和指针纠缠不清的地方一律先把内存布局画出来把每个表达式的类型写清楚再去看运算结果。你如果觉得脑子转不过来就上编译器输出地址去逆推多试几次那种“突然通了”的感觉会非常明显。写代码的时候也别死记公式。a[i][j]这种下标的写法在 99% 的场景里都够用但你要能看懂指针版本你要能说清楚为什么int (*p)[4] a合法int *p a不合法你要能在字符矩阵和指针数组之间根据需求做选择。把这些想明白标题里那个“难”字你就可以亲手划掉了。