☰
二叉树创建方法详解:手写递归与层序实现
2026/10/15 6:37:49 网站建设 项目流程

最近和朋友聊数据结构,发现很多人一开始学二叉树就被“创建”这一步劝退了。倒不是二叉树本身有多难,而是网上讲概念的资料一堆,真到了要自己动手把一棵树建出来的时候,反而不知道代码怎么落笔。我最初学的时候也是这样,看书上画着节点和箭头觉得挺清楚,一关编辑器就傻了:这棵树的节点到底怎么连起来的?递归看着简洁,但脑子里绕不明白它到底怎么一层层把树搭出来的。

这篇文章就专门把这层窗户纸捅破。我会从节点的定义开始,把创建二树的几种典型写法逐一拆开揉碎,包括手动搭树、递归建树、层序建树,再附上验证树的遍历代码和几个很容易踩的坑。无论你是刚开始学数据结构的学生,还是工作中突然要用树结构做业务开发的程序员,照着这篇文章走一遍,之后再看任何树的增删改查题,都会觉得顺眼很多。

1. 先搞清楚:创建二叉树,到底是在“创建”什么

很多人写不出代码,不是因为不会写递归,而是没想明白一个最基本的问题:一棵二叉树的本质是什么?弄清了这个问题,后面的代码设计就顺理成章。

1.1 一张图看懂树的结构

二叉树在数学上的定义是:每个节点最多有两个子节点。“最多两个”这点很关键——正是因为这个限制,整个树的形态被大大简化,我们才可以用一套统一的规则来描述它:每个节点持有自己的数据,同时持有两个指针/引用,分别指向左子树和右子树。

一个节点长成这样:

[数据域] / \ [左指针] [右指针]

每个指针要么指向另一个节点,要么为空(也就是null/None)。这个“空指针”的位置恰恰是树的边界——它告诉你这片子树到此结束。从根节点出发,顺着一层层的指针走下去,每一条路径都用一个“空指针”收尾,这就是一棵完整的树。

用生活里的例子来想:二叉树就像公司组织架构里规定“每个主管最多带两名下属”。你从总经理(根节点)开始数,沿着每个主管的下属关系一直数下去,所有链条都在某个没有下属的员工那里结束。你要“创建”一棵二叉树,本质就是创建一批节点,然后把这些节点按左右关系用指针连接起来。

1.2 创建二叉树的实际场景有三种

深入代码之前,先把“创建”这件事按场景分开。不同场景下,建树的方法完全不同,混在一起最容易看晕。

  • 手工搭建:你为了测试某个算法,需要一个形态特定的树。比如面试官说“给你一棵只有右孩子的链式树”,这时候最快的方式就是在代码里一个个节点手动连出来。
  • 递归创建:你拿到一个值列表,这个列表某种顺序定义了树的节点关系(比如按前序序列给出,空节点用None标记),需要程序自动还原出原树。
  • 层序创建:你拿到一个数组,这个数组是按完全二叉树层序排列的(比如LeetCode风格的[1,2,3,null,5]),需要按层级关系把数组映射成链式树结构。

这三种场景的数据来源和规则不同,对应的写法差异很大。理解了这一点,你在网上看到五花八门的建树代码,就能一眼看出它属于哪一种,也不会再拿前序序列去套层序建树的逻辑。

2. 动手前先拆清楚:节点的定义和存储方式选型

树的“骨架”是节点。不同编程语言定义节点的方式各有风格,但设计逻辑基本一致。这节把最核心的两个决策讲清楚:节点怎么定义,以及用链式还是数组存储。

2.1 Node长什么样,代码怎么写

以Python为例,最简单的节点类定义:

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

构造函数把值、左孩子、右孩子都作为参数,带默认值。这个定义能覆盖绝大多数场景:TreeNode(5)创建一个孤立节点;TreeNode(5, left_node, right_node)一步就把左右子树接上了。

Java版本基本是同一个套路:

public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } }

