Lec6 Heaps & Priority Queues

接下来迎面走来的是很有可能出在应用题的堆,一个比较容易掌握的知识点。

Binary Heaps

引言

FindMin(查找最小值)应用

应用场景

  1. 操作系统调度:
    • 操作系统需要根据优先级调度作业,而不是按照先进先出(FIFO)方式调度。FindMin可以帮助快速找到优先级最高的作业进行调度。
  2. 事件模拟:
    • 在事件模拟中,例如银行客户的到达和离开事件,事件按时间顺序排序。通过使用FindMin,可以在一组事件中快速找到下一个发生的事件(最早的事件)。
  3. 查找学生最高成绩:
    • 在学校中,FindMin可以用来快速查找成绩最好的学生,或者某个班级或学科中成绩最低的学生(例如进行奖学金评选时)。
  4. 查找员工最高薪资:
    • 在公司中,FindMin可以帮助找到薪资最高的员工,或者根据薪资进行排名,来决定奖金发放或职位提升等。

优先队列(Priority Queue)ADT

优先队列的功能

优先队列(Priority Queue)是一种数据结构,它支持以下高效操作: 1. FindMin(查找最小值):找到优先队列中的最小元素。 2. DeleteMin(删除最小值):删除优先队列中的最小元素。 3. Insert(插入元素):向优先队列中插入一个新的元素。

