测试开发基础之算法(13):堆、堆排序及三种应用(优先级队列、Top k、中位数)

1. 什么是堆?

堆是一种完全二叉树,堆中每一个节点的值都必须大于等于(或者小于等于)子树中每一个节点的值。
完全二叉树要求,除了最后一层,每一层的节点数都是满的,最后一层的节点都是靠左排列。
对于每一个节点的值都大于等于其子树中每一个节点的值的堆,叫大顶堆;对于每一节点的值都小于等于其子树中每一节点的值的堆,叫小顶堆。
看几个例子:
在这里插入图片描述
上图中,①是大顶堆②是大顶堆③是小顶堆④不是堆因为他不是完全二叉树。

2.堆的存储方式

完全二叉树比较适合用数组来存储,相比链表法存储非常节省空间。所以对于堆,也非常适合用数组来存储。
下图是用数组存储堆的例子
在这里插入图片描述
堆顶元素存储在数组下标为i=0的位置,左子节点存储在数组下标为i=1的位置,右子节点存在下标为i=2的位置。总结下来,假设堆中某个元素存储在数组下标i的位置,那这个元素的左子节点就存在数组下标为2i+1的位置,右子节点就存在数字下标为2i+2的位置,父节点就是存在i/2的位置。

3.往大顶堆中插入一个元素

往堆中插入一个元素后,我们需要继续满足堆的两个特性。
我们可以让新插入的节点先放在数组最后,然后将其与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间的大小关系满足大顶堆的要求。
https://www.cnblogs.com/MasterMonkInTemple/p/11363445.html

class Heap:
    def __init__(self):
        """
        用数组存储,从下标为0处开始存
        假设堆中某个节点在数组中的下标是index,则:
        1. 它的父节点在数组中的下标是index//2,
        2. 它的左子节点在数组的下标是2*index+1
        3. 它的由子节点在数组的下标是2*index+2
        """
        self.items = []

    def insert(self, data):
        """
        插入元素到堆
        将元素先放到数组最后,然后从下往上堆化
        """
        index = len(self.items)  # 以下两行顺序不能颠倒
        self.items.append(data)
        # 开始从下往上堆化
        while index != 0:
            parent_index = index // 2
            if self.items[index] > self.items[parent_index]:
                self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
            index = parent_index
if __name__ == '__main__':
    heap = Heap()
    heap.insert(9)
    heap.insert(10)
    heap.insert(7)
    heap.insert(12)
    heap.insert(3)
    heap.insert(4)
    print(heap.items)

4.删除大顶堆顶元素

把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。

class Heap:
    def __init__(self):
        """
        用数组存储,从下标为0处开始存
        假设堆中某个节点在数组中的下标是index,则:
        1. 它的父节点在数组中的下标是index//2,
        2. 它的左子节点在数组的下标是2*index+1
        3. 它的由子节点在数组的下标是2*index+2
        """
        self.items = []

    def insert(self, data):
        """
        插入元素到堆
        将元素先放到数组最后,然后从下往上堆化
        """
        index = len(self.items)  # 以下两行顺序不能颠倒
        self.items.append(data)
        # 开始从下往上堆化
        while index != 0:
            parent_index = index // 2
            if self.items[index] > self.items[parent_index]:
                self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
            index = parent_index

    def remove(self):
        """
        删除队顶元素
        将最后一个元素放在堆顶,再从上往下依次堆化
        """
        removed_data = self.items[0]
        self.items[0] = self.items[-1]
        del self.items[-1]
        # 从上往下堆化
        self.heapify(0)
        return removed_data

    def heapify(self, index):
        """
        从index开始,由上往下依次堆化
        """
        max_index = len(self.items) - 1
        while True:
            parent_index = index
            left_child_index = 2 * index + 1
            right_child_index = 2 * index + 2
            if left_child_index <= max_index and self.items[left_child_index] > self.items[parent_index]:
                parent_index = left_child_index
            if right_child_index <= max_index and self.items[right_child_index] > self.items[parent_index]:
                parent_index = right_child_index
            if parent_index == index:  # 前面两个if都不成立,才会到这里
                break
            self.items[index], self.items[parent_index] = self.items[parent_index], self.items[index]
            index = parent_index


if __name__ == '__main__':
    heap = Heap()
    heap.insert(9)
    heap.insert(10)
    heap.insert(7)
    heap.insert(12)
    heap.insert(3)
    heap.insert(4)
    print(heap.items)
    print(heap.remove())
    print(heap.items)

一个包含 n 个节点的完全二叉树,树的高度不会超过 log2​n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。

5.堆排序

排序的过程大致分解成两个大的步骤,建堆排序

下面图示展示了数组[7, 5, 19, 8, 4, 1, 20, 13, 16]的建堆过程。调整节点与左右子节点大小关心,让其满足堆的特性,这个过程就叫作堆化(heapify)。建堆,就是不断对节点进行堆化的过程。
因为堆是完全二叉树,而对于完全二叉树来说,下标从 n/2到 n-1 的节点都是叶子节点,而叶子节点是不需要堆化的,我们只需要对下标从 n/2-1​ 开始到 0 的数据进行堆化。见下面代码中heapify函数。
在这里插入图片描述

