《Java 编程的逻辑》笔记——第11章 堆与优先级队列(二)

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

11.2 剖析 PriorityQueue

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

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

11.2.1 基本概念

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

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

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

11.2.2 基本用法

11.2.2.1 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),返回头部元素,并且从队列中删除

11.2.2.2 构造方法

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 接口。

11.2.2.3 基本例子

我们来看个基本的例子:

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 

11.2.2.4 任务队列

我们再来看个例子,模拟一个任务队列,定义一个内部类 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

11.2.3 实现原理

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

11.2.3.1 内部组成

内部有如下成员:

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 时已介绍过。

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

11.2.3.2 基本构造方法

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

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。

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

11.2.3.3 添加元素 (入队)

代码为:

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),继续向上寻找。这与上节介绍的算法和图示是对应的。

11.2.3.4 查看头部元素

代码为:

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

就是返回第一个元素。

11.2.3.5 删除头部元素 (出队)

代码为:

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 表示较小的孩子节点。

11.2.3.6 查找元素

代码为:

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;
}

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

11.2.3.7 根据值删除元素

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

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 迭代器的删除方法,迭代器的细节我们就不介绍了。

11.2.3.8 构建初始堆

如果从一个既不是 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 了。

11.2.4 小结

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

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

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

11.3 堆和 PriorityQueue 的应用

PriorityQueue 除了用作优先级队列,还可以用来解决一些别的问题,比如下面两个应用:

  • 求前 K 个最大的元素,元素个数不确定,数据量可能很大,甚至源源不断到来,但需要知道到目前为止的最大的前 K 个元素。这个问题的变体有:求前 K 个最小的元素,求第 K 个最大的,求第 K 个最小的。
  • 求中值元素,中值不是平均值,而是排序后中间那个元素的值,同样,数据量可能很大,甚至源源不断到来。

本节,我们就来探讨如何解决这两个问题。

11.3.1 求前 K 个最大的元素

11.3.1.1 基本思路

一个简单的思路是排序,排序后取最大的 K 个就可以了,排序可以使用 Arrays.sort() 方法,效率为O(N*log2(N))。不过,如果 K 很小,比如是 1,就是取最大值,对所有元素完全排序是毫无必要的。

另一个简单的思路是选择,循环选择 K 次,每次从剩下的元素中选择最大值,这个效率为 O(N*K),如果 K 的值大于 log2(N),这个就不如完全排序了。

不过,这两个思路都假定所有元素都是已知的,而不是动态添加的。如果元素个数不确定,且源源不断到来呢?

一个基本的思路是:维护一个长度为 K 的数组,最前面的 K 个元素就是目前最大的 K 个元素,以后每来一个新元素的时候,都先找数组中的最小值,将新元素与最小值相比,如果小于最小值,则什么都不用变,如果大于最小值,则将最小值替换为新元素

这有点类似于生活中的末尾淘汰,新元素与原来最末尾的比即可,要么不如最末尾,上不去,要么替掉原来的末尾。

这样,数组中维护的永远是最大的 K 个元素,而且不管源数据有多少,需要的内存开销是固定的,就是长度为 K 的数组。不过,每来一个元素,都需要找最小值,都需要进行 K 次比较,能不能减少比较次数呢?

解决方法是使用最小堆维护这 K 个元素,最小堆中,根即第一个元素永远都是最小的,新来的元素与根比就可以了,如果小于根,则堆不需要变化,否则用新元素替换根,然后向下调整堆即可,调整的效率为 O(log2(K)),这样,总体的效率就是 O(N*log2(K)),这个效率非常高,而且存储成本也很低

使用最小堆之后,第 K 个最大的元素也很容易获得,它就是堆的根。

理解了思路,下面我们来看代码。

11.3.1.2 实现代码

我们来实现一个简单的 TopK 类,代码如下所示:

public class TopK <E> {
    private PriorityQueue<E> p;
    private int k;
    
    public TopK(int k){
        this.k = k;
        this.p = new PriorityQueue<>(k);
    }

    public void addAll(Collection<? extends E> c){
        for(E e : c){
            add(e);
        }
    }
    
    public void add(E e) {
        if(p.size()<k){
            p.add(e);
            return;
        }
        Comparable<? super E> head = (Comparable<? super E>)p.peek();
        if(head.compareTo(e)>0){
            //小于TopK中的最小值,不用变
            return;
        }
        //新元素替换掉原来的最小值成为Top K之一。
        p.poll();
        p.add(e);
    }
    
    public <T> T[] toArray(T[] a){
        return p.toArray(a);
    }

    public E getKth(){
        return p.peek();
    }
}

我们稍微解释一下。

TopK 内部使用一个优先级队列和 k,构造方法接受一个参数 k,使用 PriorityQueue 的默认构造方法,假定元素实现了 Comparable 接口。

