我读数据结构5-树

(叠甲:如有侵权请联系,内容都是自己学习的总结,一定不全面,仅当互相交流(轻点骂)我也只是站在巨人肩膀上的一个小卡拉米,已老实,求放过)

一、什么是树

树是一种一对多的非线性数据结构,由 n 个节点组成的有限集合。对比一下:

  • 数组、链表、队列、栈是「线性结构」:一个节点最多只有一个前驱、一个后继
  • 树是「树形结构」:一个节点可以有多个后继(子节点),但只有一个前驱(父节点)

严格递归定义

  1. 当 n = 0 时,称为空树
  2. 当 n > 0 时,有且仅有一个根节点 ,其余节点可以划分为 m 个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树

递归是树的灵魂:树的定义是递归的,树的遍历、计算高度、删除等操作也几乎都可以用递归实现。

我们用一棵具体的树作为示例,后面所有术语都对应它:

复制代码
        A       ← 第1层
      / | \
     B  C  D    ← 第2层
    / \
   E   F       ← 第3层

二、树的核心术语

这些术语是后续所有学习的基础,务必理解清楚:

  1. 根节点 :最顶层、没有父节点的节点,一棵树有且只有一个根。示例中是 A
  2. 父节点 / 子节点 :一个节点的直接前驱是父节点,直接后继是子节点。示例中 AB 的父节点,BA 的子节点。
  3. 兄弟节点 :拥有同一个父节点的节点互为兄弟。示例中 B、C、D 互为兄弟,E、F 互为兄弟。
  4. 叶子节点(终端节点) :没有子节点的节点(度为 0)。示例中 C、D、E、F 都是叶子。
  5. 节点的度 :一个节点拥有的子节点个数 。示例中 A 的度是 3,B 的度是 2,C 的度是 0。
  6. 树的度:树中所有节点的度的最大值。示例中树的度是 3。
  7. 层次 / 层:根节点为第 1 层,它的子节点为第 2 层,依次向下递增。
  8. 深度(节点) :从根节点到该节点的路径上的节点数。示例中 A 深度为 1,B 深度为 2,E 深度为 3。 注意:部分教材定义深度从 0 开始计数,核心逻辑一致,只是数值差 1,做题时看清题目约定即可。
  9. 高度(节点) :从该节点到最远叶子节点的路径上的节点数。示例中 B 的高度是 2,A 的高度是 3。 树的高度 = 根节点的高度 = 树的最大深度。
  10. 森林:m 棵互不相交的树的集合。把一棵树的根节点删掉,剩下的所有子树就构成一片森林。

三、树的核心数学性质

这些性质是计算题和空间复杂度分析的基础:

  1. 总边数 = 总节点数 - 1 除根节点外,每个节点都有且仅有一条边连向父节点,因此 n 个节点的树一定有 n-1 条边。

  2. 度为 m 的树,第 i 层最多有 m^(i-1) 个节点(i ≥ 1) 比如度为 3 的树,第 1 层 1 个,第 2 层最多 3 个,第 3 层最多 9 个,以此类推。

  3. 高度为 h 的 m 叉树,最多有 (m^h - 1)/(m-1) 个节点 这是等比数列求和:1 + m + m² + ... + m^(h-1) = (m^h - 1)/(m-1)。 当 m=2 时,就是满二叉树的总节点数公式 2^h - 1,和二叉树的性质完全对应。

四、树的三种存储结构

树是一对多的结构,存储方式主要有三种,各有优劣:

1. 双亲表示法(数组实现)

用一维数组存储所有节点,每个节点包含两部分:数据域 + 父节点下标,根节点的父下标记为 -1。

下标 数据 父下标
0 A -1
1 B 0
2 C 0
3 D 0
4 E 1
5 F 1
  • 优点:找父节点极快(O (1)),结构简单
  • 缺点:找子节点需要遍历整个数组(O (n))
2. 孩子表示法(数组 + 链表)

用数组存所有节点,每个节点附带一个链表,链表中存它所有子节点的下标。

  • 优点:找子节点非常方便
  • 缺点:找父节点麻烦,需要额外维护双亲信息
3. 孩子兄弟表示法(二叉树表示法)⭐ 最重要

这是把任意树统一转换成二叉树的核心方法,也是工业界最常用的存储方式。

每个节点只存两个指针:

  • firstchild:指向该节点的第一个孩子
  • nextsibling:指向该节点的下一个兄弟节点

对应到示例树:

  • A 的第一个孩子是 BB 的下一个兄弟是 CC 的下一个兄弟是 D

  • B 的第一个孩子是 EE 的下一个兄弟是 F

    typedef int DataType;
    struct Node
    {
    struct Node* _firstChild1;
    struct Node* _pNextBrother;
    DataType _data;
    };

