【数据结构】堆的应用 -- 堆排序和TopK问题

简介: 【数据结构】堆的应用 -- 堆排序和TopK问题

前言

在开始这一节的内容之前,我们先来回顾一下与堆相关的重点:

1、堆是二叉树顺序存储结构的一个具体体现,堆中每个节点的值总是不大于或不小于其父节点的值 (大堆/小堆),堆总是一棵完全二叉树,堆使用顺序表存储元素;


2、堆中父节点下标的计算公式:(n-1)/2,左孩子下标:n*2+1,右孩子下标:n*2+2;


3、堆只能在尾部插入数据,且插入数据后需要保证堆的结构,所以在插入数据之后我们要进行向上调整,向上调整的时间复杂度为O(log N) (log 以2为底) ;


4、堆只能在头部删除数据,且删除数据后需要保证堆的结构,又因为顺序表头删需要挪动数据,效率很低,所以在堆删除数据会先将堆顶和堆尾的数据进行交换,然后让 size–,再进行向下调整,向下调整的时间复杂度为O(log N) (log 以2为底) 。

堆排序

堆排序是选择排序的一种,它的时间复杂度为 O(N*logN),空间复杂度为 O(1)。

1、建堆

堆排序的第一步就是建堆,建堆有两种方法:向上调整建堆和向下调整建堆。

向上调整建堆: 把数组的第一个元素作为堆的根节点,然后在堆尾依次插入其余元素,每插入一个元素就向上调整一次,从而保证堆的结构;

image.png

向上调整建堆的时间复杂度: 由于堆是完全二叉树,而满二叉树是完全二叉树的一种,所以此处为了简化计算,使用满二叉树来得出时间复杂度 (时间复杂度本身看的就是近似值,多几个节点不影响最终结果):

2020062310470442.png

如上图,我们把每一次的节点个数除以每一个节点需要调整的次数,最后再求和,就可以得到一共需要调整的次数;然后再根据满二叉树节点总数与树的高度的关系将表达式中的h替换掉,最终可以得到向上调整建堆的时间复杂度为:O(N*logN)

向下调整建堆: 从倒数第一个非叶子节点 (即最后一个叶节点的父节点) 开始向下调整,直到调整到根。

image.png

向下调整建堆的时间复杂度: 

2020062310470442.png


如上图,向下调整建堆的时间复杂度为:O(N)

综合上面两种建堆方法,建堆的时间复杂度为:O(N)

2、选数

现在我们建堆工作已经完成了,接下来就是选数,假设现在我们要排升序,那么方法一共有三种:


1、建小堆,开辟一个和原数组同等大小的新数组,每次取出堆顶的元素 (最小的元素) 放在新数组中,然后挪动数组中的数据,最后排好序以后再将新数组中的数据覆盖至原数组;


缺点:每次挪动数据的效率很低,且挪动数据会造成堆中其余元素父子关系混乱,需要重新建堆,而建堆的时间复杂度也是O(N),所以二者嵌套后时间复杂度:O(N^2),空间复杂度:O(N);


2、也是建小堆,不过这次我们借鉴堆 pop 数据的方法,先将堆顶的元素放入新数组中,然后交换堆顶和堆尾的元素,之后再向下调整数组中前 n-1 个数据,直到排好序,最后将排好序以后再将新数组中的数据覆盖至原数组;


缺点:虽然此方法可以让我们每次都拿到数组中最小的元素,但是需要开辟额外的空间,时间复杂度:O(N*logN),空间复杂度:O(N);


3、建大堆,先将堆顶和堆尾的数据进行交换,使得数组中最大的元素处于数组末尾,然后向下调整前 n-1 个元素,使得次大的数据位于堆顶,最后重复前面的步骤,把次大的数据存放到最大的数据之前,直到数组有序;


优点:没有额外的空间消耗,且效率达到了 O(N*logN);


综合上面三种选数的方法,选数的时间复杂度为:O(N*logN),空间复杂度为:O(1);

3、代码

