☰
二叉树详解:从遍历到搜索,一文搞定核心概念与工程实践
2026/10/13 17:02:39 网站建设 项目流程

1. 二叉树:数据结构意义上的“第一座山”

1.1 线性结构扛不住层级关系,这是它的起点

先扯点背景。数组、链表、队列、栈,这些数据结构摆在桌上,共性只有一个:每个元素最多一个前驱、一个后继,整条数据从前往后拉成一根线。处理排队、历史记录、括号匹配之类的问题,线性结构非常顺手。但有一类问题,线性结构天生吃力,就是“层级关系”。

随便举几个例子:公司组织架构,董事长下面是各部门总监,总监再往下带组长和组员;操作系统的文件夹,根目录下面挂着子目录和文件,子目录里还能继续嵌套;编译器解析1 + 2 * 3这个表达式,也得知道乘号的优先级高于加号,这本身就是一个层次结构。你用链表或者数组硬模拟这些场景,不是不行,但代价很大。查一个文件的完整路径,你得反复遍历好几个层级;判断两个部门是否平级,你可能要写一堆循环外加辅助数组。代码绕来绕去,最后自己都看不懂。本质问题在于建模工具和问题模型不匹配,线性结构表达不了“分支”和“嵌套”。

树这种结构就是为层级关系而生的。每个节点可以往下分出多个子节点,子节点还能继续分下去,这种嵌套关系天然贴合上面那些场景。而在所有树里,二叉树是最基础、最克制、也最优雅的一种形态。

1.2 二叉树的定义和术语,先清账

二叉树是这么定义的:每个节点最多有两个子树,分别叫左子树和右子树。这两个子树本身也是二叉树,所以递归定义顺理成章。

这里有几个术语,很多初学者容易混:

  • 根节点:整棵树最顶层的节点,没有父节点。
  • 叶子节点:没有子节点的节点,也就是树的末端。
  • 边:连接父节点和子节点的线。
  • 节点的深度:从根节点到该节点经历的边数。根节点的深度是 0。
  • 节点的高度:从该节点到最远叶子节点经历的边数。只有根节点的树,根节点高度是 0。
  • 树的高度:根节点的高度,也就是整棵树的层数减一。

我特别想提醒一点:深度和高度是从两个方向看问题的。深度是从上往下数,根是 0;高度是从下往上数,叶子是 0。刷题的时候,深度优先和高度优先的代码模板完全不同,别混。很多框架里题目问“二叉树的最大深度”,答案等于根节点的高度,这俩在根节点上恰好重合,但概念不是一个东西,最好从第一天就把边界弄清楚。

因为每个节点最多只有两个分支,所以递归的写法在二叉树上非常漂亮。一个节点要做的事情只有三件:处理自己,处理左子树,处理右子树。左子树和右子树还能继续往下拆。这就是为什么后面所有遍历代码你都看得懂,但真要自己写,又容易漏掉递归出口的原因。

1.3 为什么偏偏是二叉树,而不是三叉树四叉树

很多人会问:既然树可以有几个孩子,为什么默认讨论的都是二叉树?多叉树不也能表达层级关系吗?

我的理解是这样的:二叉树是功能最全、表达力足够、实现又最简单的形态。首先,任何多叉树都可以用“左孩子右兄弟”的方式转成二叉树,所以二叉树并没有损失表达能力。其次,二叉树的每个分支只分两种状态,在代码里就是left和right两个指针,这给后面做搜索、旋转、剪枝都提供了极好的控制粒度。第三,从复杂度角度说,二叉树的平均高度是 O(log n),这把查找、插入、删除都压到了对数级别,性价比拉满。如果你用三叉树,查找路径确实可能短一点,但每个节点要维护三个指针,插入和删除时的调整逻辑复杂一圈,收益不划算。

还有一个原因我觉得很实际:面试和工程里的大量算法讨论,默认语境就是二叉树。左旋、右旋、红黑树的变色、堆的下沉上浮,全都是围绕“最多两个孩子”的设定展开的。你要是真上了三叉树,光是平衡策略就够你喝一壶。所以先把二叉树吃透,后面的进阶路会顺很多。

2. 二叉树的形态分类,一字之差天壤之别

2.1 满二叉树、完全二叉树、完美二叉树

