数据结构--堆的深度解析

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 数据结构--堆的深度解析

引言

堆(Heap)是一种非常重要的非线性数据结构,广泛应用于各种算法和问题解决中,如优先队列、堆排序、Top-k 问题等。本文将深入解析堆的定义、类型、操作、应用及其优缺点。

上篇博客我们简单介绍了堆,并用堆实现了二叉树的顺序存储,这里我们趁热打铁深入解析堆这种结构。

一、基本概念

1.1堆的概念

堆是一种特殊的完全二叉树,具有以下性质:

  • 完全二叉树:堆是一个完全二叉树,意味着树的每一层都被完全填满,除了最后一层可能没有填满,但所有节点都集中在左侧。
  • 堆性质:堆有两种类型:
  • ‧ 小顶堆(min heap):任意节点的值 ≤ 其子节点的值。
    ‧ 大顶堆(max heap):任意节点的值 ≥ 其子节点的值。

1.2堆的存储结构

堆通常使用数组进行存储,这种方式的优点是可以直接通过数组下标计算父节点和子节点的位置:

数组索引映射:

对于节点 i:

父节点:parent(i) = (i - 1) / 2(使用整数除法)
左子节点:left(i) = 2 * i + 1
右子节点:right(i) = 2 * i + 2

//定义堆的结构--数组
typedef int DataType;
typedef struct Heap//小堆为例
{
  DataType *arr;
  int size;
  int capacity;
}HP;

存储示例

假设堆的数组表示为 {9,8,6,6,7,5,2,1,4,3,6,2}:

7的索引是4,其父节点为8(索引1),左子节点为3(索引9),右子节点6(索引10)。

这种方式有效利用了内存,避免了指针的开销。

1.3堆的特点

堆作为完全二叉树的一个特例,具有以下特性。

‧ 最底层节点靠左填充,其他层的节点都被填满。

‧ 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。

‧ 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。

二、 堆的基本操作

2.1初始化

//初始化
void HPInit(HP* p)
{
  assert(p);
  p->arr = NULL;
  p->size = p->capacity = 0;
}

2.2创建堆

创建堆通常有两种方式:

1.逐步插入:

从一个空堆开始,逐个插入元素。每插入一个新元素后,使用“上浮”操作来维持堆的性质。

for (int i = 0; i < n; i++)
{
  AdjustUp(arr, i);//向上调整算法,2.5中右有详细说明
}

2.堆化(Heapify):

给定一个无序数组,可以通过一次性构建堆来提高效率。这通常采用自底向上的方法,(先假定这组数据是一个“堆结构”)从最后一个非叶节点开始,逐个进行“下沉”操作,使之调整为堆(真正具有堆的特性)。

堆化算法步骤:
对于索引 i 从 (n-1-1)/2 到 0 进行下沉操作,n 是数组的长度。

for (int i =(n-1-1)/2 ; i >= 0; i--)//n-1为数组最后一个数据的下标,(n-1-1)/2为其父节点
{
  AdjustDown(arr, i, n);//向下调整算法,2.5中右有详细说明
}

2.3插入元素

在堆中插入元素的步骤如下:

1.添加元素:
将新元素添加到堆的末尾(数组的最后一个位置)。

2.上浮操作:
比较新元素与其父节点的值,如果新元素大于父节点(在最大堆中)或小于父节点(在最小堆中),则交换两者,并继续上浮直到堆性质得以恢复。

时间复杂度:O(log n)

代码实现:

//插入
void HPPush(HP* p, DataType x)
{
  assert(p);
  if (p->size == p->capacity)//判断空间是否足够
  {
    //扩容
    int NewCap = p->capacity == 0 ? 4 : 2 * p->capacity;
    DataType* tmp = (DataType*)realloc(p->arr, NewCap * sizeof(DataType));
    if (tmp == NULL) 
    {
      perror("realloc fail!");
      exit(1);
    }
    p->arr = tmp;
    p->capacity = NewCap;
  } 
  p->arr[p->size] = x;
  AdjustUp(p->arr, p->size);
  ++p->size;
}

2.4删除元素

到通常,删除操作是从堆中移除根节点(最大或最小值),其步骤如下:

1.替换根节点:
将根节点(堆顶)替换为最后一个元素,然后从数组中删除该元素。
2.下沉操作:
从根节点开始,依次将该节点与其子节点进行比较,将其值与较大的子节点(在最大堆中)或较小的子节点(在最小堆中)进行交换,直堆的性质恢复。

时间复杂度:O(log n)

代码实现:

//删除,出堆顶数据
void HPPop(HP* p)
{
  assert(p && p->size);
  Swap(&p->arr[0], &p->arr[p->size - 1]);
  --p->size;
  AdjustDown(p->arr, 0, p->size);
}

2.5堆化操作

堆化操作是保持堆性质的关键,主要包括:

1.向上调整(AdjustUp):

用于插入操作。当新插入的元素大于(或小于)其父节点时,通过交换将其上移,直至堆性质满足。

