首页 > 编程语言 >PriorityQueue源码阅读

PriorityQueue源码阅读

时间:2024-01-15 16:46:47浏览次数:31  
标签:遍历 int 元素 最小 PriorityQueue 源码 阅读 节点 es

目录

本人的源码阅读主要聚焦于类的使用场景,一般只在java层面进行分析,没有深入到一些native方法的实现。并且由于知识储备不完整,很可能出现疏漏甚至是谬误,欢迎指出共同学习

本文基于corretto-17.0.9源码,参考本文时请打开相应的源码对照,否则你会不知道我在说什么

简介

JCF相关基础类接口/抽象类源码阅读中介绍Queue接口时说过:

虽然名义上是队列,但不一定是FIFO,也可以是优先队列,也可以是LIFO。总而言之Queue只是提供“入队”和“出队”的语义,而出入队顺序并不作规定,即在给定入队顺序下,出队顺序由子类定义

这篇要介绍的就是优先队列PriorityQueue,先出队的元素的是当前队列中“最小的元素”,这里"最小"可以是1小于2这种数值间的比较,也可以是自定义的对复杂类之间的比较方法,比如长方形类优先队列,面积最小者为最小的元素:

class Retangle {
  public int length; // 长
  public int width; // 宽
	// 计算面积
  public int area() { return length * width; }
}
var pq = new PriorityQueue((x, y) -> {
  x.area() - y.area();
});

模型

从这篇开始,更换一下分析代码的风格,不一头扎进代码,代码只是实现抽象模型的具体实现,因此应该先搞懂模型,换句话说就是先搞懂原理,自上而下地分析。

优先队列是用这个数据结构来实现的,首先堆可以看成是一颗完全二叉树,每个节点存储单个元素,并且根据父节点是否大于子节点可以分为 最大堆最小堆,比如一个最大堆(为了简单起见,元素就是普通的整数类型):

image-20240114161757407

可以看到每个节点都比自己的子节点大。这样一来,根节点为堆的最大节点。而最小堆则是每个节点小于等于自己的子节点,根节点为堆的最小节点。

因此,回到我们的优先队列:先出队的元素的是当前队列中“最小的元素”。因此不难得知优先队列是对最小堆的封装,每次出队的元素就是最小堆的根节点即可,而入队的时候,将新节点添加到树的末尾(保持其还是一颗完全二叉树),然后逐渐将该节点上移使其保持堆的特性。

代码分析

成员变量

public class PriorityQueue<E> extends AbstractQueue<E> implements java.io.Serializable {
  // 堆
  transient Object[] queue;
  // 元素个数
  int size;
  // 用户自定义比较器
  private final Comparator<? super E> comparator;
  // 帮助迭代器检测结构性修改
  transient int modCount;
}

其中size和modCount不用再说。

queue就是堆这个数据结构,哎,堆不是二叉树吗,为什么可以用数组来存?因为堆不光是二叉树,还是一个完全二叉树,因此可以用数组来存储,下标为k的节点的两个字节点下标分别为k*2+1和k*2+2。

comparator是用户自定义的比较器,用于元素类型不是可比较的类型比如整数的时候,用户可以自己定义元素之间的大小关系,又或者比如类型就是整数,但不想要默认的最小堆,而是每次出队最大的元素,也可以通过用户自定义比较器做到。

方法

首先看构造方法,主要分析用集合来构造的方法:

public PriorityQueue(Collection<? extends E> c) {
  // 用SotedSet合初始化
  if (c instanceof SortedSet<?>) {
    SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
    this.comparator = (Comparator<? super E>) ss.comparator();
    initElementsFromCollection(ss);
  }
  // 用PriorityQueue初始化
  else if (c instanceof PriorityQueue<?>) {
    PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
    this.comparator = (Comparator<? super E>) pq.comparator();
    initFromPriorityQueue(pq);
  }
  // 普通初始化
  else {
    this.comparator = null;
    initFromCollection(c);
  }
}

最简单的是用PriorityQueue初始化,拷贝成员变量就行了。其次是用SortedeSet初始化,借助其toArray拷贝到底层数组。最后是用其他的集合初始化,也是toArray拷贝到底层数组,但是由于其他的集合toArray得到的数组不一定是最小堆,因此需要heapify将其调整为最小堆,即建堆

private void heapify() {
  final Object[] es = queue;
  int n = size, i = (n >>> 1) - 1;
  final Comparator<? super E> cmp;
  // 将节点下移调整到适合的位置。根据元素的自然顺序,元素必须已实现Comparable接口
  if ((cmp = comparator) == null)
    for (; i >= 0; i--)
      siftDownComparable(i, (E) es[i], es, n);
  // 将节点下移调整到适合的位置。使用用户自定义的Comparator
  else
    for (; i >= 0; i--)
      siftDownUsingComparator(i, (E) es[i], es, n, cmp);
}

