数据结构——9.堆

引言:为什么需要堆?—— 从“比赛树”说起

在解决“找到n个整数中的最大值”这类问题时,我们有几种思路:

  • 直接查找:遍历一次数组,时间复杂度为 O(n)O(n)O(n)。如果你只需要找一次最大值,这很简单。但如果需要频繁地找出当前集合的最大值(例如,找出第1、第2、第3…大的值),每次都重新扫描效率就很低了。
  • 排序后查找:先用一种排序算法(如快速排序)将数组排序,时间复杂度为 O(nlog⁡n)O(n \log n)O(nlogn)。之后,你可以轻易地得到第k大的元素。但如果元素的集合是动态变化的(经常有插入和删除),每次都重排序的代价太高。

为了解决这个问题,我们可以从“淘汰赛”中获得启发,构建一棵“比赛树”(也叫选择树)。

  • 比赛树 (Winner Tree)
    • 它是一棵满二叉树。
    • 叶子节点存放参赛选手(我们的数据元素)。
    • 内部节点存放其两个孩子节点比赛的“赢家”(较大或较小的值)。
    • 树根节点自然就是最终的冠军,即所有元素中的最大值(或最小值)。

比赛树能让我们在 O(1)O(1)O(1) 时间内获得最大值(根节点),但它有缺点:

  1. 结构固定:必须是满二叉树,如果元素数量不是2的幂,需要补充虚拟节点。
  2. 查找次大值复杂:找到最大值后,要找到次大值,需要将最大值所在的路径重新比赛一遍,效率不高。
  3. 空间浪费:内部节点重复存储了叶子节点的值。

为了克服这些缺点,一种更高效、更灵活的数据结构——堆 (Heap) 应运而生。


第一部分:堆的核心概念

1. 堆的定义

堆本质上是一棵完全二叉树,同时满足一个特殊的有序性质

  1. 结构性:必须是完全二叉树 (Complete Binary Tree)

    • 这意味着除了最后一层,其他层都是满的,并且最后一层的节点都尽可能地靠左排列。
    • 这个性质使得我们可以非常方便地用数组来存储堆,而不需要指针。
  2. 有序性(堆序性, Heap Property)

    • 树中任意一个节点的值都必须大于或等于(或小于或等于)其所有子节点的值。

2. 堆的分类

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

  • 极大堆 (Max-Heap):也叫大根堆。任意节点的值都大于或等于其子节点的值。因此,堆顶(根节点)的元素是整个堆中的最大值
  • 极小堆 (Min-Heap):也叫小根堆。任意节点的值都小于或等于其子节点的值。因此,堆顶(根节点)的元素是整个堆中的最小值

注意: 除非特别说明,我们通常讨论的是大根堆

3. 堆的数组表示

由于堆是完全二叉树,我们可以用一个数组来高效存储它。节点在数组中的索引关系如下(假设数组索引从1开始):

  • 节点 i 的父节点索引是 i / 2 (整除)。
  • 节点 i 的左孩子索引是 2 * i
  • 节点 i 的右孩子索引是 2 * i + 1

这种表示方法非常节省空间,并且能快速定位父子关系,是机试中的标准实现方式。


第二部分:堆的核心操作

为了维护堆的性质,有两个基本操作是所有其他高级操作(如插入、删除)的基础。

1. 上浮 (Sift-up / Swim)

  • 场景:当堆中某个节点的值变大(在大根堆中)时,它可能比其父节点还要大,从而违反了堆的有序性。这时就需要“上浮”。典型的应用是插入新元素。

  • 过程:将该节点与其父节点比较,如果它比父节点大,就交换两者位置。然后继续将该节点与其新的父节点比较,重复此过程,直到它不再比父节点大,或者已经到达堆顶(根节点)位置。

  • 时间复杂度:上浮的路径最多是树的高度,因此时间复杂度为 O(log⁡n)O(\log n)O(logn)

2. 下沉 (Sift-down / Sink)

  • 场景:当堆中某个节点的值变小(在大根堆中)时,它可能比其某个子节点小,从而违反了堆的有序性。这时就需要“下沉”。典型的应用是删除堆顶元素建堆

  • 过程:将该节点与其较大的那个子节点进行比较。如果它比这个子节点小,就交换两者位置。然后继续将该节点与其新的子节点比较,重复此过程,直到它的两个子节点都比它小,或者它已经成为叶子节点。

  • 时间复杂度:下沉的路径最多也是树的高度,因此时间复杂度为 O(log⁡n)O(\log n)O(logn)


第三部分:堆的基本应用

基于“上浮”和“下沉”,我们可以实现堆的常用接口。

1. 插入元素 (Insert)

  • 步骤
    1. 将新元素添加到数组的末尾(也就是完全二叉树的下一个空位)。
    2. 对这个新元素执行上浮操作,以恢复堆的性质。
  • 时间复杂度O(log⁡n)O(\log n)O(logn)

2. 删除堆顶元素 (Delete-Max)

这是堆最重要的操作之一,因为我们总是能快速访问和删除最大(或最小)值。

  • 步骤
    1. 堆顶元素就是我们想要的返回值。先将其保存起来。
    2. 将数组中最后一个元素(堆的最后一个叶子)移动到堆顶位置。
    3. 堆的有效元素数量减一。
    4. 此时新的堆顶元素可能太小,不满足堆性质,因此对堆顶(索引1)执行下沉操作。
  • 时间复杂度O(log⁡n)O(\log n)O(logn)

3. 删除任意元素 (Delete at index x)

  • 步骤:
    1. 为了不破坏完全二叉树的结构,我们不能直接删除中间的元素。
    2. 将数组中最后一个元素 h[hlen] 移动到要删除的元素 h[x] 的位置。
    3. 堆的有效元素数量减一。
    4. 此时 h[x] 位置的新值,可能比它的父节点大,也可能比它的子节点小。因此需要判断:
      • 如果 h[x] 的值比原来 h[x] 的值大,它可能需要上浮
      • 如果 h[x] 的值比原来 h[x] 的值小,它可能需要下沉
      • 一个简单的判断是:先尝试上浮,如果没动,再尝试下沉。
  • 时间复杂度: O(log⁡n)O(\log n)O(logn)

第四部分:初始建堆 (Build Heap)

如何将一个无序的数组转换成一个堆?有两种方法。

方法一:逐个插入 (慢速建堆)

  • 思路:创建一个空堆,然后遍历原始数组,将每个元素依次插入 (insert) 到堆中。每次插入都会调用一次“上浮”操作。
  • 时间复杂度:共进行 n 次插入,每次插入的复杂度为 O(log⁡n)O(\log n)O(logn),所以总时间复杂度为 O(nlog⁡n)O(n \log n)O(nlogn)

方法二:自底向上调整 (快速建堆)

这是更高效且标准的建堆方法。

  • 思路
    1. 将整个数组看作一棵不满足堆性质的完全二叉树。
    2. 最后一个非叶子节点开始,向前遍历到根节点。
    3. 对遍历到的每个节点,都执行一次下沉操作。
  • 为什么从最后一个非叶子节点开始?
    • 在数组表示中,最后一个元素的索引是 n,它的父节点是 n/2。所以从 n/2 开始的节点都是非叶子节点。
    • 叶子节点自身已经满足堆的定义(没有子节点),无需调整。
    • 当我们对节点 i 执行下沉时,可以保证它的左右子树(如果存在)已经都是合法的堆了。这保证了下沉操作的正确性。
  • 时间复杂度:虽然看起来是 n/2 次下沉,每次 O(log⁡n)O(\log n)O(log
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱看烟花的码农

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值