Bootstrap

初阶数据结构(8)(优先级队列的模拟实现:堆的概念、性质、存储、创建——向下和向上调整、插入与删除、PriorityQueue常用接口介绍、构造、常见方法、扩容、top-K问题、堆的排序、对象的比较)

接上次博客:二叉树相关OJ练习题(1、锯齿形层序遍历 2、二叉搜索子树的最大键值和 3、验证二叉树 4、剑指 Offer II 047. 二叉树剪枝)_di-Dora的博客-CSDN博客

目录

 优先级队列(Priority Queue)

优先级队列的模拟实现

堆的概念

堆的性质 

堆的存储方式

堆的创建

堆向下调整

向下过程(以大堆为例):

向下过程(以小堆为例):

建堆(向下调整)的时间复杂度

堆的插入与删除

堆的插入

堆的删除

用堆模拟实现优先级队列

常见习题

常用接口介绍

PriorityQueue的特性

PriorityQueue常用接口介绍 

1. 优先级队列的构造:

2、常见方法

3、扩容方式

 OJ练习——top-k问题:最大或者最小的前k个数据

做法1:堆排序:

做法2:堆数据结构:

最佳做法:

java对象的比较

元素的比较

基本类型的比较

对象比较的问题

对象的比较

覆写基类的equals

基于Comparble接口类的比较

基于比较器比较

三种方式对比

集合框架中PriorityQueue的比较方式


优先级队列(Priority Queue)

前面介绍过队列,队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列,该种场景下,使用队列显然不合适。这个时候,就需要使用优先级队列。

优先级队列是一种数据结构,用于存储具有优先级的元素集合。与传统的队列不同,优先级队列中的元素并不按照插入顺序进行处理,而是根据其优先级确定处理顺序。优先级较高的元素先被处理,而优先级较低的元素则被延迟处理。

优先级队列提供了两个基本操作:

1. 返回最高优先级对象:优先级队列允许快速访问当前具有最高优先级的元素,而无需遍历整个队列。通过执行相应的操作,可以直接获取队列中具有最高优先级的元素。这对于需要及时处理具有最高优先级的任务或数据项的应用场景非常重要。

2. 添加新的对象:当新的元素被添加到优先级队列中时,它会根据其优先级被正确地插入到队列的适当位置,以确保整个队列按照优先级顺序维护。这样,每当需要处理下一个具有最高优先级的元素时,可以立即访问并移除队列的开头元素。

通过使用优先级队列,可以高效地管理具有不同优先级的任务、事件或数据项。这种数据结构在许多实际应用中都发挥着重要作用,如调度算法、事件处理、路由器缓存管理等。它提供了一种灵活且高效的方式来处理具有优先级的元素,确保优先级高的元素被及时处理,从而满足各种需求和场景的要求。

优先级队列的模拟实现

JDK1.8中的PriorityQueue(顺序存储的二叉树)底层使用了堆这种数据结构,而堆实际就是在完全二叉树的基础上进行了一些调整。

堆的概念

堆是一种基于完全二叉树的数据结构,用于实现优先级队列。在堆中,关键码的集合被存储在一维数组中,并满足以下性质:对于任意节点 Ki,其左子节点 K2i+1 和右子节点 K2i+2 的关键码值满足特定的大小关系。

如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储 在一 个一维数组中,并满足:K i = K 2i +1 且 K i >= K 2i + 2  ( i = 0,1,2…),则称为小堆(或大堆)。

将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

对于最大堆(或大根堆),满足 Ki >= K2i+1 且 Ki >= K2i+2,即根节点的关键码值最大。而对于最小堆(或小根堆),满足 Ki <= K2i+1 且 Ki <= K2i+2,即根节点的关键码值最小。

在最大堆中,根节点的关键码值最大,且每个父节点的关键码值都大于或等于其子节点的关键码值。这意味着在最大堆中,优先级最高的元素(根节点)可以通过常数时间复杂度(O(1))来获取。

类似地,在最小堆中,根节点的关键码值最小,且每个父节点的关键码值都小于或等于其子节点的关键码值。因此,在最小堆中,优先级最高的元素也可以通过常数时间复杂度(O(1))来获取。

堆结构的这种性质使其非常适合实现优先级队列。通过使用堆,可以快速访问和操作具有最高优先级的元素,而不需要对整个集合进行排序或遍历。

除了优先级队列,堆还用于各种排序算法,如堆排序(Heap Sort)和优先级队列的实现。堆排序利用堆的性质,将待排序的元素构建成一个堆,然后通过不断取出根节点,并调整堆的结构,最终得到有序的输出序列。这些我们一会儿都会介绍到。

总之,堆是一种基于完全二叉树的数据结构,它通过满足特定的大小关系来维护关键码的优先级。最大堆和最小堆分别用于获取具有最高和最低优先级的元素。堆在优先级队列和排序算法中发挥着重要的作用,提供了高效的操作和访问方式。

堆的性质 

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树。

小根堆:孩子节点比根节点大:

大根堆:孩子节点比根节点小。

 存储结构表示的是层序遍历的顺序。

堆的存储方式

从堆的概念可知,堆是一棵完全二叉树,因此可以层序的规则采用顺序的方式来高效存储。

我们使用一维数组来表示完全二叉树的结构。

具体地,将堆的元素按照完全二叉树的顺序存储在数组中,其中数组的索引从0开始。

假设有一个堆的关键码集合 K = {k0, k1, k2, ..., kn-1},按照堆的性质,元素的存储方式满足以下规则:

根节点位于数组的索引 0 处,即 K[0] 表示堆的根节点。
对于任意节点 K[i],其左子节点为 K[2i+1],右子节点为 K[2i+2]。
对于任意节点 K[i],其父节点为 K[(i-1)/2](向下取整)。

注意:对于非完全二叉树,则不适合使用顺序方式进行存储,因为为了能够还原二叉树,空间中必须要存储空节点,就会导致空间利用率比较低。