这组名词很多人分不清楚,因为他们翻译自英文的不同概念,中文教材之间还不统一。我先把区分逻辑摆出来。

完美二叉树(Perfect Binary Tree):除了叶子节点,每个节点都有两个子节点,而且所有叶子都在同一层。这种树长得很对称,满打满算没有缺口。高度为 h 的完美二叉树节点总数是 2^(h+1) - 1。

满二叉树(Full Binary Tree):每个节点要么是叶子,要么就有左右两个孩子,不允许只挂一个孩子。注意,它不要求所有叶子在同一层。有人把 Perfect 翻译成满二叉树,这就造成了混乱。我个人的做法是:严格区分“完美”和“满”,别混着叫。

完全二叉树(Complete Binary Tree):除了最后一层,上面的层都是满的,最后一层的节点靠左排列,不能左边空着右边有节点。完全二叉树有一个非常棒的性质:如果用数组从 1 开始编号存储,父节点在 i,那么左孩子就在 2i,右孩子就在 2i+1。堆排序就是靠这个性质起飞的。

三者对比,一句话归纳:完全二叉树允许最后一层缺右边,满二叉树禁止只有一个孩子的节点,完美二叉树则要求全部填满且同一水平。做题和工程实现时,优先判断是完全二叉树,因为它的数组存储特性能直接把内存利用率拉满。

2.2 二叉搜索树:有序性才是灵魂

二叉树本身不限制节点值的大小关系,左右子树放什么都可以。但二叉搜索树(Binary Search Tree,简称 BST)加了一条硬规则:对于任意节点,左子树所有节点的值都小于它,右子树所有节点的值都大于它。

这条规则带来一个黄金性质:中序遍历的结果是有序的。二叉搜索树的查找也特别直观:从根开始,目标值比当前节点小就往左走,比当前节点大就往右走,直到找到或者走到空节点。这非常像你在通讯录里翻名字时的二分思路,每一步砍掉一半的搜索范围,平均复杂度 O(log n)。

构造 BST 的时候有个经典陷阱:插入时如果值相等怎么办。不同实现各有取舍,有的选择往左走,有的选择往右走,有的直接忽略重复值。这个选择会直接影响树的结构和搜索行为。工程上我倾向于用left <= root < right这种约定,也就是相等值走左子树,这样能保留重复元素,同时中序遍历还保持非降序。

2.3 平衡二叉树:防止性能从 log n 退化到 n

BST 的时间复杂度有个前提:树长得足够对称。如果你把一组升序的数字,比如 1,2,3,4,5,6,按顺序插入 BST,会发生什么?每次新节点都比前一个大,一路往右挂,最后变成一条不折不扣的链表。查找复杂度和链表一样,退化成 O(n),没有任何优势。

所以有了平衡二叉树的约束。平衡二叉树要求任何节点的左右子树高度差不超过 1,一旦打破这个限制,就需要通过旋转操作把树重新扳正。旋转分为左旋、右旋、先左后右、先右后左四种情况,本质都是在不破坏 BST 有序性的前提下,改变节点之间的父子关系,让树重新变矮。

AVL 树是严格平衡的实现,红黑树是近似平衡的实现。AVL 对平衡要求更严,查询更快,但插入删除时旋转更频繁;红黑树放宽了平衡限制,牺牲一点查询效率,换来了更少的旋转操作,所以系统库里用红黑树的场景更多。这俩是 BST 的进阶形态,我不建议初学者一上来就啃,先把普通 BST 的原理和操作写稳,再上平衡策略会轻松很多。

3. 遍历二叉树:递归三板斧加上队列刷层

3.1 前序、中序、后序遍历,递归模板要背熟

所谓遍历,就是按某种顺序把树里的每个节点访问一次。二叉树的递归遍历有三种经典顺序,区别只在处理根节点的时机。

前序遍历:先访问根,再遍历左子树,最后遍历右子树。顺序是“根左右”。前序最常见的作用是做树的拷贝,因为先拿到根才能创建新根,再往下挂子树。

中序遍历:先遍历左子树,再访问根,最后遍历右子树。顺序是“左根右”。前面说了,BST 的中序遍历结果是有序的,这个性质在判断一棵树是不是 BST 时非常有用。

后序遍历:先遍历左子树,再遍历右子树,最后访问根。顺序是“左右根”。后序的特点在于,处理完左右子树才轮到根,所以特别适合做销毁树、计算子树大小、释放内存这类需要先搞定孩子的操作。