//堆的向上调整
void AdjustUp(DataType* arr, int child)
{
  int parent = (child - 1) / 2;//父节点
  while(child>0)//注意child可能会被调整到根节点,这时候就不能再调整了
  {
    //建小堆  <  //建大堆  >
    if (arr[child] < arr[parent])//如果条件语句不成立,就说明堆已经成型
    {
      Swap(&arr[child], &arr[parent]);
      child = parent;
      parent = (child - 1) / 2;//循环以上步骤
    }
    else
    {
      break;
    }
  }
}

计算向上调整算法建堆时间复杂度

因为堆是完全⼆叉树,⽽满⼆叉树也是完全⼆叉树,此处为了简化使⽤满⼆叉树来证明(时间复杂度本来看的就是近似值,多⼏个结点不影响最终结果)

分析:

第1层, 2^0 个结点,需要向上移动0层

第2层, 2^1 个结点,需要向上移动1层

第3层, 2^2 个结点,需要向上移动2层

第4层, 2^3 个结点,需要向上移动3层

......

第h层, 2^(h-1) 个结点,需要向上移动h-1层

则需要移动结点总的移动步数为:每层结点个数 * 向上调整次数(第⼀层调整次数为0)

由此可知,

向上调整算法建堆时间复杂度为:O(n ∗ log2 n)

2.向下调整(AdjustDown):

用于删除操作和堆化。根节点(或其他节点)与其子节点进行比较,将其值与较大的子节点(或较小的子节点)进行交换,直至堆性质满足。

//堆的向下调整
void AdjustDown(DataType* arr, int parent, int n)
{
  int child = parent * 2 + 1;//左孩子
  while (child < n) 
  {
    //小堆:寻找左右孩子最小的那个
    //大堆:寻找左右孩子最大的那个
    if (child + 1 < n && arr[child] > arr[child + 1])
    {
      child++;
    }
    //这里和向上调整就一样了,比较,交换,循环
    //建小堆  <   //建大堆  >
    if (arr[child] < arr[parent])
    {
      Swap(&arr[child], &arr[parent]);
      parent = child;
      child = parent * 2 + 1;
    }
    else
    {
      break;
    }
  }
}

计算向下调整算法建堆时间复杂度

分析:

第1层, 2^0 个结点,需要向下移动h-1层

第2层, 2^1 个结点,需要向下移动h-2层

第3层, 2^2 个结点,需要向下移动h-3层

第4层, 2^3 个结点,需要向下移动h-4层

......

第h-1层, 2^(h-2) 个结点,需要向下移动1层

由此可知,

向下调整算法建堆时间复杂度为:O(n)

2.6堆的判空

堆的大小为0,堆为空,反之则非空。

//判空
bool HPEmpty(HP* p)
{
  assert(p);
  return p->size == 0;
}

2.7获取堆顶元素

获取堆的根节点(最大或最小值)非常简单,只需返回数组的第一个元素。

//返回堆顶元素
DataType HPTop(HP* p)
{
  assert(p && p->size);
  return p->arr[0];
}

三、堆的常见应用

1. 优先队列

优先队列是一种数据结构,允许根据优先级处理元素。堆可以高效地实现优先队列,支持在 O(logn) 时间内插入和删除元素,适用于任务调度和图算法(如 Dijkstra 算法)。

2. 堆排序

堆排序是一种基于堆的排序算法,时间复杂度为 O(nlogn)。它通过构建最大堆并逐步提取最大元素来实现排序,适合需要原地排序的场景。

3. Top-k 问题

Top-k 问题涉及从数据集中找出前 k 个最大或最小的元素。使用最小堆可以在 O(nlogk) 的时间复杂度内有效解决该问题,常用于数据分析和排行榜。

4. 图论中的应用

在图算法中,堆常用于 Dijkstra 和 Prim 算法,通过优先队列高效管理节点和边,从而加速最短路径和最小生成树的计算。

下面我们来详细讲一下 Top-k问题

四、Top-k问题精讲

方法一:遍历选择

我们可以进行 𝑘 轮遍历,分别在每轮中提取第 1 2 𝑘 大的元素,时间复杂度为 𝑂(𝑛𝑘)

此方法只适用于 𝑘 ≪ 𝑛 的情况,因为当 𝑘 𝑛 比较接近时,其时间复杂度趋向于 𝑂(𝑛^ 2 ) ,非常耗时。 (当 𝑘 = 𝑛 时,我们可以得到完整的有序序列,此时等价于“选择排序”算法。 )

方法二:排序

我们可以先对数组 nums 进行排序,再返回最右边的 𝑘 个元素,时间复杂度取决于排序所使用的算法,最快为 𝑂(𝑛 log 𝑛)

显然,该方法“超额”完成任务了,因为我们只需找出最大的 𝑘 个元素即可,而不需要排序其他元素

方法三:最小堆法

1. 初始化一个小顶堆,其堆顶元素最小。

2. 先将数组的前 𝑘 个元素依次入堆。

3. 从第 𝑘 + 1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。

