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 大数据,我们都可以里立刻返回给他
堆的应用三:利用堆求中位数








"""

 

posted on 2019-02-19 21:19  wzc521  阅读(136)  评论(0)    收藏  举报

导航