数据结构---堆的应用

  • 如何理解堆

           堆是完全二叉树;堆中每个节点的值大于等于(或者小于等于)子树中每个节点的值。

  • 如何实现堆      

           下面说的自下而上和自上而下都是禁止操作本身,而不是从全局角度考虑。并且假设数组从下标1开始存储数据。

           存储:完全二叉树适合用数组来存储,省去了保存指针,所以比较节省空间。另外,使用数组下标访问父节点和子节点更  方便。

           插入(比如大顶堆):一般在堆的最后位置插入一个元素,需要重新调整,使其满足堆的两个特性(堆化过程)。这里使  用自下而上的方法进行堆化。我们让新插入的点和其父节点进行比较,如果小于父节点则进行交换,一直重复这个过程, 直到路径上的所有父子节点之间满足刚说的那种大小关系。

           删除(比如大顶堆):这里使用自上而下的堆化方法。一般是删除堆顶元素,因为这里堆顶元素是整个堆中值最大的元      素。再将最后一个节点元素放到堆顶,比较父节点和子节点的值大小,如果父节点值小于子节点值则进行交换,一直重复  这个过程,直到路径上的所有父子节点之间满足刚说的那种大小关系。如果最开始只是将根的子节点作比较再放到堆顶位  置,最后数组中很容易就出现了空洞,因为最后删除的是叶子节点,仍然不满足完全二叉树(靠左排列)的特点。

           时间复杂度分析:假设树的节点个数为n,完全二叉树的高不超过2为底n的对数(log2n),堆化是顺着节点路径作的比较    和交换,所以时间复杂度和高度成正比,也就是O(logn)。插入和删除的主要逻辑是堆化过程,所以他们的时间复杂度是 O(logn)。

           堆排序:时间复杂度非常稳定,O(nlogn),还是原地排序算法。

                  建堆:第一种思路,依据前面所讲的插入方法建堆,起初堆中只有一个数据,从下往上堆化数据。

                             第二种思路,从最后一个非叶子节点开始,进行堆化操作,依次从数组下标n/2到1的的数据进行堆化。

                 建堆时间复杂度分析:

                             每个节点堆化操作,需要比较和交换的节点个数跟该节点高度k成正比。将每个节点的高度求和,就是建堆的    时间复杂度:

                             S_{1} = 1*h + 2^{1} * (h-1)+  2^{2} * (h-2)  + ... + 2^{k} * (h-k) + ... + 2^{h-1} * 1

                             S = 2S_{1} - S_{1} = -h + 2 + 2^{2} + 2^{3} + ... + 2^{k} + ... +2^{h-1} + 2^{h}=2^{h+1}-h-2

                             因为h = 2为底的n的对数,并由上一章介绍的加法法则,得出S= O(n)。

                  排序:建堆结束后,堆顶元素是最大元素,我们将它与最后一个元素交换,那最大元素就在n的位置。再通过堆化方    法将剩余n-1个元素重新构建堆,将堆顶元素与n-1位置元素交换,一直重复这个过程,知道堆中只剩下下标为1的一个元素,排序就完成了。 时间复杂度为O(nlogn)。

                 堆排序时间复杂度分析:

                             堆排序由建堆和排序两部分组成,所以时间复杂度即是量级较大者,O(nlogn)。

                  堆排序稳定性分析:

                             在排序过程中,因为存在将堆顶元素和最后元素交换的操作,所以有可能改变值相等的元素原始相对顺序。

                   和快速排序比较:

                             时间复杂度同样是O(nlogn)快排,性能却更优于堆排序,原因归结为两点:

                             一是,堆排序的数据访问方式没有快排好。因为快排是顺序访问,而堆排序是跳着访问,对CPU缓存不友好。

                             二是,同样的数据,在排序过程中堆排序数据交换次数是多于快排的。这点可以使用一个记录交换次数的变      量,每次交换加一,排序完成后,在比较二者的值。

  • 堆的应用

           上面已经介绍过堆的第一个应用,堆排序,下面将介绍另外三个应用:优先队列、求Top K、求中位数。

            1. 优先队列:

            数据的出队顺序不是按照先进先出原则,二是按照优先级高的先出队列。堆和优先队列非常相似,一个堆可以看作一个优  先队列,很多时候,堆和优先队列只是概念上的区分。往优先队列中插入一个元素,就是往堆中插入一个元素;同样,从  优先队列中取出一个元素,就是从堆顶取出一个元素。优先队列的应用非常多:赫夫曼编码、图的最短路径、最小生成树  算法等。

            例子1、合并有序小文件

            假设有100个100M的.txt文件,每个文件存储的都是有序的字符串,希望将100个文件合并成一个有序的大文件,并从数    组中删除。

            整体思路有点像归并排序的合并函数,将小文件的第一行字符串读出,放在小顶堆中,那么堆顶元素,也就是优先队列的  首元素, 就是最小的字符串。我们将这个最小字符串放入大文件中,然后从堆中删除,再从小文件中取出下一行字符串    放入到堆中。循环这个过程,最终是将100个小文件合并到有序的大文件中。

            例子2、高性能的定时器

            假设有个定时器,定时器里面维护了很多个定时任务,每个任务设定了触发执行的时间点。定时器每过很小的单位时间时  间,就扫描一次任务,看是否有任务到达设定的执行的时间。这样势堆必会很低效。

            我们可以将这些任务按照执行时间放到优先队列中,位于队首的任务就是最先要执行的任务。将队首任务时间与当前时间  点相减,得到一个时间间隔T。这样,定时器可以设定T时间后,再来执行这个任务。再来重新计算新的队首任务设定执行的时间点与当前时间点的差值,就作为定时器执行下一个任务需要等待的时间。这样定时器的性能也就提高了。

            2. 求Top K问题:

            这里抽象分为两类:静态数据集合、动态数据集合。

            静态数据集合:如何在一个含有n个数据数组中,查找前K大数据呢? 我们可以维护一个K大小的小顶堆,顺序遍历数        组,从数组中取出元素与堆顶元素比较,如果比堆顶元素大,则先删除掉堆顶元素,再插入到堆中;如果比堆顶小,则先  不做处理。

            遍历数组需要O(n)的时间复杂度,一次堆化需要O(logK)的时间复杂度,所以最坏情况下,n个元素都需要入堆一次,时间  复杂度是O(nlogK)。

            动态数据集合:求的就是实时Top K,包括两个操作:添加和询问。如果每次询问前K大数据,都针对当前数据进行重新    计算的话,时间复杂度就是O(nlogK)。实际上,我们可以一直维护一个K大小的小顶堆,当有数据添加到集合时,我们就  将这个数据拿来跟堆顶数据比较,如果比堆顶大,将删除掉堆顶数据,在插入到堆中;如果比堆顶数据小,则不需处理。  这样,每次询问时都可以直接给出前K大数据。

            3. 求中位数

            顾名思义,就是求处在中间位置的数据。如果是奇数,则中间位置是 n/2 + 1;如果是偶数,则中间位置有两个,n/2和      n/2+1,我们可以取前面位置n/2的数据。如果是静态数据,我们可以先排好序,每次询问,我们就直接去中间位置n/2或者n/2+1上的数据。但如果是动态数据,如果每次询问都要先排好序,那效率就不高了。

            利用堆这种数据结构,不用排序,就能高效的完成求中位数操作。我们这里需要维护两个堆,大顶堆和小顶堆。大顶堆    是前半部分数据,小顶堆是后半部分数据,且小顶堆中元素都要大于大顶堆中数据。也就是说,情况一,如果有n个数      据,n是偶数,大顶堆就是前n/2个数据,小顶堆就是后n/2个数据,大顶堆的对顶元素就是这个中位数;情况二,如果n是  奇数,大顶堆就是前n/2+1个数据,小顶堆就是后n/2个数据,大顶堆堆顶数据就是这个中位数。

            当我们要不停的添加数据到集合中时,可以这样处理:

            如果新插入的数据大于等于小顶堆的堆顶元素,则插入到小顶堆,否则插入到大顶堆。如果大顶堆数据个数小于  小顶堆  数据个数,则需将小顶堆堆顶数据移到大顶堆,直到大顶堆数据个数等于或者比小顶堆数据个数大1个,因为我们要满足  大顶堆堆顶数据是中位数。每次插入数据,都要进行堆化操作,时间复杂度O(logn),询问中位数,直接返回大顶堆堆顶    数据,时间复杂度O(1)。

            对这个问题我们可以拓展,还可以求其他百分位的数。比如求99百分位上的数,我们就是求大于前面99%的那位数。我    们维护两个堆,大顶堆和小顶堆。大顶堆是前n*99%的数据,小顶堆是后n*%1的数据,每次插入数据时,如果数据大于    等于小顶堆堆顶数据,则插入小顶堆,否则插入大顶堆。每次插入完之后,都要重新计算大顶堆和小顶堆数据比例是否还  是99:1,如果不满足,我们就需要将一个堆数据移到另一个堆。

            4. Top K实际应用举例

            假设我们有一个包含有10亿搜索关键词的日志文件,如何能快速获取到热门榜Top 10关键词?

            这里设定场景为单机,内存为1GB。因为搜索关键词会有重复关键词,首先要统计搜索关键词出现的频率。这里可以通过  散列表、平衡二叉查找树或者其他一些支持快速查找的、插入的数据结构。这里选用散列表,假设10亿条关键词不重复    的有1亿条,每条关键词长度 50 bytes,总就会有将近5GB内存需求,而散列表为了避免频繁冲突,不会选择太大的装载  因子,所以消耗内存就更多。

            我们可以通过哈希算法,将10亿条关键词分片到10个文件中,然后去掉重复关键词,可能就只有1000万条关键词,没条    关键词 50 bytes,将近500MBytes。我们针对每个文件,利用堆和散列表,分别求出Top 10。最后将这10个Top10放到一  起,求出最后一个Top 10,解决了问题。

            

以上所述,是本人最近在极客时间上学习数据结构和算法相关课程做的笔记吧。

具体参照: https://time.geekbang.org/column/intro/126

发布了37 篇原创文章 · 获赞 20 · 访问量 4971

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/90379111