add 方法,实现向其中动态添加元素,如果元素个数小于 k 直接添加,否则与最小值比较,只在大于最小值的情况下添加,添加前,先删掉原来的最小值。addAll 方法循环调用 add 方法。

toArray 方法返回当前的最大的 K 个元素,getKth 方法返回第 K 个最大的元素。

我们来看一下使用的例子:

TopK<Integer> top5 = new TopK<>(5);
top5.addAll(Arrays.asList(new Integer[]{
        100, 1, 2, 5, 6, 7, 34, 9, 3, 4, 5, 8, 23, 21, 90, 1, 0
}));

System.out.println(Arrays.toString(top5.toArray(new Integer[0])));
System.out.println(top5.getKth());

保留 5 个最大的元素,输出为:

[21, 23, 34, 100, 90]
21

代码比较简单,就不解释了。

11.3.2 求中值

11.3.2.1 基本思路

中值就排序后中间那个元素的值,如果元素个数为奇数,中值是没有歧义的,但如果是偶数,中值可能有不同的定义,可以为偏小的那个,也可以是偏大的那个,或者两者的平均值,或者任意一个,这里,我们假定任意一个都可以。

一个简单的思路是排序,排序后取中间那个值就可以了,排序可以使用 Arrays.sort() 方法,效率为 O(N*log2(N))。

不过,这要求所有元素都是已知的,而不是动态添加的。如果元素源源不断到来,如何实时得到当前已经输入的元素序列的中位数?

可以使用两个堆,一个最大堆,一个最小堆,思路如下

  1. 假设当前的中位数为 m,最大堆维护的是 <=m 的元素,最小堆维护的是 >=m 的元素,但两个堆都不包含 m。
  2. 当新的元素到达时,比如为 e,将 e 与 m 进行比较,若 e<=m,则将其加入到最大堆中,否则将其加入到最小堆中。
  3. 第二步后,如果此时最小堆和最大堆的元素个数的差值 >=2 ,则将 m 加入到元素个数少的堆中,然后从元素个数多的堆将根节点移除并赋值给 m。

我们通过一个例子来解释下,比如输入元素依次为:

34, 90, 67, 45,1

输入第一个元素时,m 即为 34。

输入第二个元素时,90 大于 34,加入最小堆,中值不变,如图 11-20 所示:

在这里插入图片描述

输入第三个元素时,67 大于 34,加入最小堆,但加入最小堆后,最小堆的元素个数为 2,需调整中值和堆,现有中值 34 加入到最大堆中,最小堆的根 67 从最小堆中删除并赋值给 m,如图 11-21 所示:

在这里插入图片描述

输入第四个元素 45 时,45 小于 67,加入最大堆,中值不变,如图 11-22 所示:

在这里插入图片描述

输入第五个元素 1 时,1 小于 67,加入最大堆,此时需调整中值和堆,现有中值 67 加入到最小堆中,最大堆的根 45 从最大堆中删除并赋值给 m,如图 11-23 所示:

在这里插入图片描述

11.3.1.2 实现代码

理解了基本思路,我们来实现一个简单的中值类 Median,代码如下所示:

public class Median <E> {
    private PriorityQueue<E> minP; // 最小堆
    private PriorityQueue<E> maxP; //最大堆
    private E m; //当前中值
    
    public Median(){
        this.minP = new PriorityQueue<>();
        this.maxP = new PriorityQueue<>(11, Collections.reverseOrder());
    }
    
    private int compare(E e, E m){
        Comparable<? super E> cmpr = (Comparable<? super E>)e;
        return cmpr.compareTo(m);
    }
    
    public void add(E e){
        if(m==null){ //第一个元素
            m = e;
            return;
        }
        if(compare(e, m)<=0){
            //小于中值, 加入最大堆
            maxP.add(e);
        }else{
            minP.add(e);
        }
        if(minP.size()-maxP.size()>=2){
            //最小堆元素个数多,即大于中值的数多
            //将m加入到最大堆中,然后将最小堆中的根移除赋给m
            maxP.add(this.m);
            this.m = minP.poll();
        }else if(maxP.size()-minP.size()>=2){
            minP.add(this.m);
            this.m = maxP.poll();
        }
    }
    
    public void addAll(Collection<? extends E> c){
        for(E e : c){
            add(e);
        }
    }
    
    public E getM() {
        return m;
    }
}

代码和思路基本是对应的,比较简单,就不解释了。我们来看一个使用的例子:

Median<Integer> median = new Median<>();
List<Integer> list = Arrays.asList(new Integer[]{
        34, 90, 67, 45, 1, 4, 5, 6, 7, 9, 10
});
median.addAll(list);
System.out.println(median.getM());

输出为中值 9。

11.3.3 小结

本节介绍了堆和 PriorityQueue 的两个应用,求前 K 个最大的元素和求中值,介绍了基本思路和实现代码,相比使用排序,使用堆不仅实现效率更高,而且还可以应对数据量不确定且源源不断到来的情况,可以给出实时结果。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bm1998

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值