二叉树 — 节点结构与四种遍历方式
二叉树 — 节点结构与四种遍历方式
一、二叉树是什么
二叉树是每个节点最多有两个子节点的树形结构,这两个子节点分别称为左子节点和右子节点。它是很多更复杂树结构(二叉搜索树、堆、AVL 树、红黑树)的基石。
理解二叉树不需要太高深的数学知识。你可以把它想象成一个家族树,每一代最多生两个孩子,并且要分清左右——左边的是"左孩子",右边的是"右孩子"。哪怕只有一个孩子,也要明确它是左孩子还是右孩子,这在有些算法里至关重要。
基本术语
| 术语 | 含义 |
|---|---|
| 根节点(Root) | 树的最顶层节点,没有父节点 |
| 父节点(Parent) | 有子节点的节点 |
| 子节点(Child) | 被某个节点指向的节点 |
| 叶子节点(Leaf) | 没有子节点的节点 |
| 深度(Depth) | 从根到该节点的边数 |
| 高度(Height) | 从该节点到最远叶子的边数 |
| 子树(Subtree) | 以某个节点为根的那部分树 |
满二叉树 vs 完全二叉树
- 满二叉树:每一层的节点数都达到最大值。第 k 层有 2ᵏ 个节点。
- 完全二叉树:除了最后一层,其他层都是满的,且最后一层的节点都靠左排列。二叉堆就是典型的完全二叉树。
二、用 Go 定义二叉树节点
二叉树节点的结构非常简单:一个数据域加两个指针,分别指向左孩子和右孩子。Go 里用结构体来表示再自然不过:
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
为什么用指针?因为树的节点是动态创建的,节点之间的关系靠指针来维系。如果不用指针,递归定义会导致编译器无法确定结构体的大小——想想看,一个 TreeNode 里面包含两个 TreeNode,那两个 TreeNode 又各自包含两个 TreeNode,无限套娃下去,内存永远分配不完。
使用泛型可以让节点存储任意类型:
type TreeNode[T any] struct {
Val T
Left *TreeNode[T]
Right *TreeNode[T]
}
三、四种遍历方式
遍历二叉树有四种经典方式,它们决定了"访问节点"和"处理子树"的先后顺序。记住一个简单口诀:
- 前序:先看自己,再看左边,最后看右边(根 → 左 → 右)
- 中序:先看左边,再看自己,最后看右边(左 → 根 → 右)
- 后序:先看左边,再看右边,最后看自己(左 → 右 → 根)
- 层序:一层一层从左到右看
3.1 前序遍历(Pre-order)
前序遍历先访问根节点,再递归访问左子树,最后递归访问右子树。
应用场景:序列化一棵树(把树结构存成字符串或文件)、复制一棵树。
递归实现:
func PreOrder(root *TreeNode) {
if root == nil {
return
}
fmt.Print(root.Val, " ") // 先处理根
PreOrder(root.Left) // 再处理左子树
PreOrder(root.Right) // 最后处理右子树
}
非递归实现(用栈模拟递归过程):
递归的本质是函数调用栈,我们可以显式地用一个栈来模拟。思路是:先把根压栈,每次弹出栈顶并访问,然后先压右孩子再压左孩子——因为栈是后进先出,这样左孩子会先被弹出。
func PreOrderIterative(root *TreeNode) []int {
if root == nil {
return nil
}
var result []int
stack := []*TreeNode{root}
for len(stack) > 0 {
// 弹出栈顶
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
result = append(result, node.Val)
// 先右后左入栈,保证左先出
if node.Right != nil {
stack = append(stack, node.Right)
}
if node.Left != nil {
stack = append(stack, node.Left)
}
}
return result
}
3.2 中序遍历(In-order)
中序遍历先递归访问左子树,再访问根节点,最后递归访问右子树。
应用场景:对二叉搜索树进行中序遍历,得到的是一个升序排列的序列。这是 BST 最重要的性质之一。
递归实现:
func InOrder(root *TreeNode) {
if root == nil {
return
}
InOrder(root.Left) // 先处理左子树
fmt.Print(root.Val, " ") // 再处理根
InOrder(root.Right) // 最后处理右子树
}
非递归实现:
中序遍历的非递归稍微复杂一些。核心思路是:一路向左走到底,边走边压栈,走到头后弹出一个节点访问,然后转向该节点的右子树,重复这个过程。
func InOrderIterative(root *TreeNode) []int {
var result []int
var stack []*TreeNode
curr := root
for curr != nil || len(stack) > 0 {
// 一路向左走到底,沿途压栈
for curr != nil {
stack = append(stack, curr)
curr = curr.Left
}
// 弹出栈顶访问
curr = stack[len(stack)-1]
stack = stack[:len(stack)-1]
result = append(result, curr.Val)
// 转向右子树
curr = curr.Right
}
return result
}
3.3 后序遍历(Post-order)
后序遍历先递归访问左子树,再递归访问右子树,最后访问根节点。
应用场景:删除一棵树(必须先删子节点再删父节点)、计算目录大小(先算子目录大小再汇总)。
递归实现:
func PostOrder(root *TreeNode) {
if root == nil {
return
}
PostOrder(root.Left) // 先处理左子树
PostOrder(root.Right) // 再处理右子树
fmt.Print(root.Val, " ") // 最后处理根
}
非递归实现:
后序遍历的非递归最复杂。一个巧妙的思路是:前序遍历的顺序是"根→左→右",如果我们改成"根→右→左"(先压左再压右),得到的结果刚好是后序遍历的逆序。把结果反转一下就行了。
func PostOrderIterative(root *TreeNode) []int {
if root == nil {
return nil
}
var result []int
stack := []*TreeNode{root}
for len(stack) > 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
// 头部插入(相当于最终结果的反向)
result = append([]int{node.Val}, result...)
// 先左后右入栈,配合头部插入实现后序
if node.Left != nil {
stack = append(stack, node.Left)
}
if node.Right != nil {
stack = append(stack, node.Right)
}
}
return result
}
3.4 层序遍历(Level-order)
层序遍历从上到下、从左到右逐层访问节点。这是唯一不使用递归(或者说递归不自然)的遍历方式,它天然依赖队列。
应用场景:求树的宽度、判断完全二叉树、BFS(广度优先搜索)。
实现:
func LevelOrder(root *TreeNode) [][]int {
if root == nil {
return nil
}
var result [][]int
queue := []*TreeNode{root}
for len(queue) > 0 {
levelSize := len(queue)
var currentLevel []int
for i := 0; i < levelSize; i++ {
node := queue[0]
queue = queue[1:]
currentLevel = append(currentLevel, node.Val)
if node.Left != nil {
queue = append(queue, node.Left)
}
if node.Right != nil {
queue = append(queue, node.Right)
}
}
result = append(result, currentLevel)
}
return result
}
四、四种遍历对比
假设有这样一棵树:
1
/ \
2 3
/ \ / \
4 5 6 7
| 遍历方式 | 结果 | 口诀 |
|---|---|---|
| 前序 | 1 2 4 5 3 6 7 | 根 → 左 → 右 |
| 中序 | 4 2 5 1 6 3 7 | 左 → 根 → 右 |
| 后序 | 4 5 2 6 7 3 1 | 左 → 右 → 根 |
| 层序 | [[1], [2,3], [4,5,6,7]] | 逐层从左到右 |
一个记忆技巧:前、中、后说的都是根节点的位置——根在前面处理就是前序,根在中间处理就是中序,根在后面处理就是后序。左右子树的相对顺序永远是先左后右。
五、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 递归遍历 | O(n) | O(h),h 为树高,最坏 O(n) |
| 非递归遍历 | O(n) | O(h)(栈空间) |
| 层序遍历 | O(n) | O(w),w 为树的最大宽度 |
其中 n 是节点总数,h 是树的高度。递归版本的空间复杂度来自函数调用栈的深度,一棵退化成链表的树(每个节点只有左孩子或只有右孩子),递归深度就是 n。
六、关键要点
- 递归是树的天然表达方式:树的定义本身就是递归的(一棵树由根和子树组成),所以递归遍历代码简洁优雅。
- 三种深度遍历的区别只在访问顺序:把
fmt.Print放在递归调用的前面、中间还是后面,就决定了是前序、中序还是后序。 - 层序遍历用队列而非栈:因为需要先进先出,保证同一层的节点按顺序处理。
- 非递归实现的核心是栈:手动维护一个栈来模拟函数调用栈的行为。

浙公网安备 33010602011771号