核心口诀:左孩子,右兄弟。 任何一棵树,都可以通过孩子兄弟表示法转换成唯一的一棵二叉树。这也是为什么我们要重点学二叉树 ------ 只要掌握了二叉树,就能处理所有树的问题。

五、树的遍历方式

遍历就是按某种规则不重复地访问树中所有节点,树有三种经典遍历方式:

1. 先根遍历(先序遍历)

规则 :先访问根节点,再依次先根遍历每一棵子树。 示例树结果:A → B → E → F → C → D

2. 后根遍历(后序遍历)

规则 :先依次后根遍历每一棵子树,最后访问根节点。 示例树结果:E → F → B → C → D → A

3. 层序遍历(广度优先)

规则 :按层次从上到下,同一层从左到右依次访问节点。 示例树结果:A → B → C → D → E → F

一个重要对应关系: 树的先根遍历 = 对应二叉树的前序遍历 树的后根遍历 = 对应二叉树的中序遍历 这也是树转二叉树的意义:把复杂的多叉树问题,转化为我们熟悉的二叉树问题来解决。

六、树 → 二叉树的转换(手动推导方法)

用「左孩子右兄弟」规则,两步就能转:

  1. 连线:给每个节点和它的第一个孩子连左线,和它的右边第一个兄弟连右线
  2. 删线:删掉每个节点和除第一个孩子外其他孩子的连线

转换后你会发现:

  • 原树的根节点,转换后依然是二叉树的根
  • 原树中所有兄弟节点,转换后变成了右子树链条
  • 原树的第一个孩子,转换后变成了左孩子

一、二叉树的定义与核心特点

二叉树是 n 个节点的有限集合:

  1. 可以是空集(空二叉树);
  2. 非空时由一个根节点 + 两棵互不相交的左子树、右子树组成。

最关键的特点:二叉树的子节点有严格的左右之分,顺序不能颠倒。哪怕一个节点只有一个子节点,也要明确它是左孩子还是右孩子 ------ 这是二叉树和普通「度为 2 的树」最本质的区别。

基础术语和通用树完全一致,仅约束节点的度只能是 0、1、2:

  • 叶子节点:度为 0 的节点
  • 节点的度:该节点拥有的子节点个数
  • 深度 / 高度 / 层:定义与通用树完全统一

二、二叉树的核心数学性质

这些性质是笔试计算题、空间复杂度分析的基础,务必理解推导逻辑。

  1. 第 i 层最多有 2^(i-1) 个节点(i ≥ 1) 第 1 层 1 个(2⁰),第 2 层最多 2 个(2¹),第 3 层最多 4 个(2²),以此类推。

  2. 深度为 k 的二叉树,总节点数最多为 2^k - 1 对应满二叉树,本质是等比数列求和:1 + 2 + 4 + ... + 2^(k-1) = 2^k - 1

  3. 任意二叉树:叶子节点数 = 度为 2 的节点数 + 1 公式:n₀ = n₂ + 1 推导过程:

    • 设总节点数为 n,度为 0、1、2 的节点数分别为 n₀、n₁、n₂,则 n = n₀ + n₁ + n₂
    • 树的总边数 = n - 1(除根节点外,每个节点对应一条入边)
    • 总边数也等于所有节点出边之和:n - 1 = 0×n₀ + 1×n₁ + 2×n₂
    • 联立两式化简,最终得到 n₀ = n₂ + 1
  4. 完全二叉树的编号性质 若对完全二叉树按层从上到下、每层从左到右编号:

    • 根节点编号为 1 时:
      • 节点 i 的左孩子编号:2i
      • 节点 i 的右孩子编号:2i + 1
      • 节点 i 的父节点编号:i/2(向下取整)
    • 若数组下标从 0 开始(工程常用):
      • 节点 i 的左孩子下标:2i + 1
      • 节点 i 的右孩子下标:2i + 2
      • 节点 i 的父节点下标:(i - 1) / 2(向下取整)

三、四类高频特殊二叉树

1. 满二叉树

深度为 k,且每一层的节点数都达到最大值的二叉树。

  • 特点:没有度为 1 的节点,所有非叶子节点度都为 2;叶子全部在最底层
  • 总节点数一定是 2^k - 1,反过来也成立
2. 完全二叉树 ⭐

除最后一层外,其他所有层节点数都达到最大值;且最后一层的节点全部靠左连续排列

  • 满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树
  • 核心应用:堆结构(大顶堆、小顶堆)本质就是完全二叉树
  • 特点:n 个节点的完全二叉树,深度为 ⌊log₂n⌋ + 1
