当前位置: 首页 > news >正文

数据结构:优先队列 (Priority Queue)

目录

第一步:重新思考“队列”——优先级是关键

第二步:探索实现方式(发现问题,寻找更优解)

方案A:使用无序数组或链表

方案B:使用有序数组或链表

寻求平衡:是否存在更好的方案?

第三步:终极武器——堆 (Heap)

1. 堆的结构——“完全二叉树”

2. 堆的属性——“堆序”

第四步:一步步实现堆的操作

1. 优先队列的蓝图

2. 插入操作 (Insert)

3. 提取最大值操作 (Extract-Max)

总结


第一步:重新思考“队列”——优先级是关键

我们之前讨论的队列是“先进先出”(FIFO),所有元素一视同仁,唯一的评判标准是“来得早晚”。

但在现实世界中,很多情况并非如此:

  • 医院急诊室:医生不会按挂号顺序看病,而是优先处理生命垂危的病人。

  • 操作系统:会优先执行系统级的关键任务,而不是用户打开的计算器。

  • 打印任务:你可能希望先打印1页的文档,而不是排在它前面的500页的报告。

这些场景的共同点是:

每个元素都有一个“优先级”,我们总是需要先处理优先级最高的那个元素。

这就是优先队列的核心思想。它不再是 FIFO,而是 “最高优先级先出” (Highest Priority Out)

它的基本操作是:

  1. 插入 (Insert):将一个带优先级的元素放入队列。

  2. 提取最大/最小 (Extract-Max / Extract-Min):从队列中取出并移除优先级最高的元素。


第二步:探索实现方式(发现问题,寻找更优解)

我们如何实现这个“最高优先级先出”的结构呢?让我们用已知的数据结构来试试。假设数字越大,优先级越高。

方案A:使用无序数组或链表

最简单的想法,直接把元素存入一个数组或链表。

  • 插入 (Insert):非常简单,直接在数组末尾或链表尾部添加新元素。时间复杂度是 O(1)

  • 提取最大值 (Extract-Max)

因为元素是无序的,为了找到优先级最高的元素,我们必须遍历整个数组/链表,比较所有元素,找到最大值。时间复杂度是 O(N)

这个方案“入队”很快,“出队”很慢。

如果“出队”操作非常频繁(比如操作系统调度),那每次都遍历一遍是无法接受的。此方案否决。

方案B:使用有序数组或链表

为了让“出队”变快,我们可以时刻保持队列中的元素按优先级有序。

由于元素总是有序的,优先级最高的元素永远在固定的一端(比如数组的末尾)。我们直接取出即可。时间复杂度是 O(1)

  • 提取最大值 (Extract-Max)

  • 插入 (Insert):为了维持有序性,当一个新元素到来时,我们必须找到它在序列中正确的位置,然后把它插入进去。

    • 对于数组,找到位置(例如用二分查找 O(log N)),但插入需要把后面的所有元素都向后移动一位,这个移动操作是 O(N)

    • 对于链表,需要遍历找到插入点,也是 O(N)

这个方案“出队”很快,“入队”很慢。

如果“入队”操作非常频繁(比如日志系统收到大量不同优先级的日志),那每次都为了插入而移动大量元素也是无法接受的。此方案亦否决。

寻求平衡:是否存在更好的方案?

我们发现,方案A和B是两个极端:

一个牺牲了出队效率,另一个牺牲了入队效率。是否存在一种“中庸之道”,能让入队和出队都比较快,比如都是 O(log N)?

答案是肯定的。我们需要一个数据结构,它:

  1. 查找最大值很快,最好是 O(1)。

  2. 在插入或删除后,调整结构的速度比 O(N) 快得多。

这个完美的数据结构,就是 “堆” (Heap)


第三步:终极武器——堆 (Heap)

“堆”是一种非常巧妙的、基于树的结构,它专门为优先队列而生。

1. 堆的结构——“完全二叉树”

首先,堆在逻辑上是一棵完全二叉树 (Complete Binary Tree)

