# 三、堆和优先级队列
## 概述
堆数据结构提供了两种简单的行为:
1. 允许向集合中添加值。
2. 根据是“最小”堆还是“最大”堆,返回集合中的最小值或最大值。
虽然这些行为起初看起来可能过于简单,但这种简单的性质允许数据结构在时间和空间上非常有效地实现,同时提供了一种通常需要的行为。
考虑堆的一个简单方法是将其视为二叉树,它有两个简单的规则:
1. 任何节点的子值都小于该节点的值。
2. 这棵树将是一棵完整的树。
不要在规则 1 中读到比它说的更多的东西。任何节点的子节点都将小于或等于其父节点。订购没有任何意义,不像二叉查找树,订购非常重要。
那么规则 2 是什么意思呢?完整的树是每一层都尽可能满的树。唯一可能不满的级别是最后一个(最深的)级别,它将从左到右填充。
当这些规则在树中递归应用时,应该清楚堆中的每个节点本身就是堆中子堆的父节点。
让我们看看一些无效的堆树:
![](img/image017.jpg)
图 16:无效,因为子值 8 大于父值 6
![](img/image018.jpg)
图 17:无效,因为子节点填充在右边,而不是左边
现在是有效的堆:
![](img/image019.jpg)
图 18:遵循属性和完整性规则的有效堆
## 二叉树为数组
### 结构概述
虽然堆在概念上很容易映射到树,但实际上它们通常不存储在树结构中。相反,使用非常简单的算法将树结构投影到数组中。因为我们使用的是一个完整的树,所以这变成了一个非常简单的操作。
让我们先来看一棵树,它的节点是根据它们在树中的级别来着色的。
![](img/image020.jpg)
图 19:基于级别的颜色树
这是一个完整的树,有三个层次的七个节点。我们将此逐级放入一个数组中,如下所示:
![](img/image021.jpg)
图 20:投影到数组中的二叉树
根节点(级别 1)位于第一个数组索引中。它的孩子(下一级)紧随其后。他们的孩子跟着。这种模式持续到树的每一层。请注意,在数组的末尾甚至还有一个未使用的数组索引。当我们想要向堆中添加更多数据时,这将变得非常重要。
让我们看一个具体的例子,其中这个有效的堆映射成一个数组。
![](img/image022.jpg)
图 21:作为二叉树的有效堆
![](img/image023.jpg)
图 22:映射到数组中的有效堆树
### 像树一样导航数组
树的一个好处是它们很容易以迭代和递归的方式导航。在树中上下移动就像导航到子节点或返回到父节点一样简单。
如果我们要有效地使用数组来包含我们的树数据,我们需要同样有效的机制来确定两个事实:
1. 给定一个索引,什么索引代表它的子索引?
2. 给定一个索引,哪个索引代表它的父索引?
原来这很简单。
任何索引的子代都是:
* 左子索引= 2 × + 1
* 右子索引= 2 × + 2
让我们证明这是可行的。回想一下,根节点存储在索引 0 中。使用该公式,我们可以看到它的左子可在索引 1 处找到(使用公式 2 × 0 + 1),它的右子可在索引 2 处找到(2 × 0 + 2)。
找到任何节点的父节点只是该函数的逆函数:
父=(索引- 1) / 2
从技术上讲,它是 floor((index - 1) / 2)。然而,C# 为我们处理整数截断。
### 关键点
从这一节中要带走的关键点是:堆中最大的值总是数组中的第一项。
## 堆类
我们将实现的堆类是一个最大堆,这意味着它非常有效地返回最大值。这门课非常简单。它有一个添加新值、移除最大值、查看最大值、清除集合以及获取堆中当前项目数的方法。
堆要求其泛型类型参数实现`IComparable`接口,并且有两个字段:后备数组和堆中当前的项目计数。这里还显示了两种实用方法`Swap`和`Parent`。
请注意,没有枚举或执行其他查找操作的方法,例如确定堆是否包含特定值或直接索引到堆中。虽然这些方法并不难添加,但它们与堆是什么有冲突。堆是返回最小值或最大值的不透明容器。
```cs
public class Heap
where T: IComparable
{
T[] _items;
int _count;
const int DEFAULT_LENGTH = 100;
public Heap()
: this(DEFAULT_LENGTH)
{
}
public Heap(int length)
{
_items = new T[length];
_count = 0;
}
public void Add(T value);
public T Peek();
public T RemoveMax();
public int Count { get; }
public void Clear();
private int Parent(int index)
{
return (index - 1) / 2;
}
private void Swap(int left, int right)
{
T temp = _items[left];
_items[left] = _items[right];
_items[right] = temp;
}
}
```
## 添加
| 行为 | 将提供的值添加到堆中。 |
| 表演 | *O* (日志 *n* ) |
向堆中添加值有两个步骤:
1. 将该值添加到支持数组的末尾(如有必要,可以增加)。
2. 将该值与其父值交换,直到满足堆属性。
例如,让我们看看之前的有效堆:
![](img/image024.jpg)
图 23:有效堆
我们将把值 10 添加到堆中。使用所描述的算法,我们从将值添加到后备数组的末尾开始。由于我们在数组中存储了一个完整的树,这意味着我们在最后一层最左边的空闲槽中添加了一个新节点。
![](img/image025.jpg)
图 24:向有效堆中添加 10
我们的后备数组现在看起来如下:
```cs
[8, 6, 5, 3, 4, 10]
```
您应该注意到这违反了堆属性,该属性要求每个节点的子节点小于或等于父节点的值。在这种情况下,值 5 有一个值为 10 的子级。要解决这个问题,我们需要交换节点,如下所示:
![](img/image026.jpg)
图 25:交换 10 和 5 个节点
我们的后备数组现在如下所示:
```cs
[8, 6, 10, 3, 4, 5]
```
我们现在已经修复了 10 和 5 之间的关系,但是 10 的父节点是 8,这违反了堆属性,所以我们也需要交换这些。
![](img/image027.jpg)
图 26:交换 10 和 8 个节点
我们的后备数组现在看起来如下:
```cs
[10, 6, 8, 3, 4, 5]
```
该树现在满足堆属性,并且仍然是完整的,因此`Add`操作完成。
请注意,随着树的重新平衡,在数组中执行的操作只是父索引和子索引值的简单交换。
```cs
public void Add(T value)
{
if (_count >= _items.Length)
{
GrowBackingArray();
}
_items[_count] = value;
int index = _count;
while (index > 0 && _items[index].CompareTo(_items[Parent(index)]) > 0)
{
Swap(index, Parent(index));
index = Parent(index);
}
_count++;
}
private void GrowBackingArray()
{
T[] newItems = new T[_items.Length * 2];
for (int i = 0; i < _items.Length; i++)
{
newItems[i] = _items[i];
}
_items = newItems;
}
```
## 删除最大值
| 行为 | 移除并返回堆中最大的值。如果堆为空,则会引发异常。 |
| 表演 | *O* (日志 *n* ) |
`RemoveMax`的工作原理与`Add`相似,但方向相反。`Add`从树的底部(或数组的末尾)开始,将值向上移动到适当的位置,`RemoveMax`从存储堆中最大的值开始,该值在数组索引 0 中。这是将返回给调用者的值。
由于该值正在被移除,数组索引 0 现在是空闲的。为了确保我们的数组没有任何间隙,我们需要向其中移动一些东西。我们要做的是抓取数组中的最后一项,并将其向前移动到索引 0。在树形视图中,它看起来像这样:
![](img/image028.jpg)
图 27:将最后一个数组项移动到索引 0
我们的后备数组变化如下:
```cs
[10, 6, 8, 3, 4, 5] => [5, 6, 8, 3, 4]
```
与`Add`一样,树不处于有效状态。但是,与`Add`不同,坏节点是根(数组索引 0),而不是最后一个节点。
我们需要做的是将较小的父节点与其子节点交换,直到满足堆属性。这就留下了一个问题:我们和哪个孩子交换?答案是我们总是和最大的孩子交换。想想如果我们和小一点的孩子交换会发生什么。树会从一个无效状态切换到另一个无效状态。例如:
![](img/image029.jpg)
图 28:用较小的值交换不会创建有效的堆
我们还没有解决问题!相反,我们需要像这样与两个孩子中较大的一个交换:
![](img/image030.jpg)
图 29:与最大的子代交换创建一个有效的堆
我们的后备数组变化如下:
```cs
[5, 6, 8, 3, 4] => [8, 6, 5, 3, 4]
```
这个例子只需要一次交换就可以使堆有效。但实际上,交换操作可能需要在每个级别执行,直到满足堆属性,或者直到节点处于最后一个级别(可能回到它开始的位置)。
```cs
public T RemoveMax()
{
if (Count <= 0)
{
throw new InvalidOperationException();
}
T max = _items[0];
_items[0] = _items[_count - 1];
_count--;
int index = 0;
while (index < _count)
{
// Get the left and right child indexes.
int left = (2 * index) + 1;
int right = (2 * index) + 2;
// Make sure we are still within the heap.
if (left >= _count)
{
break;
}
// To avoid having to swap twice, we swap with the largest value.
// E.g.,
// 5
// 6 8
//
// If we swapped with 6 first we'd have
//
// 6
// 5 8
//
// and we'd require another swap to get the desired tree.
//
// 8
// 6 5
//
// So we find the largest child and just do the right thing at the start.
int maxChildIndex = IndexOfMaxChild(left, right);
if (_items[index].CompareTo(_items[maxChildIndex]) > 0)
{
// The current item is larger than its children (heap property is satisfied).
break;
}
Swap(index, maxChildIndex);
index = maxChildIndex;
}
return max;
}
private int IndexOfMaxChild(int left, int right)
{
// Find the index of the child with the largest value.
int maxChildIndex = -1;
if (right >= _count)
{
// No right child.
maxChildIndex = left;
}
else
{
if (_items[left].CompareTo(_items[right]) > 0)
{
maxChildIndex = left;
}
else
{
maxChildIndex = right;
}
}
return maxChildIndex;
}
```
## Peek
| 行为 | 返回堆中的最大值,如果堆为空,则引发异常。 |
| 表演 | *O* (1) |
```cs
public T Peek()
{
if (Count > 0)
{
return _items[0];
}
throw new InvalidOperationException();
}
```
## 计数
| 行为 | 返回堆中的项数。 |
| 表演 | *O* (1) |
```cs
public int Count
{
get
{
return _count;
}
}
```
## 晴
| 行为 | 从堆中移除所有项目。 |
| 表演 | *O* (1) |
`Clear`将计数设置为 0,并分配一个新的默认长度数组。进行数组分配是为了确保垃圾收集器有机会释放在调用`Clear`之前被堆引用的任何对象。
```cs
public void Clear()
{
_count = 0;
_items = new T[DEFAULT_LENGTH];
}
```
## 优先队列
优先级队列是队列和堆之间的交叉。它看起来和感觉上都像一个队列——项目可以入队和出队。但是,返回的值是最高优先级的项目。
优先级队列通常用于处理许多项目的场景,其中一些项目比其他项目更重要。
网络路由是一个例子,我想我们都能理解。并非所有网络流量都是相等的。一些数据(如实时语音通信)需要高质量的服务,而其他数据(如网络备份程序的后台文件传输)需要较低的服务质量。
如果我的电脑正在为 VoIP 电话连接发送数据包,并且正在传输我去脸书度假的照片,那么我很可能希望语音连接优先于图片传输。图片传输时间长一点我可能不会注意到,但是你可以肯定我会注意到我的语音通话质量差。
在本例中,网络栈可能提供一种机制,允许网络流量为自己声明一些优先级属性,以确定数据的时间敏感性。网络栈可能会在内部使用优先级队列来实现这一点。这显然是一个复杂话题的琐碎化,但重点应该是明确的:有些数据集比其他数据集更重要。
### 优先级队列类别
优先级队列类是堆上非常薄的包装器。它真正做的只是分别用`Enqueue`和`Dequeue`包装`Add`和`RemoveMax`方法。
```cs
public class PriorityQueue
where T: IComparable
{
Heap _heap = new Heap();
public void Enqueue(T value)
{
_heap.Add(value);
}
public T Dequeue()
{
return _heap.RemoveMax();
}
public void Clear()
{
_heap.Clear();
}
public int Count
{
get
{
return _heap.Count;
}
}
}
```
### 用法举例
本示例创建一个简单的应用程序,其中消息按以下属性排队:
* 优先级从 0(最低)到 3(最高)。
* 消息的年代。
* 消息本身(字符串)。
优先级队列用于确保消息按优先级顺序处理。给定优先级内的多条消息从最早到最新进行处理。
`Data`类具有前面列表中提到的三个属性,一个以结构化方式打印属性的`ToString`方法,以及一个`IComparable`。`CompareTo`通过`Priority`和`Age`进行比较的方法。
```cs
class Data : IComparable
{
readonly DateTime _creationTime;
public Data(string message, int priority)
{
_creationTime = DateTime.UtcNow;
Message = message;
Priority = priority;
}
public string Message { get; private set; }
public int Priority { get; private set; }
public TimeSpan Age
{
get
{
return DateTime.UtcNow.Subtract(_creationTime);
}
}
public int CompareTo(Data other)
{
int pri = Priority.CompareTo(other.Priority);
if (pri == 0)
{
pri = Age.CompareTo(other.Age);
}
return pri;
}
public override string ToString()
{
return string.Format("[{0} : {1}] {2}",
Priority,
Age.Milliseconds,
Message);
}
}
```
接下来,有一个简单的类将 1000 个随机优先级的消息添加到队列中,消息之间有 0–3 毫秒的短暂延迟,以确保可以通过优先级和时间来演示排序。
然后,应用程序按照优先级和时间顺序打印出消息,以演示如何使用优先级队列。
```cs
static void PriorityQueueSample()
{
PriorityQueue queue = new PriorityQueue();
queue = new PriorityQueue();
Random rng = new Random();
for (int i = 0; i < 1000; i++)
{
int priority = rng.Next() % 3;
queue.Enqueue(new Data(string.Format("This is message: {0}", i), priority));
Thread.Sleep(priority);
}
while (queue.Count > 0)
{
Console.WriteLine(queue.Dequeue().ToString());
}
}
```