3. 二叉搜索树(BST)

一种有序二叉树,满足:

  • 左子树所有节点的值 < 根节点的值
  • 右子树所有节点的值 > 根节点的值
  • 左右子树也分别是二叉搜索树
  • 核心性质:中序遍历结果是严格升序序列
4. 平衡二叉搜索树(AVL / 红黑树)
  • AVL 树:约束任意节点左右子树的高度差(平衡因子)绝对值 ≤ 1,保证查找效率稳定在 O (logn)
  • 红黑树:通过节点颜色规则维持近似平衡,插入删除性能更优,广泛应用于语言标准库(如 C++ map、Java TreeMap)

四、二叉树的存储结构

1. 顺序存储(数组存储)

用一维数组按层序存储节点,通过下标计算父子关系,依赖完全二叉树的编号性质。

  • 优点:访问父子节点速度极快(O (1)),适合完全二叉树、堆结构
  • 缺点:普通二叉树会产生大量空位置,空间浪费严重;极端情况下退化成链表,空间利用率极低
2. 链式存储(二叉链表)⭐ 最常用

每个节点包含数据域 + 左孩子指针 + 右孩子指针,是二叉树最通用的存储方式。

C 语言节点定义:

复制代码
// 二叉树节点结构体
typedef struct TreeNode {
    int val;                // 节点存储的数据
    struct TreeNode *left;  // 左子树指针
    struct TreeNode *right; // 右子树指针
} TreeNode;
  • 优点:适配任意形态的二叉树,插入删除无需移动元素,空间利用率高
  • 缺点:无法随机访问,只能通过指针遍历查找

五、二叉树的四大核心遍历

遍历就是按固定规则不重复地访问所有节点,分为深度优先遍历(DFS) 和**广度优先遍历(BFS)**两大类。

我们统一用这棵二叉树作为示例:

复制代码
      1
     / \
    2   3
   / \
  4   5
深度优先遍历(前 / 中 / 后序)

三者的核心区别是根节点的访问时机不同,左子树永远优先于右子树访问。

1. 前序遍历(根 → 左 → 右)

先访问根节点,再遍历左子树,最后遍历右子树。

  • 示例结果:1, 2, 4, 5, 3

  • 递归实现(C 语言):

    void preOrder(TreeNode* root) {
    if (root == NULL) return; // 空树直接返回
    printf("%d ", root->val); // 1. 访问根节点
    preOrder(root->left); // 2. 递归遍历左子树
    preOrder(root->right); // 3. 递归遍历右子树
    }

2. 中序遍历(左 → 根 → 右)

先遍历左子树,再访问根节点,最后遍历右子树。

  • 示例结果:4, 2, 5, 1, 3

  • 递归实现:

    void inOrder(TreeNode* root) {
    if (root == NULL) return;
    inOrder(root->left); // 1. 递归遍历左子树
    printf("%d ", root->val); // 2. 访问根节点
    inOrder(root->right); // 3. 递归遍历右子树
    }

重要结论:二叉搜索树的中序遍历是升序序列,这是 BST 最核心的判定与应用依据。

3. 后序遍历(左 → 右 → 根)

先遍历左子树,再遍历右子树,最后访问根节点。

  • 示例结果:4, 5, 2, 3, 1

  • 递归实现:

    void postOrder(TreeNode* root) {
    if (root == NULL) return;
    postOrder(root->left); // 1. 递归遍历左子树
    postOrder(root->right); // 2. 递归遍历右子树
    printf("%d ", root->val); // 3. 访问根节点
    }

适用场景:需要先处理子节点、再处理根节点的操作,比如销毁整棵树、计算树的高度。

广度优先遍历:层序遍历

从上到下、每层从左到右的顺序访问节点。

  • 示例结果:1, 2, 3, 4, 5
  • 实现思路:借助队列。先将根节点入队;每次出队一个节点并访问,再将它的左、右孩子依次入队,直到队列为空。

六、基础操作示例:求二叉树的最大深度

用递归思路非常简洁:一棵树的最大深度 = 1 + max (左子树深度,右子树深度)

复制代码
int maxDepth(TreeNode* root) {
    if (root == NULL) return 0;     // 空树深度为0
    int leftDepth = maxDepth(root->left);
    int rightDepth = maxDepth(root->right);
    return 1 + (leftDepth > rightDepth ? leftDepth : rightDepth);
}

七、经典例题

第 1 题

题目:某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )

A. 不存在这样的二叉树

B. 200

C. 198

D. 199

考点:二叉树的核心性质:叶子节点数 = 度为 2 的节点数 + 1((n0 = n2 + 1))

解题过程:

  1. 根据二叉树性质,直接计算叶子节点数:

(n0 = n2 + 1 = 199 + 1 = 200)

  1. 验证总节点数是否合理:

总节点数 (n = n0 + n1 + n2),代入得 (399 = 200 + n1 + 199),解得 (n1=0)。

度为 1 的节点数为 0 是合法的(所有非叶子节点都有 2 个孩子,即正则二叉树),因此这样的二叉树存在。

答案:B


第 2 题

题目:下列数据结构中,不适合采用顺序存储结构的是( )

A. 非完全二叉树

B. 堆

C. 队列

D. 栈

考点:顺序存储结构的适用场景

解题过程:

  • 顺序存储的核心是用数组连续存储,通过下标对应位置关系。
  • 堆的本质是完全二叉树,天然适合数组存储,父子节点可通过下标直接计算。
  • 栈、队列都可以用数组实现顺序存储(顺序栈、循环队列)。
  • 非完全二叉树如果用顺序存储,需要按照完全二叉树的结构预留空位,大量不存在的节点会占用数组空间,造成严重的空间浪费,因此不适合顺序存储。

答案:A


第 3 题

题目:在具有 2n 个结点的完全二叉树中,叶子结点个数为( )

A. n

B. n+1

C. n-1

D. n/2

考点:完全二叉树的节点性质(度为 1 的节点数只能是 0 或 1)

解题过程:

  1. 二叉树通用关系:(n = n0 + n1 + n2),且 (n0 = n2 + 1)

联立得总节点数 (N = 2n0 - 1 + n1)

  1. 完全二叉树中,(n1)(度为 1 的节点数)只能取 0 或 1。

本题总节点数 2n 是偶数,代入公式:

(2n = 2n0 - 1 + n1)

左边为偶数,右边 (2n0) 是偶数,因此 (-1 + n1) 必须为偶数,即 (n1=1)。

  1. 代入得 (2n = 2n0 - 1 + 1),化简得 (n0 = n)。

答案:A


第 4 题

题目:一棵完全二叉树的节点数为 531 个,那么这棵树的高度为( )

A. 11

B. 10

C. 8

D. 12

考点:完全二叉树的高度计算(根节点为第 1 层)

解题过程:

高度为 h 的完全二叉树,节点数满足:

(2^{h-1} le N le 2^h - 1)

  • 当 (h=9) 时,最多节点数为 (2^9 - 1 = 511),小于 531,不符合。
  • 当 (h=10) 时,最多节点数为 (2^{10} - 1 = 1023),且前 9 层共 511 个节点,第 10 层有 (531-511=20) 个节点,符合完全二叉树定义。

也可以用公式直接计算:(h = lfloor log2 N rfloor + 1)

(log2 531 approx 9.05),向下取整为 9,加 1 得高度为 10。

答案:B


第 5 题

题目:一个具有 767 个节点的完全二叉树,其叶子节点个数为()

A. 383

B. 384

C. 385

D. 386

考点:完全二叉树叶子节点数计算

解题过程:

方法 1:通用公式推导

总节点数 (N = n0 + n1 + n2),结合 (n0 = n2 + 1),得 (N = 2n0 - 1 + n1)。

完全二叉树 (n1) 只能为 0 或 1,本题 N=767 是奇数,因此 (n1=0)。

代入得 (767 = 2n0 - 1),解得 (n0 = 384)。

方法 2:完全二叉树结论

  • 总节点数为奇数时,叶子节点数 (= (N+1)/2)
  • 总节点数为偶数时,叶子节点数 (= N/2)

本题 767 是奇数,((767+1)/2 = 384)

八、我们深入一下二叉树的存储结构

我们从底层原理、实现细节、空间特性、适用场景 四个维度,深度拆解二叉树的两种核心存储结构。存储结构的本质,是解决「如何用物理内存表达二叉树的逻辑父子关系」的问题,分为顺序存储(数组) 和**链式存储(指针)**两大体系。

一、顺序存储结构(数组实现)

顺序存储的核心思路是:用一维数组连续存储节点,通过「完全二叉树的层序编号规则」建立数组下标与节点位置的对应关系,无需指针就能通过下标计算找到父子节点。

1. 编号映射规则

对二叉树按「从上到下、每层从左到右」的顺序进行层序编号,编号直接对应数组下标。行业内有两种下标约定,核心逻辑完全一致:

约定类型 根节点编号 节点 i 的左孩子 节点 i 的右孩子 节点 i 的父节点 适用场景
教材版 1 2 * i 2 * i + 1 i / 2(向下取整) 数据结构考试、公式推导
工程版 0(数组首下标) 2 * i + 1 2 * i + 2 (i - 1) / 2(向下取整) 代码实现、堆排序、优先级队列