递归模板长这样:

def preorder(root): if not root: return print(root.val) # 根 preorder(root.left) # 左 preorder(root.right) # 右 def inorder(root): if not root: return inorder(root.left) print(root.val) inorder(root.right) def postorder(root): if not root: return postorder(root.left) postorder(root.right) print(root.val)

三份代码唯一的区别就是print的位置。很多人背模板背不下来,其实就是没搞懂递归顺序。你只要记住:递归会把左子树当一棵新树处理,右子树当另一棵新树处理,根节点只做一次访问,那三个顺序自然就出来了。

3.2 层序遍历:队列是唯一正确工具

前中后序都是沿着分支往深处扎,层序遍历则是从上到下、从左到右一层一层扫。直观地说,你想按层级看一棵树,就用层序遍历。

层序遍历的实现核心用队列,因为队列先进先出,正好能保证同一层的节点按顺序出队,同时把下一层的节点按顺序入队。很多初学者会试着用栈模拟,结果发现输出的顺序永远不对,原因就是栈后进先出,天然反着干活。

from collections import deque def level_order(root): if not root: return [] result = [] queue = deque([root]) while queue: node = queue.popleft() result.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) return result

如果你还需要知道每一层具体包含哪些节点,可以在 while 循环里加一层 for 循环,先记录当前队列长度,然后只弹出这么多节点。这个技巧在刷题时几乎必考,比如“二叉树的右视图”“二叉树的最大宽度”,都依赖分层遍历。

3.3 递归转迭代:显式栈怎么搞

递归虽然简洁,但它消耗函数调用栈,树的深度一上来就容易爆栈。所以很多生产代码里会写成迭代版本。递归转迭代的核心思路:递归用的是系统栈,迭代就是自己拿一个栈模拟这个过程。

中序遍历的迭代模板值得单独记:

def inorder_iter(root): stack = [] cur = root result = [] while cur or stack: while cur: stack.append(cur) cur = cur.left cur = stack.pop() result.append(cur.val) cur = cur.right return result

这段代码的难点在于那个内层 while。它的作用是把当前节点的左边界全部压栈,一路压到空为止。弹出的时候,先访问该节点,再把它的右子树入栈,因为右子树里可能又有一长串左边界。我第一次写的时候总想着把根也一起压进去,结果要么重复输出,要么漏掉右子树。后来想通了:外循环的cur or stack已经天然覆盖“刚处理完一个节点继续处理右子树”的场景,不需要额外入栈根节点。

前序转迭代更简单,先压右孩子再压左孩子即可。后序转迭代稍微绕一点,常见做法是用两个栈,或者用一个栈加一个逆序输出。我建议新手先把中序的迭代吃透,因为它在 BST 相关操作里出现频率最高。

4. 工程实操:查找、插入与删除的正确打开方式

4.1 查找与插入:藏着几个边界条件

BST 的查找实现非常直白:

def search(root, key): cur = root while cur and cur.val != key: if key < cur.val: cur = cur.left else: cur = cur.right return cur

循环比递归更省栈空间,所以生产环境我通常用循环实现查找。判空的时机是cur and cur.val != key,注意写成while cur而不是while cur.left,不然最后一个非空节点会被漏掉。

插入类似,也是从根开始走,走到空位置就挂上去。关键点是,记录父节点是谁,否则挂不上。

def insert(root, key): node = TreeNode(key) if not root: return node cur, parent = root, None while cur: parent = cur if key < cur.val: cur = cur.left else: cur = cur.right if key < parent.val: parent.left = node else: parent.right = node return root

值得一提的边界:如果原来的树是空的,直接返回新节点;如果不允许重复值,插入前可以先单独查一下;如果允许重复,插入时相等值走哪边要想清楚。我第一次写插入时没判空树,结果空指针异常直接把人搞懵了。

4.2 删除节点:最容易写崩的三段式逻辑

删除是 BST 操作里最有技术含量的部分,因为它要处理三种情况。

情况一:目标节点没有子节点,也就是叶子。直接把它从父节点上摘掉就行。

情况二:目标节点只有一个子节点。让它唯一的子树直接顶替它的位置。这个操作在 C/C++ 里要格外小心释放内存的顺序。

