二叉树算法入门:递归遍历、层序与深度计算实战指南
算法训练营进行到第十三天终于轮到二叉树了。说实话前面数组、链表、栈、队列学下来很多人的心态是数据结构也就这么回事直到开始写二叉树才真正体会到什么叫递归的恐惧。这篇文章就把我训练营期间关于二叉树学习、刷题和踩坑的经验整体梳理一遍从基本概念到遍历方法从深度计算到常见报错尽量用大白话讲清楚。不管是正在学算法的学生还是准备面试跳槽的工程师希望这篇文章能帮你把二叉树这块硬骨头啃下来。1. 二叉树到底是什么从概念到本质理解1.1 二叉树为什么是算法学习的分水岭先给没接触过二叉树的朋友扫个盲。二叉树是一种树形结构每个节点最多只有两个子节点分别叫左孩子和右孩子。这个最多两个分支的限制看起来很苛刻但正是这个限制让二叉树变得无比重要。数组和链表都是线性结构数据一个一个排着队而二叉树首次引入了层级和分支的概念这带来的认知升级是巨大的。我训练营的导师说过一句话印象很深你如果能把二叉树学明白后面图的算法基本就是套模板学不明白DFS和BFS永远都是背代码。这句话不夸张。因为二叉树的遍历本质上就是深度优先搜索DFS和广度优先搜索BFS的缩影。前序、中序、后序遍历就是DFS的三种变体层序遍历就是BFS。树的结构把递归这个抽象概念具象化了所以很多人在二叉树这里第一次真正理解了递归也有人在这里彻底卡住。我在训练营里观察到二叉树学得好的同学都有一个共同特点他们不急着背代码而是先在纸上画树。一个三层的二叉树画出来每个节点的指针关系一目了然递归调用的过程也能顺着箭头走一遍。这个习惯非常关键。1.2 存储方式选型链式结构还是数组结构二叉树在代码里有两种主流存储方式一种是链式存储另一种是顺序存储数组。链式存储很好理解每个节点就是一个结构体或类里面存着数据和两个指针struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这种写法在LeetCode和面试中最常见操作灵活插入和删除方便。你只需要维护根节点指针剩下的节点通过指针互相连接想怎么遍历就怎么遍历。顺序存储则是把二叉树按完全二叉树的规则放到数组里根节点放在下标1或者0它的左孩子在下标2*i或2*i1右孩子在2*i1或2*i2。这种方式的优势是访问速度快、内存连续但缺点也很明显——如果树是斜树每个节点只有左孩子或只有右孩子数组里会浪费大量空间。实际工程中用数组存二叉树的场景不多就是堆排序里的完全二叉树还有线段树这种本身就是完全二叉树结构的数据结构。我的建议是训练营阶段把链式存储作为主攻方向把所有遍历和深度计算的题目都用链式结构做一遍。数组存储可以在学到堆排序的时候再回头看那个时候你会觉得数组存树的设计非常精妙。提示链式二叉树中叶子节点的左右指针都要指向 nullptr。很多人定义节点时忘记初始化指针运行时直接崩掉这是最常见的低级错误。C里建议都用带默认参数的构造函数Java和Python则在声明时直接赋 null。2. 二叉树的遍历核心考点的完整拆解2.1 前序、中序、后序遍历的理解方式二叉树的遍历可以说是算法训练营第十三天的重中之重。深度优先遍历DFS有三种前序先序、中序、后序。它们的区别就是访问根节点的时机前序遍历根节点 - 左子树 - 右子树中序遍历左子树 - 根节点 - 右子树后序遍历左子树 - 右子树 - 根节点很多初学者死记这三句话然后就去做题了结果遇到给前序和中序重建二叉树这种题直接懵掉。问题出在理解不够本质。我的理解方式是这样的所谓遍历本质上是把一个非线性结构压扁成线性序列。前序、中序、后序只是选择不同的时机把节点加入序列。递归代码相当简单以中序遍历为例def inorder(root): if root is None: return inorder(root.left) print(root.val) inorder(root.right)注意这个递归的节奏先一路向左走到最深处然后逐层回溯。中序遍历的结果对于搜索二叉树来讲是从小到大排好序的这个特性后面会反复用到。前序遍历则是先访问根再往左钻所以结果序列的第一个元素一定是整棵树的根节点。后序遍历最后一个元素一定是根节点。这两个性质是重建二叉树类题目的核心依据。我觉得训练营里最有价值的练习方式是手动模拟递归栈。拿一棵三层的小树把递归调用当成一摞盘子每次进入函数就往栈顶压一个节点返回就弹出一个。用这种方式走上几棵树递归就不再玄学了。2.2 层序遍历队列的应用场景层序遍历就是广度优先搜索BFS在二叉树上的体现从根开始一层一层往下扫。在力扣上这是最高频的二叉树题目类型之一因为它后面接了很多变种题按层收集结果、之字形打印、求每层最大值等等。层序遍历的经典实现用的是队列from collections import deque def levelorder(root): if root is None: return [] result [] q deque([root]) while q: level_size len(q) level [] for _ in range(level_size): node q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) result.append(level) return result这里有一个非常实操的细节要在进入每一层之前先记录level_size len(q)。为什么因为队列是在不断变化的你一边往里加孩子节点如果不提前锁定当前层的节点数循环就会把新加入的下一层节点也当成当前层来处理最后结果全乱。我见过不少同学在层序遍历这里翻车问题几乎都出在这一行。之前面试某大厂的时候也问过这个考点所以一定要记住按层处理先记录当前队列长度再出队。2.3 递归转迭代栈模拟的通用方法训练营一定会让你把递归遍历改写成迭代遍历因为有的面试官明确要求不要用递归。递归改迭代的核心思路就一句话用显式的栈模拟函数调用栈。拿中序遍历来举例递归版很好写迭代版就需要注意先一路压左再访问根再处理右子树def inorder_iter(root): result [] stack [] cur root while cur or stack: # 一路向左压栈 while cur: stack.append(cur) cur cur.left # 弹栈访问 cur stack.pop() result.append(cur.val) # 转向右子树 cur cur.right return result前序遍历改成迭代更容易因为根节点先访问直接压栈处理右左即可注意栈是后进先出所以先压右再压左def preorder_iter(root): if root is None: return [] result [] stack [root] while stack: node stack.pop() result.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result后序遍历迭代版稍微绕一点网上的套路是用两个栈或者用前序遍历的变体再反转。我先说结论先做根-右-左的遍历最后把结果反转就是左-右-根的后序遍历。这个思路我很喜欢因为理解成本低写起来还不容易出错。实操心得如果面试被问到迭代遍历优先写前序和中序版本这两个最直白。后序版本用反转法三步就能搞定别去背那种同时维护两个栈的复杂写法容易临场卡壳。3. 二叉树的深度与节点计算3.1 最大深度与最小深度的区别二叉树深度相关问题在训练营里几乎是必做题也是热词榜里的高频关键词。力扣第104题二叉树的最大深度解法非常典型def maxDepth(root): if root is None: return 0 left_depth maxDepth(root.left) right_depth maxDepth(root.right) return max(left_depth, right_depth) 1递归的思路很清晰一棵树的深度等于左子树深度和右子树深度的较大值再加一根节点自己占一层。这个递归公式理解透了后面所有跟深度相关的题都能顺手解。但注意最小深度没有这么简单。最小深度是从根节点到最近叶子节点的最短路径上的节点数很多人想当然地写min(left, right) 1结果遇到一棵树只有左子树没有右子树的情况就翻车了。比如根节点只有左子树、右子树为空那么从根到最近叶子的路径只能走左边但min(左子树深度, 0) 1会得出1这显然是错的——根节点本身不是叶子路径至少要到左子树的某个叶子才算数。正确的写法需要加判断def minDepth(root): if root is None: return 0 if root.left is None and root.right is None: return 1 if root.left is None: return minDepth(root.right) 1 if root.right is None: return minDepth(root.left) 1 return min(minDepth(root.left), minDepth(root.right)) 1这个坑我训练营里踩过当时想了半天才意识到自己漏了叶子节点这个定义。做题之前先搞清楚题目里术语的准确定义这是训练营第十三天给我最大的收获之一。3.2 节点计数与平衡判断的统一思路有了深度的递归公式很多相关问题都可以举一反三。比如统计节点总数def countNodes(root): if root is None: return 0 return countNodes(root.left) countNodes(root.right) 1再比如判断一棵树是否平衡左右子树高度差不超过1就是深度与计数的组合应用def isBalanced(root): if root is None: return True left_h getHeight(root.left) right_h getHeight(root.right) if abs(left_h - right_h) 1: return False return isBalanced(root.left) and isBalanced(root.right)这套题目做多了就会发现二叉树的递归题目全都是分而治之先处理根节点或者不处理然后递归处理左右子树最后把结果组合起来。你只要写出当前节点做什么和递归结果怎么合并这两部分代码自然就出来了。这里再补一个我自己总结的做题顺序拿到二叉树题目先别写代码先在脑子里回答三个问题。第一递归的终止条件是什么一般是节点为空第二当前层要做什么操作第三左右子树的结果怎么合并回答清楚这三个问题八成以上的二叉树递归题都能解出来。4. 搜索二叉树与线索二叉树进阶概念扫盲4.1 搜索二叉树的特性与应用搜索二叉树BSTBinary Search Tree也叫二叉排序树、二叉查找树是二叉树里最实用的一种变体。它的定义不复杂左子树所有节点的值都小于根节点右子树所有节点的值都大于根节点并且左右子树本身也是搜索二叉树。这个特性带来一个惊人的结果对BST做中序遍历结果是有序序列。因为中序遍历的顺序是左-根-右而BST的性质保证了左 根 右所以整个序列天然单调递增。BST的实际意义在于查找效率。在一棵平衡的BST里查找一个节点平均只需要 O(log n) 的时间复杂度。这个效率在数据量大的时候非常可观。而且BST支持动态插入和删除不像数组排序那样需要整体移动所以它成为很多系统底层结构的基础。不过BST有个致命问题如果插入数据的顺序碰巧是有序的树会退化成一条链表查找效率直接掉到 O(n)。这就是为什么后面会学到AVL树、红黑树这些自平衡的BST变体。训练营里学BST的时候多留个心眼把退化这个问题记住后面学平衡树你会理解得更深。4.2 线索二叉树解决什么问题线索二叉树在热词里也出现了这里简单讲一下。线索化要解决的问题是传统的二叉树节点只有左右孩子指针做中序或其他序遍历的时候必须借助栈或者递归时间复杂度是O(n)且做不到空间O(1)。但如果给每个节点额外加上前驱和后继的线索指针就可以在没有栈的情况下线性遍历二叉树了。具体做法是利用叶子节点和部分空指针让原本指向null的左指针指向前驱节点右指针指向后继节点。这样遍历的时候就相当于走一条串好的项链不需要递归和栈。说句实在话线索二叉树在面试中出现的频率不高但在实际工程中它启发了很多设计思路比如数据库索引的结构优化。训练营里学到它主要目的是扩充你对如何优化遍历效率的认知边界。知道有这么个东西理解它的基本思路就够了不用过度投入时间去手写线索化。4.3 二叉树在真实工程里的应用场景聊到这里估计有人会问二叉树学了到底有啥用我不能说它直接决定了你的工资但它在计算机领域就是基础设施级别的存在。编译器把表达式转换成抽象语法树AST本质上是二叉树或类树结构。你要写一个计算器能用二叉树轻松支持括号和运算符优先级。数据库的索引大量使用B树而B树就是多路搜索树和BST的搜索思想一脉相承。操作系统里的文件目录结构是树形结构路由器转发数据包用到前缀树Trie也是多叉树的特例。堆排序里的堆本质上就是一颗完全二叉树。我训练营的讲师说了一句很提气的话你学的不是二叉树是计算机世界里组织和查找数据的元能力。日常工作中即使你只用CRUD一旦遇到需要处理层级关系的数据组织架构、商品分类、评论的回复楼二叉树这套思维模型会直接帮你快速建模。5. 写二叉树程序总报运行时错误排查指南5.1 空指针解引用最常见的运行时错误在热词搜索里写二叉树程序时为什么总是报运行时错误这个搜索频率非常高。我敢说十个二叉树报错七个是空指针问题。空指针解引用最常见的一个场景是访问node.left或node.right时没有检查node本身是否为 null。比如你要打印一棵树的所有节点写了if (node.left.val 0)这样的判断但node.left是空指针程序直接崩溃。正确的写法要么先判空要么把空值判断放在递归的最前面统一处理。另一个常被忽略的场景是修改树的时候用了悬空的临时指针。比如删除BST节点时把父节点的指针直接指向了待删节点的一个子树但如果你提前释放了待删节点占用的内存新的指针就指向了已回收的空间这种问题在C/C里尤其隐蔽有时候不是立刻崩而是偶尔崩排查起来特别痛苦。我的经验是每次拿到node指针后第一件事就是问一句这个指针能确定非空吗不能确定就用条件判断包一层。多写一个if不丢人少写一个if会丢时间。5.2 递归边界条件与栈溢出二叉树递归题的另一个大坑就是递归边界写错最常见的错误是忘了写终止条件或者终止条件的位置不对导致函数无限递归最终栈溢出。比如你写一个求和函数def sumTree(root): if root is None: return 0 return root.val sumTree(root.left) sumTree(root.right)这个没问题。但如果你把终止条件写成都判断左右孩子漏掉了对root本身的空判断当root已经是空节点时代码还想访问root.left立刻空指针。所以我的建议是任何递归函数的开头第一句就写空值判断就算某个分支永远走不到也不要冒险省略。还有一种栈溢出的情况是树太深。递归版遍历的栈深度等于树的高度如果这棵树不幸退化成链表比如1万个节点排成一条线递归深度就是1万层很容易爆栈。遇到这种极端情况要改用迭代版遍历显式栈放在堆上安全得多。5.3 二叉树常见运行时错误速查表错误现象常见原因排查方向访问空指针崩溃没判空就访问node.left/right在递归函数开头统一判空无限递归导致栈溢出递归终止条件缺失或位置错误检查root None是否在函数最前结果与预期不符前中后序遍历顺序写混画一棵三层小树手动模拟层序遍历结果乱序没有记录每层节点数直接len(q)遍历进入每层前先存level_size len(q)修改后树结构丢失指针赋值顺序错误先保存需要保留的指针再改指向C内存泄漏删除节点后父指针未置空删除后检查父节点对应指针是否为nullptr5.4 调试技巧如何快速定位二叉树Bug二叉树调试有一个非常实用的土办法写一个打印函数。训练营阶段别嫌麻烦先花两分钟写一个能把二叉树按层级结构打印出来的工具函数def printTree(root, depth0): if root is None: return printTree(root.right, depth 1) print( * depth str(root.val)) printTree(root.left, depth 1)这段代码用中序的方式把树旋转90度打印出来根节点在左侧右子树在上方左子树在下方。每次调完算法先用这个函数打印一下当前树的真实形态很多玄学Bug马上就能看出来。我在训练营里就是靠这个工具活过来的——没有可视化工具的年代它就是最简单直观的画图调试。再补充一个建议如果做的是修改型操作比如插入、删除节点每次只改变一个指针改完立刻打印不要攒了一堆操作再调试。小步快跑比大段盲改要高效得多。6. 针对训练营的二叉树刷题路线与个人建议6.1 建议刷题顺序从基础到进阶的清单训练营第十三天到第十五天的时间是很紧凑的想一口气把所有内容都学完不现实建议把刷题按优先级排序。我自己按照这样的顺序来梳理效果还不错基础梯队必做二叉树前中后序遍历递归版、层序遍历、最大深度、节点个数、反转二叉树。进阶梯队尽力做最小深度、判断平衡、对称二叉树、路径总和。高阶梯队按需看重建二叉树、二叉树最近公共祖先、序列化与反序列化。这套路线遵循的原则是先把遍历写熟再把递归公式套熟最后才碰状态复杂的题目。很多人第一天上手就做最近公共祖先做到怀疑人生就是因为基础梯队还没稳住。先把简单题刷到条件反射再上难度心态和效果都会好很多。6.2 一个让我茅塞顿开的类比训练营有个导师用公司会议来比喻递归我觉得这个类比值得分享。想象你是一家公司的CEO你把任务拆解给两位副总裁左子树和右子树让他们各自搞定自己负责的部门。你不需要亲自处理部门里的事你只需要等他们提交结果然后汇总。这对应的是后序遍历——先处理左右子树最后汇总到根。前序遍历则是老板先定方向再让下面执行CEO先拍板然后副手们按既定方向去做。这个类比解释了为什么递归代码看起来什么都没做却能把问题解决因为它把具体执行下放给了子调用自己只负责处理和合并结果。理解了这一层递归就不再是玄学。6.3 从第十三天往后看二叉树是地基不是终点训练营里学二叉树真的不只是为了二叉树本身。第十三天的递归思维是后面图论、动态规划甚至回溯算法的基石。树的DFS学会了图的DFS就是加了一个visited数组树的层序遍历学会了图的BFS就是多写一个visited集合。动态规划里的状态转移本质上也和树的递归合并结果是同一个思维模式。所以我的判断是在二叉树这里多花时间是值得的。哪怕进度比别人慢几天只要把每一道题都吃透、把递归框架内化成自己的肌肉记忆后面会越走越轻松。反过来如果这周草草带过等到学图和DP的时候再回来补二叉树付出的时间成本反而更高。最后再分享一个训练营里流传的练习方法晚上睡前拿纸笔手写一棵随机树然后默写三种遍历和最大深度的代码写完再睡觉。坚持一个星期你再看二叉树题目心态会完全不一样。我用这个方法熬过了最难的那两天后面遇到再新的二叉树题也只要往递归框架里套就行了。训练营第十三天只是个开始把这个地基打牢后面的算法之路会稳得多。