简单选择排序的缺点
没有把每一趟的比较结果存下来,在后一趟的比较中,有许多比较在前一趟已经做过了。堆排序是简单选择排序的一种改良。堆是一种数据结构,在讲解堆结构之前,我们先了解下满二叉树和完全二叉树。
满二叉树
在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子结点都在同一层上,这样的二叉树称为满二叉树。
满二叉树的特点有:
- 叶子只能出现在最下一层
- 非叶子结点的度(结点拥有的子树数)一定是2
- 在同样深度的二叉树中,满二叉树的结点个数最多,叶子个数最多
完全二叉树
对一棵具有n个结点的二叉树按照层序编号,如果编号为i(0 ≤ i < n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中的位置完全相同,则这棵二叉树称为完全二叉树。
上图是一棵满二叉树。
上图是一棵完全二叉树。
上图中两个二叉树都不是完全二叉树。很显然,它们有几个结点的编号和满二叉树中相同位置的结点不同。
从这里也可以得出一些完全二叉树的特点:
- 叶子结点只能出现在最下两层
- 最下层的叶子一定集中在左部连续位置
- 倒数第二层若有叶子结点,一定都在右部连续位置
- 若结点的度为1,则该结点只有左孩子
- 同样结点数的二叉树,完全二叉树的深度最小
堆
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值称为大顶堆。下图是一个大顶堆:
每个结点的值都小于或等于其左右孩子结点的值称为小顶堆。下图是一个小顶堆:
由堆的定义可知,根结点的值一定是所有结点中最大或最小的。
如果按照层序遍历的方式给结点从0开始编号,假设i是编号,k是结点的值,n是结点个数,则有如下规律:
如果i=0,则结点i是二叉树的根,无双亲;如果i>0, 则其双亲是结点(i-1)/2(向下取整);i的左右孩子结点是2i+1和2i+2。
上文中的大顶堆和小顶堆用层序遍历存入数组,则一定满足上面的关系表达式。
堆排序算法
思路
堆排序(Heap Sort)就是利用堆(假设使用大顶堆)进行排序的算法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它与堆数组末尾元素交换,此时末尾元素就是最大值,然后将剩余的n-1个元素重新构造成一个堆,这样就会得到n个元素中的第二大的值。如此反复执行,便能得到一个有序序列了。
如图,将最大值90与堆数组末尾元素20互换,此时90就成了整个堆序列的最后一个元素。
将20经过调整,使得除90以外的结点继续满足大顶堆定义。然后再考虑将30与80互换位置。。。。。。
堆排序的基本思想就是这样,不过要实现它还需要解决两个问题:
- 如何由一个无序序列构成一个堆?
- 如何在输出堆顶元素后,调整剩余元素成为一个新的堆?
代码示例
/**
* 堆排序,时间复杂度O(nlogn)
*/
public class HeapSort {
/**
* 从小到大排序
* @param arr
*/
public static void sortAsc(int[] arr) {
int i;
// (arr.length >>> 1) - 1对应公式中的(n / 2) - 1
// 从这个结点开始,编号递减,也就是从右往左,从下往上遍历
for (i = (arr.length >>> 1) - 1; i >= 0; i--) {
heapAdjustAsc(arr, i, arr.length - 1);
}
// 从右往左遍历序列并与0号结点交换位置
// 对于大顶堆来说,0号结点的值最大,也就是较大值一直往后移
// 数组左边是未排序序列,右边是已排序序列
// 未排序序列越来越短,已排序序列越来越长
for (i = arr.length - 1; i > 0; ) {
swap(arr, 0, i--);
heapAdjustAsc(arr, 0, i); // 每次交换完位置都需要调整元素位置以满足大顶堆特性
}
}
/**
* 从大到小排序
* @param arr
*/
public static void sortDesc(int[] arr) {
int i;
for (i = (arr.length >>> 1) - 1; i >= 0; i--) {
heapAdjustDesc(arr, i, arr.length - 1);
}
for (i = arr.length - 1; i > 0; ) {
swap(arr, 0, i--);
heapAdjustDesc(arr, 0, i);
}
}
/**
* 使用大顶堆进行从小到大排序
* @param arr
* @param s 起始位置
* @param m 最大边界
*/
private static void heapAdjustAsc(int[] arr, int s, int m) {
int d = arr[s];
// 这个循环是从结点s开始遍历它的子孙,可以看成是从上往下遍历,不过不是遍历所有子孙,而是单线路的
// 正因为是单线路的,所以它的比较次数比简单选择排序少,排序效率更高
for (int j = (s << 1) + 1; j <= m; j = (j << 1) + 1) {
// s是父结点,j是它的左孩子,j + 1是它的右孩子(如果有的话)
if (j < m && arr[j] < arr[j + 1]) // 选取值最大的那个孩子
++j;
if (d >= arr[j]) // 父结点的值比它的两个孩子的值要大,无须调整
// 因为外层循环(sortAsc这个方法的for循环)是从下往上调整,所以当父结点的值比它的两个孩子的值大时,
// 肯定也比它所有子孙的值大,因此能直接break
break;
arr[s] = arr[j];
s = j;
}
arr[s] = d;
}
/**
* 使用小顶堆进行从大到小排序
* @param arr
* @param s 起始位置
* @param m 最大边界
*/
private static void heapAdjustDesc(int[] arr, int s, int m) {
int d = arr[s];
for (j = (s << 1) + 1; j <= m; j = (j << 1) + 1) {
if (j < m && arr[j] > arr[j + 1])
++j;
if (d <= arr[j])
break;
arr[s] = arr[j];
s = j;
}
arr[s] = d;
}
private static void swap(int[] arr, int i, int j) {
int d = arr[i];
arr[i] = arr[j];
arr[j] = d;
}
public static void main(String[] args) {
testCase5();
}
private static void testCase1() {
int[] arr = {50, 10, 90, 30, 70, 40, 80, 60, 20};
sortAsc(arr);
print(arr);
}
private static void testCase2() {
int[] arr = {50};
sortAsc(arr);
print(arr);
}
private static void testCase3() {
int[] arr = {50, 10, 90, 30, 70, 40, 80, 60, 20};
sortDesc(arr);
print(arr);
}
private static void testCase4() {
int[] arr = {50};
sortDesc(arr);
print(arr);
}
private static void testCase5() {
int[] arr = {7, 5, 9, 9, 2, 2, 1};
sortDesc(arr);
print(arr);
}
private static void print(int[] arr) {
for (int e : arr) {
System.out.print(e + " ");
}
}
}