☰
二叉树刷题进阶:层序遍历、路径回溯与中序后序构造全解析
2026/10/13 4:01:26 网站建设 项目流程

训练营进度走到第16天,二叉树这个大头算是啃到中段了。今天三道题分别是找树左下角的值、路径总和、从中序与后序遍历序列构造二叉树,光看名字好像没什么关联,实际上是一套递进关系:先学会“怎么遍历一棵树”,再学会“在遍历中做判断和回溯”,最后学会“从遍历结果反推树的原始结构”。把这三道题吃透,二叉树相关的递归思路、层序思路、数组切分思路都能串起来。这篇打卡笔记适合正在刷二叉树模块的朋友参考,无论你是第一次接触这些题,还是已经写过几遍想复盘边界细节,应该都能找到点东西。

1. 今日题单与整体拆解

1.1 三道题在二叉树知识体系中的定位

先给结论,今天这三道题分别对应二叉树的三个核心操作维度:读取、判定、重建。

找树左下角的值属于“读取”维度。它不要求你修改树的结构,也不要求复杂的路径记录,只需要在遍历过程中按某种规则把目标节点捞出来。这种题练的是对层序遍历和递归深度的敏感度。很多人觉得这道题简单,但真正手写的时候,很容易在“最底层”和“最左边”这两个条件的组合上翻车。

路径总和属于“判定+回溯”维度。它要求你在深度优先搜索的过程中边走边判断,一旦当前分支不满足条件还要退回去重新选路。这种题练的是递归函数的设计能力,尤其是终止条件和回溯动作的配合。好多人在这一步第一次感受到“递归为什么有去有回”。

从中序与后序遍历序列构造二叉树属于“重建”维度。前两道题都是在“读”树,这一道直接让你用遍历结果把树“写”回来。它练的是对三种遍历顺序(前序、中序、后序)内部规律的把握,以及数组区间切分的边界控制能力。这道题也是二叉树递归题里最容易写出越界 bug 的一类。

三道题组合在一起,从读树到搜路径再到重建树,刚好把二叉树题目最常见的几种出题方向都覆盖了。一天做完这三个方向,比单刷十道同类型题目的收获要更立体。

1.2 训练营刷题节奏的观察

第16天这个位置,其实是一个分水岭。前半个月应该已经打完了数组、链表、哈希表、字符串、栈队列这些线性结构,二叉树是第一个真正意义上的非线性结构。为什么这里容易卡人?因为二叉树的递归思维和之前线性结构的迭代思维完全是两码事。我之前刷数组题的时候,一个 for 循环能解决的事基本不会想递归;但到了二叉树这里,递归几乎是默认选择。

我自己的体会是,二叉树题目要想刷得顺,必须先建立两个底层认知。第一,递归函数的三要素——参数与返回值、终止条件、单层递归逻辑——不是背出来的,是设计出来的。第二,任何遍历都是在访问节点,难点从来不在“怎么访问”,而在“访问之后拿这个节点做什么”。今天这三道题,本质都是在回答“访问到某个节点之后,我要做什么”:找左下角是“更新答案”,路径总和是“判断剩余值”,构造二叉树是“切分区间继续递归”。把这个思路捋顺了,后面刷二叉搜索树也会顺手很多。

2. 找树左下角的值:别被“左下角”三个字骗了

2.1 题目理解与第一直觉

题目本身一句话:给定一个二叉树,返回该树最底层最左边节点的值。很多人第一反应是“最底层最左边?那不就是一路往左走到叶子?”这是全题最大的坑。最底层最左边节点的意思是:在树的最后一层(深度最大的那一层),取最靠左的那个节点。注意,最后一层可能没有左孩子,那么最左边的节点可能是某个右孩子。

举个例子,一棵树只有右子树一直延伸到第四层,而左子树在第三层就结束了,那么最底层是第四层,最左边的节点就是那条右链上的节点,而不是第三层左子树里的任何节点。搞清楚这个前提,题目才算真正读懂了。搞反方向的话,后面写的算法再漂亮也是错的。

2.2 层序遍历解法:最直观也最不容易错

既然要找最底层的最左边节点,最直接的想法就是一层一层往下扫,这就是层序遍历。每一层按从左到右的顺序访问节点,当层序遍历完整棵树后,最后一次遍历的那一层的第一个节点就是答案。

具体实现上,用队列做 BFS。每次处理一层时,记录该层第一个弹出的节点值,层数增加时覆盖更新。等队列清空,最后保留下来的值就是最底层最左边节点的值。这里有个小技巧:不需要单独记录“当前是第几层”,只需要在每一轮开始时把当前队列长度取出来,这个长度就是当前层的节点数量,处理完这么多节点后自然进入下一层。

class Solution { public: int findBottomLeftValue(TreeNode* root) { queue<TreeNode*> q; q.push(root); int result = 0; while (!q.empty()) { int size = q.size(); for (int i = 0; i < size; i++) { TreeNode* node = q.front(); q.pop(); if (i == 0) result = node->val; // 每层第一个节点 if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } return result; } };

关键就在if (i == 0)这里。因为队列是按从左到右的顺序依次入队的,每一层第一个出队的节点就是该层最左边的节点。当循环结束时,result 存的是最后一层(也是最后处理的一层)的最左节点值,正好符合题目要求。我实测下来,这种写法几乎不需要调试,边界情况也少。

2.3 递归解法:深度优先下的全局更新

层序遍历解法适合第一次接触这道题的人。如果你已经对递归游刃有余,也可以用 DFS 来做,思路更符合“深度优先”的直觉。

递归做法的核心是维护两个全局变量:一个记录当前遍历到的最大深度,一个记录在最大深度下最左节点的值。遍历过程中,每当到达一个节点,如果它的深度比之前记录的最大深度还大,就更新最大深度和节点值。

这里必须强调一个细节:只有当depth > maxDepth时才更新,而不是>=。原因和层序解法一样,如果是同一深度,我们要保留的是更左边的节点。而 DFS 默认是先走左子树再走右子树,所以同一深度下先访问到的节点就是更左边的节点,用>可以保证后面的右子树节点不会覆盖掉左边的结果。

class Solution { private: int maxDepth = -1; int result = 0; void dfs(TreeNode* node, int depth) { if (node == nullptr) return; if (depth > maxDepth) { maxDepth = depth; result = node->val; } dfs(node->left, depth + 1); dfs(node->right, depth + 1); } public: int findBottomLeftValue(TreeNode* root) { dfs(root, 0); return result; } };

这里把初始 maxDepth 设为 -1,可以保证根节点在 depth = 0 的时候也能触发更新。如果你把 maxDepth 初始化为 0,根节点就不会更新,一旦整棵树只有根节点,result 就是初始值而不是根节点值,这种边界情况在面试里特别容易翻车。我第一次手写就踩过这个坑,所以特意拿出来强调。

2.4 两种解法怎么选

层序和递归各有各的适用场景。层序遍历写起来模板化,不容易出错,而且在需要“逐层处理”的题目里可以直接复用;递归写法代码更短,不需要额外队列,但需要对全局变量的更新时机很敏感。

我个人建议:训练阶段两种解法都写一遍。层序解法帮你建立 BFS 的框架感,递归解法帮你强化 DFS 中深度更新时的取舍逻辑。如果面试现场时间紧,优先写层序,因为它逻辑简单、边界少、考官也好理解。

3. 路径总和:递归的终止条件才是灵魂

3.1 题目描述与思路转换

路径总和这题(题库编号 112)要求判断二叉树中是否存在一条从根节点到叶子节点的路径,使得路径上所有节点值之和等于目标值。

这类题有一个非常实用的技巧:不要从头到尾维护一个累加和,而是用目标值一路做减法。初始时传入 targetSum,每访问一个节点就用 targetSum 减去当前节点的值,走到叶子节点时判断剩余值是否刚好等于 0。这样做的好处是省去了“回溯时撤销累加”的麻烦,因为减法天然自带“路径局部性”。

为什么减法的写法更贴近递归本质?因为递归函数的返回值本质上是在问“以当前节点为根的子树里,是否存在一条到叶子节点的路径,其剩余待凑值为 targetSum - node.val”。每一次递归的入参变化本身就是路径信息,不需要额外维护一个全局变量来记录当前总和,这样逻辑就清爽很多。

3.2 递归实现与终止条件陷阱

先看代码:

class Solution { public: bool hasPathSum(TreeNode* root, int targetSum) { if (root == nullptr) return false; if (root->left == nullptr && root->right == nullptr) { return targetSum == root->val; } bool leftRes = false; bool rightRes = false; if (root->left) { leftRes = hasPathSum(root->left, targetSum - root->val); } if (root->right) { rightRes = hasPathSum(root->right, targetSum - root->val); } return leftRes || rightRes; } };

这里最值得玩味的终止条件:只有当节点是叶子节点(左右孩子都为空)时才做最终判断。一开始我写的时候,习惯性地在进入节点时判断if (targetSum == 0) return true;,这个写法是错的。因为路径总和要求的是“根到叶子”的完整路径,如果中间某个节点恰好让剩余值变成 0,但下面还有孩子,这条路径并没有走完,不能算满足条件。

另外一种更常见也更清晰的写法是:传入参数时直接减掉当前节点值,递归进入子节点时判断叶子。这种写法把“进入子节点前做减法”和“退出子树后加回来”放在一起,回溯痕迹非常明显:

bool traversal(TreeNode* node, int count) { if (node->left == nullptr && node->right == nullptr) { return count == 0; } if (node->left) { count -= node->left->val; if (traversal(node->left, count)) return true; count += node->left->val; // 回溯 } if (node->right) { count -= node->right->val; if (traversal(node->right, count)) return true; count += node->right->val; } return false; }

这一版就非常直白地体现了回溯:在进入左子树前先减去左孩子的值,如果左子树返回 false,就加回来,恢复原状,然后再去尝试右子树。回溯的本质就是“撤销当前选择,回到上一个状态”,这里的加回来就是撤销。理解了这个动作,后面做路径收集类的题会轻松很多。

3.3 扩展:路径总和 II 与结果收集

如果题目要求输出所有满足条件的路径,那就是第 113 题。思路完全一样,只是需要把路径记录下来。这就要用到一个回溯数组 path,每进入一个节点就把它加入 path,回溯时再把它弹出。

核心代码(局部示意):

void traversal(TreeNode* node, int count) { if (node->left == nullptr && node->right == nullptr && count == 0) { result.push_back(path); return; } if (node->left) { path.push_back(node->left->val); traversal(node->left, count - node->left->val); path.pop_back(); } if (node->right) { path.push_back(node->right->val); traversal(node->right, count - node->right->val); path.pop_back(); } }

注意这里的顺序:先 push_back,再递归,再 pop_back。这个顺序就是“选择—递归—撤销选择”的标准节奏。很多人在递归里忘记 pop_back,导致 path 里混入不属于当前路径的节点,输出的路径全是乱的。这是路径收集题最经典的错误,没有之一。我见过身边好几个同学在讨论群里发错误截图,十次里有八次都是因为这个。

3.4 路径相关题目的通用套路

做完这两道题其实可以提炼出一个模板:凡是“从根到叶子找路径、判断路径和、收集路径”这类题目,都可以用下面这个框架:

  1. 递归函数接收当前节点、剩余目标值、当前路径。
  2. 到达叶子节点时,判断剩余目标值是否为 0。
  3. 非叶子节点时,分别尝试左右孩子,每次尝试前把子节点信息加入路径,递归返回后撤销。

用这个框架,路径总和、路径总和 II、二叉树的所有路径(题库编号 257)都能一套思路解下来。区别只在于叶子节点处的处理动作是“返回 true”还是“收集路径”。框架一旦搭好,剩下的就是微调,而不是重写。

4. 从中序与后序遍历序列构造二叉树:切分边界是重中之重

4.1 为什么中序+后序能唯一确定一棵二叉树

题目(题库编号 106)给出两个数组:中序遍历序列 inorder 和后序遍历序列 postorder,要求还原出整棵二叉树。

先复习遍历顺序的基本规则:

  • 中序遍历:左子树 -> 根 -> 右子树
  • 后序遍历:左子树 -> 右子树 -> 根

正因为后序遍历的最后一个元素一定是整棵树的根节点,所以第一步永远是把根节点“抠”出来。然后拿着这个根节点的值去中序遍历里找它的位置,中序数组中根左边的部分就是左子树的中序序列,根右边的部分就是右子树的中序序列。

有了左子树有多少个节点,就可以反过来在后序数组中做切分:因为后序遍历的数组里,左子树的所有节点一定连在一起、右子树的所有节点也一定连在一起,而且整体顺序是“左子树后序 + 右子树后序 + 根”。知道了左右子树各有多少个节点,后序数组里哪些区间属于左子树、哪些属于右子树就一目了然了。整个推导环环相扣,找不到任何一个环节是可以省略的。

4.2 六步切分法的过程拆解

我梳理了一套固定的切分步骤,每一步都不能乱:

  1. 取后序数组的最后一个元素,作为根节点的值,创建节点。
  2. 在中序数组中找到根节点位置的下标 index。
  3. 中序数组左半边 [0, index) 是左子树的中序序列。
  4. 中序数组右半边 [index+1, len) 是右子树的中序序列。
  5. 根据左子树序列的长度 leftSize,切分后序数组:前 leftSize 个元素是左子树的后序序列,剩下的(不包括最后一个已用掉的根)是右子树的后序序列。
  6. 用左子树的中序+后序递归构造左孩子,用右子树的中序+后序递归构造右孩子。

这里我强烈建议所有区间都用左闭右开 [start, end) 来管理。为什么?因为左闭右开的区间天然避免了 +1、-1 的偏移混乱,切分时只需要记住“end 不包含”,递归时直接传子区间进去,边界一致性有保证。如果你一会儿用闭区间一会儿用开区间,大概率会在某个递归深度上越界。这算是构造类题目里最高频的失误来源。

4.3 代码实现

以 C++ 为例,用下标传区间的方式避免频繁拷贝数组:

class Solution { public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { return traversal(inorder, 0, inorder.size(), postorder, 0, postorder.size()); } TreeNode* traversal(vector<int>& inorder, int inStart, int inEnd, vector<int>& postorder, int postStart, int postEnd) { if (postStart == postEnd) return nullptr; int rootVal = postorder[postEnd - 1]; TreeNode* root = new TreeNode(rootVal); // 在中序数组中找根节点的位置 int index = inStart; for (; index < inEnd; index++) { if (inorder[index] == rootVal) break; } // 左子树节点数量 int leftSize = index - inStart; // 切分左子树 root->left = traversal(inorder, inStart, index, postorder, postStart, postStart + leftSize); // 切分右子树 root->right = traversal(inorder, index + 1, inEnd, postorder, postStart + leftSize, postEnd - 1); return root; } };

重点讲一下后序数组的切分逻辑:

  • 左子树的后序区间:起始位置是 postStart,结束位置是 postStart + leftSize。因为后序数组前 leftSize 个元素属于左子树。
  • 右子树的后序区间:起始位置是 postStart + leftSize,结束位置是 postEnd - 1。注意这里要把最后一个元素(根节点)排除掉,所以是 postEnd - 1,这正好和左闭右开区间统一。

我当时第一次写的时候,右子树结束位置写成了 postEnd,结果递归到深层后越界取到了根节点或更后面的垃圾值,树的结构全乱了。后来养成了一个习惯:每次切分完都问自己一句“最后一个元素我用了没有?”如果用了,就在所有涉及它的区间里把它排掉。这个习惯帮我避开了大量越界问题。

4.4 中序+前序构造与中序+后序构造的对照

这类题还有一道镜像题(题库编号 105):根据前序遍历和中序遍历构造二叉树。逻辑完全对称:

  • 前序数组的第一个元素是根。
  • 在中序里找到根的位置,切分出左右子树的中序区间。
  • 前序数组里,第一个元素(根)后面紧跟 leftSize 个左子树节点,然后才是右子树节点。
  • 前序数组的切分:左子树 [1, 1+leftSize),右子树 [1+leftSize, end)。

对照来看,规律其实很统一:中序数组永远按“根的位置”切成两段,另一个遍历数组则按“左右子树各自的节点数量”切成两段。只要知道左右子树的数量,不管给的是前序还是后序,都能准确切分。

这里有个小思考:为什么必须要有中序?如果有前序+后序,能否构造二叉树?答案是不能唯一确定。因为前序和后序都是先遍历子树再遍历根或先遍历根再遍历子树,当某个节点只有一个孩子时,你无法判断这个孩子到底在左边还是右边。中序恰恰提供了“根左边是左子树、根右边是右子树”的绝对约束,所以中序是不可或缺的。这个点面试时候被追问过,答上来能让考官对理解深度加分。

4.5 递归构造时的效率优化

如果要求整体复杂度更低,上面这份代码还有一个可以优化的点:每次都要用 for 循环在中序数组里线性查找根的位置,总体的时间复杂度是 O(n log n)(每层递归都要扫一遍)。可以改用哈希表预处理中序数组中每个值对应的下标,把查找降到 O(1),整体降到 O(n)。

class Solution { private: unordered_map<int, int> indexMap; public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { for (int i = 0; i < inorder.size(); i++) { indexMap[inorder[i]] = i; } return traversal(inorder, 0, inorder.size(), postorder, 0, postorder.size()); } TreeNode* traversal(vector<int>& inorder, int inStart, int inEnd, vector<int>& postorder, int postStart, int postEnd) { if (postStart == postEnd) return nullptr; int rootVal = postorder[postEnd - 1]; TreeNode* root = new TreeNode(rootVal); int index = indexMap[rootVal]; int leftSize = index - inStart; root->left = traversal(inorder, inStart, index, postorder, postStart, postStart + leftSize); root->right = traversal(inorder, index + 1, inEnd, postorder, postStart + leftSize, postEnd - 1); return root; } };

这里注意:indexMap 是成员变量,在 buildTree 里先初始化再调用递归。如果直接在递归里每次都构建哈希表,那复杂度反而退化了。所以哈希表一定要放在递归外面只建一次。类似的优化思路在前序+中序构造那题里也完全适用,可以一并改掉。

5. 今日踩坑实录与复盘笔记

5.1 高频错误速查表

把今天三道题里最容易踩的坑整理成一张表,方便以后复习时候快速扫一眼:

题目典型错误错误原因正确做法
找树左下角的值递归更新用>=同深度右节点覆盖左节点用>,保留先访问到的左节点
找树左下角的值maxDepth 初始化为 0只有根节点时无法更新初始化为 -1
路径总和非叶子节点就判断和为 0路径必须到叶子结束只有左右孩子都为空才判断
路径总和递归传入 targetSum - root->val 后又修改入参重复减导致值异常传参时减,不要在函数体内再减
构造二叉树后序右子树区间结束写成 postEnd根节点被重复使用写成 postEnd - 1
构造二叉树区间一会儿左闭右闭一会儿左闭右开边界混乱导致越界全程统一左闭右开
构造二叉树哈希表在递归内重复构建复杂度退化在入口处构建一次

这张表我每次复盘都会更新,到现在已经攒了不少。比起反复翻完整代码,这种“错误—原因—正确做法”三列的速查表在二刷的时候效率最高。

5.2 三个非语法层面的细节心得

除了上面的硬错误,还有几个非语法层面的细节,对刷题体验影响很大,值得单独拿出来说。

第一,递归函数的参数名和注释要能表达“入参的含义”。比如路径总和里我习惯把第二个参数命名为 count 而不是 targetSum,因为递归过程中它代表的是“剩余待凑的值”,不是最初的目标值。变量名一准确,写终止条件的时候思路就清晰。这听起来像小事,但实际写代码的时候影响很大。

第二,构造二叉树这类题,动手写代码之前先在纸上画一遍切分过程。拿一个小例子,把中序和后序两个数组写在纸上,用方括号标出每次递归对应的区间,画两三层之后你就会发现规律特别明显。直接在编辑器里硬写,十个里有八个会栽在边界上。我画了大概三个例子之后,后序数组的切分就再也没错过。

第三,写递归时心里默念“让子问题自己解决自己”。构造二叉树里,你不需要在递归函数内部手动去拼接左右子树的中序数组,只需要把区间传进去。很多初学者会不自觉地在函数里 new 出新的 vector 去拷贝子数组,这样功能上没错,但时间和空间都浪费了。用下标传区间是更专业的做法,也更容易配合哈希表优化。

5.3 训练营打卡的节奏建议

第16天这个位置刚好是二叉树模块承上启下的节点。前面的数组、链表、哈希表题目,很多可以靠“背模板”过关;但二叉树模块开始,题目之间开始出现明显的思路迁移,今天从遍历到回溯到构造,每一步都建立在前一步的理解上。如果只追求“把代码交上去”,很容易在题目变形后无从下手。

我的建议是:每天打卡结束后,把当天的三道题按“题目类型—核心考点—通用模板”三个维度整理到一个笔记里。比如今天是:

  • 找树左下角的值:层序/递归遍历 + 深度更新
  • 路径总和:DFS + 终止条件 + 回溯
  • 构造二叉树:中序 + 前/后序定根 + 区间切分

这样积累到后期,复习时不需要翻原题,看自己整理的关键词就能快速回忆整个思路。我试过几次,效果比反复刷题要好,因为整理关键词的过程本身就是一种主动回忆,比被动刷题更能加深理解。

最后说一句个人感受。很多人觉得二叉树难,是因为总想一口气把递归想明白,但在递归里“想明白”往往不如“写明白”。我今天写路径总和的时候,代码写完后反复问自己:终止条件对吗?回溯加回来了吗?路径总和 II 收集路径的时候 pop_back 做了吗?这些问题一个一个核查一遍,比在那里空想要高效得多。训练营打卡的乐趣也在于此:每一天的题目不是孤立的点,而是把前面学过的思想重新组合一遍。明天应该就要进入二叉搜索树了,回头再看今天的中序+后序构造,说不定又会有新的理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询