尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++二叉树递归求解:LeetCode刷题核心框架与高频题型解析

C++二叉树递归求解:LeetCode刷题核心框架与高频题型解析 1. 项目概述为什么递归是二叉树刷题的“定海神针”如果你在LeetCode上刷过一阵子题尤其是树相关的题目大概率会和我有同样的感受面对二叉树递归解法常常是那个最直观、最优雅有时甚至是唯一的“标准答案”。项目标题“leetcode_刷题总结(c)_二叉树递归求解”精准地指向了算法面试中一个至关重要的核心技能点——用C实现二叉树的递归求解。这不仅仅是一个简单的解法合集。在真实的面试场景和日常开发中二叉树结构无处不在比如文件系统目录树、DOM树、决策树模型而递归思想是理解和操作这类自相似结构的天然钥匙。很多初学者觉得递归“绕”容易栈溢出但在二叉树领域递归的代码往往比迭代使用栈模拟更简洁逻辑更清晰。这个总结项目的价值就在于它系统性地梳理了用递归“征服”二叉树各类问题的通用思维框架和代码模板帮你把看似复杂的题目拆解成几个重复的、简单的子问题。简单来说这个项目适合所有正在使用C刷LeetCode希望在树形结构题目上形成肌肉记忆、突破解题瓶颈的开发者。无论是正在准备面试的应届生还是希望巩固基础的中级工程师掌握这套递归方法论都能让你在遇到“二叉树的直径”、“路径总和”、“最近公共祖先”这类高频考题时心中不慌下笔有神。接下来我会结合自己多年的刷题和面试官经验带你深入这套方法论的每一个细节。2. 递归求解二叉树的核心思想与框架递归之所以能成为二叉树问题的利器根源在于二叉树本身就是一个递归定义的数据结构每个节点最多有两个子节点左子树和右子树本身也是二叉树。这种自相似的特性让“分而治之”的策略变得无比自然。2.1 递归的“三部曲”思维模型处理任何一个二叉树递归问题你都可以遵循以下三个步骤来思考我称之为“递归三部曲”确定递归函数的参数和返回值你需要明确这个递归函数需要接收什么信息当前节点指针、额外参数如目标和以及它需要返回什么结果是一个节点指针、一个布尔值、一个整数还是一个数组。这是设计递归的起点。确定终止条件这是防止无限递归的关键。对于二叉树最常见的终止条件就是当前节点为空nullptr。根据问题不同终止条件可能还包括到达叶子节点或者找到了满足条件的路径。确定单层递归的逻辑这是最核心的部分。你需要想清楚在当前节点这一层应该做什么操作比如访问节点值、计算深度、累加路径和然后如何递归地调用函数来处理左子树和右子树最后如何将左右子树的结果组合起来返回给上一层。2.2 两种基础的递归遍历框架所有复杂的二叉树递归算法都建立在三种深度优先遍历前序、中序、后序的变体之上。其中后序遍历在解决许多复杂问题时尤为强大。后序遍历递归框架解决子树问题/** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: // 这是一个典型的后序遍历递归函数框架 ReturnType traversal(TreeNode* node) { // 1. 终止条件 if (node nullptr) { return /* 针对空节点的返回值例如0 nullptr 或特定标识 */; } // 2. 递归处理左子树后序遍历左右中 ReturnType leftResult traversal(node-left); // 3. 递归处理右子树 ReturnType rightResult traversal(node-right); // 4. 处理当前节点“中”的操作 // 这里会根据leftResult和rightResult计算当前节点相关的答案 ReturnType currentResult /* 基于node-val, leftResult, rightResult的计算 */; return currentResult; } };注意后序遍历的顺序是“左右中”。为什么很多难题用它因为它先收集了左右子树的信息当前节点就能基于完整的子树信息做出决策。例如计算二叉树直径、判断平衡二叉树都需要先知道左右子树的高度。前序遍历递归框架解决“自上而下”传递信息的问题class Solution { public: // 前序遍历中左右 void preorderTraversal(TreeNode* node, vectorint path, int targetSum) { // 终止条件 if (node nullptr) { return; } // 处理当前节点“中” path.push_back(node-val); // 检查是否满足条件例如路径和等于targetSum // ... // 递归处理左子树“左” preorderTraversal(node-left, path, targetSum); // 递归处理右子树“右” preorderTraversal(node-right, path, targetSum); // 回溯在返回前撤销对当前节点的处理 path.pop_back(); } };实操心得前序遍历适合需要“记录路径”或“在进入子树前就进行判断”的场景。注意代码中的path.pop_back()这是回溯算法的典型体现。在递归返回时必须撤销当前节点对共享状态如path向量的修改否则路径信息会错乱。这是递归回溯中极易出错的地方。3. 高频题型递归解法深度解析掌握了框架我们来看具体问题。以下选取LeetCode中最具代表性的几类二叉树递归问题拆解其思路和代码实现细节。3.1 深度与高度问题理解递归的返回值题目示例104. 二叉树的最大深度 110. 平衡二叉树这类问题的核心是理解“深度”和“高度”的区别并设计正确的递归返回值。深度从根节点到该节点的边数。求深度适合用前序遍历从上往下计数。高度从该节点到最远叶子节点的边数。求高度适合用后序遍历从下往上累加。求二叉树的最大深度后序遍历思路class Solution { public: int maxDepth(TreeNode* root) { // 终止条件空节点高度为0 if (root nullptr) return 0; // 递归计算左子树高度 int leftHeight maxDepth(root-left); // 递归计算右子树高度 int rightHeight maxDepth(root-right); // 当前节点的高度 左右子树较高者 1 int currentHeight max(leftHeight, rightHeight) 1; return currentHeight; } };为什么用后序遍历因为一个节点的高度取决于其左右子树的高度。我们必须先知道leftHeight和rightHeight才能计算currentHeight。这是一个典型的“先子后父”的后序过程。判断平衡二叉树后序遍历的进阶应用class Solution { public: // 递归函数返回当前子树的高度如果已经不是平衡树则返回-1作为特殊标记 int getHeight(TreeNode* node) { if (node nullptr) return 0; // 空树高度为0 int leftHeight getHeight(node-left); // 提前剪枝如果左子树已不平衡直接返回-1不再计算右子树 if (leftHeight -1) return -1; int rightHeight getHeight(node-right); if (rightHeight -1) return -1; // 判断当前节点是否平衡不平衡返回-1平衡则返回真实高度 if (abs(leftHeight - rightHeight) 1) { return -1; } else { return max(leftHeight, rightHeight) 1; } } bool isBalanced(TreeNode* root) { return getHeight(root) ! -1; } };注意事项这里使用了一个常用技巧——用特殊返回值-1传递错误状态。这样可以在递归过程中一旦发现不平衡就立即层层向上返回避免不必要的计算。同时注意abs(leftHeight - rightHeight) 1这个判断条件它必须放在计算了左右子树高度之后再次印证了后序遍历的必要性。3.2 路径问题回溯法的经典舞台题目示例112. 路径总和 113. 路径总和 II 257. 二叉树的所有路径路径问题通常需要记录从根节点到当前节点的路径并在叶子节点进行判断。这里会频繁用到“回溯”。路径总和 I判断是否存在class Solution { public: bool hasPathSum(TreeNode* root, int targetSum) { // 终止条件1空节点不存在路径 if (root nullptr) return false; // 终止条件2叶子节点判断剩余值是否等于节点值 if (root-left nullptr root-right nullptr) { return targetSum root-val; } // 单层逻辑分别向左、右子树递归目标值减去当前节点值 bool leftHas hasPathSum(root-left, targetSum - root-val); bool rightHas hasPathSum(root-right, targetSum - root-val); // 左右子树任意一条路径满足即可 return leftHas || rightHas; } };这个解法很简洁它隐式地使用了“回溯”targetSum - root-val这个参数在递归调用时被修改但递归返回后在当前层的targetSum值并没有改变因为它是值传递。这是一种天然的回溯。路径总和 II找出所有路径class Solution { public: vectorvectorint result; vectorint path; void traversal(TreeNode* node, int targetSum) { if (node nullptr) return; // 前序遍历位置进入节点时加入路径 path.push_back(node-val); targetSum - node-val; // 终止条件到达叶子节点且满足目标和 if (node-left nullptr node-right nullptr targetSum 0) { result.push_back(path); // 找到一条合格路径 // 注意这里不能return因为还要执行后面的pop_back进行回溯 } // 递归左右子树 traversal(node-left, targetSum); traversal(node-right, targetSum); // 后序遍历位置离开节点时从路径中移除回溯 path.pop_back(); // targetSum是值传递无需回溯 } vectorvectorint pathSum(TreeNode* root, int targetSum) { result.clear(); path.clear(); traversal(root, targetSum); return result; } };踩坑实录这里最容易犯两个错误。第一在叶子节点满足条件时如果直接return就会跳过后面的path.pop_back()导致路径没有被正确清理影响后续递归。第二result.push_back(path)时如果直接传入path存入的是path的引用后续path被修改result里的结果也会变。好在vector的push_back会调用拷贝构造函数这里没问题。但如果你用的是其他引用类型数据结构就需要手动拷贝一份如result.push_back(vectorint(path))。3.3 子树与公共祖先问题递归的“侦察兵”模式题目示例236. 二叉树的最近公共祖先 (LCA) 572. 另一棵树的子树这类问题要求递归函数不仅完成计算还要充当“侦察兵”向上级汇报是否发现了目标。二叉树的最近公共祖先LCAclass Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { // 终止条件如果遇到空节点或者直接找到了p或q则返回当前节点 if (root nullptr || root p || root q) { return root; } // 派“侦察兵”去左子树和右子树侦查 TreeNode* left lowestCommonAncestor(root-left, p, q); TreeNode* right lowestCommonAncestor(root-right, p, q); // 处理侦察兵带回来的情报 if (left ! nullptr right ! nullptr) { // 左右子树分别找到了p和q那么当前节点就是LCA return root; } if (left nullptr) { // 左子树没找到那结果就在右子树传上来的那个节点可能是p、q或LCA return right; } // 否则结果在左子树传上来的那个节点 return left; } };这个解法的精妙之处在于递归函数的定义在以root为根的树中查找p和q如果找到任意一个或找到LCA就返回这个节点否则返回nullptr。如果左右子树均非空说明p和q分居两侧root是LCA。如果一边为空说明p和q都在另一边LCA也就是另一边传上来的那个节点。这个逻辑同样能处理p是q的祖先的情况。判断子树class Solution { public: // 判断两棵树是否完全相同 bool isSameTree(TreeNode* s, TreeNode* t) { if (s nullptr t nullptr) return true; if (s nullptr || t nullptr) return false; if (s-val ! t-val) return false; return isSameTree(s-left, t-left) isSameTree(s-right, t-right); } bool isSubtree(TreeNode* root, TreeNode* subRoot) { // 终止条件如果主树为空肯定不是子树除非子树也为空但题目规定非空 if (root nullptr) return false; // 单层逻辑判断当前节点开始的树是否和subRoot相同 // 或者递归判断左/右子树是否包含subRoot return isSameTree(root, subRoot) || isSubtree(root-left, subRoot) || isSubtree(root-right, subRoot); } };核心技巧这是一个“双重递归”。isSubtree递归地遍历主树的每个节点对于每个节点再用另一个递归函数isSameTree判断从该节点开始的子树是否和目标子树完全相同。时间复杂度是O(M*N)其中M、N分别是两棵树的节点数。虽然存在更优的KMP算法O(MN)但递归解法在面试中写出并解释清楚通常已经足够。4. 递归优化与陷阱规避实战指南递归虽好但用不好也会带来麻烦。下面分享一些实战中总结的优化技巧和避坑指南。4.1 避免重复计算记忆化搜索有些递归问题存在大量的重复子问题最典型的是二叉搜索树中的众数查找虽然不是严格重复但类似思想和树形DP问题。例如如果递归函数中需要多次计算同一棵子树的高度就会造成指数级的时间浪费。示例带记忆化的递归求节点数虽然此题直接递归即可此处用于演示思想class Solution { public: unordered_mapTreeNode*, int memo; // 记忆化缓存 int countNodes(TreeNode* root) { if (root nullptr) return 0; // 先查缓存如果计算过直接返回 if (memo.find(root) ! memo.end()) { return memo[root]; } // 否则递归计算 int leftCount countNodes(root-left); int rightCount countNodes(root-right); int total leftCount rightCount 1; // 存入缓存 memo[root] total; return total; } };对于完全二叉树有更优的利用性质判断左右子树高度的O(logN * logN)解法但记忆化是一个通用的、用于优化重叠子问题的强大工具。在更复杂的树形DP问题如打家劫舍III中记忆化或直接使用DP数组是必备技能。4.2 递归深度与栈溢出C中函数调用栈空间是有限的。对于一棵极度不平衡的二叉树退化成链表递归深度可能达到节点数N例如10^5这很可能导致栈溢出Stack Overflow。应对策略尾递归优化遗憾的是C标准并不强制编译器进行尾递归优化TCO。而且二叉树递归大多不是尾递归形式因为需要处理左右子树的结果。迭代法替代对于可能深度过大的问题最稳妥的方法是使用迭代法手动维护一个栈来模拟递归过程。例如二叉树的前中后序遍历都有对应的迭代算法。平衡二叉树如果数据可控确保输入的二叉树相对平衡可以极大降低递归深度。实操心得在面试中如果面试官没有特别说明通常默认二叉树是相对平衡的递归解法可以接受。但如果他追问“如果树退化成链表怎么办”你必须能给出迭代解法作为备选这体现了你的思维严密性。4.3 递归中的参数传递值传、引用与指针这是C刷题时的一个关键细节选错了可能导致错误或性能低下。传递方式语法示例特点适用场景值传递void dfs(TreeNode* node, int sum)函数内对sum的修改不影响外层。每次递归调用都会复制一份值。简单的基本类型int, bool且需要天然回溯的场景如路径总和I。对于复杂对象vector, string避免使用复制开销大。引用传递void dfs(TreeNode* node, vectorint path)函数内操作的是外层变量的别名修改直接影响外层。需要收集路径、共享状态且需要显式回溯手动push/pop的场景。指针传递void dfs(TreeNode* node, int* pSum)类似引用但可能为空。操作指针指向的内存。较少用于递归参数更多用于修改树节点本身如TreeNode*。经典错误示例// 错误试图用值传递的path来记录所有路径结果会混乱。 void dfs(TreeNode* node, vectorint path, vectorvectorint result) { if (!node) return; path.push_back(node-val); if (isLeaf(node)) result.push_back(path); // 每次push_back的都是不同的path副本 dfs(node-left, path, result); // 左递归拿到的是包含当前节点的path副本 dfs(node-right, path, result); // 右递归拿到的也是**同一个层级的**包含当前节点的path副本左右分支的path互不干扰不它们都基于同一个初始path但却是不同的拷贝。 }这个代码逻辑上可能也能得到正确结果因为每次递归都拷贝了整个path但空间复杂度极高O(N^2)因为每一层递归都在复制向量。正确做法是使用一个引用传递的path并配合回溯。5. 从递归到迭代思维拓展与代码实现尽管递归是理解二叉树问题的基石但掌握迭代解法同样重要。它能加深你对遍历过程的理解也是应对深度过大问题的保底方案。5.1 使用栈模拟递归过程以前序遍历为例递归的本质是系统帮我们维护了一个调用栈。我们可以显式地用一个stackTreeNode*来模拟。迭代法前序遍历vectorint preorderTraversal(TreeNode* root) { vectorint result; if (root nullptr) return result; stackTreeNode* stk; stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); stk.pop(); result.push_back(node-val); // 中 // 注意栈是后进先出所以先右后左 if (node-right) stk.push(node-right); // 右 if (node-left) stk.push(node-left); // 左 } return result; }与递归的对应关系stk.push(root)对应第一次函数调用。result.push_back(node-val)对应递归函数中“处理当前节点”的部分。递归调用左子树traversal(node-left)在这里被分解为先把右孩子压栈以便后续处理再把左孩子压栈然后在下一次循环中栈顶的左孩子会被弹出处理模拟了进入左子树递归的过程。5.2 统一的迭代遍历法中序和后序的迭代写法稍复杂因为访问节点和处理节点的时机不同。有一种标记法可以统一三种遍历的迭代代码其核心思想是将访问的节点直接入栈同时将一个空节点作为“处理标记”一同入栈。统一迭代法中序遍历vectorint inorderTraversal(TreeNode* root) { vectorint result; stackTreeNode* stk; if (root) stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); if (node ! nullptr) { stk.pop(); // 弹出当前节点避免重复操作 // 右中左的顺序入栈对应中序遍历左中右的倒序 if (node-right) stk.push(node-right); // 右 stk.push(node); // 中 stk.push(nullptr); // 中节点访问过但未处理加入空节点作为标记 if (node-left) stk.push(node-left); // 左 } else { // 遇到空节点表示下一个栈顶节点需要被处理 stk.pop(); // 弹出空节点 node stk.top(); // 取出待处理节点 stk.pop(); result.push_back(node-val); // 处理节点 } } return result; }理解关键nullptr就像一个“信号弹”。当栈顶是nullptr时我们就知道下一个元素是需要被加入到结果集中的节点。这种方法思维转换成本较高但一旦理解代码非常统一和模板化。对于前序和后序只需调整右、中、左三行代码的入栈顺序即可。6. 常见“坑点”排查与调试技巧即使思路正确递归代码也容易因为细节问题出错。下面是一些常见错误和调试方法。6.1 无限递归与栈溢出症状程序运行后很快崩溃或返回“Time Limit Exceeded”在调试器中可能看到栈溢出错误。原因与排查终止条件缺失或错误这是最常见原因。检查递归函数是否对所有可能的分支都有明确的return特别是处理空节点nullptr的情况。一个黄金法则是任何递归函数第一个判断就应该是对nullptr的处理。递归条件写错例如在遍历二叉树时错误地写成了traversal(root-left, ...)和traversal(root-left, ...)两个都是left导致永远在左子树循环。递归参数未向终止条件收敛例如在计算深度时递归调用应该是maxDepth(node-left)而不是maxDepth(node)否则参数永远不变无限循环。调试技巧在递归函数入口打印当前节点值和深度用一个额外参数depth记录。观察输出是否在预期内增长是否出现了重复的节点序列。6.2 结果不正确逻辑错误与状态污染症状程序能运行结束但输出结果不对。原因与排查返回值逻辑错误检查递归函数的返回值组合是否正确。例如在判断平衡二叉树时是要求左右都平衡还是||一个平衡即可在路径总和中是还是||这需要仔细分析题意。共享状态未回溯当使用引用传递如vectorint path记录路径时在递归返回前必须撤销当前节点的选择path.pop_back()。忘记回溯会导致路径包含所有访问过的节点。指针操作错误混淆了TreeNode*和TreeNode。例如if (root-left)是正确的if (root.left)在C中会编译错误除非root是引用或对象。确保你一直使用指针访问成员。边界条件处理不当对于空树、单节点树、左斜树等特殊情况你的代码是否能正确处理在LeetCode上提交前务必在脑中或用纸笔过一遍这些边界案例。调试技巧对于复杂的递归可以画出一棵很小的二叉树3-5个节点然后手动模拟递归过程在纸上记录每个函数调用的参数、返回值和共享状态的变化。这是理解递归执行流程最有效的方法。6.3 性能问题重复计算与优化症状算法逻辑正确但在大数据集上超时。原因与优化存在重复子问题如前面所述使用记忆化搜索缓存来避免对同一子树重复计算。不必要的遍历有些问题可以通过一次遍历解决不要进行多次遍历。例如在求二叉树直径时可以在计算高度的递归函数中顺带更新直径最大值避免先求高度再求直径的两次遍历。剪枝在递归过程中如果已经能确定某些分支不可能得到正确结果应提前返回。例如在路径总和问题中如果当前路径和已经大于目标值且所有节点值为正就可以提前结束该分支的搜索。7. 递归求解二叉树的进阶应用与思维训练当你熟练掌握了上述基础模型后可以挑战一些更综合的问题它们往往需要你灵活组合多种递归技巧。7.1 构造二叉树这类问题如105. 从前序与中序遍历序列构造二叉树106. 从中序与后序遍历序列构造二叉树是递归“分治”思想的完美体现。核心思路是利用前序或后序确定根节点然后在中序序列中找到根节点从而确定左右子树的区间再递归构建。从前序与中序遍历序列构造二叉树class Solution { public: TreeNode* buildTree(vectorint preorder, vectorint inorder) { // 用哈希表快速定位中序中根节点的位置 unordered_mapint, int inorderMap; for (int i 0; i inorder.size(); i) { inorderMap[inorder[i]] i; } // 进入递归 return build(preorder, 0, preorder.size() - 1, inorder, 0, inorder.size() - 1, inorderMap); } private: TreeNode* build(vectorint preorder, int preStart, int preEnd, vectorint inorder, int inStart, int inEnd, unordered_mapint, int inorderMap) { // 终止条件区间无效 if (preStart preEnd || inStart inEnd) return nullptr; // 前序第一个元素是根节点 int rootVal preorder[preStart]; TreeNode* root new TreeNode(rootVal); // 找到根节点在中序中的位置 int inRootIdx inorderMap[rootVal]; // 计算左子树的节点个数 int leftSubtreeSize inRootIdx - inStart; // 递归构建左子树 // 前序左子树区间[preStart 1, preStart leftSubtreeSize] // 中序左子树区间[inStart, inRootIdx - 1] root-left build(preorder, preStart 1, preStart leftSubtreeSize, inorder, inStart, inRootIdx - 1, inorderMap); // 递归构建右子树 // 前序右子树区间[preStart leftSubtreeSize 1, preEnd] // 中序右子树区间[inRootIdx 1, inEnd] root-right build(preorder, preStart leftSubtreeSize 1, preEnd, inorder, inRootIdx 1, inEnd, inorderMap); return root; } };关键点确定左右子树在数组中的区间范围是这类题的核心。务必画图理解下标计算。使用哈希表存储中序值到索引的映射可以将查找根节点位置的操作从O(N)降到O(1)。7.2 二叉搜索树BST中的递归BST的性质左子树所有节点值 根节点值 右子树所有节点值让递归变得更加高效。验证二叉搜索树常见的错误是只检查当前节点和其直接子节点。正确做法是传递一个值的有效范围。class Solution { public: bool isValidBST(TreeNode* root) { return helper(root, LONG_MIN, LONG_MAX); // 使用long避免边界值溢出 } bool helper(TreeNode* node, long long lower, long long upper) { if (node nullptr) return true; if (node-val lower || node-val upper) { return false; } // 左子树的所有节点值必须在 (lower, node-val) 之间 // 右子树的所有节点值必须在 (node-val, upper) 之间 return helper(node-left, lower, node-val) helper(node-right, node-val, upper); } };这个解法体现了递归的另一个重要思想将约束条件通过参数向下传递。每个节点都继承了一个数值范围它只需要检查自己是否在这个范围内然后把更严格的范围传递给它的子节点。7.3 递归与动态规划的结合树形DP有些问题需要在树上进行状态转移例如 337. 打家劫舍 III。这类问题通常需要递归函数返回一个数组或结构体包含多个状态信息。打家劫舍 IIIclass Solution { public: struct SubtreeStatus { int selected; // 偷当前节点时子树的最大收益 int notSelected; // 不偷当前节点时子树的最大收益 }; SubtreeStatus dfs(TreeNode* node) { if (node nullptr) { return {0, 0}; } SubtreeStatus left dfs(node-left); SubtreeStatus right dfs(node-right); // 偷当前节点则左右孩子都不能偷 int selected node-val left.notSelected right.notSelected; // 不偷当前节点则左右孩子可以偷也可以不偷取最大值 int notSelected max(left.selected, left.notSelected) max(right.selected, right.notSelected); return {selected, notSelected}; } int rob(TreeNode* root) { auto result dfs(root); return max(result.selected, result.notSelected); } };这本质上是一个后序遍历。每个递归调用返回两个状态父节点根据这两个状态计算自己的两个状态。这种“子状态推导父状态”的模式是树形DP的典型特征。
返回列表