☰
从零实现二叉树:遍历、递归与边界处理
2026/10/15 1:25:31 网站建设 项目流程

1. 为什么二叉树值得你亲手实现一遍

很多人第一次接触二叉树,是在数据结构的课堂上,听老师讲完前序、中序、后序遍历,考试画一画图就过去了。等到真正写代码的时候,发现面试要手写、项目里要用,脑子里只剩一个模糊的"左子树、右子树"概念,连删除节点怎么处理都说不清楚。我见过不少工作两三年的开发者,用起各种框架很熟练,但让他从零实现一个能跑、能测、边界情况都覆盖的二叉树,代码里全是漏洞。

这篇文章就是来解决这个问题的。我会带你从零把一个二叉树完整实现出来,包括节点定义、插入、三种深度优先遍历、层序遍历、查找、删除,以及几个容易被忽略但非常关键的边界处理。关键词就三个:二叉树、遍历、递归。适合刚学数据结构的学生,也适合想重新夯实基础的开发者。读完之后,你不只是"知道"二叉树是什么,而是能拿出一份可以直接用的代码,并且清楚每一行为什么这么写。

我个人的习惯是,学任何数据结构都先问三个问题:它解决什么问题、它的核心操作有哪些、它的边界在哪里。二叉树解决的是层次化数据的组织与快速检索问题,核心操作是插入、遍历、查找、删除,边界则集中在空树、单节点、只有左或只有右子树这几种情况上。把这三点想清楚,实现起来就不会乱。

下面我按"先建骨架、再补操作、最后处理坑"的顺序来讲,每一步都给出可运行的代码和背后的思考。

2. 节点结构与树的骨架怎么搭

2.1 节点到底该存哪些字段

二叉树的节点,最朴素的版本只需要三个字段:数据本身、左孩子指针、右孩子指针。用 Python 写出来是这样:

class TreeNode: def __init__(self, val): self.val = val self.left = None self.right = None

看起来简单,但这里有个新手常犯的错:把left和right默认值写成TreeNode(0)之类的空节点。这样做会导致遍历时永远走不到真正的None,递归没有终止条件,直接栈溢出。空指针就是空指针,用None表示,不要用哨兵节点去填充,除非你在实现特定的平衡树结构(比如红黑树的 NIL 节点),那是另一回事。

那要不要加一个parent指针指向父节点?这取决于你的使用场景。如果只是做遍历和查找,不需要;但如果你要实现"找某个节点的后继节点"或者"删除节点后向上调整"这类操作,parent会省很多事。我的建议是:初学阶段不加,把指针关系理清楚之后,再根据需求决定。加了parent之后,每次修改树结构都要同步维护它,漏掉一处就是隐蔽的 bug。

2.2 用类封装树,还是直接用节点裸操作

两种风格都常见。裸操作就是直接拿根节点传来传去,函数签名长这样def insert(root, val)。封装成类则是class BinaryTree里面维护一个self.root,方法写成def insert(self, val)。

我倾向于封装成类,原因有两个。第一,根节点是整棵树的状态,把它藏在类里,外部调用者不用关心"我现在传的是不是根",接口更干净。第二,后续如果要加树的大小、高度缓存等字段,有地方放。骨架大概是这样:

class BinaryTree: def __init__(self): self.root = None

注意这里root初始为None,代表一棵空树。空树是所有边界情况的源头,后面每个操作都要先考虑"如果树是空的会怎样"。

2.3 插入:为什么普通二叉树插入这么"随意"

这里要先澄清一个概念。普通二叉树对节点值没有顺序要求,插入位置可以任意指定。而**二叉搜索树(BST)**要求左子树所有值小于根、右子树所有值大于根,插入位置由值的大小决定。很多人把这两个概念混在一起,写出来的代码既想保持有序又没维护好,最后四不像。

本文实现的是普通二叉树,所以插入有两种常见做法:一是按层序找第一个空位插入(类似完全二叉树的填法),二是指定父节点和方向插入。我选第一种,因为它不需要调用者关心结构,接口最简单:

from collections import deque def insert(self, val): node = TreeNode(val) if self.root is None: self.root = node return q = deque([self.root]) while q: cur = q.popleft() if cur.left is None: cur.left = node return elif cur.right is None: cur.right = node return else: q.append(cur.left) q.append(cur.right)

这段代码的逻辑是:从根开始逐层扫描,遇到第一个缺左孩子的位置就放左边,缺右孩子就放右边,都满了就把两个孩子入队继续往下找。用队列做层序遍历来定位插入点,这是关键。如果你用递归去插入,很难保证"填满一层再填下一层"这个顺序。

提示:如果你要实现的是二叉搜索树,插入逻辑完全不同,需要一路比较大小走到叶子再挂上去。别把两套逻辑混用。

3. 四种遍历方式,递归与迭代各写一遍

遍历是二叉树最核心的操作,没有之一。很多算法题本质就是遍历的变体。我把四种遍历都实现一遍,并且每种都给递归和迭代两个版本,因为面试里这两种写法都会被问到。