情况三:目标节点有两个子节点。这里有两种策略,找左子树里的最大值节点,或者找右子树里的最小值节点。用这个节点的值覆盖目标节点的值,然后递归地在子树里删除那个用来覆盖的节点。这样省去了真正物理移除目标节点的复杂指针操作。

def delete(root, key): if not root: return None if key < root.val: root.left = delete(root.left, key) elif key > root.val: root.right = delete(root.right, key) else: if not root.left: return root.right if not root.right: return root.left # 左右都在,找右子树最小节点 node = root.right while node.left: node = node.left root.val = node.val root.right = delete(root.right, node.val) return root

这里最反直觉的地方,是删除函数要返回处理后的子树根节点。很多初学者写着写着就忘了把递归返回值赋回原字段,最后树被删得七零八落。你只要记住,所有递归都遵循“先把子树处理完,再把结果挂回去”这个套路,就不容易崩。

4.3 复杂度分析:给二叉树做个体检

摊开来看,BST 的核心操作复杂度是很好记的。平均水平下,树高是 O(log n),所以查找、插入、删除都是 O(log n)。但任何一环出了问题,比如插入升序数据,树变成链表,复杂度直接变成 O(n)。这就是平衡二叉树存在的意义。

空间方面,递归实现需要调用系统栈,最坏情况 O(n);迭代实现用显式栈,也是 O(n)。层序遍历的队列空间最坏 O(n),树的最后一层节点数量到不了 n,但对完全二叉树来说,最后一层差不多 n/2,级别上还是 O(n)。

我个人的做法是写代码前先算一遍:这棵树会不会退化?如果会,就提前想好要不要用平衡树,或者干脆换数据结构。动手前先算复杂度,比写完再调优省太多时间。

5. 实战避坑:二叉树代码里的血泪教训

5.1 递归深度和栈溢出,刷题刷出的心理阴影

我见过不止一次:本地跑好好的,一到线上或者用超大测试用例,直接 Stack Overflow。问题基本都出在递归深度上。树如果特别深,比如 10 万层的单链树,递归层数就是 10 万,系统栈根本扛不住。

一个稳妥的做法是把递归遍历改成迭代版,尤其是中序和后序。另一个做法是提前估计树高,如果超出可接受范围,就改用显式栈或者层序遍历。生产环境的通用原则是:有递归的方案要明确设置深度上限,没有上限时宁可多写几行迭代代码,也不要赌系统栈扛得住。

5.2 指针、引用和内存,C/C++ 患者特别要注意

在 C++ 里删除节点时,内存释放的时机非常关键。你先把指针删了,再回头去改父节点的指针,那等于访问了一块已经被释放的内存,UB(未定义行为)直接拉满。正确思路是:先调整链接关系,把节点从树上摘下来,再执行 delete。顺序错了,排查起来极其痛苦。

还有一类经典问题:节点被重复释放。有些实现为了省事,删除一个子树后没有把父节点的指针置空,导致同一块内存被析构两次。这种 bug 不崩溃是运气,崩溃了特别难定位。我的经验是在大规模删除场景里,干脆把树转成迭代方式处理,逐节点摘除,然后统一释放,比递归省心得多。

5.3 调试可视化:让树现出原形

二叉树调试最怕的是“只在脑袋里画树”。打印出一个节点值看不出整体结构,很难判断是哪一步接错了。我常用的办法是把树序列化成数组或者直接打印括号表示法。

一个简单方案是层序遍历的格式化输出,每层一个列表:

def tree_to_list(root): if not root: return [] res = [] queue = deque([root]) while queue: level = [] for _ in range(len(queue)): node = queue.popleft() level.append(node.val if node else None) if node: queue.append(node.left) queue.append(node.right) res.append(level) return res

这个输出按层分好组,一眼就能看出哪一层的左右子节点挂错了。我调试平衡树的旋转操作时,几乎全靠这个函数看旋转前后形态的变化。再叠加一个“空节点打印 None”的策略,叶子位置也能看得清清楚楚。调试代码前先把这个工具函数准备好,能省出一小时用来挠头。

5.4 代码模板:写熟这几段就够了

二叉树相关的代码模板不算多,但每段都要写到肌肉记忆程度。我建议重点练习:

  • 递归遍历的前中后序三件套
  • 层序遍历和按层取值的变体
  • 中序迭代遍历
  • BST 的查找、插入、删除
  • 树的序列化与反序列化