def heapify(arr, n, i):  # 堆化以下标i为根的子树
    """
    从下标i开始堆化数组arr。
    :param arr: 待堆化的数组
    :param n: 数组长度
    :param i: 堆顶下标
    :return:
    """
    while True:
        max_pos = i
        left = 2 * i + 1
        right = 2 * i + 2
        if left < n and arr[left] > arr[i]:
            max_pos = left
        if right < n and arr[right] > arr[max_pos]:
            max_pos = right
        if max_pos == i:  # 前两个if不成立时,不用堆化了
            break
        arr[i], arr[max_pos] = arr[max_pos], arr[i]  # 交换
        i = max_pos

def build_heap(arr, n): # 建堆,构建大顶堆
    for i in range(n // 2, -1, -1):
        heapify(arr, n, i)

建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的下标为0的元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n-1 的位置。然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。

def heap_sort(arr):
    n = len(arr)
    build_heap(arr, n)
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]  # 交换堆顶元素和最后一个元素
        heapify(arr, i, 0)

arr = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
build_heap(arr, 10)
print(arr)
heap_sort(arr)
print(arr)

整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。

6. 为什么快速排序要比堆排序性能好?

实际的应用开发中,很少有人使用堆排序,而是更多的采用快速排序。主要有以下两点原因:

6.1 堆排序数据访问不友好

对于堆排序,访问数组元素是跳着访问的。比如,下图对堆顶元素进行堆化时,要一次访问下标为1,2,4,8的元素。而快速排序时,对数组的访问是顺序访问的。因此快速排序对CPU的缓存更友好。
在这里插入图片描述

6.2 堆排序的交换次数过多

前面排序算法的章节中,介绍了基于比较的排序算法,最大的交换次数是逆序度。而堆排序的第一步建堆的过程中,会打乱数组的有序度。从而增加了逆序度。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。比如下图:
在这里插入图片描述

10.Pyhton的heapq模块

10.1 创建堆

import heapq

li = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
heap = []
print(li)
for i in li:
    heapq.heappush(heap, i)
print(heap)

heapq.heapify(li)
print(li)

10.2 合并多个的序列使之有序

l1 = [23, 2, 12, 656, 324, 23, 54]
l2 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
sl1 = sorted(l1)
sl2 = sorted(l2)
print(sl1, sl2)
sl = heapq.merge(sl1, sl2)
for i in sl:
    print(i)

10.3 堆排序

l3 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]


def heap_sort(nums):
    heapq.heapify(nums)
    return [heapq.heappop(l3) for _ in range(len(l3))]


print(heap_sort(l3))

10.4 获取堆中最大或最小值

l4 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
profile = [
    {'name': 'IBM', 'shares': 100, 'price': 91.1},
    {'name': 'AAPL', 'shares': 50, 'price': 543.22},
    {'name': 'FB', 'shares': 200, 'price': 21.09},
    {'name': 'HPQ', 'shares': 35, 'price': 31.75},
    {'name': 'YHOO', 'shares': 45, 'price': 16.35},
    {'name': 'ACME', 'shares': 75, 'price': 115.65}
]
n_largest = heapq.nlargest(3, l4)  # 相当于sorted(iterable, reverse=True)[:n]
print(n_largest)
n_smallest = heapq.nsmallest(3, l4)  # 相当于sorted(iterable, reverse=False)[:n]
print(n_smallest)
n_largest_profile = heapq.nlargest(3, profile, key=lambda x: x['shares'])  # 相当于sorted(profile, key=lambda x: x['shares'], reverse=True)[:3]
print(n_largest_profile)

10.5 更换堆顶元素

l5 = [7, 5, 19, 8, 4, 1, 20, 13, 16, 21]
heapq.heapify(l5)
heapq.heapreplace(l5, 50)  # 相当于删除堆中最小元素并加入一个新元素
print(l5)

10.1 实现优先级队列

优先级队列的特点:
给定一个优先级(Priority)
每次pop操作都会返回一个拥有最高优先级的项。

class MyPriorityQueue:

    def __init__(self):
        self._index = 0  # 指针用于记录push的次序
        self.queue = []  # 创建一个空列表用于存放队列

    def push(self, priority, value):
        """队列由(priority, index, item)形式的元组构成"""
        heapq.heappush(self.queue, (-priority, self._index, value))  # 根据-priority的值建堆,heapq构建的是小顶堆,所以要加负号
        self._index += 1  # 如果优先级一样,则按index

    def pop(self):
        return heapq.heappop(self.queue)[-1]  # 返回拥有最高优先级的项


pq = MyPriorityQueue()
pq.push(5, "foo")
pq.push(1, "bar")
pq.push(3, "spam")
pq.push(1, "grok")
for i in range(4):
    print(pq.queue)
    print(pq.pop())

从输出中可以看到,pop()是每次返回一个拥有最高优先级的项。对于拥有相同优先级的项(bar和grok),会按照被插入队列的顺序来返回。代码的核心是利用heapq模块,之前已经说过,heapq.heappop()会返回最小值项,因此需要把 priority 的值变为负,才能让队列将每一项按从最高到最低优先级的顺序级来排序。