这里的核心设计思想是自引用结构:left和right的类型仍然是TreeNode本身。初学者容易觉得这个概念神乎其神,其实它就是在说:一棵树的每个子树,本身也是一棵完整的树。这个“自相似”的特性贯穿二叉树的一切操作——遍历是递归的,创建是递归的,删除也是递归的。理解了自引用,你才能理解为什么树的代码到处都是“自己调自己”。

2.2 链式存储 vs 数组存储,到底选哪个

创建二叉树之前必须做一个选型决策:用链式节点(指针引用)还是用数组(列表)存储。很多人默认“树就该用链式”,其实数组有数组的适用场景,选错了会让代码非常别扭。

两种方式的对比如下:

对比维度链式存储数组存储
节点表示每个节点是独立对象,含左右指针用数组下标表示位置关系
父子关系通过指针直接跳转通过下标公式推算
稀疏树表现良好,空节点就是None差,中间空位越多浪费越严重
扩展性易插入删除插入删除代价高
内存局部性较差较好
经典应用常规二叉树、搜索树、AVL、红黑完全二叉树、堆、线段树

数组存储的父子关系公式很简单:下标为i的节点(从0开始),它的左孩子下标是2*i + 1,右孩子下标是2*i + 2,父节点下标是(i-1)//2。像堆(Heap)就是典型的完全二叉树,数组存储天然合适,根本不需要建链。

但如果是平时做题或写业务代码,链式存储是默认选择。它的表达力最强,树的形态再稀疏也不浪费空间,插入和删除也更灵活。下面所有代码都基于链式实现。

3. 三种创建二叉树的写法与适用边界

接下来进入正题。这一节逐个演示三种建树写法,并且告诉你每种写法适合什么场景、代码怎么理解、边界条件在哪。

3.1 手动搭骨架:最快的“测试专用”建树法

如果只是为了跑通某个代码片段,手动建树是最直接的方式。整个过程就是:创建节点,然后把指针指好。

# 构建一棵这样的树: # 1 # / \ # 2 3 # / \ \ # 4 5 6 root = TreeNode(1) root.left = TreeNode(2) root.right = TreeNode(3) root.left.left = TreeNode(4) root.left.right = TreeNode(5) root.right.right = TreeNode(6)

简洁直观,没有任何多余动作。缺点是只适合节点很少的树,节点一多,代码就变得冗长,而且树的结构从代码里看不出来容易出错。

3.2 递归创建:最有“树味”的方法

递归建树常见于场景:给你一个前序遍历的数组,数组中的某个占位符表示空节点(比如None),要求还原原来的树。

考虑这样一个前序序列:

[3, 9, 20, None, None, 15, 7]

前序遍历的规则是“根-左-右”。那么遇到None就代表“这个位置没有子树”,直接返回空。递归函数的写法如下:

class Solution: def buildTree(self, preorder): self.index = 0 return self.helper(preorder) def helper(self, preorder): if self.index >= len(preorder): return None val = preorder[self.index] self.index += 1 if val is None: return None node = TreeNode(val) node.left = self.helper(preorder) node.right = self.helper(preorder) return node

这段代码初看很绕,但它的逻辑其实非常清晰:每次从数组中取出一个值。如果值是None,说明这里没有节点,返回空;否则创建一个节点,然后递归构建左子树,再递归构建右子树。关键在于self.index是全局推进的——每处理一个元素,不管有没有生成节点,这个索引都会 +1。

这个过程用一句话概括:递归把“创建一棵子树”这个词,变成了“创建根节点 + 继续创建它的左子树 + 继续创建它的右子树”。树的整体结构就通过这样一次次自我拓展生成出来。

来看一个实际运行的例子。上面那个序列:

  1. preorder[0] = 3,创建根节点3;
  2. 递归建左子树,读取9,创建节点9;
  3. 继续递归建9的左子树,读取None,返回空;
  4. 回到9的右子树,读取None,返回空;
  5. 于是9的左右子都为空,9这棵子树完成,挂到根节点3的左边;
  6. 继续读取20,创建根节点20的左子树节点;
  7. 依此类推……