3.1 前中后序:递归版本三行搞定

递归版本的遍历代码极其对称,区别只在于"访问根节点"这一行放在什么位置:

def preorder(self, node, res): if node is None: return res.append(node.val) # 前序:根在前 self.preorder(node.left, res) self.preorder(node.right, res) def inorder(self, node, res): if node is None: return self.inorder(node.left, res) res.append(node.val) # 中序:根在中间 self.inorder(node.right, res) def postorder(self, node, res): if node is None: return self.postorder(node.left, res) self.postorder(node.right, res) res.append(node.val) # 后序:根在最后

递归的终止条件必须是node is None时直接返回,这是整个递归能停下来的唯一保证。我见过有人写成if node.left is None and node.right is None: return,结果遇到只有一个孩子的节点就出问题。记住:判断的是"当前节点是否为空",不是"当前节点是否是叶子"。

递归的优点是代码短、逻辑清晰,缺点是递归深度受调用栈限制。一棵退化成链表的二叉树,深度可能达到节点总数,几万个节点就能把栈撑爆。所以生产环境里,迭代版本更稳妥。

3.2 用栈模拟递归:前序和中序的迭代写法

前序遍历的迭代思路很直接:用一个栈,先压右孩子再压左孩子,这样出栈顺序就是先左后右,符合前序"根左右"的要求。

def preorder_iter(self): if self.root is None: return [] res, stack = [], [self.root] while stack: node = stack.pop() res.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return res

注意压栈顺序是先右后左,因为栈是后进先出,先压进去的会后出来。这个细节写反了,遍历结果就变成"根右左"了。

中序的迭代稍微绕一点,需要一路向左走到头,再回退访问:

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

这段代码的精髓在于内层while cur循环负责"把左链全部压栈",外层循环负责"弹出一个访问,然后转向右子树"。第一次看可能觉得别扭,建议拿一棵三层的树手动模拟一遍,走完就通了。

3.3 后序迭代为什么最难写

后序遍历的迭代是四种里最麻烦的,因为根节点要在左右子树都访问完之后才能访问。常见做法是用两个栈,或者用一个栈加一个"上次访问节点"的标记。

双栈法最好理解:第一个栈按"根右左"的顺序遍历,把结果压进第二个栈,最后第二个栈出栈就是"左右根"。

def postorder_iter(self): if self.root is None: return [] res, stack = [], [self.root] while stack: node = stack.pop() res.append(node.val) if node.left: stack.append(node.left) if node.right: stack.append(node.right) return res[::-1]

这里先压左再压右,出栈就是"根右左",最后把结果数组反转,得到"左右根"。反转数组这个技巧比维护访问标记清爽得多,我强烈推荐。

3.4 层序遍历:队列的主场

层序遍历用队列,这个几乎没争议:

def level_order(self): if self.root is None: return [] res, q = [], deque([self.root]) while q: level = [] for _ in range(len(q)): node = q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level) return res

关键点是内层for _ in range(len(q)),它把当前层的节点数固定下来,这样每一层的节点会被归到同一个子数组里。如果不需要分层,直接一个 while 循环弹出即可。这个"先取长度再循环"的写法是层序分层的标准套路,很多变体题(比如求每层最大值、右视图)都靠它。

4. 查找、删除与几个必须处理的边界

4.1 查找:普通二叉树只能老老实实遍历

普通二叉树没有排序性质,查找只能遍历整棵树,时间复杂度 O(n)。用递归写:

def find(self, val): return self._find(self.root, val) def _find(self, node, val): if node is None: return None if node.val == val: return node left = self._find(node.left, val) if left: return left return self._find(node.right, val)

注意这里找到就立刻返回,不要继续遍历右子树,否则白白多跑一半。先查左子树,查到就短路返回,这是个小优化但值得养成习惯。

4.2 删除:最容易被低估的操作

删除是二叉树里最容易写错的操作。普通二叉树的删除,标准做法是:找到目标节点后,用它右子树中最深的那个节点(或者左子树最深的)来替换它,然后删掉那个替换节点。

为什么用最深的节点替换?因为最深节点一定是叶子或只有一个孩子,删除它最简单,不会破坏其他节点的结构。如果随便拿一个节点替换,可能引入新的空位,处理起来更复杂。

def delete(self, val): self.root = self._delete(self.root, val) def _delete(self, node, val): if node is None: return None if node.val == val: # 情况1:叶子节点,直接删 if node.left is None and node.right is None: return None # 情况2:只有一个孩子,用孩子顶替 if node.left is None: return node.right if node.right is None: return node.left # 情况3:两个孩子都有,找右子树最左节点替换 successor = self._min_node(node.right) node.val = successor.val node.right = self._delete(node.right, successor.val) return node node.left = self._delete(node.left, val) node.right = self._delete(node.right, val) return node def _min_node(self, node): while node.left: node = node.left return node

这段代码把删除分成三种情况,每种情况单独处理,逻辑才不会乱。情况三里,找到右子树的最小节点(也就是最左节点),把它的值复制到当前节点,然后递归删除那个最小节点。因为最小节点最多只有右孩子,删除它必然落到情况一或情况二,递归一定能终止。

