堆和堆傻傻分不清?一文告诉你 Java 集合中「堆」的最佳打开方式(上)

简介: 上一篇的 「Java 集合框架」里,还剩下一个大问题没有说的,那就是 PriorityQueue,优先队列,也就是堆,Heap。

什么是堆?


堆其实就是一种特殊的队列——优先队列。


普通的队列游戏规则很简单:就是先进先出;但这种优先队列搞特殊,不是按照进队列的时间顺序,而是按照每个元素的优先级来比拼,优先级高的在堆顶


这也很容易理解吧,比如各种软件都有会员制度,某软件用了会员就能加速下载的,不同等级的会员速度还不一样,那就是优先级不同呀。


还有其实每个人回复微信消息也是默默的把消息放进堆里排个序:先回男朋友女朋友的,然后再回其他人的。


这里要区别于操作系统里的那个“堆”,这两个虽然都叫堆,但是没有半毛钱关系,都是借用了 Heap 这个英文单词而已。


我们再来回顾一下「」在整个 Java 集合框架中的位置:


image.png


也就是说,


  • PriorityQueue 是一个类 (class);


  • PriorityQueue 继承自 Queue 这个接口 (Interface);


<span style="display:block;color:blue;">那 heap 在哪呢?


heap 其实是一个抽象的数据结构,或者说是逻辑上的数据结构,并不是一个物理上真实存在的数据结构。


<span style=";color:blue;">heap 其实有很多种实现方式,</span>比如 binomial heap, Fibonacci heap 等等。但是面试最常考的,也是最经典的,就是 binary heap 二叉堆,也就是用一棵完全二叉树来实现的。


<span style="display:block;color:blue;">那完全二叉树是怎么实现的?


其实是用数组来实现的!


所以 binary heap/PriorityQueue 实际上是用数组来实现的。


这个数组的排列方式有点特别,因为它总会维护你定义的(或者默认的)优先级最高的元素在数组的首位,所以不是随便一个数组都叫「堆」,实际上,它在你心里,应该是一棵「完全二叉树」。


这棵完全二叉树,只存在你心里和各大书本上;实际在在内存里,哪有什么树?就是数组罢了。


那为什么完全二叉树可以用数组来实现?是不是所有的树都能用数组来实现?


这个就涉及完全二叉树的性质了,我们下一篇会细讲,简单来说,因为完全二叉树的定义要求了它在层序遍历的时候没有气泡,也就是连续存储的,所以可以用数组来存放;第二个问题当然是否。


堆的特点


  1. 堆是一棵完全二叉树;


  1. 堆序性 (heap order): 任意节点都优于它的所有孩子


a. 如果是任意节点都大于它的所有孩子,这样的堆叫大顶堆,Max Heap;


b. 如果是任意节点都小于它的所有孩子,这样的堆叫小顶堆,Min Heap;


image.png


左图是小顶堆,可以看出对于每个节点来说,都是小于它的所有孩子的,注意是所有孩子,包括孙子,曾孙...


  1. 既然堆是用数组来实现的,那么我们可以找到每个节点和它的父母/孩子之间的关系,从而可以直接访问到它们。


image.png


比如对于节点 3 来说,


  • 它的 Index = 1,


  • 它的 parent index = 0,


  • 左孩子 left child index = 3,


  • 右孩子 right child index = 4.


可以归纳出如下规律:


  • 设当前节点的 index = x,


  • 那么 parent index = (x-1)/2,


  • 左孩子 left child index = 2*x + 1,


  • 右孩子 right child index = 2*x + 2.


有些书上可能写法稍有不同,是因为它们的数组是从 1 开始的,而我这里数组的下标是从 0 开始的,都是可以的。


这样就可以从任意一个点,一步找到它的孙子、曾孙子,真的太方便了,在后文讲具体操作时大家可以更深刻的体会到。


基本操作


任何一个数据结构,无非就是增删改查四大类:


功能 方法 时间复杂度
offer(E e) O(logn)
poll() O(logn)
无直接的 API 删 + 增
peek() O(1)


这里 peek() 的时间复杂度很好理解,因为堆的用途就是能够快速的拿到一组数据里的最大/最小值,所以这一步的时间复杂度一定是 O(1) 的,这就是堆的意义所在。


那么我们具体来看 offer(E e)poll() 的过程。


offer(E e)


比如我们新加一个 0 到刚才这个最小堆里面:


image.png


那很明显,0 是要放在最上面的,可是,直接放上去就不是一棵完全二叉树了啊。。


所以说,


  • 我们先保证加了元素之后这棵树还是一棵完全二叉树,


  • 然后再通过 swap 的方式进行微调,来满足堆序性。


这样就保证满足了堆的两个特点,也就是保证了加入新元素之后它还是个堆


那具体怎么做呢:


Step 1.


先把 0 放在最后接上,别一上来就想着上位;


image.png


OK!总算先上岸了,然后我们再一步步往上走。


这里「能否往上走」的标准在于:


是否满足堆序性


