由于在做减面算法时需要用到优先队列,忘记优先队列的实现了,因此补充一下有关的知识点。
完全二叉树
理解堆的实现前需要先了解完全二叉树。完全二叉树满足以下性质:
-
层级顺序完全填满:除了最后一层外其他层完全填满,也就是说除了最后一层,前面的层不存在空节点。
-
最后一层尽可能靠左排列:最后一层从左到右紧密排列,不存在连续的空节点。
当一颗完全二叉树增加一个节点时,有可能发生两种情况:
-
如果父节点已经有了一个叶子结点,那么该节点挂到另一半成为叶子节点,没有节点性质发生改变
-
如果所有父节点的叶子节点均已满,那么会选择一个叶子节点变成父节点,新增节点成为叶子节点
核心观察:由于满足以上性质,完全二叉树中叶子节点始终比非叶子结点多 0 个或 1 个
因此可以直接用数组进行存储整棵树,并通过元素下标运算实现访问父节点、子节点的操作。
完全二叉树的元素索引
当新增一个节点 C,将一个 P 节点从叶子节点变成父节点时,P 点会是最新且是最后一个父节点,如果 P 点的索引为 i i i,那么从 0 ∼ i 0 \sim i 0∼i 均为父节点,第 i + 1 i + 1 i+1 及以后得节点均为叶子节点,由于叶子节点数量始终比非叶子结点数量多 0 或 1 个,因此此时叶子节点数量同样为 i + 1 i+1 i+1 个(数量 = 索引+1),因此 C 点的索引为 ( i + 1 ) × 2 − 1 = 2 i + 1 (i+1) \times2 -1=2i+1 (i+1)×2−1=2i+1,且这里 C 点为左节点(因为只有新增左节点才会产生节点性质的变化)。
所以我们可以访问直接采用元素索引的方式子节点,父节点的访问方式可以反算出来:
-
左子节点索引: 2 i + 1 2i+1 2i+1
-
右子节点索引: 2 i + 2 2i+2 2i+2
-
父节点索引: ⌊ ( i − 1 ) / 2 ⌋ \left\lfloor(i-1)/2\right\rfloor ⌊(i−1)/2⌋(向下取整)
同样根据核心观察,可以知道如果总节点数为 n n n,那么父节点总数为 ⌊ n / 2 ⌋ \left\lfloor{n/2}\right\rfloor ⌊n/2⌋,因此:
-
最后一个非叶子节点索引: ⌊ n / 2 ⌋ − 1 \left\lfloor{n/2}\right\rfloor-1 ⌊n/2⌋−1
-
叶子结点索引范围: ⌊ n / 2 ⌋ ∼ n − 1 \left\lfloor{n/2}\right\rfloor\sim n-1 ⌊n/2⌋∼n−1
另外完全二叉树还有一个高度属性,这个比较好理解,就不过多赘述
- 高度: ⌊ l o g 2 ( n ) + 1 ⌋ \left\lfloor{log_2(n)+1}\right\rfloor ⌊log2(n)+1⌋
堆和队列
在理解堆之前,首先需要理解队列是什么,通常而言队列是由于处理器资源限制,需要将等待处理的任务进行排队,依次处理任务。像食堂排队打饭就是由于打饭阿姨数量少于吃饭的人,因此需要吃饭的人在后面排队。队列是一种缓解资源紧张的设计思想,并不是一种特定的数据形式(比如数组、列表)。一般社会规则遵循先来后到,这种队列就是先入先出队列,除此之外还可以设计先入后出队列(也就是栈,后入队的元素优先出队),以及基于特定优先级判定规则决定的队列(定义为堆,优先级更高的元素优先出队)。每种队列拥有不同的适用场合,但不管在什么场合下,队列要解决的问题是不变的:
-
存储一定数量的元素,因此队列拥有一个属性
size
-
支持有新的元素进来排队,因此队列有一个方法
Push
-
获取下一个待处理的元素,因此队列有一个方法
Pop
堆通常也称为优先队列,与先入先出、先入后出的优先队列不同,优先队列根据元素本身的优先级进行排列,可以根据我们给元素定义的优先级实现快速的入队、出队操作。
堆的实现
在完全二叉树的基础上,如果满足任意父节点的优先级都始终大于等于其子节点的优先级,这颗完全二叉树就是堆。
核心观察:在堆中,上层节点的值优先级始终大于下一层的任意节点,而相同层的节点值之间优先级不固定。且在新增、删除元素之后,这个性质也不发生变化。
为了实现始终保持这个性质,算法人员想出了一个方法,通过 ShiftUp
操作子节点,通过 ShitfDown
操作父节点,让每个父节点-子节点的节点对都满足上述性质,这样就能在队列新增、删除元素后进行快速调整。
ShiftDown
核心思想在于递归(虽然实现上是循环,但思想是递归)地处理父节点,先拿出子节点中优先级最高的节点,将其和父节点的优先级进行比较,如果父节点优先级更低,则说明父节点该让位于子节点,交换两者。在成为子节点后,再递归地判断它是否需要让位于新的子节点,直到它满足优先级高于它的所有子节点。
private void ShiftDown(int index)
{
while (true)
{
if (!IsIndexValid(index)) return;
var value = _array[index];
var leftChildIndex = index * 2 + 1;
var rightChildIndex = leftChildIndex + 1;
if (!IsIndexValid(leftChildIndex) && !IsIndexValid(rightChildIndex)) return;
var childIndex = leftChildIndex;
var childValue = _array[leftChildIndex];
if (IsIndexValid(rightChildIndex))
{
var rightChildValue = _array[rightChildIndex];
if (rightChildValue > childValue)
{
childIndex = rightChildIndex;
childValue = rightChildValue;
}
}
if (childValue <= value) return;
_array[index] = childValue;
_array[childIndex] = value;
index = childIndex;
}
}
ShiftUp
用于处理子节点,并递归向上,让整棵树保持堆的性质。
private void ShiftUp(int index)
{
while (true)
{
if (!IsIndexValid(index)) return;
var value = _array[index];
var parentIndex = (index - 1) / 2;
if (!IsIndexValid(parentIndex)) return;
var parentValue = _array[parentIndex];
if (parentValue >= value) return;
_array[index] = parentValue;
_array[parentIndex] = value;
index = parentIndex;
}
}
在有了这两个操作之后,就可以在构建堆、新增元素、删除元素时,方便地放置元素到合理的位置。
- 构造
当用数组构造堆时,思路是对所有父节点,从后往前做一遍 ShiftDown
操作,便可以让整棵树保持堆的性质。
public MyHeap(int[] array)
{
size = array.Length;
for (var i = 0; i < array.Length; i++)
_array[i] = array[i];
for (var i = size/2 - 1; i >= 0; i--)
ShiftDown(i);
}
- Push
在往队列中增加元素时,将新增元素直接放置在最后一个叶子节点上,并对其做 ShiftUp
,即可将该元素插入到合适的位置
public void Push(int value)
{
_array[size] = value;
size++;
ShiftUp(size - 1);
}
- Pop
在从队列中获取优先级最高的元素时,此时第一个父节点便是优先级最高的节点,因此可以直接返回父节点的值。在移出父节点后,将最后一个叶子节点放到第一个父节点位置,并对其做 ShiftDown
即可重新让树保持堆的性质。
public int Pop()
{
var value = _array[0];
_array[0] = _array[size - 1];
size--;
ShiftDown(0);
return value;
}
总结
以上就是关于堆的理解,要实现堆,首先需要了解堆是用来做什么的(队列的概念)。然后在实现堆的过程中为什么可以采用数组来存储堆中的元素(完全二叉树的概念),以及如何让完全二叉树实现堆(ShiftDown
和 ShiftUp
)。最后通过堆来实现元素的入队(Push
)、出队(Pop
),以此实现优先级队列。