1.初始化时,为什么要全部放入完全二叉树中再调整,而不是用put()一个一个插入?
复杂度不同
全部放入再调整,第一个开始调整的节点是第一个非叶子节点,每个节点只需要“向下调整”,调整的最大次数即节点到叶子的“高度”。
一个一个插入,每个节点总是插入在完全二叉树的最后一个节点上,需要“向上”调整,调整的最大次数即节点到根的“深度”。
O(2N) vs O(NlogN)。
具体证明比较简单:
全部放入:高度为h的节点个数为:2^h - 1,调整次数 H-h(H为总高度,节点个数为N的二叉树高度为logN)
平均下来(2^h-1)* (logN-h)累加,h从logN到0,除以N则得到平均次数:O(2N)
一个一个放入:高度为h的节点个数:2^h - 1,向上调整,最多调整次数为h,
平均下来(2^h-1)* h 累加,h从0到logN,除以N则得到平均次数:O(NlogN)
严格证明可以手工计算(高中前n项和问题),或者网上找资料,这里提供一个感性认识:
全部放入时,节点总是“向下调整”,调整的最大次数即节点到叶子的“高度”,完全二叉树整体是个“金字塔”形状,越往下每层节点越多,越往上节点越少,而对于全部放入而言,越下层的节点调整次数越少,越往上调整次数越多。反映在之前的求和公式上即:(2^h-1)* (logN-h),金字塔底部节点数多(2^h-1大)但距底部近(logN-h小)。金字塔顶部相反。这样导致二者之积总是“有界”的,两个相反的增长趋势可以将放大效应相互抵消。
而一个一个放入时,情况相反。(2^h-1)* h,金字塔底部节点数多(2^h-1大),同时但距根远(h大),这样导致二者之积单调递增,放大效应叠加,从而趋向无穷,其实根据极限的知识也知道,对于(2^h-1)* h 的前h项和,如果是算复杂度(只看数量级)的话,看最后一项即可:(2^h-1)* h = N * logN。
2.向上调整和向下调整策略的正确性
这里顺便说一下二者策略的正确性。
全部放入调整,其实局部策略就是一条:保证每个节点一定小于等于两个孩子(最小堆),而如果真的和左右孩子之一(肯定是和最小孩子)调整了位置才使本节点达到此目的,则那个被交换值的孩子节点的平衡性(小于等于左右孩子)可能被破坏了,所以需要向下递归调整。
这反映在我们向下调整的策略上就是:一直调整到没有和孩子发生交换或者已经触及叶子节点为止。
(注意:不用一定调整到叶子,最后一个非叶子节点的右孩子也不一定存在,这个很坑!!)
那么为什么这样一定就是正确的策略呢?其实这就是最小堆的定义:所有节点都小于等于孩子。
看,多么精妙啊,很多时候我们只是去记策略:从当前往下调整,比孩子大就和孩子交换blablabla......其实这样的“规则”或者策略是为基本的定义服务的,不是这样做就能构造这样的最小堆,而是只有这么做构造出来的堆才具有最小堆的性质。数学中的当且仅当(if and only if)在这里再恰当不过了,
言归正传,之前的问题好像还没有大结局呢:现在假设调整当前节点时,跟左孩子发生了交换,并且递归从左孩子向下调整了m层,则好像我们现在只能得到这条“跛腿”的“人”字型(左长右短)的路径是一定满足最小堆定义的:所有节点都小于等于孩子。那么其他节点呢?能保证下标大于等于当前节点的节点都满足最小堆条件了?
别忘了,我们是一路从第一个非叶子节点调整过来的,倒数第1个调整完了,条件满足;接下来调整倒数第2个,满足了,调整第3个......调整到当前节点时,当前节点之前的节点都满足条件了,他们的满足条件不是“凭空”蹦出来的,第k个是以第k-1个满足为条件推导而来,第k-1个是以第k-2个满足为条件推导而来......而倒数第一个是我们调整的起点,是满足的。所以可以看到,我们调整当前节点时,之前的节点都满足了条件,这是以倒数第一个满足条件为基础递推而来,基石稳固。这多么像我们数学归纳法啊,只要第一个满足条件,后面的满足前后项约束关系,推导出来必然是正确的。
那么,刚才除去“跛腿”的“人”字型(左长右短)的节点满不满足的问题就很明显了,所有当前节点之后的节点,没有经过移动的,都是已经满足条件的。换言之,在调整当前节点之前,其实所有节点(当前节点之后)都已经满足条件了(有序),“跛腿”的“人”字型路径只是为了弥补当前节点进行的破坏活动所进行的修复,之前说:
“只能得到这条“跛腿”的“人”字型(左长右短)的路径是一定满足最小堆定义”
其实现在再回看,应该是只有这条路径经历了平衡——失衡——复衡的过程,其他部分一直是平衡的。
至此,已经很明晰了,调整完当前节点,则当前节点之后(包括当前节点)的“区域”都是有序的,那么调整完树的根时,整棵树就是有序的了。
至于插入之后向上调整,其实就比较简单了。在插入到完全二叉树最后一个位置之前,树是有序的,插入后,可能会使其父节点失衡,如果比父亲小(最小堆),则要和父亲交换,这里不用管左孩子(如果有的话),因为如果插入的节点比父亲还要小,则一定比右孩子小,当前节点放上父亲位置之后,一定还是比左孩子小,不会影响左孩子即子树的平衡性(左孩子子树更不用说,都没有碰他们的祖先,怎么会影响到他们的平衡)。
换上父亲的位置后,还要跟爷爷比较,因为现在父亲的位置上换上了比原来更小的值,而爷爷及太爷...是一个比一个小的,现在位置如果没变或者换成了更大的数则祖先一定不用变,但现在换上了更小的数,则有可能影响到他们的平衡。所以要递归向上调整,一直到根即可。
这里深究起来还有2个问题:
① 插入节点后从叶子一路调整到根就结束,其他节点都不用管的吗? 万一影响到其他节点平衡性怎么办 ?
即除了这条路径上节点,其他区域的节点会受到影响吗?
其实这个问题与其这样问,不如换个方式:什么情况下会影响到一个节点的平衡性(最小堆)?
还是回到最小堆定义:所有节点都小于等于孩子。
可以看到,当前节点满不满足最小堆定义,只和它的孩子和祖先有关。
向上调整时,相对于当前节点,最小堆的节点可以分为两类:一类是我调整路径上的节点,一类是其他节点。前者不用说,我已经调整或者将来调整,对于后者,都可以认为是当前节点的远房表兄。现在就考虑一下,从叶子到根调整时,到底有没有破坏表兄们的平衡性,我们来观察任意一个表兄——即隔壁或者远房树上的一个节点:我调整我的,我一没动你的后代(之前上来的路径上都是我自己的后代),二没动你的祖先——呃,好吧,我动了我爸我爷我太爷...他可能也是你爸,你爷爷你太爷......但是别激动!听我解释!我不是随便动的,每次动他们都是在我一定比他们小的时候才动的,我比他们小,那一定比你们还小,所以我动不动对你们都没有影响。除了我调整的这条路径上的节点,其他所有区域保持原样就好!!
② 这条路径上的节点一定是满足最小堆的吗(递减)
向上调整的策略时,一发现父亲比我大,就把父亲"拉下马"(嘘。。。。我爸是不会看到这个帖子的),然后自己上位,继续向上挑战,如果发现又有比自己大的,一样拉下马,很明显,这样总是能保证最后一个调整的节点满足最小堆性质,但是“身后”路径上的节点呢? 你只是一味的将他们一个个填坑式的向下拉下马,他们一个个下去以后能保证是父亲比左右孩子小吗?
答案是肯定的。
先来看路径上的孩子,其实从叶子一直到最终停止的地方(父亲比当前节点小,不用调整了),整体上看是将整条路径向下“拉”了一层,然后将插入的值放在最顶端。而这条路径原本就是有序最小堆的一条路径,当然是满足最小堆条件——递减。
再看路径上任意节点的另外一个孩子,之前父亲在位时,父亲比我小。现在父亲被“拽”下来成为了我的兄弟,爷爷成了新》...家长(怎么那么怪异啊。。。。),当然,爷爷肯定比爸爸小,那么现在爷爷在位,我肯定比他大,不会影响到我,故也不会影响到我的孩子。
两个一结合,嗯,对于这条路径本身,调整了完了之后也是符合最小堆定义的。
故① ② 一结合,向上调整时,调整后的路径是符合定义的,其他区域也是符合的(没有变),故策略有效。
啰嗦了这么多,其实还是有收获的。可以看到,无论向上还是向下,调整都一定非要调整到根或者叶子,只要不满足调整的条件后就可以停止了,后面不需要检查是否有“例外”出现。这也是最多调整次数的由来。
3.能对最小堆任意节点进行update,delete操作吗
update或者delete必然要先查找到该节点,但是最小堆不是排序树,虽然总是满足父亲小于等于孩子,但是左右孩子没有顺序,考虑一个实际问题:如果发现查找值大于当前节点值,接下来该走左孩子还是右孩子?
这一点从二叉树每一层节点的value的“随机”分布也能看出来,上层节点下来时,根本无法决策怎么走,查找的值完全可能出现任意的子树中。
但是考虑到最小堆(完全二叉树)一般都是用数组存储,倒是可以走遍历数组这条路。注意,是遍历,没有任何捷径可走,这样查找的复杂度已经为O(N)。
如果已经保证节点的“值”是唯一的(非常重要),走O(N)查找倒是能够定位位置。接下来考虑更新,更新完成后,该怎么调整呢?
其实,这个问题和之前的向上或者向下调整的理解有很大的关系。
① 等于原值,不动。
② 更新的值比原来的大,则现在的情形和之前考虑的向下调整的情景:考虑一个节点向下调整到某处的情形:上面的路径(到调整点)已经有序,下面等待我去比较调整(如果比我小的话)。
现在条件更友好:向上,从当前节点到根都是有序的,其余的所有区域都是有序的(之前是部分区域),现在更新的值比原来的大,则向上不用调整了(比他们都大),只用向下调整,而且只用一条路径。
③ 更新的值比原来的小,跟之前向上调整的中间任意节点情形一模一样:所有节点都是有序的,只有当前节点不一定。
则和之前一样操作即可:一路调整到根。
不过有个隐含的条件挺扯的,怎么保证更新操作产生的值不会和其他值发生冲突呢,其实这个问题在之前查找时就提到过,但是具体实现应该结合一个hashmap来实现,考虑一个10个ip访问日志中找出前10个ip地址的问题,一般这个问题都是将大文件切分成能够读入内存的小文件后,再使用hashmap进行计数,Key为IP(字符串),Value为出现次数,这样,使用hashmap就达到了保证Key唯一的目的。而最小堆的每个节点也是key+value+left+right的结构,查找定位用key,比较大小用value,这样update总是更新value,然后根据value调整最小堆,key是没有变化的(值没变,节点位置变了)。当然,最后不要忘了讲hashmap中的value也更新了(也可以先更新hashmap再更新堆,当然,最保险的是加锁,做成事务,一起更新)。
再考虑删除,有了上面的基础,查找删除节点的位置很好搞定:判断hashmap.contains(key)为true以后到最小堆数组中O(N)根据该key查找到位置,但是接下来该怎么办呢?
参考之前删除根节点的情况来看,删除后拿最后一个节点填上,向下调整即可。
但是注意,我们可不能直接这么一样搞。因为任意节点都是根节点的后代,即根节点必然是小于等于最后一个节点的,所以只用向下调整(也没法向上不是),而对于非根节点的删除可不一定,我们拿最后一个节点来填坑,但是最后一个节点跟删除的节点的大小关系是不一定的,可能大可能小,可能等于,要分类处理,其实逻辑和上面的更新是一样的:
size--(最后一个删掉,方式现在向下删除可能和最后一个节点发生交换,当然,一般向下调整的逻辑都是都是当前值 > 孩子才交换,不会包含等于,所以也不会发生问题)
如果等于:不动即可,最简单。
如果大于:可以归结为上面的更新了一个大于原值的值——向下调整到叶子即可。
如果小于:归结上面的更新了一个小于原值的值——向上调整到根即可。
可以看到,update或者delete查找的复杂度都是O(N),调整则要视大小而定,如果平均来看:各1/2(可千万不能认为等于小于大于的概率各1/3,确切地说,等于的概率为0)。
则每一层节点向上和向下都是1/2,其实就成了之前算了向上调整法和向下调整法建树的计算,所以更新或删除的复杂度为:
O(2N+NlogN)/2 /N= O(2+logN)
相对于其他需要维护的有序表,复杂度还是低一些,当然,平衡二叉树可以达到此复杂度。