行业资讯
📅 2026/7/15 7:48:42
二叉树遍历:从递归到迭代,C++实现与面试要点全解析
1. 项目概述为什么二叉树遍历是面试的“必考题”如果你正在准备C相关的技术面试尤其是那些对算法和数据结构有要求的岗位那么“二叉树遍历”这道题你大概率是绕不过去的。这几乎成了面试官检验候选人基本功的“试金石”。为什么因为它看似简单一个递归函数几行代码就能搞定但恰恰是这种简单能暴露出你对递归、栈、指针、树结构等核心概念的理解深度以及你写代码的严谨性和边界处理能力。我见过太多候选人一上来就刷刷刷写出一个递归的前序遍历但当被问到“不用递归怎么做”、“如果树有百万个节点递归会有什么问题”、“如何实现层次遍历”时就开始卡壳。二叉树遍历就像一面镜子能照出你是只会背模板还是真正理解了背后的原理和实现细节。这篇文章我们就来彻底拆解二叉树遍历。我会从最基础的递归实现讲起这是理解问题的起点然后深入到非递归迭代实现这是面试官最爱追问的难点最后我们会探讨一些高级话题和实际应用场景。我会提供完整的、可直接编译运行的C代码并附上详尽的注释和我在实际面试与被面试中总结出的“避坑指南”。我们的目标不仅仅是“实现”更是“优雅地实现”——写出既高效又清晰能让面试官眼前一亮的代码。2. 二叉树遍历的核心思想与递归实现在深入代码之前我们必须先统一思想。二叉树遍历本质上是按照某种规则系统地访问树中的每一个节点且每个节点只访问一次。根据访问根节点、左子树、右子树的先后顺序主要分为三种经典方式前序遍历根节点 - 左子树 - 右子树中序遍历左子树 - 根节点 - 右子树后序遍历左子树 - 右子树 - 根节点还有一种按层访问的层次遍历我们稍后讨论。2.1 二叉树的节点结构定义任何操作都始于数据结构。在C中我们通常这样定义一个二叉树节点// 二叉树节点的定义 struct TreeNode { int val; // 节点存储的值这里以int为例 TreeNode* left; // 指向左子节点的指针 TreeNode* right; // 指向右子节点的指针 // 构造函数方便创建新节点 TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这里有几个关键点使用struct而非class对于简单的数据聚合体struct默认成员是public的更简洁。在算法题中这很常见。指针的使用left和right是指针这意味着它们可以指向另一个TreeNode对象或者为空nullptr。这是构建树形结构的基础。构造函数TreeNode(int x)这个构造函数非常实用它让我们可以用new TreeNode(5)这样的方式快速创建节点并初始化值同时将左右指针安全地初始化为nullptr避免了野指针。注意在实际项目中你可能需要考虑使用智能指针如std::unique_ptrTreeNode来管理内存避免内存泄漏。但在算法面试和竞赛中为了聚焦于算法逻辑本身使用原始指针并假设由调用者管理生命周期是更常见的做法。不过如果你能在面试中主动提及这一点会是一个很好的加分项。2.2 递归实现直观但暗藏玄机递归是描述树遍历最自然的方式因为它完美契合了树的递归定义一棵树由根节点和左右子树构成。前序遍历的递归实现void preorderTraversalRecursive(TreeNode* root, vectorint result) { if (root nullptr) { return; // 递归基如果节点为空直接返回 } result.push_back(root-val); // 访问根节点 preorderTraversalRecursive(root-left, result); // 递归遍历左子树 preorderTraversalRecursive(root-right, result); // 递归遍历右子树 }中序遍历的递归实现void inorderTraversalRecursive(TreeNode* root, vectorint result) { if (root nullptr) { return; } inorderTraversalRecursive(root-left, result); // 递归遍历左子树 result.push_back(root-val); // 访问根节点 inorderTraversalRecursive(root-right, result); // 递归遍历右子树 }后序遍历的递归实现void postorderTraversalRecursive(TreeNode* root, vectorint result) { if (root nullptr) { return; } postorderTraversalRecursive(root-left, result); // 递归遍历左子树 postorderTraversalRecursive(root-right, result); // 递归遍历右子树 result.push_back(root-val); // 访问根节点 }递归代码的“优雅”与“陷阱”看起来非常简单对吧但这就是面试官要考察你的地方。你需要能清晰地说出以下几点递归基Base Caseif (root nullptr)这一行至关重要。它定义了递归的终止条件。没有它递归将无限进行下去最终导致栈溢出。这是必须检查的边界条件。递归调用栈每一次递归调用都会在系统调用栈上压入一个新的栈帧存储当前函数的参数、局部变量和返回地址。对于一棵深度为h的树递归的空间复杂度是O(h)在最坏情况树退化成链表下是O(n)。时间复杂度每个节点恰好被访问一次因此时间复杂度是O(n)其中n是节点总数。尾递归优化仔细观察这三种递归都不是尾递归除了最后一步还有其他操作因此编译器通常无法进行尾递归优化。这是递归方法的一个潜在性能局限。实操心得在面试中写递归代码时一定要先写出递归终止条件然后再写递归体。这是一个非常好的编码习惯能有效避免逻辑错误。同时要能主动分析其时间复杂度和空间复杂度。3. 迭代实现手动模拟递归栈递归虽然简洁但在实际工程中如果树非常深递归可能导致调用栈溢出。此外理解如何用迭代循环实现遍历能证明你真正理解了遍历过程的本质。迭代法的核心思想是用我们自己维护的栈Stack来模拟系统调用栈的行为。3.1 前序遍历的迭代实现前序遍历的顺序是“根左右”。迭代的思路是先将根节点入栈。当栈不为空时弹出栈顶节点并访问。然后先将右子节点入栈再将左子节点入栈注意顺序因为栈是后进先出我们要保证左子节点先被弹出访问。vectorint preorderTraversalIterative(TreeNode* root) { vectorint result; if (root nullptr) return result; stackTreeNode* nodeStack; nodeStack.push(root); while (!nodeStack.empty()) { TreeNode* currentNode nodeStack.top(); nodeStack.pop(); result.push_back(currentNode-val); // 访问根节点 // 右子节点先入栈后出栈 if (currentNode-right ! nullptr) { nodeStack.push(currentNode-right); } // 左子节点后入栈先出栈 if (currentNode-left ! nullptr) { nodeStack.push(currentNode-left); } } return result; }为什么先右后左栈是LIFO后进先出结构。我们希望访问顺序是“根-左-右”。所以在访问完根节点后下一个应该访问的是左子节点。为了让左子节点先被弹出就必须让它后入栈。因此需要先将右子节点入栈再将左子节点入栈。3.2 中序遍历的迭代实现中序遍历左根右的迭代法稍复杂一些因为访问节点的时机和处理节点的时机不同。我们需要一个指针curr来辅助遍历。思路是从根节点开始将所有左子节点依次入栈直到最左边的叶子节点。这模拟了“深入左子树”的过程。弹出栈顶节点这是当前可访问的、最左侧的节点并访问。转向该节点的右子树重复步骤1。vectorint inorderTraversalIterative(TreeNode* root) { vectorint result; stackTreeNode* nodeStack; TreeNode* curr root; while (curr ! nullptr || !nodeStack.empty()) { // 深入左子树将所有左节点入栈 while (curr ! nullptr) { nodeStack.push(curr); curr curr-left; } // 此时curr为nullptr栈顶元素就是最左节点 curr nodeStack.top(); nodeStack.pop(); result.push_back(curr-val); // 访问节点 // 转向右子树 curr curr-right; } return result; }这个算法的精妙之处在于while循环的条件curr ! nullptr || !nodeStack.empty()。它涵盖了两种情况一是正在沿着左子树深入curr不为空二是需要回溯到父节点并处理右子树栈不为空curr可能为空。这是面试中很容易被要求手写并解释的代码。3.3 后序遍历的迭代实现后序遍历左右根是迭代法中最难的一种因为我们在访问一个节点前需要先确认它的左右子树都已处理完毕。一个取巧且经典的方法是利用前序遍历的变种。我们观察一下前序遍历根 - 左 - 右后序遍历左 - 右 - 根如果我们稍微修改前序遍历的顺序变成根 - 右 - 左然后将得到的结果反转就变成了左 - 右 - 根这正是后序遍历vectorint postorderTraversalIterative(TreeNode* root) { vectorint result; if (root nullptr) return result; stackTreeNode* nodeStack; nodeStack.push(root); while (!nodeStack.empty()) { TreeNode* currentNode nodeStack.top(); nodeStack.pop(); result.push_back(currentNode-val); // 访问“根”节点 // 注意这里是先左后右因为我们要得到“根-右-左”的顺序 if (currentNode-left ! nullptr) { nodeStack.push(currentNode-left); } if (currentNode-right ! nullptr) { nodeStack.push(currentNode-right); } } // 将结果反转得到“左-右-根”的后序顺序 reverse(result.begin(), result.end()); return result; }注意事项这种方法虽然巧妙且代码与前序遍历极其相似但需要一次额外的reverse操作。在面试中你需要能解释清楚这个原理。还有一种更符合直觉的、使用prev指针记录上一个访问节点的方法但代码更复杂。对于面试而言掌握这种“反转法”通常就够了但最好能知道另一种方法的存在。3.4 层次遍历广度优先搜索层次遍历使用队列Queue而非栈它按层访问节点属于广度优先搜索BFS。vectorvectorint levelOrder(TreeNode* root) { vectorvectorint result; if (root nullptr) return result; queueTreeNode* nodeQueue; nodeQueue.push(root); while (!nodeQueue.empty()) { int levelSize nodeQueue.size(); // 当前层的节点数 vectorint currentLevel; for (int i 0; i levelSize; i) { TreeNode* currentNode nodeQueue.front(); nodeQueue.pop(); currentLevel.push_back(currentNode-val); // 将下一层的节点加入队列 if (currentNode-left ! nullptr) { nodeQueue.push(currentNode-left); } if (currentNode-right ! nullptr) { nodeQueue.push(currentNode-right); } } result.push_back(currentLevel); // 存储当前层的结果 } return result; // 结果是一个二维数组每一层是一个子数组 }关键点int levelSize nodeQueue.size();这行代码是关键。它在进入每一层循环前先记录当前队列的长度也就是这一层节点的数量。这样内层for循环就只会处理这一层的节点从而将不同层的节点结果分开存储。这是解决“二叉树的层平均值”、“二叉树每层的最大值”等衍生问题的基础模板。4. 莫里斯遍历极致的空间优化无论是递归还是迭代我们都需要O(h)或O(n)的额外空间栈或队列。有没有可能只用O(1)的额外空间即常数空间完成遍历呢答案是肯定的这就是莫里斯遍历。它的核心思想是利用树中大量的空指针临时将当前节点的前驱节点在中序遍历中的右孩子指向自己从而在遍历完左子树后能通过这个临时链接回到根节点之后再将链接恢复。由于莫里斯遍历算法修改了树的结构尽管是临时的理解起来比较复杂在面试中不常要求手写但如果你能说出其思想会是巨大的亮点。这里以中序莫里斯遍历为例简述其步骤初始化curr指向根节点。当curr不为空时如果curr没有左孩子访问curr然后curr指向其右孩子。如果curr有左孩子 a. 找到curr左子树上的最右节点即中序遍历下curr的前驱节点记为pre。 b. 如果pre的右孩子为空将其右孩子指向curr建立临时链接然后curr指向其左孩子。 c. 如果pre的右孩子已经是curr说明左子树已遍历完则断开这个链接pre-right nullptr访问curr然后curr指向其右孩子。vectorint inorderMorrisTraversal(TreeNode* root) { vectorint result; TreeNode* curr root; TreeNode* pre nullptr; while (curr ! nullptr) { if (curr-left nullptr) { // 如果没有左孩子直接访问当前节点 result.push_back(curr-val); curr curr-right; } else { // 找到当前节点在中序遍历下的前驱节点 pre curr-left; while (pre-right ! nullptr pre-right ! curr) { pre pre-right; } if (pre-right nullptr) { // 建立临时链接指向当前节点 pre-right curr; curr curr-left; // 继续处理左子树 } else { // 左子树已处理完断开链接并访问当前节点 pre-right nullptr; result.push_back(curr-val); curr curr-right; // 转向右子树 } } } return result; }重要提示莫里斯遍历会修改树的结构虽然在遍历结束后会恢复但在多线程环境或遍历过程中不允许修改树结构的场景下不能使用。在面试中除非面试官明确要求或问题涉及空间优化否则优先展示栈迭代法。但了解莫里斯遍历体现了你对算法的深入理解。5. 完整可运行的代码示例与测试理论讲完了我们来看一个完整的、可以编译运行的例子。这个例子会构建一棵简单的二叉树并分别用递归和迭代的方法进行前序、中序、后序和层次遍历。#include iostream #include vector #include stack #include queue #include algorithm using namespace std; // 1. 二叉树节点定义 struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; // 2. 递归遍历实现 void preorderRecursive(TreeNode* root, vectorint res) { if (!root) return; res.push_back(root-val); preorderRecursive(root-left, res); preorderRecursive(root-right, res); } void inorderRecursive(TreeNode* root, vectorint res) { if (!root) return; inorderRecursive(root-left, res); res.push_back(root-val); inorderRecursive(root-right, res); } void postorderRecursive(TreeNode* root, vectorint res) { if (!root) return; postorderRecursive(root-left, res); postorderRecursive(root-right, res); res.push_back(root-val); } // 3. 迭代遍历实现 vectorint preorderIterative(TreeNode* root) { vectorint res; if (!root) return res; stackTreeNode* stk; stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); stk.pop(); res.push_back(node-val); if (node-right) stk.push(node-right); if (node-left) stk.push(node-left); } return res; } vectorint inorderIterative(TreeNode* root) { vectorint res; stackTreeNode* stk; TreeNode* curr root; while (curr || !stk.empty()) { while (curr) { stk.push(curr); curr curr-left; } curr stk.top(); stk.pop(); res.push_back(curr-val); curr curr-right; } return res; } vectorint postorderIterative(TreeNode* root) { vectorint res; if (!root) return res; stackTreeNode* stk; stk.push(root); while (!stk.empty()) { TreeNode* node stk.top(); stk.pop(); res.push_back(node-val); if (node-left) stk.push(node-left); if (node-right) stk.push(node-right); } reverse(res.begin(), res.end()); return res; } // 4. 层次遍历实现 vectorvectorint levelOrder(TreeNode* root) { vectorvectorint res; if (!root) return res; queueTreeNode* q; q.push(root); while (!q.empty()) { int size q.size(); vectorint level; for (int i 0; i size; i) { TreeNode* node q.front(); q.pop(); level.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } res.push_back(level); } return res; } // 辅助函数打印vector templatetypename T void printVector(const string name, const vectorT vec) { cout name : [; for (size_t i 0; i vec.size(); i) { cout vec[i]; if (i ! vec.size() - 1) cout , ; } cout ] endl; } // 辅助函数打印二维vector用于层次遍历结果 void printLevelOrder(const vectorvectorint levels) { cout Level Order: endl; for (size_t i 0; i levels.size(); i) { cout Level i : [; for (size_t j 0; j levels[i].size(); j) { cout levels[i][j]; if (j ! levels[i].size() - 1) cout , ; } cout ] endl; } } // 主函数构建测试树并运行 int main() { // 构建一棵测试二叉树: 1 // / \ // 2 3 // / \ / // 4 5 6 TreeNode* root new TreeNode(1); root-left new TreeNode(2); root-right new TreeNode(3); root-left-left new TreeNode(4); root-left-right new TreeNode(5); root-right-left new TreeNode(6); vectorint resRecursive, resIterative; // 测试递归遍历 cout Recursive Traversal endl; resRecursive.clear(); preorderRecursive(root, resRecursive); printVector(Preorder , resRecursive); resRecursive.clear(); inorderRecursive(root, resRecursive); printVector(Inorder , resRecursive); resRecursive.clear(); postorderRecursive(root, resRecursive); printVector(Postorder, resRecursive); // 测试迭代遍历 cout \n Iterative Traversal endl; resIterative preorderIterative(root); printVector(Preorder , resIterative); resIterative inorderIterative(root); printVector(Inorder , resIterative); resIterative postorderIterative(root); printVector(Postorder, resIterative); // 测试层次遍历 cout \n Level Order Traversal endl; vectorvectorint levelRes levelOrder(root); printLevelOrder(levelRes); // 内存清理简单示例实际项目建议用智能指针 delete root-right-left; delete root-left-right; delete root-left-left; delete root-right; delete root-left; delete root; return 0; }预期输出 Recursive Traversal Preorder : [1, 2, 4, 5, 3, 6] Inorder : [4, 2, 5, 1, 6, 3] Postorder: [4, 5, 2, 6, 3, 1] Iterative Traversal Preorder : [1, 2, 4, 5, 3, 6] Inorder : [4, 2, 5, 1, 6, 3] Postorder: [4, 5, 2, 6, 3, 1] Level Order Traversal Level Order: Level 0: [1] Level 1: [2, 3] Level 2: [4, 5, 6]你可以将这段代码复制到任何C编译器如GCC, Clang, MSVC或在线IDE中运行直观地看到不同遍历方式的结果。6. 面试实战高频问题与回答思路掌握了代码实现我们来看看面试官可能会如何围绕二叉树遍历进行提问。以下是一些经典问题及回答要点问题1递归和迭代实现遍历各自的优缺点是什么递归优点代码极其简洁逻辑清晰与树结构的数学定义高度吻合。缺点函数调用有开销深度过大的树可能导致栈溢出。调试可能更困难。迭代优点没有栈溢出风险除非自己实现的栈也很大性能通常更稳定便于控制流程。缺点代码相对复杂尤其是中序和后序遍历需要手动维护栈和指针状态。问题2如果二叉树节点数量极大例如百万级你会选择递归还是迭代为什么应该选择迭代。因为递归深度受限于系统调用栈大小通常几MB到几MB对于极度不平衡的树如退化成链表递归深度等于节点数很容易导致栈溢出。而迭代法使用堆内存上的栈可用空间大得多更安全可控。问题3如何判断两棵二叉树是否相同这是一个典型的遍历应用。可以使用递归先判断根节点值是否相同再递归判断左右子树是否相同。也可以使用迭代借助队列进行层次遍历比较或者借助栈进行前序/中序遍历比较。递归解法更简洁bool isSameTree(TreeNode* p, TreeNode* q) { if (p nullptr q nullptr) return true; if (p nullptr || q nullptr) return false; if (p-val ! q-val) return false; return isSameTree(p-left, q-left) isSameTree(p-right, q-right); }问题4如何求二叉树的最大深度递归树的深度等于左子树深度和右子树深度的较大值加1。int maxDepth(TreeNode* root) { if (root nullptr) return 0; return 1 max(maxDepth(root-left), maxDepth(root-right)); }迭代使用层次遍历层数即为深度。问题5给定一棵二叉树的前序遍历和中序遍历序列如何重建这棵树这是二叉树遍历的经典应用题。前序遍历的第一个节点是根节点。在中序遍历中找到这个根节点其左边序列是左子树的中序遍历右边是右子树的中序遍历。根据左右子树的节点数量可以在前序遍历中划分出左右子树的前序遍历序列。然后递归地重建左右子树。回答要点清晰地描述这个分治递归的过程并指出前提是树中节点值唯一。问题6写一个二叉树的镜像翻转递归交换当前节点的左右子树然后递归地对左右子树进行镜像操作。TreeNode* mirrorTree(TreeNode* root) { if (root nullptr) return nullptr; // 交换左右子树 TreeNode* temp root-left; root-left root-right; root-right temp; // 递归处理子树 mirrorTree(root-left); mirrorTree(root-right); return root; }迭代可以使用栈或队列在遍历每个节点时交换其左右孩子。7. 避坑指南与性能优化建议在实际编码和面试中有一些常见的“坑”需要特别注意空指针检查这是最基础也最容易出错的地方。在任何访问node-left或node-right之前尤其是在递归基或入栈/入队前一定要检查节点是否为nullptr。迭代法中栈或队列的初始状态前序和层次遍历需要先将根节点入栈/队而中序遍历的迭代法则从curr root和一个空栈开始。混淆初始状态是常见的错误来源。后序遍历迭代法的理解要能解释清楚为什么“根-右-左”的反转就是后序。如果面试官要求不借助反转实现你需要知道使用prev指针记录上一个访问节点的标准迭代法。层次遍历中层的划分忘记在while循环内用int size q.size()来固定当前层节点数会导致结果混层无法区分不同层。内存管理在面试中如果被要求写一个完整的程序包括构建树结束后最好提一下内存释放。虽然很多在线判题系统不检查这个但这体现了良好的编程习惯。使用new创建节点就应该有对应的delete。函数参数传递递归函数中结果容器vectorint result通常以引用方式传递避免不必要的拷贝。在迭代法中函数直接返回结果向量。C标准库的使用熟练使用std::stack,std::queue,std::vector了解其基本操作push,pop,top,front,back,size,empty等。避免自己重复造轮子。性能优化小技巧对于迭代法如果确定树不会特别深使用std::stack和std::queue没问题。如果追求极致性能可以考虑预分配一个数组和头尾指针来模拟栈/队减少动态内存分配开销但这在面试中通常不是重点。在递归版本中如果编译器支持尾递归优化TCO可以尝试将递归改写成尾递归形式但这对于二叉树遍历非尾递归比较困难。最重要的“优化”是写出正确、清晰、易维护的代码。在99%的情况下算法的O(n)时间复杂度已经是最优代码的可读性和鲁棒性比微小的常数优化更重要。二叉树遍历是数据结构与算法的基石之一。希望这篇详细的拆解能帮助你不仅“记住”代码更“理解”其背后的思想。在面试中当被问到这个问题时不妨先从递归的简洁实现开始然后主动引出迭代实现并分析优劣最后如果能提一下莫里斯遍历的思想一定会让面试官印象深刻。记住清晰的思路和严谨的代码比死记硬背要重要得多。