数据结构:优先队列 (Priority Queue)
目录
第一步:重新思考“队列”——优先级是关键
第二步:探索实现方式(发现问题,寻找更优解)
方案A:使用无序数组或链表
方案B:使用有序数组或链表
寻求平衡:是否存在更好的方案?
第三步:终极武器——堆 (Heap)
1. 堆的结构——“完全二叉树”
2. 堆的属性——“堆序”
第四步:一步步实现堆的操作
1. 优先队列的蓝图
2. 插入操作 (Insert)
3. 提取最大值操作 (Extract-Max)
总结
第一步:重新思考“队列”——优先级是关键
我们之前讨论的队列是“先进先出”(FIFO),所有元素一视同仁,唯一的评判标准是“来得早晚”。
但在现实世界中,很多情况并非如此:
-
医院急诊室:医生不会按挂号顺序看病,而是优先处理生命垂危的病人。
-
操作系统:会优先执行系统级的关键任务,而不是用户打开的计算器。
-
打印任务:你可能希望先打印1页的文档,而不是排在它前面的500页的报告。
这些场景的共同点是:
每个元素都有一个“优先级”,我们总是需要先处理优先级最高的那个元素。
这就是优先队列的核心思想。它不再是 FIFO,而是 “最高优先级先出” (Highest Priority Out)。
它的基本操作是:
-
插入 (Insert):将一个带优先级的元素放入队列。
-
提取最大/最小 (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)?
答案是肯定的。我们需要一个数据结构,它:
-
查找最大值很快,最好是 O(1)。
-
在插入或删除后,调整结构的速度比 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)
当一个新元素要插入时,我们如何维护堆的结构和属性?
-
结构维护:为了保持完全二叉树的结构,我们先把新元素放在数组的最后(即
data[size]
的位置),然后size
加一。 -
属性恢复:新加进来的元素可能会破坏最大堆的属性(比如一个很大的数被放在了叶子节点)。我们需要让这个新元素“上浮” (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)
如何取出并移除最大值,同时维持堆的结构和属性?
-
获取最大值:最大值永远在堆顶 (
data[0]
),我们先把它取出来。 -
结构维护:堆顶空了,形成一个“窟窿”。为了维持完全二叉树,我们用一个元素来填补它。用哪个?用数组的最后一个元素。我们把它挪到堆顶,然后
size
减一。 -
属性恢复:这个从末尾挪上来的元素几乎肯定会破坏最大堆属性(因为它一般很小)。我们需要让这个新堆顶“下沉” (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) 的时间内完成了插入和删除,是实现优先队列最正统、最高效的方式。