将元素存储到数组中后,可以根据二叉树的性质对树进行还原。假设i为节点在数组中的下标,则有:

  • 如果i为0,则i表示的节点为根节点,否则i节点的双亲节点为 (i - 1)/2
  • 如果2 * i + 1 小于节点个数,则节点i的左孩子下标为2 * i + 1,否则没有左孩子
  • 如果2 * i + 2 小于节点个数,则节点i的右孩子下标为2 * i + 2,否则没有右孩子

总之,通过这种存储方式,可以方便地根据节点的索引计算出其父节点和子节点的索引,而无需使用指针或其他额外的数据结构。通过使用一维数组来存储堆的元素,可以节省空间并简化对堆的操作。

而在实际的实现中,我们可以通过计算索引和数组的交换操作来实现堆的插入、删除和调整等操作,以维护堆的性质和顺序。 

堆的创建

堆向下调整

对于集合{ 27,15,19,18,28,34,65,49,25,37 }中的数据,如果将其创建成堆呢? 

向下过程(以大堆为例):

下面是按照给定步骤进行向下调整最大堆的详细步骤:

1. 标记需要调整的节点为 parent,同时标记 parent 的左孩子为 child。确保 parent 至少有左孩子。(注意:parent如果有孩子一定先是有左孩子)

2. 如果 parent 的左孩子存在(child < size),执行以下操作,直到 parent 的左孩子不存在:

   a. 检查 parent 的右孩子是否存在(child + 1 < size)。如果右孩子存在,则找到左右孩子中较大的孩子,并将 child 标记为较大的孩子。

   b. 比较 parent 与较大的孩子 child 的值:

  • 如果 parent 小于较大的孩子 child,则调整结束,当前子树满足最大堆的性质。
  • 如果 parent 大于较大的孩子 child,则交换 parent 和较大的孩子 child 的值。交换后,较大的元素被移到 parent 的位置,可能导致子树不满足最大堆的性质,因此需要继续向下调整。

3. 更新 parent 和 child 的值:parent = child,child = parent * 2 + 1。

4. 回到步骤 2,继续向下调整,直到 parent 的左孩子不存在。

这个过程实际上是将不满足最大堆性质的元素向下移动,以恢复堆的性质。通过不断比较 parent 和其孩子的值,并交换它们的位置,确保较大的元素在父节点位置,从而满足最大堆的性质。

需要注意的是,由于交换可能导致子树不满足最大堆的性质,因此在交换后需要继续向下调整被交换元素所在的子树,以保持整个堆的性质。通过更新 parent 和 child 的值,并重复执行步骤 2,可以持续进行向下调整,直到当前子树满足最大堆的性质或没有左孩子存在为止。

public void shiftDownbig(int[] array, int parent) {
    int child = 2 * parent + 1; // 左孩子下标
    int size = array.length;
    while (child < size) {
        // 如果右孩子存在,找到左右孩子中较大的孩子,用 child 进行标记
        if (child + 1 < size && array[child + 1] > array[child]) {
            child += 1;
        }
        // 如果双亲比其最大的孩子还大,说明该结构已经满足大根堆的特性了
        if (array[parent] >= array[child]) {
            break;
        } else {
            // 将双亲与较大的孩子交换
            int t = array[parent];
            array[parent] = array[child];
            array[child] = t;
            // parent 中小的元素往下移动,可能会造成子树不满足堆的性质,因此需要继续向下调整
            parent = child;
            child = parent * 2 + 1;
        }
    }
}

 p = ( len - 1 - 1 ) / 2 最后一棵子树的根节点

往后只用 p - - 就可以找到每一棵子树的根节点,并调整。

向下过程(以小堆为例):

下面是按照给定步骤进行向下调整最小堆的详细步骤:

1. 标记需要调整的节点为 parent,同时标记 parent 的左孩子为 child。确保 parent 至少有左孩子。

2. 如果 parent 的左孩子存在(child < size),执行以下操作,直到 parent 的左孩子不存在:

   a. 检查 parent 的右孩子是否存在(child + 1 < size)。如果右孩子存在,则找到左右孩子中较小的孩子,并将 child 标记为较小的孩子。

   b. 比较 parent 与较小的孩子 child 的值:

  • 如果 parent 小于较小的孩子 child,则调整结束,当前子树满足最小堆的性质。
  • 如果 parent 大于较小的孩子 child,则交换 parent 和较小的孩子 child 的值。交换后,较小的元素被移到 parent 的位置,可能导致子树不满足最小堆的性质,因此需要继续向下调整。

3. 更新 parent 和 child 的值:parent = child,child = parent * 2 + 1。

4. 回到步骤 2,继续向下调整,直到 parent 的左孩子不存在。

这个过程实际上是将不满足最小堆性质的元素向下移动,以恢复堆的性质。通过不断比较 parent 和其孩子的值,并交换它们的位置,确保较小的元素在父节点位置,从而满足最小堆的性质。

类似于向下调整最大堆的步骤,由于交换可能导致子树不满足最小堆的性质,因此在交换后需要继续向下调整被交换元素所在的子树,以保持整个堆的性质。通过更新 parent 和 child 的值,并重复执行步骤 2,可以持续进行向下调整,直到当前子树满足最小堆的性质或没有左孩子存在为止。

    public void shiftDownsmall(int[] array, int parent) {
        // child先标记parent的左孩子,因为parent可能有左没有右
        int child = 2 * parent + 1;
        int size = array.length;
        while (child < size) {
            // 如果右孩子存在,找到左右孩子中较小的孩子,用child进行标记
            if (child + 1 < size && array[child + 1] < array[child]) {

                child += 1;
            }
            // 如果双亲比其最小的孩子还小,说明该结构已经满足小根堆的特性了
            if (array[parent] <= array[child]) {
                break;
            } else {
                // 将双亲与较小的孩子交换
                int t = array[parent];
                array[parent] = array[child];
                array[child] = t;
                // parent中大的元素往下移动,可能会造成子树不满足堆的性质,因此需要继续向下调整
                parent = child;
                child = parent * 2 + 1;
            }
        }
    }

 注意:在调整以parent为根的二叉树时,必须要满足parent的左子树和右子树已经是堆了才可以向下调整。

