【数据结构】优先级队列 - 堆

文章目录

  • 1.优先级队列
    • 1.1概念
  • 2.堆的模拟实现
    • 2.1 堆的概念
    • 2.2 堆的存储方式
    • 2.3 堆的创建
      • 2.3.1 堆的向下调整(shiftDown)
      • 2.3.2 堆的创建
      • 2.3.3 建堆的时间复杂度
    • 2.4堆的插入和删除
      • 2.4.1 堆的插入
      • 2.4.3 堆的删除
  • 3.Java中的优先级队列
    • 3.1 PriorityQueue
    • 3.2 PriorityQueue常用接口介绍
      • 1. 构造方法
      • 2.常用方法
      • 3. 扩容方式
  • 4. 堆的应用
    • 4.1 堆排序
    • 4.2 Top-k问题

1.优先级队列

1.1概念

之前我们学习过队列是一种先进先出的数据结构,但在某些场景下,操作的数据具有优先级,在出队列时,需要优先级高的数据先出队列。比如在打游戏时来电话了,那么操作系统应该先处理电话;还有在游戏英雄联盟中泰坦的Q的优先级比机器人高;

诸如这些情况下,我们需要数据结构提供两种最基本的操作,首先添加新的对象,然后返回最高优先级对象,这种数据结构就是优先级队列(Priority Queue).

2.堆的模拟实现

在JDK1.8中的PriorityQueue底层使用了堆的数据结构,而堆通常是一个可以被看做一棵完全二叉树的数组对象。

2.1 堆的概念

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

堆满足以下性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值。
  • 堆总是一棵完全二叉树。
    【数据结构】优先级队列 - 堆_第1张图片

2.2 堆的存储方式

因为堆是一颗完全二叉树,所以我们可以利用层序遍历的方式来存储元素
【数据结构】优先级队列 - 堆_第2张图片
注:对应非完全二叉树则不适用于顺序方式来存储,因为为了通过顺序表来还原二叉树,则空间中就需要存储空结点,这样会导致空间利用率低。

当元素存储到数组中后,我们如何还原二叉树呢?假设i为节点在数组中的下标,

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

用代码来表示就是:

	parent = i / 2;
	leftChild = 2 * i;
	rightChild = 2 * i + 1;
	//或者已知父节点为root
	parent = root;
	leftChild = (2 * parent) + 1;
	rightChild = (2 * parent)  + 1;

2.3 堆的创建

2.3.1 堆的向下调整(shiftDown)

对于集合{ 27,15,19,18,28,34,65,49,25,37 }中的数据,如何将其创建成堆呢?
【数据结构】优先级队列 - 堆_第3张图片
通过观察可知:根节点的左右子树已经完全满足堆的性质,因此只需将根节点向下调整好即可。

建小堆为例:

  1. 让parent标记需要调整的节点,child标记parent的左孩子(注意:parent如果有孩子一定先是有左孩子)
  2. 如果parent的左孩子存在,即:child < len, 进行以下操作,直到parent的左孩子不存在或者已经满足堆的性质。
    2.1 parent右孩子是否存在,存在找到左右孩子中最小的孩子,让child进行标记
    2.2 将parent与较小的孩子child比较,如果parent小于较小的孩子child,调整结束;否则:交换parent与较小的孩子child,交换完成之后,parent中大的元素向下移动,可能导致子树不满足对的性质,因此需要继续向下调整,即parent = child;child = parent*2+1; 然后重复2 。
    【数据结构】优先级队列 - 堆_第4张图片
/**
 * 向下调整 -
 * 时间复杂度 0(logN)
 * @param arr 二叉树中存放的元素
 * @param root 根节点的下标
 * @param len 数组长度
 */
private static void shiftDown(int[] arr, int root, int len) {
    int parent = root;
    int child = (2 * parent) + 1;//左孩子
    //孩子节点索引不会大于len
    while (child < len) {
        //使child索引位置保存子节点的最大值
        if (child + 1< len && arr[child] < arr[child + 1]) {
            child++;
        }
        if (arr[child] > arr[parent]) {
            swap(arr, child, parent);
            parent = child;
            child = (2 * parent) + 1;
        } else {
            break;
        }
    }
}

