冒泡排序
从要排序序列的第一个元素开始,不断比较相邻元素的值,发现逆序则交换,将值较大的元素逐渐从前向后移动。
每找到待排序序列的最大值时,就将该最大值固定在待排序序列的尾部,且每找到一个待排序序列最大值需要循环一次,n 个值则需要循环 n 次,但最后一个值无需比较,则实际需循环 n-1 次,即 i < arr.length - 1
。
1 2 3 4 5 6 7 8 9 10 11
| public void bubbleSort (int[] arr) { for (int i = 0; i < arr.length - 1; i++) { for (int j = 0; j < arr.length - i - 1; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }
|
当然,该方法还可以进行优化。
定义一个布尔变量 flag
,用来标记每轮是否进行了交换。在每轮遍历开始时,将 flag
设置为 false。若当轮没有发生交换,即此时 flag
依然为 false,说明此时数组已经按照升序排列。此时外层循环直接退出,排序结束。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public void bubbleSort (int[] arr) { boolean flag = false; for (int i = 0; i < arr.length - 1; i++) { flag = false; for (int j = 0; j < arr.length - i - 1; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; flag = true; } } if (!flag) { break; } } }
|
复杂度分析:
- 时间复杂度 O(n^2)
- 空间复杂度 O(1)
- 稳定
选择排序
选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。同样,选择排序也需要比较 n - 1
轮,最后一轮无需比较。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public void selectSort (int[] arr) { for (int i = 0; i < arr.length - 1; i++) { int minIndex = i; for (int j = i + 1; j < arr.length; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; } } if (minIndex != i) { int temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } } }
|
复杂度分析:
- 时间复杂度 O(n^2)
- 空间复杂度 O(1)
- 不稳定
直接插入排序
把 n 个待排序的元素看成为一个有序表
和一个无序表
,开始时 有序表 中只包含一个元素,无序表中包含有 n-1 个元素,排序过程中每次从无序表中取出第一个元素,与有序表中的元素进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。
1 2 3 4 5 6 7 8 9 10 11 12 13
| public void insertSort (int[] arr) { for (int i = 1; i < arr.length; i++) { int temp = arr[i]; int j = i; while (j > 0 && arr[j - 1] > temp) { arr[j] = arr[j - 1]; j--; } if (j != i) { arr[j] = temp; } } }
|
复杂度分析:
- 时间复杂度 O(n^2)
- 空间复杂度 O(1)
- 稳定
希尔排序
希尔排序,也被称为递减增量排序,是直接插入排序的一种改进版本。简单插入排序可能存在的问题:当需要插入的数是较小的数时,后移的次数明显增多,对效率有影响。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public void shellSort(int[] arr) { int length = arr.length; for (int step = length / 2; step >= 1; step /= 2) { for (int i = step; i < length; i++) { int temp = arr[i]; int j = i - step; while (j >= 0 && arr[j] > temp) { arr[j + step] = arr[j]; j -= step; } arr[j + step] = temp; } } }
|
复杂度分析:
- 时间复杂度 O(nlogn)
- 空间复杂度 O(1)
- 不稳定
归并排序
归并排序的核心思想是分治法,把一个复杂问题拆分成若干个子问题来求解。
归并排序的算法思想是:把数组从中间划分为两个子数组,一直递归地把子数组划分成更小的数组。长度为 1 序列是有序的,因此应递归分解直到子数组里面只有一个元素的时候开始排序。排序的方法就是按照大小顺序合并两个元素。接着依次按照递归的顺序返回,不断合并排好序的数组,直到把整个数组排好序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| public static void mergeSort(int[] source, int[] temp, int left, int right) { if (left >= right) { return; } int mid = (left + right) / 2; mergeSort(source, temp, left, mid); mergeSort(source, temp, mid + 1, right); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (source[i] <= source[j]) { temp[k++] = source[i++]; } else { temp[k++] = source[j++]; } } while (i <= mid) { temp[k++] = source[i++]; } while (j <= right) { temp[k++] = source[j++]; } for (i = left, j = 0; i <= right; i++, j++) { source[i] = temp[j]; } }
public static void main(String[] args) { int[] arr = {12, 3, 15, 55, 97, 6, 11, 41, 88, 12}; int[] temp = new int[arr.length]; mergeSort(arr, temp, 0, arr.length - 1); for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } }
|
复杂度分析:
- 时间复杂度 O(nlogn)
- 空间复杂度 O(logn)
- 稳定
快速排序
快速排序也采用了分治的思想,如果说归并排序是先拆分再排序,那么快速排序就是先排序再划分。
快速排序的基本思想是:首先从待排序列中选定一个记录,称之为 枢纽
,通过关键字与枢纽的比较将待排序列的序列划分成位于枢纽前后的两个子序列,其中枢纽之前的子序列的所有关键字都不大于枢纽,枢纽之后的子序列的所有关键字都不小于枢纽;此时枢纽已到位,再按同样方法对这两个子序列分别递归进行快速排序,最终使得整个序列有序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivot = arr[left]; int i = left, j = right; while (i < j) { while (i < j && arr[j] >= pivot) j--; arr[i] = arr[j]; while (i < j && arr[i] <= pivot) i++; arr[j] = arr[i]; } arr[i] = pivot; quickSort(arr, left, i - 1); quickSort(arr, j + 1, right); }
|
复杂度分析:
- 时间复杂度 O(nlogn)
- 空间复杂度 O(n)
- 不稳定
堆排序
堆排序是指利用堆这种数据结构所设计的一种排序算法。堆是一类完全二叉树,具有以下特性:
大顶堆:每个结点的值都大于或等于其左右孩子结点的值
小顶堆:每个结点的值都小于或等于其左右孩子结点的值
堆排序基本步骤:
- 将待排序数组构造成一个大顶堆,即从右至左、从下至上进行下沉操作。此时,整个序列的最大值就是堆顶的根节点。一般升序采用大顶堆,降序采用小顶堆
- 将其与末尾元素进行交换,此时末尾就为最大值。
- 然后将剩余n-1个元素进行调整重新构造成一个堆。再将堆顶元素与末尾元素交换,如此反复执行,便能得到一个有序序列了。
以数组首元素索引是0还是1区分,算法有以下两个版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public void heapSort(int[] arr) { int N = arr.length - 1; for (int i = N / 2; i > 0; i--) { sink(arr, i, N); } while (N > 1) { swap(arr, 1, N--); sink(arr, 1, N); } }
private void sink(int[] arr, int pos, int N) { while (pos <= N / 2) { int j = 2 * pos; if (j < N && arr[j] < arr[j + 1]) { j++; } if (arr[j] < arr[pos]){ return; } swap(arr, pos, j); pos = j; } }
private void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public static void heapSort(int[] arr) { int N = arr.length; for (int i = N / 2 - 1; i >= 0; i--) { sink(arr, i, N); } while (N > 1) { swap(arr, 0, --N); sink(arr, 0, N); } }
private static void sink(int[] arr, int pos, int N) { while (pos <= N / 2 - 1) { int j = 2 * pos + 1; if (j < N - 1 && arr[j] < arr[j + 1]) { j++; } if (arr[j] < arr[pos]){ return; } swap(arr, pos, j); pos = j; } }
private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }
|
复杂度分析:
- 时间复杂度 O(nlogn)
- 空间复杂度 O(1)
- 不稳定
堆排序是一种原地排序,没有利用额外的空间。
快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。现代操作系统很少使用堆排序,因为它无法利用局部性原理进行缓存,也就是数组元素很少和相邻的元素进行比较和交换。使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。
计数排序
比较和非比较的区别
常见的快速排序、归并排序、堆排序、冒泡排序等属于比较排序。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。
在冒泡排序之类的排序中,问题规模为n,又因为需要比较n次,所以平均时间复杂度为O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为logN次,所以时间复杂度平均**O(nlogn)**。
比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。
计数排序、基数排序、桶排序则属于非比较排序。非比较排序是通过确定每个元素之前,应该有多少个元素来排序。针对数组arr,计算arr[i]之前有多少个元素,则唯一确定了arr[i]在排序后数组中的位置。
非比较排序只要确定每个元素之前的已有的元素个数即可,所有一次遍历即可解决。算法时间复杂度**O(n)**。
非比较排序时间复杂度低,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
适用条件:计数排序需要占用大量空间,它仅适用于数据比较集中的情况,如[0,100],高考学生成绩。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public void countSort(int[] arr) { int maxValue = arr[0]; for (int num : arr) { if (maxValue < num) { maxValue = num; } } int[] bucket = new int[maxValue + 1]; for (int num : arr) { bucket[num]++; } for (int i = 0, j = 0; i <= maxValue; i++) { while (bucket[i]-- > 0) { arr[j++] = i; } } }
|
当然,上面是最基本的计数排序,还可以有优化的地方。比如开辟的bucket数组的长度可以是最大值与最小值的差值+1,不一定要求数据必须从0开始。
复杂度分析:
- 时间复杂度 O(N+k)
- 空间复杂度 O(k)
- 稳定
桶排序
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增大桶的数量
- 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
什么时候最快:
当输入的数据可以均匀的分配到每一个桶中。
什么时候最慢:
当输入的数据被分配到了同一个桶中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| public void bucketSort(int[] arr){ int max = arr[0]; int min = arr[0]; for(int num : arr){ if (num > max) { max = num; } else if (num < min) { min = num; } } int bucketNum = (max - min) / arr.length + 1; List<List<Integer>> bucketArr = new ArrayList<>(bucketNum); for(int i = 0; i < bucketNum; i++){ bucketArr.add(new ArrayList<Integer>()); } for(int i = 0; i < arr.length; i++){ int hash = (arr[i] - min) / (arr.length); bucketArr.get(hash).add(arr[i]); } for(int i = 0; i < bucketArr.size(); i++){ Collections.sort(bucketArr.get(i)); } int index = 0; for(int i = 0; i < bucketArr.size(); i++){ for(int j = 0; j < bucketArr.get(i).size(); j++){ arr[index++] = bucketArr.get(i).get(j); } } }
|
复杂度分析:
基数排序
基数排序是一种非比较型排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。
将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。 这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| public void radixSort(int[] arr) { int[][] bucket = new int[10][arr.length]; int[] bucketCount = new int[10]; int maxValue = arr[0]; for (int num : arr) { if (num > maxValue) { maxValue = num; } } int maxLength = (maxValue + "").length(); for (int digit = 0, n = 1; digit < maxLength; digit++, n*= 10) { for (int i = 0; i < arr.length; i++) { int value = arr[i] / n % 10; bucket[value][bucketCount[value]++] = arr[i]; } int index = 0; for (int j = 0; j < 10; j++) { for (int k = 0; k < bucketCount[j]; k++) { arr[index++] = bucket[j][k]; } bucketCount[j] = 0; } } }
|
复杂度分析:
基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 计数排序:每个桶只存储单一键值;
- 桶排序:每个桶存储一定范围的数值;
- 基数排序:根据键值的每位数字来分配桶;
总结
- 当数据规模较小时,可以使用直接插入排序。
- 当数组初始时已经基本有序,可以用直接插入排序和冒泡排序。
- 当数据规模较大时,可以考虑使用快速排序,速度最快。当记录随机分布的时候,快速排序平均时间最短。
- 堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的。
- 若要求排序稳定,则可选用归并排序。