时间复杂度分析: 最坏的情况,从根一路比较到叶子,比较的次数为完全二叉树的高度,即时间复杂度为 O( log以2为底 n)

建堆(向下调整)的时间复杂度

因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是 近似值,多几个节点不影响最终结果):

 因此:建堆的时间复杂度为O(N)。

同理,向上调整的时间复杂度:

堆的插入与删除

堆的插入

堆的插入操作包括以下两个关键步骤:

1. 将元素放入堆的末尾:首先,将待插入的元素放入堆的末尾,也就是放在最后一个孩子节点的后面。如果堆的内部数组空间不够,需要进行扩容操作,以保证能够容纳新的元素。

2. 向上调整:插入后,如果堆的结构被破坏,即新插入的节点不满足堆的性质(最大堆或最小堆),就需要将新插入的节点沿着其双亲(父节点)逐级向上调整,直到满足堆的性质。

这里我们涉及到一个新的概念——向上调整。

向上调整的具体步骤如下:

  • 将新插入的节点与其父节点进行比较。如果堆是最大堆,比较父节点与新节点的值,如果新节点的值比父节点大,则交换它们的位置。如果堆是最小堆,比较父节点与新节点的值,如果新节点的值比父节点小,则交换它们的位置。通过交换,新插入的节点被向上移动一层。
  • 重复进行上述比较和交换的步骤,直到新插入的节点已经成为堆的根节点(没有父节点),或者它的值满足堆的性质,不再需要交换。

这样,通过向上调整,新插入的节点可以沿着堆的路径逐级向上移动,直到它找到适当的位置,以使整个堆恢复满足堆的性质。

堆的插入操作的时间复杂度为 O(log n),其中 n 是堆中元素的个数。这是因为向上调整的过程中,新插入的节点最多需要与堆的高度成比例的次数进行比较和交换。

public void insert(int value) {
    //判断是否已满
    if (usedSize == elem.length) {
        // 扩容
        resize();
    }
    // 将元素放入堆的末尾
    elem[usedSize] = value;
    usedSize++;
    // 向上调整新插入的节点
    shiftUp(usedSize - 1);
}

//向上调整
private void shiftUp(int index) {
    if (index == 0) {
        return; // 已经是堆的根节点,不需要再调整
    }
    int parent = (index - 1) / 2; // 计算父节点的索引
    if (elem[parent] < elem[index]) {
        // 最大堆,父节点的值小于新插入节点的值,交换它们的位置
        swap(parent, index);
        // 继续向上调整
        shiftUp(parent);
    }
    // 如果是最小堆的插入操作,需要将上述条件中的小于号改为大于号。
}

//交换
private void swap(int i, int j) {
    int temp = elem[i];
    elem[i] = elem[j];
    elem[j] = temp;
}

//扩容
private void resize() {
    int[] newElem = new int[elem.length * 2];
    System.arraycopy(elem, 0, newElem, 0, elem.length);
    elem = newElem;
}

那我们可以用向上调整的方式创建堆吗?

当然可以,但是其时间复杂度会大于向下调整,为O ( n * log n )。

因为向下调整,只需要自上而下调整到合适位置,而向上调整,从底层开始,一直到根结点,每一层都要调整。

堆的删除

堆的删除操作是针对堆顶元素进行的,具体步骤如下:

1. 将堆顶元素与堆中最后一个元素交换:首先,将堆顶元素与堆中最后一个元素进行交换。这样可以将要删除的元素移到了堆的末尾。

2. 减少堆中有效数据的个数:将堆中有效数据的个数减少一个,即将堆的大小减一,表示删除了堆顶元素。

3. 对堆顶元素进行向下调整:对新的堆顶元素进行向下调整操作,使其满足堆的性质。通过比较堆顶元素与其子节点的值,并将其与较小(或较大,具体取决于是最小堆还是最大堆)的子节点进行交换,以保持堆的性质。

需要注意的是,由于删除操作使得堆的大小减少了一个,因此在向下调整时可以将调整到最后的原先下标为 0 的节点排除在外,不进行调整操作。这是因为该节点已经位于堆的正确位置上,不需要再进行调整。

通过这样的删除操作,堆顶元素被删除,并且堆的大小减小了一个。随后,通过向下调整操作,新的堆顶元素被移动到适当的位置,从而保持了堆的性质。

堆的删除操作的时间复杂度为 O(log n),其中 n 是堆中元素的个数。这是因为向下调整的过程中,新的堆顶元素最多需要与堆的高度成比例的次数进行比较和交换,而堆的高度为 O(log n)。

public int delete() {
    if (usedSize == 0) {
        throw new IllegalStateException("Heap is empty");
    }
    int deletedElement = elem[0]; // 保存要删除的堆顶元素
    elem[0] = elem[usedSize - 1]; // 将堆中最后一个元素移到堆顶
    usedSize--; // 堆的有效数据个数减一
    shiftDown(0); // 对新的堆顶元素进行向下调整
    return deletedElement;
}

private void shiftDown(int parent) {
    int child = (2 * parent) + 1; // 左孩子下标
    while (child < usedSize) {
        // 如果右孩子存在,找到左右孩子中较小的孩子(或者较大的孩子,取决于是最小堆还是最大堆)
        if (child + 1 < usedSize && elem[child + 1] < elem[child]) {
            child += 1;
        }
        // 如果双亲比其最小(或最大)的孩子还小(或还大),说明该结构已经满足堆的特性了
        if (elem[parent] <= elem[child]) {
            break;
        } else {
            // 将双亲与较小(或较大)的孩子交换
            int temp = elem[parent];
            elem[parent] = elem[child];
            elem[child] = temp;
            // parent 中较小(或较大)的元素往下移动,可能会造成子树不满足堆的性质,因此需要继续向下调整
            parent = child;
            child = parent * 2 + 1;
        }
    }
}