核心性质:只要知道一个节点的下标,就能以 O (1) 时间直接算出它的父节点、左右孩子位置,这是顺序存储最大的优势。

2. 不同二叉树的存储表现

顺序存储的空间利用率,高度依赖二叉树的「完全程度」:

(1)完全二叉树 / 满二叉树:最优适配

我们以这棵完全二叉树为例:

复制代码
      1
     / \
    2   3
   / \
  4   5

按根节点编号为 1 存储,数组内容如下(下标 0 闲置):

数组下标 0 1 2 3 4 5 6 7
节点值 1 2 3 4 5

总节点数 5 个,数组仅需预留到第 3 层的最大位置即可,没有实质性空间浪费。满二叉树更是 100% 空间利用率,这也是堆结构(完全二叉树)默认采用顺序存储的根本原因。

(2)普通非完全二叉树:空间浪费

如果二叉树形态不规则,必须按照「同高度满二叉树的大小」开辟数组空间,不存在的节点用空值占位,会产生大量空间浪费。

例如一棵深度为 3 的右斜树:

复制代码
1
 \
  2
   \
    3

它只有 3 个节点,但顺序存储必须占用深度为 3 的满二叉树对应大小的数组(共 7 个位置):

数组下标 0 1 2 3 4 5 6 7
节点值 1 2 3

空间利用率仅 3/7 ≈ 43%。如果是深度为 k 的斜树,只有 k 个节点,却需要 2^k - 1 个数组空间,空间复杂度从 O (k) 退化为 O (2^k),浪费极其严重。

3. 优缺点与适用场景
  • 优点
    1. 父子节点访问速度极快,均为 O (1)
    2. 无需存储指针,额外空间开销为 0
    3. 内存连续,CPU 缓存命中率高
  • 缺点
    1. 非完全二叉树空间浪费严重
    2. 插入、删除节点需要移动大量元素,时间复杂度 O (n)
    3. 数组大小固定,扩容成本高
  • 适用场景 :仅推荐用于完全二叉树,典型代表是堆、优先级队列、堆排序。

二、链式存储结构(指针实现)

链式存储是二叉树最通用、最主流的存储方式,核心思路是:每个节点独立存储,通过指针记录子节点(或父节点)的地址,节点间通过指针建立逻辑关系,物理内存可以不连续。

根据指针数量和作用的不同,链式存储又分为 3 种常见实现。

1. 二叉链表(最核心、最常用)
节点结构

每个节点包含 3 部分:数据域 + 左孩子指针 + 右孩子指针。 C 语言定义如下:

复制代码
typedef struct TreeNode {
    int data;                // 数据域,存储节点值
    struct TreeNode *left;   // 左指针,指向左子树根节点
    struct TreeNode *right;  // 右指针,指向右子树根节点
} TreeNode;
空指针性质(重要考点)

对于有 n 个节点的二叉链表,我们可以推导出空指针的数量:

  • 每个节点有 2 个指针,总指针数 = 2n
  • 二叉树总边数 = n - 1(除根节点外,每个节点对应一条入边)
  • 非空指针数 = 总边数 = n - 1
  • 因此空指针数 = 2n - (n-1) = n + 1

这个结论是「线索二叉树」的理论基础:利用这些空指针存放遍历的前驱 / 后继线索,可以提升遍历效率。

优缺点
  • 优点
    1. 适配任意形态的二叉树,无空间浪费
    2. 插入、删除节点仅需修改指针,时间复杂度 O (1)(找到目标位置后)
    3. 动态扩容灵活,不受固定大小限制
  • 缺点
    1. 访问父节点需要从根遍历,时间复杂度 O (n)
    2. 每个节点需要额外存储两个指针,有空间开销
    3. 节点内存不连续,CPU 缓存命中率低
2. 三叉链表(带父指针扩展)
节点结构

在二叉链表的基础上,增加一个父节点指针,方便快速回溯到父节点。

复制代码
typedef struct TriTreeNode {
    int data;                    // 数据域
    struct TriTreeNode *left;    // 左孩子指针
    struct TriTreeNode *right;   // 右孩子指针
    struct TriTreeNode *parent;  // 父节点指针
} TriTreeNode;
优缺点
  • 优势:查找父节点的时间复杂度从 O (n) 降为 O (1),在需要频繁向上回溯的场景(如红黑树旋转、最近公共祖先问题)中效率极高。
  • 代价:每个节点多一个指针的空间开销,插入删除时需要额外维护父指针。
3. 静态链表(数组模拟链式)

这是一种「物理顺序、逻辑链式」的折中方案,用数组存储节点,用数组下标代替指针记录子节点位置,-1 表示空节点。

