做算法题的人大概都有过这样的状态:题目刷了不少,但真要问“你从这些题里沉淀下来了什么”,一时半会儿反而答不上来。我有段时间也是这样,每天机械地点开LeetCode(leeccode平台,大家习惯这样拼),写两道,看题解,然后划掉,第二天继续。直到3月19号那天,我照常开始LeetCode刷题,却意外发现自己卡在了一道“看得懂题解、但关了题解就写不出来”的题上,这才意识到之前刷题的方式出了问题。
这篇内容就从3月19日这天的真实刷题过程说起。我不会只贴题解代码,而是把当天的读题思路、选择了哪种解法、为什么这么选、代码写完之后踩了什么坑、以及复盘时总结的刷题方法论,全部摊开来讲。适合正在准备算法面试、打算系统提升LeetCode刷题效率的人参考,尤其是那种“刷了三个月还在原地踏步”的选手,这篇应该能帮上一点忙。
1. 3月19日的刷题记录价值在哪——不只是完成任务
这一天的记录,在我的刷题笔记里卡了一个比较特殊的位置。它既不是我第一次用LeetCode刷题,也不是备考冲刺阶段的突袭,而是处于拆解“为什么刷题效率不高”这个问题的中段。这一天我给自己安排的题量不大,只有三道,但每一道都刻意选择了不同的数据结构和算法方向,目的是为了测试自己现阶段的知识覆盖程度。
1.1 当日刷题清单和考点的选择逻辑
当天选的题目是这样分布的:
| 题号 | 题名 | 涉及考点 | 我预设的解法方向 |
|---|---|---|---|
| 31 | 下一个排列 | 数组、双指针 | 从右向左扫描找规律 |
| 236 | 二叉树的最近公共祖先 | 二叉树、递归 | 递归返回值的语义设计 |
| 215 | 数组中的第K个最大元素 | 堆、快速选择 | 优先队列 / 快速排序变体 |
可能有人看到这三个题会觉得“这不就是高频题嘛,早刷过了”。但我3月19日的目标不是把它们做对,而是要求自己在没有题解提示的前提下,独立写出最优解,并且把每一处边界条件讲清楚。结果证明,这三道题正好暴露了我三个层面上的问题:第一题是“规律推演不够扎实”,第二题是“递归返回值设计思路模糊”,第三题是“只会背模板但说不清复杂度差异”。
1.2 刷题准备状态与当日环境
刷题之前我花了几分钟把笔记软件、代码编辑器都准备好。不夸张地说,工欲善其事必先利其器,刷题时如果编辑器没有自动补全,或者测试用例跑起来不方便,会明显打断思路。我习惯用VS Code配一个Python环境,因为LeetCode刷题的代码量通常不大,Python表达起来最直接,调试迭代也快。这个选择在当天验证特别有用,因为涉及递归和数组操作的题目,Python的列表切片和递归写法能让人把注意力集中到算法本身,而不是被语言细节绊住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “下一个排列”这道题——规律推演比背题解重要得多
2.1 题面在说什么
LeetCode 31“下一个排列”是典型的“题面短、信息密度高”的题。它的意思是:给定一个整数数组,把它看成数字排列在字典序下的位置,要原地修改为下一个更大的排列;如果已经是最大的排列,就重排成最小的排列。我第一次看这个题的时候觉得特别绕,后来用一个生活化的方式理解:一本字典里单词按字母顺序排列,给定一个单词,你要找出“下一页”的单词是什么。例如数组 [1,2,3] 的下一个排列就是 [1,3,2];[3,2,1] 没有更大的排列,所以下一个是 [1,2,3]。
当天我一开始想的是“全排列然后找到当前排列的位置”,这种想法在数据量小的时候没问题,但仔细分析,数组长度如果是 n,全排列是 n! 个,连排序都比较吃力。对于一个面向面试的算法题来说,全排列思路暴露出的是没有掌握“排列之间的顺序是由什么决定的”这个底层规律。
2.2 寻找下一个排列的三个固定步骤
实际上,从右向左扫描是整个题目的核心思路。我把标准解法和自己的推理过程合在一起,整理成了三层:
第一层,从数组右侧开始找第一个“顺序对”的破坏点。所谓顺序对,就是 nums[i] < nums[i+1]。如果从右向左一直没有找到这样的 i,说明整个数组是严格递减的,也就是说这是最大的排列,直接反转整个数组即可。这个部分比较好理解,但我第一次自己写的时候,把 i 和 i+1 的索引搞反了,导致判断方向错误,用测试用例跑了一遍才发现。
第二层,在右侧找到第一个比 nums[i] 大的元素,然后交换。注意这里的“第一个”是从最右边开始找的,因为右侧区域本身是递减的,从右往左第一个大于 nums[i] 的元素,恰好就是“比 nums[i] 大但最小”的那个元素,这正好满足字典序最小差值的要求。
第三层,把 i 右侧的区域反转。这一步是很多刷题的人容易忽略的。交换完成之后,右侧区域仍然是一个递减序列,为了得到“下一个排列”中紧随其后的最小状态,必须把它变成递增序列。如果不反转,得到的结果会跳过一个中间排列,不符合题意。
2.3 实现代码与关键点注释
这里我给一份可以直接跑通的Python实现,关键位置我加了注释:
python复制def nextPermutation(nums):
n = len(nums)
# 第一步:从右向左找第一个升序对
i = n - 2
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
# 如果整个数组递减,直接反转
if i >= 0:
# 第二步:从右向左找第一个大于 nums[i] 的元素
j = n - 1
while j >= 0 and nums[j] <= nums[i]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
# 第三步:反转 i+1 到末尾
left, right = i + 1, n - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
这里要特别强调一下“nums[i] >= nums[i + 1]”和“nums[j] <= nums[i]”中的等号。我第一次写的时候就忽略了等号,处理 [1,5,1] 这种有重复元素的用例时直接出错。原因在于,有重复值时如果只用大于号判断,会跳过相等元素的交换,导致无法生成正确的下一个排列。LeetCode刷题里,边界条件的处理往往就是解题能力的直接体现,这个点我在当天踩坑后记得特别牢。
2.4 这一题带给我的思维方式转变
做完这道题我意识到,以前刷这种数组规律题,总喜欢先看题解,然后照着背。这导致如果题目稍微变一下,比如改成“上一个排列”,我可能又会卡住。实际上,“下一个排列”和“上一个排列”本质是同一个逻辑,只是把查找降序对、升序对的扫描方向反过来而已。如果理解了“字典序排列的顺序是由后缀递减结构决定的”这一核心原理,哪怕题目改成“第 k 个排列”,也不需要从零开始。
3. 二叉树的最近公共祖先——递归返回值设计是这道题的灵魂
3.1 第一直觉为什么不够好
LeetCode 236“二叉树的最近公共祖先”,题面比较清晰:在一棵二叉树里找两个给定节点的最近公共祖先节点。“最近”指的是离这两个节点都最近的那个祖先,可以把它理解为是这两个节点路径分岔之前的最后一个公共节点。
我当天的第一直觉很朴素:分别找到从根节点到两个目标节点的遍历路径,然后从根开始比较这两条路径,最后一个相同的节点就是结果。这个思路本身没错,也不难实现,但问题是需要额外的空间去存储路径。在面试场合,如果要求“只能使用常数额外空间”或者“希望一次递归完成”,这种路径存储方案就不太够看了。
3.2 递归函数到底应该返回什么
我当天掉进的坑是:写递归时总想着“一次递归要直接返回答案”,导致函数签名设计得很拧巴。后来我换了个思路,递归函数的返回值不要定义成“最终答案”,而是定义成“从当前节点往下搜索,能向上一层回报什么信息”。
对于这道题,合理的设计是:递归函数返回“当前子树中是否找到了目标节点之一;如果找到了public ancestor就返回它”。具体来说有几种情况:
如果当前节点为空,返回 None。如果当前节点本身就是 p 或 q,那么当前节点就是这段子树里找到的目标节点,返回当前节点。然后分别看左子树和右子树的返回结果:如果左子树和右子树都返回了非空节点,说明 p 和 q 分别位于当前节点的左右两侧,那当前节点就是最近公共祖先,直接返回当前节点。如果只有一边返回非空,就把这一边的结果往上抛。
3.3 代码实现和边界条件
用代码写出来就是:
python复制def lowestCommonAncestor(root, p, q):
if not root:
return None
if root == p or root == q:
return root
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
if left and right:
return root
return left if left else right
这段代码短,但信息密度很大。当天我第一次直呼“这么简单的递归居然能秒杀”,但等到自己闭卷写,才发现有两个点特别容易漏:
第一,递归出口不能只写“空节点返回 None”,还要写“当前节点等于 p 或 q 时直接返回当前节点”。这个条件省略的话,函数会一直递归下去,无法收敛。第二,左右子树都非空时返回当前节点,这个判断是核心;如果只返回左子树的非空结果,就会丢掉另一侧的信息。
3.4 扩展思考:迭代解法值得掌握吗
当天我在笔记里还写了一行“这里能不能不递归,用迭代实现”,后来我确实尝试了用栈配合父节点哈希表来求解。思路是:先从根节点出发做一次遍历,记录下来每个节点的父节点,然后标记 p 到根节点的全部祖先,再从 q 向上找,遇到的第一个被标记过的节点就是答案。
这个办法的空间复杂度是 O(n),在工程上更直观,但面试时递归解法通常已经足够。我自己的体会是,迭代版本可以作为理解递归返回值的辅助手段,但不必强求背下来。LeetCode刷题的时间和精力有限,应该花在“为什么这么设计”上,而不是把所有解法的代码都背一遍。
4. 第K个最大元素——堆和快选之间的取舍逻辑
4.1 这题“无脑排序”为什么不是最优解
LeetCode 215“数组中的第K个最大元素”,题目很直接。我第一次见这题时,第一反应是排序然后取倒数第 K 个,代码只要一行:
python复制return sorted(nums)[-k]
这个代码能过,时间复杂度是 O(n log n)。但如果在面试里只写这一行,大概率会被追问“你还能优化吗”。这里的优化方向不是炫技,而是理解“我们只需要第 K 大,不需要完整排序所有元素”这个信息论上的冗余。
4.2 基于堆的解法
最容易想到的优化是用最小堆维护当前最大的 K 个元素。具体做法是:遍历数组,维护一个大小为 K 的最小堆,堆顶就是当前第 K 大的元素。当堆的大小超过 K 时,把堆顶弹出。遍历结束后,堆顶就是整个数组的第 K 大元素。
python复制import heapq
def findKthLargest(nums, k):
heap = []
for num in nums:
heapq.heappush(heap, num)
if len(heap) > k:
heapq.heappop(heap)
return heap[0]
这个方案的时间复杂度是 O(n log k),在 k 比较小的时候表现很好。而且它天然适合处理流式数据——如果数据是不断进来的,你不需要一次性拥有整个数组,只要维护一个堆就能持续输出当前第 K 大。这是排序法做不到的。
4.3 快速选择的思路和代价
当天我还研究了一下快速选择(Quick Select)方案。核心思想和快速排序类似:选一个基准值,把数组分成大于基准和小于基准的两部分,然后判断第 K 大落在哪一侧,只递归处理那一侧。平均时间复杂度是 O(n),比堆更优。
python复制def findKthLargest_quick(nums, k):
def partition(left, right):
pivot = nums[right]
i = left
for j in range(left, right):
if nums[j] > pivot:
nums[i], nums[j] = nums[j], nums[i]
i += 1
nums[i], nums[right] = nums[right], nums[i]
return i
left, right = 0, len(nums) - 1
k = k - 1 # 转换为第k大在分区后的索引
while True:
pos = partition(left, right)
if pos == k:
return nums[pos]
elif pos < k:
left = pos + 1
else:
right = pos - 1
快选的优点是平均时间更短,缺点是存在最坏情况 O(n²),比如输入已经有序且每次基准都选得不好。工程上可以用“随机选基准”来降低最坏情况概率。我在3月19日那天对比了这两种做法,把它们写在同一个笔记页上,左侧是堆,右侧是快选,下面标注了各自适合的场景。这个习惯让我在之后面对类似题目时,第一反应不是写代码,而是先判断哪种解法在当前场景下更稳。
4.4 笔试和面试怎么选解法
这是我当天复盘时特意记录的一条:如果是在线笔试,优先写堆解法。它不容易写出边界 bug,代码稳定,一般不会触发最坏情况。如果是现场面试,聊完堆解法之后主动提起快选,并说明它平均 O(n) 的原理和可能退化到 O(n²) 的隐患,会是一个不错的加分点。LeetCode刷题时不仅要写正确解法,还要能说出每一个选择背后的理由,而这恰恰是很多人忽视的部分。
5. 实刷当天踩过的坑——超时、索引错乱和递归深度
这一节是3月19日当天最有“血泪感”的部分。我把实际操作中遇到的三个坑分别复盘一下。
5.1 递归爆栈和“测试用例能过但提交就挂”
我在做二叉树的题目时,一开始写了一个基于层序遍历的解法,想绕开递归。当时的想法是“递归可能会有栈深度问题”,但自己用层序遍历实现时,把节点加入队列后,忘记处理节点为空的判断,结果在树只有一个节点或者空树的边界用例上直接报错。后来我意识到,对于二叉树的最近公共祖先这类题,递归深度和树的高度相关,在 LeetCode 默认的测试数据里完全够用,没必要为了“避免递归”强行改成层序,反而增加了逻辑复杂度。
5.2 快排分区公式里的索引偏移
做第K大元素用快速选择时,我踩了一个低级的索引坑。我的初始写法没有把 k 转成从 0 开始的索引,而是直接用 k 去和 pos 比较,导致结果在某些测试用例里偏移一位。排查过程还算顺利:我先在纸上列了一个长度为 5 的数组,手动模拟了一遍,发现 pos 从 0 开始编号,而第 k 大的 k 是 1 开始的自然语言编号,两者之间差 1。修正后很快就通过了。这个坑虽然不大,但很能说明一个问题:算法题里的“1和0”问题,往往不是语法问题,而是语义问题,需要回到定义里去找答案。
5.3 一个测试用例引发的“超时”排查
那天在下一题时,我因为在一个循环里多次求了长度,比如每次 while 循环都用 len(nums) 去调用一个时间复杂度为 O(n) 的切片操作,导致数据量大时性能下降明显。虽然 LeetCode 的测试用例不都是极端大数据,但这种写法一旦碰上规模大的用例就会超时。我给自己的校规是:循环内尽量不调用可能产生 O(n) 开销的操作,除非有必要,否则先用变量存下来。这种细节不刷题的时候根本不会注意,但刷题刷多了,就会形成一种肌肉记忆式的代码嗅觉。
6. 三道题背后的刷题方法论复盘——怎样才算真正会了一道题
6.1 自测标准:能否独立解释四个问题
3月19日当天刷完三道题之后,我给自己定了一个判断“是否真正会了”的标准,不再以“代码提交通过”为准。这个标准包含四个问题:
第一,不看题解能不能写出核心思路。不是说完全不看,而是看题解之后合上,隔一段时间再写。第二,能不能说出这个解法的复杂度,不仅是大 O 标注,还要理解为什么是这个复杂度。第三,能不能举出一个边界用例。比如下一个排列里该处理有重复数字的数组,最近公共祖先里该处理 p 或 q 就是根节点的情况。第四,能不能把这个思路迁移到另一个变形题。如果这四个问题都能答上来,那才叫掌握了这道题。
6.2 错题归因:把错误分成三类
我在笔记里把当天的错误分成了三类:
第一类是“不会”:完全没有思路。这类错题需要补的是知识盲区,例如之前没接触过单调栈。第二类是“思路对但写错”:通常是边界条件、索引偏移、空值判断的问题。这类错题说明题目已经理解了一半,需要通过大量边界测试来加固。第三类是“写对了但不够好”:比如排序法通过了,但没考虑到更优解法。这类错题是最有提升空间的,因为已经站在正确的起点上,只差往前推一步。
6.3 后续刷题计划怎么调整
3月19日之后,我把刷题策略做了一次调整。以前是每天按顺序刷,今天刷链表,明天刷树,后天刷动态规划,很机械。现在改成了“按题型 + 按错误类型”混合刷:每周选择两个核心考点,题目难度从易到难递进;同时把之前错题本里的题定期拿出来重刷,重点看是否还会犯同类错误。这个方法不一定适合所有人,但对我个人的 LeetCode刷题效率提升很明显。
总体来说,3月19日这天我原本只打算完成三道题,实际收获却远超过“完成三道题”。它让我重新审视了刷题的本质:我们不是为了往题库里添加“已解决”的标记,而是为了在一次次思维碰撞中建立对数据结构和算法的直觉。如果你也在刷题过程中感觉到卡壳,不妨专门挑一天,放慢节奏,记录下自己的错误和思考过程,再对照本文提到的四个自测问题重新评估自己掌握的程度。这样做的效果,会比多刷二十道简单题更扎实。
