目录
一、优先队列
普通队列:先进先出,后进后出
优先队列:出队顺序和入队顺序无关,和优先级相关。
使用优先队列的动态场景
所谓的动态,就是队列中的数据是不确定的,其中的元素是不断变化的,无法预知所有数据,无法对一个整体进行排序,因此我们需要使用优先队列。
所谓的优先队列,其实就是一个队列,它的接口跟队列是一样的
对于队列的实现,我们可以使用不同的底层数据结构,如图:
底层使用线性的数据结构,可分为普通线性结构和顺序线性结构,这两种结构各有所长,同样也各有所短。它们的缺点是,入队和出队操作中,不得不有一个操作的时间复杂度是O(n)级别的。为了解决O(n)级别的时间复杂度问题,这里详细介绍堆这种数据结构的实现逻辑,它的时间复杂度是O(log(n))级别的,我们知道O(log(n))级别的时间复杂度也是一种很快的使时间复杂度。
二、堆-树的一种
1、满二叉树
如下是一颗满二叉树,它的每个非叶子节点都具备左孩子和右孩子。它的每一层拥有的节点个数是固定的,比如一层1个,二层2个,三层4个...形成一种后一层比前一层的节点数多2倍的这种关系。
2、完全二叉树
完全二叉树跟满二叉树不同的是,它的非叶子节点并不一定都具备左孩子和右孩子,但是,它的数据存放逻辑一定是一层一层来存放的,如图,只有当第二层数据放满了,才会去放第三层的位置,按这样的方式存储元素的树,我们称之为一棵完全二叉树。
3、二叉堆
二叉堆首先是一棵完全二叉树,二叉堆有一个重要的性质:堆中某个节点的值总是不大于其父节点的值。所以,二叉堆的根节点是所有元素中最大的值。根据这种方式定义的堆称为最大堆(相应的也可以定义最小堆)。
注:最大堆并不保证树中层次直之间的顺序,比如,第二层的数据不一定比第三层的大,它只保证父节点和孩子节点的顺序大小。
(1)用数组存储二叉堆
我们把二叉堆的节点通过数字进行层序(按照层级从左到右)标记,通过数组来进行存储,那么对于任意一个节点,我们通过什么样的方法来寻找它的左右孩子节点或者父节点呢?
如上图,我们可以很快的发现,对于任意一个节点,它的左右孩子以及父节点的索引跟此节点位置的索引(i)有如下规律:
左孩子索引:2 x i ;右孩子索引:2 x i + 1;父节点索引:i / 2(计算机中的整数的除法都是整数)。
不过,在计算机中,我们都习惯使用0作为索引的开始。如下图,是我们使用0作为开始索引进行标号的图示,相对应的,我们在计算任意节点的父亲节点和孩子节点时,都要相应的加上一个偏移量。
下边我们通过程序来实现一个最大堆的逻辑,在这个最大堆的实现逻辑中,我们使用之前我们自己实现的动态数组Array类,使得我们在添加和删除数据时,不在需要考虑数组的容量问题,对于动态数组的实现,不清楚的可以参照我这篇文章:动态数组—代码实现。
我们通过数组来表示完全二叉树时,我们所存储的元素都限定为可比较的,它的代码实现逻辑如下:
public class MaxHeap<E extends Comparable<E>> {
// 使用最大堆,所存储的元素都是可以用来比较的
private Array<E> data;
// 构造方法
public MaxHeap(){
data = new Array<E>();
}
public MaxHeap(int capacity){
data = new Array<E>(capacity);
}
// 获取元素存储数量
public int size(){
return data.getSize();
}
public boolean isEmpty(){
return data.isEmpty();
}
// 设计辅助函数,获取父节点、左孩子节点和右孩子节点的索引
private int parent(int index){
if(index == 0){
throw new IllegalArgumentException("index-0 doesn't have parent.");
}
return (index - 1) / 2;
}
// 完全二叉树数组表示中,获取左孩子
private int leftChild(int index) {
return index * 2 + 1;
}
// 完全二叉树数组表示中,获取右孩子
private int rightChild(int index){
return index * 2 + 2;
}
}
(2)向堆中添加元素sift up
向堆中添加元素的过程逻辑(元素上浮):
1) 首先我们向树的最尾端添加一个新的元素,比如添加元素52,相应的我们也要维护一下在这个位置的索引值size++;
2) 然后我们发现52作为右孩子,它比父节点16还要大,因此为了满足最大堆的定义,我们需要交换16和52的节点位置。对于交换位置后的二叉树,我们又要去看它是否满足最大堆的定义,如果仍不满足,我们继续把父节点和子节点进行交换,直到满足定义为止。
对于以上逻辑的实现,我们需要在原来的Array数组中新增加一个交换元素的方法:
// 交换两者之间的位置
public void swap(int i , int j){
if (i < 0 || i >= size || j < 0 || j >= size) {
throw new IllegalArgumentException("index is illegal.");
}
E temp = data[i];
data[i] = data[j];
data[j] = temp;
}
下边,我们通过代码来实现一下增加的逻辑
// 向堆中添加元素
public void add(E e){
data.addLast(e);
// 维护一下元素的性质
siftUp(data.getSize()-1);
}
private void siftUp(int k){
// 如果父节点比当前节点要小,交换两者的位置
while (k > 0 && data.get(parent(k)).compareTo(data.get(k)) < 0) {
data.swap(k,parent(k));
// 重新赋值=当前索引=原父节点位置的索引,进行新一轮比较
k = parent(k);
}
}
(3)取出堆中的最大元素和sift down
对于最大堆的取出操作,我们只能取出最大堆中根节点位置的元素(即堆中最大的元素)。——下沉操作
取出最大元素的实现逻辑:
1)当取出堆中最大的元素62时,我们把二叉树末尾的元素16提取到62的位置,然后删掉末尾的元素16,从而形成一棵新的二叉树。
2)当交换完队首和队尾的元素时,二叉树不能满足最大堆的定义,因此我们需要进行元素的下沉操作,即把交换位置的16和两个孩子节点52和30进行比较,然后跟其中最大的那个元素进行位置交换,在下图的示例中,也就是16跟52进行位置交换;交换完成后,我们需要再一次进行上一步的比较工作,只到所有的节点都满足最大堆的定义为止。
根据上边所述的逻辑,我们通过代码来实现一个最大堆的元素移除过程
// 获取堆中最大的元素
public E findMax(){
if(data.getSize() == 0){
throw new IllegalArgumentException("heap is empty。");
}
return data.get(0);
}
// 取出堆中最大的元素
public E extraMax() {
E ret = findMax();
// 交换位置
data.swap(0, data.getSize() - 1);
// 删除末尾的元素
data.removeLast();
siftDown(0);
return ret;
}
private void siftDown(int k) {
// 循环条件,获取左孩子的索引值,索引值没有越界,左孩子存在
while(leftChild(k) < data.getSize()){
// 左孩子节点的索引
int j = leftChild(k);
// 如果右孩子存在,且右孩子的值大于左孩子的值,那么返回右孩子的索引
if (j + 1 < data.getSize() && data.get(j + 1).compareTo(data.get(j)) > 0) {
j = rightChild(k);
}
// data[j] 时左孩子和右孩子中的最大值
if (data.get(k).compareTo(data.get(j)) >= 0) {
break;
}
// 交换两者之间的值
data.swap(k, j);
// 替换索引进行新的循环和对比
k = j;
}
}
到此为止,我们通过代码实现一个最大堆的操作过程基本就算完成了,接下来我们写一个简单的测试程序进行一下测试
public static void main(String[] args) {
int n = 1000000;
MaxHeap<Integer> maxHeap = new MaxHeap<Integer>();
Random random = new Random();
for (int i = 0; i < n; i++) {
maxHeap.add(random.nextInt(Integer.MAX_VALUE));
}
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = maxHeap.extraMax();
}
// 验证最大堆的取出元素的顺序是不是倒序的
// 每次对比相邻的两个元素,验证逻辑的正确性, i=1开始
for (int i = 1; i < n; i++) {
if (arr[i-1] < arr[i]) {
throw new IllegalArgumentException("error");
}
}
System.out.println("test maxHeap completed");
}
查看验证结果,表明我们以上实现逻辑的正确性——堆排序
(4)堆的时间复杂度分析
总之,对于我们的树结构来说,因为它的遍历深度跟我们树的层次结构的深度相关,所以它的时间复杂度也是O(log(n))级别的。
(5)Heapify整理成堆和replace替换
replace操作:取出最大元素后,并替换成新的元素;
替换操作后,其实堆的总体元素没有发生变化,我们只要把堆顶的元素替换成新的元素,然后再进行下沉操作(Sift Down)就可以了,这种操作的时间复杂度是O(log(n))的。代码实现逻辑如下
// 取出最大的元素,并替换成e
public E replace(E e) {
E ret = extraMax();
data.set(0, e);
// 元素下沉操作
siftDown(0);
return ret;
}
Heapify操作:将任意数组整理成堆的形状。
按照常规思维,我们会选择将一个数组中的数据全部取出来,然后一一插入到最大堆中。不过,在这里,我们可以使用更加高效的方式来实现。我们可以把原数组看成一个完全二叉树,然后从最后一个非叶子节点开始,循环进行下沉操作,使所有元素都满足最大堆的定义。
对于求最后一个非叶子节点索引的问题,我们只要获取最后一个元素的索引,就可以求得它的父节点(即倒数第一个非叶子节点)的索引。求得最后一个非叶子节点的索引后,我们就可以对所有的非叶子节点都做一遍下沉操作了,如下图,从索引为4的节点开始,我们依次对索引为3、2、1、0位置的节点进行下沉操作。
下图中最后一个叶子节点的求解算式为:(9-1)/2
Heapify操作的好处是,从一开始操作我们就抛弃了占用将近一半数量的叶子节点,简化了操作节点的数量,而直接往空堆中插入元素的操作需要对所有的元素都进行一遍操作。
因而他们的时间复杂度分析如下:
将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn);而heapify得过程,它的算法复杂度为O(n)。
接下来,我们通过代码来实现这一个逻辑,在往MaxHeap中新增方法时,我们需要往自己实现的Array类中新增一个构造方法,即支持传入一个数组形成一个动态数组
public Array(E[] arr) {
data = (E[]) new Object[arr.length];
for (int i = 0; i < arr.length; i++) {
data[i] = arr[i];
}
size = arr.length;
}
有了传入一个数组来构造动态数组的构造函数,那么要实现传入一个数组来构造一个最大堆就比较容易了,根据上边我们分析的逻辑,代码实现如下
public MaxHeap(E[] arr) {
data = new Array<E>(arr);
for (int i = parent(data.getSize() - 1); i >= 0; i--) {
siftDown(i);
}
}
三、优先队列
前边通过动态数组定义了一个最大堆,现在,我们需要使用最大堆,作为底层数据结构来实现一个优先队列,介于最大堆本身的特点,在其基础上实现一个优先队列还是很方便的。
public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {
// 使用最大堆实现优先队列
private MaxHeap<E> maxHeap;
public PriorityQueue(){
maxHeap = new MaxHeap<E>();
}
@Override
public int getSize() {
return maxHeap.size();
}
@Override
public boolean isEmpty() {
return maxHeap.isEmpty();
}
@Override
public void enqueue(E e) {
maxHeap.add(e);
}
@Override
public E dequeue() {
return maxHeap.extraMax();
}
@Override
public E getFornt() {
return maxHeap.findMax();
}
}