AIGC标识 二叉树 — 节点结构与四种遍历方式

二叉树 — 节点结构与四种遍历方式


一、二叉树是什么

二叉树是每个节点最多有两个子节点的树形结构,这两个子节点分别称为左子节点右子节点。它是很多更复杂树结构(二叉搜索树、堆、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。


六、关键要点

  1. 递归是树的天然表达方式:树的定义本身就是递归的(一棵树由根和子树组成),所以递归遍历代码简洁优雅。
  2. 三种深度遍历的区别只在访问顺序:把 fmt.Print 放在递归调用的前面、中间还是后面,就决定了是前序、中序还是后序。
  3. 层序遍历用队列而非栈:因为需要先进先出,保证同一层的节点按顺序处理。
  4. 非递归实现的核心是栈:手动维护一个栈来模拟函数调用栈的行为。
posted @ 2026-07-29 09:18  FfHUCisI  阅读(18)  评论(0)    收藏  举报