private static void swap(int[] arr, int i, int j) {
    int tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
}

2.3.2 堆的创建

对于一个普通序列,即根的左右子树都不满足堆的特性,那么该如何调整呢?

【数据结构】优先级队列 - 堆_第5张图片

/**
 * 建堆
 * 时间复杂度 0(n)
 * @param arr
 */
private static void createHeap(int[] arr) {
	// 找倒数第一个非叶子节点,从该节点位置开始往前一直到根节点,遇到一个节点,应用向下调整
    for (int p = (arr.length - 1 - 1) / 2; p >=0; p--) {
        shiftDown(arr, p, arr.length);
    }

}

2.3.3 建堆的时间复杂度

堆是完全二叉树,这里我们简化用满二叉树来证明
【数据结构】优先级队列 - 堆_第6张图片
所以,建堆的时间复杂度为0(N)

2.4堆的插入和删除

2.4.1 堆的插入

插入步骤:

  1. 先把元素放到堆最后(即数组最后),如果空间不足,则需要扩容。
  2. 将插入的元素向上调整,直到满足堆的性质
    【数据结构】优先级队列 - 堆_第7张图片
	
/**
 * 向堆中插入元素
 * @param val
 */
public void push(int val) {
    //判满
    if (ifFull()) {
        this.elem = Arrays.copyOf(elem, elem.length * 2);
    }
    //插入
    this.elem[usedSize] = val;
    //调整
    shiftUp(usedSize);
    //有效数据调整
    usedSize++;
}

private boolean ifFull() {
    return elem.length == usedSize;
}

/**
 * 向上调整 - 向堆中添加元素 - 只能添加到末尾然后向上调整
 */
private  void shiftUp(int child) {
    int parent = (child - 1) / 2;
    while (child > 0) {
        //这里本来就是小根堆,根节点的值一定比原来的左右子结点小,所以不需要比较左右节点的大小
        if (elem[child] > elem[parent]) {
            swap(elem, child, parent);
            child = parent;
            parent = (child - 1) / 2;
        } else {
            break;
        }
    }
}

2.4.3 堆的删除

删除步骤:
注:堆的删除一定是删除优先级高的元素(即堆顶元素)

  1. 将堆顶元素和堆中最后的元素交换
  2. 有效数据减一
  3. 从堆顶元素开始,向下调整
    【数据结构】优先级队列 - 堆_第8张图片
/**
 * 出堆顶元素
 */
public void pollHeap() {
    if (isEmpty()) {
        System.out.println("队列为空");
        return;
    }
    //把堆顶元素和堆尾元素交换
    swap(elem, 0, usedSize - 1);
    //调整大小
    usedSize--;
    //堆的结构改变,所以要向下调整
    shiftDown(elem, 0, usedSize);
}
/**
 * 向下调整 -
 * 时间复杂度 0(logN)
 * @param arr 二叉树中存放的元素
 * @param root 根节点的下标
 * @param len 数组长度
 */
private static void shiftDown(int[] arr, int root, int len) {
    int parent = root;
    int child = (2 * parent) + 1;//左孩子
    //孩子节点索引不会大于len
    while (child < len) {
        //使child索引位置保存子节点的最大值
        if (child + 1< len && arr[child] < arr[child + 1]) {
            child++;
        }
        if (arr[child] > arr[parent]) {
            swap(arr, child, parent);
            parent = child;
            child = (2 * parent) + 1;
        } else {
            break;
        }
    }
}

3.Java中的优先级队列

3.1 PriorityQueue

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

使用注意事项:

  1. PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastException异常
  2. 不能插入null对象,否则会抛出NullPointerException
  3. 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
  4. 插入和删除元素的时间复杂度为0(logN)
  5. PriorityQueue底层使用了堆数据结构, 且是最小堆

3.2 PriorityQueue常用接口介绍

1. 构造方法

它的构造方法在JDK1.8中一共有7种,下面我们介绍几种常见的构造方法

