本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接http://item.jd.com/12299018.html


上节介绍了堆的基本概念和算法,本节我们来探讨堆在Java中的具体实现类 - PriorityQueue。

我们先从基本概念谈起,然后介绍其用法,接着分析实现代码,最后总结分析其特点。

基本概念

顾名思义,PriorityQueue是优先级队列,它首先实现了队列接口(Queue),与LinkedList类似,它的队列长度也没有限制,与一般队列的区别是,它有优先级的概念,每个元素都有优先级,队头的元素永远都是优先级最高的。

PriorityQueue内部是用堆实现的,内部元素不是完全有序的,不过,逐个出队会得到有序的输出。

虽然名字叫优先级队列,但也可以将PriorityQueue看做是一种比较通用的实现了堆的性质的数据结构,可以用PriorityQueue来解决适合用堆解决的问题,下一节我们会来看一些具体的例子。

基本用法

Queue接口

PriorityQueue实现了Queue接口,我们在LinkedList一节介绍过Queue,为便于阅读,这里重复下其定义:

public interface Queue<E> extends Collection<E> {
boolean add(E e);
boolean offer(E e);
E remove();
E poll();
E element();
E peek();
}

Queue扩展了Collection,主要操作有三个:

  • 在尾部添加元素 (add, offer)
  • 查看头部元素 (element, peek),返回头部元素,但不改变队列
  • 删除头部元素 (remove, poll),返回头部元素,并且从队列中删除

构造方法

PriorityQueue有多个构造方法,如下所示:

public PriorityQueue()
public PriorityQueue(int initialCapacity)
public PriorityQueue(int initialCapacity, Comparator<? super E> comparator)
public PriorityQueue(Collection<? extends E> c)
public PriorityQueue(PriorityQueue<? extends E> c)
public PriorityQueue(SortedSet<? extends E> c)

PriorityQueue是用堆实现的,堆物理上就是数组,与ArrayList类似,PriorityQueue同样使用动态数组,根据元素个数动态扩展,initialCapacity表示初始的数组大小,可以通过参数传入。对于默认构造方法,initialCapacity使用默认值11。对于最后三个构造方法,它们接受一个已有的Collection,数组大小等于参数容器中的元素个数。

TreeMap/TreeSet类似,为了保持一定顺序,PriorityQueue要求,要么元素实现Comparable接口,要么传递一个比较器Comparator:

  • 对于前两个构造方法和接受Collection参数的构造方法,要求元素实现Comparable接口。
  • 第三个构造方法明确传递了Comparator。
  • 对于最后两个构造方法,参数容器有comparator()方法,PriorityQueue使用和它们一样的,如果返回的comparator为null,则也要求元素实现Comparable接口。

基本例子

我们来看个基本的例子:

Queue<Integer> pq = new PriorityQueue<>();
pq.offer(10);
pq.add(22);
pq.addAll(Arrays.asList(new Integer[]{
11, 12, 34, 2, 7, 4, 15, 12, 8, 6, 19, 13 }));
while(pq.peek()!=null){
System.out.print(pq.poll() + " ");
}

代码很简单,添加元素,然后逐个从头部删除,与普通队列不同,输出是从小到大有序的:

2 4 6 7 8 10 11 12 12 13 15 19 22 34 

如果希望是从大到小呢?传递一个逆序的Comparator,将第一行代码替换为:

Queue<Integer> pq = new PriorityQueue<>(11, Collections.reverseOrder());

输出就会变为:

34 22 19 15 13 12 12 11 10 8 7 6 4 2 

任务队列

我们再来看个例子,模拟一个任务队列,定义一个内部类Task表示任务,如下所示:

static class Task {
int priority;
String name; public Task(int priority, String name) {
this.priority = priority;
this.name = name;
} public int getPriority() {
return priority;
} public String getName() {
return name;
}
}

Task有两个实例变量,priority表示优先级,值越大优先级越高,name表示任务名称。

Task没有实现Comparable,我们定义一个单独的静态成员taskComparator表示比较器,如下所示:

private static Comparator<Task> taskComparator = new Comparator<Task>() {

    @Override
public int compare(Task o1, Task o2) {
if(o1.getPriority()>o2.getPriority()){
return -1;
}else if(o1.getPriority()<o2.getPriority()){
return 1;
}
return 0;
}
};

