数组是C语言里最常见的考点,也是许多初学者第一次感到“代码不是照着敲就能跑通”的地方。为什么数组下标从0开始?为什么越界后不直接报错,反而程序跑到后面才莫名其妙崩掉?为什么字符串逆序输出这么简单的一道题,能让人卡半小时?这篇文章要聊的“适合C初学数组题”,就是把这些问题打包成一套能照着跑的练习:先过一维数组的初始化、遍历、边界控制,再上二维数组的鞍点、成绩统计,最后把排序去重、指针数组、动态数组和常见报错一并讲清楚。刚学完分支和循环、准备啃数组的同学,可以把它当成一张学习路线图;备考计算机二级或者刷学校机试的,也能拿来做查漏补缺。
数组题说难也难,说简单也简单。难在它第一次把“内存”“类型”“下标”这些概念揉在一起,一不留神就写出越界代码;简单在你只要真正理解了“数组是一块连续内存”这个本质,绝大多数题最终都能归到“遍历+判断”四个字上。这文章里没有高深技巧,只有贴着初学者水平走的题目和踩坑记录,代码都用标准C编写,GCC编译验证过,可以直接放心复现。
1. 题材怎么选:数组题到底在练什么
1.1 一道数组题的标准拆法
数组题看起来五花八门,其实拆开就四件事:存储、遍历、判断、输出。我拿最简单也最常考的“输入n个数,逆序输出”举例。你可以把数组想象成一排连续编号的储物格,每个格子里放一个int,编号从0开始。代码写出来是这样:
#include <stdio.h> #define MAXN 100 int main(void) { int n, i; int arr[MAXN]; scanf("%d", &n); for (i = 0; i < n; i++) { scanf("%d", &arr[i]); } for (i = n - 1; i >= 0; i--) { printf("%d", arr[i]); if (i > 0) { printf(" "); } } return 0; }这段代码背后有三个新手最容易忽略的点。第一是#define MAXN 100,先把数组上限写死,初学者暂时不用折腾变长数组和动态内存,可以把注意力放在逻辑上;第二是逆序输出时i从n-1开始而不是从n开始,因为最后一个有效下标就是n-1,从n开始一上来就越界;第三是输出空格的时机,很多人习惯每输出一个数就加一个空格,结果末尾多了一个空格,在OJ上会报Presentation Error。逆序输出这道题应该背下来,它同时练了循环、下标、边界三个基本功。
1.2 给初学者的一条刷题路线
数组部分的学习顺序,我建议按“一维基础题 → 字符串处理 → 二维数组题 → 排序查找题 → 数组与指针结合题 → 动态数组”这样推进。不要一上来就碰指针数组,很多教材把指针和数组放在同一章,容易让人误以为“数组就是指针”,实际上它们只是在函数传参时存在等价关系,数组本身是一块连续内存,指针只是一个保存地址的变量。顺序一旦打乱,很容易卡在中途失去信心。
选题目时,优先选边界条件明确的题,比如“输入若干个数,求最大值及所在下标”“把数组元素循环右移一位”,这些题能把遍历、判断、交换练透。题源方面,我比较推荐翁恺老师的C语言课配套练习题,以及PTA平台的C语言基础题集。这两个题源共同的优点是题目短、输入输出格式写得很清楚、样例覆盖广,适合先自己画思路,再上机验证。我自己的习惯是每道题写两种做法,比如逆序输出可以下标逆着走,也可以双指针交换后顺序输出,这样练出来的思路更灵活。
1.3 题目范围与工具要求
这篇文章里出现的题目,全部只需要标准C库就能跑,不依赖任何第三方库。运行环境我用的是GCC/Clang,Windows上可以装MinGW-w64,再用VS Code或者Dev-C++写代码都行;macOS/Linux直接在终端里用gcc编译就是。C语言标准建议用C11或更新,写代码时尽量让编译器开启警告,比如GCC的-Wall -Wextra。对初学者来说,编译时多看警告比运行时调试更省力,因为很多错误在警告里已经暗示了。环境能支持单文件编译和最基本的调试就够用,别在编辑器主题、代码补全上折腾半天,那不是“适合C初学数组题”的重点。
2. 一维数组入门题:初始化、下标、边界控制
2.1 数组初始化与下标为什么从0开始
先看最简单的初始化。定义数组后,里面的值是“不确定”的,所以新手第一件事就是学会“清零”:
#include <stdio.h> int main(void) { int a[10] = {0}; // 所有元素初始化为0 int b[5] = {1, 2, 3}; // b[3]、b[4]自动补0 for (int i = 0; i < 10; i++) { printf("a[%d] = %d\n", i, a[i]); } return 0; }很多人只记住了“大括号初始化”,却不知道部分初始化时剩余元素会自动补0。int b[5] = {1,2,3}这种写法,程序员如果只显式给了3个值,后两个也会是0,这一点在统计、累加的题里非常有用。如果定义后不写大括号,比如int a[10];,那数组里装的是栈上的残留数据,很可能出现“每次运行结果都不一样”的诡异现象。排查方法很简单:定义时顺手加上={0}。
下标从0开始也不是编译器随便定的,而是因为C语言规定a[i]等价于*(a + i),也就是“从数组首地址向后偏移i个元素”。第一个元素的偏移量是0,所以下标从0开始。理解了这一点,后面学指针数组、指针算术时都会顺畅很多。另外要记住,C语言不支持数组整体赋值,int c[5]; c = b;编译不过,想复制只能逐元素循环或用memcpy。
2.2 越界不报错,不等于可以越界
C语言为了提高运行效率,默认不检查数组下标是否越界,这是它和Java、Python最大的区别。不检查带来的后果是:越界那一刻程序可能还是一副没事的样子,但已经悄悄改坏了旁边变量的值,或者破坏了栈上的返回地址,直到运行到很后面才崩溃。
#include <stdio.h> int main(void) { int a[3] = {1, 2, 3}; int b = 10; a[3] = 99; // 越界写入,编译不报错 printf("b = %d\n", b); // 在某些编译器布局下,b 会被改成 99 return 0; }这个例子依赖编译器内存布局,不同平台结果可能完全不一样,不要用这种写法去“验证”什么。它存在的意义是提醒你:边界问题只能靠人肉盯。最常见的越界场景来自for循环的边界写错,比如数组长度是n,结果习惯性写成了i <= n,最后一次循环恰好把数据写到arr[n]这个“不存在”的位置。排查越界时,推荐用一个简单办法:在循环里临时加一句printf("i = %d\n", i);,把所有下标都打出来,一眼就能看出哪里多走了一步。
2.3 字符串逆序输出:一整类题的入口
字符数组本质也是数组,只是它用来存字符,并且约定以'\0'结尾。字符串逆序输出几乎是所有OJ平台都会出现的题,它考的不是什么高深算法,就是“取长度”和“反向遍历”两个基本功:
#include <stdio.h> #include <string.h> int main(void) { char s[101]; scanf("%100s", s); // 最多读100个字符,防止越界 int len = strlen(s); for (int i = len - 1; i >= 0; i--) { putchar(s[i]); } putchar('\n'); return 0; }这里有一个细节特别值得说:char s[101]能容纳的字符串长度其实是100个字符加一个'\0',所以scanf里写了%100s而不是%101s,否则一旦输入正好100个字符,就容易丢掉结束符。如果只是逆序输出,直接反向遍历就行;如果要求“把字符串本身反转”,可以双指针原地交换:
void reverse(char s[]) { int left = 0, right = strlen(s) - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } }这一整类题的入口在于:所有字符串处理都逃不开“先找长度、再逐个字符操作”这两步。后续学比较两个字符串、统计单词数、去掉空格,甚至“数组分割并显示包含某一字符”这类题目,其实都是在这个框架上做变形。核心还是遍历字符数组、按分隔符切分、逐段判断,本质没有跳出“长度+遍历”的圈子。
3. 二维数组与经典算法题
3.1 二维数组的存储本质:逻辑表格,物理线性
二维数组在逻辑上是“行和列”,但在内存里其实是一段连续的一维空间。比如int a[3][4],C语言按行优先存储:先放第0行的4个int,再放第1行、第2行。这个本质决定了大部分二维数组题的做法。
#include <stdio.h> int main(void) { int a[3][4] = {0}; for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { a[i][j] = i * 10 + j; } } for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%4d", a[i][j]); } putchar('\n'); } return 0; }用两层循环遍历时,习惯上外层控制行i,内层控制列j,这样访问顺序和内存存储顺序一致,性能也最好。如果你想知道数组是不是真的连续,可以用int *p = &a[0][0];,之后用p[5]这种一维下标访问,会发现它和a[1][1]是同一个位置。少数题目会利用这种连续性,比如用memset(a, 0, sizeof(a))一次性清空整个二维数组,前提就是二维数组的内存必须连续。
3.2 鞍点问题:一道题练穿多重循环和状态判断
“计算5×5矩阵的鞍点”是C语言数组题里很有代表性的综合题,PTA和其他练习册里都有。题目说的是:如果某个元素在它所在的行上最大,同时在它所在的列上最小,它就是一个鞍点。这道题把二维数组遍历、比较、布尔状态标记三者全部考到了。
#include <stdio.h> #define N 5 int main(void) { int a[N][N]; int i, j, k; int found = 0; for (i = 0; i < N; i++) { for (j = 0; j < N; j++) { scanf("%d", &a[i][j]); } } for (i = 0; i < N && !found; i++) { for (j = 0; j < N; j++) { int isRowMax = 1, isColMin = 1; for (k = 0; k < N; k++) { if (a[i][k] > a[i][j]) isRowMax = 0; if (a[k][j] < a[i][j]) isColMin = 0; } if (isRowMax && isColMin) { printf("鞍点: a[%d][%d] = %d\n", i, j, a[i][j]); found = 1; break; } } } if (!found) { printf("不存在鞍点\n"); } return 0; }我见过不少人的第一反应是“先找每行最大值,再判断它是不是列最小”,这个思路本身没问题,但写起来容易漏:一行里可能存在多个相同最大值,只看第一个会漏掉。上面的写法是暴力判断每个元素:对任意a[i][j],在同一行里找有没有比它大的,在同一列里找有没有比它小的,两个条件都满足就是鞍点。5×5规模很小,三重循环最坏也才125次比较,初学阶段完全可行。做题时还要看清题目对相等情况的定义:有的题说最大值唯一,有的允许并列,如果你只是“遇到大于才否定”,那就默认允许并列,这在大多数OJ上都能通过。
3.3 二维数组实战:成绩统计与九九乘法表
二维数组最贴合的应用就是表格数据,比如“5名学生、3门课程的成绩表,求每个学生的平均分和每门课的平均分”。这个题考的是二重循环的两种方向:按行走和按列走。
#include <stdio.h> #define ROWS 5 #define COLS 3 int main(void) { int scores[ROWS][COLS]; for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { scanf("%d", &scores[i][j]); } } double stuAvg[ROWS], subjAvg[COLS]; for (int i = 0; i < ROWS; i++) { int sum = 0; for (int j = 0; j < COLS; j++) { sum += scores[i][j]; } stuAvg[i] = sum / (double)COLS; } for (int j = 0; j < COLS; j++) { int sum = 0; for (int i = 0; i < ROWS; i++) { sum += scores[i][j]; } subjAvg[j] = sum / (double)ROWS; } for (int i = 0; i < ROWS; i++) { printf("第%d名学生平均分: %.2f\n", i + 1, stuAvg[i]); } for (int j = 0; j < COLS; j++) { printf("第%d门课平均分: %.2f\n", j + 1, subjAvg[j]); } return 0; }注意sum / (double)COLS,如果不转成double,整数除法会把平均分的小数部分直接丢掉。九九乘法表其实也是二维数组的经典练习,可以先把结果存到int table[9][9]里,再按行打印;虽然题目本身不用数组也能打印,但用数组存一遍再输出,能让你更清楚第i行第j列的下标映射关系。这类“表格类”数组题的通用套路就是:先确定行和列的含义,再用二重循环填数据,最后按需求方向做统计。
4. 数组进阶题:指针传参、排序与动态内存
4.1 冒泡排序与数组去重:最经典的综合基础题
学完一维数组和二维数组之后,就该练排序了。冒泡排序是C数组题中最经典的综合基础题,它把遍历、比较、交换三个动作全串在一起。基本思路是每一轮把相邻元素两两比较,大的往后挪,这样一轮下来最大值就“冒”到了末尾。
void bubbleSort(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而不是n - 1?因为每一轮结束后,数组末尾已经排好了i个元素,它们不需要再参与下一轮比较。如果少了- i,排序结果不会错,但会做很多无意义比较,初学阶段虽然看不出来,养成这个好习惯以后写更复杂的算法会受益匪浅。
数组去重也是经常出现的题,常见套路有两种。第一种是双重循环,遇到重复元素就把后续元素整体前移一位,直观但有点啰嗦;第二种是先用冒泡排序排好序,再遍历一次,只保留和前一个不同的元素。后者把问题转化成“判断相邻元素是否不同”,代码短也好查错。这两道题一起练,能把“边界”“交换”“状态标记”这三个核心动作一次补齐。
4.2 指针数组与数组指针,一次分清
到了这一节,题目的难度就上来了,很多初学者在这里开始迷糊。先说结论:指针数组是一个数组,里面装的是指针;数组指针是一个指针,它指向一个数组。
#include <stdio.h> int main(void) { char *names[3] = {"C Language", "Array", "Pointer"}; int (*p)[3]; // 指向“含3个int的数组”的指针 int a[3] = {1, 2, 3}; p = &a; for (int i = 0; i < 3; i++) { puts(names[i]); } printf("%d %d %d\n", (*p)[0], (*p)[1], (*p)[2]); return 0; }char *names[3]怎么读?从变量名往右看,先看到[3],说明它首先是一个数组,元素类型是char *。int (*p)[3]因为有括号,所以p先是指针,指向的对象是含3个int的数组。指针数组最常用的场景就是存放字符串,因为字符串本身是字符数组,在C里习惯用char *指向它的首字符,多个字符串就放在一个char *数组里。要提醒的是:直接初始化的字符串字面量在部分平台是只读的,比如names[0][0] = 'X'可能直接崩溃;如果题目要求修改字符串,最好改成二维字符数组char names[3][20],或者用动态分配复制一份。
4.3 数组传参会退化,函数里别用sizeof
数组名在绝大多数表达式里会“退化”成指向首元素的指针,函数传参时尤其明显。也就是说,函数形参写成int arr[]和int *arr完全等价。这个特性导致一个经典错误:在函数内部用sizeof(arr)求数组长度,得到的是8字节指针大小,而不是数组真实大小。
#include <stdio.h> void badSize(int arr[]) { printf("在函数里sizeof(arr) = %zu\n", sizeof(arr)); // 64位机器上通常是8 } int main(void) { int a[10]; printf("在主函数里sizeof(a) = %zu\n", sizeof(a)); // 通常是40 badSize(a); return 0; }正确的做法是把长度作为参数传进去。二维数组传参也遵循同样逻辑,void print(int a[][4], int rows)的列数不能省略,因为编译器要用列数计算a[i][j]的实际偏移;行数则可以根据需要作为第二个参数传进来。很多刚开始刷题的同学在编写函数处理数组时发现结果不对,就要先怀疑是不是把数组长度用错了。
4.4 用malloc让数组长度由输入决定
练习到后面,你会发现“固定大小的数组”有时候不够用,比如题目要求读入n个整数,但n可能是10也可能是100000。这时可以动态分配数组,让长度由输入决定:
#include <stdio.h> #include <stdlib.h> int main(void) { int n, *arr; scanf("%d", &n); arr = (int *)malloc(n * sizeof(int)); if (arr == NULL) { return 1; } for (int i = 0; i < n; i++) { arr[i] = i * i; printf("%d ", arr[i]); } printf("\n"); free(arr); return 0; }malloc返回的是void*,在C语言里可以直接赋给任意指针,写成(int *)是为了兼容C++和部分老编译器;最重要的是检查返回值是否为NULL,忘记检查在内存耗尽时会直接对空指针写入,程序当场崩掉。用完后一定记得free,但free之后不要再碰这块内存,这是动态数组最容易踩的坑。
二维动态数组稍微麻烦一点,需要先分配行指针数组,再给每行分配列空间:
int **matrix = malloc(rows * sizeof(int *)); for (int i = 0; i < rows; i++) { matrix[i] = malloc(cols * sizeof(int)); } // 使用... for (int i = 0; i < rows; i++) { free(matrix[i]); } free(matrix);释放顺序和分配顺序相反,先释放每一行,再释放行指针数组,这个顺序写反同样会造成内存错误。
5. 环境配置与排查报错实录
5.1 初学数组最容易遇到的高频错误速查表
先给一张速查表,基本覆盖常用痛点:
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 输出每次结果都不一样 | 数组没初始化,读到栈残留数据 | 定义时写={0} |
| 程序运行到一半崩溃 | 数组下标越界,写坏了未知内存 | 检查for边界是否出现<=n |
| 字符串输出一堆乱码后崩溃 | 字符数组缺少'\0'结束符 | 定义数组时多留一个字节 |
| scanf读完一个数后“卡住” | 输入缓冲区残留换行 | 在格式串前加空格或用getchar清掉 |
| 编译报错“assignment to expression with array type” | 对数组名整体赋值 | 改用strcpy或逐元素复制 |
| 某个变量的值莫名变掉 | 附近数组越界写入 | 打印循环下标,缩小范围定位 |
| printf打印出奇怪字符或负数 | 格式串写错,比如字符串用了%d | 对照printf格式说明逐一检查 |
| OJ上样例一模一样却判错 | 多输出了空格/换行,或数组开小了 | 检查输出格式和数组容量 |
这张表是我带新手时总结出来的,数组题出错基本不会超出这几个范围。排查的时候我习惯先分两类:一类是编译错误,编译器会告诉你在哪一行;另一类是运行结果不对,这类最需要耐心,把输入规模改小、逐步打印中间量,通常很快就能定位。
5.2 我踩过的几个经典坑
第一个坑是scanf("%s", s)读不进带空格的字符串。数组题里的字符串处理经常要求读一整行,但%s遇到空格就会停下来,于是明明输入了“hello world”,程序只读到“hello”。处理办法是换成fgets(s, sizeof(s), stdin),它会连换行符一起读进来,用完记得把末尾的'\n'去掉:if (s[strlen(s) - 1] == '\n') s[strlen(s) - 1] = '\0';。
第二个坑是初始化误判。很多初学者写了int a[3]; a[0] = 0;就以为三个元素都是0,其实只有第一个是0,后面两个还是随机值。正确的想法是:只有定义时的大括号初始化才有“自动补0”的效果,定义之后逐个赋值,每个元素都要显式处理。
第三个坑是越界错误在OJ上不容易复现。本地小数据能跑,提交到OJ上就崩,或者答案变来变去,多半是越界访问或数组开太小。我的建议是数组大小在允许范围里尽量多留一点,比如题目说n不超过100,就定义int a[105]或int a[110],多出的几个位置根本不影响逻辑,却能把很多边界错误挡在外面。
5.3 VS Code跑C数组题的极简配置
既然标题里提到了C语言入门,就顺手把VS Code写C的配置说清楚。我自己在Windows上用的方案是:先装MinGW-w64,安装时可以直接放在D:\mingw64这种非系统目录,一是路径里没有空格和中文,二是不用往C盘塞东西,C盘也不容易爆。装完把D:\mingw64\bin加进系统PATH,然后在命令行敲gcc --version验证。
VS Code里装一个名为“C/C++”的扩展(作者是Microsoft),然后建一个.vscode/tasks.json:
{ "version": "2.0.0", "tasks": [ { "label": "C Build", "type": "cppbuild", "command": "gcc", "args": [ "-g", "-std=c11", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe" ], "group": "build" } ] }再建一个.vscode/launch.json用来调试:
{ "version": "0.2.0", "configurations": [ { "name": "C Debug", "type": "cppdbg", "request": "launch", "program": "${fileDirname}\\${fileBasenameNoExtension}.exe", "miDebuggerPath": "D:/mingw64/bin/gdb.exe", "cwd": "${fileDirname}" } ] }这样写完代码按F5就能编译调试。如果不想折腾,直接用Dev-C++或者Code::Blocks也完全可以,初学阶段没必要在编辑器上花大把时间。VS Code配置最大的好处是调试时能看到数组每个下标对应的值,这一步对理解越界问题帮助很大,值得花十分钟把它配好。
最后再分享一个我自己练数组题的小习惯:每写一道题,先在纸上画出数组和下标的关系,再用printf把循环里每个关键下标临时打出来。这个“笨办法”帮我解决过不少很难查的bug。初学数组,最重要的不是背语法,而是把每个下标盯清楚,下标对了,题目基本就通了一半。练完这些基础题,你再看指针数组、排序查找、动态数组那些内容,会发现它们都只是数组这根主线上长出来的枝叶。