节点结构:

复制代码
typedef struct {
    int data;   // 数据域
    int left;   // 左孩子的数组下标,-1表示空
    int right;  // 右孩子的数组下标,-1表示空
} StaticTreeNode;

StaticTreeNode tree[1000]; // 预分配固定大小的数组
特点
  • 没有指针的编程语言(如部分早期脚本语言)可用这种方式实现二叉树
  • 节点大小固定,预分配空间,避免动态内存分配的开销
  • 本质还是链式逻辑,只是用下标替代了内存地址
对比维度 顺序存储 二叉链表 三叉链表 静态链表
物理结构 连续数组 离散节点,指针连接 离散节点,指针连接 连续数组,下标模拟指针
空间利用率 完全二叉树高,普通二叉树低 高,按需分配 较高,比二叉链表多一个指针 中等,预分配固定空间
访问孩子 O(1) O(1) O(1) O(1)
访问父节点 O(1) O(n) O(1) O(n)
插入删除效率 O (n)(需移动元素) O (1)(找到位置后) O (1)(需维护父指针) O (1)(找到位置后)
适用场景 完全二叉树、堆 绝大多数普通二叉树 需频繁回溯父节点的场景(红黑树) 无指针语言、固定大小场景

四、选型总结

  1. 如果是完全二叉树(如堆、优先级队列):优先选顺序存储,空间利用率和访问速度都是最优。
  2. 如果是普通二叉树,频繁增删节点:优先选二叉链表,灵活且无空间浪费,是工业界的默认选择。
  3. 如果需要频繁查找父节点(如平衡树调整、回溯算法):选三叉链表,用少量空间换时间效率。
  4. 如果环境不支持动态内存 / 指针:用静态链表做折中实现。

九、堆

我们从底层结构、核心操作、建堆原理、堆排序、经典应用 五个维度,系统深入地讲解堆。堆的本质是满足堆序性质的完全二叉树,它完美复用了完全二叉树的顺序存储特性,是实现「优先级队列」「TopK 问题」的最优数据结构

一、堆的定义与核心特性

堆必须同时满足两个条件:

  1. 结构约束 :必须是一棵完全二叉树(除最后一层外其余层全满,最后一层节点靠左连续排列)
  2. 性质约束(堆序性):任意节点的值,与它的左右子节点满足固定的大小关系

根据堆序性的不同,堆分为两类:

  • 大顶堆(最大堆):任意父节点的值 ≥ 左右子节点的值 → 堆顶(根节点)是全局最大值
  • 小顶堆(最小堆):任意父节点的值 ≤ 左右子节点的值 → 堆顶(根节点)是全局最小值
关键误区:堆 ≠ 二叉搜索树

很多初学者会把堆和 BST 混淆,二者有本质区别:

特性 二叉搜索树(BST)
大小约束 仅约束父子节点的大小关系,左右兄弟无大小顺序 约束左子树 < 根 < 右子树,全树有序
遍历特性 中序遍历无序 中序遍历是严格升序序列
核心价值 O (1) 获取全局极值 O (logn) 查找任意值

堆的设计目标不是「有序存储所有元素」,而是「快速拿到极值」,这是理解堆的核心出发点。

二、堆的存储结构:数组实现

堆是完全二叉树,因此天然适配顺序存储(数组),不需要指针,通过下标计算就能直接定位父子节点,这是堆高效的底层基础。

下标映射规则(工程通用版:数组下标从 0 开始)

设当前节点下标为 i

  • 左孩子下标:2 * i + 1
  • 右孩子下标:2 * i + 2
  • 父节点下标:(i - 1) / 2(向下取整)
直观示例

我们以一个大顶堆为例,对应树结构和数组存储如下:

复制代码
树结构(大顶堆):
      10
     /  \
    7    9
   / \  /
  5  3  8

数组存储:下标 0 1 2 3 4 5
          值 10 7 9 5 3 8

验证下标关系:

  • 下标 0(10)的左孩子:2*0+1=1(7),右孩子:2*0+2=2(9)
  • 下标 3(5)的父节点:(3-1)/2=1(7)
  • 最后一个非叶子节点:最后一个元素下标是 5,父节点 (5-1)/2=2(9),也就是最后一个非叶子节点

验证下标关系:

  • 下标 0(10)的左孩子:2*0+1=1(7),右孩子:2*0+2=2(9)
  • 下标 3(5)的父节点:(3-1)/2=1(7)
  • 最后一个非叶子节点:最后一个元素下标是 5,父节点 (5-1)/2=2(9),也就是最后一个非叶子节点

三、堆的两大灵魂操作:上浮与下沉

