前言
排序(sort)是将一个记录的无序序列调整为一个有序序列的过程,排序的主要目的是为了进行快速查找,在其他很重要的领域中,排序也被视作一个重要的辅助步骤。
到如今,被发明的排序算法少说几十种,但是没有一种排序算法在任何情况下都是最优的,有些排序算法实现简单,但是速度相对较慢;有些排序算法速度较快,但是却有些复杂;有些算法适合于随机排列的输入,有些算法更适合于基本有序的初始排列。
本文介绍了常见的十种排序算法,给出了 Java 的代码实现(为了方便说明,后面将以整型数据为例进行讲解,其他类型的数据排序算法与此类似),并对这些算法进行了简单地分析和总结,为每种算法的执行过程添加了动图,方便大家直观的感受,感兴趣的小伙伴可以看看。
冒泡排序
基本思想
将相邻关键字进行比较,较大的下沉,较小的上浮(假设要求升序排列,后算的算法相同),一趟过后最大的关键字会到末尾,借着按照同样的方式进行第二轮比较,次大的关键字会到倒数第二位,以此类推。
在排序的过程中,较小的关键字形如水里的泡泡一样,逐渐向上漂浮,而较大的关键字像石头一样不断下沉,因此这种算法被形象地称为冒泡排序。
动图展示
代码实现
public void bubbleSort(int[] array) {
for(int i = 0; i < array.length - 1; i++) {
// 标志位,一旦发生置为 false ,一轮冒泡结束后如果没有发生交换意味着已经有序,不必继续排序
boolean flag = true;
for (int j = 0; j < array.length - 1 - i; j++) {
if(array[j] > array[j + 1]) {
flag = false;
int tmp = array[j]; // 冒泡(交换)
array[j] = array[j + 1];
array[j + 1] = tmp;
}
}
if(flag) break;
}
}
算法分析
-
时间复杂度: O ( n 2 ) O(n^2) O(n2) ,如果初始数组正序,则不需要进行元素的交换操作,只需要进行一轮比较;如果初始数组逆序,则需要进行 n-1 轮排序,需要进行 n*(n-1)/2 次比较。因此总的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
选择排序
基本思想
每一趟在 n-i+1 (i=1,2,…,n-1) 个记录中选取关键字最小的元素作为有序序列的第 i 个元素,重复 n-1 趟。
动图展示
代码实现
public void selectionSort(int[] array) {
for(int i = 0; i < array.length - 1; i++) {
int index = i; // 最小元素的下标
for (int j = i + 1; j < array.length; j++) {
if(array[j] < array[index]) {
index = j;
}
}
int tmp = array[index]; // 交换第一个元素和最小元素
array[index] = array[i];
array[i] = tmp;
}
}
算法分析
-
时间复杂度: O ( n 2 ) O(n^2) O(n2) ,在 n 个关键字中选出最小值,至少进行 n-1 次比较,一共需要进行 n(n-1)/2 次比较,总的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
插入排序
基本思想
在要排序的一组数中,假定前 n-1 个数已经排好序,现在将第 n 个数插到前面的有序数列中,使得这 n 个数也是排好顺序的。如此反复循环,直到全部排好顺序。
动图展示
代码实现
public void insertionSort(int[] array) {
for (int i = 0; i < array.length; i++) {
for (int j = i; j > 0; j--) {
if(array[j] < array[j - 1]) {
int tmp = array[j];
array[j] = array[j - 1];
array[j - 1] = tmp;
} else {
break; // 如果与前一个比较更大,意味着当前元素已经有序
}
}
}
}
算法分析
-
时间复杂度: O ( n 2 ) O(n^2) O(n2) ,若待排序记录原本正序,则只需要比较 n-1 次,不需要移动元素;若待排序记录为逆序,此时需要比较 (n+2)(n-1)/2 次,元素需要移动 (n+4)(n-1)/2 次。因此插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
希尔排序
基本思想
希尔排序又称“缩小增量排序”,它也是一种属于插入排序类的方法。
从对直接插入排序的分析来看,如果序列基本有序,那么元素移动的次数就会大大减少,当 n 值很小时,直接插入排序的效率也会非常高,基于此,希尔排序对直接插入排序进行了改进,其基本思想如下:
在要排序的一组数中,根据某一增量分为若干子序列,并对子序列分别进行插入排序。然后逐渐将增量减小,并重复上述过程。直至增量为1,此时数据序列基本有序,最后进行插入排序。
动图展示
代码实现
public void shellSort(int[] array) {
// 设置步长(增量),初始为数组长度的一半
int length = array.length;
for(int step = length / 2; step > 0; step /= 2) {
for (int i = step; i < length; i++) {
for (int j = i; j >= step ; j -= step) {
if(array[j] < array[j - step]) {
int tmp = array[j];
array[j] = array[j - step];
array[j - step] = tmp;
} else {
break;
}
}
}
}
}
算法分析
-
时间复杂度: O ( n 3 2 ) O(n^\frac32) O(n23) ,希尔排序的分析是一个复杂的问题,因为它的时间是选取的增量序列的函数,这涉及到了数学上的一些尚未解决的难题,研究和数据表明希尔排序的时间复杂度约为 O ( n 3 2 ) O(n^\frac32) O(n23) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
归并排序
基本思想
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
分治法中的“分”和“治”是两个过程,“分”是不断地将序列分成从中间两段,直至最后子序列只有一个元素;“治”是将一个个的子序列进行排序得到有序的子序列,再将子序列合并得到完全有序的序列,即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
动图展示
代码实现
/**
* 归并排序
* @param array 待排序数组
*/
public void mergeSort(int[] array) {
merge_sort(array, 0, array.length - 1);
}
/**
* 归并排序(分)
* @param array 待排序数组
* @param left 分后数组第一个元素下标
* @param right 分后数组最后一个元素下标
*/
private void merge_sort(int[] array, int left, int right) {
if(left < right) {
int mid = (left + right) / 2;
merge_sort(array, left, mid);
merge_sort(array, mid + 1, right);
merge(array, left, mid, right);
}
}
/**
* 归并排序(治)
* @param array 待排序数组
* @param left 分后数组第一个元素下标
* @param mid 分开的位置
* @param right 分后数组最后一个元素下标
*/
private void merge(int[] array, int left, int mid, int right) {
// 临时数组,保存归并后的结果
int[] tmp = new int[right - left + 1];
int i, j, k;
// 合并两个有序数组
for (i = left, j = mid + 1, k = 0; i <= mid && j <= right;) {
if(array[i] < array[j]) {
tmp[k++] = array[i++];
} else {
tmp[k++] = array[j++];
}
}
while (i <= mid) {
tmp[k++] = array[i++];
}
while (j <= right) {
tmp[k++] = array[j++];
}
// 修改原数组
for (int l = 0; l < k; l++) {
array[left + l] = tmp[l];
}
}
算法分析
-
时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) ,当有 n 个记录时,整个归并排序的过程需要进行 ⌈ l o g 2 n ⌉ \lceil{log_2n}\rceil ⌈log2n⌉ 趟,每一轮归并,比较次数不超过 n,元素移动次数都是 n,因此归并排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) 。
-
空间复杂度: O ( n ) O(n) O(n) ,实现归并排序需要和待排序记录等数量的辅助空间。
快速排序
基本思想
快速排序是对冒泡排序的一种改进,它同样采用了分治的策略,算法思想如下:
-
先从数列中取出一个数作为基准数(pivot)。
-
分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
-
再对左右区间重复第二步,直到各区间只有一个数。
快速排序的难点在于如何将基准数放在正确的位置上,常见方法有两种:挖坑法和指针交换法,两种方法的原理几乎相同,只是代码层面有一点不同,挖坑法的具体演示如下图。
动图展示
代码实现
/**
* 快速排序
* @param array 待排序数组
*/
public void quickSort(int[] array) {
quick_sort(array, 0, array.length - 1);
}
private void quick_sort(int[] array, int left, int right) {
if(left < right) {
// 设置基准值(这里选择了第一个元素)
int pivot = array[left];
// 挖坑法找基准值的位置
int i = left, j = right;
while (i < j) {
// 从右往左找第一个小于基准值的数
while (i < j && array[j] >= pivot) {
j--;
}
if(i < j) {
array[i] = array[j];
i++;
}
// 从左往右找第一个大于基准值的数
while (i < j && array[i] <= pivot) {
i++;
}
if(i < j) {
array[j] = array[i];
j--;
}
}
// 填基准值
array[i] = pivot;
// 递归调用
quick_sort(array, left, i - 1);
quick_sort(array, i + 1, right);
}
}
算法分析
-
时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) ,计算过程需要用到一点高数的知识,这里不再推导啦,可以参看这篇博客,或者清华大学《数据结构》C语言版第276页。
-
空间复杂度: O ( l o g 2 n ) O(log_2n) O(log2n) ,每次递归传参 left 和 right,平均栈深度为 O ( l o g 2 n ) O(log_2n) O(log2n) 。
堆排序
基本思想
堆排序是利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
动图展示
代码实现
/**
* 堆排序
* @param array 待排序数组
*/
public void heapSort(int[] array) {
int length = array.length;
// 建立大顶堆
buildMaxHeap(array, length);
for (int i = length - 1; i > 0; i--) {
// 排序(交换堆顶元素与最后一个元素)
int tmp = array[0];
array[0] = array[i];
array[i] = tmp;
length--;
// 调整堆顶元素位置,维持大顶堆结构
adjustHeap(array, 0, length);
}
}
/**
* 建立大顶堆
* @param array 待排序数组
* @param length 数组长度
*/
private void buildMaxHeap(int[] array, int length) {
for (int i = (length - 1) / 2; i >= 0; i--) {
adjustHeap(array, i, length);
}
}
/**
* 调整堆定元素,维持堆结构
* @param array 待排序数组
* @param index (临时)堆顶元素下标
* @param length 还未排序的数组长度
*/
private void adjustHeap(int[] array, int index, int length) {
int leftChild = index * 2 + 1;
int rightChild = index * 2 + 2;
// 左孩子比较大
if(leftChild < length && array[leftChild] > array[index]) {
// 交换
int tmp = array[leftChild];
array[leftChild] = array[index];
array[index] = tmp;
// 递归比较
adjustHeap(array, leftChild, length);
}
// 右孩子比较大
if(rightChild < length && array[rightChild] > array[index]) {
// 交换
int tmp = array[rightChild];
array[rightChild] = array[index];
array[index] = tmp;
// 递归比较
adjustHeap(array, rightChild, length);
}
}
算法分析
-
时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) ,堆排序的时间复杂度主要在初始化堆过程和每次选取最大数后重新建堆的过程,建堆的时间复杂度是 O ( n ) O(n) O(n) ,调整堆的时间复杂度是 O ( l o g 2 n ) O(log_2n) O(log2n) ,调用了n-1次,故堆排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
计数排序
基本思想
计数排序不是一个基于比较的排序算法。
计数排序的基本思想是对于给定的输入序列中的每一个元素 x,确定该序列中值小于 x 的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。一旦获得该信息,就可以将 x 直接存放到最终的输出序列的正确位置上。例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
动图展示
代码实现
public void countingSort(int[] array) {
// 元素最值
int maxValue = Integer.MIN_VALUE;
for(int value : array) {
maxValue = Math.max(maxValue, value);
}
// 计数
int[] bucket = new int[maxValue + 1];
for (int value : array) {
bucket[value]++;
}
// 排序
for (int i = 0, j = 0; i < bucket.length; i++) {
while (bucket[i] > 0) {
array[j++] = i;
bucket[i]--;
}
}
}
算法分析
-
时间复杂度: O ( n + k ) O(n+k) O(n+k) ,k 为整数范围。计数排序的优势在于在对一定范围内的整数排序时,可以到达线性的时间复杂度,但是当 k 很大时,计数排序的优势就不那么明显了。
-
空间复杂度: O ( k ) O(k) O(k) ,保存 k 范围内的整数的计数值。
桶排序
基本思想
桶排序是计数排序的升级版,它的基本思想是将数组分到有限数量的桶里,每个桶再进行排序(可以使用别的排序算法或是以递归方式继续使用桶排序)。
将数组分配到有限数量的桶里需要一个高效的分配方法,尽可能地做到:
- 在额外空间充足的情况下,尽量增大桶的数量
- 将输入的 N 个数据均匀的分配到 K 个桶中
对于桶中元素的排序,选择何种比较排序算法对于性能的影响同样重要。
动图展示
代码实现
public void bucketSort(int[] array) {
// 元素最值
int maxValue = Integer.MIN_VALUE;
int minValue = Integer.MAX_VALUE;
for(int value : array) {
minValue = Math.min(minValue, value);
maxValue = Math.max(maxValue, value);
}
// 桶的数量
int bucketCount = (maxValue - minValue) / array.length + 1;
// 所有桶
ArrayList<ArrayList<Integer>> buckets = new ArrayList<>();
for(int i = 0; i < bucketCount; i++) {
buckets.add(new ArrayList<>());
}
// 将元素放入桶中
for (int value : array) {
// 映射关系
int index = (value - minValue) / array.length;
buckets.get(index).add(value);
}
// 对每个桶进行排序
for (int i = 0, k = 0; i < bucketCount; i++) {
// 这里的排序算法可以自己选择,这里就使用集合框架的排序算法
Collections.sort(buckets.get(i));
for(Integer integer: buckets.get(i)) {
array[k++] = integer;
}
}
}
算法分析
-
时间复杂度: O ( n + c ) O(n+c) O(n+c) ,其中 c = n × ( l o g 2 n m ) c=n×(log_2{\frac{n}{m}}) c=n×(log2mn) ,m 是桶的数量。
-
空间复杂度: O ( n + m ) O(n+m) O(n+m) 。
基数排序
基本思想
基数排序是一种基于多关键字排序的的方法,什么是多关键字?举个栗子:
一副扑克牌(除去大小王),每张牌的位置由两个因素决定,一个是花色,一个是面值。同花色时面值小的在上,面值大的在下;不同花色,无论面值多少,先后顺序总是红桃♥、方片♦、梅花♣和黑桃♠ 。这里花色和面值就是多关键字。
现在这副牌已经洗乱了,现在你想给它排好序,就像刚买的时候一样,这个时候你会怎么做?你有两种做法:
- 按照花色分成四份,每份按照面值排序,最后将四分排序好的牌按花色排序即可;
- 按照面值分成13份,每份按照花色排序,然后按照面值顺序取13张牌,直至所有花色取完即可。
这里两种方式对应着基数排序的两种方法,第一种是最高位优先(Most Significant Digit first)法,简称 MSD 法;第二种是最低位优先(Least Significant Digit first)法,简称 LSD 法。
MSD 法:先按最高位关键字排序分组,同一组中记录,关键字相等,再对各组按次高位关键字排序分成子组,之后,对后面的关键字继续这样的排序分组,直到按最低位关键字对各子组排序后。再将各组连接起来,便得到一个有序序列。
LSD 法:先从最低位关键字开始排序,再对高一位关键字进行排序,依次重复,直到对第一个关键字排序后便得到一个有序序列。
动图展示
代码实现
public void radixSort(int[] array) {
// 元素最大值
int maxValue = Integer.MIN_VALUE;
for(int value : array) {
maxValue = Math.max(maxValue, value);
}
// 最大值的位数
int maxValueDigit = 0;
while (maxValue != 0) {
maxValue /= 10;
maxValueDigit++;
}
int mod = 10, div = 1;
// 所有桶
ArrayList<ArrayList<Integer>> buckets = new ArrayList<>();
for(int i = 0; i < 10; i++) {
buckets.add(new ArrayList<>());
}
// 排序
for (int i = 0; i < maxValueDigit; i++, mod *= 10, div *= 10) {
for (int value : array) {
int index = (value % mod) / div;
buckets.get(index).add(value);
}
for (int j = 0, k = 0; j < buckets.size(); j++) {
for (Integer integer : buckets.get(j)) {
array[k++] = integer;
}
buckets.get(j).clear();
}
}
}
算法分析
-
时间复杂度: O ( n × ( n + r ) ) O(n×(n+r)) O(n×(n+r)) ,其中待排序列为 n 个记录,d 个关键字,关键码的取值范围为 r,一趟分配时间复杂度为 O ( n ) O(n) O(n),一趟收集时间复杂度为 O ( r ) O(r) O(r),共进行 d 趟分配和收集。
-
空间复杂度: O ( r × d ) O(r×d) O(r×d) 。
性质汇总
排序算法 | 平均时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|
冒泡排序 | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 不稳定 |
插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
希尔排序 | O ( n 3 2 ) O(n^\frac32) O(n23) | O ( 1 ) O(1) O(1) | 不稳定 |
归并排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n ) O(n) O(n) | 稳定 |
快速排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( l o g 2 n ) O(log_2n) O(log2n) | 不稳定 |
堆排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( 1 ) O(1) O(1) | 不稳定 |
计数排序 | O ( n + k ) O(n+k) O(n+k) | O ( k ) O(k) O(k) | 稳定 |
桶排序 | O ( n + c ) O(n+c) O(n+c) | O ( n + m ) O(n+m) O(n+m) | 稳定 |
基数排序 | O ( n × ( n + r ) ) O(n×(n+r)) O(n×(n+r)) | O ( r × d ) O(r×d) O(r×d) | 稳定 |
>上一篇:常见七大查找算法
>下一篇: