资讯动态

二叉树递归与回溯核心实战:六道题吃透专题训练

发布时间:2026/10/13 11:46:05 来源:尧图企业网站定制
学习算法最怕的不是不会而是“眼睛看会了手一写就废”。二叉树这一块尤其容易这样个中原因倒也不复杂——递归本身就是反直觉的再加上回溯、切分区间这些概念一起上稍微不扎实就栽了。我是在训练营第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 二叉树的所有路径回溯不是模板是递归的自然产物“二叉树的所有路径”这道题让我第一次真正意识到回溯不是一种需要额外记忆的算法框架它本身就是递归在路径收集场景下长出来的东西。题目要求从根节点到所有叶子节点的路径每条路径用“-”连接。我的第一版写法很粗糙只在递归函数里把当前节点值塞进一个字符串参数最后发现一条路径的前半段会被后面的路径重复拼接导致路径乱套。后来我意识到问题出在一个关键的机制上路径是全局共享的如果不“撤销”已经访问的节点后面的递归分支会把不该包含的节点混进来。这就是回溯的真正含义。用代码说话vectorstring res; vectorint 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(vectorint inorder, vectorint 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里磨下来的那些基本功返回值类型、终止条件、回溯状态、区间切分。最后再分享一个小技巧每次刷完题把通过的代码放在本地仓库里然后在题解中最下方写一行“这题我踩了什么坑”。不需要写长篇大论只需要一句话。比如“偷懒没写右子树回溯”等过一阵子回过头看这些零散的经验往往比刷了三遍重复题更值钱。算法这条路没有捷径但把每一步踩过的坑记录下来确实是普通人能做到的最低成本的加速方式。祝你在二叉树的专题阶段稳扎稳打后面回溯和动态规划的时候你会发现今天的每一道题都没有白刷。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