“完全”意味着:除了最底层,其他层都是满的,并且最底层的节点都尽可能地靠左排列。

这个结构有一个巨大的优势:

可以用一个普通的数组来高效地表示,而不需要任何指针!

假设一个节点在数组中的下标是 i

  • 它的父节点下标是:floor((i - 1) / 2)

  • 它的左子节点下标是:2 * i + 1

  • 它的右子节点下标是:2 * i + 2 这个映射关系是堆实现的基础。

2. 堆的属性——“堆序”

光有结构还不够,堆还有一个核心规则,称为堆序属性 (Heap Property)。分为两种:

任何一个父节点的值,都大于或等于它的所有子节点的值。

这条规则的直接推论是:堆顶(数组的第0个元素)永远是整个堆中的最大值。

  • 最大堆 (Max-Heap)

  • 最小堆 (Min-Heap):任何一个父节点的值,都小于或等于它的所有子节点的值。

堆只保证“父子”关系,不保证“兄弟”关系。它是一种“部分有序”,而不是“完全有序”,正是这种“不完全”带来了效率的提升。

现在,我们的目标明确了:用数组实现一个最大堆,来作为优先队列的底层结构。


第四步:一步步实现堆的操作

1. 优先队列的蓝图

我们需要一个数组来存数据,一个变量记录容量,一个变量记录当前堆中有多少个元素。

【代码实现 1:结构体定义】

#include <stdio.h>
#include <stdlib.h>typedef struct {int* data;     // 指向数据数组int capacity;  // 数组的总容量int size;      // 当前堆中的元素数量
} PriorityQueue;// 为了方便,写一个交换函数
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}

2. 插入操作 (Insert)

当一个新元素要插入时,我们如何维护堆的结构和属性?

  1. 结构维护:为了保持完全二叉树的结构,我们先把新元素放在数组的最后(即 data[size] 的位置),然后 size 加一。

  2. 属性恢复:新加进来的元素可能会破坏最大堆的属性(比如一个很大的数被放在了叶子节点)。我们需要让这个新元素“上浮” (Sift Up / Heapify Up) 到它正确的位置。

“上浮”的过程

  • 将新元素和它的父节点比较。

  • 如果它比父节点大,就和父节点交换位置。

  • 重复这个过程,不断地与新的父节点比较、交换,直到它不再比父节点大,或者它已经到达了堆顶。

【代码实现 2:插入与上浮】

// "上浮"操作的实现
// heap: 堆的指针
// index: 需要上浮的节点的起始下标
void heapifyUp(PriorityQueue* pq, int index) {// 如果节点已经在堆顶,或者它的值不比父节点大,就停止// 父节点下标: (index - 1) / 2while (index > 0 && pq->data[index] > pq->data[(index - 1) / 2]) {// 和父节点交换swap(&pq->data[index], &pq->data[(index - 1) / 2]);// 更新当前节点的下标为它父节点的下标,继续向上检查index = (index - 1) / 2;}
}// 插入操作
void insert(PriorityQueue* pq, int value) {if (pq->size >= pq->capacity) {printf("插入失败:优先队列已满。\n");return;}// 1. 将新元素放在数组末尾pq->data[pq->size] = value;// 2. 对这个新元素执行“上浮”操作heapifyUp(pq, pq->size);// 3. 堆的元素数量加一pq->size++;
}

3. 提取最大值操作 (Extract-Max)

如何取出并移除最大值,同时维持堆的结构和属性?

  1. 获取最大值:最大值永远在堆顶 (data[0]),我们先把它取出来。

  2. 结构维护:堆顶空了,形成一个“窟窿”。为了维持完全二叉树,我们用一个元素来填补它。用哪个?用数组的最后一个元素。我们把它挪到堆顶,然后 size 减一。

  3. 属性恢复:这个从末尾挪上来的元素几乎肯定会破坏最大堆属性(因为它一般很小)。我们需要让这个新堆顶“下沉” (Sift Down / Heapify Down) 到它正确的位置。