不同实现方式的性能分析

  1. 列表(Lists)
    • 已排序的列表
      • FindMin:因为列表是有序的,所以最小元素总是放在第一个位置,因此查找最小值的时间复杂度是 O(1)
      • Insert:为了保持列表的有序性,插入元素需要遍历列表并找到合适的位置,所以插入的时间复杂度是 O(n),其中 n 是列表的长度。
    • 未排序的列表
      • FindMin:需要遍历整个列表以找到最小值,所以查找最小值的时间复杂度是 O(n)
      • Insert:可以将新元素直接添加到列表的末尾,因此插入的时间复杂度是 O(1)
  2. 二叉搜索树(Binary Search Trees)
    • FindMin:二叉搜索树的最小值在最左侧的叶子节点,因此查找最小值的时间复杂度是 O(h),其中 h 是树的高度。
      • 对于平衡树(如AVL树),高度为 O(log n),所以查找最小值的时间复杂度是 O(log n)
    • Insert:为了插入一个新的元素,需要找到合适的位置并插入,因此插入的时间复杂度是 O(h),同样,平衡树的高度为 O(log n),所以插入的时间复杂度是 O(log n)
  3. 哈希表(Hash Tables)
    • FindMin:哈希表并不是一个有序的数据结构,因此不能直接查找最小值。为了找到最小值,需要遍历整个哈希表,时间复杂度是 O(n)

    • Insert:哈希表插入元素的时间复杂度是 O(1),如果发生哈希冲突,则需要处理冲突的方式(如链表法或开放地址法),但平均情况下,插入的时间复杂度仍然是 O(1)

  • FindMin
    • 已排序列表:O(1)
    • 未排序列表:O(n)
    • 二叉搜索树:O(h)(平衡树为 O(log n)
    • 哈希表:O(n)
  • Insert
    • 已排序列表:O(n)
    • 未排序列表:O(1)
    • 二叉搜索树:O(h)(平衡树为 O(log n)
    • 哈希表:O(1)

比平衡二叉搜索树更高效吗?

有限的需求:Insert(插入)、FindMin(查找最小值)、DeleteMin(删除最小值)

目标是: - FindMin 时间复杂度为 O(1) - Insert 时间复杂度为 O(log N) - DeleteMin 时间复杂度为 O(log N)

二叉堆(Binary Heap)

二叉堆是一种完全二叉树(Complete Binary Tree),而不是二叉搜索树(BST)。它具有以下特点:

  • 完全二叉树:树是完全填充的,除了最底层可能不完全填充,且最底层节点从左到右逐渐填充。
  • 堆序性质
    • 最小堆(Min-Heap):每个节点的值都小于或等于其子节点的值,因此根节点是树中最小的节点。
    • 最大堆(Max-Heap):每个节点的值都大于或等于其子节点的值,因此根节点是树中最大的节点。

操作分析

  1. FindMin
    • 在最小堆中,根节点总是最小的,因此查找最小值的时间复杂度是 O(1)
  2. Insert
    • 插入元素时,首先将新元素插入到堆的最后一个位置,然后通过“上浮”操作将其移动到正确的位置,保持堆的顺序。这个过程的时间复杂度为 O(log N)
  3. DeleteMin
    • 删除最小元素(根节点)时,首先将堆的最后一个元素移到根节点的位置,然后通过“下沉”操作将根节点元素移到合适的位置,保持堆的顺序。这个过程的时间复杂度为 O(log N)

二叉堆的优势

  • FindMin O(1):根节点始终是最小元素(对于最小堆),因此查找最小值是常数时间操作。
  • Insert 和 DeleteMin O(log N):二叉堆可以保证在对数时间内进行插入和删除最小值操作,比平衡二叉搜索树的操作更高效,尤其是在需要频繁执行删除最小值和插入操作时。
image-20241120004559098

二叉堆是完全二叉树

  • 完全二叉树:二叉堆是一种完全二叉树,即除了最底层可能没有完全填满外,其他层都是满的,且最底层的节点是从左到右依次填充的。

  • 完全二叉树的节点数量:一个高度为 $ h $ 的完全二叉树的节点数介于 $ 2^h $ 和 $ 2^{h+1} - 1 $ 之间。因此,完全二叉树的节点数量范围是:
    $ 2^h N ^{h+1} - 1 $

  • 高度:完全二叉树的高度 $ h $ 可以通过节点数量 $ N $ 来计算。对于一个包含 $ N $ 个节点的完全二叉树,其高度为: $ h = N $ 其中,$ x $ 表示向下取整。

完全二叉树的特性

  1. 每一层的节点数都满,除了最后一层,且最后一层的节点是从左到右填充的。
  2. 高度:给定 $ N $ 个节点,完全二叉树的高度为 $ N $。
  3. 节点的范围:一个完全二叉树的节点数 $ N $ 在高度 $ h $ 的范围内从 $ 2^h $ 到 $ 2^{h+1} - 1 $,这意味着完全二叉树的节点数在高度增加时是指数增长的。
image-20241120004738638

堆的数组实现(隐式指针)

在堆(如二叉堆)的数组实现中,堆中的节点通过数组的索引进行关联。这种实现方式没有显式的指针,而是通过数组的索引来推导父节点和子节点的位置,通常是基于以下的公式。

节点索引关系

  • 根节点:通常为数组中的第一个元素,假设根节点为 A[1](或者有些实现使用 A[0])。

    • 根节点(A[1])A[0](有些实现会将根节点放在 A[0],这取决于实现)。

    • 父节点:对于数组中的任意节点 A[i],其父节点的索引为 A[i/2](对于 i ≠ 0i < n)。

    • 左子节点:对于 A[i],左子节点的索引为 A[2i]

    • 右子节点:对于 A[i],右子节点的索引为 A[2i + 1]

    以上公式基于从1开始的索引(A[1]为根节点),通常我们将数组索引从 1 开始,索引1对应树的根节点。然后通过简单的算术运算来找到父节点和子节点。

image-20241120010716306

另一种计算方式

另外一种常见的计算方式是使用从 0 开始的数组索引:

  1. 根节点:通常为 A[0]
  2. 父节点:对于数组中的任意节点 A[r],其父节点的索引是 ⌊(r - 1) / 2⌋,前提是 r ≠ 0r < n
  3. 左子节点:对于节点 A[r],其左子节点的索引为 2r + 1,前提是 2r + 1 < n
  4. 右子节点:对于节点 A[r],其右子节点的索引为 2r + 2,前提是 2r + 2 < n
  5. 左兄弟:对于节点 A[r],如果 r 是偶数,且 r > 0r < n,则其左兄弟的索引为 r - 1
  6. 右兄弟:对于节点 A[r],如果 r 是奇数,且 r + 1 < n,则其右兄弟的索引为 r + 1
image-20241120010727755

二叉堆的类模板(C++ 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template <typename Comparable>
class BinaryHeap {
public:
// 默认构造函数,初始化堆的容量
explicit BinaryHeap(int capacity = 100);

// 使用一个元素列表初始化堆
explicit BinaryHeap(const vector<Comparable>& items);

// 查找堆中的最小元素
const Comparable& findMin() const;

// 向堆中插入元素
void insert(const Comparable& x);
void insert(Comparable&& x);

// 删除最小元素
void deleteMin();
void deleteMin(Comparable& minItem);

private:
int currentSize; // 当前堆中的元素个数
vector<Comparable> array; // 堆数组

// 构建堆
void buildHeap();

// 向下调整堆
void percolateDown(int hole);
};

类模板分析

  1. BinaryHeap 构造函数
    • BinaryHeap(int capacity = 100):默认构造函数,创建一个容量为100的二叉堆。
    • BinaryHeap(const vector<Comparable>& items):使用给定的元素列表初始化堆。
  2. 基本操作
    • findMin():查找堆中的最小元素(最小堆的根节点)。
    • insert():向堆中插入一个元素。支持左值和右值两种插入方式。
    • deleteMin():删除并返回堆中的最小元素。
  3. 辅助函数
    • buildHeap():将一个未排序的数组转换为有效的堆结构。
    • percolateDown(int hole):向下调整堆的元素,保持堆的性质。

二叉堆的 deleteMininsert 操作的实现

在二叉堆中,我们使用数组来实现堆结构,并通过对堆的调整(如向下和向上调整)保持堆的性质。以下是关于 deleteMininsert 操作的详细实现和说明。


deleteMin 操作

deleteMin 操作用于删除堆中的最小元素,并将其返回。这个操作的基本步骤是: 1. 如果堆为空,抛出 UnderflowException 异常。 2. 将堆顶元素(最小元素)保存到 minItem 中。 3. 将堆的最后一个元素移到堆顶。 4. 对堆顶元素进行调整(向下调整),保持堆的性质。

代码实现:

1
2
3
4
5
6
7
8
9
void deleteMin(Comparable &minItem) {
if (isEmpty()) // 如果堆为空,抛出异常
throw UnderflowException{};

minItem = std::move(array[1]); // 记录最小元素
array[1] = std::move(array[currentSize--]); // 把最后一个元素移到堆顶

percolateDown(1); // 调整堆,保持堆的性质
}
  • percolateDown(1):在 deleteMin 中调用了 percolateDown(1),这是将堆顶元素下沉到合适位置的操作。具体实现见下文。

percolateDown 操作

percolateDown 用于将堆顶元素下沉到合适位置,使得堆的性质得以恢复。它会比较当前节点与子节点的大小,并决定是否需要交换位置。

伪代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void percolateDown(int hole) {
int child;
Comparable tmp = std::move(array[hole]); // 保存当前元素
for (; hole * 2 <= currentSize; hole = child) {
child = hole * 2; // 左子节点
if (child != currentSize && array[child + 1] < array[child]) // 如果右子节点小
++child; // 选择右子节点
if (array[child] < tmp) // 如果子节点比当前节点小
array[hole] = std::move(array[child]); // 将子节点移到父节点
else
break; // 堆的性质已恢复,停止调整
}
array[hole] = std::move(tmp); // 将当前元素放到最终位置
}

  • 时间复杂度:由于每次向下调整堆需要遍历堆的深度,而堆的深度是 log NN 为堆中的节点数),所以 deleteMin 操作的时间复杂度为 O(log N)。

insert 操作

insert 操作用于将一个新元素插入到堆中。堆的性质要求新插入的元素必须放置在数组的末尾。插入操作后,需要通过向上调整(percolateUp)来恢复堆的性质。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void insert(const Comparable &x) {
if (currentSize == array.size() - 1) // 如果堆已满,则扩展数组
array.resize(array.size() * 2);

int hole = ++currentSize; // 将元素插入堆的末尾
Comparable copy = x;
array[0] = std::move(copy); // 临时存储插入的元素

// 向上调整堆,直到堆的性质恢复
for (; x < array[hole / 2]; hole /= 2)
array[hole] = std::move(array[hole / 2]);

array[hole] = std::move(array[0]); // 将元素放置到正确位置
}
  • insert 过程:插入一个新元素时,首先将其放到堆的末尾,然后通过 percolateUp 向上调整,直到堆的性质被恢复。

  • 时间复杂度:插入操作的时间复杂度是 O(log N),因为插入后的向上调整最多需要遍历堆的深度。


时间复杂度总结

  • deleteMin:O(log N),因为每次删除最小元素后,我们需要向下调整堆,时间复杂度与堆的深度有关。
  • insert:O(log N),因为每次插入元素后,我们需要向上调整堆,时间复杂度同样与堆的深度有关。

完全二叉树的深度

堆是一棵完全二叉树,对于 N 个元素的堆,堆的深度(高度)是 ⌊log2(N)⌋。因此,无论是插入操作还是删除最小元素操作,最坏情况下都需要进行与堆深度成正比的调整操作,时间复杂度为 O(log N)。


二叉堆构建(BuildHeap)

二叉堆可以通过初始集合的元素来构建。常见的构建方法有两种:

  1. 逐个插入方法
    • 可以通过连续的插入操作来构建堆。
    • 每次插入的时间复杂度是 O(log N),因此总的时间复杂度是 O(N log N),这是最坏情况下的复杂度。
  2. 使用 buildHeap 方法
    • buildHeap 是一种更高效的方法,能够在线性时间内建立堆的顺序性质。
    • 通过对每一个非叶子节点执行 percolateDown 操作,从下至上的顺序调整堆,直到整个堆满足堆的性质。

buildHeap 方法的实现

buildHeap 方法的核心思想是从树的最底层非叶子节点开始,逐个向上执行 percolateDown 操作,恢复堆的顺序性质。

代码实现

1
2
3
4
5
void buildHeap() {
// 从最后一个非叶子节点开始向上调整
for (int i = currentSize / 2; i > 0; --i)
percolateDown(i);
}

详细解释

  1. currentSize / 2
    • currentSize / 2 是堆中最后一个非叶子节点的位置,因为堆的最后一层的节点都没有子节点,所以从这个节点开始调整即可。
    • 从这个节点开始向上调整,直到堆顶节点。
  2. percolateDown(i)
    • percolateDown 是堆的调整操作,它将节点 i 向下调整到合适的位置,保持堆的顺序性质。该操作确保了树的每个父节点都小于或大于其子节点(对于最小堆来说,父节点小于子节点)。
  3. 时间复杂度
    • 由于 percolateDown 操作的时间复杂度为 O(log N),但是 buildHeap 中的每一层的 percolateDown 操作的节点数是指数级增长的,从树的底部到顶部,percolateDown 操作总的时间复杂度为 O(N),而不是 O(N log N)。
image-20241120010619176
image-20241120010630813

归纳

  • buildHeap 方法能够在 O(N) 时间内完成堆的构建。
  • 空间复杂度:buildHeap 需要的空间是 O(N),因为它使用了一个数组来存储堆的元素,外加一个变量来存储堆的大小。
  • 各种操作的时间复杂度:
    • FindMin:O(1),因为堆顶是最小元素。
    • DeleteMinInsert:O(log N),因为插入和删除堆顶都需要调整堆的结构。
    • buildHeap:O(N),因为它通过 percolateDown 从底向上调整堆的顺序。

进一步分析

对于二叉堆,其时间复杂度的分析可以通过递推的方式得出。假设堆的大小为 N,堆的高度为$ k(N ≈ 2^k - 1)$,不同层次的调整步数逐渐减少:

  • 第 1 层需要 k-1 步处理 1 个元素。
  • 第 2 层需要 k-2 步处理 2 个元素。
  • 第 3 层需要 k-3 步处理 4 个元素。
  • 第 i 层需要 k-i 步处理 \(2^(i-1)\) 个元素。

总的步骤数可以通过以下公式表示:

$ _{i=1}^{k-1} (k-i) ^{i-1} = $2^k - k - 1 \(= O(N)\)

因此,buildHeap 的时间复杂度是 O(N),而不是 O(N log N)。

总结

  • 使用 buildHeap 方法能在 O(N) 的时间复杂度内高效地建立二叉堆。
  • 通过从树的最底层非叶子节点开始调整,逐层修正堆的顺序性质,避免了逐个插入法的 O(N log N) 最坏时间复杂度。