堆的所有操作(插入、删除、建堆)都基于两个基础调整动作:上浮(Sift Up)下沉(Sift Down)。它们的作用是:当堆的局部结构被破坏时,以最小成本重新恢复堆序性。

1. 下沉调整(Sift Down)

适用场景 :删除堆顶、原地建堆 核心逻辑:将一个不满足堆序的节点,逐层向下交换,直到它落到正确的位置,满足堆序性。

大顶堆下沉步骤

  1. 找到当前节点的左、右孩子,选出值更大的那个孩子节点
  2. 如果当前节点的值 < 这个大孩子的值,交换两者位置
  3. 将当前节点下移到孩子的位置,重复上述步骤
  4. 终止条件:当前节点 ≥ 两个孩子,或者已经到达叶子节点

时间复杂度:O (logn),节点最多从根节点走到叶子节点,移动距离等于树的高度。

C 语言实现(大顶堆下沉)

复制代码
// 下沉调整:对下标为i的节点做下沉,n是堆的有效元素个数
void siftDown(int heap[], int n, int i) {
    while (1) {
        int left = 2 * i + 1;   // 左孩子下标
        int right = 2 * i + 2;  // 右孩子下标
        int largest = i;        // 记录父、左、右中最大值的下标

        // 找左右孩子中更大的那个
        if (left < n && heap[left] > heap[largest]) {
            largest = left;
        }
        if (right < n && heap[right] > heap[largest]) {
            largest = right;
        }

        // 父节点已经是最大的,堆序恢复,退出
        if (largest == i) break;

        // 交换父节点和更大的孩子
        int temp = heap[i];
        heap[i] = heap[largest];
        heap[largest] = temp;

        // 继续向下检查
        i = largest;
    }
}

小顶堆的下沉逻辑完全一致,只需把比较符号从 > 改成 <,找更小的孩子即可。

2. 上浮调整(Sift Up)

适用场景 :插入新元素 核心逻辑:将新插入的节点,逐层向上交换,直到它满足堆序性。

大顶堆上浮步骤

  1. 找到当前节点的父节点
  2. 如果当前节点的值 > 父节点的值,交换两者位置
  3. 将当前节点上移到父节点的位置,重复上述步骤
  4. 终止条件:当前节点 ≤ 父节点,或者已经到达根节点

时间复杂度:O(logn)

四、堆的构建:原地建堆(O (n) 复杂度)

建堆就是把一个无序数组,调整成满足堆序性的结构。有两种建堆思路,标准工业实现都是原地下沉建堆

方法 1:逐元素插入法(上浮建堆)
  • 思路:从空堆开始,把元素逐个追加到数组末尾,每加一个就做一次上浮
  • 时间复杂度:O (nlogn),每个元素最多上浮 logn 层
  • 缺点:效率低,仅适合动态插入的场景,不适合批量建堆
方法 2:原地下沉建堆 ⭐ 标准实现

核心思路

  1. 先把所有元素按原顺序放进数组,先满足「完全二叉树」的结构要求
  2. 最后一个非叶子节点开始,从后往前(从下往上)对每个节点执行下沉调整
  3. 为什么从下往上?因为下沉操作的前提是「左右子树已经是合法堆」,从底层往上调,能保证调整父节点时,子树已经满足堆序

最后一个非叶子节点的下标(n - 1) / 2(最后一个元素的父节点)

时间复杂度:O (n) 这是面试高频考点,简单推导:叶子节点不需要调整;倒数第二层节点最多下沉 1 层;倒数第三层最多下沉 2 层...... 根节点最多下沉 h-1 层。总操作数是等比数列求和,最终收敛到 O (n),比逐元素插入更高效。

C 语言实现(建大顶堆)

复制代码
// 原地建大顶堆
void buildMaxHeap(int arr[], int n) {
    // 从最后一个非叶子节点开始,从后往前下沉
    for (int i = (n - 1) / 2; i >= 0; i--) {
        siftDown(arr, n, i);
    }
}

五、堆的三大核心操作

1. 获取堆顶元素

直接返回数组首元素,时间复杂度 O(1)

复制代码
int getTop(int heap[]) {
    return heap[0];
}
2. 插入元素

步骤

  1. 把新元素追加到数组的末尾(保持完全二叉树结构)
  2. 对这个新元素执行上浮调整,恢复堆序性

时间复杂度:O(logn)

3. 删除堆顶元素(弹出极值)

为什么不能直接删除数组第一个元素? 直接删除会导致数组中间出现空位,破坏完全二叉树结构,后续所有元素都要前移,时间复杂度退化为 O (n)。

