堆(Heap)的实现及堆(Heap)的应用(Swift && Dart)

532 阅读9分钟

堆(Heap)

堆(Heap)是一种特殊的完全二叉树数据结构,它有以下特点:

  1. 堆中任意节点的值都必须大于等于(或小于等于)其子树中任意节点的值。这种性质被称为堆性质。
  2. 堆总是一棵完全二叉树。也就是说,除了最底层,其他层的节点都被元素填满,且最底层的元素都集中在左侧连续的位置上。

根据堆顶元素的值与子树元素的关系,我们可以将堆分为最大堆和最小堆:

  • 最大堆:堆顶元素是堆中所有元素的最大值。
  • 最小堆:堆顶元素是堆中所有元素的最小值。

堆的主要操作包括:

  1. heapify:将一个无序数组构建成堆的过程。
  2. insert:向堆中插入一个新元素。
  3. extract_max/extract_min:删除并返回堆顶元素。
  4. 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 操作来恢复堆的性质。

具体步骤如下:

  1. 从根节点(索引为0)开始,与其左右子节点进行比较。
  2. 找到三个节点中最大(或最小)的那个,记录其索引。
  3. 如果根节点不是最大(或最小)的,则将根节点与最大(或最小)节点交换位置。
  4. 递归地对交换后的子树进行 heapifyDown 操作,直到满足堆的性质。

heapifyUp:

heapifyUp 操作用于在向堆中插入新元素时维护堆的性质。当我们向堆中添加一个新元素时,需要将其添加到数组的末尾,然后执行 heapifyUp 操作来恢复堆的性质。

具体步骤如下:

  1. 从插入的节点(索引为 n-1n 为当前堆的大小)开始,与其父节点进行比较。
  2. 如果插入的节点比父节点大(或小),则交换两个节点的位置。
  3. 递归地对交换后的父节点进行 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)。实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。