0. 前言
本系列文章将介绍一些常用的排序算法。排序是一个非常常见的应用场景,也是开发岗位面试必问的一道面试题,有人说,如果一个企业招聘开发人员的题目中没有排序算法题,那说明这个企业不是一个“正规”的企业,哈哈,虽然有点戏谑,但是也从侧面证明了排序算法的重要性。
本文将介绍的是常见排序算法中的堆排序。
7 堆排序
7.1 基本思想
7.1.1 堆的概念
堆是一种特殊形式的完全二叉树,堆又分为最大堆和最小堆。最大/小堆指每个节点的值均不大于/小于其父节点的值(如果有父节点的话)。因此最大/小堆的根节点值一定是最大/小的。堆有几种基本操作必须掌握:
(1)堆的创建。可以声明一个容量足够的数组来存放堆内数据,下标为0的位置存放堆中的元素数。详情可见7.2代码实现中Heap的构造方法。
(2)堆的插入。堆的插入存在堆满、堆空、和其他,三种情况。堆满则不能再插入,这里以最大堆为例,堆空则直接插入到数组下标为1的位置作为根节点。其他的情况呢,就是先把要插入的数value放在数组最后面,再和其父节点的值相比较,若比父节点还要大,则父节点的值下移,直到value比父节点的父节点(等等若干个父节点)的值小了,或者value成了根节点,则退出循环,最后插入value,整个插入过程结束。详情可见7.2代码实现中Heap的insert()方法。
(3)堆的删除。堆的删除并不是指定一个元素删除,而是删除该堆此时的根节点,即最大堆的最大值lastRoot。这时,再把数组的最后一个元素lastValue,即二叉树中最下排最右边的元素lastValue放在根节点的位置,这时因为不知道该值是否配做“老大”,接下来就是对堆进行重建的时候,重建的过程也很容易理解,就是让lastValue值和第二排的最大值做比较,他们仨谁大谁上来,lastValue值一直下沉,直到它到了合适的位置,或者已经成了叶子节点则退出循环,后插入lastValue,整个删除过程结束。详情可见7.2代码实现中Heap的delete()方法。该方法返回lastRoot值。
7.1.2 堆排序的基本思想
堆排序的过程,其实就是把所有待排的n个元素依次insert()进堆,并且进行n次delere()的过程,每次都将delete()的返回值放在数组最后的位置。最后的序列就是一个有序数列了。
7.2 代码实现
首先是Heap类:
/*
*@author Calvin
*@blog http://blog.csdn.net/seu_calvin/article/details/58673035
*@data 2017/02/28
*/
public class Heap {
private int[] element;
public Heap(int maxSize){
//数组的第0位维护一个数组中有效元素的个数
element = new int[maxSize+1];
element[0] = 0;
}
public boolean isEmpty(){
return element[0] == 0;
}
public boolean isFull(){
return element[0] == element.length-1;
}
public void insert(int value){
if(isFull()){
throw new IndexOutOfBoundsException("该堆已满");
}
if(isEmpty()){
element[1] = value;
}else{
//新增元素下标
int position = element[0] + 1;
while(position != 1 && value > element[position/2]){
//若比父节点值大,则父节点下移
element[position] = element[position/2];
//判断是否需要继续与父节点进行大小比较
//跳出循环条件:直到(1)该值不比父节点大,或者(2)该值为最大,即到element[1]位置。
position/=2;
}
//最终把该值放在合适位置
element[position] = value;
}
//最后记录元素数加1
element[0] ++;
}
public int delete(){
if(isEmpty()){
throw new IndexOutOfBoundsException("该堆为空");
}
//要删除的根元素位置先赋值为最后一个有效元素的值
int deleteElement = element[1];
element[1] = element[element[0]];
int temp = element[1];
element[0]--;
//重建堆
int parent = 1;
int child = 2;
while(child <= element[0]){
if(element[child] < element[child+1]){
//如果右孩子更大则孩子下标加1
child++;
}
if(temp > element[child]){
//如果上来的元素比最大的孩子都大,那重建结束
break;
}else{
//大孩子上来
element[parent] = element[child];
parent = child;
child *= 2;//直到child > element[0]退出循环
}
}
//移动上来的根节点最终放置的位置为parent
element[parent] = temp;
//返回删除的最开始根节点
return deleteElement;
}
public void sort(){
if(isEmpty()){
throw new IndexOutOfBoundsException("该堆为空");
}
//依次删除元素,并把delete返回的结果放在最后
int size = element[0];
for(int i = 0; i < size; i++){
int deleteElement = delete();
element[element[0]+1] = deleteElement;
}
//输出排序结果
for(int j = 1; j < size+1; j++){
System.out.println(element[j]);
}
}
public void printAll() {
for(int j = 1; j < element[0]+1; j++){
System.out.println(element[j]);
}
}
}
接着是主函数,将待排元素依次insert()进,再调用其sort()方法,该方法就是依次删除的过程,Heap中的代码逻辑已经注释的很清楚了,认真看看其实也不难。
/*
*@author Calvin
*@blog http://blog.csdn.net/seu_calvin/article/details/58673035
*@data 2017/02/28
*/
public class Order {
public static void main(String[] args) {
int[] array = new int[]{3,1,5,9,6,5,0,2,4,12};
Heap order = new Heap(10);
for(int i = 0; i < array.length; i++){
order.insert(array[i]);
}
order.sort();
}
}
输出结果略。
7.3 性能特点
堆排序的插入操作时间复杂度为O(logn),n次插入总时间为nO(logn),遍历删除也一样是nO(logn)。因此堆排序的时间复杂度是O(nlogn),空间复杂度为O(1),性能是非常好的。堆排序是不稳定的。因为堆排序是要建堆的,因此更适合对大量数据进行排序时使用。但是堆排比较的几乎都不是相邻元素,对cache极不友好,这也是其比较少被采用的原因。
7.4 堆排序与快排的性能比较
那么堆排序和快排哪个效率更好呢?两者时间复杂度相同,但是空间复杂度堆排序貌似更胜一筹,答案是快速排序比堆排序的效率高很多,并且随着数据规模的扩大,二者的差距不断扩大,快速排序的优势越来越明显。因此堆排比较少被使用。原因总结如下:
(1)首先要明确第一点,数学上的时间复杂度不代表实际运行时的情况。
(2)堆排比较的几乎都不是相邻元素,对cache极不友好,堆排中比较父节点和子节点的值大小的时候,虽然计算下标会很快完成,但在大规模的数据中对数组指针寻址也需要一定的时间。而快速排序只需要将数组指针移动到相邻的区域即可。
(3)堆排序中删除堆顶后的将最后的元素放到堆顶,再让其自我调整。这样有很多比较将是被浪费的,因为被拿到堆顶的那个元素几乎肯定是很小的,而靠近堆顶的元素又几乎肯定是很大的,最后一个元素能留在堆顶的可能性微乎其微,而且很有可能最终再被移动到底部。