上一篇的 「Java 集合框架」里,还剩下一个大问题没有说的,那就是 PriorityQueue,优先队列,也就是堆,Heap。面试
堆其实就是一种特殊的队列——优先队列。算法
普通的队列游戏规则很简单:就是先进先出;但这种优先队列搞特殊,不是按照进队列的时间顺序,而是按照每一个元素的优先级来比拼,优先级高的在堆顶。api
这也很容易理解吧,好比各类软件都有会员制度,某软件用了会员就能加速下载的,不一样等级的会员速度还不同,那就是优先级不一样呀。数组
还有其实每一个人回复微信消息也是默默的把消息放进堆里排个序:先回男友女友的,而后再回其余人的。微信
这里要区别于操做系统里的那个“堆”,这两个虽然都叫堆,可是没有半毛钱关系,都是借用了 Heap 这个英文单词而已。数据结构
咱们再来回顾一下「堆」在整个 Java 集合框架中的位置:框架
也就是说,编辑器
<span style="display:block;color:blue;">那 heap 在哪呢?spa
heap 实际上是一个抽象的数据结构,或者说是逻辑上的数据结构,并非一个物理上真实存在的数据结构。操作系统
<span style=";color:blue;">heap 其实有不少种实现方式,</span>好比 binomial heap, Fibonacci heap 等等。可是面试最常考的,也是最经典的,就是 binary heap 二叉堆,也就是用一棵彻底二叉树来实现的。
<span style="display:block;color:blue;">那彻底二叉树是怎么实现的?
实际上是用数组来实现的!
这个数组的排列方式有点特别,由于它总会维护你定义的(或者默认的)优先级最高的元素在数组的首位,因此不是随便一个数组都叫「堆」,实际上,它在你内心,应该是一棵「彻底二叉树」。
这棵彻底二叉树,只存在你内心和各大书本上;实际在在内存里,哪有什么树?就是数组罢了。
那为何彻底二叉树能够用数组来实现?是否是全部的树都能用数组来实现?
这个就涉及彻底二叉树的性质了,咱们下一篇会细讲,简单来讲,由于彻底二叉树的定义要求了它在层序遍历的时候没有气泡,也就是连续存储的,因此能够用数组来存放;第二个问题固然是否。
a. 若是是任意节点都大于它的全部孩子,这样的堆叫大顶堆,Max Heap;
b. 若是是任意节点都小于它的全部孩子,这样的堆叫小顶堆,Min Heap;
左图是小顶堆,能够看出对于每一个节点来讲,都是小于它的全部孩子的,注意是全部孩子,包括孙子,曾孙...
好比对于节点 3 来讲,
能够概括出以下规律:
有些书上可能写法稍有不一样,是由于它们的数组是从 1 开始的,而我这里数组的下标是从 0 开始的,都是能够的。
这样就能够从任意一个点,一步找到它的孙子、曾孙子,真的太方便了,在后文讲具体操做时你们能够更深入的体会到。
任何一个数据结构,无非就是增删改查四大类:
功能 | 方法 | 时间复杂度 |
---|---|---|
增 | offer(E e) | O(logn) |
删 | poll() | O(logn) |
改 | 无直接的 API | 删 + 增 |
查 | peek() | O(1) |
这里 peek()
的时间复杂度很好理解,由于堆的用途就是可以快速的拿到一组数据里的最大/最小值,因此这一步的时间复杂度必定是 O(1)
的,这就是堆的意义所在。
那么咱们具体来看 offer(E e)
和 poll()
的过程。
好比咱们新加一个 0
到刚才这个最小堆里面:
那很明显,0 是要放在最上面的,但是,直接放上去就不是一棵彻底二叉树了啊。。
因此说,
这样就保证知足了堆的两个特色,也就是保证了加入新元素以后它仍是个堆。
那具体怎么作呢:
先把 0 放在最后接上,别一上来就想着上位;
OK!总算先上岸了,而后咱们再一步步往上走。
这里「可否往上走」的标准在于:
是否知足堆序性。
也就是说,如今 5 和 0 之间不知足堆序性,那么交换位置,换到直到知足堆序性为止。
这里对于最小堆来讲的堆序性,就是小的数要在上面。
此时 0 和 3 不知足堆序性了,那么再交换。
还不行,0 还比 1 小,因此继续换。
OK!这样就换好了,一个新的堆诞生了~
总结一下这个方法:
先把新元素加入数组的末尾,再经过不断比较与 parent 的值的大小,决定是否交换,直到知足堆序性为止。
这个过程就是 siftUp()
,源码以下:
这里不难发现,其实咱们只交换了一条支路上的元素,
也就是最多交换 O(height)
次。
那么对于彻底二叉树来讲,除了最后一层都是满的,O(height) = O(logn)
。
因此 offer(E e)
的时间复杂度就是 O(logn)
啦。
poll()
就是把最顶端的元素拿走。
对了,没有办法拿走中间的元素,毕竟要 VIP 先出去,小弟才能出去。
那么最顶端元素拿走后,这个位置就空了:
咱们仍是先来知足堆序性,由于比较容易知足嘛,直接从最后面拿一个来补上就行了,先放个傀儡上来。
这样一来,堆序性又不知足了,开始交换元素。
那 8 比 7 和 3 都大,应该和谁交换呢?
假设与 7 交换,那么 7 仍是比 3 大,还得 7 和 3 换,麻烦。
因此是与左右孩子中较小的那个交换。
下去以后,还比 5 和 4 大,那再和 4 换一下。
OK!这样这棵树总算是稳定了。
总结一下这个方法:
先把数组的末位元素加到顶端,再经过不断比较与左右孩子的值的大小,决定是否交换,直到知足堆序性为止。
这个过程就是 siftDown()
,源码以下:
一样道理,也只交换了一条支路上的元素,也就是最多交换 O(height)
次。
因此 offer(E e)
的时间复杂度就是 O(logn)
啦。
还有一个大名鼎鼎的很是重要的操做,就是 heapify()
了,它是一个很神奇的操做,
O(n)
的时间把一个乱序的数组变成一个 heap。可是呢,heapify()
并非一个 public API,看:
因此咱们没有办法直接使用。
惟一使用 heapify()
的方式呢,就是使用PriorityQueue(Collection<? extends E> c)
这个 constructor 的时候,人家会自动调用 heapify() 这个操做。
<span style="display:block;color:blue;">那具体是怎么作的呢?
哈哈源码已经暴露了:
siftDown()
.由于叶子节点不必操做嘛,已经到了最下面了,还能和谁 swap?
举个例子:
咱们想把这个数组进行 heapify()
操做,想把它变成一个最小堆,拿到它的最小值。
那就要从 3 开始,对 3,7,5进行 siftDown()
.
尴尬 😅,3 并不用交换,由于以它为顶点的这棵小树已经知足了堆序性。
7 比它的两个孩子都要大,因此和较小的那个交换一下。
交换完成后;
最后一个要处理的就是 5 了,那这里 5 比它的两个孩子都要大,因此也和较小的那个交换一下。
换完以后结果以下,注意并无知足堆序性,由于 4 还比 5 小呢。
因此接着和 4 换,结果以下:
这样整个 heapify()
的过程就完成了。
怎么计算这个时间复杂度呢?
其实咱们在这个过程里作的操做无非就是交换交换。
那到底交换了多少次呢?
没错,交换了多少次,时间复杂度就是多少。
那咱们能够看出来,其实同一层的节点最多交换的次数都是相同的。
那么这个总的交换次数 = 每层的节点数 * 每一个节点最多交换的次数
这里设 k 为层数,那么这个例子里 k=3.
每层的节点数是从上到下以指数增加:
$$\ce{1, 2, 4, ..., 2^{k-1}}$$
每一个节点交换的次数,
从下往上就是:
$$ 0, 1, ..., k-2, k-1 $$
那么总的交换次数 S(k) 就是二者相乘再相加:
$$S(k) = \left(2^{0} *(k-1) + 2^{1} *(k-2) + ... + 2^{k-2} *1 \right)$$
这是一个等比等差数列,标准的求和方式就是错位相减法。
那么
$$2S(k) = \left(2^{1} *(k-1) + 2^{2} *(k-2) + ... + 2^{k-1} *1 \right)$$
二者相减得:
$$S(k) = \left(-2^{0} *(k-1) + 2^{1} + 2^{2} + ... + 2^{k-2} + 2^{k-1} \right)$$
化简一下:
(很差意思我实在受不了这个编辑器了。。。
因此 heapify()
时间复杂度是 O(n)
.
以上就是堆的三大重要操做,最后一个 heapify()
虽然不能直接操做,可是堆排序中用到了这种思路,以前的「选择排序」那篇文章里也提到了一些,感兴趣的同窗能够后台回复「选择排序」得到文章~至于堆排序的具体实现和应用,以及为何实际生产中并不爱用它,咱们以后再讲。
最后再说一点题外话,最近发现了几篇搬运个人文章到其余平台的现象。每篇文章都是我精心打造的,都是本身的心肝宝贝,看到别人直接搬运过去也没有标明做者和来源出处实在是太难受了。。为了最好的阅读体验,文中的图片我都没有加水印,但这也方便了他人搬运。今天考虑再三,仍是不想违背本身的本意,毕竟个人读者更为重要。
因此若是以后有小伙伴看到了,恳请你们后台或者微信告诉我一下呀,很是感谢!
我在各大平台同名,请认准「码农田小齐」~
若是你喜欢个人文章或者有收获的话,麻烦给我点个「赞」或者「在看」给我个小鼓励呀,会让我开心很久~
想跟我一块儿玩转算法和面试的小伙伴,记得关注我,我是小齐,咱们下期见。