4. 遍历完成后,堆中保存的就是最大的 𝑘 个元素。

代码实现:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
 
typedef int DataType;
 
// 堆的结构
typedef struct Heap
{
  DataType* arr;
  int size;
  int capacity;
} HP;
 
// 交换两个元素
void Swap(DataType* x, DataType* y)
{
  DataType tmp = *x;
  *x = *y;
  *y = tmp;
}
// 向下调整堆
void AdjustDown(DataType* arr, int parent, int n)
{
  int child = parent * 2 + 1; // 左孩子
  while (child < n) {
    if (child + 1 < n && arr[child] > arr[child + 1])
    {
      child++;
    }
    if (arr[child] < arr[parent]) {
      Swap(&arr[child], &arr[parent]);
      parent = child;
      child = parent * 2 + 1;
    }
    else
    {
      break;
    }
  }
}
 
// 建小堆
void BuildMinHeap(int* arr, int k)
{
  for (int i = (k-1-1) / 2; i >= 0; i--)
  {
    AdjustDown(arr, i, k);
  }
}
 
// 查找Top-k元素
void FindTopK(DataType* arr, int n, int k) {
  if (k <= 0 || k > n)
  {
    printf("k的值非法\n");
    return;
  }
 
  //开辟所需小堆的内存
  int* minheap = (int*)malloc(sizeof(int) * k);
  if (minheap == NULL)
  {
    perror("malloc error");
    return;
  }
 
  // 将前k个元素放入小堆
  for (int i = 0; i < k; i++)
  {
    minheap[i] = arr[i];
  }
  BuildMinHeap(minheap, k);
 
  // 遍历剩余元素,更新小堆
  for (int i = k; i < n; i++)
  {
    if (arr[i] > minheap[0])
    {
      minheap[0] = arr[i]; // 替换堆顶
      AdjustDown(minheap, 0, k); // 重新调整堆
    }
  }
 
  // 输出Top-k元素
  printf("最大的前%d个元素为:\n", k);
  for (int i = 0; i < k; i++)
  {
    printf("%d ", minheap[i]);
  }
  printf("\n");
 
  // 释放内存
  free(minheap);
}
 
int main() {
  DataType array[] = {1,3,5,7,9,2,4,6,8,10};
  int n = sizeof(array) / sizeof(array[0]);
  int k = 3; // 查找前3个最大的元素
 
  FindTopK(array, n, k);
 
  return 0;
}

总共执行了 𝑛 轮入堆和出堆,堆的最大长度为 𝑘 ,因此时间复杂度为 𝑂(𝑛 log 𝑘) 。该方法的效率很高,当 𝑘 较小时,时间复杂度趋向 𝑂(𝑛) ;当 𝑘 较大时,时间复杂度不会超过 𝑂(𝑛 log 𝑛)

另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 𝑘 个元素的动态更新。

总结

堆是一种强大的数据结构,使用堆排序解决 Top-k 问题是一种高效且简洁的方法。通过维护一个最小堆,我们能够在遍历数据的同时有效地找出前 k 个最大元素。这种方法在实际应用中非常有用,特别是在处理大规模数据时。如果你对堆或其他相关数据结构有进一步的兴趣,欢迎留言讨论!

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=3avhxavk524g0 


相关文章
|
11天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
25 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
13天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
56 16
|
18天前
|
存储 消息中间件 NoSQL
Redis数据结构:List类型全面解析
Redis数据结构——List类型全面解析:存储多个有序的字符串,列表中每个字符串成为元素 Eelement,最多可以存储 2^32-1 个元素。可对列表两端插入(push)和弹出(pop)、获取指定范围的元素列表等,常见命令。 底层数据结构:3.2版本之前,底层采用**压缩链表ZipList**和**双向链表LinkedList**;3.2版本之后,底层数据结构为**快速链表QuickList** 列表是一种比较灵活的数据结构,可以充当栈、队列、阻塞队列,在实际开发中有很多应用场景。
|
1月前
|
存储 JavaScript 前端开发
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
67 1
|
18天前
|
存储 NoSQL 关系型数据库
Redis的ZSet底层数据结构,ZSet类型全面解析
Redis的ZSet底层数据结构,ZSet类型全面解析;应用场景、底层结构、常用命令;压缩列表ZipList、跳表SkipList;B+树与跳表对比,MySQL为什么使用B+树;ZSet为什么用跳表,而不是B+树、红黑树、二叉树
|
1月前
|
存储 算法
探索数据结构:分支的世界之二叉树与堆
探索数据结构:分支的世界之二叉树与堆
|
1月前
|
存储 算法 Java
【用Java学习数据结构系列】用堆实现优先级队列
【用Java学习数据结构系列】用堆实现优先级队列
29 0
|
1月前
|
存储 算法
【数据结构】二叉树——顺序结构——堆及其实现
【数据结构】二叉树——顺序结构——堆及其实现
|
1月前
【数据结构】大根堆和小根堆
【数据结构】大根堆和小根堆
27 0
|
14天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
90 9

推荐镜像

更多