想要了解什么是堆排序,我们就必须知道数据结构中的堆。 堆总是一棵完全二叉树,将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
堆的直观展示如下:
堆排序的前提是将数组看成一个堆的形式来进行排序,如下图所示:
由此可以得到它们之间的一种关系:
堆中一个位置的父节点对应的是数组中的(i - 1)/2位置
堆中一个位置的左孩子对应的是数组中的2 * i + 1位置
堆中一个位置的右孩子对应的是数组中的2 * i + 2位置
当在数组中加入一个数时,也即在堆中末尾加上一个数(下面的代码任何位置都可以,本质是向上调),我们知道堆有大根堆和小根堆,这里展示的是大根堆。每次加入一个数,都将其变成大根堆,这需要将这个数和它的父节点代表的值进行比较,如果大于父节点就将两者交换,如果交换成功就再比较上一个父节点,知道直到不大于位置。
相关的代码是:
//某数的位置在index需要向上移动变成大根堆
public static void heapInsert(int[] arr, int index){
while (arr[index] > arr[(index - 1) / 2]){
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
那当我们删除一个数的时候呢,已经排好的大根堆该怎样变化呢,这时因为去掉了一个数,所以heapSize已经减1,由此需要将位于末尾的值交换到要删除的位置,方便heapSize--。在交换完成后,我们需要比较删除位置的左孩子和右孩子谁大,将较大的那一个和换到删除位置的那个值进行比较,如果孩子比它大,就两者交换位置,如果交换了位置,就再看它的左孩子和右孩子,选出最大的和它进行比较,如果孩子大就再次交换直到不大于和到达了heapSize。
相关的代码是:
public static void heapify(int[] arr, int index, int size){
int left = index * 2 + 1;
while (left < size){
int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index){
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
介绍完加入数和去掉数,那么正式开始我们的堆排序,首先依次从数组中读取数,调用heapInsert()函数依次形成大根堆的形式。读取数完成后,依次将大根堆的顶点取出来,heapSize--,调用heapify()重新变成大根堆,直到heapSize = -1读完。由此堆排序结束。
整个过程没有拷贝空间,没有使用递归,所以空间复杂度是O(1),heapInsert()形成大根堆的过程时间复杂度是log(1) + log(2) + log(3) + ... +log(n) = O(N * logN)。Heapify()的时间复杂度也同样是O(N*logN),由此堆排序的时间复杂度是O(N * logN)。
堆排序的动态直观展示:
堆排序的整体代码是:
public static void main(String[] args) {
int[] nums = new int[]{2,1,0,5,6,4,1};
heapSort(nums);
for (int i : nums){
System.out.println(i);
}
}
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2){
return;
}
//这个方法传的是arr[i],而我们用的是i,所以应该用下面的for代码
// for (int i : arr){
// heapInsert(arr, i);
// }
for (int i = 0; i < arr.length; i++) {//O(NlogN)
heapInsert(arr, i);
}
//上面的操作可以更快,那就是用heapify(),因为heapify是向下调整,越往下调整的次数越少
//因为堆的结构原因,大量的数集中在堆的下面,所以用heapify更快一些,这种情况下会达到O(N)
for(int i = arr.length - 1; i >= 0, i--){
heapify(arr, i, arr.length);
}
int size = arr.length;
swap(arr, 0, --size);
while (size > 0){
heapify(arr, 0 ,size);
swap(arr, 0, --size);
}
}
//某数的位置在index需要向上移动变成大根堆
public static void heapInsert(int[] arr, int index){
while (arr[index] > arr[(index - 1) / 2]){
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
//去掉一个数后,之后再次形成大根堆
public static void heapify(int[] arr, int index, int size){
int left = index * 2 + 1;
while (left < size){
int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index){
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
public static void swap(int[] arr, int i, int j){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
总结一下堆的相关的性质:
- 堆结构就是用数组实现的完全二叉树结构
- 大根堆
- 小根堆
- 堆结构的heapInsert和heapify操作
- 堆结构的增大和减少
- 优先级队列结构就是堆结构,在java中就是
//默认是小根堆
PriorityQueue<Interger> heap = new PriorityQueue<>();
但是要注意,优先级队列可以实现当你加入一个数的时候(heap.add(x))变成小根堆形式(自动实现),当你弹出一个数的时候(heap.pop())剩下的自动变成小根堆,无论是大根堆还是小根堆,它们的插入和删除的复杂性都是O(height of tree)也就是O(logN)。但是对于其中第几个数突然改变,或者第几个数突然消失它自己实现的效率会不如heapify()和heapInsert(),所以优先级队列只可以使用加入和弹出的功能。
例如这个题可以改成:
public static void main(String[] args) {
int[] nums = new int[]{2, 1, 0, 5, 6, 4, 1};
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int i : nums) {
heap.add(i);
}
for (int i = 0; i < nums.length; i++) {
nums[i] = heap.poll();
System.out.println(nums[i]);
}
}
但是上面的执行时间大约是24ms,而第一种是8ms。
补充一下:因为优先级队列默认是小根堆的,由此可以实现升序排列,那怎样实现大根堆由此实现降序排列呢。这是就需要用到比较器了。比较器的详细讲解。
public static void main(String[] args) {
int[] nums = new int[]{2, 1, 0, 5, 6, 4, 1};
PriorityQueue<Integer> heap = new PriorityQueue<>(new DiscendingComparator());
for (int i : nums) {
heap.add(i);
}
for (int i = 0; i < nums.length; i++) {
nums[i] = heap.poll();
System.out.println(nums[i]);
}
}
public static class DiscendingComparator implements Comparator<Integer>{
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}