尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言数组题全攻略:常见题型、解题套路与避坑指南

C语言数组题全攻略:常见题型、解题套路与避坑指南 C语言里数组这个知识点说简单呢定义、初始化、遍历翻来覆去就那几招说难呢一上题目就露馅——九九乘法表还能应付遇到字符串逆序就犯嘀咕再碰上鞍点、去重、冒泡排序直接开始怀疑人生。我自己带过不少初学者发现大部分人卡数组题不是语法不会而是不知道题目想让你干嘛。这篇文章专门聊适合C初学者的数组题把常见题型、解题套路、容易踩的坑一次讲清楚。不管你是刚学到数组这一章的新手还是刷题总超时的自学者都可以顺着这篇文章的节奏一块一块把数组题的底子打牢。1. 初学数组题先搞懂出题人到底想考什么1.1 数组题考的不是语法是管理一组数据的能力很多人把数组题当成语法题来做——背定义、背下标规则、背初始化格式结果真到做题的时候照样两眼一抹黑。原因很简单数组题的真正考点根本不在语法层而在如何高效地管理一组同类型数据。给你一个长度为 n 的数组你要能随手做到三件事遍历每个元素、统计满足条件的元素、交换或移动元素的位置。这三件事就是几乎所有数组题的基础动作。用一个生活化的类比来说数组就像一条储物柜走廊。每个柜子有自己的编号下标柜子里面放的必须是同一种物品元素类型。你做数组题本质上就是在这条走廊里干活从1号柜走到 n 号柜观察所有物品把某个柜子里的东西换到另一个柜子或者把符合某种特征的物品挑出来单独处理。语法只是开柜门的动作真正考验你的是在这条走廊里怎么规划路线、怎么不重复也不遗漏地处理每个柜子。所以我的建议是初学者不要在语法细节上过度纠结先练遍历这个动作——一个 for 循环从 0 走到 n-1这是所有数组题的起手式。很多题做不出其实不是后面算法多难而是你连遍历每个元素时我在这个位置上该做什么判断都没想清楚。1.2 五类经典题型一张表看清考点数组题虽然千变万化但站在初学者视角我用一张表把最常见的类型、考点和难度等级列清楚题型核心考点典型题目建议练习顺序遍历与统计循环结构、条件判断求最大值、平均值、统计奇数个数第1批交换与移动下标操作、临时变量冒泡排序、逆序、循环移位第2批字符串处理字符数组、结束符字符串逆序、统计单词、分割提取第2批二维矩阵处理双重循环、行列关系矩阵转置、鞍点、对角线求和第3批筛选与去重标记思想、嵌套循环数组去重、删掉指定元素第3批这张表想传达一个信息数组题是有练习梯度的。第一梯队先练单层循环就能搞定的统计题建立遍历每个元素的肌肉记忆第二梯队练涉及元素交换的题体会临时变量、双指针这类工具第三梯队再上二维数组和去重这时候你已经有了循环嵌套和状态标记的概念不会被复杂度吓住。还有一类题值得单独拎出来说用数组模拟数据结构比如循环队列的入队出队、树状数组的前缀和查询。这些概念虽然超出了初学者的舒适区但如果你已经能把数组玩熟再去看它们会发现底层还是遍历 下标计算那套东西。我的看法是初学者不用急着碰这类题先把基础五类练扎实数据结构专题自然水到渠成。2. 一维数组入门题的三个必练套路2.1 数组初始化的完整姿势与细节做题第一步就是把数组造出来。C语言里数组初始化有几种常见写法看起来差不多实际行为差异很大初学者经常在这上面吃暗亏。我整理一份对照int a[10]; // 不初始化里面是垃圾值 int b[10] {0}; // 全部元素赋为0 int c[10] {1,2,3}; // 前3个赋指定值其余自动补0 int d[] {1,2,3}; // 不写长度编译器数出3个元素 int e[10] {[2] 5}; // 指定下标初始化C99支持GCC可用这里最需要注意的是第一行局部数组不初始化里面的值是不可预测的垃圾值。很多初学者统计题做错了根源就是声明数组后没清零就开始累加。比如int cnt[10]; for (int i 0; i 10; i) { cnt[i]; // 错的cnt[i]初始值不确定 }正确做法是先int cnt[10] {0};对所有元素清零再开始统计。这个细节我见过太多次了几乎每个初学数组的人都要踩一遍。从C99开始还能用变长数组写法就是int n; scanf(%d, n); int a[n];。这个特性在一些教材里不推荐因为数组长度需要运行时才确定在栈上分配可能出问题。我的建议是初学者尽量先固定长度练习阶段用足够大的固定数组即可等后面学了 malloc 再处理动态长度。2.2 遍历统计与排序从九九乘法表到冒泡排序先看一个最容易上手的组合九九乘法表。严格来说它本身不是数组题但很多教材把它放在数组章节前后当练习因为它的双重循环结构和二维数组的遍历逻辑一模一样。基础的打印版我不用多说这里给一个进阶版本——把结果存到二维数组里再打印#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(%2d , table[i][j]); } printf(\n); } return 0; }这个练习的好处是让你同时体会两件事第一嵌套循环的行列控制——内层循环的终止条件是j i这就引入了三角形矩阵的概念第二先写数据再读数据——存的过程和输出的过程是分离的这跟后面很多二维数组题的思路完全一致。再看冒泡排序这大概是数组题里最经典的交换类题目。初学时别急着背代码先把过程在纸上走一遍从第一个元素开始依次比较相邻的两个元素如果前一个比后一个大就交换这样一趟下来最大的数就像气泡一样浮到了最后。下一趟再从头开始比较但最后一组不用再比因为它已经排好了。外层循环控制需要走几趟内层循环控制每趟比较到哪个位置#include stdio.h void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped 1; } } if (!swapped) break; // 这趟没发生交换说明已经有序 } } int main() { int a[] {3, 1, 4, 1, 5, 9, 2, 6}; int n sizeof(a) / sizeof(a[0]); bubble_sort(a, n); for (int i 0; i n; i) printf(%d , a[i]); return 0; }代码里那个swapped标志是我特别建议初学者加上的优化。当年我第一次写冒泡排序老老实实跑完所有趟数后来才发现如果某一趟完全没有发生交换说明数组已经有序可以直接 break。虽然对初学者这不是考核点但养成这种观察状态变化的习惯对后续学习二分查找、快速排序都很有帮助。2.3 字符串逆序字符数组的隐藏考点字符串在C语言里不是独立类型本质就是字符数组末尾用结束符\0结尾。很多初学者在整数数组里挺明白一碰到字符串就晕其实规则完全一样只是多了一个结束符需要留意。字符串逆序有两种思路。第一种是最直观的双指针一个指针指向开头一个指向末尾两个位置交换然后相向移动直到相遇或交错#include stdio.h #include string.h void reverse(char s[]) { int left 0; int right strlen(s) - 1; // 最后一个有效字符不包括\0 while (left right) { char temp s[left]; s[left] s[right]; s[right] temp; left; right--; } } int main() { char s[] hello; reverse(s); printf(%s\n, s); // 输出 olleh return 0; }这里有个关键点right的初始值只能是strlen(s) - 1不能直接数数组长度因为数组长度包含\0。如果把\0当成字符参与交换字符串就废了——结尾的结束符完全没有printf 会一路输出到内存的某个\0为止行为不可预测。我自己就见过学生把字符串逆序写完后输出结果后面跟着一串乱码就是这个原因。第二种思路是借助另一个数组逆序存放把源数组从后往前读依次写入新数组。这种思路适合要求不修改原字符串、而是生成新字符串的题目。做题时还会遇到数组分割并显示包含某一字符这类需求本质也是先定位字符串中特定字符的位置再决定从哪里开始复制——核心仍然是通过下标访问字符数组的每个位置的元素。提示写字符串相关数组题时养成两个习惯——第一遍历循环用s[i] ! \0作为终止条件第二凡是自己手动构造字符串一定要记得在末尾补\0。3. 二维数组与矩阵题从线性思维升级到网格思维3.1 二维数组的本质一维数组的一维数组初学者第一次接触二维数组最容易产生的误解是它是一个平面网格。从逻辑上这么理解没问题但在C语言的内存模型里二维数组的本质是一维数组的数组——也就是说一个int a[3][4]其实就是长度为3的一维数组只不过每个元素又是一个长度为4的 int 数组。内存里它按行优先连续排列第0行的4个元素排完接着排第1行的4个元素。这个本质决定了访问方式。a[i][j]是先定位到第 i 行再偏移到该行的第 j 列。行优先的布局意味着a[i][j]和a[i*4 j]在某种意义上等效这在刷题和调试时非常有用——当你把一个二维数组看成一维数组来处理时很多矩阵题就能套用一维数组的经验。二维数组的初始化也有讲究int a[3][4] {0}; // 所有元素清零 int b[3][4] {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; // 按行赋值 int c[3][4] {1,2,3,4,5,6,7,8,9,10,11,12}; // 也能按内存顺序铺开 int d[][4] {1,2,3,4,5,6,7,8}; // 列数必须写行数可省第三行那种写法虽然合法但我劝初学者少用因为可读性太差出错了也不好定位。第四行的规则倒是值得记一下二维数组定义时只有第一维可以省略第二维必须写。原因很简单编译器需要知道每一行有多长才能计算出d[1][0]的确切地址。注意C语言里没有真正的多维数组语法糖之外的东西任何多维数组最终都是内存里的连续一段空间。调试时如果某个元素不符合预期可以试着打印它的地址用是否存在连续性来判断是不是下标算错了。3.2 鞍点问题的完整拆解先画矩阵再写代码鞍点问题是二维数组题里非常经典的一道也是很多初学者第一次真正被二维数组折磨的题。题目常见描述是在一个 m×n 矩阵中如果一个元素既是其所在行的最大值又是其所在列的最小值就称它为鞍点。要求找出所有鞍点或者判断不存在。第一次见到这题我的建议是别急着写代码先在纸上画一个 3×3 或 4×4 的矩阵用笔把每行的最大值圈出来再把每列的最小值方框标出来看看有没有元素同时符合两个条件。这步做完你自然会发现解题套路第一步遍历每一行找到该行的最大值记住它的列坐标第二步判断这个最大值在它那一列里是不是最小值。很多初学者卡住是因为试图一次循环同时做两件事——既要找行最大又要判断列最小结果把自己绕晕了。正确的做法是先分步每步一个循环逻辑清晰之后再考虑合并。我给一个标准解法#include stdio.h #define ROWS 3 #define COLS 4 int main() { int a[ROWS][COLS] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int found 0; for (int i 0; i ROWS; i) { int max a[i][0]; int col 0; for (int j 1; j COLS; j) { if (a[i][j] max) { max a[i][j]; col j; } } // 此时已经找到第 i 行的最大值和列位置 int is_min 1; for (int r 0; r ROWS; r) { if (a[r][col] max) { is_min 0; break; } } if (is_min) { printf(鞍点: a[%d][%d] %d\n, i, col, max); found 1; } } if (!found) printf(不存在鞍点\n); return 0; }这个解法的时间复杂度是 O(ROWS × COLS)因为每行都要扫一遍全列。对初学者来说先不要管优化那一步是学完高级数据结构之后才能谈的。值得注意的细节有三处第一行最大值的初始值直接用a[i][0]不要用 0因为矩阵可能全是负数第二找列最小值时是从r 0到r ROWS不是从i1开始——很多人会在这里写错只检查了下半部分列第三found标志位用来记录是否找到最后统一输出不存在这种处理方式在题目要求里很常见。做完这题之后我还建议学生做一个变式找每列的最小值、再验证是否行最大。你会发现代码结构几乎完全一样只是把行和列的循环对调。这种换个视角再看同一个问题的练习对二维数组的理解帮助非常大。4. 数组去重与动态扩容走出定长舒适区4.1 去重题的三层递进思路数组去重是筛选类题目里最经典的之一题目一般这样描述给定一个可能包含重复元素的整数数组要求去除重复返回不重复的元素。初学阶段我推荐按三层递进的方式来练。第一层暴力去重。从前往后扫描对每个元素看它之前是否出现过没出现过就保留出现过就跳过。实现方式是用一个辅助数组每遇到一个新元素就在里面检查一遍。时间复杂度 O(n²)但思路最直接适合刚接触标记思想的初学者。第二层排序 一次遍历。先用冒泡排序把数组排好然后利用重复元素必定相邻的特点一次遍历就能完成去重。这个方案会把顺序改变如果题目要求保持原有顺序就不能用它但它能训练你排序之后问题往往变简单的直觉。第三层用标志数组记录出现过的值。如果数组元素范围不大比如是 0 到 99 的整数可以开一个flag[100]遇到元素 v 就把flag[v]置为1下次再遇到 v 就跳过。这个技巧的空间换时间思路是后面学哈希表的铺垫。我给出第二层的参考代码因为它既不过分暴力又能让初学者练到排序#include stdio.h void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped 1; } } if (!swapped) break; } } int main() { int a[] {3, 1, 2, 3, 4, 2, 5, 1}; int n sizeof(a) / sizeof(a[0]); bubble_sort(a, n); int k 0; for (int i 0; i n; i) { if (i 0 || a[i] ! a[k - 1]) { a[k] a[i]; // 复用原数组把不重复元素搬到前面 } } for (int i 0; i k; i) printf(%d , a[i]); return 0; }这里有个很巧妙的细节直接在原数组上用 k 记录有效位置后面的元素直接覆盖到前面。这种就地整理的思路在后序很多算法题里都会用到比如移除指定值、压缩数组本质上都是双指针一个读一个写的模式。初学者如果能理解这一点就会明白为什么排序 去重能写得这么简短。4.2 动态数组realloc 的正确打开方式初学者用数组时经常被定长限制住——数组多大就得提前定好。刷题时有的题不会告诉你精确的数据规模只说有个不超过1000的输入这时开int a[1000]没问题但万一规模到10万呢栈空间就不够了。这时候就要引入动态内存分配。C语言的malloc和realloc就是用来干这个的。下面是一个简单示例读入若干整数数量未知动态扩展数组#include stdio.h #include stdlib.h int main() { int capacity 4; int count 0; int *arr (int *)malloc(capacity * sizeof(int)); if (arr NULL) { printf(内存分配失败\n); return 1; } int x; while (scanf(%d, x) 1) { if (count capacity) { capacity * 2; int *new_arr (int *)realloc(arr, capacity * sizeof(int)); if (new_arr NULL) { free(arr); printf(扩展失败\n); return 1; } arr new_arr; } arr[count] x; } for (int i 0; i count; i) printf(%d , arr[i]); printf(\n); free(arr); return 0; }这段代码有几个关键点用capacity记录分配容量用count记录实际元素数量两者要分清。很多初学者只有一个变量 n结果扩容时不知道容量到底多大。每次扩容翻倍而不是每次只多一个 int这是为了减少 realloc 的次数。realloc 可能涉及内存拷贝频繁调用是很浪费的。realloc失败时返回 NULL如果直接arr realloc(arr, ...)会把原来的指针覆盖掉导致旧内存也没法释放。正确写法是先用临时变量new_arr接收成功后再赋值给arr。提示动态分配的内存必须用free释放而且释放的必须是 malloc/realloc 返回的原始地址。在写代码时我习惯在分配之后就立刻写上 free避免后期忘掉造成内存泄漏。5. 初学者最容易踩的数组坑与排查方法5.1 越界访问数组下标从0开始代价不小数组下标从0开始这个事几乎每个初学者都要栽一次跟头。声明int a[5]能访问的合法下标是0、1、2、3、4一共5个元素但a[5]是越界的。问题在于C语言不检查越界写a[5] 10不会报编译错误甚至运行时不一定会立刻崩溃它只是悄悄修改了数组后面那一段内存里的数据——可能是另一个变量的值也可能是某个指针的一部分。这种问题特别隐蔽排查起来非常费劲。我自己遇到过的最典型案例是循环条件写成for (int i 0; i n; i)等于多访问了一个元素。尤其是当数组后面恰好跟着另一个变量时这个越界写会把那个变量的值悄悄改掉程序表现就变得莫名其妙。排查方法也很简单把循环边界打印出来逐个数一遍下标的取值范围。5.2 未初始化数组统计类题目的隐形杀手前面提到过局部数组不初始化就是垃圾值。这个坑在统计题里尤其致命。比如统计字符出现次数、统计分数段人数这类题目如果上来直接cnt[score]而cnt从未清零结果就是一堆毫无意义的随机数。解决办法是声明时就写成int cnt[100] {0};让编译器把所有元素初始化成0。还有一个相关的坑用赋值语句int a[5] {1,2,3};时后两个确实会自动补0但如果数组是全局变量它才默认清零局部数组则没有这个待遇。初学者最好统一记住一条规则想要0就显式写出来不要指望环境默认。5.3 数组当参数传递sizeof 的陷阱这是C语言新手最容易困惑的点。你写了一个函数void print_array(int arr[]) { printf(%d\n, sizeof(arr)); // 打印的是指针大小不是数组大小 }看起来像是把数组传进来了但实际传进来的只是指针sizeof(arr)在64位系统上通常是8而不是整个数组的字节数。道理前面说过数组作为函数参数时会退化成指针。所以函数内部如果想遍历必须同时把数组长度传进来void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } }这个数组参数退化为指针的规则直接决定了你能否在函数内用sizeof获取元素个数。我在2.2节代码里特意用sizeof(a) / sizeof(a[0])计算长度那是合法的因为发生在 main 函数里、a还是真正的数组一旦放进函数就换了一套规则。很多初学者把主函数里计算长度的代码拷贝到函数里结果 n 变成8或4循环边界整个错乱就是这个原因。提示遇到函数内数组长度不对的问题十有八九是 sizeof 陷阱。排查时先在函数入口打印 sizeof(arr)确认它到底是数组大小还是指针大小基本就能定位问题。说实话数组题做到后面你会慢慢发现它没有想象中那么可怕。很多看起来五花八门的题目底层都是那几个基础动作的组合遍历每个元素、比较相邻元素、用临时变量交换、用标志位记录状态。我自己的经验是每做一道新题先在纸上把我想要对数组做什么操作用普通话说清楚再翻译成代码比直接上手敲要快得多也少踩一半的坑。最后给大家一个实操建议把本文提到的几个基础代码——冒泡排序、字符串逆序、鞍点、去重——每个都在编译器里亲手敲三遍第一遍照着理解第二遍合上文章独立写第三遍计时写。三遍之后你会发现自己对数组下标的掌控感明显不一样了。等这层基础打牢再去看指针数组、字符串指针、循环队列那些进阶概念会发现它们不过是数组这棵树上长出来的枝叶罢了。
返回列表