其实Python提供了现成的优先级队列,就是queue.PriorityQueue类。相比我自己实现的优先级队列,PriorityQueue的操作是同步的,提供锁操作,支持并发的生产者和消费者。

10.2 Top K问题

求 Top K 的问题抽象成两类:一类是从静态数据集合中求TopK,一类是从动态集合中求TopK。
静态集合数据求Top K,指的是在数据结合不变的情况下,求出Top K。可以先通过排序,在用切片操作。例如借助sorted(iterable, key=key, reverse=True)[:n],也可以通过heapq.nlargest或者heapq.nsmallest方法。

这是针对数据量比较小的情况下,如果数据量是海量的数据,这种方法将导致内存不够用。
从海量数据中找Top K,可以维护一个包含K个元素的小顶堆,最开始将海量数据中的前K个元素放入堆中,K+1个以后的元素,逐个与堆顶比较,若大于堆顶,则入堆,然后删除堆顶;依此往复,直至扫描完所有元素。无论任何时候需要查询当前的前 K 大数据,我们把堆中数据返回即可。

动态集合求Top K,指的是数据集合中一边在添加数据,另一边实时询问当前的Top K 。求解办法一样。

下面看代码实现:

def top_k(nums, k):
    if inputs is None or len(nums) < k or len(nums) <= 0 or k <= 0:  # 注意极限条件的确定
        return []
    output = []

    for number in nums:
        # 先用前k个元素生成一个小顶堆,这个小顶堆用于存储当前最大的k个元素。
        if len(output) < k:
            heapq.heappush(output, number)
        # 从第k+1个元素开始扫描,和堆顶(堆中最小的元素)比较
        else:
            if number > output[0]:  # 如果被扫描的元素大于堆顶,则替换堆顶的元素
                heapq.heapreplace(output, number)  # heapreplace有替换并调整堆的功效,以保证堆内的k个元素,总是当前最大的k个元素。

    return output[::-1]


inputs = [4, 5, 1, 6, 2, 7, 10, 3, 8]
print(top_k(inputs, 3))

10.3 中位数问题

中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。
例如,

[2,3,4] 的中位数是 3

[2,3] 的中位数是 (2 + 3) / 2 = 2.5

对于数据量比较小的数据集,求中位数,最简单的办法是先排序在索引。比如,

L = [0, 1, 5, 6, 2, 3, 4]
l = len(L)  # 数出列表中有几个元素,将个数放到l里
L.sort()  # 将列表按升序排列
if l % 2 == 0:  # 如果有偶数个整数
    m = (L[int(l // 2) - 1] + L[int(l // 2)]) / 2  # 计算中间两个的平均值,存到m里
    print("%.1f" % m)
else:
    m = L[int(l // 2)]  # 将中间那个整数的值存到m里
    print(m)  # 直接打印m

如果是从数据流中,实时获取数据的中位数,则需要借助堆,利用两个堆来实现,一个大顶堆(利用取相反数来实现),一个小顶堆。

用一个大顶堆和一个小顶堆来维护数据,每次每个数进来,先把它丢进小顶堆,然后把小顶堆的堆顶丢进大顶堆,调整两个堆,使得size 差最大为1。

这么搞的好处在于,小顶堆是数据流里前一半大的数,大顶堆是数据流里后一半的大的数,

而且小顶堆的size一定 >= 大顶堆的size,小顶堆的堆顶M是小顶堆里最小的数,大顶堆的堆顶N是大顶堆里最大的数,如果两个堆的size相同,那么中位数就是return (M + N) / 2.0 ,否则,return M / 1.0。

min_h = []
heapq.heapify(min_h)
max_h = []
heapq.heapify(max_h)

# 构建大顶堆和小顶堆
def add_num(num):
    heapq.heappush(min_h, num) # 先放入小顶堆
    heapq.heappush(max_h, -heapq.heappop(min_h))  # 把小顶堆堆顶放入大顶堆
    if len(max_h) > len(min_h):  # 维持小顶堆比大顶堆数据多一个
        heapq.heappush(min_h, -heapq.heappop(max_h))

# 输出中位数
def find_median(num):
    add_num(num)
    print(min_h)
    print(max_h)
    if len(min_h) == len(max_h):  # 大顶堆和小顶堆数据一样多
        return (min_h[0] + (-max_h[0])) / 2
    else:  # 小顶堆对大顶堆多一个数据,返回小顶堆堆顶元素
        return min_h[0]

# 随着不断加入数据,可以输出中位数
print(find_median(1))
print(find_median(2))
print(find_median(3))
print(find_median(4))
print(find_median(5))

11.参考资料

https://www.geeksforgeeks.org/heap-queue-or-heapq-in-python/
https://docs.python.org/3.8/library/heapq.html
https://docs.python.org/zh-cn/3/library/heapq.html
https://cloud.tencent.com/developer/article/1414361
https://dbafu.github.io/2017/12/25/Heap-python-data-structure-and-its-realization/

发布了187 篇原创文章 · 获赞 270 · 访问量 172万+

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/103479121