标准删除步骤

  1. 保存堆顶元素(作为返回值)
  2. 把数组最后一个元素移动到堆顶位置(下标 0),堆的有效长度减 1
  3. 对新的堆顶元素执行下沉调整,恢复堆序性

时间复杂度:O(logn)

C 语言实现(删除大顶堆堆顶)

复制代码
int popMax(int heap[], int *size) {
    int maxVal = heap[0];
    // 最后一个元素移到堆顶
    heap[0] = heap[*size - 1];
    (*size)--;
    // 堆顶下沉
    siftDown(heap, *size, 0);
    return maxVal;
}

六、堆排序(Heap Sort)

堆排序是堆的经典应用,是一种原地、不稳定的选择排序,时间复杂度稳定为 O (nlogn)。

核心思路(升序排序用大顶堆)
  1. 先对数组原地建大顶堆,此时堆顶是全局最大值
  2. 把堆顶元素和当前堆的末尾元素交换(最大值就放到了数组末尾,已排序)
  3. 堆的有效长度减 1,对新的堆顶执行下沉调整,恢复堆序
  4. 重复步骤 2-3,直到堆的有效长度为 1,数组全部有序
为什么升序用大顶堆?

每次把最大值放到堆的末尾,相当于从后往前填充有序序列,最终数组自然升序。如果要降序排序,改用小顶堆即可。

C 语言完整实现
复制代码
// 堆排序:升序
void heapSort(int arr[], int n) {
    // 1. 原地建大顶堆
    buildMaxHeap(arr, n);

    // 2. 不断交换堆顶和末尾,调整堆
    for (int i = n - 1; i > 0; i--) {
        // 堆顶(最大值)和当前末尾交换
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;

        // 对堆顶在[0, i-1]范围内下沉
        siftDown(arr, i, 0);
    }
}
堆排序特性总结
  • 时间复杂度:O (nlogn),建堆 O (n) + n 次下沉 O (nlogn)
  • 空间复杂度:O (1),纯原地排序,不需要额外空间
  • 稳定性:不稳定排序(堆顶和末尾交换可能打乱相等元素的相对顺序)
  • 适用场景:适合海量数据的 TopK 问题,不需要全量排序

七、堆的经典应用场景

1. 优先级队列(Priority Queue)

堆是优先级队列的标准底层实现。操作系统的任务调度、消息队列的优先级消费、定时器任务管理,本质都是用堆来实现「每次取出优先级最高的任务」。

2. TopK 问题(面试必考)

问题 :从 100 万个数据中找出前 100 大的数,内存有限无法全量加载。 最优解法 :维护一个大小为 K 的小顶堆

  • 遍历数据:如果当前数 > 堆顶(当前 K 个里最小的),就替换堆顶并下沉调整
  • 遍历结束后,堆里的 K 个数就是全局前 K 大的数
  • 为什么用小顶堆不用大顶堆?小顶堆的堆顶是 K 个里的最小值,可以快速判断新元素是否有资格进入 TopK,淘汰最小值

时间复杂度:O (nlogK),空间复杂度:O (K),完美适配海量数据场景。

3. 数据流中位数

维护两个堆:

  • 大顶堆:存储前半部分较小的数,堆顶是前半部分最大值
  • 小顶堆:存储后半部分较大的数,堆顶是后半部分最小值 保证两个堆的大小差不超过 1,中位数就是两个堆顶之一(或平均值)。
4. 多路归并排序

多个有序数组合并时,用小顶堆记录每个数组的当前头部,每次 O (1) 取出全局最小值,再将该数组的下一个元素入堆调整。

相关推荐
code bean3 小时前
【C#】 `Channel<T>` 深度解析:生产者-消费者模式的现代解法
数据结构·c#
storyseek4 小时前
前缀和实现Kogge-Stone算法
数据结构·算法
元Y亨H5 小时前
深度解构:数据结构与算法的理论基石与工程演进
数据结构·算法
元Y亨H5 小时前
数据结构与算法的通俗指南
数据结构·算法
2301_764441335 小时前
用动力学系统(微分方程)为 Kernberg 的客体关系单元提供数学化的操作定义,把“自体—客体“这对心理结构建模成一个二维耦合系统
数据结构·python·算法·数学建模
冻柠檬飞冰走茶10 小时前
PTA基础编程题目集 7-7 12-24小时制(C语言实现)
c语言·开发语言·数据结构·算法
雨落在了我的手上10 小时前
Java数据结构(八):双链表的实现
数据结构
paeamecium10 小时前
【PAT甲级真题】- Kuchiguse (20)
数据结构·c++·python·算法·pat考试·pat
青山木10 小时前
Hot 100 --- 全排列
java·数据结构·算法·leetcode·深度优先