也就是说,现在 5 和 0 之间不满足堆序性,那么交换位置,换到直到满足堆序性为止


这里对于最小堆来说的堆序性,就是小的数要在上面


Step 2. 与 5 交换


image.png


此时 0 和 3 不满足堆序性了,那么再交换。


Step 3. 与 3 交换


image.png


还不行,0 还比 1 小,所以继续换。


Step 4. 与 1 交换


image.png


OK!这样就换好了,一个新的堆诞生了~


总结一下这个方法:


先把新元素加入数组的末尾,再通过不断比较与 parent 的值的大小,决定是否交换,直到满足堆序性为止。


这个过程就是 siftUp(),源码如下:


image.png


时间复杂度


这里不难发现,其实我们只交换了一条支路上的元素,


image.png


也就是最多交换 O(height) 次。


那么对于完全二叉树来说,除了最后一层都是满的,O(height) = O(logn)


所以 offer(E e) 的时间复杂度就是 O(logn) 啦。


poll()


poll() 就是把最顶端的元素拿走。


对了,没有办法拿走中间的元素,毕竟要 VIP 先出去,小弟才能出去。


那么最顶端元素拿走后,这个位置就空了:


image.png


我们还是先来满足堆序性,因为比较容易满足嘛,直接从最后面拿一个来补上就好了,先放个傀儡上来。


Step1. 末尾元素上位


image.png


这样一来,堆序性又不满足了,开始交换元素。


那 8 比 7 和 3 都大,应该和谁交换呢?


假设与 7 交换,那么 7 还是比 3 大,还得 7 和 3 换,麻烦。


所以是与左右孩子中较小的那个交换。


Step 2. 与 3 交换


image.png


下去之后,还比 5 和 4 大,那再和 4 换一下。


Step 3. 与 4 交换


image.png


OK!这样这棵树总算是稳定了。


总结一下这个方法:


先把数组的末位元素加到顶端,再通过不断比较与左右孩子的值的大小,决定是否交换,直到满足堆序性为止。


这个过程就是 siftDown(),源码如下:


image.png


时间复杂度


同样道理,也只交换了一条支路上的元素,也就是最多交换 O(height) 次。


所以 offer(E e) 的时间复杂度就是 O(logn) 啦。



目录
相关文章
|
1月前
|
算法 Java 数据处理
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。HashSet基于哈希表实现,提供高效的元素操作;TreeSet则通过红黑树实现元素的自然排序,适合需要有序访问的场景。本文通过示例代码详细介绍了两者的特性和应用场景。
40 6
|
1月前
|
存储 Java
深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。
【10月更文挑战第16天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。HashSet基于哈希表实现,添加元素时根据哈希值分布,遍历时顺序不可预测;而TreeSet利用红黑树结构,按自然顺序或自定义顺序存储元素,确保遍历时有序输出。文章还提供了示例代码,帮助读者更好地理解这两种集合类型的使用场景和内部机制。
39 3
|
1月前
|
存储 Java 数据处理
Java Set接口凭借其独特的“不重复”特性,在集合框架中占据重要地位
【10月更文挑战第16天】Java Set接口凭借其独特的“不重复”特性,在集合框架中占据重要地位。本文通过快速去重和高效查找两个案例,展示了Set如何简化数据处理流程,提升代码效率。使用HashSet可轻松实现数据去重,而contains方法则提供了快速查找的功能,彰显了Set在处理大量数据时的优势。
33 2
|
1月前
|
存储 算法 Java
Java Set因其“无重复”特性在集合框架中独树一帜
【10月更文挑战第14天】Java Set因其“无重复”特性在集合框架中独树一帜。本文深入解析Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定的数据结构(哈希表、红黑树)确保元素唯一性,并提供最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的`hashCode()`与`equals()`方法。
31 3
|
24天前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
14天前
|
Java
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式。本文介绍了 Streams 的基本概念和使用方法,包括创建 Streams、中间操作和终端操作,并通过多个案例详细解析了过滤、映射、归并、排序、分组和并行处理等操作,帮助读者更好地理解和掌握这一重要特性。
25 2
|
13天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
18天前
|
存储 Java
判断一个元素是否在 Java 中的 Set 集合中
【10月更文挑战第30天】使用`contains()`方法可以方便快捷地判断一个元素是否在Java中的`Set`集合中,但对于自定义对象,需要注意重写`equals()`方法以确保正确的判断结果,同时根据具体的性能需求选择合适的`Set`实现类。
|
18天前
|
存储 Java 开发者
在 Java 中,如何遍历一个 Set 集合?
【10月更文挑战第30天】开发者可以根据具体的需求和代码风格选择合适的遍历方式。增强for循环简洁直观,适用于大多数简单的遍历场景;迭代器则更加灵活,可在遍历过程中进行更多复杂的操作;而Lambda表达式和`forEach`方法则提供了一种更简洁的函数式编程风格的遍历方式。
|
18天前
|
Java 开发者
下一篇
无影云桌面