用堆模拟实现优先级队列

import java.util.Arrays;

public class TestHeap {
    private int[] elem;
    private int usedSize;

    public TestHeap() {
        this.elem = new int[10];
    }

    public void initHeap(int[] array) {
        for (int i = 0; i < array.length; i++) {
            elem[i] = array[i];
            usedSize++;
        }
    }

    public void createHeap() {
        for (int parent = (usedSize-1-1)/2; parent >= 0 ; parent--) {
            shiftDownbig(parent,usedSize);
        }
    }
    private void swap(int i,int j) {
        int tmp = elem[i];
        elem[i] = elem[j];
        elem[j] = tmp;
    }
    private void shiftDownbig(int parent, int usedSize) {
        int child = (2*parent)+1;//左孩子下标
        while (child < usedSize) {
            if(child+1 < usedSize && elem[child] < elem[child+1]) {
                child++;
            }
            //child一定是 左右孩子最大值的下标
            if(elem[child] > elem[parent]) {
                swap(child, parent);
                parent = child;
                child = 2*parent+1;
            }else {
                //已经是大根堆了
                break;
            }
        }
    }

    public void createHeap2() {
        for (int parent = (usedSize - 1) / 2; parent >= 0; parent--) {
            shiftDownsmall(parent, usedSize);
        }
    }

    private void shiftDownsmall(int parent, int usedSize) {
        int child = (2 * parent) + 1; // 左孩子下标
        while (child < usedSize) {
            if (child + 1 < usedSize && elem[child] > elem[child + 1]) {
                child++;
            }
            // child 一定是左右孩子中最小值的下标
            if (elem[child] < elem[parent]) {
                swap(child, parent);
                parent = child;
                child = 2 * parent + 1;
            } else {
                // 已经是小根堆了
                break;
            }
        }
    }

    //插入一个元素
    public void offer(int val) {
        if(isFull()) {
            this.elem = Arrays.copyOf(elem,2*elem.length);
        }
        this.elem[usedSize] = val;//usedSize=10
        //向上调整
        shiftUp(usedSize);
        usedSize++;
    }

    private void shiftUp(int child) {
        int parent = (child-1)/2;
        while (child > 0) {
            if (elem[child] > elem[parent]) {
                swap(child,parent);
                child = parent;
                parent = (child-1)/2;
            } else {
                break;
            }
        }
    }

    public boolean isFull() {
        return usedSize == elem.length;
    }

    //删除
    public int poll() {
        int tmp = elem[0];
        swap(0,usedSize-1);
        usedSize--;
        shiftDownbig(0,usedSize);
        return tmp;
    }
}

常见习题

1.下列关键字序列为堆的是:(  )

A: 100,60,70,50,32,65

B: 60,70,65,50,32,100

C: 65,100,70,32,50,60

D: 70,65,100,32,50,60

E: 32,50,100,70,65,60

F: 50,100,70,65,60,32

[参考答案] A

2.已知小根堆为8,15,10,21,34,16,12,删除关键字8之后需重建堆,在此过程中,关键字之间的比较次数是(  )

A: 1

B: 2

C: 3

D: 4

[参考答案] C

 3.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是(  )

A: [3,2,5,7,4,6,8]

B: [2,3,5,7,4,6,8]

C: [2,3,4,5,7,8,6]

D: [2,3,4,5,6,7,8]

[参考答案] C

常用接口介绍

PriorityQueue的特性

Java集合框架中提供了PriorityQueue和PriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的,接下来我们主要介绍PriorityQueue。

PriorityQueue 是 Java 集合框架中提供的一种优先级队列实现,它基于堆(Heap)数据结构。优先级队列是一种特殊的队列,其中的元素按照优先级进行排序,具有最高优先级的元素最先被移除。

以下是 PriorityQueue 的主要特性:

1. 元素排序:PriorityQueue 使用自然顺序或自定义比较器来对元素进行排序。默认情况下,PriorityQueue 使用元素的自然顺序进行排序,也可以通过传递一个比较器来定义自定义的排序规则。

2. 堆结构:PriorityQueue 内部使用堆数据结构来实现。堆是一种完全二叉树,它满足堆的性质,即父节点的值总是大于或等于(或小于或等于,取决于是最大堆还是最小堆)其子节点的值。

3. 插入和删除操作:PriorityQueue 提供了插入(offer 或 add)和删除(poll 或 remove)元素的操作。插入操作将元素添加到队列中,并根据排序规则将其放置在适当的位置。删除操作将队列中的头部元素(具有最高优先级)移除,并返回该元素。

4. 头部元素访问:可以使用 peek 方法来获取队列的头部元素(具有最高优先级),但不会将其从队列中删除。如果需要获取并删除头部元素,则可以使用 poll 方法。

5. 动态扩容:PriorityQueue 的容量会根据需要自动进行动态扩容,以容纳更多的元素。在扩容过程中,会创建一个更大的内部数组,并将现有元素复制到新数组中。

6. 线程不安全:PriorityQueue 是非线程安全的,不适用于多线程环境。如果在多线程环境中需要使用优先级队列,可以考虑使用 PriorityBlockingQueue。

总的来说,PriorityQueue 提供了一种方便的方式来管理具有优先级的元素。它可以根据特定的排序规则对元素进行排序,并在需要时提供高效的插入和删除操作。注意,在使用 PriorityQueue 时需要注意其线程安全性,以及对元素的排序规则进行适当定义。

关于PriorityQueue的使用要注意:

1. 使用时必须导入PriorityQueue所在的包,即:

import java.util.PriorityQueue;

2. PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出 ClassCastException异常 (后面top-K问题会解释)

3. 不能插入null对象,否则会抛出NullPointerException(当你添加一个元素——null 的时候并不会报错,但是一旦再添加一个 null 就必然会报错,因为根本无法比较)

