上一组参赛选手:
👉插入排序,希尔排序
1️⃣必备排序常识
稳定性:在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求能在内存和硬盘(外部存储器)之间移动数据的排序。
时间复杂度:一个排序算法在执行过程中所耗费的时间量级的度量。
空间复杂度:一个排序算法在运行过程中临时占用存储空间大小的度量。
本次讲解的排序都是内排序
2️⃣选择排序
1.单路选择排序
排序原理:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,排在有序元素后,然后再从剩余的未排序元素中寻找到最小(大)元素,继续排在已排序元素后,直到数组整个有序。
过程展示:
代码实现:
public static void selectionSort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// min变量存储了当前的最小值索引
int min = i;
// 从剩下的元素中选择最小值
for (int j = i; j < arr.length; j++) {
if(arr[j] < arr[min]){
min = j;
}
}
// min这个索引一定对应了当前无序区间中找到的最小值索引,换到无序区间最前面i
swap(arr,min,i);
}
}
特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:
O(n^2)
- 空间复杂度:
O(1)
- 稳定性:不稳定。交换值的同时可能将其他相同值的位置改变了。
例: 4 9 55
8 5 69
-> 4 9 59
8 5 65
此时就有相同值的位置顺序改变了
2.双路选择排序
在遍历未排序元素时,可以一次找出最大和最小的元素,将其放在前(后)已排序元素的后(前)即可,当前后遍历的下标相同时,说明整个数组就有序了。
//双路选择排序
public static void selectionSortOp(int[] arr){
int left = 0;
int right = arr.length - 1;
// low = high,无序区间只剩下一个元素,整个数组已经有序
while(left < right){
int min = left;
int max = left;
for (int i = left; i <= right; i++) {
if(arr[min] > arr[i])
min = i;
if(arr[max] < arr[i])
max = i;
}
// min索引一定是当前无序区间的最小值索引,与low交换位置
swap(arr,min,left);
if(max == left)
// 最大值已经被换到min这个位置
max = min;
swap(arr,max,right);
left++;
right--;
}
}
3.性能比较
测试使用的类:
/**
* 排序的辅助类
* 生成测试数组以及对排序算法进行测试
**/
public class SortHelper {
// 获取随机数的对象
private static final ThreadLocalRandom random = ThreadLocalRandom.current();
//在[left...right]上生成n个随机数
public static int[] generateRandomArray(int n,int left,int right) {
int[] arr = new int[n];
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(left,right);
}
return arr;
}
/**
* 生成一个大小为n的近乎有序的数组
* @param n
* @param times 交换的次数,次数越小越有序,次数越大越无序
* @return
*/
public static int[] generateSoredArray(int n,int times) {
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = i;
}
// 交换部分元素,交换次数越小,越有序
for (int i = 0; i < times; i++) {
// 生成一个在[0..n]上的随机数
int a = random.nextInt(n);
int b = random.nextInt(n);
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
return arr;
}
// 根据传入的方法名称就能调用这个方法,需要借助反射
// 根据方法名称调用相应的排序方法对arr数组进行排序操作
public static void testSort(String sortName,int[] arr) {
Class<SevenSort> cls = SevenSort.class;
try {
Method method = cls.getDeclaredMethod(sortName,int[].class);
long start = System.nanoTime();
method.invoke(null,arr);
long end = System.nanoTime();
if (isSorted(arr)) {
// 算法正确
System.out.println(sortName + "排序结束,共耗时:" + (end - start) / 1000000.0 + "ms");
}
} catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
e.printStackTrace();
}
}
// 生成一个arr的深拷贝数组
// 为了测试不同排序算法的性能,需要在相同的数据集上进行测试
public static int[] arrCopy(int[] arr) {
return Arrays.copyOf(arr,arr.length);
}
public static boolean isSorted(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
System.err.println("sort error");
return false;
}
}
return true;
}
}
排序十万个随机数时:
public static void main(String[] args) {
int n = 100000;
int[] arr = SortHelper.generateRandomArray(n, 0, Integer.MAX_VALUE);
int[] arrCopy1 = SortHelper.arrCopy(arr);
SortHelper.testSort("selectionSort", arr);
SortHelper.testSort("selectionSortOp", arrCopy1);
}
运行结果:
因为选择排序是
O(n^2)
的时间复杂度,所以排序起来非常慢,实际生活中也不怎么使用。
排序十万个接近有序数组时:
int n = 100000;
//将有序的数组元素进行随机交换100次就可构成一个接近有序的数组
int[] arr = SortHelper.generateSoredArray(n,100);
运行结果:
可以看出,因为选择排序稳定的
O(n^2)
的时间复杂度,即使排序接近有序的数组时也没有优势,甚至会更慢。
3️⃣堆排序
排序原理:
堆排序(Heap Sort)是利用堆进行排序的方法。其基本思想为:将待排序列构造成一个大堆(或小堆),整个序列的最大值(或最小值)就是堆顶的根结点,将根节点的值和堆数组的末尾元素交换,此时末尾元素就是最大值(或最小值),然后将剩余的n - 1个序列(数组末尾已排序元素除外)重新构造成一个堆,这样就会得到n - 1个元素中的次大值(或次小值),如此反复执行,最终得到一个有序序列。
堆有很多种存储形式,这里使用的堆是用数组表示的完全二叉树。要学习堆排序,首先要学习堆的向下调整算法,因为要用堆排序先得建堆,而建堆需要执行多次堆的向下调整算法。
堆的向下调整算法(使用前提):
大堆:堆中根结点值>=
子树中的结点值
小堆:堆中根结点值<=
子树中的结点值
若想将其调整为小堆,那么根结点的左右子树必须都为小堆。
若想将其调整为大堆,那么根结点的左右子树必须都为大堆。
向下调整算法的基本思想(以建大堆为例):
1.从根结点处开始,选出左右孩子中值较大的孩子。
2.让大的孩子与其父亲进行比较。
若大的孩子比父亲还大,则该孩子与其父亲的位置进行交换。并将原来大的孩子的位置当成父亲继续向下进行调整,直到调整到叶子结点为止。
若大的孩子比父亲小,则不需处理了,调整完成,整个树已经是大堆了。
使用堆的向下调整算法,最坏的情况下(即一直需要交换结点),需要循环的次数为:
h - 1
次(h为树的高度)。而h = log2(n+1)
(n为树的总结点数)。所以堆的向下调整算法的时间复杂度为:O(logn)
。
此时需要找出最后一个有叶子结点的父结点,n为结点的总个数,(n - 2) / 2
就是满足条件的下标,并以该结点从下往上依次向下调整。最后就可以建成一个大堆。
整个排序过程:
代码实现:
public static void heapSort(int[] arr){
// 先将arr进行heapify调整为最大堆
// 从最后一个非叶子节点开始进行siftDown操作
for (int i = (arr.length - 1 - 1) >> 1; i >= 0; i--) {
siftDown(arr,i,arr.length);
}
// 此时arr就被我调整为最大堆
for (int i = arr.length - 1; i >= 0; i--) {
// arr[0] 堆顶元素,就是当前堆的最大值
swap(arr,i,0);
siftDown(arr,0,i);
}
}
/**
* 元素下沉操作
* @param arr
* @param i 当前要下沉的索引
* @param length 数组长度
*/
private static void siftDown(int[] arr, int i,int length) {
int child = 2 * i + 1;
if (child >= length) {
return;
}
if (child + 1 < length && arr[child + 1] > arr[child]) {
child++;
}
//child就是左子树最大的索引
if (arr[i] < arr[child]) {
swap(arr,i,child);
}
//将子结点赋值给需要下沉的索引,向下递归,直到下沉完毕
i = child;
siftDown(arr,i,length);
}
private static void swap ( int[] arr, int i, int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度为固定的
O(n*logn)
前面提到向下建堆的时间复杂度为O(logn)
,相乘后的时间度就为O(n*logn)
。
- 空间复杂度:
O(1)
- 稳定性:不稳定。因为向下调整的时候可能将相同的值位置顺序改变。
例:
性能比较
排序十万个随机数时:
运行结果:
可以看出由于堆排序稳定的
O(nlogn)
的时间复杂度下,速度比O(n^2)
快出不少。
排序十万个接近有序数组时:
运行结果:
最后,堆排序胜出本场比赛!