数组在C++里被很多人当成最简单的章节,可真到了做《C++程序设计》第五章习题的时候,才发现处处是坑:下标习惯性从0还是1开始犯迷糊、数组越界编译器一声不吭、二维数组传参传着传着就“退化”成了指针、字符数组一遇到空格就罢工。我最近把这章和数组有关的习题重新过了一遍,把每个题从教材常见写法到改进后的代码都梳理了一遍,包括筛法求素数、选择法排序、矩阵对角线求和、有序数组插入、逆序输出、杨辉三角、单词计数这些经典问题。这篇东西不是简单抄一遍答案,而是把每道题的原始思路、改进逻辑、边界条件、容易踩的坑都摊开讲清楚,适合正在学数组的同学、准备期末复习的人,以及想看看自己写的代码还能怎么优化的人参考。
1. 第五章数组习题的整体思路与改进说明
1.1 这批习题到底想练什么
数组这一章在C++教材里的位置很有意思:前面刚讲完循环、分支和函数,后面紧接着就是指针和结构体,数组刚好卡在中间,承担的是“批量数据存储和处理”这个核心概念。第五章的习题表面上是在考语法,实际上练的是三件事:
- 用下标访问连续内存中的数据
- 用循环结构与数组下标配合完成批量操作
- 在边界条件下保证程序行为正确
筛法求素数练的是“用数组标记状态”,选择法排序练的是“数组元素的交换与遍历”,矩阵对角线求和练的是“二维数组的下标运算”,有序数组插入练的是“元素移动与位置查找”,逆序输出练的是“对称下标的计算”,杨辉三角练的是“递推关系的数组表达”,单词计数练的是“字符数组与状态变化”。这几道题覆盖了数组的绝大多数典型操作,每道题都有自己的“考点陷阱”,做一遍下来基本能把数组的各种坑都踩个遍。
1.2 我做“改进”时的三个原则
我对这些习题做改进,不是为了炫技或者把代码写得奇奇怪怪,心里其实有几个约束:
第一,保留教学价值。改进后的代码不能跳步太多,不能使用超出本章范围的语法。比如有的题可以用STL容器写得很短,但那就失去了练数组的意义,所以我的改进版仍然全部使用原生数组。
第二,修正逻辑上的低效和隐患。教材里的部分写法存在效率问题,比如筛法求素数用双重循环逐个取模,选择法排序每发现一个逆序就交换一次。这些写法能跑通,但不够好,我会指出问题所在并给出更合理的方案。
第三,注释清楚,方便复制后自己修改。我在代码里加了比较详细的注释,特别是边界条件和容易犯错的位置。因为做习题的人往往不是只看懂就行,还会照着敲一遍、改成自己的版本,注释写清楚能省很多事。
2. 一维数组经典题:筛法素数与选择排序
2.1 筛选法求100以内素数:从逐个取余到真正的筛法
这道题在教材里的常见做法是:定义一个长度为101的整型数组,下标从1到100分别存1到100,然后把1置为0,再用双重循环逐个判断后面的数能否被前面的数整除,能整除就置为0,最后输出所有不为0的数。
逻辑上没错,但有个明显的问题:内层循环每次都要做取余运算,外层循环要到100,计算量是平方级别的。而且用整型数组存“这个数还在不在”这个状态,有点浪费——存状态用bool数组就够了。
我的改进版把思路从“逐个取余判断”转成真正的“筛法”:从2开始,把每个素数的倍数全部标记为合数,剩下的就是素数。
#include <iostream> using namespace std; int main() { const int N = 100; bool isPrime[N + 1]; // 下标0和1不用,所以长度是N+1 // 初始化:2到N全部先假定为素数 for (int i = 2; i <= N; i++) { isPrime[i] = true; } isPrime[0] = isPrime[1] = false; // 0和1不是素数 // 筛法核心:从2开始,把每个素数的倍数全部标记为false // 改进点1:外层循环只需要到 i*i <= N,因为大于sqrt(N)的合数 // 必然有一个小于等于sqrt(N)的因子,早就被筛掉了 for (int i = 2; i * i <= N; i++) { if (isPrime[i]) { // 改进点2:内层循环从 i*i 开始,而不是从 2*i 开始 // 因为 i*2, i*3, ..., i*(i-1) 这些数已经被更小的素数筛过了 // 这里用 += i 代替取模运算,效率高很多 for (int j = i * i; j <= N; j += i) { isPrime[j] = false; } } } // 输出结果,每10个数换一行 int count = 0; for (int i = 2; i <= N; i++) { if (isPrime[i]) { cout << i << "\t"; count++; if (count % 10 == 0) { cout << endl; } } } cout << endl; return 0; }两个改进点值得展开说一下。
外层循环为什么只到i * i <= N就行?因为一个合数 n 如果大于 sqrt(N),它必然能写成 n = a × b 的形式,其中 a 和 b 里至少有一个小于等于 sqrt(N)。也就是说,这个合数在 a 作为素因子被筛的时候就会被标记掉,根本轮不到它自己。所以外层循环到10就够了,不用真的跑到100。
内层循环为什么从i * i开始?拿 i = 5 举例:5 × 2 = 10 会被2筛掉,5 × 3 = 15 会被3筛掉,5 × 4 = 20 会被2筛掉,这些倍数在早先的循环里已经被置为false了,没必要再筛一遍。直接从25开始才是5的“新鲜倍数”。
这个题还有一个容易踩的细节:数组长度要定义成 N+1,因为下标从0开始,要访问a[100]的话数组长度至少101。很多同学写成int a[100],然后循环到100,最后就越界了,程序崩溃还找不到原因。
2.2 选择法排序:别把“选择”写成“冒泡”
教材里“选择法对10个整数排序”这道题,很多参考答案写的其实是交换排序的变体:外层循环 i 从0到8,内层循环 j 从 i+1 到9,只要发现 a[i] > a[j] 就交换。这个写法能排序,但它不是严格意义上的“选择排序”。
严格的选择排序应该是:每一轮从待排序区间里找到最小元素的下标,等这一轮比较全部结束后,再把最小元素和区间第一个元素交换一次。这样做的好处是每轮最多交换一次,而不是可能交换很多次。
#include <iostream> using namespace std; // 封装成函数,方便复用 void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int minIndex = i; // 先假设当前位置就是最小值的位置 for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; // 记录真正的最小值下标 } } // 如果最小值不在当前位置,才需要交换 if (minIndex != i) { int temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } } } int main() { const int N = 10; int a[N]; cout << "请输入" << N << "个整数:" << endl; for (int i = 0; i < N; i++) { cin >> a[i]; } selectionSort(a, N); cout << "排序后的结果为:" << endl; for (int i = 0; i < N; i++) { cout << a[i] << " "; } cout << endl; return 0; }为什么改进成这种写法?因为“每发现逆序就交换”在数据量稍大时会产生大量不必要的交换操作。比如数组接近有序时,交换排序法依然会频繁交换;而选择排序每轮最多交换一次,总共最多 n-1 次交换,逻辑上也更好理解:每一轮的目标非常明确——找最小,放前面。
这里有个容易被忽略的点:if (minIndex != i)这个判断。如果当前元素已经是最小值,就没必要和自己交换。加上这个判断少几次无意义的赋值操作,虽然对10个数来说无所谓,但养成习惯后在更大的数据量上会有收益。
3. 二维数组与插入逆序:数组操作的边界控制
3.1 3×3矩阵对角线求和:只要一个if就能避开重复计算
这道题要求输入一个3×3矩阵,求两条对角线上的元素之和。教材常见写法是:
sum = 0; for (int i = 0; i < 3; i++) { sum += a[i][i]; // 主对角线 sum += a[i][2 - i]; // 副对角线 } sum -= a[1][1]; // 中心元素被加了两次,要减掉一次乍一看没问题,但有一个非常经典的坑:矩阵中心那个元素同时属于主对角线和副对角线,在上面这个写法里会被加两次,所以最后要手动减一次。可是如果矩阵不是3×3而是 N×N,中心元素是否存在、是否重复,都要重新判断,手动减的做法就不通用了。
我的改进版用双重循环加一个条件判断,让中心元素只被计入一次:
#include <iostream> using namespace std; int main() { const int N = 3; int a[N][N]; cout << "请输入" << N * N << "个矩阵元素(按行输入):" << endl; for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { cin >> a[i][j]; } } int sum = 0; for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { // 主对角线满足 i == j // 副对角线满足 i + j == N - 1 // 两者都满足时,说明是中心元素,只加一次 if (i == j || i + j == N - 1) { sum += a[i][j]; } } } cout << "两条对角线元素之和为:" << sum << endl; return 0; }这个改进的价值在于:它把“手动减去重复元素”变成了“从条件上避免重复”,代码对任意 N 都成立。当你把const int N = 3改成5、7的时候,逻辑依然正确。如果坚持用教材的写法,矩阵改大之后很容易忘记调整减去的那个元素。
写这道题时还要注意输入的顺序。我习惯加上“按行输入”的提示,因为有的同学会一行一行地输入,有的同学会一列一列地输入,虽然从键盘上看都是9个数字,但顺序错了结果就不对。遇到这种需要人工输入的题目,提示清楚能省很多沟通成本。
3.2 有序数组插入:从后往前边找边移
这道题的条件是:有一个已经按照升序排列好的数组,比如{1, 3, 5, 7, 9, 11, 13, 15, 17, 19},现在输入一个数,要求插入到这个数组里,并保持升序。数组长度要能容纳插入后的全部元素,所以原数组有10个元素,插入后变成11个,定义时就要给11个位置。
很多教材答案的做法是:先从头遍历找到插入位置,然后把插入位置之后的所有元素统一往后移动一位,最后把新数放进去。这个思路对,但写起来容易出错,因为要同时管理“找到的位置”和“移动的边界”。
我的改进做法是把“找位置”和“移动元素”合并成一步:从数组末尾开始往前遍历,遇到比新数大的元素就往后移动一位,直到找到合适位置为止。
#include <iostream> using namespace std; int main() { int a[11] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19}; int x; cout << "请输入要插入的数:"; cin >> x; int pos = 10; // 数组当前最后一个元素的下一个位置 // 从后往前比较:只要前一个元素比x大,就把它往后移 // pos > 0 是为了防止pos减到-1导致越界 while (pos > 0 && x < a[pos - 1]) { a[pos] = a[pos - 1]; // 把比x大的元素往后挪 pos--; // 插入位置候选前移 } a[pos] = x; // 此时pos就是x应该待的位置 cout << "插入后的数组为:" << endl; for (int i = 0; i < 11; i++) { cout << a[i] << " "; } cout << endl; return 0; }这个写法妙在哪?它利用了“从后往前移动不会覆盖还没处理的数据”这一特性。假设要插入的是6,数组最后一位是19,19比6大,就把19从a[9]移到a[10];接着比较17,17比6大,移到a[9];这样一路移下去,直到遇到5比6小,循环停止,此时pos指向5后面的那个空位,把6放进去即可。
这个题有两个边界条件必须测试:插入的数比所有元素都大时,比如20,循环条件x < a[pos - 1]一次都不成立,pos保持10,20放在最后,正确;插入的数比所有元素都小时,比如0,循环会一直执行到pos变成0,0放在最前面,所有元素都向右移了一位,也正确。
3.3 数组逆序输出:交换到一半就要停
这个题要求把一个数组的元素顺序颠倒过来,比如{1, 2, 3, 4, 5}变成{5, 4, 3, 2, 1}。教材里经常出现的解法是定义一个新数组,倒着遍历原数组,把元素存进去再输出。这样做没错,但浪费了一倍的内存空间。
更标准的做法是原地交换:把第一个元素和最后一个元素交换,第二个和倒数第二个交换,以此类推。
#include <iostream> using namespace std; int main() { const int N = 10; int a[N]; cout << "请输入" << N << "个整数:" << endl; for (int i = 0; i < N; i++) { cin >> a[i]; } // 只需要交换前 N/2 个元素 // 比如N=10,交换a[0]和a[9]、a[1]和a[8]、a[2]和a[7]、 // a[3]和a[6]、a[4]和a[5],交换5次就完成 for (int i = 0; i < N / 2; i++) { int temp = a[i]; a[i] = a[N - 1 - i]; a[N - 1 - i] = temp; } cout << "逆序后的数组为:" << endl; for (int i = 0; i < N; i++) { cout << a[i] << " "; } cout << endl; return 0; }这里最容易犯的错是把循环条件写成i < N,那样的话,交换到一半之后又开始反向交换,最终数组又变回原样了。我第一次写这道题时就犯过这个错,调了半天才发现是循环范围搞错了。记住一个原则:交换对称元素只需要用到数组长度的一半,也就是N / 2。如果数组长度是奇数,中间那个元素不需要动。
另外要注意交换顺序:必须先把a[i]存到临时变量里,再给a[i]赋新值。很多刚写代码的同学直接a[i] = a[N - 1 - i]; a[N - 1 - i] = a[i];,这其实是把两个位置都赋成了后面的值,前一个值就丢了。临时变量是交换操作的保底操作,不能省。
3.4 杨辉三角:递推公式的两个边界写法
杨辉三角是一个二维数组的典型应用题。要求输出前10行,每行的第一个和最后一个元素都是1,中间元素是上一行对应位置两个数之和。
教材标准写法是用二维数组存整个三角形:
#include <iostream> #include <iomanip> using namespace std; int main() { const int N = 10; int a[N][N] = {0}; for (int i = 0; i < N; i++) { a[i][0] = 1; // 每行第一个元素是1 a[i][i] = 1; // 每行最后一个元素是1(对角线) for (int j = 1; j < i; j++) { a[i][j] = a[i - 1][j - 1] + a[i - 1][j]; } } // 输出:上方加空格让它形成三角形 for (int i = 0; i < N; i++) { for (int k = 0; k < N - i; k++) { cout << " "; } for (int j = 0; j <= i; j++) { cout << setw(4) << a[i][j]; } cout << endl; } return 0; }递推公式a[i][j] = a[i - 1][j - 1] + a[i - 1][j]只在1 <= j < i的范围内成立,这也是为什么每行的首尾要单独赋值为1。如果试图用统一的公式计算所有元素,就会访问到上一行的0号元素本身,逻辑会乱。
这里我额外想分享一个进阶的改进:用一维数组从头到尾只保存一行,从后往前更新,这样空间复杂度从O(n²)降到O(n)。原理是:如果从前往后更新,a[j]的新值会覆盖旧值,导致计算a[j+1]时读到的已经不是上一行的j位置数据;但反过来从后往前更新,a[j]依赖的a[j-1]还是旧值,没有破坏递推关系。
#include <iostream> #include <iomanip> using namespace std; int main() { const int N = 10; int a[N] = {0}; a[0] = 1; for (int i = 0; i < N; i++) { // 从后往前更新,保证每个位置用的还是上一轮的值 for (int j = i; j > 0; j--) { a[j] += a[j - 1]; } // 输出当前行 for (int k = 0; k < N - i; k++) { cout << " "; } for (int j = 0; j <= i; j++) { cout << setw(4) << a[j]; } cout << endl; } return 0; }这个一维版本值得好好体会,它是对“覆盖顺序会影响结果”最直观的展示。数组操作里有很多优化都依赖类似的思考:是先读再写,还是先写再读,从哪个方向遍历,都会影响结果的正确性。能自己想通这个例子,对数组的理解会上一个台阶。
4. 字符数组与字符串处理:单词计数题里的隐藏细节
4.1 用状态变量而不是“看前一个字符”
字符串处理的题在第五章里也经常出现,比如“输入一行字符,统计其中有多少个单词”。这里的“单词”通常定义为一个连续的、不含空格的字符序列。
教材版本里常见写法是:遍历字符串,判断每个字符是不是空格,如果当前字符不是空格且前一个字符是空格,就认为一个单词开始了。这个写法用str[i - 1]判断前一个字符,逻辑上可行,但有一个边界问题:字符串第一个字符如果不是空格,str[i - 1]其实访问的是下标-1,属于越界访问,需要特殊处理,很多教材答案里会忽略这一点。
我推荐用一个状态变量inWord来标记“当前是否处于一个单词内部”,这样可以避免看前一个字符带来的边界问题:
#include <iostream> using namespace std; int main() { char str[100]; cout << "请输入一行字符:" << endl; cin.getline(str, 100); // 注意:不是 cin >> str int count = 0; bool inWord = false; for (int i = 0; str[i] != '\0'; i++) { if (str[i] != ' ') { if (!inWord) { // 从空格状态进入单词状态,说明新单词开始了 count++; inWord = true; } } else { // 遇到空格,退出单词状态 inWord = false; } } cout << "单词个数为:" << count << endl; return 0; }这个写法的好处是:不管字符串开头有没有空格、中间有多少个连续空格、结尾有没有空格,统计结果都正确。状态变量本质上是把“前一个字符是什么”这个信息保存下来,避免了索引前一个元素的越界风险,理解起来也更直观:遇到非空格字符时,如果之前不在单词里,就计数加1;如果之前在单词里,就还在同一个单词里继续走。
连续空格是最容易出bug的场景。比如输入"hello world",中间有两个空格,用“前一个字符是空格”判断的写法,遇到第二个空格时还要再看前一个,很容易漏记或重记。状态变量写法天然免疫这个问题。
4.2 cin.getline与cin >>的差别决定成败
这道题里还有一个很多人踩过的坑:用cin >> str读字符串。cin >>遇到空格会停止读取,如果输入的是"hello world",它只把"hello"读进数组,"world"留在缓冲区,统计结果直接变成1,程序表现完全错误。
正确做法是用cin.getline(str, 100),它的作用是读取一整行,包括空格,直到遇到换行符为止。第二个参数100表示最多读取99个字符,留一个位置给字符串结束符'\0'。这也是字符数组题目的经典考点:C风格字符串必须以'\0'结尾,cin.getline会自动补上,这是它比cin >>更适合处理含空格输入的原因。
另外还要注意,cin.getline和cin >>混用时可能出现问题:如果之前用cin >>读过一个整数,缓冲区里会残留换行符,紧接着调cin.getline可能读到一个空行。解决办法是在cin >>之后加一句cin.get();把换行符吃掉,或者用cin.ignore()清空缓冲区。这个细节在写综合实验、多个输入项混合时特别容易出现,我记不清被它坑了多少次。
5. 数组习题实战中常见的坑与排查思路
5.1 数组越界:编译器不报错才最危险
数组越界是新手写数组程序遇到最多的问题,而且它最讨厌的地方在于:编译器多半不报错,程序运行时有时正常、有时崩溃,或者数据莫名其妙被改。原因在于,C++的数组越界访问的是“数组相邻内存地址上的未知数据”,后果无法预知。
典型的出界场景有几个:
- 定义了
int a[10],循环却写for (int i = 0; i <= 10; i++),最后访问了a[10],越界 - 二维数组
int a[3][3],却用a[i][3]访问“第四列”,越界 - 字符数组
char s[10],往里存入超过9个字符的内容,导致'\0'写到数组外面
排查越界问题时,我的经验是先看循环边界是不是用了<=,再看数组下标计算有没有-1这种偏移。还有一个土办法:在循环体里临时打印一下i和数组长度,或者故意把数组定义大一点观察行为变化。如果加大数组长度后问题消失了,那大概率是越界。
5.2 初始化不等于清零
局部数组和全局数组的初始行为差别很大。全局数组或静态数组会被自动初始化为0,但函数里定义的局部数组不会。也就是说:
int a[10]; // 在main里定义:内容是未知的、不确定的 int a[10] = {0}; // 显式清零:全部元素都是0 int a[10] = {1}; // 注意:只有a[0]是1,其余是0第三个例子特别容易误解。{1}只指定了第一个元素的值,剩下的元素被默认初始化为0。很多人以为“给第一个元素赋值,后面的就自动跟着一样”,这是不对的。想让数组所有元素都是同一个非零值,要么用循环逐个赋值,要么用后续讲的 fill 类工具。
在上一章杨辉三角的二维版本里我写了int a[N][N] = {0},就是要确保所有元素初始为0,因为递推计算中依赖的某些位置如果没有被显式赋值,读取到垃圾值,结果就是乱码一样的大数。
5.3 函数传参会“退化”的数组
第五章习题虽然很多还不需要自己写函数,但排序题里我封装了selectionSort(int arr[], int n),这里就涉及到数组传参的一个重要特性:数组作为函数参数传递时,并不是把整个数组复制一份,而是传递了首元素的地址,也就是数组“退化”成了指针。
所以下面两种写法完全等价:
void f(int arr[], int n); void f(int *arr, int n);这个特性带来的直接后果是:在函数里对arr做修改,会直接影响调用者那边的原数组,不需要返回值。看起来“方便”,但也意味着如果你希望函数内部不修改原数据,必须格外小心。
另一个后果是:sizeof(arr)在函数里得到的是指针的大小,而不是数组的大小。很多初学者在函数内部写sizeof(arr) / sizeof(arr[0])想计算元素个数,得到的却是错误的数字。正确做法是把数组长度作为单独参数传进去,这也是我写selectionSort时要带n参数的原因。
5.4 几个实用的调试习惯
做数组题目时,我总结了一套调试习惯,可能听着朴素,但确实能救命:
第一,先在纸上大概画出数组下标和值的关系,特别是二维数组。程序运行前手动模拟一遍循环,能提前发现大部分下标错误。
第二,每写一段关键代码,就输出中间结果验证,而不是等整个程序写完再跑。比如排序题,可以先输出排序前的数组,再输出排序后的,看差异是否合理。
第三,用固定的、简单的测试数据。不要一上来输一堆数字,那样数据错了也不好定位。数组题常用的小测试集:全正数、全负数、0、最大值最小值放在首尾、重复元素。这些边界数据能把代码里隐藏的问题逼出来。
第四,遇到程序崩溃,优先怀疑越界和未初始化变量。在代码里临时插入cout打印下标和值,定位是第一遍循环还是后面出错,比盯着屏幕发呆强很多。
写在最后:数组题的真正价值
做完整章数组习题,我最深的体会是:数组本身只是语法,但它逼你去思考“下标”、“边界”、“内存布局”这些比语法更根本的东西。筛法里的“从 i*i 开始”是效率意识,插入里的“从后往前移动”是数据覆盖顺序,杨辉三角的一维优化是空间意识,单词计数的状态变量是逻辑抽象。这些能力在后续学指针、链表、动态内存时全都用得上。
如果你正在做这章习题,我的建议是不要满足于“能跑通”,每道题试着问自己三个问题:这个写法会在什么情况下出错?能少用一个变量或者少一层循环吗?如果数组规模变大,程序还能扛得住吗?能把这几个问题想明白,一章习题做下来获得的提升,远远不只是会写几道题而已。