今天打卡算法训练营第16天,三道题分别是“找树左下角的值”、“路径总和”、“从中序与后序遍历序列构造二叉树”。前两道是二叉树递归的经典应用,第三道是二叉树重建,难度明显上了一个台阶。这三道题放一起其实挺妙:前两道练递归的“传递”和“返回”,第三道练递归的“分治”和“切分”,正好把二叉树递归里最难讲清楚的两件事都覆盖了。
很多人刷二叉树,递归模板背得滚瓜烂熟,但一见“返回多个值怎么办”“知道两种遍历怎么还原整棵树”就懵。这篇就按我自己的做题过程,把三道题从读题到AC的完整思考路径写下来,包括我踩过的坑和排查逻辑,希望能给卡在同样位置的朋友一点参考。
1. 三道题的定位与整体思路
1.1 为什么这三道题适合放在同一天刷
第一感是这三道题没有直接关联,一个找节点、一个判断路径、一个重建树。但实际做下来,会发现它们都在反复逼问你同一个问题:递归函数的返回值到底应该表示什么,什么时候需要返回值,什么时候不需要。
“找树左下角的值”本质是深度优先搜索时维护一个“当前深度”和“当前答案”,递归函数本身不需要返回什么,结果藏在全局变量或容器里。
“路径总和”需要递归函数返回一个布尔值,告诉上一层“从这个节点出发能不能凑出剩余的目标值”。这时返回值就是“递”下去之后“归”上来的核心信息。
“从中序与后序遍历序列构造二叉树”则更极端:递归函数直接返回一棵子树的根节点指针,每一层递归负责构造左子树和右子树,再把它们挂到当前根上。整个过程既是“分治”又是“组装”。
把这三道题串起来,其实就是在练递归设计的三种典型模式:无返回值只改外部状态、返回布尔值传递结果、返回节点指针构建结构。如果只背代码不琢磨这一层,遇到变体题依然会翻车。
1.2 递归三要素在这三道题里的具体体现
递归三要素每个刷题人都知道:确定递归函数参数和返回值、确定终止条件、确定单层递归逻辑。但能从“背出来”到“用出来”,中间隔着一堆真实题目的打磨。
在“找树左下角的值”里,参数除了当前节点,还要带上当前的深度。为什么?因为递归在往下走时,如果不手动维护深度,等回到上一层再想找“最后一层”就找不到参照系了。终止条件是遇到叶子节点并尝试更新答案。单层逻辑就是先左后右的遍历顺序,保证深度相同时先遍历到的节点来自左侧。
在“路径总和”里,参数是当前节点和“还需要凑多少”,返回值是布尔值。终止条件不再是“空节点返回”,而是“叶子节点判断剩余值是否等于节点值”。单层逻辑就是分别去左右子树继续问“扣除当前节点后,剩余目标还能不能凑出来”。
在“构造二叉树”里,参数是中序区间和后序区间,返回值是“用这两个区间构造出来的子树根节点”。终止条件是区间为空,返回空指针。单层逻辑先用后序区间的最后一个元素找到根节点值,再从中序区间里定位根,从而知道左子树和右子树各有多少个节点,然后切割区间,递归构造左右子树。
三道题的三要素对照起来看,就会发现递归真正的难点在于“设计参数和返回值”——不是想清楚要做什么,而是想清楚每个递归调用之间要传递什么信息。
2. 找树左下角的值
2.1 题目理解与陷阱
“找树左下角的值”就是给定一棵二叉树,找到最底层最左边节点的值。第一眼很多人会以为是左叶子节点或者树的最左边节点,但题目说的是“最底层的最左边”,即最后一行最左侧的节点。
这个定义里有三个关键词:
- 最底层:深度最大的那一层,不是高度最大的那一边。
- 最左边:在这一层里,最靠左的节点。
- 值:返回的是节点的值,不是节点本身。
有个很典型的例子:一棵树只有根节点和一个右孩子,那么最底层的左节点就是右孩子,而不是左孩子,因为左孩子根本不存在。如果你盯着“左”字去无脑递归找左孩子,就会错。
我一开始就犯了类似错误:用“优先左子树、维护最大深度”的递归去解,其实思路是对的,但我在更新答案时没有判断“必须是叶子节点”,导致如果某一层最左边是一个中间节点,但它的左子树为空、右子树很深,最终返回的是中间节点的值,而不是右子树里更深的节点。准确说,这个题里“叶子节点”判断不是必须的,因为只要维护的“当前最大深度”只在叶子节点处更新,就能保证答案是叶子节点。但如果中间节点本身处于一个从未被叶子覆盖的更深层?其实不可能,因为中间节点的深度一定小于它下方任意叶子节点的深度。所以关键不是判不判叶子,而是“只在第一次到达新深度时记录”。
2.2 递归解法:前序遍历维护最大深度
我最终采用的是递归前序遍历。核心思路:用maxDepth记录已经发现的最大深度,用result记录该深度下最先遇到的节点值。只要当前深度大于maxDepth,就说明遇到了新的一层,而且因为遍历顺序是“根-左-右”,每一层第一次遇到的节点一定是最左边的节点。
代码如下(C++):
class Solution { public: int maxDepth = -1; int result = 0; int findBottomLeftValue(TreeNode* root) { dfs(root, 0); return result; } void dfs(TreeNode* node, int depth) { if (!node) return; if (depth > maxDepth) { maxDepth = depth; result = node->val; } dfs(node->left, depth + 1); dfs(node->right, depth + 1); } };这里有个关键点:dfs(node->left...)和dfs(node->right...)的顺序决定了“最左边”的定义。先走左边,再走右边,确保同一深度的节点先遇到左节点。如果先走右边再走左边,遇到新深度时记录的就是右下角的值了。
这段代码有个值得注意的地方,maxDepth初始化为-1,这样深度为0的根节点就能触发第一次更新。如果你把maxDepth初始化为0,根节点的depth也是0,depth > maxDepth不成立,result就不会被更新成根节点的值,直接返回初始值0,万一整棵树只有一个节点就错了。
2.3 迭代解法:层序遍历的“最后一层第一个”
递归解法的本质是前序遍历。如果从另一个角度看,这个题也可以用层序遍历,而且逻辑更直观:一层一层从左往右遍历,记录每一层的第一个节点,遍历结束后,最后一个记录的值就是答案。
层序遍历代码也很短:
class Solution { public: int findBottomLeftValue(TreeNode* root) { queue<TreeNode*> q; q.push(root); int leftmost = root->val; while (!q.empty()) { int size = q.size(); for (int i = 0; i < size; i++) { TreeNode* node = q.front(); q.pop(); if (i == 0) leftmost = node->val; if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } return leftmost; } };注意层序遍历里,for循环内用i == 0判断每一层的起始节点。由于队列是按从左到右顺序加入的,i == 0必然是当前层最左边的节点。
两种解法对比,我更推荐递归解法,因为它的时间复杂度是O(n),空间复杂度在最坏情况下是链状树的O(n)递归栈,而层序遍历也需要队列O(n)。二叉树的题目如果不刻意考你迭代技巧,递归依然是最好写、最好改、最好讲的。
2.4 容易踩的坑
第一,depth是值传递还是引用传递。在递归写法中,我用depth + 1传给下一层,不修改当前层的depth,所以值传递没问题。但如果你习惯写成:
dfs(node->left, ++depth); dfs(node->right, ++depth);那就会出大问题,第二个递归调用时depth已经被第一个调用改掉了,丢失了本层的深度。正确写法是depth + 1,或者先记录本层深度再用临时变量。
第二,全局变量的重置。如果你把maxDepth和result作为类成员变量,力扣这类平台会在同一个测试对象上多次调用测试用例,所以每个用例会重新构建Solution对象,一般没问题。但如果你在本地写一个Solution实例连续跑多个测试数据,就要记得手动重置。我以前就在本地调试时连续跑两个用例,第二个用例直接返回了第一个用例的旧结果,排查半天才发现是成员变量没清。解决方式很简单:在findBottomLeftValue里对成员变量重新初始化,或者干脆把这两个变量改成dfs的引用参数。
第三,空树问题。题目一般约定非空,但如果面试时问“空树返回什么”,最好和面试官确认。我的习惯是函数开头加一行if (!root) return 0;,保证鲁棒性,同时说明这是防御性写法。
3. 路径总和
3.1 题目变体:是否存在总和
“路径总和”这个题有两个常见版本:一个是判断是否存在一条从根节点到叶子节点的路径,其节点值之和等于目标值;另一个是不仅判断存在,还要输出所有满足条件的路径,也就是“路径总和II”。
我们要做的是基础版。它的描述很简洁:给定二叉树和一个目标和,判断树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
注意“叶子节点”的定义是左右孩子都为空的节点。也就是说,如果某个节点只有一个孩子,不能在这里停下当作路径结尾,因为路径必须从根一路走到叶子。这一点很容易在判断条件上写错,我见过不少人用node == nullptr作为递归终止条件,然后直接比对累加值,这样会误把“到空节点的路径”当有效路径。
3.2 递归回溯的关键:返回值与count减法
这道题最优雅的解法是“减法”思维。不需要维护一个累加和变量,而是从目标值里不断扣减当前节点的值,如果走到叶子节点时剩余值刚好等于叶子节点值(或扣完刚好为0),说明存在这样一条路径。
我用的代码:
class Solution { public: bool hasPathSum(TreeNode* root, int targetSum) { if (!root) return false; if (!root->left && !root->right) { return targetSum == root->val; } int remaining = targetSum - root->val; return hasPathSum(root->left, remaining) || hasPathSum(root->right, remaining); } };这里为什么用或运算合并左右子树的结果?因为只要有一条路径满足就行。||本身具备短路性质,左边递归找到了,右边根本不会执行,这算是隐性的性能优化。
还有,这里没有显式的“回溯”动作,因为remaining是值传递,每一层递归都有自己独立的remaining副本。如果你改成引用传递,比如int& remaining,那必须在递归返回后把remaining加回来,否则同一个引用会在左右子树之间被反复修改,导致结果错乱。这就是回溯的本质:把状态改回去。
3.3 路径总和II的扩展
我在打卡时顺手做了一下输出所有路径的版本。它的递归返回值就不是bool了,而是void,因为结果要收集到二维数组里。这正好呼应了第一道题提到的“递归返回值设计”问题:
class Solution { public: vector<vector<int>> result; vector<int> path; vector<vector<int>> pathSum(TreeNode* root, int targetSum) { if (!root) return result; dfs(root, targetSum); return result; } void dfs(TreeNode* node, int remaining) { if (!node) return; path.push_back(node->val); remaining -= node->val; if (!node->left && !node->right && remaining == 0) { result.push_back(path); } else { dfs(node->left, remaining); dfs(node->right, remaining); } path.pop_back(); // 回溯 } };这里的path.pop_back()就是标准的回溯操作。因为path是成员变量,所有递归调用共享同一个数组,所以每次递归返回前必须把刚加入的节点弹出,否则路径会无限叠加。
有意思的是,remaining依然是值传递,所以不用回溯。如果把remaining也改成引用,就需要remaining += node->val回去了。所以你在刷题时,最好形成一套自己的约定:递归中作为“状态”的变量,如果希望每个分支独立,就用值传递;如果希望所有分支共享修改,就用引用或全局变量,并手动回溯。状态是值传递时,天然隔离;状态是引用时,必须手动还原。
3.4 经典错误
最常见的错误是把终止条件写成:
if (!root) return targetSum == 0;这会在树为空时错误返回true,因为此时targetSum恰好是0。而且它也没办法正确判断叶子节点,比如一个非叶子节点有左孩子,走到空右孩子时,targetSum可能被扣除到0,于是返回true,但实际并没有到达叶子。所以必须显式判断root->left == nullptr && root->right == nullptr。
第二个常见错误是忘记先判断根节点为空。力扣的测试用例里会有空树,空树直接返回false是合理的。
第三个很容易忽略:递归到叶子时,比较的是targetSum == root->val,而不是remaining == 0。如果你写成后者,需要先执行remaining -= root->val再判断,逻辑等价,但代码顺序容易被搞乱。建议统一用“剩余值等于当前节点值才是到达终点”这个口径,逻辑最清晰。
4. 从中序与后序遍历序列构造二叉树
4.1 原理:中序定左右,后序定根
这道题是三道题里最抽象的。给定两个数组:中序遍历序列inorder和后序遍历序列postorder,要求还原整棵二叉树。
先回顾三个遍历序列的特点:
- 中序遍历:左子树、根、右子树。所以如果我知道了根节点在中序序列中的位置,那么中序数组里根左边的所有元素都属于左子树,根右边的所有元素都属于右子树。
- 后序遍历:左子树、右子树、根。所以后序数组的最后一个元素一定是整棵树的根节点。
- 前序遍历:根、左子树、右子树。如果有前序,那么第一个元素是根。
利用中序+后序重建树的思路是:
- 从
postorder的最后一个元素取出根节点的值。 - 在
inorder中找到这个值的位置rootIndex。 - 那么中序序列的
[inLeft, rootIndex-1]是左子树对应中序区间,[rootIndex+1, inRight]是右子树对应中序区间。 - 关键:要知道后序序列中分别对应左子树和右子树的区间。这需要先知道左子树有多少个节点。左子树节点数量 =
rootIndex - inLeft。后序序列的顺序是“左子树-右子树-根”,所以后序序列区间中,紧挨着根元素之前的那一段就是右子树,右子树前面那一段是左子树。具体区间计算方式如下:- 后序左子树区间:
[postLeft, postLeft + leftCount - 1] - 后序右子树区间:
[postLeft + leftCount, postRight - 1] - 根:
postRight位置
- 后序左子树区间:
很多题解直接给出代码,但没解释为什么后序区间这样切。这里的关键在于leftCount的桥梁作用:中序序列里根把左右分开了,知道了左子树元素个数,就可以在后序序列里以同样的数量切出左子树部分。顺序上后序是先左后右再根,所以左子树部分紧贴在序列开头,右子树部分紧贴在根之前。
4.2 代码实现与边界处理
我一开始直接照模板写得比较冗余,后面精简成用递归区间索引的版本。代码:
class Solution { public: unordered_map<int, int> indexMap; TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { for (int i = 0; i < inorder.size(); i++) { indexMap[inorder[i]] = i; } return build(inorder, 0, inorder.size() - 1, postorder, 0, postorder.size() - 1); } TreeNode* build(vector<int>& inorder, int inLeft, int inRight, vector<int>& postorder, int postLeft, int postRight) { if (inLeft > inRight || postLeft > postRight) return nullptr; int rootVal = postorder[postRight]; TreeNode* root = new TreeNode(rootVal); int rootIndex = indexMap[rootVal]; int leftCount = rootIndex - inLeft; root->left = build(inorder, inLeft, rootIndex - 1, postorder, postLeft, postLeft + leftCount - 1); root->right = build(inorder, rootIndex + 1, inRight, postorder, postLeft + leftCount, postRight - 1); return root; } };边界条件是最容易写错的地方。为了防止越界,我习惯在递归函数开头同时判断inLeft > inRight和postLeft > postRight,只要有一个越界就返回空。其实如果能保证用法正确,只需要判断一个区间即可,但两个都写更保险,尤其对新手来说,宁可多写一个条件也别少。
构造左子树时,后序左子树的右边界是postLeft + leftCount - 1。如果leftCount == 0,这个值会变成postLeft - 1,此时postLeft > postRight成立,递归直接返回空指针。构造右子树时,后序右子树左边界是postLeft + leftCount,右边界是postRight - 1。如果右子树不存在,postLeft + leftCount == postRight,右边界postRight - 1就更小了,于是区间非法,返回空。这套索引计算在纸上画几遍,AC之后就不会再怕这类题了。
4.3 优化技巧:用HashMap
如果没有unordered_map,每次都要在inorder中线性查找根节点位置,整体复杂度会退化为O(n²)。用哈希表把中序序列里每个值对应的下标记录下来,查找就是O(1),整体递归时间复杂度O(n)。因为每个节点恰好被创建一次,每个节点在中序里的查找都是O(1)。
哈希表定义成成员变量或局部引用都可以。我习惯定义成成员变量,这样build函数不用额外传一个map参数,代码更干净。
这里有个细节:树中节点的值不重复,所以哈希表可以用值做键。如果题目出现重复值,就不能这么玩,必须改用值+其他信息或者手动循环查找。一般来说力扣这类题默认节点值唯一,不用过于担心。
4.4 常见bug:索引错位
我在第一次写这道题时,犯过一个特别经典的错:求leftCount的时候用的是rootIndex - inLeft,但我给左子树的后序区间写成了postLeft, postLeft + leftCount,忘了减一。这会导致左子树区间从postLeft到postLeft + leftCount,一共leftCount + 1个元素,多取了一个。多取的那个元素实际上是右子树的开头。然后递归构造时,根节点值取到了右子树的第一个节点,整个树就乱套了。
另一个常见错误是:直接用postRight - 1作为右子树的右边界,而忘了右子树的后序区间其实依然是到postRight - 1结束,但如果leftCount = 0,postLeft + leftCount == postRight,右子树区间变成[postRight, postRight - 1],也是空的,没问题。所以重点只需检查左子树的右边界。
调这类错误很考验基本功。我会在本地打印每次递归的区间值,比如:
build inorder[0,4] postorder[0,4] rootVal = 3, rootIndex = 2, leftCount = 2 left: inorder[0,1] postorder[0,1] right: inorder[3,4] postorder[2,3]打印出来一眼就能看出区间切得对不对。刷题时不要嫌麻烦,能可视化调试,理解速度会快很多。
5. 今日的坑位记录与调试心得
5.1 递归返回值选择的依据
做完这三道题,我自己总结了一套判断递归函数返回值的思路,分享出来:
- 如果只需要在递归过程中“记录”某些信息,比如最大值、最左值、路径集合,递归函数可以不返回值,用成员变量或外部容器存储。
- 如果需要上层根据下层的结果决定自己的行为,比如“是否存在一条路径”,那么必须返回布尔值,通常还要配合
||、&&这样的逻辑运算。 - 如果需要把下层结果“组装”到上层,比如构造二叉树、合并链表,那么返回节点指针是最自然的。
这个判断标准不是我一开始就会的,是刷了十几个二叉树题之后才慢慢摸出来的。建议你也试着在写递归之前先想清楚“我递归一层完事之后,上一层需要我这层给什么东西”,而不是上来就套模板。
5.2 全局变量 vs 参数传递
在“找树左下角的值”里,maxDepth和result是成员变量,这算是隐式全局变量。在“路径总和II”里,path是成员变量并手动回溯。很多新手会疑惑,什么时候能用全局变量,什么时候不行。
我的经验是:如果这个变量只是用来记录“结果”或“最值”,不会影响递归分支本身的前进方向,用全局变量最省事。如果这个变量本身是递归搜索的一部分路径,每个分支路径都不同,那就必须在递归中维护,并且只能通过“值传递”或“引用+回溯”两种方式之一。
值传递更安全,但频繁复制可能增加开销。在二叉树题目中,深度一般不会太大,复制整型完全无所谓。但像vector<int> path这种容器,值传递会导致大量拷贝,性能变差,所以用全局+回溯更合适。
5.3 层序遍历的提前跳出
“找树左下角的值”如果用层序遍历,其实还有一个小优化:从右往左层序遍历,那么最后一个访问到的节点就是最底层最左边的节点。这个思路非常巧妙,代码可以更短:
class Solution { public: int findBottomLeftValue(TreeNode* root) { queue<TreeNode*> q; q.push(root); int ans = root->val; while (!q.empty()) { TreeNode* node = q.front(); q.pop(); ans = node->val; if (node->right) q.push(node->right); if (node->left) q.push(node->left); } return ans; } };为什么这样可行?因为从右往左遍历,每一层最后弹出的节点就是该层最左边的节点,遍历完整棵树后,最后一次弹出的必然是最后一层最左边的节点。这个解法不需要记录层序号,也不需要判断i == 0,代码更简洁。缺点是“从右往左”的语义不够直观,容易让人困惑。面试时我更推荐先讲前序递归的解法,再补充这个巧妙版本,显得你理解得深。
5.4 构造二叉树的递归终止条件再澄清
构造二叉树时,终止条件严格来说有两个方向:如果inLeft > inRight返回空,或者如果postLeft > postRight返回空。因为树的中序和后序长度相同,对应的区间同时为空。但为了不让别人挑刺,我喜欢两个都写。
还有一个细节:在递归函数里,我先取后序的postRight作为根节点值,然后再查哈希表。你可能想问:如果递归区间本身非法,取postorder[postRight]会不会越界?不会,因为我开头已经判断了postLeft > postRight才返回空,所以只要走到取值这行,区间必然是非空的,postRight是合法下标。这个顺序很重要,如果你先取值再判断越界,就会访问到无效内存。
6. 三道题的综合对比与方法迁移
刷完这三道题后,可以横向画个表对比一下:
| 题目 | 递归返回值 | 核心参数 | 终止条件 | 单层逻辑 |
|---|---|---|---|---|
| 找树左下角的值 | void | 节点、深度 | 节点为空 | 更新最大深度和答案,递归左右 |
| 路径总和 | bool | 节点、剩余值 | 叶子节点判断 | 扣减当前值,递归左右 |
| 构造二叉树 | TreeNode* | 中序/后序区间 | 区间为空 | 用根值分割,递归左右并挂接 |
这个表看着简单,但做题时能自己总结出来,才算真懂了。你以后遇到“前序+中序构造二叉树”“最大二叉树”“根据前序后序构造二叉树”等变体,都能用这套思路去推导。
再举一个迁移例子:如果题目改成“从前序与中序遍历序列构造二叉树”,只要把后序序列的“右边界取根”改成前序序列的“左边界取根”,然后重新计算左右子树的区间即可。原理一模一样。掌握了中序+后序,前序+中序就是十分钟的事。
最后分享一个我在实际调试中非常受益的习惯:遇到递归的题目,先别急着写代码。拿一张纸,手动画一棵三层小树,把递归调用的顺序、进入和返回的每一步写出来。三次之后,很多“为什么这里要回溯”“为什么返回值是bool”的疑问自然就解开了。代码可以忘,这个把递归过程可视化理解的能力,是刷题路上最值钱的积累。希望这篇打卡笔记能帮到正在同一条路上挣扎的你。