构造器 功能介绍
PriorityQueue() 创建一个空的优先级队列,默认容量是11
PriorityQueue(int initialCapacity) 创建一个初始容量为initialCapacity的优先级队列,注意:initialCapacity不能小于1,否则会抛IllegalArgumentException异常
PriorityQueue(Collection c) 用一个集合来创建优先级队列
PriorityQueue(Comparator comparator) 设置自己的比较器
    public static void main(String[] args) {
        // 创建一个空的优先级队列,底层默认容量是11
        PriorityQueue<Integer> q1 = new PriorityQueue<>();
        // 创建一个空的优先级队列,底层的容量为initialCapacity
        PriorityQueue<Integer> q2 = new PriorityQueue<>(100);
        ArrayList<Integer> list = new ArrayList<>();
        list.add(4);
        list.add(3);
        list.add(2);
        list.add(1);
        // 用ArrayList对象来构造一个优先级队列的对象

        // q3中已经包含了三个元素
        PriorityQueue<Integer> q3 = new PriorityQueue<>(list);
        System.out.println(q3.size());
        System.out.println(q3.peek());

        //自定义比较器
        PriorityQueue<Integer> p4 = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2 - o1;
            }
        });
    }

注:在默认情况下,PriorityQueue队列是小堆,如果需要大堆需要用户自定义比较器

  1. 自定义比较器
class IntCmp implements Comparator<Integer> {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o1 - o2;
    }
}
  1. 使用匿名内部类
        PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2 - o1;
            }
        });
  1. 自定义类中实现Comparable接口
class Student implements Comparable<Student> {
    public int age;

    public Student(int age) {
        this.age = age;
    }

    @Override
    public int compareTo(Student o) {
        return o.age - this.age;
    }

    @Override
    public String toString() {
        return "Student{" +
                "age=" + age +
                '}';
    }
}

2.常用方法

函数名 功能介绍
boolean offer(E e) 插入元素e,插入成功返回true,如果e对象为空,抛出NullPointerException异常,时间复杂度0(logN) ,注意:空间不够时候会进行扩容
E peek() 获取优先级最高的元素,如果优先级队列为空,返回null
E poll() 移除优先级最高的元素并返回,如果优先级队列为空,返回null
int size() 获取有效元素的个数
void clear() 清空队列
boolean isEmpty() 检测优先级队列是否为空,空返回true

3. 扩容方式

JDK1.8 中PriorityQueue的扩容方式及其源码

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

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

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

扩容介绍:

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

4. 堆的应用

4.1 堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤

  1. 建堆
  • 升序:建大堆
  • 降序:建小堆
  1. 利用堆删除思想来进行排序
    【数据结构】优先级队列 - 堆_第9张图片
/**
     * 时间复杂度:0(N*logN)
     * 空间复杂度:0(1)
     * 稳定性:不稳定
     * @param arr
     */
    public static void heapSort(int[] arr) {
        //建堆
        createHeap(arr);
        int end = arr.length - 1;
        while (end >= 0) {
            swap(arr, 0, end);
            shiftDown(arr, 0, end);
            end--;
        }
    }

    /**
     * 向下调整 -
     * 时间复杂度 0(logN)
     * @param arr 二叉树中存放的元素
     * @param root 根节点的下标
     * @param len 数组长度
     */
    private static void shiftDown(int[] arr, int root, int len) {
        int parent = root;
        int child = (2 * parent) + 1;//左孩子
        //孩子节点索引不会大于len
        while (child < len) {
            //使child索引位置保存子节点的最大值
            if (child + 1< len && arr[child] < arr[child + 1]) {
                child++;
            }
            if (arr[child] > arr[parent]) {
                swap(arr, child, parent);
                parent = child;
                child = (2 * parent) + 1;
            } else {
                break;
            }
        }
    }

    /**
     * 建堆
     * 时间复杂度 0(n)
     * @param arr
     */
    private static void createHeap(int[] arr) {
        for (int p = (arr.length - 1 - 1) / 2; p >=0; p--) {
            shiftDown(arr, p, arr.length);
        }

    }
    private static void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

4.2 Top-k问题

TOP-K问题:即求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大,所以对于一般排序是不可取的,最佳的方式是堆排序,基本思想如下:

  1. 用数据集合中前K个元素来建堆
    1.1 前k个最大的元素,则建小堆
    1.2 前k个最小的元素,则建大堆
  2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

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

你可能感兴趣的:(数据结构,java,算法)