(叠甲:如有侵权请联系,内容都是自己学习的总结,一定不全面,仅当互相交流(轻点骂)我也只是站在巨人肩膀上的一个小卡拉米,已老实,求放过)
一、什么是树
树是一种一对多的非线性数据结构,由 n 个节点组成的有限集合。对比一下:
- 数组、链表、队列、栈是「线性结构」:一个节点最多只有一个前驱、一个后继
- 树是「树形结构」:一个节点可以有多个后继(子节点),但只有一个前驱(父节点)
严格递归定义:
- 当 n = 0 时,称为空树;
- 当 n > 0 时,有且仅有一个根节点 ,其余节点可以划分为 m 个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树。
递归是树的灵魂:树的定义是递归的,树的遍历、计算高度、删除等操作也几乎都可以用递归实现。
我们用一棵具体的树作为示例,后面所有术语都对应它:
A ← 第1层
/ | \
B C D ← 第2层
/ \
E F ← 第3层
二、树的核心术语
这些术语是后续所有学习的基础,务必理解清楚:
- 根节点 :最顶层、没有父节点的节点,一棵树有且只有一个根。示例中是
A。 - 父节点 / 子节点 :一个节点的直接前驱是父节点,直接后继是子节点。示例中
A是B的父节点,B是A的子节点。 - 兄弟节点 :拥有同一个父节点的节点互为兄弟。示例中
B、C、D互为兄弟,E、F互为兄弟。 - 叶子节点(终端节点) :没有子节点的节点(度为 0)。示例中
C、D、E、F都是叶子。 - 节点的度 :一个节点拥有的子节点个数 。示例中
A的度是 3,B的度是 2,C的度是 0。 - 树的度:树中所有节点的度的最大值。示例中树的度是 3。
- 层次 / 层:根节点为第 1 层,它的子节点为第 2 层,依次向下递增。
- 深度(节点) :从根节点到该节点的路径上的节点数。示例中
A深度为 1,B深度为 2,E深度为 3。 注意:部分教材定义深度从 0 开始计数,核心逻辑一致,只是数值差 1,做题时看清题目约定即可。 - 高度(节点) :从该节点到最远叶子节点的路径上的节点数。示例中
B的高度是 2,A的高度是 3。 树的高度 = 根节点的高度 = 树的最大深度。 - 森林:m 棵互不相交的树的集合。把一棵树的根节点删掉,剩下的所有子树就构成一片森林。
三、树的核心数学性质
这些性质是计算题和空间复杂度分析的基础:
-
总边数 = 总节点数 - 1 除根节点外,每个节点都有且仅有一条边连向父节点,因此 n 个节点的树一定有 n-1 条边。
-
度为 m 的树,第 i 层最多有 m^(i-1) 个节点(i ≥ 1) 比如度为 3 的树,第 1 层 1 个,第 2 层最多 3 个,第 3 层最多 9 个,以此类推。
-
高度为 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的第一个孩子是B,B的下一个兄弟是C,C的下一个兄弟是D -
B的第一个孩子是E,E的下一个兄弟是Ftypedef 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
一个重要对应关系: 树的先根遍历 = 对应二叉树的前序遍历 树的后根遍历 = 对应二叉树的中序遍历 这也是树转二叉树的意义:把复杂的多叉树问题,转化为我们熟悉的二叉树问题来解决。
六、树 → 二叉树的转换(手动推导方法)
用「左孩子右兄弟」规则,两步就能转:
- 连线:给每个节点和它的第一个孩子连左线,和它的右边第一个兄弟连右线
- 删线:删掉每个节点和除第一个孩子外其他孩子的连线
转换后你会发现:
- 原树的根节点,转换后依然是二叉树的根
- 原树中所有兄弟节点,转换后变成了右子树链条
- 原树的第一个孩子,转换后变成了左孩子
一、二叉树的定义与核心特点
二叉树是 n 个节点的有限集合:
- 可以是空集(空二叉树);
- 非空时由一个根节点 + 两棵互不相交的左子树、右子树组成。
最关键的特点:二叉树的子节点有严格的左右之分,顺序不能颠倒。哪怕一个节点只有一个子节点,也要明确它是左孩子还是右孩子 ------ 这是二叉树和普通「度为 2 的树」最本质的区别。
基础术语和通用树完全一致,仅约束节点的度只能是 0、1、2:
- 叶子节点:度为 0 的节点
- 节点的度:该节点拥有的子节点个数
- 深度 / 高度 / 层:定义与通用树完全统一
二、二叉树的核心数学性质
这些性质是笔试计算题、空间复杂度分析的基础,务必理解推导逻辑。
-
第 i 层最多有
2^(i-1)个节点(i ≥ 1) 第 1 层 1 个(2⁰),第 2 层最多 2 个(2¹),第 3 层最多 4 个(2²),以此类推。 -
深度为 k 的二叉树,总节点数最多为
2^k - 1对应满二叉树,本质是等比数列求和:1 + 2 + 4 + ... + 2^(k-1) = 2^k - 1。 -
任意二叉树:叶子节点数 = 度为 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
- 设总节点数为 n,度为 0、1、2 的节点数分别为 n₀、n₁、n₂,则
-
完全二叉树的编号性质 若对完全二叉树按层从上到下、每层从左到右编号:
- 根节点编号为 1 时:
- 节点 i 的左孩子编号:
2i - 节点 i 的右孩子编号:
2i + 1 - 节点 i 的父节点编号:
i/2(向下取整)
- 节点 i 的左孩子编号:
- 若数组下标从 0 开始(工程常用):
- 节点 i 的左孩子下标:
2i + 1 - 节点 i 的右孩子下标:
2i + 2 - 节点 i 的父节点下标:
(i - 1) / 2(向下取整)
- 节点 i 的左孩子下标:
- 根节点编号为 1 时:
三、四类高频特殊二叉树
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))
解题过程:
- 根据二叉树性质,直接计算叶子节点数:
(n0 = n2 + 1 = 199 + 1 = 200)
- 验证总节点数是否合理:
总节点数 (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)
解题过程:
- 二叉树通用关系:(n = n0 + n1 + n2),且 (n0 = n2 + 1)
联立得总节点数 (N = 2n0 - 1 + n1)
- 完全二叉树中,(n1)(度为 1 的节点数)只能取 0 或 1。
本题总节点数 2n 是偶数,代入公式:
(2n = 2n0 - 1 + n1)
左边为偶数,右边 (2n0) 是偶数,因此 (-1 + n1) 必须为偶数,即 (n1=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. 优缺点与适用场景
- 优点 :
- 父子节点访问速度极快,均为 O (1)
- 无需存储指针,额外空间开销为 0
- 内存连续,CPU 缓存命中率高
- 缺点 :
- 非完全二叉树空间浪费严重
- 插入、删除节点需要移动大量元素,时间复杂度 O (n)
- 数组大小固定,扩容成本高
- 适用场景 :仅推荐用于完全二叉树,典型代表是堆、优先级队列、堆排序。
二、链式存储结构(指针实现)
链式存储是二叉树最通用、最主流的存储方式,核心思路是:每个节点独立存储,通过指针记录子节点(或父节点)的地址,节点间通过指针建立逻辑关系,物理内存可以不连续。
根据指针数量和作用的不同,链式存储又分为 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
这个结论是「线索二叉树」的理论基础:利用这些空指针存放遍历的前驱 / 后继线索,可以提升遍历效率。
优缺点
- 优点 :
- 适配任意形态的二叉树,无空间浪费
- 插入、删除节点仅需修改指针,时间复杂度 O (1)(找到目标位置后)
- 动态扩容灵活,不受固定大小限制
- 缺点 :
- 访问父节点需要从根遍历,时间复杂度 O (n)
- 每个节点需要额外存储两个指针,有空间开销
- 节点内存不连续,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)(找到位置后) |
| 适用场景 | 完全二叉树、堆 | 绝大多数普通二叉树 | 需频繁回溯父节点的场景(红黑树) | 无指针语言、固定大小场景 |
四、选型总结
- 如果是完全二叉树(如堆、优先级队列):优先选顺序存储,空间利用率和访问速度都是最优。
- 如果是普通二叉树,频繁增删节点:优先选二叉链表,灵活且无空间浪费,是工业界的默认选择。
- 如果需要频繁查找父节点(如平衡树调整、回溯算法):选三叉链表,用少量空间换时间效率。
- 如果环境不支持动态内存 / 指针:用静态链表做折中实现。
九、堆
我们从底层结构、核心操作、建堆原理、堆排序、经典应用 五个维度,系统深入地讲解堆。堆的本质是满足堆序性质的完全二叉树,它完美复用了完全二叉树的顺序存储特性,是实现「优先级队列」「TopK 问题」的最优数据结构
一、堆的定义与核心特性
堆必须同时满足两个条件:
- 结构约束 :必须是一棵完全二叉树(除最后一层外其余层全满,最后一层节点靠左连续排列)
- 性质约束(堆序性):任意节点的值,与它的左右子节点满足固定的大小关系
根据堆序性的不同,堆分为两类:
- 大顶堆(最大堆):任意父节点的值 ≥ 左右子节点的值 → 堆顶(根节点)是全局最大值
- 小顶堆(最小堆):任意父节点的值 ≤ 左右子节点的值 → 堆顶(根节点)是全局最小值
关键误区:堆 ≠ 二叉搜索树
很多初学者会把堆和 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)
适用场景 :删除堆顶、原地建堆 核心逻辑:将一个不满足堆序的节点,逐层向下交换,直到它落到正确的位置,满足堆序性。
大顶堆下沉步骤:
- 找到当前节点的左、右孩子,选出值更大的那个孩子节点
- 如果当前节点的值 < 这个大孩子的值,交换两者位置
- 将当前节点下移到孩子的位置,重复上述步骤
- 终止条件:当前节点 ≥ 两个孩子,或者已经到达叶子节点
时间复杂度: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)
适用场景 :插入新元素 核心逻辑:将新插入的节点,逐层向上交换,直到它满足堆序性。
大顶堆上浮步骤:
- 找到当前节点的父节点
- 如果当前节点的值 > 父节点的值,交换两者位置
- 将当前节点上移到父节点的位置,重复上述步骤
- 终止条件:当前节点 ≤ 父节点,或者已经到达根节点
时间复杂度:O(logn)
四、堆的构建:原地建堆(O (n) 复杂度)
建堆就是把一个无序数组,调整成满足堆序性的结构。有两种建堆思路,标准工业实现都是原地下沉建堆。
方法 1:逐元素插入法(上浮建堆)
- 思路:从空堆开始,把元素逐个追加到数组末尾,每加一个就做一次上浮
- 时间复杂度:O (nlogn),每个元素最多上浮 logn 层
- 缺点:效率低,仅适合动态插入的场景,不适合批量建堆
方法 2:原地下沉建堆 ⭐ 标准实现
核心思路:
- 先把所有元素按原顺序放进数组,先满足「完全二叉树」的结构要求
- 从最后一个非叶子节点开始,从后往前(从下往上)对每个节点执行下沉调整
- 为什么从下往上?因为下沉操作的前提是「左右子树已经是合法堆」,从底层往上调,能保证调整父节点时,子树已经满足堆序
最后一个非叶子节点的下标 :(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. 插入元素
步骤:
- 把新元素追加到数组的末尾(保持完全二叉树结构)
- 对这个新元素执行上浮调整,恢复堆序性
时间复杂度:O(logn)
3. 删除堆顶元素(弹出极值)
为什么不能直接删除数组第一个元素? 直接删除会导致数组中间出现空位,破坏完全二叉树结构,后续所有元素都要前移,时间复杂度退化为 O (n)。
标准删除步骤:
- 保存堆顶元素(作为返回值)
- 把数组最后一个元素移动到堆顶位置(下标 0),堆的有效长度减 1
- 对新的堆顶元素执行下沉调整,恢复堆序性
时间复杂度: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,数组全部有序
为什么升序用大顶堆?
每次把最大值放到堆的末尾,相当于从后往前填充有序序列,最终数组自然升序。如果要降序排序,改用小顶堆即可。
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) 取出全局最小值,再将该数组的下一个元素入堆调整。