数据结构——堆

简介: 数据结构——堆


1. 什么是堆

树和二叉树一文中可以了解到:

堆通常是一个可以被看作一棵完全二叉树的数组对象。

  • 堆的逻辑结构是一棵完全二叉树
  • 堆的物理结构是一个数组

如图所示:


2. 堆的两个特性

  • 结构性:用数组表示的完全二叉树
  • 有序性:任意节点的关键值是其子树所有节点的最大值(或最小值)
  • ”最大堆(MaxHeap)“,也叫”大顶堆“:最大值,即每棵子树的根节点的值都大于等于其子节点的值
  • ”最小堆(MaxHeap)“,也叫”小顶堆“:最小值,即每棵子树的根节点的值都小于等于其子节点的值

我们通过一道例题来加深对堆的理解:

下列关键字序列中,序列 () 是堆

A.(16,72,31,23,94,53)

B.(94,23,31,72,16,53)

C.(16,53,23,94,31,72)

D.(16,23,53,31,94,72)

我们可以通过画图来分析:

通过分析我们可以知道,只有序列D符合堆的条件,其每个子树的父亲都小于它的孩子,因此这也是一个小堆。


3. 父子节点下标之间的关系

我们定义父亲为parent,左右孩子为leftchild, rightchild

  • 我们可以通过数组的下标来确定父子节点的关系,结论如下

leftchild = parent * 2 + 1

rightchild = parent * 2 + 2

parent = (child - 1) / 2


4. 堆的实现

由于堆的物理存储结构是一个数组,因此我们可以像定义顺序表一样定义堆的结构:

typedef int HPDataType;
typedef struct Heap
{
  HPDataType* a;
  int size; //记录当前数据个数
  int capacity; //记录最大容量
}Heap;

4.1 堆的插入HeapPush

向堆中插入数据,在内存中也就相当于向一个数组插入数据,而为了确保效率,显然我们应该将新数据val插入到数组尾部

如图所示:

此时我们发现,数据10插入后,这一串数据构成的完全二叉树就不满足堆的性质了。因此为了确保堆结构的正确性,每插入一次数据,我们都要重新对堆进行调整

4.1.1 向上调整算法AdjustUp:

调整的整体原则是:

  • 如果原来是小堆,并且插入的数据val小于父亲parent,那么就将valparent调换位置。继续向根部向上调整,直到满足所有的父亲小于等于它的孩子。
  • 大堆同理。

例如:

4.1.2 AdjustUp代码实现(以大堆为例)

void AdjustUp(HPDataType* a, int child)
{
    //新插入数据的父亲
  int parent = (child - 1) / 2;
    //开始向上调整
  while (child > 0)
  {
        //如果孩子大于父亲,那就要交换
        //并继续向上调整
    if (a[child] > a[parent])
    {
      Swap(&a[child], &a[parent]);
      child = parent;
      parent = (parent - 1) / 2;
    }
        //否则说明已经符合堆的性质,退出循环
    else
      break;
  }
}

4.1.3 HeapPush实现

知道了如何向上调整,那么堆的插入就很简单了。只需要在插入新的数据后进行一次向上调整就行。

void HeapPush(Heap* hp, HPDataType x)
{
  //检查容量
  if (hp->size == hp->capacity)
  {
    hp->capacity *= 2;
    HPDataType* temp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * hp->capacity);
    if (NULL == temp)
    {
      perror("realloc");
      exit(-1);
    }
  }
  hp->a[hp->size] = x;
  hp->size++;
  AdjustUp(hp->a, hp->size - 1);
}

4.1.4 时间复杂度

对于每一次插入,向上调整时最多调整二叉树的高度次,假设二叉树有N个节点,那么它的高度h就是log2(N+1),即时间复杂度为O(logN)

4.2 堆的删除HeapPop

首先我们应该思考,对于构成堆的数组,我们应该删除数组的第一个元素还是最后一个元素?

可能有的小伙伴觉得为了使删除的效率最高,应该删除数组的最后一个元素,但是这种做法是没有任何意义的。

应该清楚,堆顶元素(即数组的首元素)要么是整个数组的最大值,要么是整个数组的最小值,因此对这一个数进行操作才能有较大的意义

正确的删除操作HeapPop应该是这样的:

  • 删除的数据应该是堆顶元素
  • 将堆顶元素(即数组首元素)和数组最后一个元素调换位置
  • 再删除最后一个元素

如图:

显然我们可以看到,和插入操作一样,删除操作后仍不能保证堆结构的正确性,因此我们仍需要重新对堆进行调整

4.2.1 向下调整算法AdjustDown

删除操作中我们将原来数组的最后一个元素放到了堆顶,从而导致堆顶数据不一定满足堆的条件,因此我们也要从堆顶开始向下调整

调整的整体原则是:

