1. 新年第一次刷题复盘,我为什么要把坑都记下来
1月2日这天,我给自己立的新年flag正式开跑——每天刷两道题,把数据结构和算法的基础重新打牢。说实话,这个目标定得很朴素,甚至有点老生常谈,但真正动手之后才发现,光有目标是远远不够的,一天下来我踩的坑比预想中多得多。
之前一段时间写业务代码写得多,算法题手感生疏了不少,很多“好像见过但写不出来”的题目开始冒出来。这次我给自己定的规则很简单:不求数量,但求每道题都能弄懂、弄透,并且把过程中遇到的问题原原本本记录在案。当天写的几道题涉及二分查找变体、双指针、动态规划和链表区间操作,都是面试中高频出现的类型,也正是最容易出错的地方。
这篇博文就是把这一整天的刷题过程做一个系统复盘。我会配合实际代码片段,把每道题我最初的错误思路、调试时的排查过程、最终的解决方案全部摊开讲清楚。这不仅仅是给我自己看,也是给同样在算法进阶路上的朋友一个参考——如果你也在刷题过程中遇到过类似的问题,一定要耐心看完,里面很多坑都是经典中的经典,跳过任何一个,下次面试都可能撞上。
对于刚接触算法刷题的新手,这篇文章能帮你避开很多“想当然”的陷阱;对于已经刷了不少题的老手,里面关于边界处理和状态转移的细节,也值得回看一遍。说白了,刷题的真正价值从来不在于“AC”那一瞬间的快感,而在于你从错误中提炼出了什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分类思路:把刷题中遇到的错误重新归堆
2.1 为什么要把错误归类?
刷题刷久了你会发现,错法其实就那么几类。有些题你第一次做错是因为边界条件没处理好,第二次做错还是同一个原因。如果不去刻意归类,每次都在同一个地方摔倒,效率会非常低。
我在1月2日的刷题过程中,把所有遇到的问题分成了四类:
- 边界条件处理错误:数组越界、循环结束条件判断错误、区间开闭把握不准,这类问题在二分查找里尤其常见。
- 指针移动时机错误:双指针题目中左右指针的移动顺序、移动条件搞混,导致结果偏差。
- 状态转移与初始化错误:动态规划题里 dp 数组的含义没有定义清楚,或者在初始化时漏掉了边界状态。
- 数据结构操作疏漏:链表的指针丢失、哈希表的重复更新、字符串拼接性能等底层操作细节。
这个分类方法并不高深,但它帮助我很快建立了“错题档案”的框架。每遇到一个错题,我就想想它属于哪一类,然后在脑子里把这一类错误的所有常见陷阱过一遍,往往能更快定位问题。
2.2 从“不会做”到“不熟练”:刷题的正确心态
很多人刷题会遇到一个心态误区:一道题看了十分钟没有思路,就觉得自己“不会做”,然后直接看题解。这个操作最大的问题在于,看题解时你觉得自己懂了,但关上答案自己再写一遍,依然写不出来,“知识”没有真正变成“技能”。
这次我的做法是,先给自己设定一个思考时限。简单题15分钟,中等题30分钟,难题最多45分钟。在规定时间内没有思路,我会去看题解,但看完之后一定要把代码关掉,自己从头写一遍,并且在写的过程中标记出哪些地方是“卡住”的地方。这些卡住的地方,往往就是这类题型的核心难点,也是我复盘时最需要记录的内容。
用这个方式来刷题,效率其实比“十道题泛做”要高得多。表面上看起来只做了几道题,但每一道题都留下了一个完整的解题路径记录,下次遇到类似题型,就算不能直接写出答案,也至少知道从哪里开始思考。
2.3 复盘记录的方式:一个适合长期使用的模板
好记性不如烂笔头。我用的复盘模板很简单,就用一个 Markdown 文件,每一天的刷题记录追加在后面。每道题的结构大概是这样的:
- 题号和题目名称
- 我的初始思路(哪怕思路是错的,也要记录下来)
- 卡住的关键点在哪里
- 题解的思路和我的思路差距在哪里
- 这类题需要注意的通用陷阱
- 重新默写一遍后的通过状态
这个模板看起来简单,但它帮我解决了一个大问题:遗忘曲线。刷题最大的敌人就是“两周后回头看这道题又不会了”。有了详细的复盘记录,重新复习时能在几分钟内快速回忆起来,而不是从头再想一遍。
3. 具体问题详解一:二分查找的边界条件真的是玄学吗
3.1 问题背景:在排序数组中查找元素的第一个和最后一个位置
这道题要求在一个有序数组中找到目标值的第一个位置和最后一个位置。比如 nums = [5,7,7,8,8,10], target = 8,返回值应该是 [3,4]。题目本身很容易理解,但它考察的是二分查找在边界处理上的功力。
我的第一版代码是这样的:
python复制def searchRange(nums, target):
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
else:
right = mid
if left >= len(nums) or nums[left] != target:
return [-1, -1]
first = left
# 找最后一个位置
left, right = first, len(nums) - 1
while left < right:
mid = (left + right + 1) // 2
if nums[mid] <= target:
left = mid
else:
right = mid - 1
return [first, left]
这个写法其实是“左闭右闭”区间加上二分查找的两个变体:找左边界时用 mid = (left + right) // 2,找右边界时用 mid = (left + right + 1) // 2。区别在于,找左边界时,如果 mid 处的值等于 target,右边界要向 mid 收缩;找右边界时,如果 mid 处的值等于 target,左边界要移动到 mid。这时如果还用 // 2 取中位,在只剩两个元素时就会死循环。
3.2 我最初卡住的原因:把两个二分查找模板搞混
说实话,我第一次写的时候根本没想到要区分 mid 的取整方向。直接用同一个模板去找左右边界,结果就是一个死循环,另一个越界。调试了很久才意识到,二分查找的边界收缩方向决定了 mid 的取法:当收缩方向是 right = mid 时,mid 要向下取整;当收缩方向是 left = mid 时,mid 要向上取整。
这里有一个很常用的记忆技巧:你最终要保留 mid 位置的值,所以 mid 必须朝着“不会被排除掉”的方向取整。找左边界时,我们都希望数字尽量靠左,所以取中位数时要靠左取;找右边界时我们希望数字尽量靠右,所以取中位数时要靠右取。
3.3 边界条件的检查清单
踩过一次坑之后,我给自己整理了一份二分查找检查清单:
- 区间是左闭右闭还是左闭右开?这决定了
right的初始值是len(nums)-1还是len(nums),也决定了退出循环的条件是left < right还是left <= right。 - 收缩边界时,
mid本身是否已经被排除?如果nums[mid] > target,说明mid之后的所有数都不可能是答案,此时right = mid - 1;如果nums[mid] >= target,说明mid可能是答案,此时right = mid。 - 当
left = mid时,必须使用mid = (left + right + 1) // 2(上取整),否则两个元素时会死循环。 - 循环退出后,一定要验证
nums[left]是否等于 target,因为有可能整个数组都不含目标值。
这套清单现在已经成为我写二分查找类题目的肌肉记忆。每次写完二分,我至少会对着清单过一遍,确保没有漏掉任何一个边界情况。
4. 具体问题详解二:双指针的移动时机是滑动窗口的灵魂
4.1 问题背景:无重复字符的最长子串
这道题要求在一个字符串中找到最长的、不含重复字符的子串长度。比如 "abcabcbb" 的结果是 3(子串 "abc")。滑动窗口是这道题的主流解法,核心思路是维护一个窗口,窗口内没有重复字符,右指针每次扩展,左指针在遇到重复字符时收缩。
我最初的代码是用一个 set 来记录窗口内出现过的字符:
python复制def lengthOfLongestSubstring(s):
chars = set()
left = 0
res = 0
for right in range(len(s)):
while s[right] in chars:
chars.remove(s[left])
left += 1
chars.add(s[right])
res = max(res, right - left + 1)
return res
这个写法其实已经能通过所有测试用例了,但我在做题时纠结了很久的一个问题是:while 循环里如果用 chars.remove(s[left]),会不会把重复字符删太快,导致某些字符丢失?比如字符串是 "abca",当右指针走到第二个 a 时,while 循环会不断移除 s[left],直到 s[right] 不在 chars 集合中。这个过程中,left 会从 0 一直移动到 3,窗口内的字符从 ["a","b","c"] 变成 [],然后再把新 a 加进去。
这个逻辑看似合理,但如果窗口内有多个重复字符,比如 "abcbca",右指针走到第三个 b 时,while 循环会先把 a 删掉,再把 b 删掉,最后 left 指向 c。但此时窗口内其实还有一个 c 是重复的?不对,仔细分析可以发现,因为 left 是从左往右逐个删除,所以到删除 c 时,第二个 c 可能还没来得及加进来,这个流程整体是安全的。真正需要注意的反而是 left 的更新要放在 remove 之后,顺序不能反,否则会删错元素。
4.2 用哈希表优化:记录字符最后出现的位置
set 方案的缺点在于,left 需要一步步移动,最坏情况下时间复杂度是 O(n^2)。虽然这道题里 n 是字符串长度,数据量通常不大,但如果我们想要严格的 O(n) 复杂度,可以用哈希表记录每个字符最近一次出现的位置。
python复制def lengthOfLongestSubstring(s):
pos = {}
left = 0
res = 0
for right, ch in enumerate(s):
if ch in pos and pos[ch] >= left:
left = pos[ch] + 1
pos[ch] = right
res = max(res, right - left + 1)
return res
这个版本的代码量更短,思路也更清晰:每次遇到重复字符,直接把左指针跳到上一个相同字符的下一个位置。关键就在于 pos[ch] >= left 这个条件,作用是把窗口之外的旧位置忽略掉。我第一遍写的时候忘了加这个条件,结果出现了一个诡异的 bug:某个字符之前出现过,但已经在窗口外,我却仍然根据它的旧位置更新了左指针,导致窗口大小变小。
这个 bug 的调试过程比较神奇。用例是 "abba",我的代码返回了 2,但正确答案是 2?其实是 2,因为最长无重复子串是 "ab" 或 "ba"。真正出问题的是 "tmmzuxt":正确答案是 5("mzuxt"),但我的代码返回了 4。原因就是走到最后一个 t 时,哈希表里记录了第一个 t 在位置 0,但此时 left 已经在位置 2,此时通过 pos[ch] >= left 判断可以避免误更新。加上这个条件后,代码就正确了。
4.3 双指针题目里的三个经典错误
- 移动左指针时,忘记检查重复字符是否在窗口内,导致窗口被错误压缩。
- 移动右指针后,没有同步更新哈希表或集合,导致后续判断失效。
- 在
while循环中先移动指针再做判断,导致指针越界或漏算窗口长度。
这三个错误几乎覆盖了滑动窗口类的常见 bug。我建议大家在写完这类题目后,特意用两个“边界测试”去验证:一个是目标出现在字符串末尾的用例,比如 "abcd",一个是所有字符相同的用例,比如 "aaaa"。这两个用例能快速暴露大部分实现问题。
5. 具体问题详解三:动态规划的初始化决定生死
5.1 问题背景:最长回文子串
最长回文子串是一道经典的动态规划题目:给定一个字符串,找出其中最长的回文子串。比如 "babad" 的答案是 "bab","cbbd" 的答案是 "bb"。
常见的 DP 做法是定义一个二维布尔数组 dp[i][j],表示 s[i:j+1] 是否是回文串。状态转移方程是:
- 如果
s[i] != s[j],则dp[i][j] = False - 如果
s[i] == s[j],则dp[i][j] = dp[i+1][j-1](当j - i > 1时) - 当
j - i <= 1时,dp[i][j] = (s[i] == s[j])
这个定义本身不难,难的是遍历的顺序。如果按照常规思维从 i 从小到大、j 从大到小遍历,会导致计算 dp[i][j] 时依赖的 dp[i+1][j-1] 还没有被计算出来,逻辑就错了。
5.2 我踩的坑:二维 DP 的遍历顺序
我第一次实现时写了两层循环,外层是 i 从 0 到 n-1,内层是 j 从 i 到 n-1。这样算 dp[0][4] 时,会依赖 dp[1][3],而 dp[1][3] 还没有被计算,因为外层 i 已经是 0 了,不会再回去算 i=1 的行。结果就是很多短回文串没有被正确标记,甚至出现误判。
正确做法是按子串长度从小到大遍历。外层循环变量 length 表示子串长度,从 2 到 n;内层循环变量 i 表示子串起点,j = i + length - 1 表示终点。这样当计算长度为 3 的子串时,长度为 1 的子串已经全部计算完毕,依赖关系就能满足了。
python复制def longestPalindrome(s):
n = len(s)
if n < 2:
return s
dp = [[False] * n for _ in range(n)]
start, max_len = 0, 1
for i in range(n):
dp[i][i] = True
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] != s[j]:
dp[i][j] = False
else:
if length == 2:
dp[i][j] = True
else:
dp[i][j] = dp[i + 1][j - 1]
if dp[i][j] and length > max_len:
start, max_len = i, length
return s[start:start + max_len]
5.3 从 DP 到中心扩展:另一个方向的思考
写完了 DP 版本后,我其实又在思考一个问题:这道题还有另一种做法——中心扩展法。枚举所有可能的回文中心(包括单字符中心和双字符中心),然后向两边扩展。时间复杂度同样是 O(n^2),但空间复杂度从 O(n^2) 降到了 O(1)。
这里我想强调的是,不要只满足于写出一种解法。每次刷题时多想一想其他方法,可以加深对题目本质的理解。比如最长回文子串,DP 的重点在于“子问题划分”,中心扩展的重点在于“枚举所有对称中心”。两者其实是同一个问题的不同切面,理解了这两个切面,后面遇到「回文子序列」这类问题时会更加游刃有余。
6. 具体问题详解四:链表题中的指针丢失问题
6.1 问题背景:反转链表 II
反转链表 II 这道题要求反转链表中从位置 left 到 right 的节点。我在写这道题时,频繁碰到“链表成环”的情况。比如链表是 1 -> 2 -> 3 -> 4 -> 5,要求反转第 2 到第 4 个节点,正确结果是 1 -> 4 -> 3 -> 2 -> 5。但我的代码一旦写错,就可能把原链表变成 1 -> 2 -> 3 -> 2 -> 3 -> ... 这样的循环结构,导致程序超时。
链表问题的核心就是处理 next 指针的指向。迭代反转区间链表时,需要用到三个指针:prev、curr、next。每一步做的事情就是先把 next 指针暂存,再把当前节点的 next 指向 prev,最后三个指针同时后移。
我第一次写类似代码时经常忘了把 next 暂存,直接执行 curr.next = prev,导致原来的下一个节点丢失。这也是链表操作中最经典的错误:修改指针前一定要先备份后续节点。
6.2 组合技巧:虚拟头节点 + 局部反转
对于反转区间这种题,加一个虚拟头节点(dummy head)可以大幅简化边界处理。如果不加虚拟头节点,当 left = 1 时,需要单独改变头节点的指向,逻辑会变得比较复杂。加上虚拟头节点之后,无论 left 是几,都可以用统一的方式处理。
python复制def reverseBetween(head, left, right):
dummy = ListNode(0, head)
prev = dummy
for _ in range(left - 1):
prev = prev.next
curr = prev.next
for _ in range(right - left):
next_node = curr.next
curr.next = next_node.next
next_node.next = prev.next
prev.next = next_node
return dummy.next
这个写法是比较经典的头插法:每次把 curr 后面的节点移动到 prev 之后,重复 right - left 次,就完成了区间反转。这个方法比“先切出来再反转再接回去”更简洁,也不容易出错。关键就在于 curr.next 在每次循环里都是指向当前未反转部分的第一个节点,而 prev.next 始终指向已反转部分的新头节点。
6.3 链表题中通用的小技巧
- 凡是涉及头节点可能被修改的题目,建议都加一个虚拟头节点,极大降低边界判断的难度。
- 画图是理解链表操作最有效的手段。在纸上画出每一步的指针变化,比盯着代码空想要清晰得多。
- 写完代码后,用一个长度为 3 到 5 的小链表手动跑一遍流程,确认指针没有丢失、链表没有成环。
- 如果程序超时,大概率是出现了循环链表。此时可以在代码里加入一个循环计数器,快速定位到成环的位置。
7. 常见问题与排查技巧实录
7.1 当天遇到的问题速查表
下面这个表是我在 1 月 2 日当天刷题过程中遇到的实际问题汇总。把问题类型、具体表现和解决方案列在一起,方便以后快速查阅。
| 问题类型 | 具体表现 | 原因分析 | 解决方案 |
|---|---|---|---|
| 二分查找死循环 | 程序卡住不退出,或返回错误索引 | 用 left = mid 时 mid 没有上取整 |
记忆技巧:要保留 mid 时,mid 就朝保留方向取整 |
| 滑动窗口窗口收缩错误 | 返回的子串长度偏小 | 哈希表中旧位置被误用 | 增加 pos[ch] >= left 条件 |
| 动态规划结果错误 | 回文子串漏判或误判 | 遍历顺序没有按长度递增 | 外层循环控制子串长度,内层循环控制起点 |
| 链表成环 | 程序超时或内存溢出 | 修改 next 前没有备份后驱节点 | 先暂存 next_node,再修改指针 |
| 数组越界 | 下标报错或返回错误值 | 循环条件与区间定义不一致 | 统一使用左闭右闭区间,并核对退出条件 |
这张表的价值在于,下次再遇到类似报错时,我可以直接对照原因去排查,而不是从零开始猜。刷题这件事,最忌讳的就是每次都在一个坑里反复跌倒。
7.2 三个能显著提升刷题效率的调试习惯
我当天刷题过程中逐渐养成了三个习惯,想重点分享给大家。
第一个习惯是“小样例先行”。写完代码后,先不要急着提交,而是在本地跑几个高度精简的用例,像是长度为 1 的数组、全部相同元素、目标值在最开头或最末尾。这些小用例跑通了,再提交到平台上跑完整测试集,能大幅减少失败的次数。
第二个习惯是“打印关键变量”。调试时不要只打印最终结果,还要在关键节点打印中间变量,比如循环里的 left、right、mid,或者 DP 数组的某一层。很多时候问题不是“结果错了”,而是“过程错了”,打印中间变量能看到过程的哪一步先出现了偏差。
第三个习惯是“错题重写”。当天做错的题,我不会只修改一行代码然后提交通过就完事,而是会等到第二天或者隔一段时间后,再把这道题重新做一遍。如果第二次能够独立写出正确解法,才算真正掌握。这个习惯虽然耗费时间,但效果是非常显著的。
7.3 关于复杂度分析的一个提醒
刷题时还有一个经常被忽略的点,就是在动手编码前先估算时间复杂度和空间复杂度。很多初学者直接跳过这一步,上来就写代码,结果写完才发现用了 O(n^2) 的解法,而题目要求 O(n log n),只能推倒重来。
我当天的几道题里,二分查找和滑动窗口都是 O(n) 或 O(log n) 级别,而 DP 是 O(n^2)。如果不是提前预估,很容易写出一版“能跑但对大数据规模无能为力”的代码。这一点在面试中尤其重要,面试官不仅看你写不写得出来,还会问“这个解法的时间复杂度是多少”“能不能优化”。
8. 后续刷题计划与扩展思考
经过 1 月 2 日一整天的实战,我对自己的算法水平有了更清醒的认识。之前总觉得“差不多都会”,真正限定时间写起来才发现,很多细节经不起推敲。所以我把 1 月的刷题计划做了调整,不再盲目追求题目数量,而是按专题分类,一个一个攻克。
接下来计划依次把二分查找、双指针、动态规划和链表这四个方向进行专项训练。每一个专题至少刷 10 道题,每道题都要求写出两种以上的解法,并且在一周后重新复习一遍。这样做虽然进度会慢一些,但每过一关都过得比较扎实。
我也在考虑把刷题笔记整理成一个开源仓库,把自己遇到的每一道题、每一个坑、每一种解法的思路变化都记录下来。如果你也在走这条路,欢迎一起交流和补充,毕竟一个人刷题容易陷入思维盲区,多看看别人的复盘和记录,往往能发现不一样的思路。
最后再分享一个小技巧:每次刷完题,不管有没有成功 AC,都在当天记录一句话总结。这句话不需要多精辟,只要能够在你两周后回看时,一眼就想起这道题的核心难点是什么。做到了这一点,你的刷题记录就不再只是一堆代码的堆砌,而是一份真正能帮你成长的资产。