heapify是建堆的实现,其思想为:从下至上、从右至左地处理所有的非叶子节点,比如在一开始举例子的图中,处理的顺序是节点80、90。

image-20240114161757407

对应到代码中就是:

// 初始化i为下标最大的非叶子节点
int n = size, i = (n >>> 1) - 1;
// 遍历所有非叶子节点
for (; i >= 0; i--)
	siftDownUsingComparator(i, (E) es[i], es, n, cmp);

具体到每个节点的处理过程如下:为了维护最小堆的特性,当前的非叶子节点x若大于任一子节点,将其与较小的子节点交换,不断重复这个过程,总体来看就相当于不断把x下移,当不大于任一子节点的时候,或者已经没有子节点(成为了叶节点)则处理完成。由于Comparable与Comparator的建堆都是一样的,只是API的使用上有点区别,因此我们只分析Comparator,即siftDownUsingComparator函数即可,分析都写在注释里:

// 实现思路:将节点x与子节点比较,如果比任一子节点大,那么与较小子节点交换(x下移),重复这个步骤直到不比子节点大或者成为叶子节点
private static <T> void siftDownUsingComparator(int k, T x, Object[] es, int n, Comparator<? super T> cmp) {
  // half为叶子节点的最小下标,即非叶子节点的最大下标+1
  int half = n >>> 1;
  // 只需要处理非叶子节点
  while (k < half) {
    int child = (k << 1) + 1;
    Object c = es[child];
    int right = child + 1;
    // 选择较小的子节点来比较
    if (right < n && cmp.compare((T) c, (T) es[right]) > 0)
      c = es[child = right];
    if (cmp.compare(x, (T) c) <= 0)
      break;
    // 如果较小子节点比父节点x小的话,将该子节点放到父节点的位置,即x下移
    es[k] = c;
    k = child;
  }
  // 此时k已经处于叶节点的位置或者x已经比k位置的两个子节点都小
  es[k] = x;
}

这个建堆的过程时间复杂度为O(n),n为元素的个数,即底层数组queue.length。这个时间复杂度有点意思,第一眼看上去似乎是O(nlogn),因为处理的节点大致为n/2个,并且节点最坏情况下会发生logn次交换,即树高logn。但其实并不是,比如logn这个就有问题,高度为i的节点最多只会交换logi而不是logn。建堆具体的复杂度分析可以参考,讲得很清晰明了:堆排序中建堆过程时间复杂度O(n)怎么来的? - SCVTheDefect的回答 - 知乎

优先队列作为一个队列,最重要的方法莫过于入队和出队,先看下入队。入队其实就是把新元素放到数组末尾,然后再调用函数将数组重新调整为最小堆:

public boolean offer(E e) {
  if (e == null)
    throw new NullPointerException();
  modCount++;
  int i = size;
  // 扩容
  if (i >= queue.length)
    grow(i + 1);
  // 插入元素到数组尾部并调整为最小堆
  siftUp(i, e);
  size = i + 1;
  return true;
}

首先优先队列用数组存储堆,扩容肯定是需要的,看过其他基于数组的JCF类比如ArrayList、ArrayDeque等就知道,扩容主要在于扩多少,这里是如果原容量较小就扩为原来的2倍,否则就1.5倍,扩容代码没什么好分析,就不看了。

主要看一下siftUp函数:

private void siftUp(int k, E x) {
  if (comparator != null)
    // 将节点上移调整到适合的位置。使用Comparator
    siftUpUsingComparator(k, x, queue, comparator);
  else
    // 将节点上移调整到合适的位置。使用Comparable
    siftUpComparable(k, x, queue);
}

注意之前建堆的过程中是让节点下移(siftDown)这里是上移(siftUp),上移的目的是当节点与其父节点之间违反了最小堆的特性时需要进行调整,为了维护最小堆:如果父节点比该节点大的话,将其与父节点交换,重复这个过程,总体来看相当于把这个节点不断上移,直到父节点不比该节点大或者这个节点已经是根节点(根节点没有父节点),则处理完成,我们依然选择Comparator版本来分析。

private static <T> void siftUpUsingComparator(int k, T x, Object[] es, Comparator<? super T> cmp) {
  // 如果k已经为0说明为根节点,没有节点
  while (k > 0) {
    // 获取父节点
    int parent = (k - 1) >>> 1;
    Object e = es[parent];
    // 如果父节点不比该节点则处理完成,退出循环
    if (cmp.compare(x, (T) e) >= 0)
      break;
    // 与父节点交换(父节点下移)
    es[k] = e;
    k = parent;
  }
  es[k] = x;
}

