跳转至

树通常用来存储和组织具有层级结构的数据,比如磁盘驱动器上的文件系统,还常用来组织数据,让集合能够快速查找、插入和删除,以及作为网络路由算法等等。

20241019143125

树是从上至下由节点(node)和边(edge)组成的,树的节点包含一些数据,可以是任何类型

如果可以从节点 A 到节点 B,则 A 被称作 B 的祖先(ancestor),B 是 A 的子孙(descendent)

如图,1、2、5 都可以称作 10 的祖先,4、9 的共同祖先是 1、2

没有祖先的顶部节点,称做「根节点 root」,至少有一个子节点被称为「内部节点」,没有子孙节点则被称作「叶子节点 leaf」

1 是 2、3 的「父节点 parent」,2、3 是 1 的「子节点 children」,2 是 9、10 的祖父节点

同一父节点下的子节点之间互为「兄弟节点 sibling」,比如 4、5、6,而 6、7 拥有相同的祖父节点,只能称作堂兄节点,3 可以叫做 6 的叔节点

树的属性

20241019150342

不难发现,树是一种递归结构,在实现和使用树的过程中,递归属性随处可见

树由根节点和子树递归组成,根节点包含指向所有子树根节点的链接

除了根节点,所有节点都只有一个指向它的边,因此,在一个有效的树中,如果有 N 个节点,就会有 \(N - 1\) 条边

20240401155058

度:子节点的数量

深度:根节点到目标节点的路径上包含的边数,下图节点 5 的深度为 2

高度:从目标节点到任意叶子节点的最长路径的边数,所以下图

  • 根节点的高度为 3
  • 节点 3 的高度为 2
  • 叶子节点的高度是 0

根节点的高度也被称为「树的高度」,即从根节点到叶子节点的最长路径的边数

所以只有一个节点的树的高度是 0,而没有节点的(被称为空树)高度是 -1

另一种关于高度的非常规定义是指节点的数量,即只有根节点的高度是 1,空树的高度是 0

二叉树

只要节点的子树不超过两个,都是二叉树(Binary Tree)

比如:有两颗子树;只有左子树或右子树;没有子树(只有根节点)

20240401154039

  • 完满二叉树(严格二叉树):每个节点都有 2 或 0 个子节点

20240401161814

  • 完全二叉树:除最底层外,其它层节点都填满(节点填满,而不是说要有子节点),且最底层节点都靠左对齐

20240401161807

注意,这不是一个完全二叉树,需要给 5 加一个左子节点才算连续

        1
       / \
      2   3
     / \  / \
    4  5 6   7
   / \
  8  9
  • 完美二叉树(满二叉树):所有层节点都填满
    • 每层节点数为:\(2^i\)(i 表示层数)
    • 节点总数为 \(2^{h+1} - 1\)(h 表示树的高度)
    • 节点数为 n 的完美二叉树的高度为 \(log_2(n+1) - 1\)

20240401161759

时间复杂度

二叉树不同操作(搜索、插入、删除)的复杂度,与树的高度成正比

二叉树的最佳结构,就是完美二叉树,最差结构,是所有节点都偏向一侧,即退化为了链表

20240401162101

也就是说,节点数为 n 时,二叉树的高度 h 范围为:\(log_2(n+1) - 1\) ~ \(n-1\)

即时间复杂度范围 \(O(h)\) 为:\(O(log_2n)\) ~ \(O(n)\),假设 n 为 \(2^{100}\),则范围为:\(100\)\(2^{100}\)

所以我们要尽可能降低树的高度,换言之,就是让二叉树尽量保持平衡

所谓的平衡二叉树,是指任意节点左右子树的高度差不超过 1,即 \(|d|<1\)(-1、0、1)

20240401161820

如上图,第二层左边节点,左子树只有一个节点,所以高度为 0,右子树为空,所以高度为 -1,则高度差 \(diff = |0 - (-1)| = 1\)

遍历

