学习算法最怕的不是不会,而是“眼睛看会了,手一写就废”。二叉树这一块尤其容易这样,个中原因倒也不复杂——递归本身就是反直觉的,再加上回溯、切分区间这些概念一起上,稍微不扎实就栽了。我是在训练营第14天刷完二叉树专题2的,这套题我个人认为是整个二叉树最核心的一次强化。它不像树的遍历那样只需要套模板,题目之间存在着很强的递进关系,审题一旦粗心,或者递归三要素没想清楚,很容易一步错步步错。
这篇文章我会把专题2涉及的题目逐一拆开讲,从题目背后的考点、递归函数怎么设计、返回值和参数怎么选、哪些地方容易踩坑,到我自己在写题过程中实际碰到的bug,展开来说。不管你是跟着训练营刷题,还是自己准备面试,这一篇能帮你少走不少弯路。
1. 内容整体设计与思路拆解
1.1 专题2在二叉树学习里的位置
先聊明白一个底层问题:二叉树专题2到底是什么难度,跟专题1的区别在哪里。
专题1侧重的是基础遍历方式,前序、中序、后序、层序,题目大多给你一棵树,让你打印出特定顺序。那是在解决“怎么走”的问题,模板清晰,手感起来很快。而专题2的题目,全部瞄准的是“对树做什么”——遇到不对称的树要判断,遇到一条路径要收集,遇到区间要切割。解题过程中不仅要走完整棵树,还要带着状态走,边走边记录、边比较、边筛选。
所以我给专题2定位是:从“会遍历”跨越到“会处理”的关键衔接点,是整个二叉树部分知识密度最高的两天训练,没有之一。
如果你现在复盘,发现自己只是把专题1的三种遍历背下来了,但是碰到“要不要带回溯”“递归返回值用什么类型”这些问题就开始卡壳,不用慌,这不仅是你个人的问题,而是绝大多数人在这个阶段都会遇到的通病。可以说这个阶段的挣扎,反而是真正理解递归的开始。
1.2 专题2的题目考点地图
我实际刷到的题目组合以这六道题为主(不同版本的大纲可能有微调,但核心考点一致),我把它们按考查维度做了一个分类,方便你整体感更强:
| 题目类型 | 涉及题目 | 核心考点 |
|---|---|---|
| 递归返回值类型设计 | 平衡二叉树、路径总和 | 什么时候返回布尔,什么时候返回高度/计数 |
| 回溯与路径收集 | 二叉树的所有路径、路径总和 | 递归和回溯如何配合,收集结果的时机 |
| 题目条件的精读 | 左叶子之和、找树左下角的值 | 题干中的限定词怎么影响递归遍历路线 |
| 区间切分与构造 | 从中序与后序遍历构造二叉树 | 切割数组的区间一致性,下标计算的数学敏感度 |
这四条主线对应的是同一件事:你真的理解了一棵二叉树在递归框架下如何被系统性地处理。判断你是否过关,不是看你能否写出递归代码,而是看你在写之前,有没有清晰地把递归函数的输入输出定义想好。
1.3 为什么要按考点分组而不是按题号刷
很多人刷题喜欢按题目顺序来,认为每天刷到位就行。但作为过来人,我的体验是像专题2这种一天六道题的强度,如果不做考点归类,很容易刷到第4道题时就把第1道题的思路忘光了,最后看似刷完了,复盘时脑子里全是一团浆糊。
我做题的习惯是,每道题做完之后,不要急着看下一道,而是花三分钟在题解旁边写一句话:这题考的是递归的哪一个维度,跟前面哪道题有什么不同。你会发现,六道题其实就在反复训练三个深层能力——递归终止条件的设定、递归返回值的确定、回溯与递归的共存关系。抓住主线去刷题,事半功倍。
2. 核心细节解析与实操要点
2.1 平衡二叉树:递归返回值不是简单的真假判断
平衡二叉树的定义很直白:一棵树是平衡的,如果它的每个节点的左子树和右子树高度差不超过1。注意这里面有两个陷阱——高度的定义、以及“每个节点”都满足。
很多新手写这道题时,习惯性定义一个isBalanced函数,然后在里面递归判断根节点左右子树高度差,再递归调用isBalanced(root->left)。这种思路在逻辑上没有错,但性能极差,因为每到一个节点都要重新计算一遍子树高度,时间复杂度会退化到O(n^2)。ACM模式下题目规模一大,这种写法直接超时。
正确的解法是用后序遍历求高度的同时做判断,这里有一个很经典的技巧:递归函数不再返回布尔值,而是返回一个整数高度值。如果发现左右子树高度差绝对值大于1,直接返回一个特殊的负数(我用的是-1)作为“已经不平衡”的标记。这样一次自底向上的遍历,既可以算出每个节点的高度,又能沿途标记有没有节点已经不平衡。代码结构如下:
int getHeight(TreeNode* root) { if (root == nullptr) return 0; int leftH = getHeight(root->left); if (leftH == -1) return -1; // 左子树已经不是平衡树 int rightH = getHeight(root->right); if (rightH == -1) return -1; // 右子树已经不是平衡树 if (abs(leftH - rightH) > 1) return -1; return max(leftH, rightH) + 1; }主函数直接return getHeight(root) != -1即可。这个“用特殊值做状态标记”的思路在二叉树题目中出现频率很高,值得单独记下来。
另外一个我在这个阶段总是搞混的点是:树的高度和深度。深度是从根节点到该节点的边的条数,高度是从该节点到叶子节点的边的条数。求高度天然适合后序遍历,因为需要先知道孩子的高度再加1;求深度天然适合前序遍历,因为需要带一个深度参数一路向下传。我建议你把这个区别刻在脑子里,因为后面很多题会来回切换这两个概念。
注意:LeetCode官方题解里,部分版本把空节点高度定义为0,叶子节点高度定义为1,实际编码时不同定义会影响终止条件,保持一致即可,但心里要清楚自己用的是哪种约定。
2.2 二叉树的所有路径:回溯不是模板,是递归的自然产物
“二叉树的所有路径”这道题让我第一次真正意识到:回溯不是一种需要额外记忆的算法框架,它本身就是递归在路径收集场景下长出来的东西。
题目要求从根节点到所有叶子节点的路径,每条路径用“->”连接。我的第一版写法很粗糙,只在递归函数里把当前节点值塞进一个字符串参数,最后发现一条路径的前半段会被后面的路径重复拼接,导致路径乱套。
后来我意识到问题出在一个关键的机制上:路径是全局共享的,如果不“撤销”已经访问的节点,后面的递归分支会把不该包含的节点混进来。这就是回溯的真正含义。用代码说话:
vector<string> res; vector<int> path; void traversal(TreeNode* cur) { path.push_back(cur->val); // 当前节点入手 if (cur->left == nullptr && cur->right == nullptr) { string s; for (int i = 0; i < path.size(); i++) { if (i > 0) s += "->"; s += to_string(path[i]); } res.push_back(s); } if (cur->left) { traversal(cur->left); path.pop_back(); // 这行的本质:撤销刚才的选择 } if (cur->right) { traversal(cur->right); path.pop_back(); } }注意这里的push_back是在递归入口处完成的,而pop_back必须在每次递归返回之后执行,这样当程序从某一侧递归返回的时候,path数组里的状态恰好回到进入该节点之前。我试过把pop_back写在递归返回后的同一行,但漏掉了对空子树的处理,结果在节点只有左子树没有右子树的情况下,path中残留了不该有的节点值,排查了快二十分钟才找到原因。
我这里给你一个判断回溯是否遗漏的直观方法:观察递归返回之后,path数组的大小是否和递归入口进去之前完全一致。如果递归进去之前path.size()是k,进去之后到return之前path.size()也是k,说明pop的位置正确,否则状态就污染了。
2.3 左叶子之和:你以为你懂题目,但你可能真没懂
左叶子之和是我这次专题里最“阴”的一道题。为什么这么说?因为很多人(包括我)在第一眼看到题目时,会把它理解成“把所有左孩子节点加起来”,但实际题目要求是“所有左叶子节点的值之和”。
这两个概念的区别,我举一个最简单的例子:假设一棵树,根节点的右孩子有一个左孩子,这个左孩子确实是一个左节点,但它同时也是右子树的左节点,这时候它本身是叶子吗?如果只看“左节点”,你可能会把非叶子节点的左孩子也算进去,这样结果就错了。
正确的判断条件有三个条件必须同时满足:
- 当前节点左孩子不为空
- 左孩子自己的左右孩子都为空(也就是说,左孩子是叶子节点)
- 累加的就是这个左叶子节点的值
这种判断逻辑天然适合后序遍历,因为你要先处理子树的情况,再把结果一层一层往上合并。代码结构大致是:
int sumOfLeftLeaves(TreeNode* root) { if (root == nullptr) return 0; int leftSum = sumOfLeftLeaves(root->left); int rightSum = sumOfLeftLeaves(root->right); int mid = 0; if (root->left != nullptr && root->left->left == nullptr && root->left->right == nullptr) { mid = root->left->val; } return mid + leftSum + rightSum; }注意这个实现里面,后序递归的返回值是一个累加和,根节点本身不参与逻辑判断,只是在汇总左右子树的结果。这个“中节点只做汇总不做事”的感觉,是你判断自己后序遍历理解是否到位的一个信号。
2.4 找树左下角的值:同时兼顾“深度”和“最左”
找树左下角的值,题目描述是找到二叉树最底层、最左边节点的值。这道题有两种主流解法,层序遍历和递归。我来分别说一下,它们各自代表了一种解题思维。
层序遍历非常直观,就是一层一层从上往下扫,遍历到每一层时记录第一个节点,等所有层遍历完,最后一层的第一个节点自然就是答案。但它的缺点是代码要维护队列,还要不断更新当前层的节点个数,不太优雅。而且这道题放在递归专题里,我更推荐尝试递归思路。
用递归解这道题有两个关键维度:深度优先的顺序和“最大深度”的追踪。搜索时一定要先递归左孩子,再递归右孩子,保证同深度的节点中,左节点会被先访问到。同时维护一个全局变量最大深度,只有当前深度大于已有最大深度时,才更新结果和最大深度值。为什么是>而不是>=?因为如果是>=,当左右孩子深度相同时,先访问左孩子已经记录了一次,再访问右孩子时,如果也改成>=就会把右孩子覆盖上去,这就不“最左”了。
我犯过的错误是把深度参数放在递归函数里传值,但在左子树递归返回后,对右子树递归时直接用了depth + 1,没有恢复成原来的深度。这个在传值模式下没毛病,但有些人会把depth改成引用传参,这时就要在递归结束前手动depth--,否则右子树的深度会叠加左子树的遍历结果,整个结果直接错乱。
2.5 路径总和:递归返回值的另一种设计思路
路径总和要求判断是否存在一条从根到叶子的路径,使得路径上节点值之和等于目标和。
这道题我比较推荐的思路是“减法”,也就是不要从根节点一路累加过去,而是每次递归时传入targetSum - 当前节点值,到叶子节点时判断这个剩余值是否恰好为0。这种“递推时的状态转移”在思路上更简洁,也不容易出现int溢出。
递归函数返回布尔值即可,整体逻辑是:
bool hasPathSum(TreeNode* root, int targetSum) { if (root == nullptr) return false; int remain = targetSum - root->val; if (root->left == nullptr && root->right == nullptr) { return remain == 0; } if (root->left && hasPathSum(root->left, remain)) return true; if (root->right && hasPathSum(root->right, remain)) return true; return false; }这里值得深思的是,为什么用targetSum - node->val这种处理方式,而不是直接累加。核心原因在于当目标值很大时,累加需要不断判断当前和是否已经超过目标值来提前剪枝,逻辑更重。而且这道题的特色是递归返回值是布尔值,不是在收集所有路径,只要找到一个路径就返回true,这时布尔值的语义天然匹配“是否存在”的问题。
我记得有一位同学在这道题上卡了很久,他写的递归没有把“叶子节点”作为终止条件,而是在节点为空时就返回false,结果遇到单节点树,根节点左右子树都为空,还没进入叶子判断逻辑就返回false了。这种错误非常隐蔽,难点在于不能用“NULL返回”作为唯一终止条件,而是要在递归入口处优先判断“是否是叶子节点”。
提示:路径总和这道题和“二叉树的所有路径”是天然的对比组合:一个收集全部路径(vector),一个只做存在性判断(bool)。前者的path数组要回溯,后者的布尔返回值本身就带着回溯的语义——只要有一条路径成功,整个递归链就不断向上返回true,不需要额外维护全局状态。
2.6 从中序与后序遍历序列构造二叉树:切割区间想清楚,别靠猜
这道题但凡参加算法训练,十有八九会遇到。题目给你中序数组和后序数组,让你重新构造出二叉树。核心逻辑并不复杂:后序数组的最后一个元素是根节点,根据这个根节点去中序数组里找到它的位置,左边部分属于左子树,右边部分属于右子树。但实现起来,很多人的代码会报越界错误或者明明构建到一半发现路径断裂,原因几乎都在于区间切分不清晰。
我在做这道题时,为了让自己不再晕,把区间统一成了左闭右开的形式。后序区间[postLeft, postRight)、中序区间[inLeft, inRight),每次递归:
- 根节点值是
postorder[postRight - 1] - 在中序数组中找这个根节点值的下标
delimiter - 左子树中序区间是
[inLeft, delimiter),右子树中序区间是[delimiter + 1, inRight) - 左子树节点数量是
delimiter - inLeft,这个数量直接决定左子树后序区间是[postLeft, postLeft + leftLen),右子树后序区间是[postLeft + leftLen, postRight - 1)
我见过最典型的错误是,有人右子树后序区间的终点直接写成postRight,把根节点也包含进去了,于是递归死循环。这里必须记住:后序的右子树区间终点必须排除掉根节点,即postRight - 1,因为根节点已经从后序数组里被消耗掉了。
还有人在递归构造时判断空区间只检查了left > right,但没检查相等的情况。如果下一次递归到空区间,也就是区间内没有任何一个节点,那么就应该返回nullptr作为空子树标记,这时检查条件必须是left >= right,否则会出现中序区间明明只有一个元素,却因为左右子树都去细分而越界的问题。
2.7 前序+中序构造二叉树的扩展思考
训练营专题2里通常只写中序+后序,但我在实际复盘时对前序+中序也做了一遍。两种构造方式的核心逻辑完全一样,区别在于前序数组的第一个元素是根节点,所以根节点位置在中序数组中找到后:
- 左子树前序区间:
[preLeft + 1, preLeft + 1 + leftLen) - 右子树前序区间:
[preLeft + 1 + leftLen, preRight) - 左子树中序区间:
[inLeft, delimiter) - 右子树中序区间:
[delimiter + 1, inRight)
这里左子树前序区间的起点是preLeft + 1,很多人在这一步出错,因为前序的0号元素是根节点,子树区间必须从1号开始。建议你在做此类题目时,把每种遍历的区间画成一张坐标图,在脑海里“倒盒子”,确认每个区间包含哪些元素,而不是靠try错来调。
3. 实操过程与核心环节实现
3.1 写递归前先回答三个问题
很多新手在写递归时无从下手,是因为脑子里没有一个固定的提问顺序。我反复磨合后,形成了一套自己的方法论,每次拿到一道二叉树题,先强制自己回答三个问题:
- 这个递归函数的返回值是什么?是void、bool、int,还是TreeNode*?
- 递归终止条件是什么?是遇到空节点返回,还是遇到符合条件的节点返回,还是区间为空返回?
- 单层递归的逻辑是什么?我先处理当前节点,还是先递归左右子树,还是在中间汇总?
以“平衡二叉树”为例,返回值是int(高度),终止条件是空节点返回0,单层逻辑是分别计算左右子树高度并检查差值。以“所有路径”为例,返回值是void(结果放在外部变量里),终止条件是叶子节点,单层逻辑是push节点、递归左孩子、回溯、递归右孩子、回溯。以“构造二叉树”为例,返回值是TreeNode*,终止条件是区间为空返回nullptr,单层逻辑是找到根节点、切分左右区间、递归构建左右子树。
把这三个问题的答案写下来,代码基本就成型了,不需要再在脑内推演全递归过程,省下大量无效思考。
3.2 一个完整的递归函数实现范例
我把自己写“从中序和后序遍历构造二叉树”的完整代码贴在这里,作为整个专题的样例。它集中体现了区间切分、索引计算和递归终止条件的配合。
TreeNode* traversal(vector<int>& inorder, vector<int>& postorder, int inLeft, int inRight, int postLeft, int postRight) { if (inRight - inLeft == 0) return nullptr; int rootVal = postorder[postRight - 1]; TreeNode* root = new TreeNode(rootVal); // 叶子节点,直接返回 if (inRight - inLeft == 1) return root; // 在中序数组中找根节点索引 int delimiter = -1; for (int i = inLeft; i < inRight; i++) { if (inorder[i] == rootVal) { delimiter = i; break; } } int leftInLeft = inLeft; int leftInRight = delimiter; int rightInLeft = delimiter + 1; int rightInRight = inRight; int leftLen = delimiter - inLeft; int leftPostLeft = postLeft; int leftPostRight = postLeft + leftLen; int rightPostLeft = postLeft + leftLen; int rightPostRight = postRight - 1; root->left = traversal(inorder, postorder, leftInLeft, leftInRight, leftPostLeft, leftPostRight); root->right = traversal(inorder, postorder, rightInLeft, rightInRight, rightPostLeft, rightPostRight); return root; }写到这里,需要提醒一个使用new TreeNode时容易忽略的点:在代码提交时会涉及内存释放问题,LC这类平台通常不做检测,但在本地测试或者面试写代码时,若使用new创建节点,应当考虑用智能指针或者统一管理析构,否则会有内存泄漏隐患。
实操中我自己会把区间统一为左闭右开,代码里检查空区间时用的是
inRight - inLeft == 0,这样比>=更语义化,也减少出错的机会。左闭右开是C++容器迭代器区间的一贯习惯,养成这个写法后,再切换到Python的切片时也会感觉非常顺手。
3.3 写题时的自测策略
很多人在练习时只是把示例用例跑通了就认为AC了,这是一个坏习惯。示例往往只有最常规的形态,比如一棵三层的满二叉树,但算法题里最会坑人的往往是边界形态。我的自测清单如下:
- 空树:根节点为nullptr的情况,递归是否直接返回?
- 单节点树:只有根节点,是否符合题目定义的“叶子节点”条件?
- 左斜树:所有节点都在左孩子方向,递归深度是否过大,是否会发生栈溢出?
- 右斜树:所有节点都在右孩子方向,回溯逻辑是否仍然一致?
- 负值节点:路径总和、左叶子之和这类带值的题目,是否需要考虑负数节点的干扰?
以路径总和为例,如果二叉树节点值允许负数,就不能在累加过程中发现超过targetSum就剪枝。我曾经下意识写了剪枝逻辑,结果所有正数节点的测试都能过,一到带负数的用例就全错。这种边界意识,需要平时自己主动补上。
3.4 手推小样例是检验递归理解的最好方式
对自己写好的递归,建议不要直接编译,也不要直接看答案。我一般是拿一张纸,画一棵只有四五个节点的小树,然后自己模拟递归调用过程,一步一步写清楚每一次push、每一次return、每一次pop之后的path内容和栈结构。
说白了,这个阶段犯的错大部分都是因为自己的脑回路没有和计算机一致。手动推演不仅帮你找到错误,还会帮你建立“递归是把状态带进带出”的直觉。推演次数多了,以后遇到新题型,眼睛扫一眼题目就能判断该用什么返回类型来构架递归体。
4. 常见问题与排查技巧实录
4.1 专题2常见报错与应对速查表
从实际练习和身边同学的反馈来看,专题2的报错几乎集中在以下几类,我整理成了速查表,希望能直接帮到你:
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| 递归栈溢出 | 递归终止条件不充分,或者在单节点树场景下没有叶子判断 | 检查递归入口处是否有空节点判断,检查是否有路径递归到叶子节点之后仍继续深入 |
| 路径结果重复/污染 | 回溯pop_back调用位置有遗漏 | 每次递归返回后检查path.size是否和进入前一致 |
| 返回false但实际存在路径 | 终止条件只判断了空节点,没有停在叶子节点 | 把递归体里的“叶子节点=左右均空”单独作为终止条件 |
| 构造二叉树越界 | 区间计算时把根节点索引包含进子树区间 | 用左闭右开画区间草图,确认每个递归分支都排除了当前根节点 |
| 计算结果偏大 | 左叶子判断条件写成了“左节点非空”而没有判断左孩子的孩子是否为空 | 认真读题,确定“左叶子”是叶子且位于左侧 |
4.2 一个真实的“回溯漏写”现场
训练营有位同学A在写“二叉树的所有路径”这道题时,代码长这样:他用了递归函数,递归入口处push当前节点值,如果到叶子就构造字符串。但他只在左子树递归返回后写了一次path.pop_back(),没有在右子树递归返回后写第二次。于是我观察到他的输出中,很多路径虽然内容对,但长度明显超出预期,拼接出来的路径包含了本不该出现的祖先节点或者兄弟节点。
这个问题本质上就出在没有每次递归返回时同步恢复状态。当你递归进入左子树之后,左子树内部会自己push和pop,最终返回时,path里仍然包含当前节点。这时如果去递归右子树,path里还会继续保留当前节点,这是合理的;但前提是,右子树递归返回后同样要执行一次pop,把当前节点从path中移除,这样才能回到上一层递归调用之前的状态。
因为左右子树递归的返回点不一样,所以每一处递归调用后都必须写对应的pop,不能因为左子树写了右子树就不用写。我建议你把“一次递归调用对应一次回溯”当作一条纪律来执行,每次在递归调用下一行写上注释“回溯”,可以帮助自己检查是否遗漏。
4.3 区间切分时的“等差漏一”
对于构造二叉树的题,我自己的排查习惯是:一旦提交报错,第一时间打印每个递归函数的参数区间,确实很有效。具体来说,在每个分支递归之前,用思考的方式输出inLeft, inRight, postLeft, postRight四个值,观察是否出现inLeft > inRight或postLeft > postRight的情况。一旦出现,就意味着区间计算有误。
我还发现一个规律:出错最多的往往是右子树后序区间的起点。因为右子树后序区间的起点等于左子树后序区间的终点,即postLeft + leftLen,如果leftLen计算多1或少1,左子树右边界就错,右子树左边界也跟着错,连锁反应导致整棵树的右半部分全乱。
为了解决这个问题,我给自己的方法是,在草稿纸上写一个具体的中序和后序数组,比如中序[9,3,15,20,7]、后序[9,15,7,20,3],然后手动标出每一层递归的区间。纸上画清楚再敲代码,远好过空想着写出全对代码,尤其是区间边界这种事,光靠脑补容易出现“气息不稳”。
4.4 造轮子比背答案重要
专题2刷到后期,很多人容易陷入“背题解”的误区,看到一个题型觉得面熟,就凭借记忆把模板代码贴出来。这种做法在简单题里可能蒙混过关,但一到变形题,比如给中序和后序只是改了遍历方式,或者把路径总和的题目改成“返回所有满足条件的路径”,就会立刻卡壳。
我的建议是,在刷完每一题后自己独立把思路重新讲一遍。具体可以是给自己录音或者写一段文字解释,把递归函数的返回值、终止条件、单层逻辑说清楚。如果中间有任何一个环节说不明白,那说明还没真正掌握,不值得急着刷下一题。
某开发者告诉我:他用“给自己出题”来检验理解程度。比如,把所有路径题改成“求所有路径中节点值之和等于target的路径”,看看代码需要改动多少。如果只是加了一个参数和判断条件,那就说明回溯逻辑是真的理解了;如果还需要改递归函数的返回类型和结构,那说明此前只是套模板。
5. 写在最后的经验与扩展
我个人在训练营第14天刷完这套题之后,最大的变化不是代码量,而是对递归的信任感。以前写递归总是想展开整个调用过程,试图在脑中维护每一层变量的值,结果越理越乱。现在我的策略是:相信“递归函数本身就是正确的”,只要我明确定义了它的输入输出,我就敢于把程序看作一个已经完成了的功能块去调用它,而不是在原地反复推演它内部的每一步。
我自己的体会是,真正把我从“不会递归”拉出来的,不是把某一本算法书看完了,而是每天固定用三件套来约束自己的解题流程:定义递归函数的意义、写清楚终止条件、刻画单层逻辑。每一次写题前都问一遍这三个问题,坚持一周以后,递归题的正确率会有肉眼可见的提升。
如果你后续还想进阶,我建议可以从“二叉树的后继节点”“莫里斯遍历”这些偏门方向再扩展一下。前者能帮你把中序遍历与递归之外的双指针法打通,后者则是用迭代去玩出递归的活,训练的是对树结构的空间理解的极限。但不管是哪个方向,基础都是专题2里磨下来的那些基本功:返回值类型、终止条件、回溯状态、区间切分。
最后再分享一个小技巧,每次刷完题,把通过的代码放在本地仓库里,然后在题解中最下方写一行“这题我踩了什么坑”。不需要写长篇大论,只需要一句话。比如“偷懒没写右子树回溯”,等过一阵子回过头看,这些零散的经验往往比刷了三遍重复题更值钱。算法这条路没有捷径,但把每一步踩过的坑记录下来,确实是普通人能做到的最低成本的加速方式。祝你在二叉树的专题阶段稳扎稳打,后面回溯和动态规划的时候,你会发现今天的每一道题都没有白刷。