2026年1月19日,周一,晚上九点半,我照例打开那个在线判题平台,准备刷一组算法题。了解我的人都知道,我给自己定的规矩是:每周至少要有一到两次完整的算法练习,每次三道题左右,不许偷懒。这一天选的题有点代表性——一道动态规划、一道二分查找变种、一道树的递归。做完之后我花了半小时复盘,觉得这三道题放在一起特别能说明问题:它们看似没有任何关联,实际上都在考同一个东西——边界条件。
这篇文章就是当天练习的记录。不是讲那种“今天我AC了三道题”的炫技流水账,而是把这三种题型的完整思考过程展开讲讲:为什么这样写状态转移,为什么这个二分的收缩方向不能写反,为什么递归里需要维护一个全局变量。包括当时在纸上推演的过程、试错的过程、踩过的坑。如果你也在保持刷题的习惯,或者正准备开始系统练算法,这篇文章应该能帮你少走一些弯路。
1. 当天的选题思路与整体的练习节奏
1.1 为什么要把“算法练习”当成固定日程来对待
我以前也有一阵子刷题刷得很猛,后来工作中一忙就搁置了。问题是,算法手感这东西真的会退化。几个星期不碰,再看一道简单的贪心都要想半天;三个月不碰,二分查找的判断条件都得重新推。所以从去年年底开始,我恢复了一个习惯:每周固定一两个晚上,专门做“三题训练”。
1月19号这次练习安排在周一,是因为周六周日都在赶项目,脑子处于“被业务需求塞满”的状态,周一晚上反而是相对清醒的时候。我个人建议,如果你刚开始保持练习频率,尽量选在自己精力最好的时段,而不是临睡前硬刷。状态差的时候做算法题,错误率高不说,还容易产生挫败感。
1.2 三道题目的选取逻辑:动态规划 / 二分搜索 / 树的递归
当天的选题是刻意安排的。有一类常见的误区是:刷题的时候跟着热门题单走,一会儿做哈希表,一会儿做回溯算法,一会儿又跳到图论,结果是每种类型都只会最基础的模板,遇到变种就发懵。我的做法是:每次练习尽量覆盖两到三个不同的算法大类,并且选“经典中的经典”来深化。
我选的这三道,分别是:
- 最大连续子数组和(动态规划)
- 搜索旋转排序数组中的最小值(二分查找)
- 二叉树的最大路径和(树的递归 + 全局最优解维护)
为什么选这三道?因为它们的共同特点是:最优解不难背,但想真正理解为什么这么写,需要动点脑筋。很多人在面试或考试中遇到它们,往往是看过答案或者背过模板,知其然不知其所以然。而这三道题恰好是检验你有没有吃透边界条件、有没有建立起正确的状态定义、有没有理解递归返回值语义的试金石。
1.3 练习环境的准备和耗时控制
我用的环境很简单:一台普通笔记本,装的是 Python 3.10,外加一个趁手的文本编辑器。有人喜欢直接在浏览器里写,但我习惯先在本地编辑器里写一遍,跑通几个自测用例之后,再贴到判题系统里。这样做的好处是:可以在本地多打印一些调试信息,观察中间过程,而判题系统通常只看结果,不方便排查。
整个练习过程大概两个小时。我在动手之前给自己定了一个“20分钟原则”:一道题如果超过20分钟没有完整思路,就允许自己看提示,但看完提示之后必须自己独立把代码写出来,不许顺着题解直接敲。这道题到现在还是我衡量自己算法水平的一把尺子——不靠记忆写出来,才是真会了。
2. 第一道题:最大连续子数组和(动态规划)
2.1 题目描述与关键条件
先看题目描述。给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。简单说就是:在一个可能有负数、可能有正数的数组里,找一段连续的区间,让这段区间的和最大。
这里有个容易忽略的字眼——“连续”。它直接决定了这道题不能用排序、不能用滑动窗口的常规思路(因为窗口内的元素没有单调性),不能用“先求前缀和再两重循环”那种 O(n²) 的暴力做法。我被问过很多次,能不能用滑动窗口?不能。因为滑动窗口要求窗口扩大时结果单调变化(比如找最短覆盖子串),而这题里加入一个负数,答案可能变小也可能变大,窗口收缩的逻辑不成立。
2.2 从暴力枚举到状态定义:为什么用DP
这道题最原始的解法是枚举所有可能的左端点 i 和右端点 j,计算 sum(i, j),记录最大值。三层循环(枚举 i、枚举 j、求和)是 O(n³),优化一下前缀和可以到 O(n²)。但数据范围一旦到 10^5 以上,O(n²) 就会超时。
这时候就要想,能不能用一次遍历解决?一维的,从左往右扫,每个位置我们都想知道:以当前位置结尾的子数组,最大和是多少。
这就是动态规划里最常见的状态定义方式:dp[i] 表示以 nums[i] 结尾的连续子数组的最大和。
为什么定义成“以 nums[i] 结尾”而不是“nums[0..i] 范围内的最大子数组和”?因为连续子数组的要求。如果定义成“前 i 个元素中任意的最大和”,那就跟“以 i 结尾”不是一回事了,后面转移会乱套。这是初学者最常犯的一个错误——状态定义得太粗,结果无法递推。
2.3 状态转移方程的推导与代码实现
有了状态定义,转移方程就水到渠成了。dp[i] 的取值只有两种情况:
- 把 nums[i] 接到以 nums[i-1] 结尾的子数组后面,也就是 dp[i-1] + nums[i]
- 自己单独成为一个子数组,也就是 nums[i]
取两者中较大的那个:
dp[i] = max(dp[i - 1] + nums[i], nums[i])为什么没有第三种情况,比如从 i 之前的某个位置开始接过来但不包含 i-1?因为“以 i 结尾”这个限制条件已经把中间的缝隙堵死了——如果上一个元素 i-1 不在子数组里,而 i 又在子数组里,那这个子数组就不是连续的,不满足定义。所以要么接上 dp[i-1],要么从自己开始。
最终答案是所有 dp[i] 中的最大值,不是 dp[n-1]。这是另一个容易踩的坑。dp[n-1] 只是“以最后一个元素结尾的最大和”,但最大和的子数组不一定包含最后一个元素。
代码写出来非常短:
def max_subarray_sum(nums): if not nums: return 0 dp = nums[0] max_sum = nums[0] for i in range(1, len(nums)): dp = max(dp + nums[i], nums[i]) max_sum = max(max_sum, dp) return max_sum我把 dp 数组优化成了一个变量,因为每一轮的 dp[i] 只依赖 dp[i-1],没必要把整个数组存下来。这种“滚动变量”的技巧在动态规划里非常常见,空间复杂度从 O(n) 降到 O(1)。
2.4 边界条件与常见错误
我自己在练习中专门列了一个边界用例清单,这道题尤其要测这四种情况:
- 全部是负数:比如 [-3, -5, -1],正确答案是 -1
- 只有一个元素:比如 [7],答案是 7
- 全是正数:比如 [1, 2, 3],答案是 6
- 正负交替且包含零:比如 [2, -1, 2, 0, 3],答案是 6(整个数组)
全部负数的情况下,最容易出错的是把初始 max_sum 设成 0。如果数组全是负的,max_sum 初始为 0 会导致答案始终是 0,而不是最小的那个负数。正确处理是初始化为 nums[0] 或者负无穷。
我之前就出过这个错,当时写的代码是max_sum = 0,跑 [ -1, -2 ] 这种用例直接返回 0,一看答案就知道不对。后来我总结了一个口诀:凡是求最值的初值,要么取第一个元素,要么取负无穷/正无穷,不要想当然用 0。
3. 第二道题:搜索旋转排序数组中的最小值(二分查找)
3.1 题目描述与考点分析
第二道题是这样:一个原本按升序排列的数组,在某个未知点进行了旋转。比如 [0, 1, 2, 3, 4, 5] 旋转之后可能变成 [3, 4, 5, 0, 1, 2]。现在要在数组中找到最小的元素。假设所有元素互不相同。
这道题的考点不是“能不能找到”,因为线性遍历 O(n) 肯定能找到,考的是能不能用 O(log n) 完成,也就是说:能不能看出这个数组虽然整体不是单调的,但它的结构仍然支持二分。
先观察旋转数组的性质:它由两段递增序列组成。左侧的递增序列整体大于右侧的递增序列(比如 [3,4,5] 和 [0,1,2],3 到 2 之间有断层)。最小值恰好在断层的右端点,也就是第二段递增序列的第一个元素。
3.2 为什么可以用二分:两段递增序列的转折点
二分的核心前提是“区间具有可排挤的性质”。在普通升序数组里,中位数 m 和右端点 r 的关系是稳定的:m < r 永远成立。但在旋转数组里,这个关系变成:
- 如果 m > r,说明从 m 到 r 之间跨越了断层,最小值一定在 (m, r] 之间,左半部分可以丢弃
- 如果 m < r,说明从 m 到 r 是连续递增的,最小值在 [l, m] 之间(包括 m),右半部分可以丢弃
很多文章会说“比较 nums[mid] 和 nums[right]”,但没解释为什么跟 right 比,而不是跟 left 比。关键在于:right 一定在“第二段”或者“第一段的末尾之前”,而 left 可能在第一段也可能在第二段(比如数组没有发生旋转时,left 一开始就在第一段)。比较 mid 和 right,能确定断层在 mid 的哪一侧;比较 mid 和 left,信息不够,因为你无法判断 mid 到底在第一段还是第二段。
3.3 区间的收缩逻辑与循环不变量
这里要注意一个细节:当 nums[mid] > nums[right] 时,断点在右侧,所以left = mid + 1是安全的,因为 mid 的位置不可能是最小值(它比 right 大,而 right 这一侧一定有比它小的数)。当 nums[mid] < nums[right] 时,断点在左侧或 mid 就是最小值,所以right = mid,不能写成right = mid - 1,否则可能把正确答案跳过去。
循环条件用while left < right,这样结束后 left 和 right 重合的位置就是最小值。这个写法的好处是,不需要考虑left == right时访问 nums[mid] 是否越界,也不用在循环里特判。
代码如下:
def find_min_in_rotated(nums): left, right = 0, len(nums) - 1 while left < right: mid = (left + right) // 2 if nums[mid] > nums[right]: left = mid + 1 else: right = mid return nums[left]3.4 代码实现与几个容易翻车的细节
实现过程中我整理了几个经验点:
- mid 的取整方向:
(left + right) // 2是向下取整。配合left = mid + 1和right = mid,这种写法在当right - left == 1时是安全的。如果取的是上取整,left = mid + 1就可能跳过区间。 - 没有旋转的情况:比如 [1, 2, 3, 4, 5]。此时每轮 nums[mid] 都小于 nums[right],所以 right 不断左移,最终停在 1。结果是正确的。
- 长度为 2 的数组:比如 [2, 1]。mid = 0,nums[0] = 2 > nums[1] = 1,left 变成 1,返回 nums[1] = 1。正确。
- 重复元素:题目假设不重复,如果重复的话这个模板还要加一个
if nums[mid] == nums[right]: right -= 1的降级处理,这是另一个话题,这里不展开。
3.5 与常规二分的差异点
这道题加深了我对二分的理解。常规二分查找是“排除不包含目标的一半”,旋转数组最小值是“排除不可能是最小值的一半”,核心逻辑没有变化,变的是比较对象和收缩方向。我之前一直记不住这个模板,后来改变策略:每次做这类题,都先写下“如果这里选左半边,那最小值可能是哪几个位置”,想清楚之后再去写代码,正确率高了很多。
我还有一个习惯:在做完这道题后,额外验证一下 mid 更新公式在整数溢出场景下的写法。Python 没有溢出问题,但换到 C++ 或 Java 里,left + right在大数组时可能溢出,所以更稳妥的写法是left + (right - left) // 2。这个习惯是我在多次代码评审里养成的,建议你不管用哪种语言,都顺手写成防溢出版本。
4. 第三道题:求二叉树中的最大路径和(树的递归)
4.1 题目描述与业务场景
第三道题我选了一道树的题:给定一棵非空二叉树,每个节点包含一个整数值,路径定义为从一个节点出发,沿着父子连接走到任意节点的序列。路径至少包含一个节点,路径和是路径上所有节点值的总和。要求返回最大路径和。
这道题看起来跟“求树的深度”类似,但实际复杂不少。因为路径允许“从某个节点向上走到某个祖先,再转向另一棵子树”这种类似倒V字形的结构。它不只是简单的自上而下遍历能解决的,需要把“局部最优”和“全局最优”结合起来考虑。
这类问题在现实中经常出现:比如计算一棵依赖树中,从任意模块到任意模块之间累计开销最大的路径;或者在社交网络中找两个用户之间影响值最大的链路。虽然真实场景往往更复杂,但核心算法思想是一致的——递归分解子树的贡献。
4.2 后序遍历与“贡献值”的概念
我一开始拿到这个题时,惯性思维是:找一条从上到下的路径,那用前序遍历就行。但这个思路很快就撞墙了,因为如果路径是倒V字形的,根节点可能在路径中间,前序遍历的时候根本不知道左右子树到底怎么组合收益最大。
正确的做法是用后序遍历。对每一个节点,我们先递归算出左子树给你的“最大单侧贡献”和右子树的“最大单侧贡献”,然后决定要不要以这个节点为拐点更新全局答案。
这里有个很关键的定义:单侧贡献表示:从当前节点出发,向下走到某一侧子树(不回头)能获得的最大路径和。对于某节点 node,它的单侧贡献是:
node.val + max(left_gain, right_gain, 0)加 0 的意思是:如果某侧子树的贡献是负数,干脆不要走那侧,路径只包含节点自己。这一步是这道题的灵魂,也是很多人写错的地方——负数子树处理不当会导致答案偏小。
4.3 递归函数的设计:返回什么、更新什么
递归函数返回的是当前节点能为父节点提供的“最大单侧贡献”,但同时需要在递归过程中更新一个全局变量 max_sum,记录跨过当前节点的路径和:
current_path_sum = node.val + left_gain + right_gain max_sum = max(max_sum, current_path_sum)为什么这里要把 left_gain 和 right_gain 都加上?因为这个节点作为拐点,路径同时向下走了左右两侧,形成一个倒V。而返回给父节点时,只能返回“包含节点且最多再沿一侧走”的值,不能把两边加起来——因为父节点还要在这条路径上继续延伸,路径不能分叉。
这两个东西很容易混淆。一个形象的类比:递归函数的返回值就像是你要交给上级的报告——只汇报自己这条线上最好的情况;全局变量则像是整个公司层面的项目总账——它考虑的是跨部门的协同成果。
代码实现:
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def max_path_sum(root): max_sum = float('-inf') def dfs(node): if node is None: return 0 left_gain = max(0, dfs(node.left)) right_gain = max(0, dfs(node.right)) path_through_node = node.val + left_gain + right_gain nonlocal max_sum max_sum = max(max_sum, path_through_node) return node.val + max(left_gain, right_gain) dfs(root) return max_sum注意 Python 里要在内层函数中用nonlocal max_sum才能修改外层变量。这是 Python 的作用域规则,我之前因为漏写 nonlocal 导致赋值失败,调试了半天才发现 max_sum 根本没有更新。
4.4 负值子树的处理与空节点的返回值
空节点返回 0 是合理的,因为空节点没有贡献。但这里有个隐蔽的问题:如果一棵子树的所有节点都是负数,那么它的最大路径和应该是那个最大的负数,而不是 0。因为在路径定义里,路径至少包含一个节点,你不能“选择空路径”。
解决方案是:递归时用max(0, dfs(...))把负贡献剪掉,但全局变量 max_sum 的初始值要设成负无穷,并且递归过程中每个节点都要至少计算一次node.val + left_gain + right_gain。即使所有节点都是负数,在叶子节点上也会算出一个负的最大值,max_sum 能正常更新。这个逻辑我踩过一次坑,当时 max_sum 初始为 0,结果全负数树的答案一直是 0。
4.5 复杂度分析与时间消耗
时间复杂度 O(n),每个节点只访问一次。空间复杂度在递归实现中是 O(h),h 是树的高度,最坏情况下(链式树)是 O(n)。这个复杂度是可以接受的。但如果树的深度特别大(比如 10 万层),Python 默认递归深度只有 1000 左右,会触发 RecursionError。我当时做这道题时专门试了一下深链的情况,确实有问题。
解决办法有两种:一是用sys.setrecursionlimit(200000)提高递归深度限制;二是改成显式栈的迭代后序遍历。第一种方法简单,但提高递归深度有可能导致栈溢出;第二种方法更稳妥,但代码复杂度上了一个台阶。平时的练习题用第一种就够,面试时可以提一下知道这个限制。
5. 做完之后的复盘与几个容易踩的坑
5.1 时间复杂度与空间复杂度汇总
三道题做完,我习惯性地把解法整理成一张表,方便以后复习:
| 题目 | 核心算法 | 时间复杂度 | 空间复杂度 | 关键易错点 |
|---|---|---|---|---|
| 最大连续子数组和 | 动态规划 | O(n) | O(1) | 全负数时最大值初始值 |
| 旋转数组最小值 | 二分查找 | O(log n) | O(1) | 收缩方向写反、mid 取整 |
| 二叉树最大路径和 | 递归 + 后序遍历 | O(n) | O(h) | 负数子树剪枝、返回单侧贡献 |
如果只记一句话,那就是:动态规划按摩状态定义走,二分按循环不变量走,递归按函数返回值语义走。
5.2 边界用例清单
我每次写完算法题后,都要在本地跑一圈边界用例,顺手把它们记成一个 checklist。这次三道题的边界用例分别是:
- 最大连续子数组和:空数组、单元素数组、全正数、全负数、正负交替
- 旋转数组最小值:数组未旋转、旋转一次且长度为 1、长度为 2、左半段只有 1 个元素
- 二叉树最大路径和:只有根节点、根节点为负、全部为负、左子树为空、右子树为空、最深的叶子节点是唯一正数
这组用例对平时练习非常管用,如果你也常刷题,建议直接保存下来作为你的默认测试矩阵。
5.3 关于输入输出的细节坑
有几处细节我觉得值得单独拎出来说:
- Python 的
float('-inf'):求最大值前把结果初始化为负无穷,而不是任意的一个小数字。因为你不知道数据范围里最小的数能到什么程度,初始化成 -10**9 这种值在极端用例下是不安全的。 - 二分查找时的除法:Python 的
/是浮点除法,用//才是整数除法。我见过不止一个人在这里写错导致mid变成浮点数,然后索引越界。 - 递归里
return的位置:一定要确保每个分支都有返回值。有些写法会把开启分支后忘记 return,导致父节点收到None而不是整数,报错时很难一眼看出来。 - 全负数数组和空树:这两类输入几乎是万能的“测试陷阱”。如果题目没有明确说明输入不会为空,你最好先处理为空的情况再谈算法。
5.4 一个调试技巧:打印中间状态而不是盲目相信样例
那晚做第一题的时候,我突然意识到一个问题:我自以为看懂了状态转移方程,但真正在草稿纸上推到第三个元素时发现,dp 值跟我想象的不一样。后来我把每个元素的 dp 值都打印出来对照,才发现问题出在我对“以 i 结尾”这四个字的理解上。
所以我的建议是:当样例跑通但心里没底时,务必要把关键中间变量打印出来看一遍。不要急着提交判题系统,因为在本地多花两分钟打 print,比在判题系统上反复提交错误答案然后挨罚时要划算得多。尤其是动态规划和递归题,打印 dp 数组、打印递归返回值,能很直观地纠正你对状态和转移的误解。
6. 实操心得与后续题单规划
6.1 这次练习暴露出的短板
说实话,三道题耗时比预期要长。第一题大概十来分钟写完,但第二题我在纸上画了半天,一开始用的是nums[mid]和nums[left]比较,结果跑用例失败,才改成跟nums[right]比较。这也提醒了我一个很重要的问题:我在写二分题时往往是靠“背模板”而不是靠“理解不变量”,一旦题目换了个问法就容易翻车。所以接下来我想专门做一组“二分查找变种”的专题训练,涵盖边界判断、重复元素、区间开闭等变化。
第三题倒是写得比较顺,因为之前做过类似的题,已经形成了“全局最优 + 单侧贡献”的肌肉记忆。但也正是这个原因,我在写nonlocal时差点忘了——这提醒我,语言细节和算法思路一样需要保持熟练度。
6.2 后续想补强的方向
对于我来说,接下来两到三周的计划是:
- 把二分查找常见变种(找左边界、找右边界、找峰值、旋转数组中找目标值)系统性过一遍
- 动态规划方面,从一维 DP 上升到区间 DP,因为很多实际问题(比如回文分割、石子合并)都是区间 DP 的思想
- 树的题目继续练,重点放在“递归返回值设计”上,尤其是“自顶向下”和“自底向上”两种模式的区别
如果你也是自己安排训练计划,我建议不要贪多。一次三道题,每道都吃透,比一次做十道但每道都浮于表面有用得多。尤其是做到第三题的时候,如果前两道已经花了一个多小时,大脑状态明显下降,那种时候再硬做新题其实是低效的。
6.3 最后再分享一个小技巧:建立自己的错题模板
我现在的做法是,每次练习结束,把没有一遍写对的地方记到本地笔记里,格式很简单:题目类型、我当时的错误写法、正确的思考方向、下一次要提醒自己的点。比如这次就记了一条:
二分查找旋转数组最小值时,不要跟 left 比,要跟 right 比,因为 left 可能在两段递增序列的任何一段,而 right 一定在最小值所在的那一段附近。
这种一两行字的笔记,比收藏一堆题解要有用得多。因为题解是别人写的,笔记是自己踩过坑之后的教训,复习起来效率完全不同。
算法练习这件事,说到底没有太多捷径,但也不是靠题海战术堆出来的。把每次练习当成一次“解剖典型问题”的机会,认真推导状态定义、循环不变量、递归语义,比盲目追求 AC 数量更值得。这一晚的三道题,让我重新梳理了三个基础算法的内核。下次再遇到类似的题目,不管是面试还是实际业务里抽象出来的问题,我都会先停下来问自己一句:这道题到底在考哪个边界条件?