0%

数据结构-堆

数据结构——堆

概念与性质

堆是一个完全二叉树

大根堆:每个子树的最大值都是头节点

小根堆:每个子树的最小值都是头节点

将堆元素按照层次遍历放到一个数组中,则i位置的节点的左孩子位置为2*i+1,右孩子的位置为2*i+2,父节点的位置为(i-1)/2

大根堆

堆中元素用什么存储

很直观的感受,大根堆应该用二叉树来存储数据,不过由于为了维护大根堆的性质,比如需要交换堆顶元素与堆底元素,用二叉树不方便

由于大根堆是满二叉树,每个节点与其父节点、孩子节点在数组下标上有规律可循(见上文),故用数组存储堆中元素

堆大小

既然堆中元素用数组存储,由于数组长度在申请内存空间时即设定,需要用一个单独的变量(heapSize)记录堆中元素的个数,如数组长度为100,heapSize=20

堆的基本操作

节点上升

对于任意节点,如果大于父节点(通过访问(i-1)/2位置找到父节点),与父节点交互,再与新的父节点比较,直到小于父节点或到达根节点

1
2
3
4
5
6
private static void siftUp(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
节点下沉

对于任意节点,如果小于较大的子节点,交换,直到不小于较大子节点或到达叶子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void siftDown(int[] arr, int index, int heapSize) {
int leftChildIndex = index * 2 + 1;
while (leftChildIndex <heapSize) {
int largest = leftChildIndex + 1 < heapSize && arr[leftChildIndex + 1] > arr[leftChildIndex] ?
leftChildIndex + 1 : leftChildIndex;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, index, largest);
index = largest;
leftChildIndex = index * 2 + 1;
}
}
向堆中插入元素

将堆大小加一,插入的元素放在数组末尾(堆尾),再将堆尾节点上升

image-20220604152134239
1
2
3
4
5
6
7
8
9
10
11
public boolean add(int n) {
if (this.size >= arr.length) {
// 数组扩容:复制已有值,长度变为原来的二倍
this.grow();
}
this.size ++;
this.arr[this.size-1] = n;
// 将数组末尾元素上升
siftUp(this.arr, this.size-1);
return true;
}
弹出最大值

将数组0位置元素(堆顶)与最后位置元素(堆尾)交互,有效长度减一,再将堆顶元素下沉

image-20220604161702736
1
2
3
4
5
6
7
8
9
10
11
public int poll() {
if (this.isEmpty()) {
throw new RuntimeException("堆已空,无法弹出元素");
}
int root = this.arr[0];
swap(this.arr, 0, this.size-1);
this.size --;
// 新的堆顶元素下沉
this.siftDown(this.arr, 0, this.size);
return root;
}

堆排序

问题描述:给定一个无序数组,将其升序排列

思路:

  1. 将无序数组构造为大顶堆
  2. 将堆顶(最大值)弹出(与数组末尾元素交互,堆大小减一,新堆顶下沉)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 构造堆结构
// 从上至下,依次上升
// for (int i = 0; i < arr.length; i++) {
// siftUp(arr, i);
// }
// 从下至上,依次下沉
for (int i = arr.length - 1; i >= 0; i--) {
siftDown(arr, i, arr.length);
}
// 弹出
int heapSize = arr.length;
while (heapSize > 0) {
swap(arr, 0, --heapSize);
siftDown(arr, 0, heapSize);
}
}

思考:将无序数组构造大顶堆有几种方式,哪种最快

构造大顶堆的方式 是否正确 效率分析
自上而下,依次上升 正确 对于一个节点,最坏情况:从叶子节点上升至根节点
自下而上,依次上升 错误 -
自上而下,依次下沉 错误 -
自下而上,依次下沉 正确 对于一个节点,最坏情况:从根节点下沉到叶子节点

直观感受:由于满二叉树中,叶子节点个数为N/2,非叶子节点个数为N/2,上升或下沉的时间复杂度与路径长度有关,最长为logN,如果自上而下,依次上升,导致大部分节点要上升的路径很长;如果自下而上,依次下沉,叶子节点不需要变动,大部分节点下沉的路径很短

时间复杂度分析:待补充

故采用自下而上,依次下沉的方式来将数组构造为大顶堆

