文章目录
一、堆
1.1、堆的概念
堆(heap):一种有特殊用途的数据结构——用来在一组变化频繁(发生增删改的频率较高)的数据集中查找最值。
堆在逻辑结构上,是一颗完全二叉树。
堆在物理层面上,表现为一组连续的数组区间:long[] array ,将整个数组看作是堆,一般我们做算法题时都是在操作数组,或者利用优先队列(PriorityQueue)来快速的构建一个堆。
满足任意结点的值都大于其子树中结点的值,叫做大根堆,或者最大堆;反之,则是小根堆,或者最小堆。当一个堆为大根堆时,它的每一棵子树都是大根堆。
1.2、堆的存储方式与父子节点的关系
从堆的概念可知,堆的逻辑结构是一棵完全二叉树,所以我们可以按照层序的规则来转化成数组来存储(二叉树的层序遍历)。
假设 i 为结点在数组中的下标,则有:
- i结点的父结点下标为
(i – 1) / 2
。 - 它的左右子结点下标分别为
2 * i + 1
和2 * i + 2
,如果越界说明没有这个子结点。
二、堆的基本操作
2.1、创建堆,向下调整与向上调整
创建堆只有两种堆可以创建,要不就是大根堆,要不就是小根堆。而要满足大根堆还是小根堆的逻辑,就要向下调整的操作才能实现。要想自己实现堆,堆本身就是一个数组,因此创建一个数组来创建堆。
向小调整指的是,从当前节点开始,第一次调整是自身与其两个孩子节点,如果这一次的调整有变动,就会影响下一层的结构,所以要一直向下调整直到叶子结点。
向上调整,是从最后一个节点的父亲节点开始,调整当前这三个节点组成的堆结构,调整后再调整上一层的,注意只要发生了交换调整,那就从发生变化的这个节点开始,进行一遍【向下调整】这是为了保证自此以下的堆结构是正常的。
看一个例子,对于集合 { 27,15,19,18,28,34,65,49,25,37 } 中的数据,如果将其创建成小顶堆呢?
public class HeapTest {
/**
* 小顶堆的向下调整,此方法只是从一个父节点开始向下调整,并没有涉及到整个堆,下面有构建整个堆的
* @param array 堆所在的数组
* @param size 前 size 个元素视为堆中的元素(一开始为最大下标)
* @param parent 要调整位置的下标(也就是父亲节点)
*/
public static void shiftDown(long[] array, int size, int parent) {
// 这里直接 while(true)即可
// while (2 * parent + 1 < size) { 如果这么写,下面就不用再进行叶子的判断
while (true) {
int left = 2 * parent + 1;
// 判断左孩子节点下标是否越界,越界 -> 没有左孩子 -> 是叶子 -> 调整结束
if (left >= size) {
return;
}
// 下面就是找三个数中最小的那个(因为是小顶堆)
int right = 2 * parent + 2;
int min = left;
// 假设最小值就是左孩子,所以 min 保存的最小值孩子所在的下标,
if (right < size && array[right] < array[left]) {
// right < size 必须在 array[right] < array[left] 之前,不能交换顺序
min = right;
}
// 当前要调整的结点的值 <= 最小的孩子值;说明这里也满足堆的性质了,所以,调整结束
if (array[parent] <= array[min]) {
return;
}
// 孩子节点的值比父节点小,所以交换两个下标的值
long t = array[parent];
array[parent] = array[min];
array[min] = t;
// min > parent,上面交换了值,所以需要对 min 位置重新进行同样的操作
// 对 min 位置进行向下调整操作
parent = min;
}
}
public static void main(String[] args) {
long[] array = { 27, 15, 19, 18, 28, 34, 65, 49, 25, 37 };
shiftDown(array,9,0);
// (将整个堆构建成小顶堆),这样数组的第一个元素就是最小的那个数
createHeap(array, array.length);
}
/**
* 创建小顶堆:从一个无规则数组开始,经过调整,得到一个小顶堆 (将整个堆构建成小顶堆),这样数组的第一个元素就是最小的那个数
* @param array 存储堆元素的数组
* @param size 前 size 元素视为堆中元素,一开始为数组长度
*/
public static void createHeap(long[] array, int size) {
// 从最后一个非叶子结点的父节点开始
// 最后一个结点的下标一定是: size - 1
// 它的父节点下标一定是: ((size - 1) - 1) / 2 = (size - 2) / 2
// 从后往前遍历,直到根也被向下调整过 下标范围:[(size - 2) / 2, 0] 左闭右闭
for (int i = (size - 2) / 2; i >= 0; i--) {
shiftDown(array, size, i);
}
}
}
2.2、堆的插入
堆的插入总共需要两个步骤:
-
1、先将元素放入到底层空间中,数组的最后的一个位置(注意:空间不够时需要扩容)
-
2、将最后新插入的节点向上调整,直到满足堆的性质 ;
以一个大顶堆为例,新插入一个80,放到最后(数组的最后一个数),然后逐渐的往上调整
// child就是新插入元素的下标(末尾元素)
public void shiftUp(int child) {
// 找到child的父节点
int parent = (child - 1) / 2;
while (child > 0) {
// 如果双亲比孩子大,parent满足堆的性质,调整结束
if (array[parent] > array[child]) {
break;
}
else{
// 父节点将与孩子节点进行交换
int t = array[parent];
array[parent] = array[child];
array[child] = t;
// 小的元素向下移动,可能导致上一层的子树不满足堆的性质,因此需要继续向上调增
// 一开始是45 35 80,45与80交换,导致之前的72 53 45变成了 72 53 80,需要重新排
// 所以这里的 child parent要变成上一层的父节点与孩子节点
child = parent;
parent = (child - 1) / 2;
}
}
}
2.3、堆的删除
具体如下:( 注意:堆的删除一定删除的是堆顶元素。)
-
1、将堆顶元素对堆中最后一个元素交换;
-
2、将堆中有效数据个数减少一个;
-
3、对堆顶元素进行向下调整;
补充:上面这个逻辑,在堆排序中也是关键。
public long poll() {
// 返回并删除堆顶元素
if (size < 0) {
throw new RuntimeException("队列是空的");
}
long e = array[0];
// 用最后一个位置替代堆顶元素,删除最后一个位置
array[0] = array[size - 1];
// 0 代表这个位置被删除了,不是必须要写的
array[size - 1] = 0;
// 堆的大小减-1,将刚刚换到最后的元素,排除掉
size--;
// 针对堆顶位置,做向下调整,
//(做删除,前提整个堆都是符合条件的,删除堆顶,只用从顶部开始重建就好了)
shiftDown(array, size, 0);
return e;
}
三、堆排序
如果要升序,要创建大顶堆来排,然后把堆顶的元素最大数,换到最后,交换后再向下调整,调整后再与堆的倒数第二个元素进行交换,直到最后。
同理,降序,要创建小顶堆来排。
以升序为例:
/**
* 堆排序:利用最小/大堆的特性,每次选择出最小/大的值(这也是完全二叉树的一种应用,利用的节点与节点之前的顺序关系)
* 实现思路:一步一步实现:
* 1.先只考虑三个结点如何建立最大堆,
* 2.然后考虑如何将整个数组建立成一个最大堆,:从下往上的把每一棵子树建立成最大堆即可,所以应该从下标为 n/2-1 的节点开始,依次-1,就可把所有的子树都弄完
* 3.最后 将最大的元素与最后的元素交换顺序,"除去"最后一个,剩下的继续建立最大堆,找出最大元素,再放到最后......
*
*/
public class HeapSort {
public static void main(String[] args) {
int[] keys = {7, 6, 10, 1, 2, 9, 8};
heapSort(keys, keys.length);
print(keys);
}
//堆排序
public static void heapSort(int[] keys, int len) {
// 一开始先建立一个堆,此时第一个就是最大元素
buildHeap(keys, len);
// 建立第一个堆之后,将第一个(最大的)与数组最后的元素交换,之后再建立堆,
// 这里有i来控制把排好序的最大值去掉,一开始就减1了
for (int i = len - 1; i >= 0; i--) {
// 第一个(最大的)与数组最后的元素交换
swap(keys, i, 0);
// 再建立堆,找最大值
buildHeap(keys, i);
}
}
/**
* 构建大顶堆,找到最大元素
* @param keys 数组
* @param len 长度
*/
static void buildHeap(int[] keys, int len) {
// 从最后一个非叶子结点的父节点开始
for (int parent = (len - 1 - 1)/ 2; parent >= 0; parent--) {
int c1 = 2 * parent + 1;
int c2 = 2 * parent + 2;
int max = parent;
if (c1 < len && keys[c1] > keys[max]) {
max = c1;
}
if (c2 < len && keys[c2] > keys[max]) {
max = c2;
}
if (max != parent) {
swap(keys, max, parent);
}
}
}
static void swap(int[] keys, int i, int j) {
int temp = keys[i];
keys[i] = keys[j];
keys[j] = temp;
}
static void print(int[] keys) {
for (int key : keys) {
System.out.print(key+" ");
}
}
}
在堆排序中,从最后一个节点的父亲节点开始构建整个大顶堆,这是【向上调整】,构建一次花费的时间为O(log2N),每找到一个最大元素,就交换到最后,然后进行下一次的重建堆,交换,重复了n(数组长度)次,所以整体的时间复杂度为O(N*log2N)
堆排序空间复杂度为O(1)。
堆排序算法不稳定。因为堆上的操作可以更改相等项目的相对顺序。
- 考虑数组
21 20a 20b 12 11 8 7
(已采用最大堆格式),在这里20a = 20b只是为了区分我们将它们表示为20a和20b的顺序。 - 虽然首先删除堆排序21并将其放置在最后一个索引中,然后删除20a并将其放置在最后一个但另一个索引中,将20b放置在最后一个但将两个索引中,所以在堆排序之后,数组看起来像
7 8 11 12 20b 20a 21
。它不保留元素的顺序,因此不稳定。 - 稳定表示两个元素具有相同的元素,则它们将保持相同的顺序或位置。但这不是堆排序的情况。堆排序不稳定,因为堆上的操作可以更改相等元素的相对顺序。
四、优先级队列(PriorityQueue)
4.1、概念
队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列。
此时,数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列:PriorityQueue,实现了Queue接口。
JDK1.8中的PriorityQueue底层使用了堆的数据结构,而堆实际就是在完全二叉树的基础之上进行了一些元素的调整。所以我们可以通过PriorityQueue类来快速的构建出一个大顶堆/小顶堆。
4.2、用堆模拟实现优先级队列
// 优先级队列(堆)的模拟实现
public class PriorityQueue {
public int[] elem;
public int usedSize;
// 构造方法:进行变量初始化
public PriorityQueue() {
this.elem = new int[10];
this.usedSize = 0; // 有效长度
}
// 初始化数组
public void initArray(int[] arr) {
this.elem = Arrays.copyOf(arr, arr.length); //将数组中元素拷贝给数组
this.usedSize = this.elem.length;
}
/**
* 建堆的时间复杂度:差比数列求和--O(N)
* 注意:从最后一个根节点依次向上的根结点遍历,以使得每棵子树都是大堆形式--使用根结点循环
* @param array
*/
public void createHeap(int[] array) {
// 注意:如果这里不传参时,usedSize就不用重新计算,直接使用initArray中已经初始化好的就行;
// 其实,就算传入数组参数也可以直接使用this.usedSize
int usedSize = array.length;
for (int parent=(usedSize-1-1)/2; parent>=0; parent--) {
shiftDown(parent,usedSize); // 注意结束条件是数组长度!
}
}
/**
* 向下调整(一次针对的是一棵子树)--大根堆
* 比较左右孩子结点大小,找到最大,然后与根结点进行比较,若果根结点小就进行交换--循环实现
* @param root 是每棵子树的根节点的下标
* @param len 是每棵子树调整结束的结束条件
* 向下调整的时间复杂度:O(logn)
*/
private void shiftDown(int root, int len) {
// 左孩子结点
int child = 2*root + 1;
// 进入循环的条件其实是:左孩子结点要小于数组长度
while(child < len) {
// 判断左右孩子结点大小:
if((child+1<len) && (elem[child] < elem[child+1])) {
child++; // 记录最大孩子结点的下标
}
// 来到这儿说明两种情况:只有左孩子or左孩子小于右孩子--两种情况都是child结点有最大值
// 判断最大子结点和父亲节点的大小 -- 进行交换swap
if(elem[child] > elem[root]) {
swap(elem, child, root);
// child root变化
root = child;
child = 2*root+1;
} else {
// 说明是:父节点大
break;
}
}
}
private void swap(int[] elem, int child, int root) {
int tmp = elem[child];
elem[child] = elem[root];
elem[root] = tmp;
}
/**
* 入队是先加在尾部 然后进行向上调整
* 入队:仍然要保持是大根堆
* @param val
*/
public void push(int val) {
if(isFull()) {
// 进行扩容
this.elem = Arrays.copyOf(elem, 2*elem.length);
}
// 要么扩容成功,要么未满 就开始进行尾插
this.elem[this.usedSize] = val;
this.usedSize++; // 要注意++!!
// 向上调整:
shiftUp(this.usedSize - 1); // 因为之前已经usedSize++,此时有效下标需要--
}
// 向上调整:也是孩子结点与父亲节点比较
private void shiftUp(int child) {
int parent = (child-1)/2;
// 注意调整条件:child>0
while(child > 0) {
// 直接与父亲节点进行比较就行,不需要再与另一个孩子结点进行比较,因为其他已经是有序的大根堆
if(this.elem[parent] < this.elem[child]) {
swap(this.elem, child,parent);
// 注意交换后一定要进行变量的变化,进行上一层的调整
child = parent;
parent = (child-1)/2;
} else {
break;
}
}
}
public boolean isFull() {
return this.usedSize==this.elem.length;
}
/**
* 出队【删除】:每次删除的都是优先级高的元素!! 即:删除的是堆顶元素!
* 仍然要保持是大根堆
* 把堆顶数据域最后一个数据进行交换,然后进行向下调整成大根堆
*/
public void pollHeap() {
if(isEmpty()) {
return;
}
int old = this.elem[0];
swap(this.elem,this.usedSize-1,0);
this.usedSize--; // 此时有效数据中被换到最后的数据就不被包含在内
// 注意该方法其实是在<usdSize时进行变换,所以不需要-1!!!
//shiftDown(0,this.usedSize-1); // 向下调整
shiftDown(0,this.usedSize);
System.out.println(old);
}
public boolean isEmpty() {
return this.usedSize == 0;
}
/**
* 获取堆顶元素
* @return
*/
public int peekHeap() {
return this.elem[0];
}
}
4.3、PriorityQueue介绍
Java集合框架中提供了PriorityQueue和PriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的。我们先看PriorityQueue。
-
PriorityQueue的底层默认是小根堆!如果需要大堆需要用户提供比较器。用户自己定义的比较器:直接实现Comparator接口,然后重写该接口中的compare方法即可;或者实现Comparable接口,重写compareTo方法。
-
PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastException异常
-
不能插入null对象,否则会抛出NullPointerException
-
没有容量限制,可以插入任意多个元素,其内部可以自动扩容
-
插入和删除元素的时间复杂度为:O(logN)
接口关系图:
4.3.1、PriorityQueue的构造方法
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
public PriorityQueue(Comparator<? super E> comparator) {
this(DEFAULT_INITIAL_CAPACITY, comparator);
}
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;
}
@SuppressWarnings("unchecked")
public PriorityQueue(Collection<? extends E> c) {
if (c instanceof SortedSet<?>) {
SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
this.comparator = (Comparator<? super E>) ss.comparator();
initElementsFromCollection(ss);
}
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);
}
}
@SuppressWarnings("unchecked")
public PriorityQueue(PriorityQueue<? extends E> c) {
this.comparator = (Comparator<? super E>) c.comparator();
initFromPriorityQueue(c);
}
@SuppressWarnings("unchecked")
public PriorityQueue(SortedSet<? extends E> c) {
this.comparator = (Comparator<? super E>) c.comparator();
initElementsFromCollection(c);
}
4.3.2、为什么默认是小顶堆?
有上面可知,无论哪个构造都要比较元素大小,来构建小顶堆/大顶堆,我们来看下PriorityQueue中比较器Comparator的应用,也就是为什么默认是小顶堆?
// 添加一个元素
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;
}
// 向上调整
private void siftUp(int k, E x) {
if (comparator != null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}
// k是最后一个元素的下标,x是当前要插入的元素
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;
}
这里就得说下Comparator这个接口
public interface Comparator<T> {
int compare(T o1, T o2);
}
Java中默认规则是 o1 - o2,在PriorityQueue中当 o1 - o2 < 0 时,才交换,将更小的交换到父节点。
如果要是实现大顶堆,直接重写 Comparator的compare方法,使之为o2-o1,此时如果结果是 < 0的,说明o2小,o1大,映射到PriorityQueue的源码中,就是x大(新加入的),e小(父),然后交换,x就被换到父节点了,说明父节点是最大的那个,自然也就成了大顶堆。
PriorityQueue queue = new PriorityQueue<Integer>(
new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}
);