最终得到:

3 / \ 9 20 / \ 15 7

使用递归建树时有一个很实用的技巧:把index做成成员变量(或闭包里的列表),而不是作为参数传来传去。因为参数传递会牵扯到“何时自增”“怎么传回新值”的问题,容易把人绕晕。闭包或成员变量让索引在递归过程中保持全局唯一,思路会清爽很多。

3.3 层序创建:从数组到树的映射逻辑

这是做题碰到最多的一种建树方式,LeetCode风格的输入就是层序数组:

tree_array = [1, 2, 3, None, 5, 6, None]

这种数组的特点是:下标i的左孩子是2*i+1,右孩子是2*i+2;None表示这个位置没有节点,但它后面的下标位置仍然留给它的“可能孩子”使用。

层序建树的经典实现思路是利用队列。队列的每个元素是一个“带待连接孩子节点的位置”,我们不断从队列弹出节点,把数组里的下一个值分别挂成它的左右孩子,如果孩子不为空就继续入队。

from collections import deque def build_tree_from_level_order(level_order): if not level_order: return None root = TreeNode(level_order[0]) queue = deque([root]) index = 1 while index < len(level_order): node = queue.popleft() if index < len(level_order): val = level_order[index] index += 1 if val is not None: node.left = TreeNode(val) queue.append(node.left) if index < len(level_order): val = level_order[index] index += 1 if val is not None: node.right = TreeNode(val) queue.append(node.right) return root

这里有个初学者容易犯的错误:直接用TreeNode(level_order[i])去接None。TreeNode(None)会创建一个值为None的节点,而不是“没有这个孩子”。一定要先判断值是否为None,是就不创建,让它保持为空。代码中每一处创建节点的前提都是val is not None,这一步过滤必须做好。

为了直观验证,我写这个函数跑了一下,构建后的树结构应该是:

1 / \ 2 3 \ / 5 6

具体验证方法下一节讲,不过这里可以先把结论给出:只要能正确创建,层序建树的逻辑就不会跑偏。

4. 创建完怎么验证是对的:写一个遍历工具

树创建完,没有人希望只是“自我感觉良好”。写代码和调公式不同,必须要用输出来验证。二叉树的验证工具就是遍历——把树的节点按某种顺序打印出来,和预期对比。

4.1 递归遍历实现的细节

二叉树有前序、中序、后序三种经典遍历方式。它们的区别只在“访问根节点”这一动作和“递归遍历左右子树”之间的相对位置。

def preorder(root): if root is None: return print(root.val, end=" ") preorder(root.left) preorder(root.right) def inorder(root): if root is None: return inorder(root.left) print(root.val, end=" ") inorder(root.right) def postorder(root): if root is None: return postorder(root.left) postorder(root.right) print(root.val, end=" ")

这三段代码结构完全一样,唯一区别就是print的位置:

  • 前序:先打印自己,再处理左,再处理右。
  • 中序:先处理左,再打印自己,再处理右。
  • 后序:先处理左,再处理右,再打印自己。

很多人混淆这三种顺序,我把它们比喻成开会点名:“前序”是领导先发言再往下传,“中序”是等左隔壁说完了自己再发言,最后右隔壁说,“后序”是等左右都讲完了,领导最后总结。位置不同,出来的结果完全不同。

验证层的顺序还有一种特殊遍历——层序遍历。它按照树的层级从上到下、每层从左到右输出。实现方式就是前文建树用到的队列技巧,不属于递归,而是迭代。

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

4.2 用遍历结果反推树结构是否正确

现在回头验证 3.3 节层序建树的正确性。用上面这个level_order函数,对建出的树做层序遍历,期望输出是:

[1, 2, 3, 5, 6]