下面来看任务队列的示例代码:

Queue<Task> tasks = new PriorityQueue<Task>(11, taskComparator);
tasks.offer(new Task(20, "写日记"));
tasks.offer(new Task(10, "看电视"));
tasks.offer(new Task(100, "写代码")); Task task = tasks.poll();
while(task!=null){
System.out.print("处理任务: "+task.getName()
+",优先级:"+task.getPriority()+"\n");
task = tasks.poll();
}

代码很简单,就不解释了,输出任务按优先级排列:

处理任务: 写代码,优先级:100
处理任务: 写日记,优先级:20
处理任务: 看电视,优先级:10

实现原理

理解了PriorityQueue的用法和特点,我们来看其具体实现代码,从内部组成开始。

内部组成

内部有如下成员:

private transient Object[] queue;
private int size = 0;
private final Comparator<? super E> comparator;
private transient int modCount = 0;

queue就是实际存储元素的数组。size表示当前元素个数。comparator为比较器,可以为null。modCount记录修改次数,在介绍第一个容器类ArrayList时已介绍过。

如何实现各种操作,且保持堆的性质呢?我们来看代码,从基本构造方法开始。

基本构造方法

几个基本构造方法的代码是:

public PriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
} public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
} public PriorityQueue(int initialCapacity,
Comparator<? super E> comparator) {
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}

代码很简单,就是初始化了queue和comparator。

下面介绍一些操作的代码,大部分的算法和图示,我们在上节已经介绍过了。

添加元素 (入队)

代码为:

public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1);
size = i + 1;
if (i == 0)
queue[0] = e;
else
siftUp(i, e);
return true;
}

offer方法的基本步骤为:

  1. 首先确保数组长度是够的,如果不够,调用grow方法动态扩展。
  2. 增加长度 (size=i+1)
  3. 如果是第一次添加,直接添加到第一个位置即可 (queue[0]=e)。
  4. 否则将其放入最后一个位置,但同时向上调整,直至满足堆的性质 (siftUp)

有两步复杂一些,一步是grow,另一步是siftUp,我们来细看下。

grow方法的代码为:

private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// overflow-conscious code
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}

如果原长度比较小,大概就是扩展为两倍,否则就是增加50%,使用Arrays.copyOf方法拷贝数组。

siftUp的基本思路我们在上节介绍过了,其实际代码为:

private void siftUp(int k, E x) {
if (comparator != null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}

根据是否有comparator分为了两种情况,代码类似,我们只看一种:

private void siftUpUsingComparator(int k, E x) {
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if (comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}

参数k表示插入位置,x表示新元素。k初始等于数组大小,即在最后一个位置插入。代码的主要部分是:往上寻找x真正应该插入的位置,这个位置用k表示。

怎么找呢?新元素(x)不断与父节点(e)比较,如果新元素(x)大于等于父节点(e),则已满足堆的性质,退出循环,k就是新元素最终的位置,否则,将父节点往下移(queue[k]=e),继续向上寻找。这与上节介绍的算法和图示是对应的。

查看头部元素

代码为:

public E peek() {
if (size == 0)
return null;
return (E) queue[0];
}

就是返回第一个元素。

删除头部元素 (出队)

代码为:

public E poll() {
if (size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0];
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);
return result;
}

返回结果result为第一个元素,x指向最后一个元素,将最后位置设置为null (queue[s] = null),最后调用siftDown将原来的最后元素x插入头部并调整堆,siftDown的代码为:

private void siftDown(int k, E x) {
if (comparator != null)
siftDownUsingComparator(k, x);
else
siftDownComparable(k, x);
}

同样分为两种情况,代码类似,我们只看一种:

private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
int right = child + 1;
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}

k表示最终的插入位置,初始为0,x表示原来的最后元素。代码的主要部分是:向下寻找x真正应该插入的位置,这个位置用k表示。

怎么找呢?新元素key不断与较小的孩子比较,如果小于等于较小的孩子,则已满足堆的性质,退出循环,k就是最终位置,否则将较小的孩子往上移,继续向下寻找。这与上节介绍的算法和图示也是对应的。