在 offer ( ) 方法的源代码中也有提到:

if (e == null)
    throw new NullPointerException();

4. “没有容量限制”,可以插入任意多个元素,其内部可以自动扩容

5. 插入和删除元素的时间复杂度为 O( log以2为底 n)

6. PriorityQueue底层使用了堆数据结构

7. PriorityQueue默认情况下是小堆——即每次获取到的元素都是最小的元素。

如果需要大堆需要用户提供比较器:

// 用户自己定义的比较器:直接实现Comparator接口,然后重写该接口中的compare方法即可

class IntCmp implements Comparator<Integer>{

@Override
    public int compare(Integer o1, Integer o2) {
        return o2-o1;
    }
}

PriorityQueue常用接口介绍 

1. 优先级队列的构造:

我们首先来看看PriorityQueue的构造方法:

我们的 PriorityQueue有如上那么多的构造方法,最重要最常见的几个构造方法源代码如下:

2、常见方法

PriorityQueue 类实现了 Queue 接口,并提供了一些常见的方法来操作优先级队列。以下是 PriorityQueue 的几种常见方法的详细介绍:

1. add(E e) / offer(E e):将指定的元素插入到优先级队列中。如果队列已满,add 方法将引发异常,而  offer 方法将返回 false。

 2. remove() / poll() :移除并返回队列的头部元素,即具有最高优先级的元素。如果队列为空,remove 方法将引发异常,而 poll 方法将返回 null。

 3.  element()  / peek() :返回队列的头部元素,即具有最高优先级的元素,但不会将其从队列中删除。如果队列为空,element 方法将引发异常,而 peek  方法将返回 null。

 4. size() :返回队列中元素的个数。

 5. isEmpty() :检查队列是否为空。如果队列中没有任何元素,则返回 true;否则返回 false。

 6. clear() :清空队列中的所有元素,使其变为空队列。

 7. toArray() :将队列中的元素以数组的形式返回。返回的数组不保证有序。

 8. iterator() :返回一个迭代器,用于遍历队列中的元素。迭代器并不保证按照优先级顺序返回元素。

 需要注意的是,PriorityQueue 是一种有序集合,元素按照优先级进行排序。排序的规则可以是元素的自然顺序,也可以通过传递一个比较器(Comparator)来自定义排序规则。在创建 PriorityQueue 实例时,可以通过构造函数选择指定比较器。如果未指定比较器,则元素必须实现 Comparable 接口,以便使用它们的自然顺序进行排序。

在操作 PriorityQueue 时,需要注意一些方法的返回值和异常处理。例如,add 方法在队列已满时会引发异常,而 offer 方法则会返回 false。remove 和 element 方法在队列为空时会引发异常,而 poll 和 peek 方法则会返回 null。

通过使用 PriorityQueue 提供的这些常见方法,我们可以方便地插入、删除、访问和管理具有优先级的元素。

3、扩容方式

 优先级队列的扩容说明:

  • 如果容量小于64时,是按照oldCapacity的2倍方式扩容的
  • 如果容量大于等于64,是按照oldCapacity的1.5倍方式扩容的
  • 如果容量超过MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容

 OJ练习——top-k问题:最大或者最小的前k个数据

class Solution {
    public int[] smallestK(int[] arr, int k) {
        int[] ret = new int[k];
        // 参数检测
        if(null == arr || k <= 0)
            return ret;
        PriorityQueue<Integer> q = new PriorityQueue<>(arr.length);
        // 将数组中的元素依次放到堆中
        for(int i = 0; i < arr.length; ++i){
            q.offer(arr[i]);
        }
        // 将优先级队列的前k个元素放到数组中

        for(int i = 0; i < k; ++i){
            ret[i] = q.poll();
        }
        return ret;
    }
}

该解法只是PriorityQueue的简单使用,并不是TopK最好的做法,那Top-K该如何实现?下面介绍:

Top-k 问题是指在一个数据集合中,找出前 k 个最大或最小的元素。一般情况下数据量都比较大

这个问题在许多领域中都非常常见,例如专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家排行榜、热门商品推荐、数据分析等。

解决 Top-k 问题的方法可以根据具体场景和需求来选择,下面是一些常见的做法:

做法1:堆排序:

堆排序即利用堆的思想来进行排序,一种简单的方法是对整个数据集合进行排序,并选择前 k 个元素作为结果。如果需要找最大的 k 个元素,可以使用降序排序;如果需要找最小的 k 个元素,可以使用升序排序。

然而,对整个数据集合进行排序的时间复杂度为 O(nlogn),对于大规模数据集来说可能效率较低。数据量非常大的时候,无法在内存当中排序,可能数据都不能一下子全部加载到内存中;

具体步骤:

1. 建堆

升序:建大堆

降序:建小堆

2. 利用堆删除思想来进行排序 建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

例如:升序,从小到大,建大根堆,然后交换下标为0的元素和最后一个未排序的元素,此时最后一个元素已经满足升序了,然后剩下的元素再向下调整,以此类推……

    public void heapSort(){
        int end = usedSize-1;
        while (end>0){
            swap(0,end);
            shiftDown(0,end);
            end--;
        }
    }

注意:此处如果把堆顶端的元素弹出,只能说明取出了一些元素,不能够表示对数组原来的数据的排序。 

练习:一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为(  )

A: (11 5 7 2 3 17)

B: (11 5 7 2 17 3)

C: (17 11 7 2 3 5)

D: (17 11 7 5 3 2)

E: (17 7 11 3 5 2)

F: (17 7 11 3 2 5)

答案:C 

解析:此处虽然没有说明排序是从小到大还是从大到小,但是根据选项,没有5在最前面,所以我们可以知道默认是升序,所以我们需要建立大根堆。把排序码做向下调整即可。

做法2:堆数据结构:

