数据结构(四十五)选择排序(1.直接选择排序(O(n²))2.堆排序(O(nlogn)))
一、选择排序的定义
选择排序的基本思想是:每次从待排序的数据元素集合中选取最小(或最大)的数据元素放到数据元素集合的最前(或最后),数据元素集合不断缩小,当数据元素集合为空时排序过程结束。常用的选择排序有直接选择排序和堆排序两种。堆排序是一种基于完全二叉树的排序。
二、直接选择排序
1.直接选择排序的定义
直接选择排序的基本思想是:从待排序的数据元素集合中选取最小的数据元素并将它与原始数据元素集合中的第一个数据元素交换位置;然后从不包括第一个位置上数据元素的集合中选取最小的数据元素并将它与原始数据集合中的第二个数据元素交换位置;如此重复,直到数据元素集合中只有一个数据元素为止。
2.直接选择排序的实现
public static void simpleSelectionSort(int[] L) { for (int i = 0; i < L.length; i++) { int min = i; // 将当前下标定义为最小值下标 for (int j = i + 1; j < L.length; j++) { // 循环之后的数据 if (L[min] > L[j]) { // 如果有小于当前最小值的关键字 min = j; // 将此关键字的下标赋值给min } } if (i != min) { // 如果min不等于i,说明找到了最小值 swap(L, i, min); // 此时才交换 } } }
int[] array1 = {9,1,5,8,3,7,4,6,2};
i=0时,min=0,j从1开始到8结束,min=1,min和i不相等,交换9和1,第一次排序结果{1}
i=1时,min=1,j从2开始到8结束,min=8,min和i不相等,交换9和2,第二次排序结果{1,2}
i=2时,min=2,j从3开始到8结束,min=4,min和i不相等,交换5和3,第三次排序结果{1,2,3}
...
3.直接选择排序的时间复杂度
(1)时间复杂度为O(n²)
简单选择排序的最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。
对于比较而言,无论是最好还是最差的情况,其比较次数都是一样多的,第i趟排序需要进行n-i次比较,此时总的比较次数为1+2+...+(n-1) = n(n-1)/2。
对于交换而言,最好情况交换0次,最坏情况交换n-1次。
最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为O(n²)。
尽管时间复杂度与冒泡排序相同,但是简单排序的性能上还是要略优于冒泡排序。
(2)空间复杂度为O(1)。
(3)由于每次从无序记录区选出最小记录后,与无序区的第一个记录交换,可能引起相同的数据元素位置发生变化。所以直接选择排序算法不是稳定的排序算法。
三、堆排序
1.堆排序的背景
在直接选择排序算法中,放在数组中的n个数据元素排成一个线性序列,要从有n个数据元素的数组中选择出一个最小的数据元素需要比较n-1次。这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序时并未保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。
如果能把待排序的数据元素集合构成一个完全二叉树结构,则每次选择出一个最大(或最小)的数据元素值需比较完全二叉树的高度次,即logn次,则排序算法的时间复杂度就是O(nlogn)。
2.堆的定义和性质
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
根据完全二叉树的性质5,
如果对一颗有n个结点的完全二叉树(其深度为【log2 n】+1)的结点按层序编号(从第1层到第【log2 n】+1层,每层从左到右),对任一结点i(0≤i≤n-1)有:
- 如果i=0,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点【(i-1)/2】
- 如果2i+1>n,则结点i无左孩子;否则其左孩子是结点2i+1
- 如果2i+2>n,则结点i无右孩子;否则其右孩子是结点2i+2
对应到堆的定义,小顶堆:a[i]≤a[2i + 1]且a[i]≤a[2i + 2];大顶堆同理。
则完全二叉树的顺序存储结构即堆数组表示为:
3.堆排序算法的定义
堆排序(Heap Sort)就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是:将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值。如此反复执行,就能得到一个有序序列了。
由定义可知:堆排序算法分为两步:1.由一个无序序列构造成一个堆。2.在输出堆顶元素后,调整剩余元素称为一个新的堆。
(1)由一个无序序列构造成一个堆
思路就是:遍历所有有孩子结点的数组下标,如果数组从0开始,则有孩子结点的数组下标为0,1,2,3,...,【(a.length-1)/2】,然后比较这些双亲结点和左右孩子的值,如果左右孩子的最大值大于双亲结点的值,就将最大值和双亲结点交换;交换之后,如果左孩子或者是有孩子恰好是另外两个结点的双亲,那么就要再次调整它们三个的关系,最后便利完所有有孩子的结点即可。
private static void createHeap(int[] a, int n, int h) { int i ,j, flag; int temp; i = h; // i为所有可能的双亲结点下标 j = 2 * i + 1; // j为i结点的左孩子结点下标 temp = a[i]; // temp为调整前双亲结点的值 flag = 0; while (j < n && flag != 1) { if (j < n - 1 && a[j] < a[j+1]) {// 确定左右孩子结点最大值的数组下标 j ++; } if (temp > a[j]) { // 如果双亲结点的数值大于其左右孩子结点的最大值 flag = 1; // 置flag为1,跳出while循环 } else { // 如果双亲结点的数值小于其左右孩子结点的最大值 a[i] = a[j]; // 就令双亲结点的值为其左孩子或者右孩子中值最大的 i = j; // 判断调整后的孩子结点与其左右孩子结点关系是否满足 j = 2 * i + 1; // j为孩子结点的左孩子结点,同理,再来一遍,确保关系正确 } } a[i] = temp; // 将原来的左右孩子结点的值赋值为双亲结点的值 } public static void initCreateHeap(int[] a) { int n = a.length; for (int i = (n - 1)/2; i >= 0; i--) { // 遍历有孩子结点的数组下标 createHeap(a, n, i); } }
结合代码和输出分析代码实现:
int[] array1 = {50,10,90,30,70,40,80,60,20};
i=4,第4层,j=9,超出范围了,序列不变
i=3,第3层,j=7,即第3层的第1个左孩子,temp=30,flag=0,j为7,a[3]=a[7]=60,i=7,j=15跳出while,a[7]=30,序列为:{50 10 90 60 70 40 80 30 20 }
i=2,第2层,j=5,即第2层的第2个左孩子,temp=90,flag=0,j为6,90>80,flag=1,跳出while,a[2]不变,序列为:{50 10 90 60 70 40 80 30 20 }
i=1,第1层,j=3,即第2层的第1个左孩子,temp=10,flag=0,j为4,a[1]=a[4]=70,i=4,j=9跳出,a[4]=10,序列为:{50 70 90 60 10 40 80 30 20 }
i=0,第0层,j=1,即第1层的第1个左孩子,temp=50,flag=0,j为2,a[0]=a[2]=9,i=2,j=5,继续while,
i=2,第1层,j=5,即第3层的第2个左孩子,temp=50,flag=0,j为6,a[2]=a[6]=80,i=6,j=13超出,a[6]=50,序列为:{90 70 80 60 10 40 50 30 20 }
测试和输出:
int[] array1 = {50,10,90,30,70,40,80,60,20};
System.out.print("大顶堆创建前: ");
print(array1);
initCreateHeap(array1);
System.out.print("大顶堆创建后: ");
print(array1);
大顶堆创建前: 50 10 90 30 70 40 80 60 20 i的值为3时: 50 10 90 30 70 40 80 60 20 i的值为2时: 50 10 90 60 70 40 80 30 20 i的值为1时: 50 10 90 60 70 40 80 30 20 i的值为0时: 50 70 90 60 10 40 80 30 20 i的值为2时: 90 70 90 60 10 40 80 30 20 大顶堆创建后: 90 70 80 60 10 40 50 30 20
(2)堆排序算法实现
过程分为三步:
- 把堆顶元素a[0]元素和当前大顶堆的最后一个元素交换。
- 大顶堆元素个数减1。
- 调整根结点使之满足大顶堆的定义。
public static void heapSort(int[] a) { int temp; initCreateHeap(a); // 初始化创建大顶堆 for (int i = a.length - 1; i > 0; i--) { // 当前大顶堆个数依次减1 temp = a[0]; // 交换堆顶元素和最后一个元素 a[0] = a[i]; a[i] = temp; createHeap(a, i, 0); // 将剩余数据元素调整为大顶堆 } }
结合代码和输出分析代码实现:
int[] array2 = {50,10,90,30,70,40,80,60,20};
首先根据给出的序列创建一个大顶堆:90 70 80 60 10 40 50 30 20
将90与20交换,然后将20 70 80 60 10 40 50 30调整为一个大顶堆
80 70 50 60 10 40 20 30,交换80与20,调整后为
70 60 50 30 10 40 20-交换70与20,调整为
60 30 50 20 10 40-然后
50 30 40 20 10-
40 30 10 20-
30 20 10-
20 10-
10
测试和输出:
int[] array2 = {50,10,90,30,70,40,80,60,20}; System.out.print("堆排序前: "); print(array2); heapSort(array2); System.out.print("堆排序后: "); print(array2); 堆排序前: 50 10 90 30 70 40 80 60 20 堆排序后: 10 20 30 40 50 60 70 80 90
4.堆排序算法的性能分析
(1)时间复杂度
堆排序的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
构建堆的过程中,对于每个非终端结点来说,最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)
正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间,这是因为完全二叉树的某个结点到根结点的距离为【logi】+1,并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。
总体来说,堆排序的时间复杂度为O(nlogn)。
由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏与平均情况下的时间复杂度都是O(nlogn),这在性能上远远好于冒泡、直接选择、直接插入的O(n²)的时间复杂度。
(2)空间复杂度:由于没有用到多于的存储空间,因此空间复杂度也为O(1)。
(3)稳定性:由于记录的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法。