堆是数据结构与算法里一个非常“拧巴”的存在。它长着一副二叉树的样子,底层却用数组存;它的逻辑结构看上去整整齐齐,实际排序时交换起来却跳来跳去。很多初学者学到“树”这一章还能勉强跟上,一到“堆排序、建堆”就开始发懵,面试时一让手写堆排序就卡壳,本质上就是因为没有把“树”和“数组”这两条线索串起来。
这篇文章我打算用一套完整的思路来拆解:先弄清楚树的基础概念分布,再盯着“完全二叉树”这一个形态,把堆的定义、数组存储、上浮下沉、建堆、堆排序全部串成一条线。看完之后你会发现,堆排序并不是需要“背下来”的算法,而是只要理解了三个关键操作,写代码就是顺水推舟的事。适合正在学数据结构的学生、准备算法面试的开发者,以及工作中需要用优先级队列处理任务调度、TopK、定时器这些场景的工程师。
1. 树的基础认知:堆的三层根基
很多人学堆的时候直接跳到“大顶堆小顶堆”,结果对堆为什么长成那样没有概念。实际上,堆是长在二叉树这棵大树上的一个特殊分支。要想把堆吃透,得先把树的地基打牢。
1.1 树的定义与核心术语
树是一种非线性的数据结构,它由 n(n≥0)个有限节点组成。n=0 时叫空树,n>0 时,树里有一个根节点,其他节点可以通过边连接到彼此,形成层次关系。树的形态很像实际生活里的组织结构:总经理下面有部门总监,每个总监下面还有主管。这种“一对多”的关系,正是树这种数据结构存在的意义。
树里有一批必须记住的术语:
- 节点(Node):树的基本单位,一个节点可以存一个数据元素。
- 根节点(Root):没有父节点的节点,一棵树只有唯一一个根。
- 父节点与子节点:相连的两个节点中,上层的是父节点,下层的是子节点。
- 叶子节点(Leaf):没有子节点的节点,也叫终端节点。
- 节点的度(Degree):一个节点拥有子节点的个数,比如一个节点有3个子节点,那它的度就是3。树的度是这棵树中所有节点度的最大值。
- 树的深度(Depth)/ 高度(Height):从根节点开始,根为第1层,根的子节点为第2层,依次往下。最大层数就是树的深度。
- 兄弟节点:同一个父节点下面的所有子节点互为兄弟。
看下面这棵简单的树结构。
code复制 A
/ \
B C
/ \ / \
D E F G
- A 是根节点。
- B、C 是 A 的子节点,D、E 是 B 的子节点。
- D、E 互称兄弟节点。
- D、E、F、G 都是叶子节点。
- 树的深度是 3。
- B 的度是 2,C 的度是 2,A 的度也是 2,所以这棵树的度是 2。
树结构在实际工程项目中太常见了。文件系统就是典型的树形结构,根目录下面有子目录,子目录下面还有文件。DOM 树、语法树、路由表,都是树的具体应用。如果把树理解透彻,后面看 B 树、B+树、红黑树这些高级话题会轻松很多。
1.2 二叉树、满二叉树与完全二叉树
在所有树结构里,用得最多的一定是二叉树。二叉树的定义很简单:每个节点最多只有两个子节点的树。正是这个“最多两个”的约束,让二叉树可以用很优雅的方式存储,也衍生出一堆非常高效的算法。
二叉树有两个重要性质需要记住:
- 在二叉树的第 i 层上,最多有 2^(i-1) 个节点(i≥1)。
- 深度为 k 的二叉树最多有 2^k - 1 个节点。
这两个性质可以自己推演一下,验证一遍比背下来牢靠得多。
在二叉树里,有两种特殊的形态,堆跟它们关系极其密切。
第一种是满二叉树(Full Binary Tree)。每一层的节点数都达到最大值,也就是说深度为 k 的满二叉树一定有 2^k - 1 个节点。从形状上看,每一层都被填满了,没有任何空缺。满二叉树非常“对称”,但实际场景中这种结构太苛刻了,很难刚好凑齐那么多节点。
第二种是完全二叉树(Complete Binary Tree)。完全二叉树的定义要严格区分于满二叉树:它要求除最后一层外,每一层都必须填满,最后一层可以不满,但所有叶子节点必须集中在左侧连续排列,不能有空隙。
完全二叉树的实际形状就是“从左到右连续填充”的。可以这样理解:一棵完全二叉树的节点编号和同等深度满二叉树的节点编号完全一致,不会有任何一个位置是“本该有节点却空了”的。
完全二叉树的重要性在于,它让“用数组存储树”成为可能。普通二叉树用数组存会遇到大量空洞,浪费空间;但完全二叉树没有空洞,每个数组位置都对应一个有效节点。
1.3 树的存储方式:链式还是数组
树可以用两种方式存储:链式存储和顺序存储。
链式存储很好理解,每个节点包含数据域和两个指针域,分别指向左孩子和右孩子。结构体大概长这样:
c复制typedef struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
} TreeNode;
链式存储的优点是直观,处理子树关系方便;缺点是每个节点都要消耗指针空间,而且对于满二叉树这种形态,指针密度过大,内存利用率不高。
顺序存储则是把树的节点按层依次存入数组。对于完全二叉树,这种存储方式特别合适,因为节点在数组中的下标可以直接通过数学公式算出父子关系。
假设数组下标从 0 开始,对于下标为 i 的节点:
- 左孩子的下标是 2 * i + 1
- 右孩子的下标是 2 * i + 2
- 父节点的下标是 (i - 1) / 2(向下取整)
这个公式就是堆的底层基础。你不需要在任何节点里存储指针,只需要知道当前节点的数组下标,就能用 O(1) 的时间找到它的孩子和父亲。
顺序存储对于普通二叉树来说会有浪费,因为有些位置可能没有节点,数组空洞会导致空间利用率下降。但完全二叉树完美避开了这个问题,因为其节点本身就是连续填充的,数组下标没有任何空洞。堆选择用数组存储,不是随意决定的,而是因为堆本身就是完全二叉树,天然适合这种紧凑的内存布局。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 堆的核心概念与存储设计
堆在树的基础上加了两个额外约
