Lec6 Heaps & Priority Queues
Lec6 Heaps & Priority Queues
接下来迎面走来的是很有可能出在应用题的堆,一个比较容易掌握的知识点。
Binary Heaps
引言
FindMin(查找最小值)应用
应用场景
- 操作系统调度:
- 操作系统需要根据优先级调度作业,而不是按照先进先出(FIFO)方式调度。FindMin可以帮助快速找到优先级最高的作业进行调度。
- 事件模拟:
- 在事件模拟中,例如银行客户的到达和离开事件,事件按时间顺序排序。通过使用FindMin,可以在一组事件中快速找到下一个发生的事件(最早的事件)。
- 查找学生最高成绩:
- 在学校中,FindMin可以用来快速查找成绩最好的学生,或者某个班级或学科中成绩最低的学生(例如进行奖学金评选时)。
- 查找员工最高薪资:
- 在公司中,FindMin可以帮助找到薪资最高的员工,或者根据薪资进行排名,来决定奖金发放或职位提升等。
优先队列(Priority Queue)ADT
优先队列的功能
优先队列(Priority Queue)是一种数据结构,它支持以下高效操作: 1. FindMin(查找最小值):找到优先队列中的最小元素。 2. DeleteMin(删除最小值):删除优先队列中的最小元素。 3. Insert(插入元素):向优先队列中插入一个新的元素。
不同实现方式的性能分析
- 列表(Lists)
- 已排序的列表:
- FindMin:因为列表是有序的,所以最小元素总是放在第一个位置,因此查找最小值的时间复杂度是 O(1)。
- Insert:为了保持列表的有序性,插入元素需要遍历列表并找到合适的位置,所以插入的时间复杂度是 O(n),其中 n 是列表的长度。
- 未排序的列表:
- FindMin:需要遍历整个列表以找到最小值,所以查找最小值的时间复杂度是 O(n)。
- Insert:可以将新元素直接添加到列表的末尾,因此插入的时间复杂度是 O(1)。
- 已排序的列表:
- 二叉搜索树(Binary Search Trees)
- FindMin:二叉搜索树的最小值在最左侧的叶子节点,因此查找最小值的时间复杂度是
O(h),其中 h 是树的高度。
- 对于平衡树(如AVL树),高度为 O(log n),所以查找最小值的时间复杂度是 O(log n)。
- Insert:为了插入一个新的元素,需要找到合适的位置并插入,因此插入的时间复杂度是 O(h),同样,平衡树的高度为 O(log n),所以插入的时间复杂度是 O(log n)。
- FindMin:二叉搜索树的最小值在最左侧的叶子节点,因此查找最小值的时间复杂度是
O(h),其中 h 是树的高度。
- 哈希表(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):每个节点的值都大于或等于其子节点的值,因此根节点是树中最大的节点。
操作分析
- FindMin:
- 在最小堆中,根节点总是最小的,因此查找最小值的时间复杂度是 O(1)。
- Insert:
- 插入元素时,首先将新元素插入到堆的最后一个位置,然后通过“上浮”操作将其移动到正确的位置,保持堆的顺序。这个过程的时间复杂度为 O(log N)。
- DeleteMin:
- 删除最小元素(根节点)时,首先将堆的最后一个元素移到根节点的位置,然后通过“下沉”操作将根节点元素移到合适的位置,保持堆的顺序。这个过程的时间复杂度为 O(log N)。
二叉堆的优势
- FindMin O(1):根节点始终是最小元素(对于最小堆),因此查找最小值是常数时间操作。
- Insert 和 DeleteMin O(log N):二叉堆可以保证在对数时间内进行插入和删除最小值操作,比平衡二叉搜索树的操作更高效,尤其是在需要频繁执行删除最小值和插入操作时。
二叉堆是完全二叉树
完全二叉树:二叉堆是一种完全二叉树,即除了最底层可能没有完全填满外,其他层都是满的,且最底层的节点是从左到右依次填充的。
完全二叉树的节点数量:一个高度为 $ h $ 的完全二叉树的节点数介于 $ 2^h $ 和 $ 2^{h+1} - 1 $ 之间。因此,完全二叉树的节点数量范围是:
$ 2^h N ^{h+1} - 1 $高度:完全二叉树的高度 $ h $ 可以通过节点数量 $ N $ 来计算。对于一个包含 $ N $ 个节点的完全二叉树,其高度为: $ h = N $ 其中,$ x $ 表示向下取整。
完全二叉树的特性
- 每一层的节点数都满,除了最后一层,且最后一层的节点是从左到右填充的。
- 高度:给定 $ N $ 个节点,完全二叉树的高度为 $ N $。
- 节点的范围:一个完全二叉树的节点数 $ N $ 在高度 $ h $ 的范围内从 $ 2^h $ 到 $ 2^{h+1} - 1 $,这意味着完全二叉树的节点数在高度增加时是指数增长的。
堆的数组实现(隐式指针)
在堆(如二叉堆)的数组实现中,堆中的节点通过数组的索引进行关联。这种实现方式没有显式的指针,而是通过数组的索引来推导父节点和子节点的位置,通常是基于以下的公式。
节点索引关系
根节点:通常为数组中的第一个元素,假设根节点为
A[1]
(或者有些实现使用A[0]
)。根节点(A[1]):
A[0]
(有些实现会将根节点放在A[0]
,这取决于实现)。父节点:对于数组中的任意节点
A[i]
,其父节点的索引为A[i/2]
(对于i ≠ 0
且i < n
)。左子节点:对于
A[i]
,左子节点的索引为A[2i]
。右子节点:对于
A[i]
,右子节点的索引为A[2i + 1]
。
以上公式基于从1开始的索引(
A[1]
为根节点),通常我们将数组索引从1
开始,索引1对应树的根节点。然后通过简单的算术运算来找到父节点和子节点。
另一种计算方式
另外一种常见的计算方式是使用从 0
开始的数组索引:
- 根节点:通常为
A[0]
。 - 父节点:对于数组中的任意节点
A[r]
,其父节点的索引是⌊(r - 1) / 2⌋
,前提是r ≠ 0
且r < n
。 - 左子节点:对于节点
A[r]
,其左子节点的索引为2r + 1
,前提是2r + 1 < n
。 - 右子节点:对于节点
A[r]
,其右子节点的索引为2r + 2
,前提是2r + 2 < n
。 - 左兄弟:对于节点
A[r]
,如果r
是偶数,且r > 0
且r < n
,则其左兄弟的索引为r - 1
。 - 右兄弟:对于节点
A[r]
,如果r
是奇数,且r + 1 < n
,则其右兄弟的索引为r + 1
。
二叉堆的类模板(C++ 实现)
1 | template <typename Comparable> |
类模板分析
- BinaryHeap 构造函数:
BinaryHeap(int capacity = 100)
:默认构造函数,创建一个容量为100的二叉堆。BinaryHeap(const vector<Comparable>& items)
:使用给定的元素列表初始化堆。
- 基本操作:
findMin()
:查找堆中的最小元素(最小堆的根节点)。insert()
:向堆中插入一个元素。支持左值和右值两种插入方式。deleteMin()
:删除并返回堆中的最小元素。
- 辅助函数:
buildHeap()
:将一个未排序的数组转换为有效的堆结构。percolateDown(int hole)
:向下调整堆的元素,保持堆的性质。
二叉堆的
deleteMin
和 insert
操作的实现
在二叉堆中,我们使用数组来实现堆结构,并通过对堆的调整(如向下和向上调整)保持堆的性质。以下是关于
deleteMin
和 insert
操作的详细实现和说明。
deleteMin
操作
deleteMin
操作用于删除堆中的最小元素,并将其返回。这个操作的基本步骤是: 1.
如果堆为空,抛出 UnderflowException
异常。 2.
将堆顶元素(最小元素)保存到 minItem
中。 3.
将堆的最后一个元素移到堆顶。 4.
对堆顶元素进行调整(向下调整),保持堆的性质。
代码实现:
1 | void deleteMin(Comparable &minItem) { |
percolateDown(1)
:在deleteMin
中调用了percolateDown(1)
,这是将堆顶元素下沉到合适位置的操作。具体实现见下文。
percolateDown
操作
percolateDown
用于将堆顶元素下沉到合适位置,使得堆的性质得以恢复。它会比较当前节点与子节点的大小,并决定是否需要交换位置。
伪代码实现: 1
2
3
4
5
6
7
8
9
10
11
12
13
14void 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 N
(N
为堆中的节点数),所以deleteMin
操作的时间复杂度为 O(log N)。
insert
操作
insert
操作用于将一个新元素插入到堆中。堆的性质要求新插入的元素必须放置在数组的末尾。插入操作后,需要通过向上调整(percolateUp
)来恢复堆的性质。
代码实现:
1 | void insert(const Comparable &x) { |
insert
过程:插入一个新元素时,首先将其放到堆的末尾,然后通过percolateUp
向上调整,直到堆的性质被恢复。时间复杂度:插入操作的时间复杂度是 O(log N),因为插入后的向上调整最多需要遍历堆的深度。
时间复杂度总结
deleteMin
:O(log N),因为每次删除最小元素后,我们需要向下调整堆,时间复杂度与堆的深度有关。insert
:O(log N),因为每次插入元素后,我们需要向上调整堆,时间复杂度同样与堆的深度有关。
完全二叉树的深度
堆是一棵完全二叉树,对于 N
个元素的堆,堆的深度(高度)是
⌊log2(N)⌋
。因此,无论是插入操作还是删除最小元素操作,最坏情况下都需要进行与堆深度成正比的调整操作,时间复杂度为
O(log N)。
二叉堆构建(BuildHeap)
二叉堆可以通过初始集合的元素来构建。常见的构建方法有两种:
- 逐个插入方法:
- 可以通过连续的插入操作来构建堆。
- 每次插入的时间复杂度是 O(log N),因此总的时间复杂度是 O(N log N),这是最坏情况下的复杂度。
- 使用
buildHeap
方法:buildHeap
是一种更高效的方法,能够在线性时间内建立堆的顺序性质。- 通过对每一个非叶子节点执行
percolateDown
操作,从下至上的顺序调整堆,直到整个堆满足堆的性质。
buildHeap
方法的实现
buildHeap
方法的核心思想是从树的最底层非叶子节点开始,逐个向上执行
percolateDown
操作,恢复堆的顺序性质。
代码实现
1 | void buildHeap() { |
详细解释
currentSize / 2
:currentSize / 2
是堆中最后一个非叶子节点的位置,因为堆的最后一层的节点都没有子节点,所以从这个节点开始调整即可。- 从这个节点开始向上调整,直到堆顶节点。
percolateDown(i)
:percolateDown
是堆的调整操作,它将节点i
向下调整到合适的位置,保持堆的顺序性质。该操作确保了树的每个父节点都小于或大于其子节点(对于最小堆来说,父节点小于子节点)。
- 时间复杂度:
- 由于
percolateDown
操作的时间复杂度为 O(log N),但是buildHeap
中的每一层的percolateDown
操作的节点数是指数级增长的,从树的底部到顶部,percolateDown
操作总的时间复杂度为 O(N),而不是 O(N log N)。
- 由于
归纳
buildHeap
方法能够在 O(N) 时间内完成堆的构建。- 空间复杂度:
buildHeap
需要的空间是 O(N),因为它使用了一个数组来存储堆的元素,外加一个变量来存储堆的大小。 - 各种操作的时间复杂度:
FindMin
:O(1),因为堆顶是最小元素。DeleteMin
和Insert
: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) 最坏时间复杂度。