当手机有来电时,手机总会优先处理来电,这种时候就要用到带有优先级的队列。在数据结构中,能添加新的对象并返回最高优先级对象的结构叫作优先级队列。堆和PriorityQueue就是一种优先级队列。
堆
什么是堆
堆是一颗完全二叉树,且其每个节点的值总是不大于或不小于其父节点的值,每个节点都大于其父节点的叫大根堆,每个节点都小于其父节点的叫小根堆。
堆的存储方式
因为堆是一棵完全二叉树,因此可以按照层序遍历的次序采用顺序的方式来存储在数组中(若不是完全二叉树,空间中必须要存储空节点,会导致空间利用率比较低)。
完全二叉按顺序存储在数组中,节点 i 的左孩子节点的下标为 2*i+1,右孩子节点的下标为 2*i+2,父节点的下标为 (i-1)/2 。
下面是模拟实现堆的存储结构:
public class Heap { //存储堆的数组 private int[] elem; //堆中有效元素的个数 private int usedSize; //数组的默认大小 private static final int DEFAULT_SIZE = 10; //构造方法初始化数组 public Heap() { elem = new int[DEFAULT_SIZE]; } }
堆的创建
堆是一颗特殊的完全二叉树,我们可以通过向下调整一颗普通的二叉树来创建堆,这里以创建小根堆为例。
下图为一次向下调整的示意图:
要想将一颗普通的二叉树转换成堆需要进行多次的向下调整,具体步骤如下:
需要parent和child两个标签,让parent标记需要调整的节点,child标记parent的左孩子(parent如果有孩子一定先是有左孩子),如果parent有左孩子, 进行以下操作:
1.判断parent右孩子是否存在,存在则让child标记左右孩子节点中最小的节点
2.将parent与child比较,如果parent小于child,调整结束 ;如果parent大于child,交换parent与child。
3.交换完成之后,parent中大的元素向下移动,可能导致子树不满足堆的性质,因此需要继续向下调整,即parent = child;child = parent*2+1; 然后继续1,2,3步骤。
//建堆 public void createHeap() { //找到为倒数第一个非叶子节点,从该节点位置开始往前一直到根节点,遇到一个节点,应用向下调整 for (int parent = (usedSize-1-1)/2; parent >= 0; parent--) { //向下调整 shiftDown(parent, usedSize); } } //向下调整 public void shiftDown(int parent, int len) { //child先标记parent的左孩子,因为parent可能右左没有右 int child = parent*2 + 1; while (child < len) { //如果右孩子存在,找到左右孩子中较小的孩子,用child进行标记 if (child+1 < len && elem[child] > elem[child+1]) { child++; } if (elem[parent] > elem[child]) { //父节点比其最小的孩子节点大 //将父节点与较小的孩子节点交换 int tmp = elem[child]; elem[child] = elem[parent]; elem[parent] = tmp; //parent中大的元素往下移动,可能会造成子树不满足堆的性质,因此需要继续向下调整 parent = child; child = 2*parent + 1; } else { //父节点比其最小的孩子节点还小,符合小根堆的特性 break; } } }
建堆的时间复杂度
因为堆是完全二叉树,为了简化计算我们用满二叉树来推导堆的时间复杂度:
假设堆有n个节点,高度为 h,则最坏情况下:
第一层有2^0个节点,每个节点需要向下调整h-1层;
第二层有2^1个节点,每个节点需要向下调整h-2层;
第三层有2^2个节点,每个节点需要向下调整h-3层;
.
.
.
第h-1层有2^(h-1)个节点,每个节点需要向下调整1层。
将上述内容写成函数的形式:
T(n) = 2^0*(h-1) + 2^1*(h-2) + 2^2*(h-3) + ... + 2^(h-2)*1 ①
由①*2得 2*T(n) = 2^1*(h-1) + 2^2*(h-2) + ... + 2^(h-2)*2 + 2^(h-1)*1 ②
由②-①得:
T(n) = 1-h + 2^1 + 2^2 + ... + 2^(h-2) + 2^(h-1)
T(n) = 2^h - 1
又∵ n = 2^h-1,h = log(n+1)
∴ T(n) = n-log(n+1)
故根据大O的渐进表示法,建堆的时间复杂度为O(n)
堆的插入
建好小根堆后,我们就可以进行插入操作了。插入操作我们可以采用向上调整(插入的节点比其父节点小的交换)的方式:
具体步骤是先将元素放入到底层空间中(空间不够时需要扩容) ,再将最后新插入的节点向上调整,直到满足堆的性质:
public void offer(int val) { //堆满了,需要扩容 if (usedSize == elem.length) { elem = Arrays.copyOf(elem, elem.length * 2); } //插入数组 elem[usedSize] = val; //有效数字加一 usedSize++; //向上调整 shiftUp(usedSize-1); } //向上调整 private void shiftUp(int child) { //找到child的父节点 int parent = (child-1) / 2; while (child > 0) { if (elem[parent] > elem[child]) { // 将双亲与孩子节点进行交换 int tmp = elem[child]; elem[child] = elem[parent]; elem[parent] = tmp; //小的元素向上移动,可能会造成子树不满足堆的性质,因此需要继续向上调增 child = parent; parent = (parent-1) / 2; } else { // 如果双亲比孩子大,parent满足堆的性质,调整结束 break; } } }
堆的删除
堆的删除删除的是堆顶元素,我们可以让最后一个叶子节点填充到堆顶,这样我们其他子树就还是大/小根堆,我们只需调整最大的那颗树即可。
具体步骤是 ①.将堆顶元素对堆中最后一个元素交换 ②.将堆中有效数据个数减少一个 ③.对堆顶元素进行向下调整:
//删除元素 public int pop() { //堆为空,返回-1 if (usedSize == 0) { return -1; } //记录要删除的元素的值 int tmp = elem[0]; //最后面的叶子节点替换掉根节点 elem[0] = elem[usedSize-1]; //有效数字减一 usedSize--; //向下调整 shiftDown(0, usedSize); //返回被删除元素的值 return tmp; }
PriorityQueue
什么是PriorityQueue
PriorityQueue是Java集合框架中的优先级队列,它的底层是堆,且其默认情况下是小根堆。
注意:
1.使用时必须导入PriorityQueue所在的包
2. PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastException异常
3. 不能插入null对象,否则会抛出NullPointerException
4. 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
5. 插入和删除元素的时间复杂度为O(logN)
6. PriorityQueue底层使用了堆数据结构,且默认情况下是小堆(每次获取到的元素都是最小的元素)
priorityQueue的构造方法
构造方法 描述 PriorityQueue() 创建一个空的优先级队列,默认容量是11 PriorityQueue(int
initialCapacity)
创建一个初始容量为initialCapacity的优先级队列,注意:
initialCapacity不能小于1,否则会抛IllegalArgumentException异
常
PriorityQueue(Collection<?
extends E> c)
用一个集合来创建优先级队列 import java.util.ArrayList; import java.util.PriorityQueue; public class Test { public static void main(){ // 创建一个空的优先级队列,底层默认容量为11 PriorityQueue<Integer> q1 = new PriorityQueue<>(); // 创建一个空的优先级队列,底层的容量为100 PriorityQueue<Integer> q2 = new PriorityQueue<>(100); ArrayList<Integer> list = new ArrayList<>(); list.add(1); list.add(2); list.add(3); // 用ArrayList对象来构造一个优先级队列的对象 PriorityQueue<Integer> q3 = new PriorityQueue<>(list); } }
上面例举的只是常见的几种构造方法,PriorityQueue还有其他一些构造方法:
PriorityQueue的常见方法
方法 描述 boolean offer(E e)
插入元素e,插入成功返回true,如果e对象为空,抛出NullPointerException异常,空间不够时候会进行扩容,时间复杂度为O(logN) E peek() 获取优先级最高的元素,如果优先级队列为空,返回null E poll() 移除优先级最高的元素并返回,如果优先级队列为空,返回null int size() 获取有效元素的个数
void clear()
清空优先级队列 boolean isEmpty()
检测优先级队列是否为空 import java.util.PriorityQueue; public class Test { public static void main() { int[] arr = {1, 2, 3, 4, 5, 6}; // 一般在创建优先级队列对象时,如果知道元素个数,建议就直接将底层容量给好 PriorityQueue<Integer> q = new PriorityQueue<>(arr.length); for (int e : arr) { q.offer(e); } System.out.println(q.size()); // 打印优先级队列中有效元素个数 System.out.println(q.peek()); // 获取优先级最高的元素 // 从优先级队列中删除两个元素之和,再次获取优先级最高的元素 q.poll(); q.poll(); System.out.println(q.size()); // 打印优先级队列中有效元素个数 System.out.println(q.peek()); // 获取优先级最高的元素 q.offer(0); System.out.println(q.peek()); // 获取优先级最高的元素 // 将优先级队列中的有效元素删除掉,检测其是否为空 q.clear(); if (q.isEmpty()) { System.out.println("优先级队列已经为空!!!"); } else { System.out.println("优先级队列不为空"); } } }
PriorityQueue如何创建大根堆
在PriorityQueue中也是通过 shiftUp(向上调整)或者shiftDown(向下调整)来使堆成为小根堆的:
从上图源码可以发现,shiftup和shiftdown在用户有传比较器(comparator!=null)的时候,是优先使用比较器比较的,而用户没有传比较器(comparator==null)的时候,堆的节点的类型必须是实现了comparable接的。故要想创建大根堆,自定义类型可以通过传入比较器和重写compareTo实现,包装类类型可以通过传入比较器实现:
通过重写compareTo方法:
class Student implements Comparable<Student>{ public int age; public Student(int age) { this.age = age; } @Override public int compareTo(Student o) { //return this.age - o.age; return o.age - this.age; } } public class Test { public static void main(String[] args) { PriorityQueue<Student> priorityQueue = new PriorityQueue<>(); priorityQueue.offer(new Student(1)); priorityQueue.offer(new Student(2)); priorityQueue.offer(new Student(3)); System.out.println(priorityQueue.peek().age); } }
运行结果:
通过传入比较器:
class IntCmp implements Comparator<Integer> { @Override public int compare(Integer o1, Integer o2) { //return o2-o1; return o2.compareTo(o1); } } public class Test { public static void main(String[] args) { //传入IntCmp比较器 PriorityQueue<Integer> priorityQueue1 = new PriorityQueue<>(new IntCmp()); priorityQueue1.offer(1); priorityQueue1.offer(2); priorityQueue1.offer(3); System.out.println(priorityQueue1.peek()); //匿名内部类实现比较器 PriorityQueue<Integer> priorityQueue2 = new PriorityQueue<>(new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o2.compareTo(o1); } }); priorityQueue2.offer(1); priorityQueue2.offer(2); priorityQueue2.offer(3); System.out.println(priorityQueue1.peek()); //lambda表达式实现比较器 PriorityQueue<Integer> priorityQueue3 = new PriorityQueue<>((x,y)->{return x.compareTo(y);}); PriorityQueue<Integer> priorityQueue4 = new PriorityQueue<>((x,y)-> x.compareTo(y)); priorityQueue3.offer(1); priorityQueue3.offer(2); priorityQueue3.offer(3); System.out.println(priorityQueue1.peek()); priorityQueue4.offer(1); priorityQueue4.offer(2); priorityQueue4.offer(3); System.out.println(priorityQueue1.peek()); } }
运行结果:
PriorityQueue的扩容方式
PriorityQueue源码中grow()就是用来扩容的函数:
通过观察源码可以发现:当容量小于64时,是按照oldCapacity的2倍+2的方式扩容的,当容量大于等于64,是按照oldCapacity的1.5倍方式扩容的 ,如果容量超过了MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容。