15.堆
堆,heap,特殊的树,应用场景多,最经典应用堆排序,一种原地、时间复杂度为O(nlogn)的排序算法。
即使比快排的时间复杂度还稳定,但实际应用中,快排却比堆排序好,why?
如何理解堆?
链接:https://jingyan.baidu.com/article/6c67b1d6a09f9a2786bb1e4a.html
1.堆是一个完全二叉树;(完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。)
2.每一个节点的值>=(或者<=)其子树(<=>左右子节点)中每个节点的值,称为大顶堆(小顶堆);
如何实现一个堆?
实现一个堆先明确堆都支持哪些操作和如何存储一个堆。
完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。

数组中下标为 i(数组从1开始,比如3) 的节点的左子节点,就是下标为 i∗2(6)的节点,右子节点就是下标为 i∗2+1(7)的节点,父节点就是下标为⌊i/2⌋(1)(向下取整)的节点。
1.往堆中插入一个元素
堆化
定义:若把新插入元素放在堆得最后,往往不符合堆的特征,需要进行调整,让其满足堆的特性
实现方法:自上而下或者自下而上(顺着节点所在路径,向上或者向下,进行交换)
class Heap(object): def __init__(self): self.list = [None]*100 self.count = 0 def swap(self,i,j): self.list[i],self.list[j] = self.list[j],self.list[i]
def insert(self,data):#从下往上
if self.count >= 100: return self.count+=1 self.list[self.count] = data count1 = self.count #while (count1 //2 >0) and (self.list[count1] < self.list[count1 // 2]):#小顶堆 while (count1 // 2 > 0) and (self.list[count1] > self.list[count1 // 2]):#大顶堆 self.swap(count1, count1 // 2) count1 = count1 // 2 def print1(self): print(self.list[1:self.count+1]) def removeMax(self):#从上往下 if self.count <=0: return self.list[1]= self.list[self.count] self.count-=1 end = self.count i = 1 j = 2*i fadeMax = self.list[1] while j <=end: if j<end and self.list[j] < self.list[j+1]: j+=1 if fadeMax<self.list[j]: self.list[i] = self.list[j] i = j j = 2*i else: break self.list[i] = fadeMax h = Heap() h.insert(70) h.insert(60) for i in [45,65,78,69,24]: h.insert(i) h.print1() print(h.count) h.removeMax() h.removeMax() h.print1() """ 一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n。 堆化的过程是顺着节点所在路径比较交换的, 所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。 插入数据和删除堆顶元素的主要逻辑就是堆化, 所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。 """
如何实现堆排序?
排序的时间复杂度非常稳定,是 O(nlogn)。并且它还是原地排序算法。
堆排序的过程大致分解成两个大的步骤,建堆和排序。
1.建堆
将原数组原地建成一个堆。
class Heap(object): def __init__(self,list): self.list = list def swap(self,i,j): self.list[i],self.list[j] = self.list[j],self.list[i] def buildHead1(self): count=0 while count < len(self.list)-1: count+=1 count1 = count while (count1 //2 >0) and (self.list[count1] < self.list[count1 // 2]):#小顶堆 #while (count1 // 2 > 0) and (self.list[count1] > self.list[count1 // 2]):#大顶堆 self.swap(count1, count1 // 2) count1 = count1 // 2 def print1(self): print(self.list[1:len(self.list)]) list1= [0,70,60,45,65,78,69,24] h = Heap(list1) h.buildHead1() """ 方法一: 尽管数组中包含 n 个数据,但是我们可以假设,起初堆中只包含一个数据, 就是下标为 1 的数据。然后,我们调用前面讲的插入操作, 将下标从 2 到 n 的数据依次插入到堆中。 这样我们就将包含 n 个数据的数组,组织成了堆。 """ h.print1()
def heap_adjust(L,start,end):
temp = L[start] i = start j = 2*i while j<=end: if (j<end) and L[j]<L[j+1]: #寻找左右子节点的最大值,(若此时j=end,就没有L[j+1],所以不能j<=end) j+=1 if temp < L[j]: L[i] = L[j] i = j j = 2*i else: break L[i]=temp def swap(L,i,j): L[i],L[j] = L[j],L[i] return L def heap_sort(L): L_len = len(L)-1 first_heapUp_point = L_len//2 for i in range(first_heapUp_point):#建堆 """ 第二种建堆实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。 因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从第一个非叶子节点(first_heapUp_point-i)开始,依次堆化就行了。 """ heap_adjust(L,first_heapUp_point-i,L_len) for j in range(L_len-1):#排序 L=swap(L,1,L_len-j) heap_adjust(L,1,L_len-j-1) return [L[i] for i in range(1,len(L))] if __name__ == "__main__": L = [0,50, 16, 30, 10, 60, 90, 2, 80, 70] print(heap_sort(L))
第二种建堆思路:

第二种建堆时间复杂度分析:
我们对下标从 n/2 开始到 1 的数据进行堆化,下标是 n/2+1 到 n 的节点是叶子节点,我们不需要堆化.
现在,我们来看,建堆操作的时间复杂度是多少呢?
每个节点堆化的时间复杂度是 O(logn),那 n/2+1 个节点堆化的总时间复杂度是不是就是 O(nlogn) 呢?这个答案虽然也没错,
但是这个值还是不够精确。实际上,堆排序的建堆过程的时间复杂度是 O(n)。我带你推导一下。
因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度 k 成正比。
我把每一层的节点个数和对应的高度画了出来,你可以看看。我们只需要将每个节点的高度求和,得出的就是建堆的时间复杂度。

对高度求和:

求解:

因为 h=log2n,带入S,S=O(n)。建堆的时间复杂度就是 O(n)。
def heap_adjust(L,start,end): temp = L[start] i = start j = 2*i while j<=end: if (j<end) and L[j]<L[j+1]: #寻找左右子节点的最大值,(若此时j=end,就没有L[j+1],所以不能j<=end) j+=1 if temp < L[j]: L[i] = L[j] i = j j = 2*i else: break L[i]=temp
def swap(L,i,j): L[i],L[j] = L[j],L[i] return L
def heap_sort(L): L_len = len(L)-1 first_heapUp_point = L_len//2 for i in range(first_heapUp_point):#建堆 heap_adjust(L,first_heapUp_point-i,L_len) for j in range(L_len-1):#排序 L=swap(L,1,L_len-j) heap_adjust(L,1,L_len-j-1) return [L[i] for i in range(1,len(L))]
if __name__ == "__main__": L = [0,50, 16, 30, 10, 60, 90, 2, 80, 70] print(heap_sort(L))
"""
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。
堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序.
"""
在前面的讲解以及代码中,堆中的数据是从数组下标为 1 的位置开始存储。那如果从 0 开始存储,实际上处理思路是没有任何变化的,唯一变化的,可能就是,代码实现的时候,计算子节点和父节点的下标的公式改变了。
如果节点的下标是 i,那左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 (i−1)/2。
即使比快排的时间复杂度还稳定,但实际应用中,快排却比堆排序好,why?
我觉得主要有两方面的原因。
第一点,堆排序数据访问的方式没有快速排序友好。
对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。

第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。
但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。
对于第二点,你可以自己做个试验看下。我们用一个记录交换次数的变量,在代码中,每次交换的时候,我们就对这个变量加一,排序完成之后,这个变量的值就是总的数据交换次数。这样你就能很直观地理解我刚刚说的,堆排序比快速排序交换次数多。
堆的应用:如何快速获取到Top 10最热门的搜索关键
词?
"""
假设现在我们有一个包含 10 亿个搜索关键词的日志文件,
如何能快速获取到热门榜 Top 10 的搜索关键词呢
堆这种数据结构几个非常重要的应用:优先级队列、求 Top K 和求中位数。
堆的应用一:优先级队列
优先级队列:顾名思义,它首先应该是一个队列。我们前面讲过,队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,
而是按照优先级来,优先级最高的,最先出队。
如何实现:用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。
一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。
往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,
就相当于取出堆顶元素。
应用场景:
宽泛:赫夫曼编码、图的最短路径、最小生成树算法等等
具体:
1. 合并有序小文件
假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。
我们希望将这些 100 个小文件合并成一个有序的大文件。
1.思路:从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。
2.做法:我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。
3.性能分析:删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。
2. 高性能定时器
原始:定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。
缺点:做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。
改进:我们就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。
这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。
当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。
堆的应用二:利用堆求 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 大数据,我们都可以里立刻返回给他
堆的应用三:利用堆求中位数
"""
浙公网安备 33010602011771号