序列化这块值得单独说。面试和生产里经常要把树传给下游,比如持久化到 Redis 或者传给另一台机器。最简单可靠的序列化方式是前序遍历加空节点标记,比如用#表示空。反序列化时用同样的顺序重建,先建根,再递归建左右。这套方案对普通二叉树完全够用,代码也不长。

6. 二叉树不是玩具,生产环境到处都是它

6.1 表达式树:编译器怎么读懂1 + 2 * 3

如果要把一个数学表达式转成可执行代码,编译器通常先把中缀表达式转成表达式树。1 + 2 * 3的树长这样:根是加号,左孩子是 1,右孩子是一个乘号节点,乘号的左孩子是 2,右孩子是 3。后序遍历这棵树,得到1 2 3 * +,这正是后缀表达式,直接可以用栈求值。这个机制解决的是运算符优先级问题:乘法天然在更深的节点,先被求值。你要是用线性结构去解析这个表达式,优先级处理会繁琐得多。

表达式树的求值本身也是一个后序遍历的典型应用:

def evaluate(root): if root is None: return 0 if root.left is None and root.right is None: return root.val left = evaluate(root.left) right = evaluate(root.right) if root.val == '+': return left + right if root.val == '-': return left - right if root.val == '*': return left * right if root.val == '/': return left // right

所以学二叉树不只是刷题,它直接解释了计算器、公式编辑器这类工具的底层机制。

6.2 数据库索引:从 BST 到 B+ 树的进化

数据库的索引通常用 B+ 树,而不是纯二叉树。原因是磁盘 I/O 的环境里,树太高就意味要多读很多个磁盘块,每次读盘都是毫秒级成本。B+ 树把节点做得很宽,一个节点塞很多个键,把树的高度压到只有三四层。几亿条数据,只要几次磁盘读取就能定位到目标。

但理解 B+ 树之前,BST 的思想是地基。你理解了“比较后决定走左还是走右”这一招,就理解了所有搜索树的核心:每次比较减少搜索范围。从 BST 到 AVL、红黑树,再到 B 树、B+ 树,核心都绕不开这一件事。很多人一上来就啃 B+ 树被劝退,我建议先回炉二叉树,把基础夯实再往上走。

6.3 堆与优先队列:二叉树的数组形态

堆是一种完全二叉树,而且用数组存储。大顶堆的每个父节点都比孩子大,所以堆顶永远是最大值。插入时先放到数组末尾,然后不断上浮;弹出堆顶时,把最后一个元素放到堆顶,再不断下沉。这个上浮和下沉的过程,就是沿着二叉树路径做交换,复杂度 O(log n)。

生产代码里,优先队列大量使用堆。任务调度要拉最高优先级任务,调度器用大顶堆;定时器要找到最近要到期的任务,用小顶堆。Java 的PriorityQueue、Python 的heapq,底层都是数组实现的堆。你在用这些工具时,其实已经在使用完全二叉树的数组存储性质了。

6.4 更多变体:线索二叉树、哈夫曼树、红黑树

线索二叉树把空指针利用起来,指向遍历序列的前驱后继,省去递归栈,空间换时间。哈夫曼树是带权路径最短的二叉树,用在压缩算法里,频率高的字符编码更短。红黑树是近似平衡的 BST,Java 的TreeMap、C++ 的std::map,底层都是它。Linux 内核里也有红黑树的身影,用于管理虚拟内存区域。

这一层扩展其实说明一个道理:二叉树这个模型很朴素,但你在各种基础组件里都能看到它的变体。理解了二叉树的存储、遍历、搜索和平衡逻辑,再看 B 树、跳跃表这些进阶结构,起点就高了很多。

最后分享一点我个人实践的体会。写二叉树代码,最大的收获不是背会了模板,而是养成了“递归思维”——拿到一个问题,先想能不能拆成左右两个子问题,再想子问题的解怎么合并成原问题的解。这种思维方式在做很多算法题、写分治策略、设计异步任务树的时候都非常有用。你们选择抽空把二叉树结构亲手实现一遍,用刚才的可视化工具看看自己插入删除后的树长什么样,效果比刷几十道题都扎实。

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

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

立即咨询