如果原来是小堆:将堆顶数据和左右孩子较小的那一个比较,如果堆顶数据大于较小值,那么就交换这两个数的位置。继续向下调整,直到满足堆的条件

大堆同理。

如图:

4.2.2 AdjustDown实现(大堆)

void AdjustDown(HPDataType* a, int parent, int n)
{
    //先默认较大的孩子为左孩子
  int child = parent * 2 + 1;
  while (child < n)
  {
        //如果右孩子存在并且右孩子大于左孩子
        //那么较大的孩子应该是右孩子
    if (child + 1 < n && a[child + 1] > a[child])
      child = child + 1;
    //如果父亲小于孩子,那就交换父亲和孩子
        //并继续向下调整
        if (a[parent] < a[child])
    {
      Swap(&a[parent], &a[child]);
      parent = child;
      child = child * 2 + 1;
    }
        //否则已经满足堆的条件,退出循环
    else
      break;
  }
}

4.2.3 HeapPop实现

和插入类似,只要每删除一次数据就进行一次向下调整,就可以保证HeapPop的正确了:

void HeapPop(Heap* hp)
{
    //交换堆顶元素和数组最后一个元素
  Swap(&(hp->a[0]), &(hp->a[hp->size - 1]));
    //更新大小
  hp->size--;
    //向下调整
  AdjustDown(hp->a, 0, hp->size);
}

4.2.4 时间复杂度

和向上调整一样,对于每一次删除,向下调整最多调整高度次,因此时间复杂度为O(logN)

4.3 堆的构建

应该清楚,执行堆的插入和删除之前,堆已经被建立。换一句话说,如果任意给一组数据,同时要对其进行堆的插入删除操作,首先就要确保这一组数据是堆。因此掌握如何建堆是十分重要的。

有两种方式建堆:

4.3.1 向上调整建堆

实现堆的插入时,我们用向上调整的方式来建堆。同样,对于任意一个给定的数组,我们也可以用类似“插入”的方法来建堆。

具体方法(以建大堆为例):

  • 遍历数组
  • 数组首元素可以默认为一个大堆
  • 从第二个元素开始遍历,可以看作每遍历一个数据,就将这个数据HeapPush到堆中
  • 直到遍历完整个数组
for (int i = 1; i < n; i++)
    AdjustUp(hp->a, i);
  • 时间复杂度:O(NlogN)

4.3.2 向下调整建堆

也可以采用分治的思想:如果构成二叉树的所有子树都是堆,那么这棵二叉树也就一定是堆。

那么我们就可以从最后一棵子树开始,用向下调整的方法建堆

具体方法:

  • 最后一个元素的下标为size - 1,其对应父亲parent的下标为(size - 1 - 1)/2
  • parent开始向前遍历数组,对每一棵子树进行向下调整
  • 直到遍历完整个数组
int parent = (hp->size - 1 - 1) / 2;
while (parent >= 0)
{
    AdjustDown(hp->a, parent, hp->size);
    parent--;
}
  • 时间复杂度:O(N)

5. 堆的应用

堆的主要用途有堆排序和处理TopK问题,感兴趣的小伙伴可以点击下面的链接进行进一步了解:

👉堆排序

👉TopK问题

相关文章
|
14天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
29 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
16天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
58 16
|
1月前
|
存储 JavaScript 前端开发
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
68 1
|
2月前
|
存储 Java
【数据结构】优先级队列(堆)从实现到应用详解
本文介绍了优先级队列的概念及其底层数据结构——堆。优先级队列根据元素的优先级而非插入顺序进行出队操作。JDK1.8中的`PriorityQueue`使用堆实现,堆分为大根堆和小根堆。大根堆中每个节点的值都不小于其子节点的值,小根堆则相反。文章详细讲解了如何通过数组模拟实现堆,并提供了创建、插入、删除以及获取堆顶元素的具体步骤。此外,还介绍了堆排序及解决Top K问题的应用,并展示了Java中`PriorityQueue`的基本用法和注意事项。
52 5
【数据结构】优先级队列(堆)从实现到应用详解
|
1月前
|
存储 算法 调度
数据结构--二叉树的顺序实现(堆实现)
数据结构--二叉树的顺序实现(堆实现)
|
1月前
|
存储 算法 分布式数据库
【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力
【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力
|
1月前
|
存储 算法
探索数据结构:分支的世界之二叉树与堆
探索数据结构:分支的世界之二叉树与堆
|
1月前
|
存储 算法 Java
【用Java学习数据结构系列】用堆实现优先级队列
【用Java学习数据结构系列】用堆实现优先级队列
31 0
|
1月前
|
存储 算法
【数据结构】二叉树——顺序结构——堆及其实现
【数据结构】二叉树——顺序结构——堆及其实现
|
1月前
【数据结构】大根堆和小根堆
【数据结构】大根堆和小根堆
27 0