资讯动态

C++六道经典作业题全解:从随机数到五子棋的实战思路与踩坑记录

发布时间:2026/10/13 14:32:17 来源:尧图企业网站定制
1. 六道作业的整体设计与选题逻辑这学期C课程结课前的最后一次大作业一共六道题覆盖了从基础语法到简单数据结构的跨度。很多人看到“作业”两个字就觉得是应付差事但实际上这六道题的编排是花了心思的它不是让你背语法而是每一道都在逼你处理一类真实编程中必然遇到的问题。我拿到的题目依次是猜数字游戏随机数与循环控制、冒泡排序实现、字符串转字符数组与统计、前缀和区间求和、学生成绩管理类、控制台五子棋小游戏。光从名字看前几道似乎都“见过”但真正动手写起来才发现每一道都有它的坑。比如随机数那一题如果不知道种子怎么设置每次运行结果都一样再比如字符串转数组那道题C风格数组和string的边界处理稍不注意就乱码。这篇文章就把六道题从拿到手到最终提交的完整过程写出来包括每道题的解题思路、完整代码、以及我实际踩过的坑希望能给正在做类似作业的同学一点参考。这套题的难度定位很明确面向刚学完指针、数组、类和简单算法的本科生不要求你用STL炫技但要求你能把基础语法用对、用稳。每道题都有明确的验收标准比如排序题要求必须手写完整排序过程不许直接sort字符串题要求不允许使用高级正则库前缀和那道题则额外要求处理多组区间查询。这些限制其实是在考察你对底层实现的理解而不是会不会调库。2. 第一道猜数字游戏随机数不是“随便用”2.1 题面与基本思路题目要求是这样的程序生成一个1到100之间的随机整数用户循环输入猜测值程序提示“大了”“小了”或“猜对了”并统计猜的次数。听起来非常简单但有一个隐藏考点——随机数生成。很多第一次接触C随机数的同学会直接写rand() % 100 1然后发现每次重新运行程序猜的数字永远是同一个。这是因为rand()生成的是伪随机序列如果不设置不同的种子它每次产生的序列完全一样。换句话说程序每次运行面对的答案都会是同一个数字这对游戏来说是致命的失败。解决方式是使用srand()设置种子。为了保证每次运行的种子不同通常用当前时间作为种子来源也就是srand(time(nullptr))。这一步必须放在生成随机数之前且只执行一次如果放在循环里反复调用反而可能导致种子过于接近生成相同的随机序列。2.2 完整代码与实现细节下面是我最终提交的版本#include iostream #include cstdlib #include ctime using namespace std; int main() { // 用当前时间作为随机数种子保证每次运行结果不同 srand(static_castunsigned(time(nullptr))); int target rand() % 100 1; int guess 0; int attempts 0; cout 猜数字游戏开始目标数字在1到100之间。 endl; do { cout 请输入你的猜测; cin guess; attempts; if (guess target) { cout 太大了重新猜 endl; } else if (guess target) { cout 太小了重新猜 endl; } else { cout 恭喜你猜对了你一共用了 attempts 次。 endl; } } while (guess ! target); return 0; }这里有个细节time(nullptr)返回的是time_t类型而srand()接受unsigned int所以需要做一个static_castunsigned转换。有些编译器不写转换也能过但会有warning正规的作业批改系统可能会把警告当作错误处理。2.3 输入校验的隐藏考点如果只是做到上面这一步最多只能算及格。这题真正拉差距的地方在于输入校验。设想用户输入了一个非数字字符比如输入abc此时cin guess会失败guess保持原值不更新更糟的是输入流进入错误状态后续所有cin操作都会失效程序陷入死循环。我在第一次提交时就踩了这个坑。后来加了一段校验逻辑while (!(cin guess)) { cin.clear(); // 清除错误状态 cin.ignore(10000, \n); // 丢弃错误的输入内容 cout 输入无效请重新输入一个整数; }这段代码的思路是先让cin尝试读入整数如果失败就清空错误标志再把缓冲区里残留的非法字符丢掉然后重新提示。如果不调用cin.ignore坏字符会一直留在缓冲区里每次循环都会读到它照样死循环。这个处理手法在后面的字符串题目里还会继续用到。3. 第二道冒泡排序背模板和写对是两回事3.1 题面与要求题目要求写一个冒泡排序函数对给定数组进行升序排序并输出每一轮排序后的中间结果。限制是必须手写排序过程允许使用swap但不允许直接调用sort。很多同学觉得这题太基础随手就写完了但实际提交后不少人都被扣了分问题大多出在循环边界上。经典的冒泡排序核心代码长这样for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { swap(arr[j], arr[j 1]); } } }这里的边界判断非常关键外层循环i从0到n-2表示总共需要n-1轮比较内层循环j从0到n-2-i因为每一轮结束后最大的元素已经沉底所以下一轮不需要再比较已经排好序的末尾元素。如果把内层循环写成j n-1虽然结果大概率也是对的但会做大量无意义的重复比较效率差而且输出中间过程时会暴露问题。3.2 加上了提前退出优化的版本题目还要求输出每一轮的结果这实际上给了你一个展示算法理解的机会。如果只是机械地写冒泡循环中间过程的输出顺序就对不上。我当时在基础版本上加了一个优化记录每轮是否发生了交换如果没有交换说明数组已经有序就可以提前退出。#include iostream using namespace std; void bubbleSort(int arr[], int n) { for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { swap(arr[j], arr[j 1]); swapped true; } } cout 第 (i 1) 轮排序结果; for (int k 0; k n; k) { cout arr[k] ; } cout endl; if (!swapped) { cout 数组已经有序提前退出。 endl; break; } } } int main() { int arr[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(arr) / sizeof(arr[0]); bubbleSort(arr, n); cout 最终排序结果; for (int i 0; i n; i) { cout arr[i] ; } cout endl; return 0; }sizeof(arr) / sizeof(arr[0])这个写法一定要理解sizeof(arr)是整个数组占据的总字节数sizeof(arr[0])是单个元素的字节数两者相除得到元素个数。这个技巧在数组作为函数参数传递时是失效的因为数组名退化成指针后sizeof只能得到指针大小。所以你在main里算好数组长度再把长度传给函数是最稳妥的写法。3.3 这道题最容易丢分的点我批改过一些同学的版本发现有一个非常隐蔽的错误有人把内层循环的j n - 1 - i写成了j n - i这会导致内层循环最后一次访问arr[j1]时下标越界。数组越界在C里不会主动报错但可能覆盖相邻的内存数据导致结果莫名其妙地错而且这种错是随机性的有时跑一次对有时跑一次不对排查起来非常痛苦。另外如果题目要求用函数接收数组注意形参不能写int arr[]就当是值传递它本质上是int*。想在函数里修改原数组没问题因为指针传递共享同一块内存但想用sizeof获取传入数组的长度就办不到了必须在外面算好传进来。4. 第三道字符串转数组与字符统计4.1 题面要求与易错点第三道题要求输入一个字符串将其转换为字符数组然后统计其中数字字符、字母字符和其他字符各有多少。这个题的陷阱不在统计逻辑而在“字符串转字符数组”这一步。很多初学者直接写char arr[100] str;编译直接报错——因为string类型不能隐式转换成C风格字符数组。字符串初始化的常见方式有几种char a[] hello; // 方式一常量字符串初始化 char b[] {h, e, l, l, o}; // 方式二字符数组初始化注意没有结束符 string s hello; char c[100]; strcpy(c, s.c_str()); // 方式三标准转换需要注意方式二时b数组的长度是5没有自动加上结束符\0如果后面用strlen(b)或者当成字符串输出会越界读到不确定的内存。所以一般情况下用方式一或方式三是更安全的。4.2 完整实现与逐字符处理我当时选择的实现方案是使用strcpy配合c_str()因为这样得到的字符数组是完整且以\0结尾的。为了满足“逐字符统计”的题目要求我写了一个循环遍历字符数组用ASCII码范围判断字符类型。#include iostream #include cstring #include cctype using namespace std; int main() { string input; cout 请输入一个字符串; getline(cin, input); char arr[1000]; strcpy(arr, input.c_str()); int letterCount 0; int digitCount 0; int otherCount 0; int len strlen(arr); for (int i 0; i len; i) { if (isalpha(arr[i])) { letterCount; } else if (isdigit(arr[i])) { digitCount; } else { otherCount; } } cout 字符数组内容 arr endl; cout 字母个数 letterCount endl; cout 数字个数 digitCount endl; cout 其他字符个数 otherCount endl; return 0; }这里我用getline(cin, input)而不是cin input是因为cin 遇到空格就停止了输入“hello 123 world”只会读入“hello”。如果题目要统计的字符串可能包含空格就必须用getline。cctype里的isalpha、isdigit是利器它们能处理ASCII字符的分类比自己写if (c a c z)更标准也不会漏掉大写字母。4.3 字符数组越界的真实场景我在测试这题时发现一个有意思的问题如果输入的字符串长度超过数组容量strcpy会一路拷贝到超出arr的内存区域造成缓冲区溢出。在作业场景下最直接的后果是后面定义的变量值被覆盖或者程序在结束时崩溃报stack smashing detected。稳妥的写法是用strncpy并限制拷贝长度或者干脆不用字符数组直接处理string对象。但题面明确要求“转换为字符数组”所以我用了一个折中方案限定输入长度超过部分截断。if (input.length() 1000) { input input.substr(0, 999); } strcpy(arr, input.c_str());这种防御式编程的思路在做可能接外部输入的题目时一定要有。5. 第四道前缀和区间求和的利器5.1 题面思路第四道题是这样给定一个长度为n的整数数组再给q次询问每次询问一个区间[l, r]要求输出该区间所有元素的和。如果直接用循环累加每次询问的时间是O(r-l1)当q很大时总复杂度是O(n*q)跑大用例会超时。这就是这道题要你掌握的核心思想——前缀和。前缀和的思路非常简单但极其重要预处理一个前缀和数组prefix令prefix[i]表示原数组前i个元素之和。那么区间[l, r]的和就是prefix[r] - prefix[l-1]。这样每次查询只需O(1)时间总复杂度降为O(nq)。用生活化的方式理解假设你想知道某段路从第3公里到第8公里开了多远不需要逐段累加只需要记录每公里处距起点总里程然后用第8公里的总里程减去第2公里的总里程即可。5.2 完整代码与边界问题#include iostream using namespace std; int main() { int n; cin n; int* arr new int[n 1]; int* prefix new int[n 1]; prefix[0] 0; for (int i 1; i n; i) { cin arr[i]; prefix[i] prefix[i - 1] arr[i]; } int q; cin q; while (q--) { int l, r; cin l r; // l 和 r 采用 1-based 下标 cout prefix[r] - prefix[l - 1] endl; } delete[] arr; delete[] prefix; return 0; }这里我选择了1-based下标即数组第一个元素存在下标1好处是前缀和公式非常自然不用处理prefix[0]和prefix[-1]的尴尬。如果数组用0-based下标区间[l, r]的和要写成prefix[r1] - prefix[l]容易搞混。注意我用了new动态分配数组结束后必须用delete[]释放。很多同学忘记释放内存程序跑得数据量小看不出问题但如果在循环中反复分配大数组内存会越占越多最终被系统杀掉。在OJ系统上可能直接显示内存超限。如果用的是固定大小数组int arr[100000]也不是不行但内存分配在栈上大数组容易爆栈动态分配在堆上更安全。5.3 前缀和的进阶思考这道题其实可以扩展成二维前缀和也就是求矩阵某子区域的和。基本思想一模一样维护一个二维前缀和矩阵每个位置存从左上角到该位置的矩形和查询时用四个矩形的加减组合得到结果。如果你把一维前缀和完全理解了二维只是多一层循环的事。我当时做完这道题后顺手用二维前缀和写了一个矩形区域求和的小工具对理解很有帮助。6. 第五道学生成绩管理类面向对象不是靠背6.1 题面与类设计思路第五道题要求设计一个学生成绩管理系统包含学生类和一个管理类。学生类至少要有姓名、学号、成绩三个私有属性提供构造、获取、显示等方法管理类要能添加学生、删除学生、按成绩排序、输出平均分。这道题考察的是面向对象的基本三大特性封装、继承、多态。先说封装。所有属性必须是private外部只能通过公有方法访问。这样做的好处是防止外部随意修改数据造成逻辑错误。比如成绩字段如果直接暴露成public外部可以写成stu.score -100显然不合理。通过设置方法可以加入校验逻辑void setScore(double s) { if (s 0 || s 100) { cout 成绩不合法拒绝修改 endl; return; } score s; }6.2 用组合还是继承我最初的设计思路是让管理类直接继承学生类后来发现逻辑完全不对学生管理系统“是一个”学生吗显然不是应该是“拥有”很多学生所以这里应该用组合而不是继承。管理类内部持有学生的容器这才是合理的建模。#include iostream #include string #include vector #include algorithm using namespace std; class Student { private: string name; int id; double score; public: Student(string n, int i, double s) : name(n), id(i), score(s) {} string getName() const { return name; } int getId() const { return id; } double getScore() const { return score; } void display() const { cout 姓名 name 学号 id 成绩 score endl; } }; class StudentManager { private: vectorStudent students; public: void addStudent(const Student s) { students.push_back(s); } void removeStudentById(int id) { for (auto it students.begin(); it ! students.end(); it) { if (it-getId() id) { students.erase(it); cout 已删除学号为 id 的学生 endl; return; } } cout 未找到该校学生。 endl; } void sortByScore() { sort(students.begin(), students.end(), [](const Student a, const Student b) { return a.getScore() b.getScore(); }); } double getAverageScore() const { double sum 0; for (const auto s : students) { sum s.getScore(); } return students.empty() ? 0 : sum / students.size(); } void displayAll() const { for (const auto s : students) { s.display(); } } };6.3 这道题真正想考的东西题目真正想考的是const修饰符是否正确使用。我在写getScore和display时加上了const因为这两个方法不会修改对象内部数据。如果不加const那么当把一个student对象放进const vector或者以const Student方式传递时这些方法就调用不了编译直接报错。另一个点是lambda表达式。C11引入的[ ]lambda结构非常方便写sort的比较函数时不用再单独定义一个全局函数或结构体。这里的lambda接收两个const Student参数按分数从高到低排序。还有一点必须提因为StudentManager内部有vector容器如果你的程序需要把StudentManager对象作为参数复制传递默认的拷贝构造会做浅拷贝。幸运的是vector自己实现了深拷贝所以这里即使不写拷贝构造函数也没有问题。但如果你用的是裸指针和手写的动态数组就必须自己处理深拷贝否则两个对象指向同一块内存析构时双重释放就会崩溃。这个知识点在后面比较进阶的作业里一定会遇到建议提前理解。7. 第六道控制台五子棋把前面所有知识揉在一起7.1 题面与棋盘数据结构最后一道综合题是控制台五子棋。要求实现双人对战棋盘15×15黑白双方轮流落子每次输入坐标后更新棋盘并判断胜负。这个题完美地串联了前面所有知识点二维数组、循环遍历、函数分工、输入校验、坐标转换。首要问题是数据结构。我选择了二维char数组const int N 15; char board[N][N]; // . 表示空位B 表示黑子W 表示白子初始化棋盘用双重循环给每个位置赋值.。打印棋盘时为了让玩家看明白坐标我在每行每列前加上了数字索引的个位。7.2 完整实现与胜负判定核心难点在胜负判定。常见的错误是只检查水平方向和垂直方向忽略了对角线。五子棋四方向都要判水平、垂直、主对角线从左上到右下、副对角线从右上到左下。#include iostream #include cstring using namespace std; const int N 15; char board[N][N]; void initBoard() { for (int i 0; i N; i) { for (int j 0; j N; j) { board[i][j] .; } } } void printBoard() { cout ; for (int j 0; j N; j) { cout j / 10 ; } cout endl; cout ; for (int j 0; j N; j) { cout j % 10 ; } cout endl; for (int i 0; i N; i) { cout (i / 10) (i % 10) ; for (int j 0; j N; j) { cout board[i][j] ; } cout endl; } } bool checkWin(int x, int y, char player) { // 四个方向水平、垂直、主对角线、副对角线 int dx[] {0, 1, 1, 1}; int dy[] {1, 1, 0, -1}; for (int dir 0; dir 4; dir) { int count 1; // 正方向延伸 for (int step 1; step 5; step) { int nx x dx[dir] * step; int ny y dy[dir] * step; if (nx 0 || nx N || ny 0 || ny N || board[nx][ny] ! player) { break; } count; } // 反方向延伸 for (int step 1; step 5; step) { int nx x - dx[dir] * step; int ny y - dy[dir] * step; if (nx 0 || nx N || ny 0 || ny N || board[nx][ny] ! player) { break; } count; } if (count 5) return true; } return false; } int main() { initBoard(); int x, y; char currentPlayer B; int moves 0; cout 五子棋开始1-15为有效坐标。 endl; printBoard(); while (true) { cout 玩家 currentPlayer 请落子输入x y; cin x y; // 坐标合法性检查 if (x 1 || x N || y 1 || y N) { cout 坐标超出范围请重新输入 endl; continue; } // 注意输入1-15对应数组下标0-14 x--; y--; if (board[x][y] ! .) { cout 该位置已有棋子请重新输入 endl; continue; } board[x][y] currentPlayer; moves; printBoard(); if (checkWin(x, y, currentPlayer)) { cout 恭喜玩家 currentPlayer 获胜 endl; break; } if (moves N * N) { cout 平局 endl; break; } // 切换玩家 currentPlayer (currentPlayer B) ? W : B; } return 0; }7.3 这里的几个关键细节坐标转换玩家习惯输入1到15的坐标而数组下标是0到14所以每次落子前要执行x--; y--;。忘了转换或者只转换了一个会出现落子位置和你预期不对应的情况这题的分基本就没了。胜负判定中的步进方向四个方向的定义是精心设计的。dx[] {0, 1, 1, 1}配合dy[] {1, 1, 0, -1}分别对应横向左-右、主对角线左上-右下、纵向上-下、副对角线右上-左下。为什么这样排顺序无所谓关键是方向向量组合必须完整覆盖四种直线。落子后再判定一定要在board[x][y] currentPlayer;之后调用checkWin否则你检查的是落子前的位置永远赢不了。这个顺序问题我见过不少同学出错。边界检查在checkWin的延伸循环里第一步就要判断nx和ny是否越界。如果忘了当棋子下在棋盘边缘时循环会访问board的负下标或者超过14的下标。C对越界访问不报错但读到的内容是垃圾数据可能导致明明只有三连子却判定为赢。这个五子棋程序做出来后功能上已经覆盖了前面五道题的所有知识点二维数组管理前缀和也用了数组、输入校验猜数字那题的方法、循环嵌套冒泡排序那题的结构、类的封装思想虽然这里没显式用类但把棋盘操作封装成函数也是一种模块化思路。可以说把六道题全部做对C的核心基础也就扎扎实实过了一遍。8. 六道题做完后我总结的几条实战经验这六道题做完我的体会是C作业题之间其实是环环相扣的猜数字游戏教会你处理用户输入和循环控制冒泡排序考验你对数组边界的敏感性字符串转数组让你明白C风格字符串的凶险前缀和让你第一次接触算法优化的思想学生管理类带你进入面向对象的门最后的五子棋则是对前面所有知识的一次总检阅。基于这些实际经验我给正在做类似作业的同学几条具体建议第一调试时优先使用cout输出中间变量的方式定位问题。比如冒泡排序题要求输出每轮结果其实就是在帮你验证排序过程。如果某轮结果不对你可以打印i和j的值以及当前数组内容立马就能看出是哪一步交换错了。第二写代码前先想清楚数据范围。比如前缀和那题如果n最大是10万栈上分配int prefix[100000]通常没问题但如果n是100万就要改用vector或动态分配。数组越界、栈溢出这类问题在作业里出现频率极高提前规划好容器是省时间的最好方式。第三提交前一定要做“暴力测试”。我常用的方法是写一个简单但慢的正确版本和一个优化版本的代码对拍随机生成大量测试数据比较两个程序输出是否一致。前缀和那题我写了十行暴力累加代码作为基准随机生成了几百组数据对拍确认prefix[r] - prefix[l-1]的公式在边界情况下也完全正确才敢提交。这个过程虽然多花了半小时但能在大作业里省下重写的痛苦。第四命名规范和注释要养成习惯。学生管理类那题里我给每个成员函数都写了注释说明用途排序函数注明是按成绩降序排列。这些注释可能在赶作业时觉得是浪费时间但是过两天你回来看代码或者老师抽查代码时问你这行是干嘛的注释的价值就体现出来了。最后如果你已经把这六道题都做完并且全部测试通过建议继续想想扩展方向猜数字游戏可以加上难度选择和猜测次数限制冒泡排序可以对比插入排序和选择排序的效率前缀和可以升级成二维前缀和做矩阵区间查询学生管理系统可以加上文件读写让数据永久保存五子棋可以做一个简陋的AI对手哪怕只是让电脑随机下子程序的完整度都会有质的提升。作业的分数是一时的但这些做扩展题时的思考才是你真正带走的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