解释下其中的一些代码:

  • k<half,表示的是,编号为k的节点有孩子节点,没有孩子,就不需要继续找了。
  • child表示较小的孩子编号,初始为左孩子,如果有右孩子(编号right)且小于左孩子则child会变为right。
  • c表示较小的孩子节点。

查找元素

代码为:

public boolean contains(Object o) {
return indexOf(o) != -1;
}

indexOf的代码为:

private int indexOf(Object o) {
if (o != null) {
for (int i = 0; i < size; i++)
if (o.equals(queue[i]))
return i;
}
return -1;
}

代码很简单,就是数组的查找

根据值删除元素

也可以根据值删除元素,代码为:

public boolean remove(Object o) {
int i = indexOf(o);
if (i == -1)
return false;
else {
removeAt(i);
return true;
}
}

先查找元素的位置i,然后调用removeAt进行删除,removeAt的代码为:

private E removeAt(int i) {
assert i >= 0 && i < size;
modCount++;
int s = --size;
if (s == i) // removed last element
queue[i] = null;
else {
E moved = (E) queue[s];
queue[s] = null;
siftDown(i, moved);
if (queue[i] == moved) {
siftUp(i, moved);
if (queue[i] != moved)
return moved;
}
}
return null;
}

如果是删除最后一个位置,直接删即可,否则移动最后一个元素到位置i并进行堆调整,调整有两种情况,如果大于孩子节点,则向下调整,否则如果小于父节点则向上调整。

代码先向下调整(siftDown(i, moved)),如果没有调整过(queue[i] == moved),可能需向上调整,调用siftUp(i, moved)。

如果向上调整过,返回值为moved,其他情况返回null,这个主要用于正确实现PriorityQueue迭代器的删除方法,迭代器的细节我们就不介绍了。

构建初始堆

如果从一个既不是PriorityQueue也不是SortedSet的容器构造堆,代码为:

private void initFromCollection(Collection<? extends E> c) {
initElementsFromCollection(c);
heapify();
}

initElementsFromCollection的主要代码为:

private void initElementsFromCollection(Collection<? extends E> c) {
Object[] a = c.toArray();
if (a.getClass() != Object[].class)
a = Arrays.copyOf(a, a.length, Object[].class);
this.queue = a;
this.size = a.length;
}

主要是初始化queue和size。

heapify的代码为:

private void heapify() {
for (int i = (size >>> 1) - 1; i >= 0; i--)
siftDown(i, (E) queue[i]);
}

与之前算法一样,heapify也在上节介绍过了,就是从最后一个非叶节点开始,自底向上合并构建堆。

如果构造方法中的参数是PriorityQueue或SortedSet,则它们的toArray方法返回的数组就是有序的,就满足堆的性质,就不需要执行heapify了。

PriorityQueue特点分析

PriorityQueue实现了Queue接口,有优先级,内部是用堆实现的,这决定了它有如下特点:

  • 实现了优先级队列,最先出队的总是优先级最高的,即排序中的第一个。
  • 优先级可以有相同的,内部元素不是完全有序的,如果遍历输出,除了第一个,其他没有特定顺序。
  • 查看头部元素的效率很高,为O(1),入队、出队效率比较高,为O(log2(N)),构建堆heapify的效率为O(N)。
  • 根据值查找和删除元素的效率比较低,为O(N)。

小结

本节介绍了Java中堆的实现类PriorityQueue,它实现了队列接口Queue,但按优先级出队,我们介绍了其用法和实现代码。

除了用作基本的优先级队列,PriorityQueue还可以作为一种比较通用的数据结构,用于解决一些其他问题,让我们在下一节继续探讨。

---------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