ok,再分析下出队,由于是数组是最小堆,因此根节点就是最小节点,把它取走出队,然后把数组最后一个元素补上来作为新的根节点,然后siftDown这个新补上来的节点,不断下移调整维护最小堆特性。下移之前介绍过,目的是当该节点与子节点之间违反最小堆特性的时候进行调整

public E poll() {
  final Object[] es;
  final E result;
  // 如果队列不空,取出根节点作为返回结果
  if ((result = (E) ((es = queue)[0])) != null) {
    modCount++;
    final int n;
    final E x = (E) es[(n = --size)];
    es[n] = null;
    // 如果取走根节点后,队列不为空,则将数组调整为堆
    if (n > 0) {
      final Comparator<? super E> cmp;
      if ((cmp = comparator) == null)
        siftDownComparable(0, x, es, n);
      else
        siftDownUsingComparator(0, x, es, n, cmp);
    }
  }
  return result;
}

Queue接口的出入队方法基本分析完了,下面来看看Collection接口的remove方法:

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

先用indexOf遍历元素定位被删除元素的下标,然后调用removeAt删除该节点。removeAt还是比较好理解的:如果删除的不是末尾节点的话,将节点删除后用末尾节点补上,然后调整该节点以维护最小堆:

E removeAt(int i) {
  final Object[] es = queue;
  modCount++;
  int s = --size;
  if (s == i)
    es[i] = null;
  else {
    // 如果不是删除末尾节点,那么将末尾节点补到被删除节点的位置,用moved保存,下一步作维护最小堆的调整
    E moved = (E) es[s];
    es[s] = null;
    // moved可能大于子节点,下移调整
    siftDown(i, moved);
    // 如果已经下移了,那么就不用再调整
    // 否则,moved可能小于父节点,需要上移进行调整
    if (es[i] == moved) {
      siftUp(i, moved);
      // 如果上移了,那么上移完成后moved所在下标小于i,返回moved
      if (es[i] != moved)
        return moved;
    }
  }
  // 返回null表示i-1以及之前的节点都没变
  return null;
}

难理解的地方在于返回值,第一眼看上去就是如果补上的节点会上移的话,就返回该节点否则返回null,就很奇怪为什么要这样设计。看了注释之后才知道:

Removes the ith element from queue. Normally this method leaves the elements at up to i-1, inclusive, untouched. Under these circumstances, it returns null.

一般情况下当移除第i个节点后,补上的末尾节点不会被上移,那么最终该节点的下标>=i。这种情况下返回null。

Occasionally, in order to maintain the heap invariant, it must swap a later element of the list with one earlier than i. Under these circumstances, this method returns the element that was previously at the end of the list and is now at some position before i.

但如果补上的节点需要上移的话,则该节点最终的下标<i。这种情况下返回该节点。

This fact is used by iterator.remove so as to avoid missing traversing elements.

这样设计是为了迭代器在遍历元素的时候,调用remove某个元素后,避免因为补上的末尾节点被上移到 i 之前的位置而没被遍历到,因此需要返回该节点并保存,保证之后会被遍历到。

再补充下,下面分析迭代器的时候会知道,它是直接按照下标顺序遍历数组,而remove可能会造成还没被遍历到的节点被移动到已经被遍历的下标,但为了保持最小堆特性又不得不这样做,因此迭代器用ArrayDeque保存这种在迭代过程中被上移的节点,以保证其最后会被遍历到。从这个角度也能知道优先队列迭代器的迭代顺序是不确定的,没有特定的顺序。

至此,PriorityQueue的各个方法已经分析得差不多,还有一个bulkRemove在ArrayList源码阅读中已经分析过,在这里的实现只能说是大同小异,不再分析。

接下来简单看一下迭代器。

private final class Itr implements Iterator<E> {
  // 指向下一个遍历到元素的指针
  private int cursor;
  // 指向刚被遍历的元素的的指针
  private int lastRet = -1;
  // 保存还未被遍历但被移动到了已遍历下标的元素
  private ArrayDeque<E> forgetMeNot;
  // 功能与lastRet一样,但是是与forgetMeNot配合使用,保存上一个被遍历到forgetMeNot的元素
  private E lastRetElt;
  // 期望的修改次数,用于检测并发修改
  private int expectedModCount = modCount;
}

cursor、lastRet、expectedModCount不用再解释。

forgetMeNot这个集合保存由于remove导致还没被遍历的元素,被移动到了当前cursor之前的位置,保证他们最终会被遍历到(详见removeAt的分析)。