遍历(Traversal)方式是通过指针逐个访问节点,但由于非线性,遍历树要更复杂,需要借助搜索算法。

  • 广度优先遍历(Breadth-First, BFS):层序遍历(Level-order)

20240402101709

  • 深度优先遍历(Depth-First, DFS):根据 root 节点(存储的是数据 Data)被访问的顺序可以分为
    • 前序遍历(Pre-order):root -> left -> right,即 DLR
    • 中序遍历(In-order):left -> root -> right,即 LDR
    • 后序遍历(Post-order):left -> right -> root,即 LRD

20240402101720

20241122215657

如图所示的二叉搜索树

层序遍历:F, D, J, B, E, G, K, A, C, I, H
前序遍历:F, D, B, A, C, E, J, G, I, H, K
中序遍历:A, B, C, D, E, F, G, H, I, J, K
后序遍历:A, C, B, E, D, H, I, G, K, J, F

二叉搜索树,使用中序遍历,会得到一个从小到大的有序列表

底层存储

从物理结构来看,树是一种基于链表的数据结构,可以动态创建节点,使用指针或引用将它们链接起来

与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现。

删除节点可以有两种策略,一种是只删除节点(将其父节点与其子树链接),另一种是删除节点及其子树(父节点引用置为空)。

20241122211635

class TreeNode:
    """二叉树节点类"""
    def __init__(self, val: int):
        self.val: int = val                # 存储节点的值
        self.left: TreeNode | None = None  # 存储左子树根节点的地址
        self.right: TreeNode | None = None # 存储右子树根节点的地址

# 初始化二叉树
n1 = TreeNode(val=1)
n2 = TreeNode(val=2)
n3 = TreeNode(val=3)
n4 = TreeNode(val=4)
n5 = TreeNode(val=5)

# 构建节点之间的引用(指针)
n1.left = n2
n1.right = n3

n2.left = n4
n2.right = n5

在一些特殊情况下(比如完全二叉树),也可以用数组表示二叉树,将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。

20241026123825

对于完全二叉树来说,节点索引为 \(i\),则左子树的索引为:\(2i + 1\),右子树的索引为:\(2i + 2\),父节点索引为:\((i-1)/2\)

堆(Heap)是一种满足特定条件的「完全二叉树」

20240401170357

所以通常采用数组来存储堆,根节点称为堆顶,将底层最靠右的节点称为堆底

20240403152558

二叉搜索树

二叉搜索树(Binary Search Tree, BST),也叫二叉查找树,指的是二叉树中任意节点 左子树各个节点的值 < 根节点值 < 右子树各个节点的值

通常用来组织数据以进行快速搜索和更新,比如系统中的多级索引

20240402103911

对一个集合,进行搜索和更新操作,使用 BST 存储是最高效的(前提是保证平衡)

20241122163347

20240402104206

BST 的搜索类似于二分查找法,先与根节点的指比较,小于根节点,就继续与左子树的根节点继续比较,大于就与右子树根节点比较,直到等于。

BST 的插入,不需要像数组那样移动节点,只需要引用到左子树或右子树中,是一个常数,因此和搜索的复杂度一样

20241122165858

BST 在每次插入或删除节点之后,可能会变得不平衡,所以需要通过算法自动调整(旋转、分裂、合并),以保持树的平衡,从而确保树的高度保持在平衡因子范围内(-1、0、1),这就叫「自平衡」

自平衡二叉搜索树:

  • AVL 树,自平衡二叉搜索树,且任意节点的两个子树的高度差 < 1
  • 红黑树,自平衡二叉搜索树,且从根到叶子的最长路径 / 最短路径的比值 ≤ 2

多路搜索树

  • B 树(B-Tree):自平衡多路搜索树,所有的叶子节点都在同一层,主要目的就是减少磁盘的 I/O 操作
  • B+ 树(B+ Tree)
  • Trie 树(字典树、前缀树)用来存储字典,非常快速和高效,用于动态拼写检查