“下沉”的过程

  • 将当前节点和它的左右子节点比较。

  • 找出它两个子节点中值较大的那个。

  • 如果当前节点比它较大的子节点还要小,就和那个较大的子节点交换位置。

  • 重复这个过程,直到它的值大于它所有的子节点,或者它已经到达了叶子节点。

【代码实现 3:提取最大值与下沉】

// "下沉"操作的实现
// index: 需要下沉的节点的起始下标
void heapifyDown(PriorityQueue* pq, int index) {int maxIndex = index;while (1) {int leftChild = 2 * index + 1;int rightChild = 2 * index + 2;// 找出当前节点和它的左右孩子中,值最大的那个节点的下标if (leftChild < pq->size && pq->data[leftChild] > pq->data[maxIndex]) {maxIndex = leftChild;}if (rightChild < pq->size && pq->data[rightChild] > pq->data[maxIndex]) {maxIndex = rightChild;}// 如果值最大的节点就是当前节点自己,说明它已经到了正确的位置,下沉结束if (maxIndex == index) {break;}// 否则,和值更大的子节点交换,并继续向下检查swap(&pq->data[index], &pq->data[maxIndex]);index = maxIndex;}
}// 提取最大值操作
int extractMax(PriorityQueue* pq) {if (pq->size <= 0) {fprintf(stderr, "提取失败:优先队列为空。\n");return -1; // 错误值}// 1. 堆顶就是最大值int maxValue = pq->data[0];// 2. 将最后一个元素挪到堆顶pq->data[0] = pq->data[pq->size - 1];pq->size--;// 3. 对新的堆顶执行“下沉”操作if (pq->size > 0) {heapifyDown(pq, 0);}return maxValue;
}

总结

实现方式插入操作复杂度提取最大值复杂度适用性
无序数组O(1)O(N)入队远多于出队
有序数组O(N)O(1)出队远多于入队
堆 (Heap)O(log N)O(log N)完美平衡,适用各种场景

通过一步步推导,我们发现,简单的数组和链表都无法同时高效地支持优先队列的两个核心操作。为了寻求平衡,我们引入了“堆”这个专门为此设计的结构。

它通过维护“完全二叉树”的结构和“最大/最小堆”的属性,巧妙地利用“上浮”和“下沉”操作,在 O(log N) 的时间内完成了插入和删除,是实现优先队列最正统、最高效的方式。

http://www.dtcms.com/a/331723.html

相关文章:

  • 解剖HashMap的put <五> JDK1.8
  • 微信公众号推送文字消息与模板消息
  • 字节跳动 VeOmni 框架开源:统一多模态训练效率飞跃!
  • JAVA 抽象类可以实例化吗
  • 机器学习概述(一)
  • Spring Cloud系列—Alibaba Sentinel熔断降级
  • 第一章 随机事件与概率
  • 前端性能优化移动端网页滚动卡顿与掉帧问题实战
  • 前端开发常见问题及解决方案全解析
  • 解剖HashMap的put流程 <一> (JDK 1.8)
  • 22.Linux samba服务
  • USB 3.0 link command 定义
  • 知识的本质
  • 数域筛法GNFS---C语言实现
  • 20道CSS相关前端面试题及答案
  • Elasticsearch:如何使用 Qwen3 来做向量搜索
  • css中container和media的用法和区别
  • SRWare Iron:隐私保护与高效浏览的完美结合
  • C++ mutex的实现源码分析
  • Xsens动作捕捉与AI驱动人形机器人训练革新
  • WVP和ZLM部署与接入NVR指南环境准备
  • 【React】hooks 中的闭包陷阱
  • 三轴云台之脉宽调制技术篇
  • Qt基本槽
  • 链游(GameFi)开发破局:如何平衡可玩性与经济模型可持续性?
  • GraphRAG:AI理解复杂知识的未知领域,开启探索之旅
  • 《Python函数:从入门到精通,一文掌握函数编程精髓》
  • MySQL主从原理
  • Linux 文件系统简介
  • 解析 TrueType/OpenType 格式的可变字体(Variable Font),提取其所有命名实例(Named Instances) 的名称信息