大根堆的完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import java.util.Arrays;

public class Heap {

private final static Integer DEFAULT_INITIAL_CAPACITY = 11;

private int[] arr;

public Heap() {
this(DEFAULT_INITIAL_CAPACITY);
}

public Heap(int initialCapacity) {
if (initialCapacity < 1) {
throw new IllegalArgumentException();
}
this.arr = new int[DEFAULT_INITIAL_CAPACITY];
}

private int size;

public int getSize() {
return this.size;
}

/**
* 扩容
*/
private void grow() {
int oldCapacity = arr.length;
this.arr = Arrays.copyOf(this.arr, oldCapacity * 2);
}

/**
* 向堆中插入一个数
* @param n
*/
public boolean add(int n) {
if (this.size >= arr.length) {
this.grow();
}
this.arr[this.size] = n;
siftUp(this.arr, this.size);
this.size ++;
return true;
}

public int peek() {
return arr[0];
}

/**
* 弹出堆顶数字
* @return
*/
public int poll() {
if (this.isEmpty()) {
throw new RuntimeException("堆已空,无法弹出元素");
}
int root = this.arr[0];
swap(this.arr, 0, this.size-1);
this.size --;
this.siftDown(this.arr, 0, this.size);
return root;
}

public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 构造堆结构
// 从上至下,依次上升
// for (int i = 0; i < arr.length; i++) {
// siftUp(arr, i);
// }
// 从下至上,依次下沉
for (int i = arr.length - 1; i >= 0; i--) {
siftDown(arr, i, arr.length);
}
// 弹出
int heapSize = arr.length;
while (heapSize > 0) {
swap(arr, 0, --heapSize);
siftDown(arr, 0, heapSize);
}
}

/**
* 将index位置数字上升
* 直到父节点比当前节点大,或已经是根节点
* @param arr
* @param index
*/
private static void siftUp(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}

/**
* 从index位置开始,不断下沉
* 直到较大的子孩子比index位置的数小,或已经没有孩子了
*/
private static void siftDown(int[] arr, int index, int heapSize) {
int leftChildIndex = index * 2 + 1;
while (leftChildIndex <heapSize) {
int largest = leftChildIndex + 1 < heapSize && arr[leftChildIndex + 1] > arr[leftChildIndex] ?
leftChildIndex + 1 : leftChildIndex;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, index, largest);
index = largest;
leftChildIndex = index * 2 + 1;
}
}

public boolean isEmpty() {
return this.size == 0;
}

@Override
public String toString() {
return "Heap{" +
"arr=" + Arrays.toString(arr) +
", size=" + size +
'}';
}

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

}

代码中存在的问题:

  1. 只支持单一类型
  2. 数组扩容方式暴力,存在OOM隐患
  3. 堆排序只支持升序

思考:

  1. 如何合理的让堆的数据动态扩容

JDK中PriorityQueue代码解读

Object[] queue 的动态扩容方法

由于每次扩容都需要拷贝整个数组,故需要控制扩容的次数不要太频繁;如果每次扩容量太大,可以减少扩容频率,但存在空间的浪费

如何找到二者的平衡呢,下面是JDK的实现方案

设置最小扩容量minCapacity,每次需要扩容时,扩容到原size的1.5倍,如果这个扩容量没有达到minCapacity,则直接扩容到minCapacity

1
2
3
4
5
6
7
8
9
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity < 64 ? oldCapacity + 2 : oldCapacity >> 1
/* preferred growth */);
queue = Arrays.copyOf(queue, newCapacity);
}

为什么 Object[] queue 前要加 transient

transient 令属性不再可序列化和反序列化

transient 的使用场景:一些不应该设为 static 的属性,不进行序列化(静态属性不会被序列化),如 Logger 实例,安全性的信息

transient 的原因:由于 Object[] queue 的长度是动态扩容产生的,即根据heapSize,设定合理的queue长度,故queue中存在很多没有存储实际数据的空间,将 queue 整体直接序列化,可能存在空间浪费。通过 writeObject 序列化,可通过 heapSize 自行控制,只序列化实际存储数据部分的queue;而利用 readObject 反序列化时,先获取 heapSize,再初始化queue中的值,并根据heapSize,动态设定合理的容量