堆(Heap)
堆(Heap)是一种特殊的完全二叉树数据结构,它有以下特点:
- 堆中任意节点的值都必须大于等于(或小于等于)其子树中任意节点的值。这种性质被称为堆性质。
- 堆总是一棵完全二叉树。也就是说,除了最底层,其他层的节点都被元素填满,且最底层的元素都集中在左侧连续的位置上。
根据堆顶元素的值与子树元素的关系,我们可以将堆分为最大堆和最小堆:
- 最大堆:堆顶元素是堆中所有元素的最大值。
- 最小堆:堆顶元素是堆中所有元素的最小值。
堆的主要操作包括:
heapify:将一个无序数组构建成堆的过程。insert:向堆中插入一个新元素。extract_max/extract_min:删除并返回堆顶元素。heap_sort:利用堆实现排序。
Swift实现:
struct Heap<T: Comparable> {
private var elements: [T] = []
private var isMaxHeap: Bool
init(isMaxHeap: Bool = true) {
self.isMaxHeap = isMaxHeap
}
var isEmpty: Bool {
return elements.isEmpty
}
var count: Int {
return elements.count
}
mutating func insert(_ element: T) {
elements.append(element)
heapifyUp(from: elements.count - 1)
}
mutating func extractMax() -> T? {
guard !isEmpty else { return nil }
elements.swapAt(0, elements.count - 1)
let max = elements.removeLast()
heapifyDown(from: 0)
return max
}
private mutating func heapifyUp(from index: Int) {
var currentIndex = index
while currentIndex > 0 {
let parentIndex = (currentIndex - 1) / 2
if compare(elements[currentIndex], with: elements[parentIndex]) {
elements.swapAt(currentIndex, parentIndex)
currentIndex = parentIndex
} else {
break
}
}
}
private mutating func heapifyDown(from index: Int) {
var currentIndex = index
while true {
let leftChildIndex = currentIndex * 2 + 1
let rightChildIndex = currentIndex * 2 + 2
var swapIndex = currentIndex
if leftChildIndex < elements.count && compare(elements[leftChildIndex], with: elements[swapIndex]) {
swapIndex = leftChildIndex
}
if rightChildIndex < elements.count && compare(elements[rightChildIndex], with: elements[swapIndex]) {
swapIndex = rightChildIndex
}
if swapIndex == currentIndex {
break
}
elements.swapAt(currentIndex, swapIndex)
currentIndex = swapIndex
}
}
private func compare(_ lhs: T, with rhs: T) -> Bool {
return isMaxHeap ? lhs > rhs : lhs < rhs
}
}
// 堆排序示例
extension Heap {
mutating func heapsort() -> [T] {
var sortedArray = [T]()
while !isEmpty {
if let max = extractMax() {
sortedArray.append(max)
}
}
return sortedArray.reversed()
}
}
heapifyDown:
heapifyDown 操作用于维护一个已经建好的最大堆(或最小堆)的性质。当我们从堆中删除根节点(最大/最小元素)并将最后一个节点放到根节点位置时,需要进行 heapifyDown 操作来恢复堆的性质。
具体步骤如下:
- 从根节点(索引为0)开始,与其左右子节点进行比较。
- 找到三个节点中最大(或最小)的那个,记录其索引。
- 如果根节点不是最大(或最小)的,则将根节点与最大(或最小)节点交换位置。
- 递归地对交换后的子树进行
heapifyDown操作,直到满足堆的性质。
heapifyUp:
heapifyUp 操作用于在向堆中插入新元素时维护堆的性质。当我们向堆中添加一个新元素时,需要将其添加到数组的末尾,然后执行 heapifyUp 操作来恢复堆的性质。
具体步骤如下:
- 从插入的节点(索引为
n-1,n为当前堆的大小)开始,与其父节点进行比较。 - 如果插入的节点比父节点大(或小),则交换两个节点的位置。
- 递归地对交换后的父节点进行
heapifyUp操作,直到满足堆的性质。
通过 heapifyDown 和 heapifyUp 两个操作,我们可以确保在插入、删除等堆操作后,堆都能保持正确的性质。这是实现高效的堆数据结构的关键。
Dart实现
class Heap<T extends Comparable<T>> {
final List<T> _elements = [];
final bool _isMaxHeap;
Heap([bool isMaxHeap = true]) : _isMaxHeap = isMaxHeap;
bool get isEmpty => _elements.isEmpty;
int get length => _elements.length;
void insert(T element) {
_elements.add(element);
_heapifyUp(_elements.length - 1);
}
T? extractMax() {
if (isEmpty) return null;
_elements.swap(0, _elements.length - 1);
final max = _elements.removeLast();
_heapifyDown(0);
return max;
}
void _heapifyUp(int index) {
int currentIndex = index;
while (currentIndex > 0) {
final parentIndex = (currentIndex - 1) ~/ 2;
if (_compare(_elements[currentIndex], _elements[parentIndex])) {
_elements.swap(currentIndex, parentIndex);
currentIndex = parentIndex;
} else {
break;
}
}
}
void _heapifyDown(int index) {
int currentIndex = index;
while (true) {
final leftChildIndex = currentIndex * 2 + 1;
final rightChildIndex = currentIndex * 2 + 2;
int swapIndex = currentIndex;
if (leftChildIndex < length && _compare(_elements[leftChildIndex], _elements[swapIndex])) {
swapIndex = leftChildIndex;
}
if (rightChildIndex < length && _compare(_elements[rightChildIndex], _elements[swapIndex])) {
swapIndex = rightChildIndex;
}
if (swapIndex == currentIndex) {
break;
}
_elements.swap(currentIndex, swapIndex);
currentIndex = swapIndex;
}
}
bool _compare(T lhs, T rhs) {
return _isMaxHeap ? lhs.compareTo(rhs) > 0 : lhs.compareTo(rhs) < 0;
}
List<T> heapsort() {
final sorted = <T>[];
while (!isEmpty) {
sorted.add(extractMax()!);
}
return sorted.reversed.toList();
}
}
extension _ListSwap<T> on List<T> {
void swap(int i, int j) {
final temp = this[i];
this[i] = this[j];
this[j] = temp;
}
}
堆的应用
假设现在我们有一个包含 10 亿个搜索关键词的日志文件,如何能快速获取到热门榜 Top 10 的搜索关键词呢?这个问题就可以用堆来解决,这也是堆这种数据结构一个非常典型的应用。 堆这种数据结构几个非常重要的应用:优先级队列、求 Top K 和求中位数。
堆的应用一:优先级队列
优先级队列,顾名思义,它首先应该是一个队列。队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。
如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。
优先级队列的应用场景非常多。比如,赫夫曼编码、图的最短路径、最小生成树算法等等。不仅如此,很多语言中,都提供了优先级队列的实现,比如,Java 的 PriorityQueue,C++ 的 priority_queue 等。
堆的应用二:利用堆求 Top K
求 Top K 的问题可以抽象成两类。一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。另一类是针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。
针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。
遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。
针对动态数据求得 Top K 就是实时 Top K。怎么理解呢?举一个例子。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻返回给他。
堆的应用三:利用堆求中位数
如何求动态数据集合中的中位数? 中位数,顾名思义,就是处在中间位置的那个数。如果数据的个数是奇数,把数据从小到大排列,那第 2n+1 个数据就是中位数(注意:假设数据是从 0 开始编号的);如果数据的个数是偶数的话,那处于中间位置的数据有两个,第 2n 个和第 2n+1 个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 2n 个数据。
对于一组静态数据,中位数是固定的,我们可以先排序,第 2n 个数据就是中位数。每次询问中位数的时候,我们直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。但是,如果我们面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。
借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。我们来看看,它是如何做到的?我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 2n 个数据存储在大顶堆中,后 2n 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 2n+1 个数据,小顶堆中就存储 2n 个数据。
我们前面也提到,数据是动态变化的,当新添加一个数据的时候,我们如何调整两个堆,让大顶堆中的堆顶元素继续是中位数呢?如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果 n 是偶数,两个堆中的数据个数都是 2n;如果 n 是奇数,大顶堆有 2n+1 个数据,小顶堆有 2n 个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。
于是,我们就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。