详解基于堆的算法

详解基于堆的算法

文章目录

  • 详解基于堆的算法
    • 概念
    • 分类及特点
    • 基础算法
      • max-heapify
      • build-max-heap
      • heap-sort
      • priority queue(优先队列)
        • 概念
        • 应用
        • heap-extract-max
        • heap-increase-key
        • heap-insert
      • 经典例题
        • leetcode-[373. Find K Pairs with Smallest Sums](https://leetcode.cn/problems/find-k-pairs-with-smallest-sums/)
        • 利用堆解决 topk 问题
    • 经典例题
      • 丑数问题
      • leetcode-218-天际线问题

概念

《算法导论》第 6 章

(二叉)堆是一种数据结构,它可以看成是一个近似于完全二叉树(除了最后一层外,其他层都被填满)的数组。

分类及特点

大根堆(max-heap):根节点大于等于左右节点。常用于堆排序。

小根堆(min-heap):根节点小于等于左右节点。常用于构建优先队列。

性质

堆结构上的基本操作的运行时间大多与树的高度成正比,即 logN

基础算法

max-heapify: 时间复杂度 O(logN)

Build-max-heap: 从无序的数组中构造一个最大堆

heapsort: 堆排序

Max-heap-insert, heap-extract-max, heap-increase-key, heap-maximum 时间复杂度是 log(N), 功能是利用堆实现一个优先队列

max-heapify

def heapify(nums: List[int], length: int, i: int):
    """
    以 i 为根节点构造 max-heap
    时间复杂度:O(logN) N 是 length 大小
    :param nums: 原始数组
    :param length: 树的大小,或者说数组的长度
    :param i:
    :return:
    """
    largest = i
    l = 2 * i + 1
    r = 2 * i + 2
    # Tip: 有可能有存在左节点没有右节点的场景,所以不要提前判断 l r 的大小
    if l < length and nums[largest] < nums[l]:
        largest = l
    if r < length and nums[largest] < nums[r]:
        largest = r
    if largest != i:
        nums[i], nums[largest] = nums[largest], nums[i]
        # Tip:此时 largest 所在的位置可能又不符合 max-heap 了所以要递归
        heapify(nums, length, largest)

build-max-heap

def build_max_heap(nums: List[int]):
    """
    根据《算法导论》中的推算,时间复杂度是 O(N)
    :param nums:
    :return:
    """
    length = len(nums)
    # Tip: 叶子节点已经符合大根堆的性质,所以无需 heapify
    for i in range(length // 2 - 1, -1, -1):
        heapify(nums, length, i)

heap-sort

def heap_sort(nums: List[int]):
    """
    时间复杂度:O(NlogN) O(N+NlogN) 即为 O(NlogN)
    空间复杂度:O(1)
    :param nums:
    :return:
    """
    length = len(nums)
    build_max_heap(nums)
    for i in range(length - 1, 0, -1):
        nums[i], nums[0] = nums[0], nums[i]
        heapify(nums, i, 0)

priority queue(优先队列)

概念

普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除。

在优先队列中,元素被赋予优先级。当访问元素时,具有最高优先级的元素最先删除。优先队列具有最高级先出 (first in, largest out)的行为特征。

应用

  1. 最大优先队列:计算机系统的作业调度:
    1. 当一个作业完成或者被中断后,调度器调用 extract-max 从所有等待作业中选出最高优先级的作业执行
    2. 任何时候可以调用 insert 把一个新的作业加入到队列中来
  2. 最小优先队列:基于事件驱动的模拟器
    1. 每一步,模拟器调用 extract-min 选择下一个要模拟的事件
    2. 当一个新事件发生时,模拟器调用 insert 将其插入到最小优先级队列中

heap-extract-max

提取最大元素

def heap_extract_max(nums: List[int]) -> int:
    """
    此时 nums 是一个大根堆,提取最大元素
    时间复杂度:O(logN)
    :param nums:
    :return:
    """
    length = len(nums)
    if length < 1:
        return None
    max_val = nums[0]
    nums[0] = nums[length - 1]
    # 重新构建大根堆
    heapify(nums, length-1, 0)
    return max_val

heap-increase-key

增大堆中某个位置的值

def heap_extract_max(nums: List[int]) -> int:
    """
    此时 nums 是一个大根堆
    时间复杂度:O(logN)
    :param nums:
    :return:
    """
    length = len(nums)
    if length < 1:
        return None
    max_val = nums[0]
    nums[0] = nums[length - 1]
    # 重新构建大根堆
    heapify(nums, length - 1, 0)
    return max_val

heap-insert

新增一个元素

def heap_insert(nums: List[int], key: int):
    """
    新增一个元素
    时间复杂度:O(logN)
    :param nums:
    :param key:
    :return:
    """
    length = len(nums)
    nums.append(key)
    heap_increase_key(nums, length, key)

if __name__ == "__main__":
    nums2 = [5, 7, 6, 4, 1, 3, 6]
    build_max_heap(nums2)
    heap_insert(nums2, 10)
    print(nums2) # [10, 7, 6, 5, 1, 3, 6, 4]

经典例题

leetcode-373. Find K Pairs with Smallest Sums

经典的使用 priority queue 解决的问题,其中也要发现一些技巧,比如向堆中 push 的时候避免重复采取的小技巧

class Solution:
    def heapify(self, nums: List[List[int]], length: int, i: int):
        """
        从 i 开始构建最小堆
        :param i:
        :param length:
        :param nums:
        :return:
        """
        l, r = i * 2 + 1, i * 2 + 2
        min_idx = i
        if l < length and nums[l][2] < nums[min_idx][2]:
            min_idx = l
        if r < length and nums[r][2] < nums[min_idx][2]:
            min_idx = r
        if min_idx != i:
            nums[i], nums[min_idx] = nums[min_idx], nums[i]
            self.heapify(nums, length, min_idx)

    def build_min_heap(self, nums: List[List[int]]):
        length = len(nums)
        # 最后一个非叶子节点所在索引
        idx = length // 2 - 1
        for i in range(idx, -1, -1):
            self.heapify(nums, length, i)

    def heap_extract_min(self, nums: List[List[int]]) -> List[int]:
        min_val = nums[0]
        length = len(nums)
        nums[0], nums[length - 1] = nums[length - 1], nums[0]
        nums.pop()
        self.heapify(nums, length - 1, 0)
        return min_val

    def heap_decrease(self, nums: List[List[int]], i: int, val: List[int]):
        """
        索引 i 处 修改为 val
        :param nums:
        :param i:
        :param val:
        :return:
        """

        # 根节点
        def get_root(idx: int) -> int:
            return (idx + 1) // 2 - 1

        nums[i] = val
        while get_root(i) >= 0 and nums[get_root(i)][2] > nums[i][2]:
            nums[get_root(i)], nums[i] = nums[i], nums[get_root(i)]
            i = get_root(i)

    def heap_push(self, nums: List[List[int]], val: List[int]):
        nums.append(val)
        length = len(nums)
        self.heap_decrease(nums, length - 1, val)

    def kSmallestPairs(self, nums1: List[int], nums2: List[int], k: int) -> List[List[int]]:
        """
        已知最小的是 (0,0), 下一个就是待比较的就是 (0,1) 和 (1,0)
        假如下一个是 (0,1) 那么下一个要比较的是 (1,0) (1,1) (0,2) => (1,0) (0,2)
        假如下一个是 (1,0) 那么下一个要比较的是 (0,1) (1,1) (2,0) => (0,1) (2,0)

        假如下一个是 (1,0) 那么下一个要比较的是 (0,2) (1,1) (2,0)
        其实就是上次比较的数,加上新的 (a+1,b) (a,b+1),但是每次都这么增加其实带有重复的情况,比如
        (0,0)
        (0,1) (1,0)
        选 (0,1) 则 (1,0) 再加入 (1,1) (0,2)
        选 (1,0) 则 (1,1) (0,2) 再加入 (1,1) (2,0) 此时重复了 (1,1)
        如果一开始我们就有 (0,0) (1,0) (2,0)...(k-1,0), 找到最小值 (a,b) 之后每次都添加 (a,b+1) 则变成了
        (0,0)
        (1,0)... 再加入 (0,1)
        选 (0,1) 则 (1,0) (2,0) ... 再加入 (0,2)
        选 (1,0) 则 (2,0) (0,2) ... 再加入 (1,1) 满足需求
        
        建堆:O(N)
        extract_min: O(logN)
        heap_push: O(logN)
        时间复杂度:O(klogk)
        空间复杂度:O(k)
        :param nums1:
        :param nums2:
        :param k:
        :return:
        """
        m, n = len(nums1), len(nums2)
        nums = [[i, 0, nums1[i]+nums2[0]] for i in range(min(k, m))]
        self.build_min_heap(nums)
        res = []
        while nums and len(res) < k:
            [i, j, _] = self.heap_extract_min(nums)
            res.append([nums1[i], nums2[j]])
            if j+1 < n:
                self.heap_push(nums, [i, j + 1, nums1[i]+nums2[j+1]])
        return res

利用堆解决 topk 问题

如果解决 topk 问题可以选 k 个数构建 Min-heap,之后遍历剩下的元素与 heap[0] 对比,如果大于其则加入堆中,直到最后构建出来由 top k 个数组成的堆,heap[0] 即为 topk。

如果解决 lowk 问题可以选 k 个数构建 Max-heap, 之后遍历剩下的元素与 heap[0] 对比,如果小于其则加入堆中,直到最后构建出来由 low k 个数组成的堆, heap[0] 即为 lowk。

TopK问题三种方法总结

经典例题

丑数问题

263. Ugly Number

264. Ugly Number II

313. Super Ugly Number

方法 1:通过构建 min-heap,n 次向堆中加入质数的倍数,并在每一次提取最小值

class Solution:
    def heapify(self, nums: List[int], length: int, idx: int):
        if length == 0:
            return
        min_idx = idx
        l = idx * 2 + 1
        r = idx * 2 + 2
        if l < length and nums[l] < nums[min_idx]:
            min_idx = l
        if r < length and nums[r] < nums[min_idx]:
            min_idx = r
        if min_idx != idx:
            nums[min_idx], nums[idx] = nums[idx], nums[min_idx]
            self.heapify(nums, length, min_idx)

    def extract_min(self, nums: List[int]) -> int:
        res = nums[0]
        length = len(nums)
        nums[0] = nums[length - 1]
        nums.pop()
        self.heapify(nums, length - 1, 0)
        return res

    def change_val(self, nums: List[int], idx: int, val: int):
        nums[idx] = val

        def root(index: int) -> int:
            return (index + 1) // 2 - 1

        while root(idx) >= 0 and nums[root(idx)] > nums[idx]:
            nums[idx], nums[root(idx)] = nums[root(idx)], nums[idx]
            idx = root(idx)

    def insert_val(self, nums: List[int], val: int):
        nums.append(val)
        length = len(nums)
        self.change_val(nums, length - 1, val)

    def nthUglyNumber_0(self, n: int) -> int:
        """
        通过构建 min-heap,每次选择 heap[0],并推进 heap[0]*[2,3,5] 下次继续选择 heap[0] 直到选到第 n 个值即可
        时间复杂度:O(NlogN)
        空间复杂度:O(N)
        1
        push 2 3 5
        select 2
        push 4 6 10
        select 3
        push 6 9 15 因此要用一个哈希表暂存加入过 heap 的值
        :param n:
        :return:
        """
        nums = [1]
        prime = [2, 3, 5]
        hash_table = {1: True}
        res = 0
        for i in range(n):
            res = self.extract_min(nums)
            for val in prime:
                if val * res not in hash_table:
                    self.insert_val(nums, val * res)
                    hash_table[val * res] = True
        return res

方法 2:dp 获取,这种思路挺难想到的,利用了三个指针

class Solution:
      def nthUglyNumber(self, n: int) -> int:
        """
        dp 实现求第 n 个元素
        我们知道第一个数是 1
        第二批是 2 3 5 从中选一个小的 2
        第三批就是 4 3 5 选一个小的 3
        第四批就是 4 5 6 选 4
        第五批就是 5 6 6 选 5
        由此可见每一批数字都跟上一次选出来的结果有关,即问题的解是由子问题的最优解构成的,即符合最优子结构性质
        状态转移方程:F(n) = min(F(f2)(n-1)*2, F(f3)(n-1)*3, F(f5)(n-1)*5)
        边界条件:初值 f2=f3=f5=1, f2 指的是还没有使用乘 2 机会的已有结果的索引,它的前一位已经使用过了。
        在每次循环中通过 min 函数对比选择了哪一个则哪一个 f指针 加 1,而且为了保证去重:相同的值则都加一
        时间复杂度:O(n)
        空间复杂度:O(n)
        1
        2 3 5 选 2   2 1 1
        4 3 5 选 3   2 2 1
        4 6 5 选 4   3 2 1
        6 6 5 选 5   3 2 2
        6 6 10 选 6  4 3 2
        8 9 10 选 8  5 3 2
        10 9 10 选 9 5 4 2
        10 12 10 选 10   6 4 3
        12 12 15 选 12   7 5 3
        :param n:
        :return:
        """
        dp = [0] * (n + 1)
        dp[1] = 1
        idx2, idx3, idx5 = 1, 1, 1
        for i in range(2, n + 1):
            num2 = 2 * dp[idx2]
            num3 = 3 * dp[idx3]
            num5 = 5 * dp[idx5]
            dp[i] = min(num2, num3, num5)
            # Tip 不能用 else 语句,可以用 10 来验证下
            if dp[i] == num2:
                idx2 += 1
            if dp[i] == num3:
                idx3 += 1
            if dp[i] == num5:
                idx5 += 1
        return dp[n]

leetcode-218-天际线问题

很有意思的一道题,hard,因此不需要完全掌握,可以了解下思路。解决问题的根本思路在于求「包含边缘点」的元素的纵坐标最大值,「包含边缘点」定义为 start<=boundary

class Solution:
	def getSkyline_0(self, buildings: List[List[int]]) -> List[List[int]]:
        """
        以 [[2,9,10],[3,7,15],[5,12,12],[15,20,10],[19,24,8]] 为例
        如果使用合并数组的方式,元素 0 与 元素 1 合并得到结果若干个再与元素 2 合并,时间复杂度其实在 O(N^2) 左右。
        但实际上答案总是在所有建筑物的边缘点上,只不过纵坐标有所差异
        根据题解,其实是观察规律得到我们总是要寻找每个建筑物或者说区间内「包含边缘点」的纵坐标的最大值,比如 [2,9,10] 它其实包含 2<=x<=8
        2 的值是 10, 9 并不包含其中,其值为 0, 我们遍历剩下的所有区间,可以得到「包含 2」的纵坐标最大值是 10,「包含 9」的纵坐标最大值是
        12,其他类推,最终将得到的边缘点从小到大排序,剔除重复纵坐标的点则得到答案
        时间复杂度:O(N^2)
        空间复杂度:O(N)
        2 10
        9  0 12
        3 15 10
        7 0 12
        5 12 15
        12 0
        15 10
        20 0 8
        19 8 10
        24 0
        排序后为 (2,10) (3,15) (5,15) (7,12) (9,12) (12,0) (15,10) (19,10) (20,8) (24,0)
        剔除 (5,15) (9,12) (19,10) 变成
        (2,10) (3,15) (7,12) (12,0) (15,10) (20,8) (24,0)
        :param buildings:
        :return:
        """
        length = len(buildings)
        boundaries = []
        for i in range(length):
            boundaries.append(buildings[i][0])
            boundaries.append(buildings[i][1])
        boundaries.sort()
        res = []
        for boundary in boundaries:
            max_val = 0
            for val in buildings:
                if val[0] <= boundary < val[1]:
                    max_val = max(max_val, val[2])
            if (res and res[-1][1] != max_val) or (not res):
                res.append([boundary, max_val])
        return res
      

但其实我们可以用优先队列来解决,我们以 buildings[x][2] 为对比值构建堆。

我们向堆中插入 buildings 元素,直到 b u i l d i n g s [ x ] [ 0 ] < = b o u n d a r y buildings[x][0]<=boundary buildings[x][0]<=boundary,buildings 是从左向右排布的所以后面的肯定不包含 boundary,然后我们 pop 出队列里 b u i l d i n g s [ x ] [ 1 ] < = b o u n d a r y buildings[x][1]<=boundary buildings[x][1]<=boundary 的值,因为它们肯定不包含 boundary, 注意 boundary 是有序的,所以后面的 boundary 不受影响。

此时堆的根就是包含 boundary 的最高的建筑,以此向后类推直到得到答案,注意去除重复高度的点即可。

class Solution:
    def heapify(self, nums: List[List[int]], length: int, idx: int):
        l = idx * 2 + 1
        r = idx * 2 + 2
        max_idx = idx
        if l < length and nums[l][1] > nums[max_idx][1]:
            max_idx = l
        if r < length and nums[r][1] > nums[max_idx][1]:
            max_idx = r
        if max_idx != idx:
            nums[max_idx], nums[idx] = nums[idx], nums[max_idx]
            self.heapify(nums, length, max_idx)

    def extract_max(self, nums: List[List[int]]):
        res = nums[0]
        length = len(nums)
        nums[0] = nums[length - 1]
        nums.pop()
        self.heapify(nums, length - 1, 0)
        return res

    def change_val(self, nums: List[List[int]], idx: int, val: List[int]):
        nums[idx] = val

        def root(idx: int) -> int:
            return (idx + 1) // 2 - 1

        while root(idx) >= 0 and nums[idx][1] > nums[root(idx)][1]:
            nums[idx], nums[root(idx)] = nums[root(idx)], nums[idx]
            idx = root(idx)

    def insert_val(self, nums: List[List[int]], val: List[int]):
        nums.append(val)
        length = len(nums)
        self.change_val(nums, length - 1, val)

    def getSkyline(self, buildings: List[List[int]]) -> List[List[int]]:
        """
        使用优先队列解题,时间复杂度:O(NlogN) 对于边界 2 而言可能是 x1logN, 对于边界 3 而言是 x2logN,累加起来就是 NlogN
        空间复杂度:O(N) 构建出来的堆 O(N), 边界数组 O(2N) N 是建筑数量
        :param buildings:
        :return:
        """
        length = len(buildings)
        boundaries = []
        for i in range(length):
            boundaries.append(buildings[i][0])
            boundaries.append(buildings[i][1])
        boundaries.sort()
        # 使用 max-heap 构建优先队列
        idx = 0
        my_heap = []
        res = []
        for boundary in boundaries:
            # tip: 为了寻找「包含 boundary」的建筑,将所有左边界小于等于 boundary 的建筑加到堆里去
            while idx < length and buildings[idx][0] <= boundary:
                self.insert_val(my_heap, [buildings[idx][1], buildings[idx][2]])
                idx += 1
            # tip: 此时构建了一个 max-heap 堆顶是高度最高的建筑,从堆中向外移除那些右边界小于等于 boundary 的建筑,因为这些建筑一定不会
            #  「包含 boundary」,同时由于 boundary 是递增的,所以小于当前 boundary 的一定也小于之后的 boundary,将其剔除掉不会影响
            #   后续 boundary 的高度的获取
            while my_heap and my_heap[0][0] <= boundary:
                self.extract_max(my_heap)
            if my_heap:
                # tip: 如果结果中上一位的高度等于当前得到的建筑高度,那么没必要加入
                if not res or (res and res[-1][1] != my_heap[0][1]):
                    res.append([boundary, my_heap[0][1]])
            else:
                if not res or (res and res[-1][1] != 0):
                    res.append([boundary, 0])
        return res

你可能感兴趣的:(Algorithm,算法,数据结构)