2025年云大计算机考研复试已经尘埃落定,这几天后台一直有学弟学妹在问机试真题。我把今年考场上还原度比较高的四道题整理了出来,每道题都按“题目描述(考场回忆版)→ 解题思路 → AC代码 → 易错点”的顺序讲透,顺便把题量、环境、给分风格也聊一聊。不管你是准备云大复试,还是想拿这套题做模拟训练,都可以直接参考。代码用C++写,Dev-C++环境下能直接编译通过,算法覆盖排序、栈、并查集、动态规划四个最常考的方向,正好是机试区分度的集中体现。
1. 机试概况与备考方向
1.1 今年机试的基本盘
云大计算机学院的复试机试一般安排在笔试后的第二天,上机时间通常是3小时,题量在4到6道之间。今年是4道题,整体难度比去年稍高,但仍然是“前两道保分、后两道拉差距”的分布。第一题属于签到题,考察结构体排序,半小时内能拿下;第二题是经典括号匹配变形,不是单纯套栈模板就能过的,需要理解通配符的贪心本质;第三题是并查集加最小生成树,题目包装换成了机房网络改造场景,核心还是Kruskal;第四题是LCS的进阶版,要求输出字典序最小的最长公共子序列,也是今年区分度最大的一道题。
从考场情况看,能AC前三道的基本上机试成绩不会差,第四题能做对一半思路的已经算高分选手。这个分布其实给准备复试的同学一个明确信号:基础算法要滚瓜烂熟,进阶题要能写出“能跑但可能不是最优”的版本,保底分照样够用。别老想着第四题一次AC,先把前两道稳稳拿到手再说。
1.2 环境、语言和评分规则
云大机试默认用的是Dev-C++ 5.11,编译器是TDM-GCC 4.9.2,支持C++11标准,但不支持C++17的很多新特性。我建议平时写代码就用Dev-C++练,别在VS Code或者CLion里写顺了,到了考场连菜单都找不到。另外bits/stdc++.h这个万能头文件在Dev-C++里是能用的,但不保证每个学校都认,稳妥起见还是分开写#include <iostream>、#include <vector>、#include <algorithm>这些常规头文件。
评分方式通常是按测试点给分,每个测试点对应若干分,代码测过了就得分,测不过就是0分。这意味着代码只需要逻辑正确、输出格式正确即可,不需要考虑工程化,也不需要写注释。但要注意一点:输出必须严格符合题目要求,多一个空格、少一个换行都会导致该测试点0分。别问我怎么知道的,我见过太多人在第一题栽在行末空格上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 真题一:考生信息系统排序
2.1 题目描述(考场回忆版)
给定N个考生的信息,包括学号(字符串,不含空格)、姓名(字符串,不含空格)、初试成绩(整数)、复试成绩(整数),以及一个需要输出的名额M。要求按总成绩从高到低排序,总成绩相同则按初试成绩从高到低排序,初试成绩仍然相同则按学号字典序升序排列。输出前M名考生的学号、姓名和总成绩,按行输出。数据范围:N不超过1000,M不超过N。
这道题原题可能还加了一个字段,比如报考专业代码,但排序逻辑只跟这几个字段有关。考场回忆版把多余的条件去掉了,核心考点不变:结构体定义、sort自定义比较器、排序稳定性处理。
2.2 解题思路
这题没什么算法含量,重点在“比较器怎么写才对”。总成绩用初试加复试,单独存一个字段就行。排序比较器里按三级优先级判断:先比较总成绩,再比较初试,最后比较学号。注意学号是字符串,直接用<比较就是字典序升序,不用自己手写strcmp。
有一个细节容易忽略:如果题目给的权重不是简单的相加,比如总成绩 = 初试 × 0.6 + 复试 × 0.4,那也不要写成浮点数比较。浮点运算有精度误差,两个在数学上相等的分数可能因为浮点精度被判成不同。正确做法是转成整数比较,比如权重是0.6和0.4,就把初试乘6、复试乘4再相加,比较大小关系完全不变。这个技巧在做分数、百分比相关排序时特别实用。
2.3 AC代码
cpp复制#include <iostream>
#include <algorithm>
#include <string>
#include <vector>
using namespace std;
struct Student {
string id;
string name;
int initScore;
int retestScore;
int totalScore;
};
bool cmp(const Student &a, const Student &b) {
if (a.totalScore != b.totalScore)
return a.totalScore > b.totalScore;
if (a.initScore != b.initScore)
return a.initScore > b.initScore;
return a.id < b.id;
}
int main() {
int n, m;
cin >> n >> m;
vector<Student> stus(n);
for (int i = 0; i < n; i++) {
cin >> stus[i].id >> stus[i].name
>> stus[i].initScore >> stus[i].retestScore;
stus[i].totalScore = stus[i].initScore + stus[i].retestScore;
}
sort(stus.begin(), stus.end(), cmp);
for (int i = 0; i < m && i < n; i++) {
cout << stus[i].id << " " << stus[i].name << " "
<< stus[i].totalScore << endl;
}
return 0;
}
2.4 易错点
第一,读入姓名如果可能包含空格(中文姓名有时候会有特殊格式),用cin >>就会踩坑。考场题目如果明确说姓名不含空格,那就直接cin;如果没说,稳妥做法是先读一整行再按空格切分,或者把姓名的分隔符改成下划线。第二,输出时只输出前M名,但如果M大于N(虽然题目保证不会),要加i < n防御一下。第三,比较器函数一定要声明成普通函数或者静态函数,不要写在main里面,否则会报错。第四,排序用sort是不稳定排序,但我们的比较器已经定义了完整的偏序关系,所以不涉及稳定性的问题;不要画蛇添足去加下标字段,反而容易出错。
3. 真题二:带通配符的括号匹配
3.1 题目描述(考场回忆版)
给定一个字符串,只包含(、)、*三种字符,长度不超过1000。*可以被替换成左括号、右括号或者空字符。判断能否通过若干次替换,使得原字符串成为一个合法的括号序列。合法括号序列的定义是:左右括号数量相等,且任意前缀中左括号数量不少于右括号数量。如果存在任意一种替换方案使序列合法,输出True,否则输出False。
样例:
"()"→True"(*)"→True"(*))"→True,把*替换成(即可"(((**"→False"())(*"→False
这道题源自LeetCode 678,但出题人把它搬到云大机试里,没有直接给“有效括号字符串”这么直白的名字,而是包了一层场景描述。如果你之前刷过原题,看到*通配符基本就反应过来了;没刷过的话,需要现场推导出贪心规则。
3.2 双栈法思路
最直接的思路是暴力枚举*的替换方式,但*数量一多直接指数爆炸。我们需要一个确定性的贪心策略。
把左括号和星号分别存进两个栈,栈里存的是它们在原字符串中的下标。从左往右遍历:
- 遇到
(,把下标压入左括号栈; - 遇到
*,把下标压入星号栈; - 遇到
),优先用左括号栈里的元素去匹配;如果左括号栈为空,就退而求其次用星号栈顶元素去匹配,此时这个星号相当于一个左括号;如果两个栈都是空的,说明这个右括号没有匹配对象,直接返回False。
遍历结束后,左括号栈里可能还残留着没匹配的左括号。这时用星号去匹配,但要满足一个关键条件:星号必须出现在左括号的右侧,才能充当右括号。所以每次比较左括号栈顶和星号栈顶的下标,如果星号下标大于左括号下标,两个一起弹出,否则无法匹配,返回False。最后左括号栈空才返回True。
为什么要优先用左括号而不是星号去匹配右括号?因为星号更灵活,既能当左括号也能当右括号。在遇到右括号时,左括号如果不用就可能会滞留到最后,而星号可以在滞留阶段继续发挥右括号的作用。所以把更灵活的资源留到后面处理,是一种“反悔贪心”的思想。
3.3 AC代码
cpp复制#include <iostream>
#include <stack>
#include <string>
using namespace std;
bool checkValidString(const string &s) {
stack<int> left, star;
for (int i = 0; i < (int)s.size(); i++) {
if (s[i] == '(') {
left.push(i);
} else if (s[i] == '*') {
star.push(i);
} else {
if (!left.empty()) {
left.pop();
} else if (!star.empty()) {
star.pop();
} else {
return false;
}
}
}
while (!left.empty() && !star.empty()) {
if (left.top() < star.top()) {
left.pop();
star.pop();
} else {
return false;
}
}
return left.empty();
}
int main() {
string s;
while (cin >> s) {
cout << (checkValidString(s) ? "True" : "False") << endl;
}
return 0;
}
3.4 贪心区间优化与易错点
其实这道题还有一个代码更短、思路更巧的做法,叫贪心区间法。维护两个变量low和high,代表当前可能的未匹配左括号数量的区间。遇到(时,low++,high++;遇到)时,low减1但最小只能减到0,high减1;遇到*时,它可以当作左括号使high++,也可以当作右括号使low减1,还可以当作空字符保持不变,所以low减1但保底为0,high加1。如果遍历过程中high变成负数,说明右括号多得没救了,直接返回False;遍历结束判断low是否为0即可。这个做法连栈都不用建,但理解起来比双栈法抽象,机试现场能想明白双栈法已经足够。
易错点有三个。第一,栈里必须存下标而不是只存字符数量,否则最后一步无法判断星号和左括号的先后顺序。第二,处理右括号时,如果左括号栈非空就一定要优先用左括号,不要因为后面还有星号就先放着;这个顺序反了就会出现错误判断。第三,字符串长度可能为0,输入时要用cin >> s按字符串读,如果题目是用getline读一整行,要提前处理好前面换行符的残留问题。
考场上有不少人栽在这个题上,是因为把*的处理想简单了,以为*只能替换成一种固定符号。记住它的本质:*是一个“可变的占位符”,先用双栈模拟,最后再合并左右栈,这个思路在面试中也会考到。
4. 真题三:机房网络改造最小成本
4.1 题目描述(考场回忆版)
学校有N个机房,编号从1到N,现在要通过网线把所有机房连成一个整体。机房之间已经列出了M条可选网线,每条网线给定了两个端点机房编号和建设成本。此外,有K对机房之前已经通过旧网线互相连通,这些旧连接不需要额外成本。在保留已有旧连接的前提下,问至少还需要投入多少成本才能让所有机房连通。如果无论怎么选都无法连通,输出-1。数据范围:N不超过1000,M不超过10000,成本为正整数。
这题考察的就是最小生成树,但场景包装成了“已有旧连接”。如果你只看题面觉得像网络规划,往Kruskal上一套就对了。
4.2 并查集+Kruskal思路
Kruskal算法的核心是贪心:把所有边按权重从小到大排序,每次选一条不会形成环的边加入,直到所有点连通。判断“会不会形成环”用并查集,这一套组合拳是机试高频套路,必须做到闭着眼能写出来。
这题的特殊点在于“已有旧连接”。旧连接已经在机房之间建立了网络,相当于这些机房已经在同一个连通分量里了。处理办法很简单:先把旧连接的K对点用并查集合并,然后再对M条可选网线按成本排序,逐条尝试合并。如果两个端点已经在同一集合里,说明这条网线多余,跳过;否则加入并统计成本和连通分量数量。当连通分量数量减到1时,提前结束循环。
这里有个隐藏考点:如果旧连接本身已经形成了环,比如(1,2)和(2,1)都出现了,并查集的merge函数要能处理合并失败的情况。merge返回false就说明是多余连接,直接忽略。再一个,如果所有边都处理完后连通分量数量仍然大于1,说明图不连通,要输出-1。
4.3 AC代码
cpp复制#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
struct Edge {
int u, v, w;
bool operator<(const Edge &other) const {
return w < other.w;
}
};
int parent[1005];
int findRoot(int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
bool mergeSet(int a, int b) {
a = findRoot(a);
b = findRoot(b);
if (a == b) return false;
parent[a] = b;
return true;
}
int main() {
int n, m, k;
cin >> n >> m >> k;
for (int i = 1; i <= n; i++) parent[i] = i;
for (int i = 0; i < k; i++) {
int a, b;
cin >> a >> b;
mergeSet(a, b);
}
vector<Edge> edges(m);
for (int i = 0; i < m; i++) {
cin >> edges[i].u >> edges[i].v >> edges[i].w;
}
sort(edges.begin(), edges.end());
int components = 0;
for (int i = 1; i <= n; i++) {
if (findRoot(i) == i) components++;
}
int cost = 0;
for (Edge &e : edges) {
if (mergeSet(e.u, e.v)) {
cost += e.w;
components--;
if (components == 1) break;
}
}
if (components == 1) cout << cost << endl;
else cout << -1 << endl;
return 0;
}
4.4 边界条件与坑位
这道题有好几个容易踩的边界条件。第一个是K可能为0,也就是没有旧连接,这时代码要能正常跑,不能因为pre为空就数组越界。第二个是M条网线中可能存在重边,比如(1,2,5)和(1,2,3)都出现,Kruskal会先选成本小的,没问题;但不要自己去重,排序后自然能正确处理。第三个是自环,即u等于v,并查集merge会返回false,不影响结果。第四个是连通分量数量的初始化,我这里是扫描一遍所有点统计根节点个数;也可以初始化为N,每次成功合并减1,效果一样。
还有一个细节:findRoot用了路径压缩,但不带递归。机试时递归版int find(int x){ return parent[x]==x?x:parent[x]=find(parent[x]); }也很好写,但N比较大的时候递归深度可能爆栈。用迭代版更稳,尤其是图论题的N到10万级别时。虽然这题N只有1000,但养成写迭代版的好习惯,能省掉很多麻烦。
考场上如果时间紧,可以用Prim算法替代Kruskal吗?可以,但Prim处理“已有旧连接”时不如Kruskal直观。Kruskal天然支持先合并部分点,再继续选边,这是它在机试中的最大优势。既然题目已经给了旧连接,看到这种题直接往并查集上想就对了。
5. 真题四:字典序最小最长公共子序列
5.1 题目描述(考场回忆版)
给定两个由小写字母组成的字符串A和B,长度均不超过1000。先输出它们的最长公共子序列(LCS)的长度,再换行输出字典序最小的那一个最长公共子序列。如果LCS为空串,则只输出长度0和第一行为空行。
这道题比普通LCS多了一个“字典序最小”的要求,难度瞬间上来。普通LCS只求长度,用二维DP表就能解决;这道题要求在长度最大的前提下,选择的字符序列字典序尽量小。
5.2 从LCS DP表到字典序构造
先复习普通LCS的DP定义。用dp[i][j]表示A[i..]和B[j..]这两个后缀的最长公共子序列长度。如果A[i] == B[j],那么dp[i][j] = dp[i+1][j+1] + 1;否则dp[i][j] = max(dp[i+1][j], dp[i][j+1])。注意这里用的是后缀式定义,从右往左填表,后面构造序列的时候会非常方便。
关键是如何保证字典序最小。一个贪心策略是:从(0,0)出发,当前剩余需要匹配的长度是dp[0][0]。在每一步,按字母从小到大的顺序尝试26个字符,看是否存在一个字符c,它在A和B当前位置之后都能找到,并且选择了它之后剩余的LCS长度刚好是目标长度减1。如果存在,就选这个c,然后跳过这个字符所在的位置,继续构造下一位。
这里需要预处理nxt数组,nxtA[i][c]表示在A中从下标i开始往后,字符c第一次出现的位置。有了nxt数组,查找某个字符是否存在就变成了O(1)操作,构造过程的时间复杂度只有O(26 × LCS长度),非常快。预处理nxt的做法是从后往前扫,每一位直接拷贝下一位的数组,再把当前位置的字符更新成当前位置。
为什么会想到用dp[p+1][q+1] == len - 1这个判断?因为dp[i][j]的含义就是从A[i..]和B[j..]能拿到的最大LCS长度。如果选择了字符c之后,剩余部分还能拿到len - 1的长度,就说明这个选择不会浪费长度,是可以继续走下去的。贪心枚举字母顺序保证了最终得到的序列是字典序最小的。
5.3 AC代码
cpp复制#include <iostream>
#include <cstring>
#include <string>
using namespace std;
const int MAXN = 1005;
int dp[MAXN][MAXN];
int nxtA[MAXN][26], nxtB[MAXN][26];
void buildNext(const string &s, int nxt[MAXN][26]) {
int len = (int)s.size();
for (int c = 0; c < 26; c++) nxt[len][c] = -1;
for (int i = len - 1; i >= 0; i--) {
for (int c = 0; c < 26; c++) nxt[i][c] = nxt[i + 1][c];
nxt[i][s[i] - 'a'] = i;
}
}
int main() {
string a, b;
cin >> a >> b;
int n = a.size(), m = b.size();
memset(dp, 0, sizeof(dp));
for (int i = n - 1; i >= 0; i--) {
for (int j = m - 1; j >= 0; j--) {
if (a[i] == b[j]) {
dp[i][j] = dp[i + 1][j + 1] + 1;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
buildNext(a, nxtA);
buildNext(b, nxtB);
int lcsLen = dp[0][0];
cout << lcsLen << endl;
int i = 0, j = 0;
string ans;
while (lcsLen > 0) {
for (int c = 0; c < 26; c++) {
int p = nxtA[i][c];
int q = nxtB[j][c];
if (p == -1 || q == -1) continue;
if (dp[p + 1][q + 1] == lcsLen - 1) {
ans.push_back('a' + c);
i = p + 1;
j = q + 1;
lcsLen--;
break;
}
}
}
cout << ans << endl;
return 0;
}
5.4 为什么后缀式DP更好用
如果用前缀式DP,即dp[i][j]表示A前i个字符和B前j个字符的LCS长度,填表时会习惯从左往右填。但到了构造序列的阶段,从(n,m)往回走时,需要不断判断“某个字符选完之后,剩下的前缀是否还能构成目标长度”,逻辑上会绕一些。后缀式DP天然支持“跳过当前位置往后找”的查询,所以构造时每一步都直接查dp[p+1][q+1],跟状态定义完全一致。
这个题真正的难点不是DP本身,而是“字典序最小”的处理。很多人在考场上会想到直接DFS暴力搜索所有LCS,然后取字典序最小的一个,但这样做时间复杂度是O(2^n)级别的,根本跑不完。理解“用dp表剪枝,用nxt加速定位字符”这个思路,才能把它变成一道能在1000数据范围内AC的题目。
多提一句:如果题目只要求输出LCS长度,不要求输出序列,那这道题就简单很多,连nxt数组都不需要。今年加了这个构造要求,说明云大机试已经开始考查“动态规划状态如何回推”了。以后准备复试,光会填表不够,还要会反向构造方案。
6. 考场调试技巧与常见编译踩坑
6.1 调试三板斧
机试现场没有IDE的智能提示那么流畅,Dev-C++的调试器也谈不上好用,所以我的调试习惯是“三分靠代码,七分靠输出”。第一板斧是分阶段输出中间结果,比如在排序后打印一遍数组,在并查集合并后打印每个点的根节点,在DP表填完后打印几个关键格子的值。这样能快速定位是算法写错还是边界条件写错。第二板斧是造极限数据测试,比如N取最大值、字符串长度取1000、栈里压满元素等,看程序会不会超时、数组越界、栈溢出。第三板斧是跟样例逐步对拍,每一步都手算一遍,特别适用于排序题和括号匹配题,能排查出比较器写反之类的低级错误。
调试完一定要删掉所有printf,或者在提交前用注释块包起来,不然输出格式会多出一堆内容,直接判0分。别问我是怎么知道的。
6.2 高频编译和运行错误清单
Dev-C++的报错信息虽然不太友好,但大部分错误是固定的,我整理了一个高频清单:
| 错误现象 | 原因 | 解决办法 |
|---|---|---|
'cout' was not declared in this scope |
没写using namespace std; |
加using namespace std;或用std::cout |
undefined reference to 'main' |
main函数拼错或缺失 | 检查是否有int main() |
Segmentation fault |
数组越界、递归爆栈 | 开全局数组、改递归为循环 |
[Error] name lookup of 'i' changed |
变量作用域冲突 | 循环变量避免重复定义 |
| 输出多了空格或换行 | 行末误加了空格 | 用i == 0判断是否输出空格 |
这里特别提醒:数组能开全局就开全局。全局数组默认在静态区,不受栈空间限制,而局部大数组(比如int dp[1005][1005]写在main里)会占用栈空间,万一栈不够直接崩溃。机试题目都不敢说让你开多大的数组,但你把一个1000×1000的二维数组放在main里很可能没事,一个1000000的一维数组就危险了。习惯性地把大数组放到外面,能少踩很多坑。
6.3 时间分配和保底策略
3小时4道题,我的建议节奏是:前30分钟通读所有题,先不做;然后花40分钟把第一题AC,再花50分钟做第二题,第三题留1小时,最后剩一点时间检查。这个节奏不一定适合所有人,但我强烈建议不要在一道题上死磕超过40分钟。机试是拿分游戏,不是AC游戏。
如果第三题或者第四题卡住了,一定要写暴力版本。比如连通性问题可以用DFS遍历判断连通分量,LCS可以用DFS枚举所有子序列后取字典序最小,虽然时间复杂度高,但至少能过部分小数据测试点。机试评分是按测试点给分的,暴力版本能过20%到40%的测试点,总比一分没有强。我在考场上见过太多人因为想不出最优解直接交白卷,这是最可惜的。
6.4 刷题节奏参考
如果你现在离复试还有一两个月,建议按这个节奏刷:先搞定基础数据结构(数组、链表、栈、队列、二叉树),再刷排序和查找,然后把字符串处理、模拟题过一遍,接着就是并查集、最短路、最小生成树、动态规划这几个机试高频模块。云大机试历年都没跳出这个范围,把《王道考研机试指南》的核心章节吃透,再把洛谷的普及-和普及/提高-题目刷上100道左右,及格绰绰有余。
最后再多说一句,刷题时别只追求AC,要学会在每道题提交通过之后,回头看一遍自己第一次写的版本,想想哪里能优化,哪里是卡壳的关键点。我备考的时候有一个小习惯:每道错题都记录错误原因,是边界条件没考虑、比较器写反,还是读入格式理解错了。到考前最后一周,只翻这个错误记录本,效率比重新刷一遍题高得多。这比任何答题模板都管用,也算是给准备复试的你们一点过来人的经验。