注意这个结果不含None。你可能会问:输入数组里有None,为什么输出里没有?因为None表示“没有节点”,遍历时跳过空节点是合逻辑的。如果建树的过程中误把None建成了节点,输出就会变成[1, 2, 3, None, 5, 6, None]——看到这样的输出,你就能立刻意识到:创建时的if val is not None过滤没写到位。

同理,3.2 节递归建树的树,做前序遍历输出是:

3 9 20 15 7

这个结果恰好和输入序列去掉None后的顺序一致,说明树结构还原正确。遍历输出就是建树函数的“单元测试”。从这时起,“建好一棵树”不再是一个无法验证的黑盒,而是变成了一系列可比较的结果。

我把这个验证流程固化成了自己的一个习惯:任何建树函数写完,立刻顺手写一行遍历调用把它打出来。输出和预期不一致就先别往下做,省得到后面排查算法逻辑问题时,还不知道错在树没建对。

5. 创建二叉树最容易踩的四个坑

自己建树和看博客建树完全是两码事。这条路上有几类错误出现的频率极高,我把它们总结出来,你在练习中遇到时可以直接对照。

5.1 递归出口写错导致栈溢出

递归建树最常见的错误是递归出口不完整。例如在 3.2 节buildTree中,有些同学只写了if self.index >= len(preorder): return None,却忘了if val is None: return None。结果序列中有None时,程序就会尝试TreeNode(None),然后继续向下递归,永远遇不到出口,最后栈溢出。

记住一个口诀:递归函数先写终止条件,再写逻辑。对于建树而言,终止条件有两个——索引越界和遇到空标记。漏掉任何一个,逻辑都可能是错的。

另一种栈溢出的场景是用递归建一棵深度很大的链式树时。Python 默认递归深度限制是1000层,如果树高超过这个值,递归直接崩。这时要么把递归限制调大,要么改写成迭代。业务代码里如果树的深度不可控,设计阶段就要想清楚是递归还是迭代。

5.2 空节点占位与数组索引错位

层序建树的坑主要集中在None的占位语义。给一个例子:

# 错误示范 tree_array = [1, None, 2] root = TreeNode(tree_array[0]) root.left = TreeNode(tree_array[1]) # 错误:None被建成了一个真实节点 root.right = TreeNode(tree_array[2])

这样建出来的树,左孩子是一个“值为 None 的节点”,而不是“没有节点”。后续遍历时,本来应该显示空的位置会蹦出一个None,本来应该没有子节点的节点还会多出两支空子树。这个 bug 在视觉上不容易发现,因为打印出来似乎也没差太多,但一旦做深度、路径、搜索类操作,结果立刻走样。

正确做法我已经反复强调了:先判断值是否为None,为None就跳过,不创建节点。层序建树时尤其注意数组里None后面的下标仍然是“有效的占位”,不要因为它是空就改变索引移动逻辑。

5.3 引用传递还是值传递造成的共享树

这个问题在 Python 和 Java 中都有体现。看这段代码:

def create_bad_tree(): shared = TreeNode(5) root = TreeNode(1) root.left = shared root.right = shared return root

这棵树的两棵子树指向同一个节点。虽然它不违反二叉树的定义,但会对很多操作造成困扰:修改root.left.val,root.right.val也会跟着变。树的形态虽然没错,但你潜意识里会认为左右子树是独立的两块区域,结果操作互相影响,排查时非常痛苦。

这个问题在“用同一个节点循环建树”时尤其容易混入。正确做法是每次创建节点都用新的对象,除非你有意让多个地方共享对象。写代码时多留个心眼:每个TreeNode都应该是全新的引用。

5.4 内存管理与释放问题

如果你用的语言需要手动管理内存(如C/C++),那建树之后的内存释放是绕不开的重点。一棵二叉树的节点是分散在堆里的,必须用后序遍历递归释放——先释放左子树、再释放右子树、最后释放当前节点。顺序错一个节点,就可能访问已经释放的内存。

