1. 开篇:算法练习到底是什么,以及我为什么还在做这件事
2026年1月19日,我又一次打开了算法题库,在练习记录上写下“算法练习2026-1-19”这个平平无奇的标题。如果你以为这是某个刚入行的应届生在刷题备战春招,那可能猜对了一半;但如果你以为算法练习只是“抱着题解背代码、把 LeetCode 刷够三百道”的机械动作,那我劝你再想想。
从我自己的经历来说,工作越久,越发现算法训练这件事被太多人误解了。不是只有面试前才需要突击,也不是刷题数量能说明任何问题。真正有价值的算法练习,是围绕“问题建模、复杂度权衡、边界条件处理、代码实现落地”这一整条链路做的刻意训练。2026开年这个时间点,各种AI编程助手已经非常成熟,很多人甚至开始怀疑“人还需要学算法吗”。我的答案是:不仅要,而且比以往更需要。原因很简单——工具越强,使用工具的人越要能判断它给出的方案到底对不对、复杂度是否合理、边界有没有漏。这种判断力,就是靠一次次手动推演和编码练出来的。
这篇文章不是什么宏大的方法论总结,也不打算给你列一个“30天算法速成计划”。我就以2026年1月19日这次真实的练习记录为切口,拆一拆我选了哪些题、为什么选它们、每道题背后的核心考点是什么、我在实现时踩了哪些坑,以及一个老程序员对算法练习这件事的几点实在建议。不管你是准备面试的求职者、刚入门的学生,还是已经工作多年想保持脑力的开发者,我希望这篇文章能让你看到:一次好的算法练习,绝不只是“做对一道题”那么简单。
2. 这次练习的目标拆解:不追求数量,追求覆盖密度
2.1 我给自己定的三个练习维度
过去我也走过弯路。有一段时间我给自己定的目标是“每天五道题”,结果坚持了不到两周就崩了。原因很简单:为了凑数量,我专挑简单题,或者直接看题解然后默写一遍,表面上打卡记录很漂亮,实际脑子里什么都没留下。后来我彻底调整了策略,把“数量指标”换成“覆盖维度指标”。
具体来说,每次练习前我会先想清楚这一次要覆盖哪些维度。以2026年1月19日这次为例,我定下的三个维度分别是:
- 数据结构维度:必须包含线性表(数组/链表)、哈希表、二叉树的题目,强迫自己在不同类型之间切换。
- 算法范式维度:至少覆盖一种经典范式,比如二分、双指针、动态规划、回溯,避免总在自己舒适区里打转。
- 工程属性维度:至少要有一道题需要考虑“如果数据规模再大十倍怎么办”,也就是复杂度优化和空间权衡问题。
为什么这么定?因为单一维度的刷题很容易形成“路径依赖”。比如你连续三天只练动态规划,脑袋里全是状态转移方程,结果面试官考你一道链表的快慢指针,你可能半天反应不过来。反过来,每次练习都刻意覆盖不同维度,本质上是在模拟真实工作中“问题类型不可预知”的状态。我后来发现,这种训练方式对面试和实际工作都有实实在在的帮助。
2.2 选题策略与时间分配
定完维度后就是具体选题。这次练习我安排在周日下午,总时长大约两个半小时。我把时间切成了三段:前40分钟做两道中等难度的“热身题”,中间70分钟主攻一道综合性较强的题目,最后30分钟做复盘和笔记整理。
有人可能会问,为什么热身题不选简单题?我的经验是,简单题对保持手感有价值,但作为“热身”太容易让人松懈。中等难度的题目刚好能把大脑从休息状态拉回到“需要思考”的状态,又不会一上来就造成挫败感。这次我选的热身题分别是:
- 一道关于“合并两个有序数组”的变种题,要求原地合并,考察双指针从后往前遍历的技巧。
- 一道“判断字符串是否为另一个字符串的排列子串”的题目,本质上是滑动窗口加哈希表计数。
两道题一热一身,分别覆盖了数组操作和字符串窗口两类基础场景,做完大约用了35分钟。接主攻题的时间,我选了一道二叉树的题目,要求“计算二叉树中所有左叶子节点之和”。题目本身不难,但它同时涉及树的遍历、递归/迭代两种实现方式、以及边界条件的处理,作为主攻题很合适。
最后一道附加题我选的是“最长连续递增子序列”,但注意,不是普通的版本,而是要求输出“所有最长连续递增子序列的起始位置”。这道题把一个经典的DP问题改造成了更贴近工程场景的问题——不仅要算最优值,还要把所有最优方案的位置都找出来。这道题我花了将近50分钟,前30分钟在推导状态设计和去重逻辑,后20分钟才真正写代码。
3. 核心细节解析:三道题的思路、实现与踩坑实录
3.1 变种题:合并两个有序数组(原地)
先说这道热身题。题目原型大家应该都见过:两个有序数组A和B,A的长度足够容纳A和B合并后的全部元素,要求原地合并到A中,结果仍然有序。常规解法是双指针从前往后,但如果从前往后,A数组前部的元素可能会被覆盖,因此标准做法是从后往前遍历。
核心代码逻辑是这样的:
def merge(nums1, m, nums2, n): p1 = m - 1 p2 = n - 1 tail = m + n - 1 while p1 >= 0 and p2 >= 0: if nums1[p1] > nums2[p2]: nums1[tail] = nums1[p1] p1 -= 1 else: nums1[tail] = nums2[p2] p2 -= 1 tail -= 1 while p2 >= 0: nums1[tail] = nums2[p2] p2 -= 1 tail -= 1这段代码有个很多人忽略的细节:第一个while结束后,如果p1还大于等于0,不需要额外处理,因为nums1的前半部分已经在正确位置上;但p2如果还有剩余,必须继续搬运。这个不对称性第一次写的时候很容易漏掉第二个while。
我在实际练习时还专门做了一步“变量命名复盘”。很多教材里习惯用i、j这样的变量名,但我更推荐用p1、p2、tail这种带语义的名字。原因很简单,这种代码在面试时写出来,你自己不容易乱,面试官也更容易看懂。这个习惯我在实际工作中也受益良多——代码首先是给人读的,其次才给机器执行。
3.2 变种题:字符串排列子串判断(滑动窗口)
第二道热身题是判断一个字符串s2是否包含s1的某个排列。比如s1是"ab",s2是"eidbaooo",那么s2的子串"ba"就是s1的一个排列,因此返回True。
这道题的标准解法是滑动窗口。核心思路是维护一个窗口,窗口大小固定为len(s1),用两个计数数组分别记录s1中每个字符的出现次数,以及当前窗口中字符的出现次数。窗口每次右移一个字符,更新计数,然后比较两个计数数组是否相等。
实现时我踩了一个坑:频繁地全量比较两个长度为26的数组其实是可以接受的(因为长度固定),但如果s1本身很长而字符集很大,更高效的做法是维护一个“差值计数”,用一个变量记录当前有多少个字符的计数对不齐。我第一次写的时候直接用了全量比较,虽然通过了,但复盘时我意识到,如果面试官追问“你的复杂度是多少”,回答O(26*n)虽然常数很小,却不如O(n)的写法更漂亮。于是我改成维护diff变量的实现方式:
def checkInclusion(s1, s2): n1, n2 = len(s1), len(s2) if n1 > n2: return False cnt = [0] * 26 for ch in s1: cnt[ord(ch) - ord('a')] += 1 diff = n1 for i, ch in enumerate(s2): idx = ord(ch) - ord('a') if cnt[idx] > 0: diff -= 1 else: diff += 1 cnt[idx] -= 1 if i >= n1: old_idx = ord(s2[i - n1]) - ord('a') if cnt[old_idx] >= 0: diff += 1 else: diff -= 1 cnt[old_idx] += 1 if diff == 0: return True return False这个diff变量的维护逻辑很绕,尤其是窗口右移时“先加新字符、再减旧字符”的顺序,以及判断条件里是>=0还是>0,非常容易写反。我练习时在这块反复推演了三遍,才彻底想清楚:cnt[idx]表示当前窗口还“欠”s1多少个该字符;欠的越多越可能匹配,多出来的则代表窗口里有s1之外的字符。理解了这一点,条件判断就不会靠死记硬背了。
3.3 主攻题:二叉树左叶子之和
这道题看似简单,但我在练习时发现它其实是一个很好的“递归边界训练题”。题目要求计算所有左叶子节点的值之和。左叶子的定义是:一个节点是父节点的左孩子,且它自身没有左右孩子。
我第一次写的时候,很自然地写出了这样的递归:
def sum_of_left_leaves(root): if not root: return 0 total = 0 if root.left and not root.left.left and not root.left.right: total += root.left.val total += sum_of_left_leaves(root.left) total += sum_of_left_leaves(root.right) return total这段代码本身没错,但有一个值得展开的细节:为什么在递归到左子树之前就先判断了“左孩子是不是叶子”?因为一旦左孩子是叶子节点,递归到它时,它既没有左孩子也没有右孩子,只能返回0,我们就会丢失这个左叶子的值。所以必须提前在父节点层面做判断。这个“先判断后递归”的模式,在树形DP和回溯问题中反复出现,是理解递归时机的好例子。
迭代实现也值得写一遍。用栈模拟深度优先遍历,维护一个“当前节点是左孩子还是右孩子”的标记:
def sum_of_left_leaves_iter(root): if not root: return 0 stack = [(root, False)] total = 0 while stack: node, is_left = stack.pop() if not node.left and not node.right and is_left: total += node.val if node.left: stack.append((node.left, True)) if node.right: stack.append((node.right, False)) return total对比递归和迭代版本,你会发现递归版本的代码更短,但边界判断隐藏在“先判断叶子”的逻辑里;迭代版本则把“是否左孩子”这个信息显式地放在栈里。面试时如果只写递归版,面试官可能会追问迭代版怎么写,所以两种写法都需要熟练掌握。
3.4 附加题:最长连续递增子序列(所有方案)
这道题是这次练习中我思考最久的一道。普通版的“最长连续递增子序列”只需要输出长度,用一次遍历就能解决:
def find_length_of_lcis(nums): if not nums: return 0 max_len = 1 cur_len = 1 for i in range(1, len(nums)): if nums[i] > nums[i - 1]: cur_len += 1 max_len = max(max_len, cur_len) else: cur_len = 1 return max_len但改成输出“所有最长连续递增子序列的起始位置”后,问题难度立刻上了一个台阶。比如数组[1,3,5,4,7,8,9,2,10,11],最长连续递增子序列有两个:一个是[1,3,5]起始位置0,另一个是[4,7,8,9]起始位置3。注意这里有个坑:如果最长长度是3,那么任何长度也为3的连续递增段都要被找出来,而这些段之间可能重叠也可能不重叠。
我的做法分两步。第一步先遍历数组,统计每一段连续递增的起点和长度;第二步找出全局最大长度,然后收集所有长度等于最大值的段的起点。这个“先分段、后筛选”的思路很简单,但我在实现时第一次只收集了“第一次出现最大值的段”,漏掉了后面同样长度的段。这种错误在动态规划类问题里特别典型——DP表算出来了,但回溯所有最优方案时,少了“遍历所有可能前置状态”的逻辑。
附录:(原样保留补充的正文)
4. 实操过程复盘:从读题到提交,我的一小时四十分钟
4.1 读题与思路推导:慢就是快
这次练习我刻意放慢了读题节奏。以前我经常犯一个毛病:题目读了个大概就急着写代码,写到一半发现漏了条件,回头重读,时间白白浪费。这次我在每道题上先花5到8分钟做“纸面推导”,不碰编辑器。
以“最长连续递增子序列的所有方案”为例,我在草稿纸上写了一个小数组,手动标出所有递增段的起点和终点。这个过程看起来慢,却能帮我验证思路是否完整。纸上推演时我发现了一个关键问题:如果两个递增段共享一个元素,比如[1,2,3,2,3,4],最长长度为3,两个满足条件的段分别是[1,2,3](起点0)和[2,3,4](起点3),它们之间没有重叠,收集起来很自然。但如果数组是[1,2,3,1,2,3],两个段都是长度3,起点分别是0和3,仍然不重叠。真正容易出问题的是[1,2,3,2,3,4,3,4,5]这种,你会发现起点0、3、6的三个段都长度3,它们不重叠但有衔接关系。手动推演这些情况,比直接跑代码更能训练对边界条件的敏感度。
读题阶段我还会做一件事:把题目中的约束条件圈出来。比如这道题要求“连续”,那就意味着不能用常规的LIS(最长递增子序列)动态规划思路,因为LIS允许跳着选元素。这种“从约束反推算法选择”的习惯,是我在无数次被边界条件坑了之后才养成的。
4.2 编码实现:先写核心逻辑,再补边界
实际编码时,我遵循一个固定顺序:先写主流程代码,确保最核心的逻辑正确,然后再处理空数组、单元素数组、数组末尾段未闭合等边界情况。
以“合并两个有序数组”为例,我第一遍只写从后往前的合并主循环,不写p2剩余元素处理。写完后我会反问自己:如果nums2里有一个比nums1所有元素都小的值,会发生什么?答案是在第一个while循环中p2始终小于p1对应的值,最后p2没有归零,剩余元素需要搬运。补上第二个while,代码才算完整。这种“先主后边”的顺序,让我不至于一开始就被边界条件打乱思路。
再以二叉树左叶子为例,我第一次只写了递归版本,测试时发现空树返回0没问题,单节点树返回0也没问题,但只有左子树没有右子树的情况,以及根节点的右子树里出现左叶子分支的情况,容易漏算。我后来习惯在写完主逻辑后,主动构造三四种特殊用例去“攻击”自己的代码。这种自测习惯,比依赖在线判题系统的错误提示更能提升代码质量。
4.3 笔记与复盘:让每一道题的价值最大化
我练习完后一定会做笔记,但不是把题解和代码复制粘贴一遍。我的笔记模板包含四部分:问题的一句话描述、我最初的思路、最终采用的思路、以及两者之间的差距。这个“差距分析”是最有价值的。
举个例子,“字符串排列子串判断”这道题,我最初想的是生成s1的所有排列然后逐一匹配,但很快意识到排列数量是阶乘级,完全不可行。这个错误思路虽然荒谬,但值得记下来——因为它让我深刻理解了“排列组合爆炸”这个概念。对比我后来的滑动窗口解法,差距就在于是否想到了“排列本质上只是字符计数的全等关系”,而不需要真的枚举排列。笔记里我还记录了一个小教训:diff变量的增减条件不能靠背,必须从“计数数组含义是当前窗口欠s1多少字符”出发去推导。
笔记的第二个作用是记录复杂度分析。每道题我会写出时间复杂度和空间复杂度,并标注“这个复杂度是否能应对题目给出的数据范围”。比如“合并两个有序数组”的O(m+n)时间和O(1)额外空间,在数据量极大时依然高效;“最长连续递增子序列所有方案”的O(n)时间和O(n)存储所有段信息,如果n达到10的7次方级别,仍可接受。做复杂度分析时,我会刻意问自己:如果面试官让我优化空间,我能不能做到?这个习惯让我在实际工作中评估技术方案时更有底气。
5. 常见问题速查与独家避坑技巧
5.1 滑动窗口类题目的四个易错点
滑动窗口在字符串匹配、子数组问题里出现频率极高。我结合这次练习和过去经验,总结出四个最常踩的坑。
第一个易错点是窗口大小的动态变化与固定变化混淆。像“字符串排列子串判断”这种题,窗口大小固定为len(s1);但像“无重复字符的最长子串”这种题,窗口大小是动态的。一旦搞混,代码会变得非常混乱。我的经验是,动手前先问自己:窗口右边界移动时,左边界是固定不动还是也要移动?固定窗口通常用if判断是否达到窗口上限,动态窗口通常用while循环收缩。
第二个易错点是计数数组更新时的顺序问题。窗口右移时,先处理新进入的字符还是先处理离开的字符?不同题目要求不同,但我建议统一采用“先加新字符,再减旧字符”的顺序,然后在纸上推演一遍微小例子验证。对“字符串排列子串判断”来说,这个顺序配合diff变量有严格规定:先更新新字符的计数和diff,再删除过期字符的计数和diff。顺序错了,结果就会错。
第三个易错点是字符集的范围。很多题假设只有小写字母,因此计数数组大小是26。如果题目没有明确说明字符集,就不能用固定数组,应该用哈希表。我曾经在一次模拟面试中因为默认字符集是26,结果题目里出现了大写字母,当场翻车。从那以后,我读题时会把字符集范围作为边界条件明确记录下来。
第四个易错点是“窗口合法性”的定义。有些题要求窗口内字符种类不超过k种,有些要求窗口内每种字符数量有上限。定义不同,收缩条件就完全不同。我的建议是把窗口的合法性条件写成一个独立的判断函数,即使性能稍差,也能保证逻辑清晰,后续优化也有明确方向。
5.2 二叉树问题的递归与递归转化
树相关的题目是面试高频区。我在二叉树的递归问题上遇到最多的困惑是“不知道自己写的递归返回什么”。很多人抄模板写递归,能通过简单用例,但一遇到递归返回值需要参与计算的题目就懵。
我的经验是,写任何一个递归函数前,先写清楚函数的“语义注释”。比如对“二叉树左叶子之和”的递归函数,注释应该写“返回以当前节点为根的子树中所有左叶子之和”。这个语义一旦明确,函数体里如何递归就清晰了:当前节点左孩子如果是叶子,直接加上;然后加上左子树的所有左叶子;再加上右子树的所有左叶子。至于当前节点自身是不是左叶子,那不是这个函数的职责,由父节点调用时判断。
递归转迭代的通用思路是手动模拟系统栈,但不同题目需要压入的信息不同。“二叉树左叶子之和”需要压入“当前节点”和“是否为左孩子”两个信息;“二叉树的最大深度”则需要压入“当前节点”和“当前深度”。每次做递归转迭代练习时,我会刻意思考“栈里的每一帧应该包含哪些信息”,这个思考过程比背模板有价值得多。
5.3 多种最优方案收集类题目的常见误区
这次附加题最大的收获,是让我意识到“收集所有最优方案”和“只计算最优值”之间的差距。计算最长连续递增子序列的长度,一次遍历即可;但收集所有方案,需要存储每一段的起点和长度,还要处理“多个段长度相同”的情况。
常见误区有两种。第一种是提前终止:一旦找到第一个长度等于最大值的段就break,导致后面的方案丢失。第二种是“长度比较”写反:把if段长度>最大长度和如果段长度等于最大长度混在一起,导致最大长度更新后,之前记录的段没有被清空。这个清空逻辑很重要——当发现新的最大长度时,之前收集的所有方案都失效了,必须先清空列表再加入新的起点;如果只是等于当前最大长度,则直接加入。我用一个简单例子验证这个过程:数组[1,2,3,0,2,4],第一段长度3,第二段长度3,最长长度就是3,两个起点0和3都要收集。如果数组[1,2,3,0,1,2],第一段长度3,第二段长度3,仍然都收集。但如果数组[1,2,3,0,1,2,3,4],第二段长度4,更新最大长度后,第一段起点0应该被丢弃。这个“清空-加入”的逻辑,我在代码里用条件判断实现了。
6. 算法练习的长期主义:我在2026年1月19日之后的一些思考
文章写到这里,我想抛开具体题目,聊聊一个更宏观的话题:算法练习到底应该怎么坚持。
2026年1月19日的这次练习,让我重新审视了自己的练习习惯。有一个数据很有说服力:过去一年我平均每周保持3到4次算法练习,每次2小时左右,累计大约400小时。这个投入换来了什么?不是某个比赛的奖项,也不是大厂录用通知,而是一种“面对未知问题时不会慌张”的底气。工作中遇到性能瓶颈、接口设计、数据结构选型,我能更快地想到可能的解决路径,因为在练习中见过了太多类似的问题模式。这种能力很难量化,但确确实实在影响我的职业表现。
关于“是否应该借助AI辅助刷题”,我现在的态度是:可以,但必须在动手思考之后。我见过不少朋友在AI工具的帮助下,能在短时间内解出很多题,但当你把AI关掉、重新面对一道中等难度的新题时,思路往往会断掉。原因是AI给出的答案简化了“从读题到实现”的思考过程,而这个过程恰恰是最需要训练的。我给自己定的规矩是:每道题至少独立思考20分钟,即使没有思路,也要先在纸上写出“我想到的几种可能的解法方向”,然后才能看参考或求助AI。借助AI的正确姿势,不是让它替你解题,而是让它扮演一个“可以随时追问细节的助教”,针对你的思路卡点给出提示,而不是直接丢一个完整答案。
最后一点是关于“复盘”的长期价值。我每次练习后都会花10到15分钟做笔记,这十几个小时的复盘时间,在短期内看不到直接收益。但三个月后再回头看这些笔记,我会发现自己当初困惑的问题已经不再困难,这本身就是进步的证据。有些笔记里记录的“烂思路”,今天看起来甚至觉得可笑,但正是那些可笑的想法,拼出了我现在的理解深度。算法练习没有终点,它更像是一种思维体操,练一天有一天收获,停一天有一天退步。
所以,如果你也想开始长期的算法练习,我建议你从明天起,给自己定一个“每次只练四道题、每道题都有明确维度”的小目标。别追求数量,别依赖答案,别忽视边界条件。坚持一段时间后,你会发现,真正让你成长的不是那些做对的题,而是那些你反复思考、反复推演、最终豁然开朗的时刻。