把所有数据放到优先级队列,出队K次不就好了?——>数据量非常大的时候,无法把所有数据放到优先级队列。(我们先前提到的)

 并且,以上两种做法的效率都不会很高!

最佳做法:

最佳的方式就是用堆来解决,基本思路如下:

通过构建一个最小堆(或最大堆),可以保持堆中的 k 个元素是当前数据集合中最小(或最大)的 k 个元素。遍历数据集合,将元素依次插入堆中并进行堆调整操作,当堆中元素个数超过 k 个时,删除堆顶元素。这样,最后堆中保留的就是前 k 个最小(或最大)的元素。使用堆解决 Top-k 问题的时间复杂度为 O(nlogk)。

具体步骤如下:

1. 用数据集合中前K个元素来建堆

  • 求前k个最大的元素,则建小堆
  • 求前k个最小的元素,则建大堆

2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素: 

  • i 下标的元素,如果大于堆顶元素,那么说明堆顶元素一定不是前K个最大的元素之一;
  • 删除堆顶元素,调整为小根堆,然后把当前元素替换进去,再次调整为小根堆。

将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

向下调整的时间复杂度:O(K) (建立这个堆)+ O(N-K) (需要进行比较的元素个数)* log K (每次相比较调整的高度) 

如果是向上调整,那么时间复杂度甚至会更小:

O( K * logK) +  O(N-K) * log K = O(N * logK)(K是个常数)

    public static int[] maxLestK(int[] array, int k) {
        int[] ret = new int[k];
        if(array == null || k <= 0) {
            return ret;
        }
        PriorityQueue<Integer> priorityQueue =
                new PriorityQueue<>();
        for (int i = 0; i < k; i++) {
            priorityQueue.offer(array[i]);
        }

        for (int i = k; i < array.length; i++) {
            int top = priorityQueue.peek();
            if(array[i] > top) {
                priorityQueue.poll();
                priorityQueue.offer(array[i]);
            }
        }
        for (int i = 0; i < k; i++) {
            ret[i] = priorityQueue.poll();
        }
        return ret;
    }

我们上面求的是前K个最大的元素,建立的是小根堆,因为本来  默认的就是小根堆,所以没有什么问题,但是如果我们要求前K个最小的元素该怎么办呢?

此时,我们需要用到比较器来转换:

   
class IntCmp implements Comparator<Integer> {

    @Override
    public int compare(Integer o1, Integer o2) {
        return o2.compareTo(o1); //相当于 o2-o1
        //写成 return o1.compareTo(o2)就相当于大根堆
    }
}
public class Test {

    public static void main(String[] args) {
        PriorityQueue<Integer> priorityQueue =
                new PriorityQueue<>(new IntCmp());

        priorityQueue.offer(3);
        priorityQueue.offer(13);

    }

    public int[] smallestK(int[] array, int k) {
        int[] ret = new int[k];
        if(array == null || k <= 0) {
            return ret;
        }
        PriorityQueue<Integer> priorityQueue =
                new PriorityQueue<>(new IntCmp());


        for (int i = 0; i < k; i++) {
            priorityQueue.offer(array[i]);
        }

        for (int i = k; i < array.length; i++) {
            int top = priorityQueue.peek();
            if(array[i] < top) {
                priorityQueue.poll();
                priorityQueue.offer(array[i]);
            }
        }
        for (int i = 0; i < k; i++) {
            ret[i] = priorityQueue.poll();
        }
        return ret;
    }
}

那么如果要我们找第K大的元素,该怎么做?

建立大小为K的小根堆,用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素,和先前一样,最终堆顶元素即为所求第K大的元素。

还有两种较常见的做法,这里只做简单的介绍:

3. 快速选择(QuickSelect):快速选择算法是一种选择第 k 小(或第 k 大)元素的高效方法,也可以用于解决 Top-k 问题。该算法基于快速排序的思想,在每次划分过程中只需要对某一边的子数组进行递归,从而减少了比较和交换的次数。通过多次划分,找到第 k 小(或第 k 大)的元素,然后再遍历一次数据集合,找到比第 k 小(或第 k 大)元素更小(或更大)的元素,即为前 k 个最小(或最大)的元素。快速选择算法的平均时间复杂度为 O(n),最坏情况下为 O(n^2),但通过优化可以达到 O(n) 的时间复杂度。

4. 分治算法:分治算法是将问题划分为更小的子问题,然后分别解决子问题并合并结果。在 Top-k 问题中,可以将数据集合划分为多个子集合,分别找到每个子集合的 Top-k 元素,然后再合并得到整体的 Top-k 元素。这种方法可以通过递归或迭代实现,时间复杂度通常为 O(nlogk)。

java对象的比较

优先级队列在插入元素时有个要求:插入的元素不能是null或者元素之间必须要能够进行比较。

之前为了简单起见,我们只是插入了Integer类型,那优先级队列中能否插入自定义类型对象呢?

——不可以直接比较自定义类型,除非你实现了Comparble接口并在类中重写了compareTo方法,或者实现了Comparator接口,并覆写了Comparator中的compare方法。

接下来我们来看看关于比较的问题。

元素的比较

基本类型的比较

在Java中,基本类型的对象可以直接比较大小。

    public class TestCompare {
        public static void main(String[] args) {
            int a = 10;
            int b = 20;
            System.out.println(a > b);
            System.out.println(a < b);
            System.out.println(a == b);
            char c1 = 'A';
            char c2 = 'B';
            System.out.println(c1 > c2);
            System.out.println(c1 < c2);
            System.out.println(c1 == c2);
            boolean b1 = true;
            boolean b2 = false;
            System.out.println(b1 == b2);
            System.out.println(b1 != b2);
        }
    }

对象比较的问题

