很多人在算法训练上卡了很久,题没少刷,但一到自己上手写,不是超时就是边界报错,甚至样例能过、一提交就全红。其实算法题“老是出错”这件事,绝大多数情况不是智商问题,也不是刷题量不够,而是缺少一套稳定的做题动作。今天这篇就专门聊聊怎么把算法写题的错误率压下来,把“总是差一点”变成“一遍能过”。
这内容不是某个比赛的速成技巧,而是我自己从频繁WA、反复debug到能在限时训练里稳定AC,踩了无数坑之后总结出来的方法。适合正在准备机试、找工作笔试,或者单纯想提升算法功底的人。无论你是刚刷了五十题,还是已经刷了五百题,只要还在被“低级错误”困扰,这篇都有参考价值。
1. 先搞清楚:你到底是“粗心”还是“不会”
不少人把错误归咎为粗心,但在我看,绝大多数所谓“粗心”其实是有固定模式的。如果不去拆解,你会一直栽在同一个地方,而且每次都会觉得“这次只是不小心”。
1.1 别急着甩锅给“粗心”,先给错误分个类
我通常是这么划分的,大家可以对号入座:
| 错误类型 | 典型表现 | 真实原因 |
|---|---|---|
| 知识盲区型 | 题目考点根本不熟,比如树状数组维护区间最值不会 | 没学过或没理解透 |
| 理解偏差型 | 题目意思读懂了一半,差了条件 | 读题习惯不好 |
| 边界疏忽型 | 数组越界、循环多跑一次、空集合没考虑 | 缺少边界分析流程 |
| 编码手误型 | 变量名写错、少了个括号、复制粘贴忘改参数 | 写码过程太随意 |
| 状态崩溃型 | 越急越乱,改一个bug引出新bug | 没有结构化调试方法 |
有个很扎心的现象:大多数人是“边界疏忽型”和“编码手误型”混着来,然后统一解释成“粗心”。但你仔细复盘会发现,这些错误完全可以在提交前用一套固定动作拦截下来。
我做错题统计的时候发现,自己的错误里大概只有两成是真正的“不会”,剩下八成都是可以预防的流程问题。想通这一点之后,我就不再跟自己说“下次细心点”,因为“细心”是一个无法执行的口号。你得把它拆成具体的检查步骤。
1.2 我观察到的三类高频“翻车现场”
第一类,样例能过但提交全WA。这通常是你的解法只覆盖了题目给的示例路径,并没有覆盖真实数据集的全部情形。比如题目要求输出字典序最小的答案,你直接回溯没排序;再比如图可能不连通,你默认从单点出发能遍历全图。
第二类,边界样例一碰就挂。最常见的就是整数数组长度为0或1的情况、n等于最大值的情况、以及结果需要取模你却在最后一步才取模导致中间溢出。我还见过有人用int存两个10^9相加,直接溢出成负数,WA到怀疑人生。
第三类,编译和运行时细节反复出问题。变量重名、函数返回类型对不上、vector越界、死循环没跳出。这类错误在本地IDE里可能不会立刻暴露,但提交到评测系统后就很致命,尤其是递归深度过大导致的栈溢出。
你只有先知道自己属于哪一类,后面纠偏才能有的放矢。我的建议是,未来两周每次提交失败后,先别急着改代码,而是记录错误类型。等积累了20条左右再回头看,你的问题分布会清晰到让你惊讶。
2. 建立一套能落地的“防错流程”
我发现很多人的刷题节奏是:看完题就开始敲代码,敲完跑一下样例,能过就交。这流程不能说完全错,但它把所有风险都留到了提交之后。你缺的不是刷题量,而是每一次做题时的动作一致性。
2.1 动笔前先花三分钟分析约束和边界
很多边界问题,其实在写第一行代码之前就能被消灭。你需要做的是扫描三个东西:输入范围、时间限制、特殊取值。
拿输入范围举例。如果题目说n <= 10^5,那么O(n^2)的解法基本危险,O(n log n)以上才稳。如果数值范围是10^9级别的,int大概率不够用,你要明确用long long。每次我看完题,都会顺手写一下“数据范围要点”在草稿纸上,哪怕题目再简单也写。这会强制你在写代码之前建立边界意识。
还有一类边界是逻辑上的:数组是否能为空、图是否连通、是否存在多条最短路径、是否有重复元素。这些不是靠编码技巧能兜住的,而是靠读题时划关键词。我的习惯是:把题干里“非空”“不同”“任意顺序”“升序”“至少一个”这些限定词全部圈出来,写代码时对着它们逐条确认。
2.2 写码时把“裸写”改成“三步走”
直接上手敲完整代码是出错率最高的写法,尤其在紧张状态下。我那段时间逼自己必须走三步,后来实际用下来,首遍正确率有很明显的回升。
第一步,把主体思路用三到五行伪代码写出来。不是要你写论文级别的伪代码,只要写出核心循环和关键判断就行。比如“遍历数组,维护当前最小值,遇到更大的就更新答案”,这比直接一上来就双指针要稳得多。
第二步,确定数据结构与边界变量。数组开多大、指针怎么移动、循环终止条件是什么、递归的base case是什么——这些在第二步里全部敲定。
第三步,才开始真正写代码。这时候你的脑子里已经有了一条完整的执行链,编码手残的概率会下降很多。
这样做还有一个好处:当你写不下去或者出错时,你手里有伪代码和结构化思路可以回溯,而不是对着一堆散装代码发呆。
2.3 提交前留五分钟做“静态审查”
我要分享一个成本极低但收益巨大的习惯:代码写完不急着交,先做一遍静态审查。所谓静态审查就是不看运行结果,只靠读代码来找错。
我的审查清单一般长这样:
- 是否有变量拼写不一致,比如index和idx混用
- 循环退出条件是严格小于还是小于等于,边界会不会死循环
- 递归有没有出口,深度是否会爆栈
- 数组/容器下标是否会出现负数或越界
- 整数运算是否有溢出风险
- 是否所有分支都有返回值
- 是否忘记处理输入为空或长度为1
每道题提交前对着清单过一遍,大概两分钟。就是这么简单的动作,可以拦下一大半本想当然提交后才发现的问题。
3. 调试不是看代码,是把错误假设揪出来
哪怕前面几步都做了,依然会有没考虑到的场景,这就需要高效调试。我看到很多人调试的方式是盯着代码反复看,或者随机加printf然后继续盯着屏幕。这种方式的效率太低。真正的调试应该像做排除法,先锁定可疑区间,再逐步缩小范围。
3.1 先构造能复现的最小数据,再动手改
代码出错了,第一件事不是改逻辑,而是复现问题。你需要在本地造出能够稳定触发bug的输入。拿排序算法举例,当你发现某个序列的结果不对,你可以尝试把它缩小到数组长度只有三到五个元素的最小用例。一个无法稳定复现的错误,你就算改了也验证不了是否真的被修复。
很多时候,当你开始构造最小复现用例,你会发现根本不需要继续调试了。因为构造用例的过程会倒逼你把逻辑重新走一遍,很多问题在写用例的过程中已经暴露出来。这也是为什么“向别人提问前先把问题缩小”这个建议那么有用。
3.2 用“打印关键变量”做二分定位
如果完全看不出问题,我的下一个动作是在怀疑的位置前后加打印输出,注意要打印的是状态和变量,不是简单打印一句“我进来了”。
举个例子,你在做二分查找时发现结果偏大。你可以把每次循环的left、right、mid和比较结果都打出来,然后重点看第一次出现与预期不符的位置是哪一次更新。这个位置往往就是问题中心,因为一个错误变量更新会一路带偏后续结果。
更高效的做法是“二分定位法”:把代码分成前一半和后一半,先在前一半末尾打印中间状态,如果状态已经错了,说明问题出在前半段;如果状态是对的,那问题就在后半段。这样一轮就能砍掉一半搜索范围,比满屏printf瞎找要快得多。
3.3 有些“灵异bug”其实是基础细节
我早期调试最痛苦的一类问题是:代码逻辑看起来完全正确,但输出就是不对。排查了很久,最后发现是以下原因之一:
- 函数里用了全局变量但不小心在内部重名覆盖了
- 多次调用同一条排序语句,第二次结果和第一次不一样,因为原数组已被改变
- 使用迭代器或引用时,在循环中修改了容器导致失效
- 优先队列默认是大顶堆,但你想要小顶堆结果忘记改比较器
这些bug之所以被称作“灵异”,是因为它们出现得毫无逻辑。但解决办法只有一个:不要迷信代码的“看起来正确”,要相信数据输出。把关键对象的地址、大小、当前内容全部打印出来,你会发现真相往往比你想象的朴素。
4. 建立“错题本”和“误区速查表”,让同一个错只犯一次
不少人刷题是刷完就忘,错误记录在评测记录里落灰。过两周遇到同类题,稍微变形一下又错。这就是典型的“有题量、无沉淀”。我现在坚持认为:真正拉开差距的不是刷了多少题,而是从每道错题里提取出了什么规则。
4.1 错题本怎么记才有效
我发现只有记录“错误因果链”才有价值,只写“这题用动态规划”毫无意义。什么叫错误因果链?就是你写清楚“我本来是怎么想的、为什么错、正确思路是什么、以后遇到哪类特征要触发什么检查”。
我自己的错题本结构是四行:
- 题目特征:这题最关键的条件或识别信号
- 我的错误:不是答案错,而是哪一步思维出了偏差
- 根因分析:是边界没考虑、复杂度没算还是某个套路没识别
- 预防规则:一条可以在其他题里复用的检查项
举个例子。有一次我写最短路径的题,用了Dijkstra,却忘记了图中存在负权边。题目已经暗示“权重可以为负”,但我默认所有最短路题都能用Dijkstra。错题本里我便新增了一条规则:只要题目没有明确“非负权”,写最短路前先检查是否存在负权边,存在就换Bellman-Ford或SPFA。这类规则积累得越多,你后续做题就越稳。
4.2 我的“首版通过率”提升法
这里我说一个自己一直在用、也分享给过不少朋友的方法:为高频错误建立“提交前速查表”。这个表不是网上随便找的通用checklist,而是从你自己错题本里提炼出来的个人高频错误名单。
我的个人速查表大概长这样:
- 涉及数组下标的算法,先检查
i和i-1在边界位置会不会错位 - 涉及区间合并,先确认区间是按左端点还是右端点排序
- 涉及DFS回溯,确认每次递归结束后状态是否恢复
- 涉及除法,检查除数为0的可能性
- 涉及取模运算,确认负数取模的预期是否符合题目定义
- 涉及字符串,检查空串和大小写差异
每次提交前,把这份表扫一遍。你会发现虽然多花了两分钟,但提交次数会显著减少。我个人的数据是:用了这个流程之后,前几周的提交出错率降了大概一半,很多以前要提交三次以上才过的题,现在基本一遍能过。
4.3 同样的类型题要集中训练
还有一个很管用的策略:不要今天写一道二分、明天写一道贪心、后天写一棵线段树。这样练完水平提升很慢,因为你的大脑还没形成类型化的识别模式,就被下一类问题打断了。
我建议按主题做集中训练,比如连续几天只做滑动窗口,或者只做DFS相关。这样做的好处是:你能在短期内大量重复同类边界条件和处理技巧。等到之后再遇到窗口类、搜索类问题,你会快速反应出“这类题有几个常见的坑”,而不是每次从零探索。
5. 实战复盘:一道题从“反复出错”到“一遍过”
光讲方法论不够,我拿一道经典题型走一遍完整的纠错流程。这道题本身不难,但它很典型,能串联起前面提到的所有环节。
5.1 我们来看最经典的“二分查找插入位置”
题目:给定一个升序数组和一个目标值,找到目标值的插入位置,使得插入后仍然有序。
很多人第一次写就挂在这了。他们的逻辑大概是这样的:
int searchInsert(int* nums, int numsSize, int target) { int left = 0, right = numsSize - 1; while (left < right) { int mid = (left + right) / 2; if (nums[mid] < target) { left = mid + 1; } else { right = mid; } } return left; }这段代码在绝大多数情况下没问题,但有一个特殊场景直接挂了:当target比数组里所有元素都大时,最后left会停在numsSize - 1,但它应该插入的位置其实是numsSize。这就是典型的“循环结束后没有处理边界场景”。
如果按照前面的流程,写完后我做静态审查时就会想到:数组末端的下一个位置是否在逻辑范围内?然后我会在循环后补上最终判断:
if (nums[left] < target) return left + 1; return left;或者更稳健的写法是把初始区间设成[0, numsSize],让right从numsSize开始:
int left = 0, right = numsSize; while (left < right) { int mid = (left + right) / 2; if (nums[mid] < target) { left = mid + 1; } else { right = mid; } } return left;这只是个小例子,但规则是通用的:你必须有意识地在写二分时把搜索区间是否包含末端、退出时指针落在哪一步、目标值全部小于或全部大于两种极端情形单独想一遍。
5.2 同样是二分,边界条件为什么总绕晕
很多初学者分不清while(left < right)和while(left <= right),也不理解什么时候right = mid、什么时候right = mid - 1。这背后的核心问题就是你对“不变量”的理解不够清晰。
我常用的方法是始终维护一个明确的区间定义。比如我自己喜欢用左闭右开区间[left, right),这样循环条件永远是left < right,收缩时要么是left = mid + 1要么是right = mid,逻辑统一,很少出错。如果你习惯闭区间[left, right],那条件就是left <= right,收缩时要注意mid - 1和mid + 1,避免死循环。
我不建议同时混合使用两套区间规则。选定一套后,每个二分题都用同一套推导,这能大幅降低边界错误。很多人调不明白边界,是因为每次写的区间定义都不统一,脑子里的模型一直在来回切换。
5.3 试试给错误“记账”
每改完一次bug,顺手在题目标题旁边写下“第几次提交失败、失败原因是什么”。这个动作看起来微乎其微,但它能极大强化你的记忆。我自己记录的时候发现,很多错误纯粹是“惯性”导致的,比如以前写某个模板习惯了某种写法,遇到变体题不假思索就套上去了。一旦把这些惯性点写下来,下次套模板前就会多问自己一句:这题的场景还适用吗?
记账不需要花哨工具,本地一个文本或者表格就够了。关键是记账的频率和复盘频率要对等。
6. 心态与训练节奏:稳定输出比爆发更重要
最后想聊几个可能被忽略但很影响结果的东西。我把它们称作“非技术因素”,因为我在实际训练后发现,算法能力不止是逻辑能力,也是心态和节奏管理能力。
6.1 允许自己犯错,但同一个错只能犯一次
很多人出一次错后就变得畏手畏尾,写每道题都怀疑自己哪里会漏。这种心态会让你的代码越来越“变形”,不敢用简洁的逻辑,反而堆了一堆防御性判断。
我想说的是:允许自己犯错,但不能允许自己在同一个坑里反复摔。事实上,建立错题本和速查表的意义就在于此,它把“犯错”从被动接受变成了主动管理。当你发现同类错误最多出现两次之后就会被速查表拦截,你会对刷题建立起一种“可控感”。
6.2 稳定的复盘频率比刷题量更重要
我见过有人一天刷十几道题,但从不复盘,一个月下来常见错误种类还是那几个。我自己更倾向于每天认真刷两三道题,然后把一道题做深。所谓做深,不光是AC,还包括想一想暴力解法为什么不行、有没有更简洁的写法、这个套路还能迁移到哪里。
一周里我也建议至少留出半天时间不做新题,只做错题重写和错题本整理。这个习惯能让你积累的每一条经验都真正“长”在脑子里面。
6.3 若你被一套题卡住太久,先放下它
还有一个反直觉但很管用的技巧:如果一道题你在三十分钟内没有进展,也不是因为知识盲区,那很可能是因为状态不好或思路框住了。这时候继续硬啃只会消耗信心,还容易让你在后面的练习里烦躁、乱写。我一般的选择是放下它,标记为“回头再看”,去做两到三道简单题找回手感,然后过几天再回来重新面对这道题。
很多次我发现,重新回来后,第一次死活想不通的点,几分钟就想明白了。原因在于你的大脑在后台已经做了一些整合,那种“卡住”的状态往往会自然解除。
话说回来,改掉算法写题出错的老毛病,不是一个“下次细心点”就能解决的问题,而是一条可以系统化的流程。从出错的分类、写码的动作、提交前的审查,再到错题本的积累,每一步都能让你离“一遍过”更近一点。我个人坚持这套方法大概两三个月后,最大的变化不是某一次比赛的排名,而是日常训练里那种焦躁感消失了。你知道自己有方法兜底,知道任何错误都能被找到并转成预防规则,这种踏实感才是坚持长期训练最核心的支撑。