void destroyTree(struct TreeNode* root) { if (root == NULL) return; destroyTree(root->left); destroyTree(root->right); free(root); }

好在Python、Java等语言有垃圾回收机制,不需要手动释放节点。但如果你用Python写那种递归创建的树,要记住一个细节:函数返回后如果没有外部引用指向树根,整棵树会被回收。所以你的树一定要有个“根引用”保存着,否则建完就丢了。

5.5 额外提一个:递归过程第一次写不出的心态问题

这个坑不在代码里,在脑子里。不少初学者第一次看递归建树代码时完全懵掉,很正常。递归确实需要一个适应过程,我自己当时也是盯着代码画了半小时递归调用栈才真正看懂。这里有个小诀窍:找一张纸,把每次递归输入的参数和返回值画成一个树状调用图,只看前三次递归调用,把它吃透,后面的递归就全通。秘诀是一次只模拟一层,不要试图一口气推理完整个递归过程。

6. 从“创建”到“会用”:这棵树还能怎么扩展

建树是入门动作,但一旦掌握了树的创建,上游下游能延伸出大量玩法。这一节我谈几个常见的扩展方向,帮你把建树和实际问题串起来。

6.1 从普通树到搜索树、平衡树

二叉搜索树(BST)在创建时有一个额外的约束:对任意节点,左子树所有值小于它,右子树所有值大于它。这导致建树逻辑发生变化——不是按给定位置放节点,而是从根开始比较大小,最终插入到合适位置。

def insert_into_bst(root, val): if root is None: return TreeNode(val) if val < root.val: root.left = insert_into_bst(root.left, val) else: root.right = insert_into_bst(root.right, val) return root

这就是动态创建 BST 的核心:每次插入递归搜索目标位置,然后挂新节点。如果按有序序列反复插入,BST 会退化成一个链表。这引出平衡树(AVL、红黑树)的概念——在插入时通过旋转调整高度。理解了树的创建之后,再去看旋转操作就会觉得没那么抽象,因为你知道“节点挂在哪个位置”是可以通过旋转重新组织而不违反遍历顺序的。

6.2 序列化与反序列化的进阶玩法

树的序列化就是把内存中的树变成一个字符串,方便存数据库或传输;反序列化就是把你写好的字符串还原成树。这个场景正好用上前面学的创建知识——反序列化本质上就是一种“按某种规则创建二叉树”的过程。

常见的序列化方案之一是采用层序序列化,把空节点也写入字符串。比如把树[1,2,3]序列化成"1,2,3,N,N,N,N"。反序列化时,读入这个字符串按层序逻辑重建树。跟前文层序建树的规则完全一致,只是读取来源从数组变成了字符串解析。

学习序列化最大的价值就是让你意识到:“创建二叉树”不是一个一次性的操作,而是一个可以反复执行的能力。你创建了一棵树,把它存起来,再从存储中重建出来,这个闭环打通后,树的运用才真正落地到工程场景。

6.3 关于创建时机的一个现实建议

最后聊点实际的。很多人学了建树之后觉得不知道什么时候用,后来在工作中才慢慢明白:树不是万能的,但它是最典型的非线性结构代表。你是不是真的需要建一棵树,取决于你的数据是否有“层级关系”,以及你是否需要频繁按层级查找、插入、删除。

我自己的经验是:数据量小、层级固定且不常变化,用数组或列表就够;数据量中等、需要频繁插入删除、层级动态变化,再考虑链式二叉树;数据量大且需要快速检索,优先考虑平衡树或B+树这类工程化树结构。建树的思路在这些场景中是共通的,学会了基础版,升级到工程版只是时间问题。

学二叉树没有捷径,但也不用焦虑。建好一棵树、打印出一行遍历输出,那种“原来如此”的感觉,会一下子把所有抽象概念串起来。后面你再碰到的树相关题目,本质上都是在“建好的树”上做文章——树的底子打牢了,往上走就容易了。

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

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

立即咨询