计算机程序的思维逻辑 (46) - 剖析PriorityQueue的更多相关文章

  1. 计算机程序的思维逻辑 (29) - 剖析String

    上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比 ...

  2. 计算机程序的思维逻辑 (48) - 剖析ArrayDeque

    前面我们介绍了队列Queue的两个实现类LinkedList和PriorityQueue,LinkedList还实现了双端队列接口Deque,Java容器类中还有一个双端队列的实现类ArrayDequ ...

  3. 计算机程序的思维逻辑 (30) - 剖析StringBuilder

    上节介绍了String,提到如果字符串修改操作比较频繁,应该采用StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于,St ...

  4. 计算机程序的思维逻辑 (31) - 剖析Arrays

    数组是存储多个同类型元素的基本数据结构,数组中的元素在内存连续存放,可以通过数组下标直接定位任意元素,相比我们在后续章节介绍的其他容器,效率非常高. 数组操作是计算机程序中的常见基本操作,Java中有 ...

  5. 计算机程序的思维逻辑 (51) - 剖析EnumSet

    上节介绍了EnumMap,本节介绍同样针对枚举类型的Set接口的实现类EnumSet.与EnumMap类似,之所以会有一个专门的针对枚举类型的实现类,主要是因为它可以非常高效的实现Set接口. 之前介 ...

  6. 计算机程序的思维逻辑 (53) - 剖析Collections - 算法

    之前几节介绍了各种具体容器类和抽象容器类,上节我们提到,Java中有一个类Collections,提供了很多针对容器接口的通用功能,这些功能都是以静态方法的方式提供的. 都有哪些功能呢?大概可以分为两 ...

  7. 计算机程序的思维逻辑 (38) - 剖析ArrayList

    从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要 ...

  8. 计算机程序的思维逻辑 (40) - 剖析HashMap

    前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用? ...

  9. Java编程的逻辑 (46) - 剖析PriorityQueue

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

随机推荐

  1. 【原】FMDB源码阅读(三)

    [原]FMDB源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 FMDB比较优秀的地方就在于对多线程的处理.所以这一篇主要是研究FMDB的多线程处理的实现.而 ...

  2. ABP入门系列(2)——通过模板创建MAP版本项目

    一.从官网创建模板项目 进入官网下载模板项目 依次按下图选择: 输入验证码开始下载 下载提示: 二.启动项目 使用VS2015打开项目,还原Nuget包: 设置以Web结尾的项目,设置为启动项目: 打 ...

  3. iOS开发之Masonry框架源码深度解析

    Masonry是iOS在控件布局中经常使用的一个轻量级框架,Masonry让NSLayoutConstraint使用起来更为简洁.Masonry简化了NSLayoutConstraint的使用方式,让 ...

  4. 浅谈Slick(2)- Slick101:第一个动手尝试的项目

    看完Slick官方网站上关于Slick3.1.1技术文档后决定开始动手建一个项目来尝试一下Slick功能的具体使用方法.我把这个过程中的一些了解和想法记录下来和大家一起分享.首先我用IntelliJ- ...

  5. SAP CRM 用户界面对象类型和设计对象

    在CRM中的用户界面对象类型的帮助下,我们可以做这些工作: 进行不同的视图配置 创建动态导航 从设计层控制字段标签.值帮助 控制BOL对象的属性的可视性 从导航栏访问自定义组件 一个用户界面对象类型之 ...

  6. 基于开源项目SharpMap的热力图(HeatLayer)实现。

    当前公司需要一个用时较少的热力图呈现方案,在避免较底层的GDI开发和比较了多家GIS产品的实际效果之后,团队决定用sharpMap的API来实现,由于之前框架采用的是另外一个开源项目GMap.net, ...

  7. android Notification介绍

    如果要添加一个Notification,可以按照以下几个步骤 1:获取NotificationManager: NotificationManager m_NotificationManager=(N ...

  8. Harmonic Number(调和级数+欧拉常数)

    题意:求f(n)=1/1+1/2+1/3+1/4-1/n   (1 ≤ n ≤ 108).,精确到10-8    (原题在文末) 知识点:      调和级数(即f(n))至今没有一个完全正确的公式, ...

  9. Jenkins配置MSBuild实现自动部署(MSBuild+SVN/Subversion+FTP+BAT)

    所要用到的主要插件: [MSBuild Plugin] 具体操作: 1.配置MSBuild的版本 [系统管理]->[Global Tool Configuration]->[MSBuild ...

  10. Johnson 全源最短路径算法

    解决单源最短路径问题(Single Source Shortest Paths Problem)的算法包括: Dijkstra 单源最短路径算法:时间复杂度为 O(E + VlogV),要求权值非负: ...