复试机试是一场“手熟”的比赛,先不谈会不会做,思路转不转得动、代码能不能一次过,往往取决于你每天有没有动键盘。我给自己定了一个相对保守但可持续的节奏:每天固定3题,跑完OJ、更新博客、小结复盘。复盘对象是第4天的字符串/链表、第5天的二叉树遍历与性质、第6天的动态规划和BFS。这三天正好覆盖了机试里最常被当作“送分题”的几类考点——挡住大多数人的不是算法本身,而是说不清原理、写不对边界、没养成调试节奏。这篇文章就把这三天的9道题、常见坑点以及我用来压节奏的方法全部摊开,给正在准备复试的人一个可以照着做的参考。
1. 打卡机制与选题逻辑:先跑起来,再谈强度
打卡这件事最怕两个极端:一种是一天宕十几题,觉得自己很强,三天后断档;另一种是反复刷自己会的题,刷得很舒服,结果新题型全没碰过。我的做法是给自己设一个最低预期:每天3题,工作日晚上雷打不动90分钟,周末补到120分钟。跑完就写复盘,不管觉得自己做得多烂,都要写,跑不通的题当天不硬耗,记录思路卡点之后明天再碰。
1.1 为什么是“每日3题”而不是“一次性10题”
复试准备周期一般是两三个月,这段时间除了OJ,还要穿插笔试科目、英语口语、机试环境熟悉、面试项目复盘,时间真的不够用。按每天3题来算,两个月就是180题左右,覆盖各大OJ的高频题、常考模板和新题已经足够。更关键的是,每天3题能让你保持“手感连续”而不是“体力透支”。
算法题的本质是模式识别加肌肉记忆。模式识别需要你见足够多的题,而肌肉记忆需要重复。一次性做10题,第二天可能什么都记不住;一天只做3题,做完当晚复盘一下,第二天再动手写这几题的核心模板,你会发现一个星期后看到类似题,手指已经先于大脑反应了。
这个节奏还有另一个好处:方便安排难度梯度。我一般会把每天的3题拆成“回顾1题 + 中档1题 + 进阶1题”的组合。回顾题是前两天做过的同类题重新写一遍,只写核心函数,不写完整流程;中档题是主流面试和高校复试机试里最常见的类型,比如区间贪心、链表操作、树的遍历;进阶题则是稍微带点弯的,比如动态规划、DFS剪枝、双端BFS这类,做不出来不丢人,第二天再想一遍。
注意:每天3题不是每天只做3题,而是保证最少3题。如果某天状态特别差,下限就是3题;状态好可以加到5题,但不要加到停不下来,否则第二天容易产生“已经刷够了”的错觉。
1.2 题型配比与时间盒设置
我不太建议连续几天只刷同一类题。复试机试考的是综合能力,比如常见高校的OJ题库往往第一题考简单模拟,第二题考基本数据结构,第三题考搜索或贪心,第四题才上动态规划。连续刷单一类型会让你在混合型考试里反应变慢。
我给自己定的三天循环是这样的:
| 时间 | 题1(回顾) | 题2(中档) | 题3(进阶) | 总时间 |
|---|---|---|---|---|
| 第4天 | 字符串全排列回溯 | 链表环检测 | 区间合并 | 90分钟 |
| 第5天 | 二叉树镜像 | 二叉树层序遍历 | BST合法性验证 | 90分钟 |
| 第6天 | 0/1背包模板 | 最长递增子序列 | 迷宫最短路径BFS | 100分钟 |
这个表格看上去很简单,但背后有一个很重要的原则:每天至少完整写一遍代码并提交通过才算真的做过。不能看题五分钟然后翻题解,那样没用。我自己有一个严格的纪律:一道题开始实现前,先在纸上或者注释里写出三要素——输入范围、边界情况、大致复杂度;写完核心逻辑后,先用样例跑,再用自己构造的边界用例跑,最后才提交。
这里特别说一下时间盒,也就是给每道题设置最长的思考时间。我的设置是:回顾题20分钟,中档题30分钟,进阶题40分钟。超过时间直接看题解,但要记录自己卡在哪一步,是想不到用某个数据结构,还是递归出口不会写,还是转移方程推不出来。等到写复盘的时候,把卡点单独列出来,第二天回顾时先不看题解,把核心代码重新默写一遍。
2. 第4~6天题目复盘:九个考点的归纳与延伸
九道题说多不多,说少不少,但它们分别覆盖了字符串/DFS、链表/双指针、排序区间、二叉树递归、二叉搜索树、动态规划入门、BFS图搜索等机试核心。下面按天拆开讲,每一题都会说清楚当时的思路、实现要点、以及在复试现场需要表达的“口述版本”。
2.1 第4天:字符串与链表的高频阵地
这三道题是我第四天打卡的选择,看起来基础,但实战里出错率极高。
题4-1 字符全排列(DFS回溯)
这个题要输出一个字符串所有字符的全排列,字符串不含重复字符,但要求按字典序输出。一开始我直接套了回溯模板:
void dfs(string s, int index) { if (index == s.size()) { ans.push_back(s); return; } for (int i = index; i < s.size(); i++) { swap(s[index], s[i]); dfs(s, index + 1); ans.push_back(s); // 这里就错了 swap(s[index], s[i]); } }写了一半就发现问题:字典序要求下,直接交换然后再回溯会产生重复排列和乱序输出。正确的做法是先把字符排序,然后用一个布尔数组标记当前层的使用状态,每一层从0开始遍历,跳过已经用过的字符,这样才能保证剩下的字母按顺序进入下一层,输出的就是字典序。
这个题在复试现场的口述版本应该是:先用sort排序,然后dfs(pos),在循环里如果当前字符和上一个相同且上一个没被用过,就跳过,这是去重。swap+回溯适合求排列数量,不适合字典序输出。
题4-2 链表环检测(快慢指针)
给一个单链表头节点,判断是否存在环。这个题的白板写法特别短:
ListNode* slow = head, *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; } return false;但恰恰因为太短,很多人忽略了两个细节:第一是fast->next判空,不然fast->next->next会空指针异常;第二是空链表和单节点链表应该直接返回 false。调试的时候我还发现一个很容易被忽略的点:快慢指针相遇不一定是环入口,要找入口还得在相遇后从头再走一遍,用数学归纳可以证明它们会在入口相遇。复试如果要求口述,这两层能分开讲,考官基本知道你是真懂。
题4-3 区间合并(排序+线性扫描)
给一组区间,要求合并重叠区间。我第一次写的时候只想到了挨个比较相邻区间,但没有先排序,导致合并顺序乱七八糟。正确做法是先按区间起点排序,然后维护一个当前合并区间的右端点:
vector<vector<int>> merge(vector<vector<int>>& intervals) { if (intervals.empty()) return {}; sort(intervals.begin(), intervals.end()); vector<vector<int>> res; res.push_back(intervals[0]); for (int i = 1; i < intervals.size(); i++) { if (intervals[i][0] <= res.back()[1]) res.back()[1] = max(res.back()[1], intervals[i][1]); else res.push_back(intervals[i]); } return res; }这个题真正的坑在于“重叠”的定义:a[1] >= b[0]就算重叠,不管是否包含。还有一个面试里经常追问的问题是排序的稳定性,其实区间合并不需要稳定排序,因为只依赖起点大小。
2.2 第5天:二叉树的递归与迭代切换
第五天这三题都是树的范畴,但考察方向完全不同,递归、队列、中序遍历三种技术各来一遍。
题5-1 二叉树镜像
镜像的意思是把每个节点的左右子树互换。递归写法很干净:
TreeNode* mirrorTree(TreeNode* root) { if (!root) return nullptr; swap(root->left, root->right); mirrorTree(root->left); mirrorTree(root->right); return root; }这个题容易在注入递归时把顺序搞错。其实先交换再递归左右子树是可以的,先递归后交换也可以。害怕递归走错方向的话,可以自己画一棵三层的完全二叉树,走一遍调用栈,这个方法比背模板管用得多。
题5-2 二叉树层序遍历(BFS队列)
层序遍历的模板我写了不下三十遍,但第五天打卡时依然栽在一个概念上:输出格式要求每一层一个数组,而不是一行全平铺。区别就在循环里要先获取当前队列长度:
int levelSize = q.size(); for (int i = 0; i < levelSize; i++) { TreeNode* node = q.front(); q.pop(); level.push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); }这里必须先把levelSize存下来,因为队列元素在循环中会变化,如果写成for (int i = 0; i < q.size(); i++),每次循环都会重新计算队列长度,结果就是同一层的节点没处理完,下一层的节点也被顺手带进去了。这个错的隐蔽性很高,OJ不会报编译错误,只会输出错乱。
题5-3 验证二叉搜索树(BST合法性)
这个题让我印象最深,因为6天复盘时它依然是我默写练习的候选。判断BST不能只看左右子节点和根的大小关系,必须保证整个左子树都小于根,整个右子树都大于根。规范写法是递归传上下界:
bool isValidBST(TreeNode* root, long long lower, long long upper) { if (!root) return true; if (root->val <= lower || root->val >= upper) return false; return isValidBST(root->left, lower, root->val) && isValidBST(root->right, root->val, upper); }这里我用long long而不是int,因为部分测试用例会用INT_MIN和INT_MAX作为边界,你再用int传参就直接冲突了。这是很典型的“看着对、测着挂”的边界问题,预判的方式是看数据范围:如果节点值范围是[-2^31, 2^31-1],那上下界就得能容纳这两个数以外的数,一般用long long或者干脆传指针。
2.3 第6天:动态规划与搜索的“送分题”不送分
第六天的三题,难度直线上升。不是算法的原理难,而是很多人总栽在初始化上,导致合不上板。
题6-1 0/1背包模板
背包问题几乎每个复试OJ都考过某种变体,最基础的是:N件物品,每件重量 weight[i]、价值 value[i],给定背包容量 C,求最大价值。
int dp[10005]; memset(dp, 0, sizeof(dp)); for (int i = 1; i <= N; i++) { for (int j = C; j >= weight[i]; j--) { dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } }这个题的标准讲法是“先遍历物品,再逆序遍历容量”,目的是让每个物品只能选一次。如果你正向遍历容量,就会出现同一个物品被重复拿的情况,这是0/1背包和完全背包的编码分水岭。复盘时我特意把一维和二位都写了一遍,确保自己不依赖模板,能够从状态定义推导出递推公式。
提示:遇到“变种题”,先确认商品能不能重复取,能重复取就正向循环,不能重复取就反向循环,模版决策比状态转移更常出错。
题6-2 最长递增子序列(LIS)
O(n²) 的DP做法非常直观,dp[i]表示以第i个元素结尾的最长递增子序列长度。但是复试时间有限,高频考法是要求 O(n log n) 的二分优化版本:
vector<int> tails; for (int x : nums) { auto it = lower_bound(tails.begin(), tails.end(), x); if (it == tails.end()) tails.push_back(x); else *it = x; } return tails.size();可能有人第一次看会质疑:tails数组里存的不是真正的LIS序列,为什么它的长度就是LIS长度?这里的核心是贪心思想:我们维护每个长度下可能的最小末尾值,这个值越小,后续拼接的空间越大。这个题在复试口试时一直被追问证明过程,所以复盘时我把“为什么 tails 替换不会影响最终长度”的逻辑理顺了,才能讲得清楚。
题6-3 迷宫最短路径(BFS)
给一个m * n的0/1矩阵,0表示可通过,1表示障碍,求从起点到终点的最短步数。BFS 模板本身不难:
queue<pair<int,int>> q; int dx[4] = {1, -1, 0, 0}; int dy[4] = {0, 0, 1, -1}; vector<vector<int>> dist(m, vector<int>(n, -1)); dist[sx][sy] = 0; q.push({sx, sy}); while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int i = 0; i < 4; i++) { int nx = x + dx[i], ny = y + dy[i]; if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue; if (dist[nx][ny] != -1 || grid[nx][ny] == 1) continue; dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } return dist[tx][ty];当时我的问题出现在“先标记再入队”还是“出队时标记”的犹豫上。正确做法是入队时立刻标记dist,否则重复入队会让队列爆炸。另外我习惯把所有距离初始化为-1,这样既当访问标记又当步数数组,一箭双雕,省掉额外的visited。迷宫题在复试现场还很适合口头考察“如何求最短路径长度”和“如何恢复路径”,后者通常再开一个prev数组就能完成。
3. 复盘过程中最值得拿出来说的调试与边界问题
九道题全部提交通过之后,我做的第一件事不是庆祝,而是把每道题的提交错误信息翻出来,逐条归纳。所谓复盘,重点不在“我做出几道”,而在“我错在哪里、下次怎么防”。
3.1 三个真实翻车案例
第一个翻车是字符全排序列举题。开头我用了交换回溯,结果字典序对不上,当时我还在纠结是不是排序不够细,后来才发现字典序不是靠最后整体sort的,而是在DFS每一层就保证剩余字符按序排列。解决办法是把同层去重条件补上,笔试里如果没加这个条件,结果就是大量重复排列。
第二个翻车在BST验证,我第一次提交用了int边界,直接爆了。当时题目给的两个测试点分别是空树和单节点,我按直觉写了两个 if 判断,结果单节点直接返回 true 没问题,空树也返回 true 没问题,可一跑完整测试就有两个样例超时。不是因为时间复杂度高,而是int边界传递导致判断误判,节点值取到了边界值时,上下界条件被意外触发。改long long后一次过,这个经验后来帮我避了好几次类似的坑。
第三个翻车是0/1背包的空间优化。我想省数组,从一位数组开始写,结果把容量循环写成了正向,提交后样例结果偏大,很明显是同一个物品重复拿了。当时我没有马上改方向,而是先手动模拟一组数据:一件物品重量3价值5,背包容量5。正向循环完dp[5]变成了10而不是5,这个模拟比翻题解说服自己多了。
3.2 把边界检查变成习惯动作
我给自己做了一张“边界检查清单”,每次提交前扫一眼,不用太费脑,但能挡住大部分低级错误:
- 空输入:空数组、空字符串、空链表、空树,全部得单独确认
- 单元素输入:链表单节点、字符串单字符、矩阵 1x1,往往最容易暴露索引问题
- 值域极值:节点值、数组元素是否接近
INT_MIN/INT_MAX,涉及比较和减法时注意溢出 - 循环边界:二分查找左闭右闭还是左闭右开,遍历时是否取了
size()导致越界 - 图搜索访问标记:入队时标记还是出队时标记,决定了会不会死循环
这张清单我在第4~6天每天调用三次,到第六天基本能闭眼背出来。它还有一个额外效果:帮我养成了“写代码前花30秒看数据范围”的习惯。OJ题基本会给出n的范围,这直接决定了应该用 O(n²) 还是 O(n log n),用 DFS 还是 BFS,要不要开 long long。
3.3 复盘笔记怎么写才有用
我这里说的复盘笔记,不是把题目和题解复制粘贴,而是写给自己看的“路径图”。我每道题留三行:
- 第一行写题号和一句话,概述考点,比如“区间合并:排序+维护右端点”
- 第二行写卡点,比如“没想到先排序,直接用原始顺序硬合并了”
- 第三行写下次提防什么,比如“下次先看数据范围再定复杂度,重叠条件要覆盖取等”
日积月累,这部分其实就是面试前最靠谱的复习资料。到第4~6天的时候,我已经攒了十几条这样的笔记,回看时能看到自己从“不会写回溯去重”到“主动判断同层是否需要剪枝”的进步,这种进度感是持续打卡的重要动力。
4. 每日3题的自我管理:如何在复试战线里保证可持续
这部分看起来和算法无关,但第4~6天能不能坚持,拼的其实不是智力而是管理。刷题最怕的不是题难,是断了之后补不回来,一断就全盘崩塌。
4.1 把每日题量装进固定时间盒
我每天给自己预留的 OJ 时间分成两段。第一段晚饭后 19:30–20:00,这30分钟只做“回顾题”,也就是前一天的三道题里挑一道重做,不做新的,目标是在不看题解的情况下15分钟内通过。第二段 20:00–21:00 或延长到 21:30,做中档和进阶题。设置固定时间盒的好处是,你不用每天思考“今天到底要不要刷题”,到点打开电脑就行,决策成本几乎为零。
4.2 放弃完美主义:允许自己只解一道题
总有些日子状态差,比如刚看完几个小时笔试科目,脑子确实转不动。这种情况下我不会逼自己一定完成3题,而是允许自己只完整写一道题,另两题只做思路复述,不写代码。复述思路也要打开IDE把注释写下来,这样可以确认自己知道要用什么数据结构、大体几重循环。这套“最低完成标准”保证我没有一天因为心理负担而中断,中断是打卡系统里最需要避免的事件。
4.3 把博客复盘当成二次学习
写博客复盘不一定是为了给别人看,更是逼自己把糊在一起的知识梳理成语言。每天3题做完了,我会花30分钟写复盘,内容包含:
- 今天三道题分别考什么,属于哪个知识板块
- 我卡在哪一步,为什么卡,卡了几分钟
- 哪些边界条件第一次没想到
- 今天有没有一个可以推广的解题套路
比如第5天复盘BST,我总结出一个通用模式:“子树合法性验证 = 递归传上下界”;第6天复盘背包,我总结出“背包九讲的第一讲本质在于倒序容量”。这些概括虽然在很多博客里能看到,但自己写一遍和看别人写一遍,记忆牢固程度完全不一样。
4.4 把OJ能力转化为复试面试表达
每天做OJ,除了要过机试,复试面试也可能让手撕代码。有两天我发现一个现象:自己能把代码写对,但让口头讲清楚为什么用快慢指针、为什么用BFS,反而有点前言不搭后语。从第5天开始,我要求自己每道题提交通过后,用一分钟对着屏幕复述一遍思路,要求达到“不用看代码也能讲出核心逻辑和复杂度”。
比如链表环检测,我的口述模板是:用两个指针,一个每次走一步,一个每次走两步,如果有环,快指针一定会在某一圈追上慢指针;如果无环,快指针会先到达空节点,循环自然结束。这样讲完,考官基本能判断你理解了问题本质,而不是背了个模板。这个方法在复试现场远比刷题数量值钱。
5. 常见问题速查与心态调节实录
复盘第4~6天的过程中,我也在持续观察自己做错题的规律。下面这些问题不是随口说的,是真实发生过并且值得围观的人。
5.1 为什么连续几道题都卡在同一种边界错法
比如 BST 验证,我用int边界挂了两次;LIS 二分我一开始把upper_bound和lower_bound用混了,找非递减子序列和严格递增子序列用的查询位置完全不一样。这类问题本质上都是“了解模板但不了解边界含义”导致的。复盘的解决办法是给每个模板配一个“为什么这样取边界”的小结,而不是只背代码。
5.2 一道题超过40分钟还做不出来怎么办
我给自己定了一条规则:超过40分钟,直接看题解,但看完题解之后必须立刻关上题解,自己默写一遍。默写不出来的部分就是真正没有理解的部分,第二天回顾时必须先做这道题。这个规则能避免两个后果:一是浪费时间在死磕上,产出极低;二是产生“看懂了就是会了”的错觉,实际动手就穿帮。
5.3 中期疲劳和厌烦期怎么破
做到第4~6天左右最容易出现的心理状态是“明明刷了题,却感觉自己什么都不会”,这是典型的平台期。我的应对策略是切换成“难度混合模式”,把当天进阶题换成一道自己已经掌握的简单题,用一次酣畅淋漓的AC来对冲挫败感。同时把打卡目标从“学会新题”暂时降级为“保住手感和心情”,因为坚持这件事本身的意义有时候比某一题的解出更大。
5.4 已经AC过的题,过几天又不会写了怎么办
这是最正常的现象,完全不需要恐慌。我自己的经验是,人对代码的记忆周期就是3~7天,AC一次只代表当时会了。真题复现率高的题目必须放入“三刷名单”:第一次写完不会,第二次隔一天默写,第三次隔三天默写。每天3题里的“回顾题”就是为这个机制服务的,它看起来占用了新题时间,实际上是在巩固重复出现的考点,整体收益更高。
5.5 关于时间与效率的几条心得
把9道题摊开复盘后,我的体感是:真正解决这些问题的核心不在于多刷,而在于高频、短周期、结构化反馈。每一个卡点,如果当时不记录,第二天就忘了;每次边界错误,如果不上升到检查清单,下一个类似题还会再犯。这套方法论同样适用于笔试中的机试模拟、英语口语准备和项目复盘,本质都是“输入 - 练习 - 反馈 - 修正”的四步循环。
回到打卡本身,我个人最满意的一点是,我通过这三天把原本“今天又得刷题”的被动感慢慢转成了“今天记一下这题还有没有别的写法”的主动好奇。临近第6天时,我做题前会先想三件事:这道题数据的规模暗示什么复杂度、边界条件会不会让我挂掉、提交通过后我能不能在一分钟内讲清楚。米尺的准确度正是靠每天前几十道木板的刻度校准的,机试水平也不是一次模拟赛能拉满的,每一天的3题,才是那个慢慢捏合自信和时间的最短积分。这9道题只是整个复试准备的很小横截面,但它们构成了我后续所有练习节奏的一块重要基石。如果你也在准备复试OJ,愿这个“日拱一卒”的错法能帮你少一点自我怀疑,多一点对下一题的确定性。