迭代器的方法实现没什么特别的,不分析了。

总结

PriorityQueue实现了优先队列,其实际上是对最小堆这个数据结构的封装,由于堆是根据元素之间的大小关系来构建的,用户可以通过实现Comparable接口或传入Comparator对象自定义元素类之间的大小关系,一般情况下推荐使用Comparator,侵入性更低。建堆复杂度O(n),出入队操作复杂度为O(logn)。

参考链接

「知乎」堆排序中建堆过程时间复杂度O(n)怎么来的? - SCVTheDefect的回答 - 知乎

标签:遍历,int,元素,最小,PriorityQueue,源码,阅读,节点,es
From: https://www.cnblogs.com/nosae/p/17965691

相关文章

  • 学习spring源码(一)
    学习文档来自小傅哥,详情可以去原文章了解,这边只是简单记录一下学习体会《Spring手撸专栏》第3章:初显身手,运用设计模式,实现Bean的定义、注册、获取工程结构:类似是这样,我这边稍微有点区别,仅做参考small-spring-step-02└──src├──main│└──java......
  • ICLR 2022: Anomaly Transformer论文阅读笔记(2) 深度解析代码
    AnomalyTransformer是一个由Transformer:AttentionIsAllYouNeed启发出的检测时间序列异常点的无监督学习算法。在这一篇我会深度解析论文算法以及代码的一一对应,让人更方便能读懂和使用源代码。阅读笔记前篇:ICLR2022:AnomalyTransformer论文阅读笔记+代码复现阅读前提......
  • PDF.js实现按需分片加载pdf文件-包含前后端开发源码和详细开发教程
    PDF.js实现按需分片加载pdf文件-包含前后端开发源码和详细开发教程:https://blog.csdn.net/qq_29864051/article/details/130742657?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522170529842016800186594900%2522%252C%2522scm%2522%253A%252220140713.130102334..%252......
  • [源码分析] - flex 标准文档导读与 一个rust实现解析
    本文是w3中css-flexbox[标准文档](CSSFlexibleBoxLayoutModuleLevel1(w3.org)解读.(2023.1),并对一些开源实现进行调研分析.文档导读csslayoutmodecsslayout模式用于确定在盒模型中的元素如何排布(大小与位置),在css2.1中有如下几种方式.blocklayout,块级别......
  • QR二维码生成器源码(中间可插入小图片)
    QR二维码生成器源码(中间可插入小图片) 二维码终于火了,现在大街小巷大小商品广告上的二维码标签都随处可见,而且大都不是简单的纯二维码,而是中间有个性图标的二维码。我之前做了一个使用google开源项目zxing实现二维码、一维码编码解码的程序并开放了源码(用C#实现的条形码和二......
  • Django 源码分析(一):命令分析
    Django源码分析(一):命令分析说明:本部分主要介绍Django程序在开发中常用的命令是如何控制生成的进行解析;1.分析入口启动命令:pythonmanage.pyrunserver127.0.0.1:8000项目启动的时候执行的manage.py脚本,相关代码如下:"""Django'scommand-lineutilityforadministrat......
  • Django 源码分析(二):wsgi & asgi
    Django源码分析(二):wsgi&asgi说明:上一节主要讲述了django项目的启动,后期主要会根据django请求的生命周期进行分析;参考文章:https://zhuanlan.zhihu.com/p/95942024参考文章:https://zhuanlan.zhihu.com/p/269456318附:生命周期参考图;第一步:浏览器发起请求补充:第一步和第......
  • Django 源码(三)-应用 & 中间件 & 配置文件
    Django源码(三)-应用&中间件&配置文件本部分主要是在为程序启动时候加载应用以及中间件的信息;1.应用的加载在程序启动的部分,我们分析到程序执行的时候都会执行一个setup()函数,相关的内容可以看之前的章节的部分;defsetup(set_prefix=True):"""Configurethes......
  • Promise超详细源码解读
    说到promise,相信大家在日常开发中都经常使用到,它是我们异步操作中必不可少的一部分,可以让代码看起来变得更好理解;我曾在技术社区看过许多关于promise底层原理的文章,大概原理明白,这次,我准备系统的分析实现源码并记录下来,本文将一行行代码去分析最后附加流程图和总结,希望这能对你......
  • 【设计模式】策略模式——策略模式在JDK源码中的应用
    策略模式在JDK中具有广泛的应用,本文只讨论最常见的应用。RejectedExecutionHandler在线程池使用有界队列并且最大线程数不为Integer.MAX_VALUE的时候,一旦task数量达到临界点,新的task添加到线程池的时候就会出现问题,ThreadPoolExecutor的构造方法中参数最多的方法中最后一个参数就是......