//交换两个节点
void Swap(int* p1, int* p2)
{
  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
//向上调整 -- 大堆
void AdjustUp(int a[], int child)
{
  int parent = (child - 1) / 2;  //找出父节点
  while (child > 0)  //当调整到根节点时不再调整
  {
    if (a[parent] < a[child])
    {
      Swap(&a[parent], &a[child]);
    }
    else
    {
      break;
    }
    //迭代
    child = parent;
    parent = (child - 1) / 2;
  }
}
//向下调整 -- 大堆
void AdjustDown(int a[], int n, int parent)
{
  int maxchild = parent * 2 + 1;  //找到左孩子(左孩子+1得到右孩子)
  while (maxchild < n)  //调整到数组尾时不再调整
  {
    if (maxchild + 1 < n && a[maxchild + 1] > a[maxchild])
    {
      maxchild += 1;
    }
    if (a[parent] < a[maxchild])
    {
      Swap(&a[parent], &a[maxchild]);
    }
    else
    {
      break;
    }
    //迭代
    parent = maxchild;
    maxchild = parent * 2 + 1;
  }
}
void HeapSort(int a[], int n)
{
  //建堆 -- 向上调整建堆:O(N*logN)
  //int i = 1;
  //for (i = 1; i < n; i++)
  //{
  //  AdjustUp(a, i);
  //}
  //建堆 -- 向下调整建堆:O(N)
  for (int i = (n - 1 - 1) / 2; i >= 0; i--)  //n-1找到最后一个叶节点,该节点-1/2找到倒数第一个父节点
  {
    AdjustDown(a, n, i);
  }
  //排序 -- 升序(建大堆,向下调整):O(N*logN)
  for (int i = n - 1; i > 0; i--)
  {
    Swap(&a[i], &a[0]);  //交换堆尾和堆顶的元素
    AdjustDown(a, i, 0);  //向下调整
  }
}
int main()
{
  int a[] = { 15, 1, 19, 25, 8, 34, 65, 4, 27, 7 };
  int n = sizeof(a) / sizeof(a[0]);
  //堆排序
  HeapSort(a, n);
  for (int i = 0; i < n; i++)
  {
    printf("%d ", a[i]);
  }
  return 0;
}

2020062310470442.png

TopK 问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大;比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。


对于Top-K问题,能想到的最简单直接的方式就是排序,但是,如果数据量非常大,排序就不太可取了 (数据都不能一下子全部加载到内存中),最佳的方式就是用堆来解决,基本思路如下:


第一步:用数据集合中前K个元素来建堆 – 前k个最大的元素,则建小堆;前k个最小的元素,则建大堆;


第二步:用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素;

//交换两个节点
void Swap(int* p1, int* p2)
{
  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
//向下调整 -- 小堆
void AdjustDown(int a[], int n, int parent)
{
  int minchild = parent * 2 + 1;  //找到左孩子(左孩子+1得到右孩子)
  while (minchild < n)  //调整到数组尾时不再调整
  {
    if (minchild + 1 < n && a[minchild + 1] < a[minchild])
    {
      minchild += 1;
    }
    if (a[parent] > a[minchild])
    {
      Swap(&a[parent], &a[minchild]);
    }
    else
    {
      break;
    }
    //迭代
    parent = minchild;
    minchild = parent * 2 + 1;
  }
}
int* TopK(int a[], int n, int k)
{
  //开辟K个元素的空间
  int* minHeap = (int*)malloc(sizeof(int) * k);
  if (minHeap == NULL)
  {
    perror("malloc fail");
    return NULL;
  }
  //将数组前K个元素拷贝到新空间
  for (int i = 0; i < k; i++)
  {
    minHeap[i] = a[i];
  }
  //建小堆 -- 向下调整建堆:O(N)
  for (int i = (k - 1 - 1) / 2; i >= 0; i--)  //n-1找到最后一个叶节点,该节点-1/2找到倒数第一个父节点
  {
    AdjustDown(minHeap, k, i);
  }
  //取后N-k个元素与堆顶元素比较,如果大于堆顶元素,就入堆
  for (int i = k; i < n; i++)
  {
    if (minHeap[0] < a[i])
    {
      minHeap[0] = a[i];
      AdjustDown(minHeap, k, 0);
    }
  }
  return minHeap;
}
int main()
{
  int a[] = { 15, 1, 19, 25, 8, 34, 65, 4, 27, 7 };
  int n = sizeof(a) / sizeof(a[0]);
  //TopK问题 -- 前K个最大的元素
  int k = 3;
  int* ret = TopK(a, n, k);
  for (int i = 0; i < k; i++)
  {
    printf("%d ", ret[i]);
  }
  free(ret);
  ret = NULL;
  return 0;
}

2020062310470442.png



相关文章
|
1月前
|
消息中间件 缓存 NoSQL
Redis各类数据结构详细介绍及其在Go语言Gin框架下实践应用
这只是利用Go语言和Gin框架与Redis交互最基础部分展示;根据具体业务需求可能需要更复杂查询、事务处理或订阅发布功能实现更多高级特性应用场景。
192 86
|
3月前
|
存储 监控 安全
企业上网监控系统中红黑树数据结构的 Python 算法实现与应用研究
企业上网监控系统需高效处理海量数据,传统数据结构存在性能瓶颈。红黑树通过自平衡机制,确保查找、插入、删除操作的时间复杂度稳定在 O(log n),适用于网络记录存储、设备信息维护及安全事件排序等场景。本文分析红黑树的理论基础、应用场景及 Python 实现,并探讨其在企业监控系统中的实践价值,提升系统性能与稳定性。
86 1
|
3月前
|
存储 监控 算法
公司员工泄密防护体系中跳表数据结构及其 Go 语言算法的应用研究
在数字化办公中,企业面临员工泄密风险。本文探讨使用跳表(Skip List)数据结构优化泄密防护系统,提升敏感数据监测效率。跳表以其高效的动态数据处理能力,为企业信息安全管理提供了可靠技术支持。
88 0
|
8月前
|
DataX
☀☀☀☀☀☀☀有关栈和队列应用的oj题讲解☼☼☼☼☼☼☼
### 简介 本文介绍了三种数据结构的实现方法:用两个队列实现栈、用两个栈实现队列以及设计循环队列。具体思路如下: 1. **用两个队列实现栈**: - 插入元素时,选择非空队列进行插入。 - 移除栈顶元素时,将非空队列中的元素依次转移到另一个队列,直到只剩下一个元素,然后弹出该元素。 - 判空条件为两个队列均为空。 2. **用两个栈实现队列**: - 插入元素时,选择非空栈进行插入。 - 移除队首元素时,将非空栈中的元素依次转移到另一个栈,再将这些元素重新放回原栈以保持顺序。 - 判空条件为两个栈均为空。
|
11月前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
908 9
|
11月前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
235 59
|
4月前
|
编译器 C语言 C++
栈区的非法访问导致的死循环(x64)
这段内容主要分析了一段C语言代码在VS2022中形成死循环的原因,涉及栈区内存布局和数组越界问题。代码中`arr[15]`越界访问,修改了变量`i`的值,导致`for`循环条件始终为真,形成死循环。原因是VS2022栈区从低地址到高地址分配内存,`arr`数组与`i`相邻,`arr[15]`恰好覆盖`i`的地址。而在VS2019中,栈区先分配高地址再分配低地址,因此相同代码表现不同。这说明编译器对栈区内存分配顺序的实现差异会导致程序行为不一致,需避免数组越界以确保代码健壮性。
74 0
栈区的非法访问导致的死循环(x64)
232.用栈实现队列,225. 用队列实现栈
在232题中,通过两个栈(`stIn`和`stOut`)模拟队列的先入先出(FIFO)行为。`push`操作将元素压入`stIn`,`pop`和`peek`操作则通过将`stIn`的元素转移到`stOut`来实现队列的顺序访问。 225题则是利用单个队列(`que`)模拟栈的后入先出(LIFO)特性。通过多次调整队列头部元素的位置,确保弹出顺序符合栈的要求。`top`操作直接返回队列尾部元素,`empty`判断队列是否为空。 两题均仅使用基础数据结构操作,展示了栈与队列之间的转换逻辑。
|
9月前
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
356 77
|
8月前
|
算法 调度 C++
STL——栈和队列和优先队列
通过以上对栈、队列和优先队列的详细解释和示例,希望能帮助读者更好地理解和应用这些重要的数据结构。
184 11

热门文章

最新文章