东华计算机考研复试里的机试环节,是我整个备考过程中最不敢停的一块。每天固定打三道OJ题,今天刚好轮到题库里的第133、134、135题。这里的复盘不是把别人的题解抄一遍,而是把每道题自己做过的版本、踩过的坑、改过的边界,一条条摊开来对照。这篇就是这三题完整的复盘记录,也是我这段时间“每日3题打卡”节奏的真实样本。如果你也在准备复试机试,或者只是想看看OJ刷题该怎么复盘,这篇应该能给你一些能直接拿走用的东西。
我不太喜欢那种把刷题记录写成流水账的方式:今天AC了几道、明天又AC了几道,看上去很热闹,实际没什么营养。真正的进步都是在错题和边界条件里抠出来的。所以这一轮复盘我特意保留了“第一版是怎么写的”“为什么提交后WA”“后来怎么改”这些原始痕迹,而不是只给一份标准答案。这样过几天再回看,还能想起当时的思考过程,比单纯的题解有价值得多。
1. 复试机试到底在考什么
1.1 东华复试机试的基本画风
先说一个很多人容易搞错的事:复试机试并不等于竞赛式算法题,它更像是一场“限时、限环境的工程编码考试”。东华复试的OJ平台用的也是常见的在线判题系统,大体流程就是给你题目描述、输入输出样例,你写完代码提交,系统跑若干组测试数据,全部通过才判AC。这种OJ平台的判题逻辑其实和平时刷的LeetCode、洛谷差不多,但有很多细节不一样,比如输入需要自己处理EOF、循环读多组数据,输出要精确匹配空格和换行,这些坑待会儿在133题里会具体踩到。
从往年信息来看,机试的题量通常不大,时间有限,语言以C/C++为主。核心考察的是三件事:第一,基本的语法和STL用得顺不顺手;第二,边界条件意识够不够;第三,在有限时间内能不能把思路转换成可运行、可提交的代码。很多同学算法思路很溜,张口就是线段树、DP优化,结果一上手写链表就漏指针,最后连基础题都没写完,这其实是最可惜的。
所以我把复试机试备考的核心策略定成了八个字:基础高频、稳定输出。每天不贪多,三题足矣,但每一题都要像考场一样认真写、认真测、认真复盘。
1.2 为什么“每日3题”比“一口吃个胖子”更稳
我是从去年年底开始固定这个节奏的。每天上午雷打不动两小时,前一小时做题,后一小时复盘。一开始我也试过一天刷七八题,效果很差,因为刷完只是“眼熟”,遇到原题换个问法照样不会。后来把量降下来,每天就三道,但要求自己把每道题的来龙去脉彻底吃透,进步反而明显。
每日3题的核心逻辑在于“有效重复”。一道题第一次做是学思路,第二次做是查边界,第三次做才是真正变成自己的东西。如果一天硬塞十道题,基本是做完就忘,留下的只是“我刷了多少题”的虚假成就感。而且复试准备期通常还有笔试、英语和专业课要复习,每天匀出两小时给OJ已经是上限,在这个时间约束下,3题是性价比最高的安排。
我的具体节奏是这样的:第1题一般是简单题,用于热身和恢复代码手感;第2题选中等偏基础的题,覆盖常见数据结构;第3题选当天指定的专题,比如这周练链表、下周练树,集中突破。这个顺序很重要,如果一开始就做难题,很容易产生挫败感,后两题会越写越毛躁。
2. 133~135三题的整体复盘
2.1 三题题型与考点速览
这一轮的三道题,题型跨度还挺舒服的,刚好覆盖了复试最爱考的几类:字符串模拟、链表双指针、二叉搜索树。先把整体信息放在这里,方便后面逐题展开。
| 题号 | 考察题型 | 核心考点 | 我的提交结果 |
|---|---|---|---|
| 133 | 字符串模拟 | 括号匹配、嵌套深度 | 第一次WA,第二次AC |
| 134 | 链表操作 | 双指针、边界处理 | 第一次RE,第二次AC |
| 135 | 树与DFS | 最近公共祖先、二叉搜索树性质 | 一次AC,但思路绕了远路 |
三题都没有特别离谱的难度,但除了135之外,前两题我都不是一次过的。而且133和134挂掉的原因都不是“不会”,而是“细节没抠干净”。这正好印证了一个观点:复试机试的分数差距往往不体现在算法难度上,而是体现在谁能在紧张的考场状态下把细节稳住。
2.2 三题背后的共同主线
如果只把这三题当成三道独立的题去做,那就亏了。复盘的时候我习惯把它们放在一起看,寻找共通的东西。133题里的括号深度、134题里的倒数第K个节点、135题里的最近公共祖先,表面上是三个完全不同的考点,但骨子里都在做同一件事:维护一个状态,然后通过一次遍历或递归把问题解决。
133用计数器维护“当前未匹配左括号数量”,134用两个指针维护“快指针比慢指针快K步”,135用根节点值维护“两个目标节点当前所处的子树区间”。它们都不是靠某个高深的公式,而是靠一个朴素的局部状态在推进。想明白这一点之后,我对这三类题的理解一下子通透了:很多复试题的套路就是设计一个状态,然后在遍历过程中不断更新它。做题的时候不妨先问自己一句,这个状态是什么,比急着写代码有用得多。
另外一个共同点是,这三题都能很自然地用多组测试来验证,这也是复试OJ和LeetCode的最大区别之一。LeetCode的函数式提交只要返回结果就行,但像东华这些传统OJ平台,很多题目需要自己写main函数、自己处理while(cin>>)这种循环输入。这个差异会在133题里直接导致我第一次WA。
3. 第133题复盘:括号匹配的嵌套深度
3.1 题目回顾与我的现场思路
133题长这样:给定一个只包含小写字母和左右括号的字符串,要求判断括号序列是否合法,如果合法再输出最大嵌套深度。输入可能包含多组数据,遇到文件结束才停止。
“括号合法”的判断标准是三句话:左括号必须按顺序和右括号配对;不能出现右括号先于左括号的情况;处理完整串之后不能有剩余的左括号。最大嵌套深度就是遍历过程中,未匹配左括号数量曾经达到的最大值。
我第一眼看到这题的反应是“这不就是栈吗”,于是立刻写了一个vector当栈,遇见左括号就push,遇见右括号就pop,最后判断栈是否为空。思路没错,但交上去WA了。
3.2 第一版代码错在哪
第一版代码的大致写法是:
#include <iostream> #include <vector> #include <string> using namespace std; int main() { string s; while (cin >> s) { vector<char> st; int maxDepth = 0; bool ok = true; for (char c : s) { if (c == '(') { st.push_back(c); if (st.size() > maxDepth) maxDepth = st.size(); } else if (c == ')') { if (st.empty()) { ok = false; } else { st.pop_back(); } } } if (!st.empty()) ok = false; cout << (ok ? "YES" : "NO"); if (ok) cout << " " << maxDepth; cout << endl; } return 0; }本地样例过了,提交WA。问题出在输入处理:我用的是cin >> s,它会把空白字符当分隔符。如果测试数据里在括号中间混有空格,比如字符串是( a ),那么我实际拿到的只是(a),甚至是被拆开的多段字符串,整个判断对象就不对了。
这类传统OJ平台里,题目如果没说“输入的字符串不含空格”,那默认就可能存在空格,稳妥的做法是用getline(cin, s)整行读取。这其实是一道送分题,我却因为惯性思维丢了一分,很亏。
3.3 改进后的完整解法
修好输入之后,我顺便把栈优化掉了。既然这题只包含一种括号,其实根本不关心括号内部的字符位置,只需要记录“当前未匹配左括号的数量”,用两个int就能完成:
#include <iostream> #include <string> using namespace std; int main() { string s; while (getline(cin, s)) { int cur = 0, maxDepth = 0; bool ok = true; for (char c : s) { if (c == '(') { cur++; if (cur > maxDepth) maxDepth = cur; } else if (c == ')') { cur--; if (cur < 0) { ok = false; } } } if (cur != 0) ok = false; cout << (ok ? "YES" : "NO"); if (ok) cout << " " << maxDepth; cout << endl; } return 0; }这个版本干净很多,而且避免了vector频繁动态扩容带来的不必要开销。为什么可以这么做?因为括号匹配的合法性只依赖两个事实:当前有多少个左括号还没被配对,以及右括号是否在左括号对应之前出现。一个足够用的状态就是计数器。
3.4 这类题最容易踩的坑
复盘括号类题目,我总结出三个高频坑。
第一个就是输入方式。传统OJ里题目喜欢用“多组数据”“读到EOF为止”这种描述,一定要根据题目判断是用cin >>还是getline。如果字符串里可能含空格,必须用getline;如果题目明确说字符串不含空白,那用cin >>反而没毛病。
第二个是右括号过剩的情况。有的人写if (st.empty()) ok = false之后,没有及时终止循环,导致右括号继续把计数器往下减,最后cur可能又回到某个看似正常的值。虽然最终判断cur != 0会兜底,但如果题目改成一遇到非法情况就立刻输出NO,那么及时终止循环或者用布尔变量短路判断,会更安全。
第三个是最大嵌套深度的统计时机。比较隐蔽的错误是:只在遇到左括号时更新maxDepth,这对当前场景是对的;但如果题目改成既统计左括号又统计右括号的“当前层数”,那就要在每次cur变化后都更新。我见过有人把maxDepth写死在左括号判断里,导致合法但深度出现在“连续右括号结束”的边界情况统计错误。
4. 第134题复盘:链表倒数第K个节点
4.1 题目回顾与我的第一反应
134题要求实现一个函数:给定单链表头节点和一个正整数K,删除链表的倒数第K个节点,并返回新链表的头节点。如果K大于链表长度,则返回原链表头节点。为了简化考场代码,题目约定K一定大于等于1,但没说K一定有效。
我看到“倒数第K个”时,脑子里蹦出的第一版方案特别朴素:先完整遍历一遍链表,数出长度len,然后从头再走到len-K的位置,跳过要删除的节点。这个思路对,但效率是两趟扫描,而且写起来要注意所有指针移动的时机。当时考场模拟的时候我嫌它不够优雅,又想去秀一下“快慢指针”,结果在第一版代码里翻车了。
4.2 快慢指针的正确推导过程
快慢指针的核心思路是:让快指针先走K+1步,然后快慢指针同步往前走。等快指针走到链表末尾的空指针时,慢指针刚好指向倒数第K+1个节点,也就是被删除节点的前驱。找到前驱之后,一行代码就能摘除目标节点。
这里最关键的细节是“先走K步”还是“先走K+1步”。如果题目只是要“找到倒数第K个节点”,快指针先走K步,然后快慢一起走,快指针到头时慢指针正好在倒数第K个节点上。但题目要的是“删除这个节点”,删除操作需要拿到前驱,所以慢指针必须停在目标节点的前一个位置,因此快指针要先走K+1步。
这多出的1步,几乎就是这道题的全部考点。很多人在白板写链表题时,指针永远差一个位置,就是因为没有把“我要找的是谁的前驱”这件事想清楚。
4.3 我第一次RE的原因
第一版代码我加了dummy节点,思路也都对,但提交后RE。排查之后发现是这个问题:当K刚好等于链表长度时,快指针先走K+1步,会直接走出链表变成空指针,然后我在for循环里继续访问fast->next,运行时直接崩了。
正确的做法是在快指针推进的循环里加上空指针检查:
struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} }; ListNode* removeNthFromEnd(ListNode* head, int k) { ListNode dummy(0); dummy.next = head; ListNode *fast = &dummy; ListNode *slow = &dummy; for (int i = 0; i <= k; i++) { if (fast == nullptr) return head; fast = fast->next; } while (fast != nullptr) { fast = fast->next; slow = slow->next; } slow->next = slow->next->next; return dummy.next; }这样处理之后,当K比链表长度还大时,fast会在i = k或i = k + 1的入口检查中发现自己是空指针,直接返回原链表头,就不会出现空指针访问。
4.4 复盘心得
做完这道题,我最大的感受是:链表题考的根本不是想象力,而是“谁能在纸上把指针移动过程提前演算清楚”。我第一次RE不是不会写,而是没有在动手之前把“K等于链表长度”这个边界情况走一遍。试想考场上编译器报错还好,最怕的是逻辑错误但编译器不报错,白白丢分。
这题还让我养成了一个习惯:写链表操作之前,先在草稿纸上画一个4节点链表,把每个阶段的指针指向标出来。不要觉得这很浪费时间,这恰恰是复试机试最稳妥的做题方式。等到代码写完,再手工推演三组输入:K等于1、K等于链表长度、K大于链表长度。这三组覆盖住了,“倒数第K个节点”这类题就基本不会翻车。
另外,dummy节点的使用是这道题的另一个关键经验。如果没有dummy,当删除的就是第一个节点时,返回头节点的地方会写着return head->next,这在逻辑上可以,但会让代码多出很多分支判断。dummy节点统一了“头节点也可能被删除”的情况,属于投资极小、收益很大的经典做法。很多复试链表题都可以靠dummy把代码写得干净利落。
5. 第135题复盘:二叉搜索树的最近公共祖先
5.1 题目回顾
135题是给一棵二叉搜索树和两个节点p、q,要求找出它们的最近公共祖先。题目保证p、q一定在这棵树中。
这类题在网上有非常多的解法,最暴力的做法是记录从根节点到p、q的两条路径,然后找两条路径最后一个相同的节点。我用这个思路做了半个多小时,代码写得很长:先做DFS查找路径,再把两条路径装进vector,最后倒着找公共部分。能跑通,但我自己都嫌它啰嗦,因为完全没有利用“二叉搜索树”这个关键条件。
5.2 利用BST特性的关键思路
二叉搜索树有一个非常强的性质:左子树的所有节点值都小于根节点,右子树的所有节点值都大于根节点。这意味着从根节点往下走的时候,每一步都知道p和q相对于当前节点的位置关系。
具体判断只有三种情况:
- 如果p和q都小于当前节点值,说明它们都在左子树,公共祖先在左子树里;
- 如果p和q都大于当前节点值,说明它们都在右子树,公共祖先在右子树里;
- 如果p和q一个在左、一个在右,或者其中一个就是当前节点,那当前节点就是最近公共祖先。
这个思路为什么是对的?因为二叉树里任意两个节点的公共祖先,一定是某个把两个节点分开在不同子树(或本身就是其中一个节点)的节点,而这个“分开”的时刻,就是深度最浅的公共祖先,也就是最近公共祖先。
用代码写出来,简洁到让人怀疑这道题是不是送分题:
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { while (root != nullptr) { if (p->val < root->val && q->val < root->val) { root = root->left; } else if (p->val > root->val && q->val > root->val) { root = root->right; } else { return root; } } return nullptr; }迭代版本只有几行,却把递归、路径记录全部省掉了。时间复杂度从最坏O(n)降到O(h),其中h是树高,空间复杂度更是从O(n)降到O(1)。在没有额外辅助空间的复试机试环境里,这种写法属于标准答案级别。
5.3 我为什么绕了远路
说实话,这道题我最开始没走捷径,很大原因是把题目里的“二叉搜索树”当成普通二叉树来做了。看到“最近公共祖先”第一反应就是路径记录或递归查找,忽略了BST自带的大小序关系。这在考场上是很危险的习惯:读题时抓不住关键定语,直接用过去做过的普通二叉树套路套上去,导致时间和代码量翻倍。
复盘之后我给自己留了一条提醒:任何树相关的题目,先看有没有“二叉搜索树”“有序”“平衡”这类前缀词。有的话,一定要先想想这个性质能不能简化问题,再动手写递归。很多复试OJ题出的都是基础数据结构,但所谓“基础”不代表没有陷阱,它往往就藏在题目的两三个定语里。
5.4 这题衍生的两个变体
如果是普通二叉树,最近公共祖先就不能用大小关系判断了,这时候递归才是正路。写一个返回值为节点的递归函数,从左子树和右子树分别找,如果两边都找到了,说明当前节点就是答案;如果只有一边有,则返回那一边。
如果是普通二叉搜索树但要处理“节点可能不在树里”的情况,那么就要把查找路径和存在性判断分开。先确认p、q都在树里,再走上面的迭代逻辑,不然可能出现“误判祖先是某个等于p但实际p不存在的值”这种边界问题。
复试很少只考一个孤立的知识点,常见的是把BST的查询、插入、删除组合出题。第135题虽然简单,但它是这类组合题的底座,值得花时间把迭代和递归两个版本都写熟练。
6. 踩坑实录:OJ提交的常见错误和对策
6.1 OJ常见错误速查表
传统OJ平台和LeetCode这类现代刷题网站有个很大的不同:提交结果五花八门,而且很多缩写的含义对于没接触过的人很不友好。我把常见错误整理成一张速查表,方便复试前集中看一遍。
| 缩写 | 含义 | 最常见原因 | 排查思路 |
|---|---|---|---|
| CE | 编译错误 | 头文件缺失、结构体没加分号、编译器版本过低 | 先在本地用相同标准编译一遍,别直接提交 |
| RE | 运行错误 | 数组越界、空指针、除零、递归栈溢出 | 重点测边界:n等于1、K等于链表长度、树为空 |
| TLE | 超时 | 算法复杂度过高、死循环、输入输出太慢 | 看数据范围,估算循环次数,必要时改用O(n) |
| WA | 答案错误 | 边界条件、多组输入残留、类型溢出 | 构造极端样例,对照输出,核对初始化位置 |
| PE | 格式错误 | 多了一个空格或少了一个换行 | 检查所有输出语句的空格和endl位置 |
复试机试最怕的其实是WA而不是RE。RE至少会明确告诉你程序崩了,WA只会冷酷地告诉你答案不对,但不告诉你哪个数据不对。所以每次写代码前,先问自己三句话:我是用什么状态去解决这个问题的?这个状态的初始值是什么?它会在哪些操作之后更新?问完这三句,WA的概率能下降一半。
6.2 复试机试的现场答题建议
很多人平时用VS Code和IntelliJ用习惯了,到复试机试现场突然回到Dev-C++或CodeBlocks,连快捷键都对不上,代码写的速度会明显变慢。我现在的做法是:每周至少有两天专门用这些复古工具做题,强迫自己适应“没有智能提示”的环境。
具体来说有三条经验值得分享。第一,代码里尽量不要写超过三层的嵌套循环,一旦出现就该考虑是不是思路过于暴力了。第二,每次循环处理多组数据时,所有变量必须在循环体内重新初始化或赋值,这个错误我见得太多了——上一组的答案残留直接污染下一组。第三,比较复杂的输出,建议先拼成一个字符串再一次性输出,而不是在循环里零散发cout,这样格式更容易控制。
6.3 复盘模板,直接抄走就能用
每天打卡的复盘,我建议用统一的格式写,这样积累一周之后回头看,会非常清晰。下面是我自己正在用的模板,可以直接复制到本地笔记里用。
# 第X题复盘 - 日期: - 题型: - 用时: - 提交结果: ## 题目简述 ## 我的初始思路 ## 代码实现 ## 踩坑记录 - 坑1: - 坑2: ## 正确思路的关键点 ## 类似题这个模板最大的价值在于强制你写出“我的初始思路”和“踩坑记录”两部分。很多人刷题复盘只写一个标准题解,完全不记录自己的思维过程,这等于把最有价值的那部分信息丢了。复试机试的核心竞争点不在“你知道多少”,而在“你在有限时间内能不能稳定地把会做的题做出来”,这个能力只有通过记录错误来源才能提升。
7. 关于打卡计划的几个真心建议
写到这儿,133到135的复盘基本就完整了。最后聊几句我自己的体会。
每天3题这个计划,最难的不是做题本身,而是坚持“每日”两个字。我见过很多人刚开始热情高涨,一天刷八题,结果一周之后彻底熄火。我自己的节奏是这样的:遇到状态好的日子,三题里会有一题做难题;遇到状态差的日子,就做三道简单题保手感,但绝对不停更。手感和体能一样,连续训练比突击训练可靠得多。
另一个很有用的动作是:每天开始刷新题之前,先花五分钟在纸上把昨天的代码主流程默写一遍。这个动作只需要五分钟,效果却出奇地好。因为复试考场没有IDE的自动补全,默写能力直接决定了你的输出速度。我在134题那次RE之后,连着几天都先默写了一遍快慢指针删除倒数第K个节点的代码框架,后来再遇到链表题,直接就能在纸上画出指针移动。
如果你现在也是在准备某个学校的复试机试,我的建议很简单:不要跟别人比刷题数量,把每道错题的边界条件吃透,比多刷十道新题都管用。OJ平台的题目总量是无限的,但复试考的核心能力就那么几种:输入输出、边界意识、状态设计、代码落地。每天三道题足够覆盖这些能力,关键是你愿不愿意在“复盘”这两个字上多花一倍时间。