【C# 数据结构】优先队列PriorityQueue
背景
计算机系统中经常会遇到这样一类问题:前一个任务已经执行完成,需要在待执行任务中挑选一个新的任务执行。最简单的方法就是将所有的任务排成一个队列,按照队列的先进先出(FIFO)的策略挑选要执行的任务。这种策略虽然保证了所有的任务都能被执行,但是往往会导致执行时间短的或者紧急度高的任务在队列中等待时间较长而导致效率低下。另一种策略是为每个任务安排一个优先级,每次挑选任务时只需要从队列中取出优先级最高的任务执行即可。实现该策略需要借助一种特殊的数据结构:优先级队列(PriorityQueue)。
优先级队列(PriorityQueue)
优先级队列虽然也叫队列,但是和普通的队列还是有差别的。普通队列出队顺序只取决于入队顺序,而优先级队列的出队顺序总是按照元素自身的优先级。换句话说,优先级队列是一个自动排序的队列。元素自身的优先级可以根据入队时间,也可以根据其他因素来确定,因此非常灵活。
优先级队列的内部实现有很多种,例如有序数组、无序数组和堆等。但是无论哪种实现,优先级队列必须实现以下两种方法:insert和delete。insert方法是将带优先级的元素插入优先级队列中(类似队列的enQueue方法);delete方法是从优先级队列中取出最高优先级(或最低优先级)的元素并在队列中删除该元素(类似队列的出队)。
//Go语言表示 type PriorityQueue struct { //隐藏实现 } //以int为例,值的大小即代表元素优先级的高低(下同) func (pq *PriorityQueue)Insert(val int) //插入带优先级的元素 func (pq *PriorityQueue)Delete() int //从优先级队列中取出优先级最高的元素
针对不同实现,优先级队列的插入和删除方法的效率是不同的。
有序数组
下面的代码展示了使用有序数组实现优先级队列的基本功能。
//Go语言表示 type PriorityQueue struct { orderArray []int //这里使用int类型为例 } func (pq *PriorityQueue)Insert(val int) { (*pq).orderArray = append((*pq).orderArray, val) //将元素插入有序数组尾部 sortSlice((*pq).orderArray, func (i,j int){ return (*pq).orderArray[i] < (*pq).orderArray[j] }) //对数组重新排序 } func (pq *PriorityQueue)Delete() int { l := len((*pq).orderArray) ret := (*pq).orderArray[l-1] //有序数组是递增排序,所以最后一个元素是优先级最高的元素 (*pq).orderArray = (*pq).orderArray[:l-1] //将最后一个元素从切片中删除 return ret //返回优先级最高的元素 }
插入操作涉及对数组排序,因此是O(n)【只需要对一个元素进行排序】;而删除操作的复杂度是O(1)。
同样也可以使用无序数组实现:插入操作时直接将元素插入无序数组的尾部,删除操作需要对数组进行排序并挑出最大的元素。
算法复杂度如下表所示:
实现方式 | 插入操作时间复杂度 | 删除操作时间复杂度 |
---|---|---|
有序数组 | O(n) | O(1) |
无序数组 | O(1) | O(n) |
所以,无序数组可以认为是优先级队列(有序数组实现版)的惰性实现:直到要吐数据时再排序。
下面介绍一种特殊的数据结构-堆,可以提高优先级队列的性能。
堆
堆的定义
一般情况下,堆指的是二叉堆,而二叉堆是一棵完全二叉树,树中每个节点存储一个元素,并且具有以下性质:
- 树中所有元素都小于(或大于)它的所有后裔,最小(或最大)的元素是根元素【也叫堆序性】;
- 堆总是一棵完全树,即除了最底层,其他层的节点都被元素填满,且最底层尽可能地从左到右填入。
所以堆必须是具有堆序性的完全二叉树。
以上两个例子中的数据结构都不是堆。例1中的树是非完全二叉树;例2中的树虽然是完全二叉树,但是不具备堆序性。
根节点最大的堆叫大根堆,根节点最小的堆是小根堆。为了方便,下面以小根堆为例进行讨论(大根堆实际上也一样)。
堆的实现
存储结构
为了效率,二叉堆一般使用数组作为内部存储结构。将逻辑上的完全二叉树映射到一维数组中,数组中第一个元素存储树的根元素。为了方便,我们假设数据下标为0的位置不存储元素,从下标为1的位置开始,如图所示:
图中是一个包含6个元素的小根堆,上半部分是树状的逻辑表示,下半部分是该小根堆在数组中存储结构。数组中下标为k的元素的父节点(假设存在的话)的下标为[k/2](向下取整),其子节点(假设子节点存在)的下标为2k和2k+1。
算法实现(用Golang实现)
根据上面的讨论,我们使用数组来实现堆的内部存储结构:
//Go语言表示 type Heap struct { array []int }
实现堆的数据结构分为以下几个步骤:
- 实现最关键的插入和删除操作;
- 实现其他可选操作:如Peek()等方法;
- 实现堆的初始化操作。
插入和删除操作
插入操作:
向堆中插入一个元素,并且仍需保持堆的有序性。暴力方式是遍历堆的结构找到元素应该插入的位置后插入,但是该方式需要保证树的完全性和堆序性,效率低下。因此使用另外一个思路:直接将元素追加到树的尾部(此时保证树是完全二叉树),然后使用上浮操作将插入的元素上浮到合适的位置以保证堆序性。
上浮操作就是递归比较元素和其父节点的大小:如果元素比父节点小,则交换元素和父节点;如果元素比父节点大,则不做任何操作。如此循环直到元素找到合适的位置,或者插入元素已经换到根元素的位置。
具体过程如图所示:
代码实现:
//Go语言表示 //实现辅助函数Less、Swap、Up //Less 实现堆中两个元素比较大小功能 func (h Heap)Less(i, j) bool { return h.array[i] < h.array[j] //这里实现的是小根堆;如果要实现大根堆,则只需将'<'改成'>' } //Swap 交换i和j位置上的元素 func (h Heap)Swap(i,j int){ h.array[i], h.array[j] = h.array[j], h.array[i] } //Up 上浮函数:对堆中的元素进行上浮操作 func (h *Heap)Up(i int) { for { parent := i/2 //向下取整 if (i == 1) || ((*h).Less(parent, i)) { //表示已经循环到根节点或者父节点比i节点小,此时退出上浮 break } (*h).Swap(parent, i) //交换parent和i元素 i = parent } } //实现插入函数Insert //Insert 实现元素插入操作,并保证插入后的数据结构仍然是一个堆结构 func (h *Heap)Insert(x int){ (*h).array = append((*h).array, x) //将元素插入到树的结尾处 h.Up(len((*h).array) - 1) //从最后一个元素开始执行上浮操作 }
复杂度分析:插入操作的复杂度主要取决于上浮操作执行的次数,最差的情况下需要执行log(n)次【n为堆中元素个数,log(n)即为堆的高度】。因此插入操作的算法复杂度为O(log(n))。
删除操作:
删除操作是将堆中最小的元素从堆结构中删除,并返回该元素。由于根元素已经是堆中最小的元素,因此直接将根元素从堆中删除并返回根元素即可。但是具体实现有两种方法:
- 直接从堆中删除根节点,然后将两个子堆合并成新堆;
- 将根元素和堆中最后一个元素互换位置,然后删除最后一个元素,并对堆中新的元素执行下沉操作直到合适的位置。
由于合并两个堆的算法比较复杂,因此选择第二种方法实现删除操作。具体过程如下图所示:
代码实现如下:
//Go语言表示 //实现辅助函数Down //Down 实现堆中下沉操作 func (h *Heap)Down(i int){ N := len((*h).array) //堆的大小 for (2*i <= N) { j := 2 * i //j表示i的子节点 if (j < N) && (*h).Less(j+1, j) { //找到两个子节点(j和j+1)中那个较小的 j++ } if (*h).Less(i, j) { //i比子节点j小,停止下沉 break } (*h).Swap(i, j) i = j } } //Delete 实现删除操作 func (h *Heap)Delete() int{ ret := (*h).array[1] N := len((*h).array) (*h).Swap(1, N-1) //交换根节点和堆中最后一个元素 (*h).array = (*h).array[:N-1] //删除最后一个元素 h.Down(1) //从根节点开始执行下沉操作,以恢复堆序性 return ret }
复杂度分析:删除操作的复杂度主要取决于下沉操作执行的次数,最差的情况下也是需要执行log(n)次。因此删除操作的算法复杂度为O(log(n))。
其他辅助函数
获取堆的大小
//Go语言表示 //Len 返回堆的大小 func (h Heap)Len()int { return len(h.array) }
获取堆顶元素
//Go语言表示 //Peek 获取当前堆顶元素 func (h *Heap)Peek() int { return (*h).array[1] }
删除特定元素
//Go语言表示 //Remove 实现删除某个特定位置上的元素 func (h *Heap)Remove(i int) { N := (*h).Len() (*h).Swap(i, N-1) (*h).array = (*h).array(:N-1) h.Down(i) } //RemoveSpec 删除某个特定值的元素 func (h *Heap)RemoveSpec(v int) { for i, n := range (*h).array { if n == v { //遍历堆找到和要被删除的元素相同的元素 h.Remove(i) //删除该位置上的元素 return } } }
堆的初始化
现在我们来讨论以下堆的初始化。堆的初始化即如何从给定的数组中构造出一个堆来。这里有三种方法。
最简单的就是构造一个空堆,然后遍历数组,将数组元素不断添加到这个空堆中。空间复杂度为2个数组长度,时间复杂度为O(n*log(n))。因为每次向堆中插入时的复杂度为O(log(n)),总共有n次迭代。
第二种方法就是不是新的空堆,直接将原数组当作一个完全二叉树,然后从数组左侧开始不停执行上浮操作,直至遍历完整个数组。该方法直接在原数组上执行,因此空间复杂度为1个数组长度,时间复杂度仍为O(n*log(n))。过程如下图所示:
第三种方法:从数组的右侧开始不停执行下沉操作直至遍历至数组左侧为止。该方法的基本思想是若一棵树的左右两个子树已经是堆序状态了,那对整棵树只需要做一次根节点和左右两子树的根节点之间的修复操作即可保证整棵树都是堆有序的。同时对于叶子节点,只有其本身无需做修复操作,故可以只需要对数组中的一半元素执行下沉操作即可。过程如下图所示:
算法复杂度
根据上面的介绍堆的相关复杂度如下表所示:
操作实现方式 | 空间复杂度 | 时间复杂度 |
---|---|---|
堆初始化筛选 | n | O(log(n)) |
堆插入操作 | - | O(log(n)) |
堆删除操作 | - | O(log(n)) |
堆Peek方法 | - | O(1) |
该方法也成为筛选方法,其算法复杂度:空间复杂度为1个数组长度,时间复杂度为O(N)。确实要比插入方法优秀。
C# 优先级队列
.net 6.0出来新的泛型集合类型-优先队列
它是如何工作的?
它作为命名空间“System.Collections.Generic”的一部分提供,因为它是泛型集合的一部分。类名是“优先级队列”。泛型类型为“优先级队列<TElement, TPriority>”。
有几种常用方法可用,
void Enqueue(TElement element, TPriority priority); |
基于优先级添加元素 |
|
获取元素而不从队列中删除元素。 |
|
根据优先级从队列中获取元素。首先从队列中降低优先级。对于相同的优先级,不能保证顺序。 |
TElement EnqueueDequeue(TElement element, TPriority priority); |
将根据优先级获取元素,同时添加元素。 |
void EnqueueRange(IEnumerable<(TElement Element, TPriority Priority)> values); |
一次插入大量元素。我们应该使用元组类型进行插入。 |
void EnqueueRange(IEnumerable<TElement> values, TPriority priority); |
一次插入大量元素。所有这些都具有相同的优先级。 |
代码演练
在这里,我们将构建一个车辆服务队列。这将包括涵盖从一般服务到车辆维修队列中的各种服务,每次我们通过汽车严重程度的优先级时。
注意:
当优先级相同时,不保证输出顺序。
using System; using System.Collections.Generic; namespace TutorialApp.ConsoleApp { class Program { static void Main(string[] args) { PriorityQueue < string, int > vehicleRepairQueue = new PriorityQueue < string, int > (); vehicleRepairQueue.Enqueue("Mirror Damaged Car", 3); vehicleRepairQueue.Enqueue("Wash Car", 10); vehicleRepairQueue.Enqueue("Severe Damaged Car", 1); System.Console.WriteLine("\nVehicle Repair Queue:\n"); while (vehicleRepairQueue.Count > 0) { System.Console.WriteLine(vehicleRepairQueue.Dequeue()); } /* Output: Severe Damaged Car Mirror Damaged Car Wash Car */ System.Console.WriteLine("\nVehicle Service Queue:\n"); vehicleRepairQueue.Enqueue("General Service Sedan Car", 5); var lastDequeue = vehicleRepairQueue.EnqueueDequeue("General Service Suv Car", 7); System.Console.WriteLine(lastDequeue); // Output: General Service Sedan Car var severeDamagedVehicles = new List < string > { "Car Damaged Sedan", "Car Damaged SUV", "Car Damaged Hatchback" }; vehicleRepairQueue.EnqueueRange(severeDamagedVehicles, 1); System.Console.WriteLine("\nVehicle Bulk Damage Queue:\n"); while (vehicleRepairQueue.Count > 0) { System.Console.WriteLine(vehicleRepairQueue.Dequeue()); } /* Output: Car Damaged Sedan Car Damaged Hatchback Car Damaged SUV General Service Suv Car */ System.Console.WriteLine("\nBulk Service Queue:\n"); var bulkServiceVehiclesRequest = new List < (string, int) > { ("Tyre Change Request 1", 3), ("Tyre Change Request 2", 3), ("Tyre Change Request 3", 3), ("Tyre Change Request 4", 3), ("Severe Damaged Car", 1) }; vehicleRepairQueue.EnqueueRange(bulkServiceVehiclesRequest); while (vehicleRepairQueue.Count > 0) { System.Console.WriteLine(vehicleRepairQueue.Dequeue()); } /* Output: Severe Damaged Car Tyre Change Request 1 Tyre Change Request 4 Tyre Change Request 3 Tyre Change Request 2 */ } } }