class Person {
    public String name;
    public int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

public class TestCustomType {
    public static void main(String[] args) {
        Person p1 = new Person("John", 25);
        Person p2 = new Person("Jane", 30);
        Person p3 = p1;
        //System.out.println(p1 > p2); // 编译报错
        System.out.println(p1 == p2); // 编译成功 ----> 打印false,因为p1和p2指向的是不同对象
        //System.out.println(p1 < p2); // 编译报错
        System.out.println(p1 == p3); // 编译成功 ----> 打印true,因为p1和p2指向的是同一个对象

    }
}

从编译结果可以看出,Java中引用类型的变量不能直接按照 > 或者 < 方式进行比较。

那为什么==可以比较?

因为:对于用户实现自定义类型,都默认继承自Object类,而Object类中提供了equals方法,而==默认情况下调用的就是equal方法。

    // Object中equal的实现,可以看到:直接比较的是两个引用变量的地址
    public boolean equals(Object obj) {
        return (this == obj);
    }

但是默认的该方法的比较规则是:不比较引用变量引用对象的内容,而是直接比较引用变量的地址,但有些情况下该种比较就不符合题意

对象的比较

当有些情况下,需要比较的是对象中的内容,比如:向优先级队列中插入某个对象时,需要对按照对象中内容来调整堆,那此时我们该如何处理呢?

覆写基类的equals

Object中默认的该方法的比较规则是:不比较引用变量引用对象的内容,而是直接比较引用变量的地址,所以如果我们想要比较对象中的内容,那么我们就需要复写object中的equals方法。

class Person {
    public String name;
    public int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        //自己和自己比较
        if (this == o) {
            return true;
        }
        // o如果是null对象,或者o不是Person的子类
        //if (o == null || !(o instanceof Person))
        if (o == null || getClass() != o.getClass()) {
            return false;
        } 

        //注意基本类型可以直接比较,但引用类型最好调用其equal方法
        Person person = (Person) o;
        return age == person.age && name.equals(person.name);
    }
}

public class TestCustomType {
    public static void main(String[] args) {
        Person p1 = new Person("John", 25);
        Person p2 = new Person("John", 25);
        Person p3 = new Person("Jane", 30);

        System.out.println(p1.equals(p2)); // true
        System.out.println(p1.equals(p3)); // false
    }
}

getClass() 是Java中Object类的一个方法,它返回对象的运行时类(Runtime Class)。在Java中,所有的类都是直接或间接地继承自Object类,因此所有的对象都可以调用getClass()方法。

getClass()方法的返回值是一个Class对象,它包含了关于类的各种信息,比如类的名称、方法、字段等。通过调用该对象的方法,可以获取有关类的详细信息。

以下是使用 getClass() 方法的一个简单示例:

public class Example {
    public static void main(String[] args) {
        String str = "Hello";
        Class<? extends String> strClass = str.getClass();
        System.out.println(strClass.getName()); // 输出 "java.lang.String"
    }
}

在这个例子中,我们创建了一个String对象 str,然后使用getClass()方法获取了该对象的运行时类,即 java.lang.String。然后,我们调用getName()方法打印出类的名称。

需要注意的是,getClass()方法返回的是对象的实际运行时类型,而不是编译时类型。这意味着如果一个对象是通过继承或多态关系创建的,那么它的getClass()方法返回的将是实际创建它的子类的Class对象。

注意:

一般覆写 equals 的套路就是上面演示的

1. 如果指向同一个对象,返回 true

2. 如果传入的为 null,返回 false

3. 如果传入的对象类型不是 Person,返回 false

4. 按照类的实现目标完成比较,例如这里只要姓名和年龄一样,就认为是相同的人 

5. 注意下调用其他引用类型的比较也需要 equals,例如这里的 name 的比较

覆写基类equal的方式虽然可以比较,但缺陷是:equal只能看是否相等,不能按照大于、小于的方式进行比较。

基于Comparble接口类的比较

Comparble是JDK提供的泛型的比较接口类,源码实现具体如下:

    public interface Comparable<E> {
        // 返回值:
        // < 0: 表示 this 指向的对象小于 o 指向的对象
        // == 0: 表示 this 指向的对象等于 o 指向的对象
        // > 0: 表示 this 指向的对象大于 o 指向的对象
        int compareTo(E o);
    }

对用用户自定义类型,如果要想按照大小与方式进行比较时:

在定义类时,实现Comparble接口即可,然后在类中重写compareTo方法。

class Person implements Comparable<Person> {
    public String name;
    public int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public int compareTo(Person o) {
        if (o == null) {
            return 1;
        }
        return age - o.age;
    }

    public static void main(String[] args) {
        Person p1 = new Person("John", 25);
        Person p2 = new Person("Jane", 30);
        Person p3 = new Person("Alice", 25);

        System.out.println(p1.compareTo(p2)); // < 0, p1 is younger than p2
        System.out.println(p2.compareTo(p1)); // > 0, p2 is older than p1
        System.out.println(p1.compareTo(p3)); // == 0, p1 and p3 have the same age
    }
}

Compareble是java.lang中的接口类,可以直接使用。

基于比较器比较

按照比较器方式进行比较,具体步骤如下:

1、用户自定义比较器类,实现Comparator接口

    public interface Comparator<T> {
        // 返回值:
        // < 0: 表示 o1 指向的对象小于 o2 指向的对象
        // == 0: 表示 o1 指向的对象等于 o2 指向的对象
        // > 0: 表示 o1 指向的对象等于 o2 指向的对象
        int compare(T o1, T o2);
    }

注意:区分Comparable和Comparator。

2、覆写Comparator中的compare方法:

import java.util.Comparator;

