数据结构:堆(Heap)
目录
从根源问题出发
设计新的结构 - 逻辑形态
优化结构 - 物理形态
堆的存储 - 从“树”到“数组”
逐步实现代码(C/C++)
从根源问题出发
我们先忘掉“堆”这个名字,思考一个非常常见的需求:
“我有一堆动态的数据,我需要频繁地、快速地找到这堆数据里的最大值(或者最小值)。”
为了解决这个问题,我们回顾一下已经学过的数据结构,看看它们表现如何:
用普通数组(无序)
-
优点:插入新数据很快,直接在末尾添加就行,时间复杂度是 O(1)。
-
缺点:找最大值太慢。你必须从头到尾遍历一遍,时间复杂度是 O(n)。如果数据量很大,每次都遍历是不可接受的。
用有序数组
-
优点:找最大值超快。它就在数组的头部或尾部,时间复杂度是 O(1)。
-
缺点:插入新数据太慢。为了维持数组的有序性,你得先找到新元素该放的位置,然后把后面的元素都移动一遍。平均时间复杂度是 O(n)。
数据结构 | 查找最大值 | 插入新元素 |
---|---|---|
无序数组 | O(n) (慢) | O(1) (快) |
有序数组 | O(1) (快) | O(n) (慢) |
你看,这两种基本结构都有明显的短板,它们没法同时“比较快”地完成查找最大值和插入元素这两个操作。我们需要一种新的、更平衡的数据结构。这个需求,就是“堆”诞生的根本原因。
设计新的结构 - 逻辑形态
既然数组这种线性结构不行,我们自然会想到你刚学过的“树”结构。树的结构比数组复杂,也许能提供更优的解决方案。
我们来尝试设计一棵“树”,让它天生就适合找最大值。一个最直观的想法是什么?
“让最大值一眼就能看到,比如,就放在树根(root)的位置!”
这个想法太棒了!如果根节点永远是最大值,那我们找最大值不就永远是 O(1) 了吗?
好,顺着这个思路继续推导:
-
如果根节点是整个树的最大值,那么对于任何一棵子树来说,这棵子树的根节点也应该是这棵子-树里的最大值。
-
换句话说,对于树中的任意一个节点,它的值都必须大于或等于它所有孩子节点的值。
我们就得到了这个结构的第一条核心属性,我们称之为 “堆序属性” (Heap Property):
父节点的值 > 子节点的值
(注意:这里没有规定左、右孩子谁大谁小,只规定了父子关系)
这种父节点总是最大的,我们称之为“大顶堆”(Max Heap)。
反之,如果规定父节点总是最小的(父节点 < 子节点),那就是“小顶堆”(Min Heap)。我们下面都以大顶堆为例。
下面这棵树就满足我们刚刚定义的“堆序属性”:
100/ \70 80/ \ /30 40 60
但是,下面这棵树,虽然根是最大值,但它不满足堆序属性,因为它局部不满足(80的子树里,根不是80,而是90)。
100/ \70 80/ \ \30 40 90 <-- 这里的 90 比它的父节点 80 大,破坏了规则
优化结构 - 物理形态
现在我们有了“堆序属性”,但这还不够。回忆一下,树的查询、插入等操作的效率,通常和什么有关?—— 树的高度。
如果一棵树长得“歪七扭八”,像下面这样,虽然它也满足我们定义的“堆序属性”:
100/70/60/
30
这棵树几乎退化成了一个链表,它的高度是 O(n)。如果我们基于这样的结构去进行插入和删除操作,效率肯定不会高。
怎样让树的高度尽可能低?—— 让它尽可能地“胖”起来,也就是让节点尽可能地铺满每一层。
你已经学过了,这种“尽可能铺满”的树,有一个专门的名字,叫 “完全二叉树” (Complete Binary Tree)。
它的定义是:一棵二叉树,除了最后一层外,其他各层节点数都达到最大,并且最后一层的节点都连续集中在最左边。
所以,为了保证效率,我们给堆加上第二条核心属性,我们称之为 “结构属性” (Shape Property):
堆必须是一棵完全二叉树。
现在,把我们从第一性原理推导出的两条属性结合起来,就得到了“堆”的严格定义:
堆(Heap)是一个完全二叉树,并且满足堆序属性(父节点的值总是不小于其子节点的值)。
这就是堆的本质。它巧妙地结合了两种特性:
-
值的顺序性 (堆序属性):保证了根节点一定是最大值。
-
结构的平衡性 (完全二叉树):保证了树的高度是 O(logn),为日后高效的插入、删除操作打下了基础。
堆的存储 - 从“树”到“数组”
现在我们逻辑上定义了堆是一棵“完全二叉树”,但在C/C++代码里怎么表示它呢?
用常规的指针方式定义树节点当然可以:
struct TreeNode {int data;TreeNode* left;TreeNode* right;
};
但对于“完全二叉树”这种结构极其规整的树来说,用指针太浪费了!因为它的节点排列没有任何“空隙”,我们可以非常巧妙地用一个数组来存储它,而且只用数组就够了,不需要任何指针!
怎么做呢?我们把树的节点按层序遍历(从上到下,从左到右)的顺序,依次放入数组中。
神奇的地方来了:在这种存储方式下,任意一个节点和它的父、子节点在数组中的下标(index)都有着固定的数学关系。
为了公式更简洁,我们先假设数组从下标 1 开始存储。对于数组中下标为 i
的节点:
-
它的父节点下标是:
floor(i / 2)
(i除以2向下取整) -
它的左孩子下标是:
2 * i
-
它的右孩子下标是:
2 * i + 1
验证一下:
-
节点
80
(下标为3):-
父节点是
floor(3/2) = 1
,对应的值是100
。正确。
-
-
节点
70
(下标为2):-
左孩子是
2 * 2 = 4
,对应的值是30
。正确。 -
右孩子是
2 * 2 + 1 = 5
,对应的值是40
。正确。
-
这个性质至关重要!它意味着我们可以抛弃复杂的指针操作,只用简单的下标运算就能在树的结构里上下游走。
这不仅代码简单,而且由于数组在内存中是连续存储的,缓存命中率更高,实际运行效率也常常优于指针实现。
逐步实现代码(C/C++)
根据上面的推导,我们现在可以定义出堆的基本结构。暂时不包含任何操作,只定义它的“骨架”。
1. 定义堆的结构体
一个堆需要什么?
-
一个指向数据的指针(数组的首地址)。
-
记录当前已经存了多少个元素。
-
记录这个数组总共能存多少个元素(容量)。
// 为了方便,我们用 C++ 的语法来写,但其思想和 C 语言是完全一致的。
// 你可以把 new/delete 看作是 malloc/free。// 堆的结构定义
struct Heap {int* data; // 用一个整型指针指向我们的数组,数组里存放堆的数据int size; // 记录当前堆中有多少个元素int capacity; // 记录堆的最大容量(数组有多大)
};
这就是堆在内存中的样子,一个非常简单的结构体。
2. 创建一个堆
我们需要一个函数来初始化这个结构体。
#include <iostream> // 只是为了后面可能的输出// 创建并初始化一个堆
Heap* createHeap(int capacity) {// 1. 为 Heap 结构体本身分配内存Heap* h = new Heap;if (!h) {// 内存分配失败return nullptr;}// 2. 为存储数据的数组分配内存// 注意:我们故意多分配一个空间,让数组从下标 1 开始使用,这样公式更简单。// 但在C/C++中,数组通常从0开始,我们后面会统一到0-based。// 这里为了讲解方便,先用 1-based 的思路。// 不过,为了让你平滑过渡到标准实现,我们还是用 0-based 来写代码。h->data = new int[capacity];if (!h->data) {// 内存分配失败delete h;return nullptr;}// 3. 初始化其他成员h->size = 0; // 一开始堆是空的h->capacity = capacity;return h;
}
3. 实现下标计算的辅助函数
现在,我们把前面推导的下标关系用代码实现。在C/C++中,数组下标都是从 0 开始的 (0-based)。公式需要做一点点调整,但原理完全一样。
对于数组中下标为 i
的节点:
-
它的父节点下标是:
(i - 1) / 2
(整数除法自动向下取整) -
它的左孩子下标是:
2 * i + 1
-
它的右孩子下标是:
2 * i + 2
我们把它们写成几个小函数,这样代码会非常清晰:
// 辅助函数,根据一个节点的下标,返回其父节点的下标
// (这是 0-based 数组的公式)
int parent(int i) {return (i - 1) / 2;
}// 返回左孩子的下标
int leftChild(int i) {return 2 * i + 1;
}// 返回右孩子的下标
int rightChild(int i) {return 2 * i + 2;
}
到这里,我们已经完成了对“堆”这个数据结构的静态定义和基础准备。我们从一个实际问题出发,推导出了堆的两个核心属性,并最终选择用数组这种高效的方式来存储它,还准备好了在数组中模拟树形关系的基础代码。
你现在应该对“堆是什么”以及“为什么是这样设计的”有了清晰的认识。下一步,我们就可以在今天搭建的这个骨架之上,去学习如何进行插入、删除等动态操作了。