注意:删除操作返回的是"处理后的子树根节点",所以调用方必须用node.left = self._delete(...)接住返回值。如果写成self._delete(node.left, val)不接收返回值,树结构就断了。这是新手最容易踩的坑。

4.3 边界情况清单

我把实现过程中必须覆盖的边界列成一张表,写完代码后逐条测一遍:

边界场景预期行为常见错误
空树遍历返回空列表直接访问 root.val 报错
空树删除静默返回,不报错抛异常或崩溃
单节点树删除树变空残留悬空指针
只有左子树的链遍历结果正确递归深度过大
删除不存在的值树结构不变误删其他节点
重复值插入按普通二叉树规则插入与 BST 逻辑混淆

这张表里的每一条,我都实际写过测试用例验证。尤其是"删除不存在的值"这一条,很多实现会在递归过程中把某些节点错误地置空,导致树被破坏。测试的时候一定要在删除后重新遍历一遍,确认结构完整。

5. 实测中暴露的问题与我的调试心得

5.1 递归返回值没接住,树悄悄断了

这是我早期最常犯的错。写删除或者插入的递归版本时,函数返回了新的子树根,但调用处忘了赋值。代码能跑,不报错,但树的结构已经不对了。排查方法很简单:每次修改树结构后,立刻做一次层序遍历,把结果打印出来对比。如果节点数变少了或者顺序乱了,就是返回值没接住。

我现在的习惯是,所有会修改结构的递归函数,命名上就带_delete、_insert这种前缀,提醒自己"这个函数有返回值,必须接住"。

5.2 用列表当默认参数,结果所有实例共享

Python 里有个经典陷阱:def traverse(self, node, res=[])。这个res是可变对象,作为默认参数只会在函数定义时创建一次,所有调用共享同一个列表。第一次调用没问题,第二次调用就会发现结果里混着上次的数据。

正确写法是res=None,函数内部判断if res is None: res = []。这个坑在写遍历的时候特别容易踩,因为遍历天然需要一个累积结果的容器。

5.3 迭代遍历的压栈顺序,写反了很难发现

前序迭代里先压右再压左,中序迭代里先一路向左,后序迭代里先压左再压右最后反转。这三个顺序我至少写反过两次,而且写反之后代码不报错,只是结果不对。验证方法就是拿一棵结构明确的树,手算出正确遍历序列,跟代码输出对比。我常用的一棵测试树是:

1 / \ 2 3 / \ \ 4 5 6

它的前序是1 2 4 5 3 6,中序是4 2 5 1 3 6,后序是4 5 2 6 3 1,层序是1 2 3 4 5 6。把这四个序列背下来,调试的时候直接对照,效率很高。

5.4 递归深度超限,换成迭代就好了

有一次我拿一棵接近十万节点的退化树做测试,递归遍历直接抛了递归深度超限的异常。换成迭代版本之后,同样的数据毫无压力。结论是:如果数据规模可能很大,或者树的形状不可控,优先用迭代。递归适合逻辑清晰、规模可控的场景,比如面试手写或者小数据量处理。

5.5 关于测试用例的设计

我建议至少准备这几类测试:空树、单节点、满二叉树、只有左子树的链、只有右子树的链、随机结构的树。每类都跑一遍插入、四种遍历、查找、删除。删除测试要特别设计,覆盖删除叶子、删除单孩子节点、删除双孩子节点、删除根节点这四种情况。这四类过了,基本就稳了。

6. 从这棵树能延伸出去的东西

把普通二叉树实现完,其实只是起点。往上走有几个方向值得继续深入。

第一个方向是二叉搜索树。在普通二叉树的基础上加上"左小右大"的约束,插入和查找就能做到平均 O(log n)。但要注意,普通 BST 在有序数据下会退化成链表,所以又引出了平衡树,比如 AVL 树和红黑树。红黑树是很多语言标准库里有序容器的底层结构,值得单独花时间研究。

第二个方向是堆。堆本质是一棵完全二叉树,用数组存储,父子节点通过下标计算关系。理解了二叉树的层序结构,堆就很好懂了。优先队列就是堆的典型应用。

第三个方向是树的序列化与反序列化。把一棵树转成字符串存起来,再还原回来,这在数据持久化和网络传输里很常见。核心思路就是用遍历序列加上空节点标记,前序加中序可以唯一确定一棵树,这个性质经常被考到。

第四个方向是树形 DP。很多算法题在二叉树上做动态规划,比如求最大路径和、判断平衡、求直径。这些题的套路都是"递归处理左右子树,合并结果",把本文的递归遍历理解透了,这类题就有感觉了。

我自己在实现完这棵树之后,最大的收获不是记住了几种遍历的代码,而是养成了一个习惯:遇到递归结构,先想清楚终止条件和返回值语义。这两点想明白了,代码基本不会错。二叉树是练习这个思维最好的载体,因为它足够简单,又足够典型。

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

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

立即咨询