C语言里最容易被低估、又最值得花透时间去弄明白的东西我觉得就是数组。你去看高校的 C 语言基础课、翁恺老师的练习题、PTA 和 PAT 的题目几乎每一套题都离不开数组等你后来接触 408 里循环队列、树状数组或者嵌入式里 ADC 采样值的滑动平均滤波底层骨干依然还是数组。这篇文章我就从头到尾把 C 数组拆开讲——从声明初始化的底层细节到指针数组和多维数组的关系再落到字符串、循环队列、动态扩容和树状数组的代码实现最后附上我踩过这么多次越界访问之后总结出来的调试姿势。适合正在学 C 语言基础的同学也适合准备期末考、刷练习题或者做课程设计的朋友拿来当参考手册。1. 数组的底层逻辑与初始化规范1.1 数组到底是什么声明、内存布局与初始化规则数组的定义听起来很简单同一类型、连续内存、按下标偏移访问的一组元素。但真正理解“连续内存”和“下标偏移”比背一百遍定义都顶用。int scores[10]; char name[20]; double matrix[5][4];这三行声明分别创建了能存 10 个 int、20 个 char、20 个 double 的连续内存块。在大多数 64 位 Linux 系统里int 占 4 字节那int scores[10]就直接占 40 字节scores[0]到scores[9]的地址相邻间隔固定为 4 个字节。这意味着一件事访问scores[i]的本质是通过首地址 i * 每个元素字节数计算出一个地址再从那个地址读数据。这也是为什么数组比链表更适合随机访问——直接拿下标做乘法就能定位不需要从头开始一个个找。初始化这一步就开始有讲究了。下面三种写法是合法的int a[5] {1, 2, 3, 4, 5}; int b[5] {1, 2, 3}; // 剩余元素自动补 0 int c[] {10, 20, 30}; // 编译器根据初始化列表推断数组大小为 3我在给初学者改代码时发现最常见的误区有两个。第一个误区是局部数组不初始化就直接拿来算结果里面全是栈上的随机垃圾值。注意区分全局数组和static修饰的数组默认清零但局部数组不会。第二个误区是初始化列表里的元素个数超过声明长度比如int d[2] {1, 2, 3};这属于编译错误不是程序运行后自己敲掉一个元素就完事的问题。日常写代码宁可多写几个 0也别让长度对不上。还要说一嘴“数组增加”这个词因为经常有新手从 Python 转过来之后问C 数组怎么 push_back事实上 C 语言内置数组没有“增加元素”的操作数组长度是编译期定死的。想实现动态增长要么定义足够大的容量然后自己维护一个有效长度要么用mallocrealloc在堆上做扩容后面第 5 节我会给出完整方案。1.2 数组遍历的下标控制循环边界是最容易翻车的地方数组遍历算是标准操作了最普通的写法是这样int a[5] {11, 22, 33, 44, 55}; for (int i 0; i 5; i) { printf(%d , a[i]); }这个循环的边界i 5很关键。如果我写成i 5循环会读取a[5]而a[5]在内存里已经跑出了数组的领地。数组越界读取不一定会立刻崩溃很多时候你只得到一个随机值但越界写就危险了——它可能把紧挨着的其他变量、甚至函数返回地址给改写程序偶尔崩溃、偶尔不崩溃成了最磨人的问题。再补充一个遍历里必须避开的坑在函数内部用sizeof(a) / sizeof(a[0])来计算传入数组的长度是算不出来的。因为数组一旦作为函数参数传递就会“退化”成首元素的指针sizeof(a)得到的是一个指针大小64 位机器上通常是 8而不是整个数组的字节数。我习惯的写法是void print_array(int arr[], int len) { for (int i 0; i len; i) { printf(%d , arr[i]); } printf(\n); }也就是说在 main 函数里可以算大小传入函数时必须额外带一个长度参数。1.3 两个数组基础操作去重与九九乘法表输出数组去重是很常见的入门题。最简单粗暴的做法用双重循环把重复元素置成标记值然后统一输出或者用“新数组收集法”遇到一个没出现过的值就追加进新数组。我第一次写去重时代码长这样#include stdio.h int main() { int a[] {1, 3, 2, 3, 4, 1, 5}; int n 7; int b[100], m 0; for (int i 0; i n; i) { int duplicated 0; for (int j 0; j m; j) { if (a[i] b[j]) { duplicated 1; break; } } if (!duplicated) { b[m] a[i]; } } for (int i 0; i m; i) { printf(%d , b[i]); } return 0; }这里b数组容量给了 100实际使用过程中如果数据量可能超过容量要自己写动态扩容不能拍脑袋给大数组。九九乘法表是训练嵌套循环和纵横坐标控制的好题目。用二维数组先存表再输出可以加深对行列结构的理解#include stdio.h int main() { int table[9][9] {0}; for (int i 0; i 9; i) { for (int j 0; j i; j) { table[i][j] (i 1) * (j 1); } } for (int i 0; i 9; i) { for (int j 0; j i; j) { printf(%d*%d%-3d, j 1, i 1, table[i][j]); } printf(\n); } return 0; }好多同学问我为什么输出时要写成%-3d其实就是为了让每列宽度统一左对齐补空格输出对齐以后像正经表格否则数字错落看起来极其难受。2. 数组与指针绕不开的亲密关系2.1 数组名、指针与下标访问的等价性数组名和指针到底是不是一回事这是我每次答疑都会被翻出来的问题。结论要先说清楚既一样又不一样。int a[5] {1, 2, 3, 4, 5}; int *p a; // 合法数组名自动转换为首元素的地址 int *p2 a[0]; // 和上面完全等价 p; // 合法p 现在指向 a[1] a; // 非法数组名不是可赋值的变量数组名 a 在绝大多数表达式中会被“退化”成一个指向首元素的指针所以p a没有任何问题。但 a 本身不是一个指针变量它是一个不可修改的地址常量。C 语言标准中把a[i]直接定义为*(a i)所以如果你写i[a]其实也能编译、也能跑只是正常人不会这么干我见过身边人用这招“炫技”就把自己坑了——代码看着特别答辩维护成本直接起飞。在函数参数里int arr[]和int *arr写法不同实际完全等价。理解这点之后你就会明白为什么传数组进函数后sizeof(arr)会失控也就能明白为什么需要单独传一个长度参数。2.2 指针数组、数组指针与二维数组传参这一小节经常把人绕晕但掰开其实非常清晰。指针数组先是一个数组数组里的每个元素都是指针。声明看这里int *p[3]; // [] 的优先级比 * 高p 先和 [3] 结合是数组这种结构最适合存多个字符串。因为字符串长度不固定用二维字符数组会浪费空间不如搞一个指针数组每个指针指向各自的字符串字面量或堆内存最后还能用sizeof(p) / sizeof(p[0])算出有多少个字符串。数组指针先是一个指针指针指向的是整个数组。声明长这样int (*p)[3]; // 先和 * 结合p 是指针指向 int[3] 类型的数组为什么老强调int (*p)[3]因为二维数组int arr[3][4]里arr的类型就是“指向int[4]的指针”。如果你把它错误地赋给int **p编译器会给你类型不匹配的警告运行起来很可能用错的步长去踩内存。正确做法int arr[3][4]; int (*p)[4] arr; // 正确p 按每次 4 个 int 的跨度移动这里可以直观理解二维数组的“行优先”内存模型arr[1]是第二行的首地址arr 1同样指向第二行首地址两者数值上相同但类型信息不一样。arr 1这个指针加减的步长是 4 个 int而不是 1 个 int这是区分二维数组指针和一维数组指针的关键。如果你要把二维数组传给函数原型可以写成void f(int arr[][4])或者void f(int (*arr)[4])此时第二维必须写死原因就在于编译器要算出每一行的字节步长否则没法从下标换算地址。3. 二维数组与经典实战题目3.1 二维数组的内存布局与遍历性能差异二维数组的逻辑结构是“多行多列”在物理内存里却是线性的。int arr[3][4]摆了 12 个 int存储顺序是一行 4 个排完再排第二行再排第三行也就是行优先。这个特性直接导致了一个实战结论遍历二维数组时按行遍历比按列遍历快得多。按行遍历时访问arr[0][0]、arr[0][1]、arr[0][2]的地址是连续递增的CPU 缓存的预取机制能直接命中按列遍历等于每访问一个元素就跳过一个行的距离缓存命中率急剧下降。数据量小的时候无所谓一旦矩阵到几百上千规模性能差距非常明显。可以用看地址的方式验证内存布局#include stdio.h int main() { int arr[2][3] {{1, 2, 3}, {4, 5, 6}}; for (int i 0; i 2; i) { for (int j 0; j 3; j) { printf(arr[%d][%d] %2d address %p\n, i, j, arr[i][j], (void *)arr[i][j]); } } return 0; }你观察输出就会发现每个地址相差正好是一个 int 的字节数完全没有缝隙。3.2 完整实战5×5 矩阵的鞍点求解题库里有一道经典题给定一个 5×5 的矩阵找出所有“鞍点”。鞍点的定义是该元素在其所在行中最大并且在其所在列中最小。题目贴上了stdio.h和limits.h的标签就是因为求解过程需要靠INT_MAX和INT_MIN来初始化比较的极值。我的写法如下#include stdio.h #include limits.h int main() { int a[5][5]; printf(请输入 5x5 矩阵\n); for (int i 0; i 5; i) { for (int j 0; j 5; j) { scanf(%d, a[i][j]); } } int found 0; for (int i 0; i 5; i) { int rowMax INT_MIN; int colIndex 0; // 第一步找到当前行的最大值以及它所在的列 for (int j 0; j 5; j) { if (a[i][j] rowMax) { rowMax a[i][j]; colIndex j; } } // 第二步在第 colIndex 列上检查该值是否是最小值 int colMin INT_MAX; for (int k 0; k 5; k) { if (a[k][colIndex] colMin) { colMin a[k][colIndex]; } } if (rowMax colMin) { printf(鞍点: a[%d][%d] %d\n, i, colIndex, rowMax); found 1; } } if (!found) { printf(该矩阵没有鞍点\n); } return 0; }这里有几个细节值得留意。第一为什么第一步找行最大值时用而不是如果行里有多个并列最大值默认取第一个。某些题目如果要求所有鞍点都要输出那遇到并列最大值时就要换个思路不能再只记一个列下标了。第二为什么第二步不能用a[i][colIndex] rowMax直接判断因为“列最小”需要跟整列的每个元素比较不是只看当前行。第三LIMITS.H的作用在于给极值一个确定的起点否则如果矩阵里全是负数初始值 0 会把真正的最大值挡在外面。这道题跑通之后强烈建议自己把 5 改成任意 n变成一个由用户输入行列数的动态矩阵版本顺便练习一下 malloc 分配的二维数组。我自己的经验是把固定大小的题目改成动态版本能真正暴露自己对指针语义的理解水平。4. 字符数组与字符串处理实战4.1 字符串本质与缓冲区操作C 语言没有独立的字符串类型字符串就是“以\0结尾的字符数组”。这一点比任何理论都重要因为所有字符串函数的边界判断都依赖这个看不见的结束符。char str1[] hello; // 数组实际大小是 6最后一个元素是 \0 char str2[5] hello; // 危险长度不够\0 被丢掉strlen 的结果不可控 char buf[1024];str1 那个写法看起来只写了 5 个字母但数组长度是 6编译器自动在末尾补了一个\0。而 str2 声明成 5要把 5 个字母全放进去\0没位置从语法上说这块内存就不再是一个合法的 C 字符串了。新手在 PTA 上做字符串题经常出现“输出多了一堆乱码”十有八九就是结束符丢了。输入函数也不能乱用。scanf(%s, buf)不检查边界输入一长直接往 buf 后面写这是缓冲区溢出问题的经典来源。在竞赛和刷题环境里我推荐用fgetschar buf[1024]; fgets(buf, sizeof(buf), stdin);fgets会自动留出空间放\0还会把换行符读进字符串里。所以读完之后通常要手动干掉换行符buf[strcspn(buf, \n)] \0;strcspn返回字符串里第一个匹配\n的位置把那个位置直接替换成结束符思路很干净。这里我还想提一下文件缓冲区和字符串输出的关系。stdio.h的printf在默认情况下是行缓冲甚至全缓冲的意思是输出内容不会立刻写到屏幕上而是先攒在缓冲区里。如果你的程序因为数组越界直接崩溃排在前面的printf内容可能还没刷出来造成一种“前面没执行”的错觉。调试时我喜欢在 main 开头写一句setbuf(stdout, NULL);把标准输出设置成无缓冲每条输出实时落屏排查问题会少很多困惑。这个细节在终端、虚拟机里跑程序时特别实用。4.2 字符串逆序PTA 级别的代码实现与避坑字符串逆序是 PTA 里的高频题。题目一般要求读入一行字符串原地翻转后输出。我给出的版本可以作为标准解#include stdio.h #include string.h int main() { char s[100]; fgets(s, sizeof(s), stdin); s[strcspn(s, \n)] \0; int len strlen(s); for (int i 0, j len - 1; i j; i, j--) { char temp s[i]; s[i] s[j]; s[j] temp; } printf(%s\n, s); return 0; }这道题最大的坑就是我上面说的换行符。假如不把fgets读进来的\n去掉strlen(s)会把那个换行符也算作一个字符。逆序之后原本的末尾换行就跑到字符串开头去了输出时第一行空一行格式直接出错。我第一次刷这个题就栽在换行符上面后来写字符串输入题第一时间先把换行干掉成了肌肉记忆。另外如果题目输入包含空格scanf(%s)会在空格处停下只读走半个字符串。所以带空格的字符串必须用fgets或getchar逐字符读。PTA 练习题里还有一大类题目是“数组分割并显示包含某一字符的字符串”处理路径也是先读进字符数组再用循环按分隔符切开遇到目标字符就输出当前这段内容。这类题考的就是对字符数组游标的精确控制代码量不大但边界条件非常多。5. 数组在数据结构和算法中的落地5.1 循环队列的数组实现rear 和 length 的配合数据结构这边数组最常见的应用就是实现循环队列。网上很多题都写着“假设以数组 q[m] 存放循环队列中的元素同时以 rear 和 length 分别指示环形队列中的队尾位置和队列长度”。这种写法比较典型因为用一个 length 字段就同时解决了队空和队满的判定问题不需要额外维护 front 指针。先看结构定义#include stdio.h #define MAX 5 typedef struct { int data[MAX]; int rear; // 队尾索引指向下一次入队的位置 int length; // 当前队列中的元素个数 } CircularQueue; void init(CircularQueue *q) { q-rear 0; q-length 0; }入队操作int enqueue(CircularQueue *q, int x) { if (q-length MAX) { return 0; // 队列满 } q-data[q-rear] x; q-rear (q-rear 1) % MAX; q-length; return 1; }出队操作int dequeue(CircularQueue *q, int *x) { if (q-length 0) { return 0; // 队列空 } int front (q-rear - q-length MAX) % MAX; *x q-data[front]; q-length--; return 1; }这里的关键是队头下标怎么算。因为队列里的元素是从队头到队尾连续排列的队尾下标是rear当前长度是length所以队头下标就是rear - length。加上 MAX 是为了处理负数取模的问题。长度刚好为 0 时理论上队头和队尾重合但这不代表队列里没有空位所以判空必须用 length而不是rear front。这种设计对考研 408 的朋友来说特别眼熟。理解(rear - length MAX) % MAX这个式子比死记循环队列公式有用得多因为只要你弄懂了“线性数组里用取模构造逻辑上收尾相接”的原理后面做环形缓冲区、循环链表思路全是相通的。5.2 动态数组扩容与树状数组模板很多刚学完 C 基础的人总想找一种“能自动增长的数组”。标准库确实没有但我们可以用mallocrealloc三十行手写一个动态数组#include stdio.h #include stdlib.h typedef struct { int *data; int len; int cap; } DynamicArray; void initArray(DynamicArray *arr, int initCap) { arr-data (int *)malloc(initCap * sizeof(int)); arr-len 0; arr-cap initCap; } void push(DynamicArray *arr, int x) { if (arr-len arr-cap) { arr-cap * 2; arr-data (int *)realloc(arr-data, arr-cap * sizeof(int)); } arr-data[arr-len] x; }扩容时直接让容量翻倍能够保证多次 push 的均摊时间复杂度是 O(1)。这个道理跟 C 的 vector 扩容策略是一样的——每次都只多扩一个位置会导致反复拷贝整块数组性能极差一次性翻倍拷贝次数呈对数下降。再往算法深入一点树状数组是竞赛题里出镜率很高的结构。它本质上就是一个普通数组tree但通过下标二进制的最低位lowbit来组织维护关系。模板通常长这样int n 16; int tree[17]; void add(int i, int x) { while (i n) { tree[i] x; i i (-i); } } int prefixSum(int i) { int res 0; while (i 0) { res tree[i]; i - i (-i); } return res; }比如维护长度 n 16 的序列查询前缀和 sum(11)。11 的二进制是 1011它会被拆成 8 2 1 三段函数依次访问tree[11]、tree[10]、tree[8]把这三段维护好的区间和累加出来。单点修改 add(3, x) 时3 的二进制是 11lowbit 计算得到 1于是先更新 tree[3]然后 i 变 4更新 tree[4]再变 8更新 tree[8]最后变 16更新 tree[16]。整个过程跑得非常快修改和查询都是 O(log n)。学树状数组最大的门槛不是代码本身而是理解为什么要按 lowbit 跳。我当时花了一下午把 1 到 16 的下标二进制挨个写出来手动模拟 add 和 sum 的访问路径才真正明白每个 tree[i] 管的是哪段区间。建议你也拿纸笔推一遍比在网上找十篇讲解都管用。6. 数组程序的调试与常见故障排查6.1 越界访问和未初始化两大隐形杀手数组程序最让人头疼的不是报错而是不报错却行为诡异。我见过太多同学盯着屏幕问我明明没改 b 的值为什么 b 变成了 6隔壁越界访问背锅了。int a[3]; int b 0; for (int i 0; i 3; i) { a[i] i * 2; }循环跑到 i 3 时a[3]已经超出了数组边界。如果内存布局里 b 恰好紧挨着 a 分配写a[3]就等于直接改 b。程序没崩溃但结果全错。这种错误靠肉眼几乎看不出来我排查时最有效的办法就是把循环边界打印出来或者干脆把循环条件写成单调递增的哨兵变量配合断点逐步观察。未初始化数组是另一个兄弟问题。局部数组里存的是栈上残留垃圾值很多人定义完数组不赋值就直接用得到天马行空的输出数字。判断是不是这个问题最简单的做法是把数组初始化成全 0 再跑一遍如果结果变了说明原来有垃圾值参与了计算。为了避免这两类问题我写代码有两条习惯一是所有数组要么初始化要么第一个操作就是往里面填正式数据二是所有循环边界都用常量或者 len 变量不在循环体里手滑把写成。6.2 用 GDB 观察数组的实用姿势热词里有一条很中肯的建议用 GDB 调试 C 语言程序。数组的调试尤其适合 GDB因为它能直接查看一片连续内存。首先用带调试信息的模式编译gcc -g -o demo demo.c gdb ./demo进入 GDB 以后我常用这几条指令(gdb) break main (gdb) run (gdb) next (gdb) print arr (gdb) print arr[2] (gdb) x/10dw arr (gdb) watch a[2]print arr会把整个数组内容列出来print arr[2]看单个元素。x/10dw arr是“以十进制格式查看从 arr 开始的 10 个字”w 表示一个字也就是 4 字节正好对应 int 数组。这个命令特别适合检查越界你怀疑数组后面的内存被改了直接扩大查看范围比如x/12dw arr看看 arr 边界外有没有异常值。watch a[2]是个监控利器。设置之后只要程序里任何地方改变了a[2]的值GDB 会立刻停下来告诉你是哪一行代码干的。排查越界改写相邻变量的场景这一招比一堆printf定位快得多。我使用的整体流程是这样先编译时加-Wall -Wextra让编译器把可疑代码警告出来再跑一遍程序如果结果不对就先在 main 开头设置无缓冲输出setbuf(stdout, NULL)接着用 print 看关键数组用 x 命令看数组前后内存最后用 watch 抓越界写的人。这一套下来九成数组问题都能锁定到具体位置。症状可能原因排查方式输出含随机大数局部数组未初始化初始化全 0 后重跑对比其他变量莫名被改数组越界写watch 相邻变量查看反汇编栈布局字符串输出乱码或换行跑到开头缺少\0或 fgets 换行未处理检查 strlen 长度和末字符函数内数组长度计算错误数组退化为指针不要用 sizeof 计算传入数组程序崩溃但 printf 没输出全缓冲导致内容滞留setbuf(stdout, NULL) 或主动 fflush关于 C 语言数组的最后一个提醒也是我自己的亲身体会数组是你理解内存的一扇门。把数组搞透学指针不会那么吃力学链表、树、哈希表的时候也会更有底。如果你手头还有浙大的 C 语言基础题或者翁恺老师的题库没刷完不妨从数组类题目开始逐题过一遍那些题考来考去最后都是在考你对一块连续内存的掌控力。真正把数组踩踏实了后面编程路上的大部分坑你已经提前迈过去一大半。
阅读完成 · 觉得有帮助?