class Person {
    public String name;
    public int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

class PersonComparator implements Comparator<Person> {
    @Override
    public int compare(Person p1, Person p2) {
        if (p1 == p2) {
            return 0;
        }
        if (p1 == null) {
            return -1;
        }
        if (p2 == null) {
            return 1;
        }
        return p1.age - p2.age;
    }
}

public class TestCustomComparator {
    public static void main(String[] args) {
        Person p1 = new Person("John", 25);
        Person p2 = new Person("Jane", 30);
        Person p3 = new Person("Alice", 25);

        PersonComparator comparator = new PersonComparator();

        System.out.println(comparator.compare(p1, p2)); // < 0, p1 is younger than p2
        System.out.println(comparator.compare(p2, p1)); // > 0, p2 is older than p1
        System.out.println(comparator.compare(p1, p3)); // == 0, p1 and p3 have the same age
    }
}

注意:Comparator是java.util 包中的泛型接口类,使用时必须导入对应的包。

相较于Comparable接口,使用比较器更加灵活,Comparable的compareTo方法一旦写死,就无法轻易修改。而如果使用的是Comparator,你可以根据自己的逻辑和思路,不断的调整完善。

三种方式对比

覆写的方法说明
Object.equals

因为所有类都是继承自 Object 的,所以直接覆写即可,不过只能比较

相等与否

Comparable.compareTo

需要手动实现接口,侵入性比较强,但一旦实现,每次用该类都有顺序,

属于内部顺序

Comparator.compare

需要实现一个比较器对象,对待比较类的侵入性弱,但对算法代码实现

侵入性强

Object.equals(Object obj) 方法:

  • 说明:equals() 方法是从 Object 类继承而来的。它用于比较两个对象是否相等。如果没有在类中覆写该方法,将使用默认实现,即比较两个对象的引用是否相同(即内存地址是否相同)。
  • 覆写方法:如果需要自定义相等性的比较逻辑,可以在类中覆写 equals() 方法,根据自己的需求定义相等的条件。覆写 equals() 方法通常需要同时覆写 hashCode() 方法以保证一致性。当然,这两个基本方法其实我们可以让Idea自动生成。
  • 注意事项:覆写 equals() 方法时,应该遵循相等性的传递性、对称性和一致性等约定,同时也要处理好 null 值的情况。

2. Comparable.compareTo(T other) 方法:

  • 说明:Comparable 接口定义了一个自然排序的顺序比较方法。实现该接口的类表示它们的对象可以与其他同类型的对象进行比较,并确定它们的顺序。
  • 覆写方法:需要手动实现 Comparable 接口并覆写 compareTo() 方法,根据对象的属性或状态进行比较,返回一个整数值表示两个对象的顺序关系。返回值的意义如下:
  •      - 小于零:当前对象小于被比较对象。
  •      - 等于零:当前对象等于被比较对象。
  •      - 大于零:当前对象大于被比较对象。
  • 注意事项:实现了 Comparable 接口后,对象可以在各种算法和集合类中使用,如排序算法和 TreeSet 等。

3. Comparator.compare(T obj1, T obj2) 方法:

  • 说明:Comparator 接口定义了一种定制比较的方式,它允许在不修改类的定义的情况下,创建不同的比较器来进行对象比较。
  • 覆写方法:需要实现 Comparator 接口并覆写 compare() 方法,根据自定义的比较逻辑比较两个对象。返回值的意义与 Comparable.compareTo() 方法相同。
  • 注意事项:Comparator 接口提供了更灵活的比较方式,可以在需要时创建多个不同的比较器对象,针对不同的比较需求使用。它不需要修改被比较类的定义,因此对于第三方类或无法修改的类的比较非常有用。

选择使用哪种方法取决于具体的需求和场景:

如果希望定义对象的默认排序方式,可以实现 Comparable 接口并覆写 compareTo() 方法;如果需要多种不同的比较方式,可以实现 Comparator 接口并创建多个比较器对象;如果只关心对象的相等性,可以覆写 equals() 方法。

集合框架中PriorityQueue的比较方式

集合框架中的PriorityQueue底层使用堆结构,因此其内部的元素必须要能够比大小。PriorityQueue采用了: Comparble和Comparator两种方式。

1. Comparble是默认的内部比较方式,如果用户插入自定义类型对象时,该类对象必须要实现Comparble接 口,并覆写compareTo方法

2. 用户也可以选择使用比较器对象,如果用户插入自定义类型对象时,必须要提供一个比较器类,让该类实现 Comparator接口并覆写compare方法。

我们上面已经详细画图介绍过每个方法的跳转与运行方式,我把源码拷贝过来了,集中在一起对比一下可能更清晰。

    // JDK中PriorityQueue的实现:
    public class PriorityQueue<E> extends AbstractQueue<E>
            implements java.io.Serializable {
        // ...
    
        // 默认容量
        private static final int DEFAULT_INITIAL_CAPACITY = 11;
        // 内部定义的比较器对象,用来接收用户实例化PriorityQueue对象时提供的比较器对象
        private final Comparator<? super E> comparator;
        // 用户如果没有提供比较器对象,使用默认的内部比较,将comparator置为null
        public PriorityQueue() {
            this(DEFAULT_INITIAL_CAPACITY, null);
        }
        // 如果用户提供了比较器,采用用户提供的比较器进行比较
        public PriorityQueue(int initialCapacity, Comparator<? super E> comparator) {
        // Note: This restriction of at least one is not actually needed,
        // but continues for 1.5 compatibility
            if (initialCapacity < 1)
                throw new IllegalArgumentException();
            this.queue = new Object[initialCapacity];
            this.comparator = comparator;
        }
        // ...
        // 向上调整:
        // 如果用户没有提供比较器对象,采用Comparable进行比较
        // 否则使用用户提供的比较器对象进行比较
        private void siftUp(int k, E x) {
            if (comparator != null)
                siftUpUsingComparator(k, x);
            else
                siftUpComparable(k, x);
        }
        // 使用Comparable
        @SuppressWarnings("unchecked")
        private void siftUpComparable(int k, E x) {
            Comparable<? super E> key = (Comparable<? super E>) x;
            while (k > 0) {
                int parent = (k - 1) >>> 1;
                Object e = queue[parent];
                if (key.compareTo((E) e) >= 0)
                    break;
                queue[k] = e;
                k = parent;
            }
            queue[k] = key;
        }
        // 使用用户提供的比较器对象进行比较
        @SuppressWarnings("unchecked")
        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;
        }
}

;