老实说,刷题这件事,最开始我是抗拒的。总觉得笔试挂人主要挂在小众题上,后来发现完全想反了——大部分挂人的题,恰恰是那些你背过答案、却没真正理解思路的常规题。去年准备暑期实习的时候,我把牛客题霸的题目和题解汇总翻来覆去整理了好几轮,才慢慢悟出一个道理:刷题的价值不在“刷过”,而在“能不能在笔试现场快速复现思路”。这篇内容不是题库的搬运,而是我基于题霸这套题单沉淀下来的整理方法、题型破题思路和踩坑记录,适合正在准备笔试面试的应届生、想转算法岗的开发者,以及刷题刷到怀疑人生的同学。
1. 题霸这套题单,解决的不只是“有没有题做”
1.1 刷题最怕的不是难,而是不知道优先级
我见过不少同学,一上来就抱着算法导论啃,或者把某平台几千道题按序号往下刷。结果刷了一个月,人倒是很努力,但问他“链表反转有几种写法”“动态规划和贪心怎么区分”,还是一脸懵。问题不在智力,在于没有按优先级组织题目。
题霸这套题单给我的第一感受是:它的专题划分和热度排序,天然就帮你做了一轮“考点筛选”。我不会从头到尾逐题刷,而是先看每个专题下的高频题分布。比如链表、二叉树、动态规划、字符串、双指针这类专题,热度明显高于冷门的位运算或几何题。那我就先集中解决这些高频专题,把80%的时间花在真正会考的方向上。
这里有一个很实用的习惯:拿到一个专题,先别急着做题,花半小时把该专题下的题目列表扫一遍,标记出“我一眼就知道思路的”和“完全没概念的”。前者直接跳过,后者才是需要慢慢啃的。这样做的逻辑很简单,刷题的本质是查漏补缺,不是集邮成就感。
1.2 题解汇总的真正价值:它逼着你写“自己的版本”
很多人收藏了一堆题解链接,真到用的时候发现还是不会。为啥?因为看题解是“被动输入”,而笔试是“主动输出”。中间缺了一个转化的动作:用自己的语言把别人的解法重新写一遍。
我的做法是:每道题至少独立尝试20分钟,超过20分钟没有头绪才看题解。看完题解后不直接抄,而是关掉题解,自己在编辑器里重新实现一遍。这个过程会暴露很多假理解——你以为看懂了,但一写就卡住,卡住的地方就是你真正需要记的薄弱点。
所以题解汇总对我而言,不是“答案大全”,而是“比照工具”:用自己的实现和题解做对比,看看是哪里绕了远路,哪里漏了边界条件。每一次对比都是一次真实的认知升级。
2. 我整理题解时用的三板斧:分类、标复杂度、写复盘
2.1 给每道题打上“模式标签”,而不是只记题目名
题目名是最不可靠的记忆锚点。我整理过一百多道题后发现,记忆里真正留下的不是“某某题”,而是“这道题属于哪种模式”。所以我给每道题打标签:数组双指针、哈希表去重、区间合并、树形DP、拓扑排序、单调栈等等。
比如“两数之和”和“最长连续序列”虽然题目完全不同,但核心模式都是“用哈希表把查找时间从O(n)降到O(1)”。我会把这类题归到同一组,复习的时候按组看,而不是按题号看。这样做的效果立竿见影:考试时看到新题,我会先问自己“这题的底层模式是什么”,而不是“我有没有做过原题”。
打标签的时候还有一个细节:尽量用“动词+对象”的结构命名,比如“用双指针收缩窗口”“用单调栈维护最近更大值”。这种标签的信息密度比“双指针”三个字高得多,因为它在提示你具体动作。
2.2 一题多解不是炫技,而是为了应对面试追问
很多人觉得一题多解浪费时间,能用一种解法AC就够了。但笔试之后还有面试,面试官最喜欢问的就是“还有别的做法吗”或者“你这个解法哪里能优化”。如果你只会一种解法,这个问题就很容易冷场。
所以我整理题解的固定动作是:每道题至少记录两种解法,并写清楚它们的时间复杂度和空间复杂度。拿最常见的“两数之和”举例:
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 |
|---|---|---|---|
| 暴力双重循环 | O(n^2) | O(1) | 枚举所有数对 |
| 哈希表一次遍历 | O(n) | O(n) | 用空间换时间 |
写法上,我不会把两种解法都完整贴出来,而是重点写“哈希表解法”,然后用一两句话说清楚“暴力法为什么慢、哈希表为什么快”。这样复习时一眼就能看到优化脉络,面试时也能从暴力解讲起,自然过渡到最优解,体现出思维过程。
def two_sum(nums, target): seen = {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] = i return []2.3 复盘笔记必须包含的三个问题
每道题解整理完,我会在文档末尾固定写三行字:一是“这题为什么这么做”,二是“我卡在了哪个地方”,三是“下次看到什么特征该想到它”。这三行字比整篇题解都值钱。
“为什么这么做”逼我把思路压缩成因果链。比如反转链表为什么要用三个指针?因为单链表只能从当前节点往后走,改一条边的指向之前必须先保存下一条边的地址,否则链表就断了。把这个因果链写出来,才说明你真的理解了。
“我卡在哪”是给未来的自己排雷。我最常卡的地方是循环终止条件,比如反转链表里cur走到None还是走到最后一个节点?每次卡完都会写进复盘,多写几次,这类边界条件就变成肌肉记忆了。
“下次该想到它”是在训练模式识别。比如看到“连续”“子串”“窗口”这些词,第一反应就该是双指针或滑动窗口;看到“方案数”“最值”“选择”这些词,就该考虑动态规划。这种联想反应需要刻意练习,复盘笔记就是练习册。
3. 高频题型破题思路拆解
3.1 链表操作:用“虚拟头节点”消灭一半的边界问题
链表题是笔试最爱出的基础题,难度不大,但边界条件很烦人。比如删除倒数第K个节点、反转链表的一部分,这些题如果不做特殊处理,经常要单独讨论“头节点被删了怎么办”,代码又长又容易错。
我的习惯是:凡是可能改动头节点的题目,先创建一个虚拟头节点dummy,让dummy.next = head,最后返回dummy.next。这样头节点就变成了中间节点,所有逻辑统一处理,不需要特判。这个技巧不是我发明的,是刷多了之后发现几乎所有高效题解都会用。
def reverse_list(head): prev = None cur = head while cur: nxt = cur.next cur.next = prev prev = cur cur = nxt return prev链表题还有一个共通的思维转变:不要试图“模拟链表的物理过程”,而是要盯住“指针的指向变化”。每操作一步之前在纸上画一下三个节点的连接,画清楚再写代码,AC率会直线上升。笔试时如果允许用草稿纸,链表题一定要画图。
3.2 动态规划:先定状态,再想转移
动态规划是大多数人最头疼的专题,包括我自己。后来我发现,动态规划其实是有固定套路的:第一步定义状态,第二步写转移方程,第三步定初始值和边界,第四步想能否优化空间。很多人一上来就纠结转移方程怎么写,其实状态定义才是根本。
以最长公共子序列(LCS)为例,这是非常经典的入门题。状态定义为dp[i][j]表示text1前i个字符和text2前j个字符的最长公共子序列长度。为什么这么定义?因为子序列问题天然适合用“前缀区间”来划分状态,往后推时有明确的前后依赖关系。
转移方程就顺理成章了:如果text1[i-1] == text2[j-1],那这个字符一定可以纳入公共子序列,所以dp[i][j] = dp[i-1][j-1] + 1;如果不相等,就取max(dp[i-1][j], dp[i][j-1]),即要么不选text1的最后一位,要么不选text2的最后一位。
def longest_common_subsequence(text1, text2): m, n = len(text1), len(text2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if text1[i - 1] == text2[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return dp[m][n]空间优化也很值得记:观察转移方程,dp[i][j]只依赖dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]三格,本质上只用到了上一行和当前行的数据。所以可以把二维数组压成两个一维数组,甚至用一个数组加一个左上角变量。这种优化几乎逢面必考,整理题解的时候一定要动手写一遍。
3.3 二叉树递归:把“遍历框架”吃透
二叉树题很容易给人“都会做”的错觉,因为递归框架就那么几行,背下来都能过。但一旦题目稍微变形,比如“判断二叉搜索树”“求最近公共祖先”,很多人就开始乱套模板。
我的体会是,二叉树递归的关键不是记住前中后序遍历的模板,而是想清楚两件事:递归函数返回值代表什么,以及当前节点要做什么。这两个问题想清楚了,代码自然就出来了。
拿“求二叉树最大深度”来说,递归函数返回“以当前节点为根的子树的最大深度”,那当前节点要做的就是取左右子树深度的最大值再加1。思维过程是:先问左子树要深度,再问右子树要深度,自己把结果合起来往上返回。这就是“后续遍历”的本质——先处理子问题,再处理当前节点。
反过来,判断两个二叉树是否相同,就可以用前序遍历的思维:先比较当前节点值,再递归比较左子树和右子树。当前节点不相等直接返回false,相等才继续往下看。你看,同样是递归,一个用后序思想,一个用前序思想,原因是返回值的语义不同。
3.4 双指针:从“暴力解”到“单调性”的思维跳跃
双指针是一个非常讨巧的模式,因为它通常能把O(n^2)的问题优化到O(n),而且代码极其简洁。我一开始对双指针的理解停留在“左右各放一个指针”,后来才发现它的本质是“利用数据的单调性来排除不可能的解”。
最典型的例子是“有序数组中找两数之和等于目标值”。暴力做法是两层循环,O(n^2)。但数组有序这个条件给了我们一个重要的单调性:左指针往右走,两数之和变大;右指针往左走,两数之和变小。于是我们让两个指针从两端出发,根据当前和与目标值的大小关系,决定移动哪个指针,每次移动都排除了一整批不可能的组合。
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: return [left + 1, right + 1] elif cur < target: left += 1 else: right -= 1 return []看到没有,代码极其短。梳理题解的时候,我会重点标出“为什么可以移动指针”,而不是只记代码。因为移动左指针说明当前和太小,所有与当前左指针匹配的组合都不可能满足条件,直接排除。这种“排除不可能解”的思路,才是双指针题解的灵魂。
4. 刷题实战里最容易翻车的四个坑
4.1 边界条件:从“几乎AC”到“完全AC”的差距
我在题霸上刷题时最痛的经历,就是自我感觉代码没问题,一提交就报错,点开看全是边界样例没过。空数组、空字符串、链表只有一个节点、目标值比所有元素都大,这些情况看起来“不正常”,但笔试判题器专门会测。
后来我总结了一个边界检查清单:容器为空时表现如何、容器只有一个元素时表现如何、循环哪一次会访问到下标越界、递归的终止条件是否覆盖了最底层的情况。每次写完代码,先别急着提交,对着这个清单过一遍,AC率能明显提升。
更重要的是,边界条件不是背出来的,是踩出来的。每次因为边界翻车,我都会把那组输入记在题解复盘里。刷到后来,我甚至能预判判题器会给我什么边界输入,这比单纯多看几道题有用得多。
4.2 超时问题:记得把“复杂度预算”写在草稿纸上
笔试平台不会因为你的代码“思路对”就给分,它只认运行时间。很多暴力解法在思路上完全正确,但数据规模一大就超时。所以我现在有个习惯:看完题目先看数据量,然后立刻算复杂度预算。
比如数据规模是n=10^5,那O(n^2)的算法大概要执行10^10次运算,在大多数笔试环境里必然超时;这时候你至少需要一个O(n log n)甚至O(n)的方案。反过来,如果n=100,那O(n^2)甚至O(n^3)都可能只是可接受,不必强行优化到O(n)。
这个习惯在双指针、二分、滑动窗口这几类题上尤其重要。因为它们看着都是“暴力变体”,但复杂度天差地别。整理题解时,我会在每题旁边标注“该题数据量如果翻十倍,这个解法还成立吗”,逼自己把复杂度意识刻进本能。
4.3 输入输出与语言差异的坑
笔试和本地面试的编辑器调试完全不同。很多平台要求你写完整输入解析,还要处理多组测试数据直到EOF;有些时候题目用固定函数签名,根本不需要你管输入输出。这个差异我在第一次机上笔试时狠狠吃过亏:本地调试好好的代码,粘贴进去反而编译失败,原因是多读了一个空白符,或者函数签名没对上。
我的建议是:刷题时熟悉题霸常见的两种模式——ACM模式(自己处理输入输出)和核心代码模式(只写函数体)。以我的经验,两种模式必须在笔试前都练到不用思考。特别是ACM模式下的循环读入,很多人会漏掉“处理到文件末尾”这个条件,导致明明可以AC的题只过了一半用例。
import sys for line in sys.stdin: a, b = map(int, line.split()) print(a + b)还有一个小细节:不同语言对边界行为的容忍度不一样。Python切片越界不会报错,但C++的vector越界是未定义行为;Java的字符串比较用equals而不是==。这些语言层面的坑,最好在平时刷题时就积累下来,别等到笔试现场才用一次报错去学。
4.4 刷题节奏管理的经验
关于刷题节奏,我见过两种极端:一种是觉得自己基础差,非得把题解背得滚瓜烂熟才开始做新题;另一种是疯狂刷新题,一天做十道,做完从不复盘。这两种最后效果都不好。
我的节奏是“3+1”循环:三天集中做同一个专题,每天做三道新题,其中一道是上一轮的错题复刷;第四天专门复习这个专题的题解汇总,不看代码,只口头讲思路。如果某道题你连思路都说不出,它就是你下一轮要复刷的目标。
这个节奏的底层逻辑是间隔重复。新知识刚学完时遗忘最快,48小时内必须接触一次,一周内再接触一次,才能真正进入长期记忆。与其每天赶进度,不如每天都留一点时间给“旧题复盘”。
5. 从题霸题解到面试现场:最后一步转化
5.1 把题目的“记忆点”压缩成一句话
整理了几百道题后我发现,真正能在面试时快速调用出来的,不是完整的代码,而是一句话的“记忆点”。比如“树的最大路径和,本质上是在后续遍历过程中维护一个全局最大值”“带环链表检测,快慢指针一定会相遇,数学上叫Floyd判圈”。
所以题解汇总的最后一列,我都会写一句不超过20个字的记忆点。复习的时候就只看这一列,如果一句话能让我想起整道题的思路,说明这题真正到位了。想不起来,就回去看详细题解。
面试现场最怕的不是题目难,而是你知道做过却想不起思路。有了这种一句话记忆库,无论抽到什么题,我都能先在记忆库里检索一下“这和哪道题类似”,思路的启动会快很多。
5.2 写题解时练习“先说复杂度,再说代码”
很多人面试写算法题时,上来就写代码,写完才被面试官要求分析复杂度。这其实是顺序搞反了。正确的顺序是:先明确数据规模,说清楚你的方案是什么复杂度,为什么这个复杂度可以接受,然后再动手写代码。
我在整理题解时养成了一个习惯:每道题的开头先写复杂度和核心思想,再写代码。这个习惯直接迁移到了面试中。因为复杂度分析不是代码的附属品,它是你选算法方案时的决策依据。你能把复杂度讲清楚,面试官才相信你是真的理解,而不是背题。
5.3 冲刺期的“题霸清单”怎么用
如果距离笔试只剩两周,我不会再推荐刷新题,而是要把之前整理的题解汇总当成冲刺清单用。具体做法是:第一周按专题过一遍所有复盘笔记,一天两个专题,只讲思路不写代码;第二周每天随机挑五道题写完整代码,限定每道题不超过30分钟。
随机挑题很关键,因为笔试时你永远不知道自己会遇到哪个专题。如果总是按专题刷,很容易形成“惯性思维”——一看是链表就用链表套路,却忽视了某些题其实是混合考点。随机抽题能逼你打破这种惯性。
最后一晚,看一遍所有题的记忆点列表就够了。相信我,你之前踩过的坑、写过的复盘、做过的对比,会在笔试时以你意想不到的方式回报你。那种看着题目嘴角上扬的感觉,就是准备到位了的信号。