引言:为什么需要堆?—— 从“比赛树”说起
在解决“找到n个整数中的最大值”这类问题时,我们有几种思路:
- 直接查找:遍历一次数组,时间复杂度为 O(n)O(n)O(n)。如果你只需要找一次最大值,这很简单。但如果需要频繁地找出当前集合的最大值(例如,找出第1、第2、第3…大的值),每次都重新扫描效率就很低了。
- 排序后查找:先用一种排序算法(如快速排序)将数组排序,时间复杂度为 O(nlogn)O(n \log n)O(nlogn)。之后,你可以轻易地得到第k大的元素。但如果元素的集合是动态变化的(经常有插入和删除),每次都重排序的代价太高。
为了解决这个问题,我们可以从“淘汰赛”中获得启发,构建一棵“比赛树”(也叫选择树)。
- 比赛树 (Winner Tree):
- 它是一棵满二叉树。
- 叶子节点存放参赛选手(我们的数据元素)。
- 内部节点存放其两个孩子节点比赛的“赢家”(较大或较小的值)。
- 树根节点自然就是最终的冠军,即所有元素中的最大值(或最小值)。
比赛树能让我们在 O(1)O(1)O(1) 时间内获得最大值(根节点),但它有缺点:
- 结构固定:必须是满二叉树,如果元素数量不是2的幂,需要补充虚拟节点。
- 查找次大值复杂:找到最大值后,要找到次大值,需要将最大值所在的路径重新比赛一遍,效率不高。
- 空间浪费:内部节点重复存储了叶子节点的值。
为了克服这些缺点,一种更高效、更灵活的数据结构——堆 (Heap) 应运而生。
第一部分:堆的核心概念
1. 堆的定义
堆本质上是一棵完全二叉树,同时满足一个特殊的有序性质。
-
结构性:必须是完全二叉树 (Complete Binary Tree)。
- 这意味着除了最后一层,其他层都是满的,并且最后一层的节点都尽可能地靠左排列。
- 这个性质使得我们可以非常方便地用数组来存储堆,而不需要指针。
-
有序性(堆序性, Heap Property):
- 树中任意一个节点的值都必须大于或等于(或小于或等于)其所有子节点的值。
2. 堆的分类
根据堆序性的不同,堆分为两种:
- 极大堆 (Max-Heap):也叫大根堆。任意节点的值都大于或等于其子节点的值。因此,堆顶(根节点)的元素是整个堆中的最大值。
- 极小堆 (Min-Heap):也叫小根堆。任意节点的值都小于或等于其子节点的值。因此,堆顶(根节点)的元素是整个堆中的最小值。
注意: 除非特别说明,我们通常讨论的是大根堆。
3. 堆的数组表示
由于堆是完全二叉树,我们可以用一个数组来高效存储它。节点在数组中的索引关系如下(假设数组索引从1开始):
- 节点
i的父节点索引是i / 2(整除)。 - 节点
i的左孩子索引是2 * i。 - 节点
i的右孩子索引是2 * i + 1。
这种表示方法非常节省空间,并且能快速定位父子关系,是机试中的标准实现方式。
第二部分:堆的核心操作
为了维护堆的性质,有两个基本操作是所有其他高级操作(如插入、删除)的基础。
1. 上浮 (Sift-up / Swim)
-
场景:当堆中某个节点的值变大(在大根堆中)时,它可能比其父节点还要大,从而违反了堆的有序性。这时就需要“上浮”。典型的应用是插入新元素。
-
过程:将该节点与其父节点比较,如果它比父节点大,就交换两者位置。然后继续将该节点与其新的父节点比较,重复此过程,直到它不再比父节点大,或者已经到达堆顶(根节点)位置。
-
时间复杂度:上浮的路径最多是树的高度,因此时间复杂度为 O(logn)O(\log n)O(logn)。
2. 下沉 (Sift-down / Sink)
-
场景:当堆中某个节点的值变小(在大根堆中)时,它可能比其某个子节点小,从而违反了堆的有序性。这时就需要“下沉”。典型的应用是删除堆顶元素和建堆。
-
过程:将该节点与其较大的那个子节点进行比较。如果它比这个子节点小,就交换两者位置。然后继续将该节点与其新的子节点比较,重复此过程,直到它的两个子节点都比它小,或者它已经成为叶子节点。
-
时间复杂度:下沉的路径最多也是树的高度,因此时间复杂度为 O(logn)O(\log n)O(logn)。
第三部分:堆的基本应用
基于“上浮”和“下沉”,我们可以实现堆的常用接口。
1. 插入元素 (Insert)
- 步骤:
- 将新元素添加到数组的末尾(也就是完全二叉树的下一个空位)。
- 对这个新元素执行上浮操作,以恢复堆的性质。
- 时间复杂度:O(logn)O(\log n)O(logn)。
2. 删除堆顶元素 (Delete-Max)
这是堆最重要的操作之一,因为我们总是能快速访问和删除最大(或最小)值。
- 步骤:
- 堆顶元素就是我们想要的返回值。先将其保存起来。
- 将数组中最后一个元素(堆的最后一个叶子)移动到堆顶位置。
- 堆的有效元素数量减一。
- 此时新的堆顶元素可能太小,不满足堆性质,因此对堆顶(索引1)执行下沉操作。
- 时间复杂度:O(logn)O(\log n)O(logn)。
3. 删除任意元素 (Delete at index x)
- 步骤:
- 为了不破坏完全二叉树的结构,我们不能直接删除中间的元素。
- 将数组中最后一个元素
h[hlen]移动到要删除的元素h[x]的位置。 - 堆的有效元素数量减一。
- 此时
h[x]位置的新值,可能比它的父节点大,也可能比它的子节点小。因此需要判断:- 如果
h[x]的值比原来h[x]的值大,它可能需要上浮。 - 如果
h[x]的值比原来h[x]的值小,它可能需要下沉。 - 一个简单的判断是:先尝试上浮,如果没动,再尝试下沉。
- 如果
- 时间复杂度: O(logn)O(\log n)O(logn)。
第四部分:初始建堆 (Build Heap)
如何将一个无序的数组转换成一个堆?有两种方法。
方法一:逐个插入 (慢速建堆)
- 思路:创建一个空堆,然后遍历原始数组,将每个元素依次插入 (insert) 到堆中。每次插入都会调用一次“上浮”操作。
- 时间复杂度:共进行
n次插入,每次插入的复杂度为 O(logn)O(\log n)O(logn),所以总时间复杂度为 O(nlogn)O(n \log n)O(nlogn)。
方法二:自底向上调整 (快速建堆)
这是更高效且标准的建堆方法。
- 思路:
- 将整个数组看作一棵不满足堆性质的完全二叉树。
- 从最后一个非叶子节点开始,向前遍历到根节点。
- 对遍历到的每个节点,都执行一次下沉操作。
- 为什么从最后一个非叶子节点开始?
- 在数组表示中,最后一个元素的索引是
n,它的父节点是n/2。所以从n/2开始的节点都是非叶子节点。 - 叶子节点自身已经满足堆的定义(没有子节点),无需调整。
- 当我们对节点
i执行下沉时,可以保证它的左右子树(如果存在)已经都是合法的堆了。这保证了下沉操作的正确性。
- 在数组表示中,最后一个元素的索引是
- 时间复杂度:虽然看起来是
n/2次下沉,每次 O(logn)O(\log n)O(log

最低0.47元/天 解锁文章
13万+

被折叠的 条评论
为什么被折叠?



