常用算法与数据结构模板系列(九)

Posted by John Mactavish on October 13, 2020

简介

堆(Heap)是一种特别的完全二叉树。若是满足以下特性,即可称为堆:“给定堆中任意结点 P 和 C,若 P 是 C 的父结点,那么 P 的值会小于等于(或大于等于) C 的值”。若父结点的值恒小于等于子结点的值,此堆称为最小堆(min heap);反之,若父结点的值恒大于等于子结点的值,此堆称为最大堆(max heap)。

复习一下,如果一个深度为 k 的二叉树有 2^(k+1) - 1 个结点,则称为完美二叉树。它的外形是完美无缺的三角形。 而完全二叉树从根结点到倒数第二层满足完美二叉树,最后一层可以不完全填充,其叶子结点都靠左对齐。

由于完全二叉树的良好特性,一般使用线性表存储堆。如下图所示,堆上结点按从上到下、从左到右的顺序存入数组中。

contribution

堆支持的操作主要有:push(添加元素)、pop(弹出堆顶)、peek(查看堆顶)。当然,在此过程中始装维持堆特性不变。

实现

下面介绍如何实现堆。

结点索引对应关系

观察数组到堆的下标表示。将数组的索引设为 i。则:

  • 左孩子找父结点:parent(i) = (i - 1) / 2。比如 12 元素的索引为 5,其父亲结点 71 的下标 parent(2) = (5 - 1) / 2 = 2
  • 右孩子找父结点:parent(i) = (i - 2) / 2。比如 29 元素找父结点 (6 - 2) / 2 = 2

因为计算机内整数相除会省略小数,所以也可统一为 parent(i) = (i - 1) / 2

反过来自然得到:

  • 父结点找左孩子:leftChild(i) = parent(i) * 2 + 1
  • 父结点找右孩子:rightChild(i) = parent(i) * 2 + 2

上浮与下沉

在此基础上,实现堆要用到两个重要操作:上浮(sift up)与下沉(sift down)。

以最大堆为例。

当向堆中添加一个元素时,先将这个元素添加到数组尾。此时一般不满足堆的性质,所以需要将它上浮到合适的位置。 因而将这个新添加的元素和它的父结点进行优先权的比较,如果比父结点的优先权要大,则和父结点交互位置; 然后不断重复和新的父结点比较,直到比新的父结点优先权小或者到达根结点为止。

contribution

堆只支持弹出根结点。但直接弹出根结点后,堆就会被破坏。此时不妨将数组尾的元素转移到根结点的位置,然后只要让它下沉到合适的位置即可。 注意下沉时元素与左右子结点中的较大值交换。因为只有较大值才能做原来的兄弟结点的根结点,反过来自然不行。

contribution

示例

下面实现最小堆,最大堆同理。

class MinHeap:
    def __init__(self):
        self.li = []

    def __len__(self):
        return len(self.li)

    def _swap(self, i, j):
        self.li[i], self.li[j] = self.li[j], self.li[i]

    def _sift_up(self, i):
        parent = (i - 1) // 2

        if parent < 0: 

            # reached the head

            return

        if self.li[parent] > self.li[i]:
            self._swap(parent, i)

            # recursive operation

            self._sift_up(parent) 

    def _sift_down(self, i):
        left_child, right_child = i * 2 + 1, i * 2 + 2
        if left_child >= len(self) and right_child >= len(self): 
        
            # reached the last
        
            return

        # must swap with the smaller child

        smaller = left_child  
        if right_child < len(self) and self.li[right_child] < self.li[smaller]:
            smaller = right_child
        if self.li[smaller] < self.li[i]:
            self._swap(smaller, i)

            # recursive operation

            self._sift_down(smaller) 

    def push(self, elem):
        self.li.append(elem)
        self._sift_up(len(self) - 1)

    def pop(self):
        # swap with the head

        self._swap(len(self) - 1, 0) 
        ret = self.li.pop()
        self._sift_down(0)
        return ret

    def peek(self):
        return self.li[0]

应用

堆常用于实现优先队列,而优先队列常用于解决 Top K Element 问题。例如:

在未排序的数组中找到第 k 个最大的元素请注意你需要找的是数组排序后的第 k 个最大的元素而不是第 k 个不同的元素

示例 1:
输入: [3,2,1,5,6,4]  k = 2
输出: 5

示例 2:
输入: [3,2,3,1,2,4,5,5,6]  k = 4
输出: 4

说明:
你可以假设 k 总是有效的 1  k  数组的长度

我们可以维护一个有 K 个元素的最小堆:

  1. 如果当前堆不满,直接添加;
  2. 堆满的时候,如果新读到的数小于等于堆顶,肯定不是我们要找的元素,只有新到的数大于堆顶的时候,才将堆顶拿出,然后放入新读到的数。

使用上面实现的最小堆,有:

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        heap = MinHeap()
        for i in range(k):
            heap.push(nums[i])
        for i in range(k, len(nums)):
            if nums[i] <= heap.peek():
                continue
            heap.pop()
            heap.push(nums[i])
        return heap.peek()

参考:

Wiki

liweiwei1419 的 LeetCode 解答

tuke_tuke 的博客

“超级小小黑”的博客

“西檬饭”的博客

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe