数组写到第三期基础语法、初始化、遍历、字符数组这些应该都不陌生了。但很多同学一旦把数组和指针混在一起用就开始原地打转int *p[5]和int (*p)[5]长得几乎一样实际一个装的是指针一个指的是数组用错轻则编译告警重则运行到一半段错误。这一篇我专门挑数组进阶里最容易翻车、也最实用的几个话题来讲指针数组与数组指针的本质区别、二维数组在内存里的真实布局、字符串逆序和数组去重这些高频操作最后再上几道经典算法题镇场。这篇文章定位是数组系列第三篇默认你已经掌握了数组的定义、初始化、一维数组遍历和基本的字符数组操作适合正在学指针、准备期末考或者刷 C 语言题的同学接着往下看。1. 指针数组和数组指针C语言里最容易翻车的一组概念1.1 从运算符优先级出发先分清两者我每带一届学生都有人在这两个声明上栽跟头。先看代码int *p1[5]; // 指针数组p1 是数组数组里有 5 个 int* 元素 int (*p2)[5]; // 数组指针p2 是指针指向一个长度为 5 的 int 数组为什么写法不同因为 C 语言里[]的优先级比*高所以int *p1[5]先被解释成“p1 是数组”然后才知道元素类型是int *。而int (*p2)[5]用括号把*和p2绑在一起先认定“p2 是指针”再指出它指向的是“长度为 5 的 int 数组”。你不需要死记可以这样看谁离变量名最近变量先变成谁的东西。在内存布局上两者的差异非常大声明本质占用大小64位系统数组名代表什么int *p1[5]数组5 × 8 40 字节每个元素存一个地址int (*p2)[5]指针8 字节指向一整块 5 个 int 的连续内存写个小程序验证一下#include stdio.h int main(void) { int a 1, b 2, c 3; int *p1[3] {a, b, c}; int arr[3] {10, 20, 30}; int (*p2)[3] arr; printf(sizeof(p1) %zu\n, sizeof(p1)); // 243个指针 printf(sizeof(p2) %zu\n, sizeof(p2)); // 8一个指针 for (int i 0; i 3; i) printf(%d , *p1[i]); // 输出 1 2 3 putchar(\n); for (int i 0; i 3; i) printf(%d , (*p2)[i]); // 输出 10 20 30 return 0; }看到差别了吧。p1自己就是一块数组内存p2只是个拿着地址的工具人它指向别处的一整块数组。这一步理不清后面所有跟数组、指针相关的题都会连坐出问题。1.2 指针数组存放字符串别再二维数组硬扛了热搜词里“指针数组存放字符串”出现频率非常高因为它真的是处理字符串列表的常用手段。最常见的写法char *planets[] { Mercury, Venus, Earth, Mars };这里planets的元素不是char而是char *每个指针指向一个字符串常量在静态存储区中的首地址。对比一下二维字符数组char planets2[][10] { Mercury, Venus, Earth, Mars };这两种写法有本质区别。二维数组必须给每一行固定分配 10 个字节哪怕Venus只有 6 个字符剩下的位置也浪费了。而指针数组不保存字符串内容只保存地址字符串本身只有一份不存在空间浪费字符串长度也很自由。使用指针数组存储字符串有一个特别隐蔽的坑字符串常量通常位于只读存储区通过指针修改内容是未定义行为。planets[0][0] M; // 危险程序可能直接崩溃如果业务上确实需要修改字符串内容老老实实用二维字符数组或者把字符串拷贝到自己管理的堆内存中。这是我在实际教学里见过最多的问题学生用指针数组存字符串然后一上来就想 strcpy 或者改字符段错误砸得一脸懵。指针数组配合字符串还有一个非常经典的用法对字符串列表排序。因为排序时只需要交换指针不用拷贝大段字符内容#include stdio.h #include string.h void sort_strings(char *arr[], int n) { for (int i 0; i n - 1; i) { for (int j i 1; j n; j) { if (strcmp(arr[i], arr[j]) 0) { char *tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } } } } int main(void) { char *fruits[] {pear, apple, banana, orange}; int n sizeof(fruits) / sizeof(fruits[0]); sort_strings(fruits, n); for (int i 0; i n; i) puts(fruits[i]); // apple banana orange pear return 0; }对比一下如果用二维char[][N]排序交换元素的代价是把整块字符区域搬来搬去用指针数组交换的仅仅是一个 8 字节地址性能差距在数据量大时非常明显。这个思路在项目里处理配置项、日志关键字列表时都能用上。1.3 数组指针配合二维数组函数形参的正确姿势数组指针的价值主要体现在二维数组上。二维数组名传入函数时类型会退化形参最常见的两种等价写法void print1(int a[][4], int rows); void print2(int (*a)[4], int rows);这两个完全等价因为int a[][4]在形参声明中自动被调整成指针了。但有一个新手极其常见、几乎每届都有人犯的错误把形参写成int **a。从类型上说二维数组名退化的类型应该是“指向长度为 4 的 int 数组的指针”也就是int (*)[4]而不是“指向 int 指针的指针”。int **的步长是一个指针的大小int (*)[4]的步长是 4 个 int 的大小两者对同一块内存的解释完全不同。下面的写法是错误的void bad_print(int **a, int rows) { ... } int matrix[3][4] {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; bad_print((int **)matrix, 3); // 即使强转运行时大概率崩溃因为matrix这块内存里存放的是 12 个 int而bad_print用a[i][j]时会把矩阵开头的 int 值当成指针去解引用段错误几乎是必然的。正确的传参方式是#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(%d , a[i][j]); putchar(\n); } } int main(void) { int m[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(m, 3); return 0; }这里的关键是列数必须固定因为数组指针必须知道“一格”跨多远。如果函数想接收任意行列的二维数组或者列数在运行时才确定就要换用动态数组或者一维数组模拟这正好进入下一章要讲的内容。2. 二维数组的底层真相与动态内存里的“数组”2.1 二维数组在内存里其实是一维的我不知道有多少人第一次意识到二维数组在内存里是连续存储时会感觉被骗了。int a[3][4]并不是在内存里铺成一个“三行四列的表格”它其实就是 12 个 int 排成一队只是逻辑上被我们切分成三组每组四个。记一个非常实用的公式a[i][j]的地址等于首地址加上(i * 4 j)个 int 单位。下面的代码利用了这个特性把二维数组当一维数组遍历#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]; for (int k 0; k 12; k) printf(%d , p[k]); // 1 2 3 4 5 6 7 8 9 10 11 12 return 0; }为什么这个性质重要因为现代 CPU 对连续内存的访问有缓存优化按行遍历二维数组时内存访问是顺序的速度远快于乱序访问。很多初学者写成外层列、内层行程序性能差距在这个时候还不明显但数据规模一大差距可以用“天壤之别”形容。数组初始化的内容也值得一提两个等效习惯int a[3][4] {0}; // 全部初始化为 0 int b[3][4] {{1,2}, {3,4,5}}; // 未指定的位置自动补 0不要写int a[3][4] {};这在标准 C 里不是合法初始化部分编译器允许纯属扩展。使用 {0}即可把所有元素清零这也是我在工程代码里最常用的写法。2.2 遍历二维数组的三种姿势按需选用第一种最普通双层循环按下标访问。for (int i 0; i rows; i) for (int j 0; j cols; j) printf(%d , a[i][j]);第二种利用数组指针把“每一行”当作一个整体移动for (int (*r)[4] a; r a 3; r) for (int j 0; j 4; j) printf(%d , (*r)[j]);第三种就是用一维指针线性扫描。三种写法最终的地址计算方式一致区别在于代码语义。我的建议是常规开发用第一种可读性最好在需要和底层内存交互、或者函数形参设计时第二三种会让你更接近机器视角。热词里还有个“数组分割并显示包含某一字符”其实就是一个很实际的小需求给你一组字符串筛选出包含指定字符的那些。放到字符串列表场景里可以这样写#include stdio.h #include string.h int main(void) { char *arr[] {apple, banana, cherry, grape}; int n sizeof(arr) / sizeof(arr[0]); char key a; for (int i 0; i n; i) { if (strchr(arr[i], key) ! NULL) printf(第 %d 个字符串 %s 包含字符 %c\n, i, arr[i], key); } return 0; }如果要把匹配到的字符串收集到一个新数组里就是一个典型的“数组分割 条件过滤 结果收集”三件套。核心逻辑不复杂关键是懂得strchr用于单字符、strstr用于子串以及用指针数组来承载结果集合。2.3 动态二维数组malloc、二级指针和手动索引怎么选C 语言里没有原生动态二维数组最常见的做法有三条路网上搜索最多的是二级指针版int **m malloc(rows * sizeof(int *)); for (int i 0; i rows; i) m[i] malloc(cols * sizeof(int));这种写法直观m[i][j]可以直接用但有两个代价第一每一行是独立 malloc 的行和行之间的内存不连续访问局部性差第二释放时先逐行 free再 free 整个指针数组很容易漏释放造成内存泄漏。如果只是做矩阵运算我不推荐这种写法除非你到底层需要“每行的地址可以被单独传递”。更推荐的是连续分配加手动索引#include stdio.h #include stdlib.h int main(void) { int rows 3, cols 4; int *m malloc(rows * cols * sizeof(int)); if (m NULL) return 1; for (int i 0; i rows; i) for (int j 0; j cols; j) m[i * cols j] i * cols j; for (int i 0; i rows; i) { for (int j 0; j cols; j) printf(%d , m[i * cols j]); putchar(\n); } free(m); return 0; }这种方法一次性分配、一次性释放内存完全连续缓存友好而且避免二级指针释放时的碎碎叨叨。唯一的代价是你需要把m[i * cols j]这个公式写对稍不留神下标就错了。我的工程实践是优先选连续分配除非函数签名必须要求二维下标的指针语义。C99 的变长数组VLA也能在栈上定义运行时尺寸的二维数组int sum(int rows, int cols) { int a[rows][cols]; // VLAC99 可选特性 // ... }VLA 很方便但它在 C11 里被降级为可选特性某些编译器默认关闭跨平台时要格外小心。项目里用不用先查编译工具的默认行为。3. 逆序、去重、扩容数组上的高频操作实战3.1 字符串逆序的双指针写法与字符集陷阱字符串逆序是 PTA、期末考试里出镜率极高的题目解法基本都是双指针#include stdio.h #include string.h void reverse(char s[]) { int len strlen(s); for (int i 0, j len - 1; i j; i, j--) { char tmp s[i]; s[i] s[j]; s[j] tmp; } } int main(void) { char s[] hello world; reverse(s); puts(s); // dlrow olleh return 0; }注意循环条件是i j不是i j否则中间字符会被自己交换两次虽然结果无碍但代码不够干净。双指针交换的思路在很多字符串题里都是底子判断回文、反转单词、字符串移位基本都能看到这个影子。但这里有个新同学经常踩的暗坑C 语言的char是一个字节而现代系统上 UTF-8 编码的中文一个汉字占 3 个字节。把中文字符串按字节逆序得到的是乱码。例如你好在 UTF-8 下是 6 个字节直接逆序就变成字节层面的镜像。如果你只是做 ASCII 字符串逆序上面代码没问题如果题目要求处理中文就要按“字符”而不是按“字节”处理或者用宽字符函数。我的建议是刷题和面试时先问清楚输入是不是纯 ASCII这能省掉后面无穷多的麻烦。3.2 数组去重排序法和原地双指针法数组去重在热词里出现得很频繁。最简单、最不容易出错的方式是“排序 相邻去重”代码量少、思路清晰。#include stdio.h void sort(int a[], int n) { for (int i 0; i n - 1; i) for (int j i 1; j n; j) if (a[j] a[i]) { int tmp a[i]; a[i] a[j]; a[j] tmp; } } int dedup(int a[], int n) { if (n 1) return n; int idx 0; for (int i 1; i n; i) { if (a[i] ! a[idx]) { idx; a[idx] a[i]; } } return idx 1; } int main(void) { int a[] {3, 1, 3, 2, 1, 5, 2}; int n sizeof(a) / sizeof(a[0]); sort(a, n); int new_len dedup(a, n); for (int i 0; i new_len; i) printf(%d , a[i]); // 1 2 3 5 return 0; }去重的核心逻辑是双指针思想idx指向去重后最后一个有效位置i扫描整个数组遇到和前一个不同的元素就往前挪。因为数组已经有序相同的值会相邻所以只需要跟a[idx]比较而不是跟前面所有元素比较。这个写法的复杂度是排序的O(n log n)加扫描的O(n)。如果题目要求保持原顺序且不能排序那就需要空间换时间用一个辅助标记数组记录某个值是否出现过。前提是数据范围有限比如题目写明元素大小不超过 10000就可以int seen[10000] {0}; int dedup_stable(int a[], int n) { int idx 0; for (int i 0; i n; i) { if (!seen[a[i]]) { seen[a[i]] 1; a[idx] a[i]; } } return idx; }这种方式是O(n)时间复杂度但引入了额外的O(10000)空间。实际面试时空间限制宽松就用哈希表思路紧凑环境就用排序法没有万能的答案只有合适的取舍。3.3 数组增加与动态扩容realloc 的坑和不为人知的柔性数组C 语言的静态数组不能“增加”所谓“数组增加”在 C 里本质是动态内存管理。最常用的手段是realloc。下面这段是我平时当作“动态数组”底层的实现#include stdio.h #include stdlib.h int main(void) { int cap 4; int size 0; int *arr malloc(cap * sizeof(int)); if (arr NULL) return 1; for (int i 0; i 100; i) { if (size cap) { int new_cap cap * 2; int *tmp realloc(arr, new_cap * sizeof(int)); if (tmp NULL) { free(arr); return 1; } arr tmp; cap new_cap; } arr[size] i; } printf(size %d, cap %d\n, size, cap); free(arr); return 0; }这里有一个我反复强调的坑realloc失败时返回NULL但原来的内存块依然有效。如果你直接写arr realloc(arr, ...)一旦失败arr变成NULL原来的内存地址就找不到了内存泄漏直接拿到手。正确做法是使用临时指针确认非空后再覆盖原指针。扩容倍数通常选 2 倍或者 1.5 倍。如果每次只加一个元素的空间那么总复制次数是 O(n²)数据量大时卡到怀疑人生翻倍扩容能把平均复杂度摊还到 O(1)。这个理念在 C 的vector里也是一样的C 语言没有现成容器只能自己维护一份。除了realloc这条路还有个更冷门但很适合特定场景的结构柔性数组。它允许结构体最后一个成员声明为不完整数组#include stdio.h #include stdlib.h struct flex_array { int len; int data[]; }; int main(void) { int n 10; struct flex_array *fa malloc(sizeof(struct flex_array) n * sizeof(int)); if (fa NULL) return 1; fa-len n; for (int i 0; i n; i) fa-data[i] i * 2; for (int i 0; i fa-len; i) printf(%d , fa-data[i]); putchar(\n); free(fa); return 0; }柔性数组的好处是数组空间和结构体元数据在一次malloc中连续分配只需要一次free内存管理更干净。对比在结构体里放一个int *data那还要单独分配一次数据区释放时多一手操作。社区里不少轻量动态数组库就是用柔性数组实现的。4. 从冒泡、鞍点到树状数组数组在算法场景的正确用法4.1 冒泡排序的优化点边界控制和提前退出冒泡排序是所有教材都会写的入门算法但很多同学的版本里藏着一堆没必要的比较。基础版本长这样void bubble(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; } } } }内层循环上限n - 1 - i是有讲究的每一轮排序后最大的元素已经“沉”到末尾下一轮不需要再碰它。这里体现的“边界感”和打印九九乘法表的双层循环是同一个道理——内层循环的结束条件往往依赖外层变量一旦写错要么越界要么多跑一遍。真正值得优化的地方是提前退出。如果一个数组本来就是有序的普通冒泡仍然会走完所有轮次浪费 O(n²) 时间。加一个交换标记任何一轮没有发生交换说明数组已经有序立即结束void bubble_optimized(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; } }进一步的优化是记录最后一次发生交换的位置作为下一轮内层循环的右边界。因为最后一次交换之后的元素都已经有序不用再扫。这些优化对算法本身的复杂度没有质变但在数据近乎有序的场景下可以省掉大量无意义的比较。做工程的人很容易觉得冒泡太慢但你写内核模块或者嵌入式代码时数据量本来就不大这种无额外空间、实现极其简单的排序反而是最稳的选择。4.2 循环队列数组 rear length队头下标怎么算热词里有道非常经典的数据结构题“假设以数组 q[m] 存放循环队列中的元素同时以 rear 和 length 分别指示环形队列中的队尾元素位置和元素个数”。这题考察的是循环队列的空满判断和下标回绕。先用一个具体场景理解“循环”普通数组队列出队后队头指针前移数组前部空间被浪费直到 rear 到达末尾就再也入不了队即使前面还有很多空位这就是“假溢出”。循环队列把数组当首尾相接的环(rear 1) % m这样的取模运算让下标回到数组开头。有length变量后空满判断变得非常直接队空条件length 0队满条件length m也就是数组被全部占用队头下标front (rear - length 1 m) % m这里为什么有一个1因为题目给出的定义是rear指向队尾元素本身所以从队头到队尾一共有length个元素队头下标就是从rear往前倒推length - 1个位置。 m再取模是为了让负数变成合法下标因为 C 语言里负数的%运算结果是负数。入队和出队这样写#include stdio.h #include stdlib.h #define M 5 void enqueue(int q[], int *rear, int *length, int x) { if (*length M) { printf(队列已满无法入队 %d\n, x); return; } *rear (*rear 1) % M; q[*rear] x; (*length); } int dequeue(int q[], int *rear, int *length) { if (*length 0) { printf(队列为空无法出队\n); exit(EXIT_FAILURE); } int front (*rear - *length 1 M) % M; int x q[front]; // 队头空出来的位置不用清理等后续元素覆盖即可 (*length)--; return x; } int main(void) { int q[M]; int rear -1; int length 0; enqueue(q, rear, length, 10); enqueue(q, rear, length, 20); enqueue(q, rear, length, 30); printf(出队: %d\n, dequeue(q, rear, length)); printf(出队: %d\n, dequeue(q, rear, length)); enqueue(q, rear, length, 40); enqueue(q, rear, length, 50); enqueue(q, rear, length, 60); return 0; }注意出队操作并不需要真的清除数组元素只需要移动指针和减少长度。这是一个省事但在编码能力考察里很少被理解的细节。有了length再也不用担心循环队列“满不满”的经典二义性问题这也是很多考题选择这种定义的原因。4.3 树状数组数组 位运算实现 O(logn) 前缀和树状数组Fenwick Tree不算 C 语言语法层面的知识但它把数组用得极其巧妙也确实在竞赛和部分嵌入式场景里高频出现。它能用一片普通数组解决动态前缀和问题支持单点修改、前缀和查询复杂度都是 O(log n)。核心是lowbit函数int lowbit(int x) { return x (-x); }x (-x)拿到的是 x 二进制中最低位的 1 所对应的数值。比如说lowbit(10)10 的二进制是1010最低位的 1 对应十进制 2所以lowbit(10) 2。树状数组的查询以长度为 16 的序列为例查询前 11 个元素的和int sum(int i) { int s 0; while (i 0) { s tree[i]; i - lowbit(i); } return s; }模拟一遍sum(11)i 11二进制1011lowbit 1累加tree[11]i变为 10i 10二进制1010lowbit 2累加tree[10]i变为 8i 8二进制1000lowbit 8累加tree[8]i变为 0所以sum(11) tree[11] tree[10] tree[8]可以通俗理解成tree[8]负责前 8 个元素的和tree[10]负责第 9、10 两个元素的和tree[11]负责第 11 个元素自己。查询时就是不断“拆掉二进制最低位的 1”把若干区间拼起来。单点修改add(3, x)则是反方向void add(int i, int val) { while (i n) { tree[i] val; i lowbit(i); } }模拟一遍n 16、add(3, x)i 3更新tree[3]i变为 4i 4更新tree[4]i变为 8i 8更新tree[8]i变为 16i 16更新tree[16]i变为 32结束所以一个点的修改会影响树上包含它的所有“区间管理节点”需要向上传播。这个算法光是看代码会觉得抽象但你手写一遍查询和修改的路径很快就能摸到规律查询往低处走修改往高处走。它的局限也很明显——只能维护前缀和、区间和这类可加减的数据区间最大值最小值它做不了线段树才是干那个的。4.4 鞍点问题先定位行最大、再回查列最小热词里那道“用 stdio.h 和 limits.h 计算 5*5 鞍点”的题正好用来做本章的收尾。所谓鞍点是指矩阵中某个元素在它所在行最大、同时所在列最小。经典解法分两步先找每一行的最大值再检查这个最大值所在列是不是本列最小值。用上limits.h里INT_MIN初始化的理由是避免人工指定一个不够小的初始值#include stdio.h #include limits.h #define N 5 int main(void) { int m[N][N] { {1, 2, 3, 4, 5}, {6, 7, 8, 9, 10}, {11, 12, 13, 14, 15}, {16, 17, 18, 19, 20}, {21, 22, 23, 24, 25} }; int found 0; for (int i 0; i N; i) { int row_max INT_MIN; int max_col 0; for (int j 0; j N; j) { if (m[i][j] row_max) { row_max m[i][j]; max_col j; } } int is_col_min 1; for (int r 0; r N; r) { if (m[r][max_col] row_max) { is_col_min 0; break; } } if (is_col_min) { printf(鞍点位于(%d, %d)值%d\n, i, max_col, row_max); found 1; break; } } if (!found) printf(该矩阵不存在鞍点\n); return 0; }这段代码的价值不在鞍点本身而在于它示范了数组综合题的拆解思路先算出中间结果每行最大值再做交叉验证回查列。大部分二维数组题都是这个套路比如“找矩阵中既是行最大又是列最大的元素”“计算每行每列均值再比较”等等都可以拆成“先统计再判断”。我在实际教学里反复让学生画一画内存图和变量变化表因为这类题出错率高通常不是逻辑想不明白而是下标写错。尤其max_col必须单独保存很多人在第一次循环结束后忘了它接着就在列判断里误用了j最后结果错得莫名其妙。像这种小细节画图比讲十遍都管用。数组和指针、数组和内存的关系确实是把 C 语言从“会写”推向“写好”的一道坎。我自己带学生也好写项目也好最大感受是不要背代码把每一次数组访问都还原成“偏移量 起始地址”这两个概念很多问题会突然变得很简单。这篇把指针数组、二维数组、动态扩容和一些经典数组算法串了一遍希望你能在动手调试中把它们真正变成自己的底子。