本文主要详细讲述常见的八种排序算法的思想、实现以及复杂度。java
:notebook: 本文已归档到:「blog」git
:keyboard: 本文中的示例代码已归档到:「algorithm-tutorial」github
冒泡排序是一种交换排序。算法
什么是交换排序呢?shell
交换排序:两两比较待排序的关键字,并交换不知足次序要求的那对数,直到整个表都知足次序要求为止。编程
它重复地走访过要排序的数列,一次比较两个元素,若是他们的顺序错误就把他们交换过来。走访数列的工做是重复地进行直到没有再须要交换,也就是说该数列已经排序完成。数组
这个算法的名字由来是由于越小的元素会经由交换慢慢“浮”到数列的顶端,故名。编程语言
假设有一个大小为 N 的无序序列。冒泡排序就是要每趟排序过程当中经过两两比较,找到第 i 个小(大)的元素,将其往上排。ide
以上图为例,演示一下冒泡排序的实际流程:post
假设有一个无序序列 { 4. 3. 1. 2, 5 }
至此,全部元素已经有序,排序结束。
要将以上流程转化为代码,咱们须要像机器同样去思考,否则编译器可看不懂。
核心代码
public void bubbleSort(int[] list) {
int temp = 0; // 用来交换的临时数
// 要遍历的次数
for (int i = 0; i < list.length - 1; i++) {
// 从后向前依次的比较相邻两个数的大小,遍历一次后,把数组中第i小的数放在第i个位置上
for (int j = list.length - 1; j > i; j--) {
// 比较相邻的元素,若是前面的数大于后面的数,则交换
if (list[j - 1] > list[j]) {
temp = list[j - 1];
list[j - 1] = list[j];
list[j] = temp;
}
}
System.out.format("第 %d 趟:\t", i);
printAll(list);
}
}
复制代码
冒泡排序算法的性能
参数 | 结果 |
---|---|
排序类别 | 交换排序 |
排序方法 | 冒泡排序 |
时间复杂度平均状况 | O(N2) |
时间复杂度最坏状况 | O(N3) |
时间复杂度最好状况 | O(N) |
空间复杂度 | O(1) |
稳定性 | 稳定 |
复杂性 | 简单 |
若文件的初始状态是正序的,一趟扫描便可完成排序。所需的关键字比较次数 C 和记录移动次数 M 均达到最小值:Cmin = N - 1, Mmin = 0。因此,冒泡排序最好时间复杂度为 O(N)。
若初始文件是反序的,须要进行 N -1 趟排序。每趟排序要进行 N - i 次关键字的比较(1 ≤ i ≤ N - 1),且每次比较都必须移动记录三次来达到交换记录位置。在这种状况下,比较和移动次数均达到最大值:
Cmax = N(N-1)/2 = O(N2)
Mmax = 3N(N-1)/2 = O(N2)
冒泡排序的最坏时间复杂度为 O(N2)。
所以,冒泡排序的平均时间复杂度为 O(N2)。
总结起来,其实就是一句话:当数据越接近正序时,冒泡排序性能越好。
冒泡排序就是把小的元素往前调或者把大的元素日后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。
因此相同元素的先后顺序并无改变,因此冒泡排序是一种稳定排序算法。
对冒泡排序常见的改进方法是加入标志性变量 exchange,用于标志某一趟排序过程当中是否有数据交换。
若是进行某一趟排序时并无进行数据交换,则说明全部数据已经有序,可当即结束排序,避免没必要要的比较过程。
核心代码
// 对 bubbleSort 的优化算法
public void bubbleSort_2(int[] list) {
int temp = 0; // 用来交换的临时数
boolean bChange = false; // 交换标志
// 要遍历的次数
for (int i = 0; i < list.length - 1; i++) {
bChange = false;
// 从后向前依次的比较相邻两个数的大小,遍历一次后,把数组中第i小的数放在第i个位置上
for (int j = list.length - 1; j > i; j--) {
// 比较相邻的元素,若是前面的数大于后面的数,则交换
if (list[j - 1] > list[j]) {
temp = list[j - 1];
list[j - 1] = list[j];
list[j] = temp;
bChange = true;
}
}
// 若是标志为false,说明本轮遍历没有交换,已是有序数列,能够结束排序
if (false == bChange)
break;
System.out.format("第 %d 趟:\t", i);
printAll(list);
}
}
复制代码
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
快速排序是一种交换排序。
快速排序由 C. A. R. Hoare 在 1962 年提出。
它的基本思想是:
经过一趟排序将要排序的数据分割成独立的两部分:分割点左边都是比它小的数,右边都是比它大的数。
而后再按此方法对这两部分数据分别进行快速排序,整个排序过程能够递归进行,以此达到整个数据变成有序序列。
详细的图解每每比大堆的文字更有说明力,因此直接上图:
上图中,演示了快速排序的处理过程:
核心代码
public int division(int[] list, int left, int right) {
// 以最左边的数(left)为基准
int base = list[left];
while (left < right) {
// 从序列右端开始,向左遍历,直到找到小于base的数
while (left < right && list[right] >= base)
right--;
// 找到了比base小的元素,将这个元素放到最左边的位置
list[left] = list[right];
// 从序列左端开始,向右遍历,直到找到大于base的数
while (left < right && list[left] <= base)
left++;
// 找到了比base大的元素,将这个元素放到最右边的位置
list[right] = list[left];
}
// 最后将base放到left位置。此时,left位置的左侧数值应该都比left小;
// 而left位置的右侧数值应该都比left大。
list[left] = base;
return left;
}
private void quickSort(int[] list, int left, int right) {
// 左下标必定小于右下标,不然就越界了
if (left < right) {
// 对数组进行分割,取出下次分割的基准标号
int base = division(list, left, right);
System.out.format("base = %d:\t", list[base]);
printPart(list, left, right);
// 对“基准标号“左侧的一组数值进行递归的切割,以致于将这些数值完整的排序
quickSort(list, left, base - 1);
// 对“基准标号“右侧的一组数值进行递归的切割,以致于将这些数值完整的排序
quickSort(list, base + 1, right);
}
}
复制代码
快速排序算法的性能
参数 | 结果 |
---|---|
排序类别 | 交换排序 |
排序方法 | 快速排序 |
时间复杂度平均状况 | O(Nlog2N) |
时间复杂度最坏状况 | O(N2) |
时间复杂度最好状况 | O(Nlog2N) |
空间复杂度 | O(Nlog2N) |
稳定性 | 不稳定 |
复杂性 | 较复杂 |
当数据有序时,以第一个关键字为基准分为两个子序列,前一个子序列为空,此时执行效率最差。
而当数据随机分布时,以第一个关键字为基准分为两个子序列,两个子序列的元素个数接近相等,此时执行效率最好。
因此,数据越随机分布时,快速排序性能越好;数据越接近有序,快速排序性能越差。
快速排序在每次分割的过程当中,须要 1 个空间存储基准值。而快速排序的大概须要 Nlog2N 次的分割处理,因此占用空间也是 Nlog2N 个。
在快速排序中,相等元素可能会由于分区而交换顺序,因此它是不稳定的算法。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
直接插入排序是一种最简单的插入排序。
插入排序:每一趟将一个待排序的记录,按照其关键字的大小插入到有序队列的合适位置里,知道所有插入完成。
在讲解直接插入排序以前,先让咱们脑补一下咱们打牌的过程。
以上的过程,其实就是典型的直接插入排序,每次将一个新数据插入到有序队列中的合适位置里。
很简单吧,接下来,咱们要将这个算法转化为编程语言。
假设有一组无序序列 R0, R1, ... , RN-1。
因此咱们须要将 Ri 和 R0 ~ Ri-1 进行比较,肯定要插入的合适位置。这就须要一个内部循环,咱们通常是从后往前比较,即从下标 i-1 开始向 0 进行扫描。
核心代码
public void insertSort(int[] list) {
// 打印第一个元素
System.out.format("i = %d:\t", 0);
printPart(list, 0, 0);
// 第1个数确定是有序的,从第2个数开始遍历,依次插入有序序列
for (int i = 1; i < list.length; i++) {
int j = 0;
int temp = list[i]; // 取出第i个数,和前i-1个数比较后,插入合适位置
// 由于前i-1个数都是从小到大的有序序列,因此只要当前比较的数(list[j])比temp大,就把这个数后移一位
for (j = i - 1; j >= 0 && temp < list[j]; j--) {
list[j + 1] = list[j];
}
list[j + 1] = temp;
System.out.format("i = %d:\t", i);
printPart(list, 0, i);
}
}
复制代码
直接插入排序的算法性能
参数 | 结果 |
---|---|
排序类别 | 插入排序 |
排序方法 | 直接插入排序 |
时间复杂度平均状况 | O(N2) |
时间复杂度最坏状况 | O(N2) |
时间复杂度最好状况 | O(N) |
空间复杂度 | O(1) |
稳定性 | 稳定 |
复杂性 | 简单 |
当数据正序时,执行效率最好,每次插入都不用移动前面的元素,时间复杂度为 O(N)。
当数据反序时,执行效率最差,每次插入都要前面的元素后移,时间复杂度为 O(N2)。
因此,数据越接近正序,直接插入排序的算法性能越好。
由直接插入排序算法可知,咱们在排序过程当中,须要一个临时变量存储要插入的值,因此空间复杂度为 1 。
直接插入排序的过程当中,不须要改变相等数值元素的位置,因此它是稳定的算法。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
希尔(Shell)排序又称为缩小增量排序,它是一种插入排序。它是直接插入排序算法的一种威力增强版。
该方法因 DL.Shell 于 1959 年提出而得名。
希尔排序的基本思想是:
把记录按步长 gap 分组,对每组记录采用直接插入排序方法进行排序。 随着步长逐渐减少,所分红的组包含的记录愈来愈多,当步长的值减少到 1 时,整个数据合成为一组,构成一组有序记录,则完成排序。
咱们来经过演示图,更深刻的理解一下这个过程。
在上面这幅图中:
初始时,有一个大小为 10 的无序序列。
须要注意一下的是,图中有两个相等数值的元素 5 和 5 。咱们能够清楚的看到,在排序过程当中,两个元素位置交换了。
因此,希尔排序是不稳定的算法。
核心代码
public void shellSort(int[] list) {
int gap = list.length / 2;
while (1 <= gap) {
// 把距离为 gap 的元素编为一个组,扫描全部组
for (int i = gap; i < list.length; i++) {
int j = 0;
int temp = list[i];
// 对距离为 gap 的元素组进行排序
for (j = i - gap; j >= 0 && temp < list[j]; j = j - gap) {
list[j + gap] = list[j];
}
list[j + gap] = temp;
}
System.out.format("gap = %d:\t", gap);
printAll(list);
gap = gap / 2; // 减少增量
}
}
复制代码
希尔排序的算法性能
参数 | 结果 |
---|---|
排序类别 | 插入排序 |
排序方法 | 希尔排序 |
时间复杂度平均状况 | O(Nlog2N) |
时间复杂度最坏状况 | O(N1.5) |
时间复杂度最好状况 | |
空间复杂度 | O(1) |
稳定性 | 不稳定 |
复杂性 | 较复杂 |
步长的选择是希尔排序的重要部分。只要最终步长为 1 任何步长序列均可以工做。
算法最开始以必定的步长进行排序。而后会继续以必定步长进行排序,最终算法以步长为 1 进行排序。当步长为 1 时,算法变为插入排序,这就保证了数据必定会被排序。
Donald Shell 最初建议步长选择为 N/2 而且对步长取半直到步长达到 1。虽然这样取能够比 O(N2)类的算法(插入排序)更好,但这样仍然有减小平均时间和最差时间的余地。可能希尔排序最重要的地方在于当用较小步长排序后,之前用的较大步长仍然是有序的。好比,若是一个数列以步长 5 进行了排序而后再以步长 3 进行排序,那么该数列不只是以步长 3 有序,并且是以步长 5 有序。若是不是这样,那么算法在迭代过程当中会打乱之前的顺序,那就不会以如此短的时间完成排序了。
已知的最好步长序列是由 Sedgewick 提出的(1, 5, 19, 41, 109,...),该序列的项来自这两个算式。
这项研究也代表“比较在希尔排序中是最主要的操做,而不是交换。”用这样步长序列的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,可是在涉及大量数据时希尔排序仍是比快速排序慢。
由上文的希尔排序算法演示图便可知,希尔排序中相等数据可能会交换位置,因此希尔排序是不稳定的算法。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
简单选择排序是一种选择排序。
选择排序:每趟从待排序的记录中选出关键字最小的记录,顺序放在已排序的记录序列末尾,直到所有排序结束为止。
如图所示,每趟排序中,将当前**第 i 小的元素放在位置 i **上。
核心代码
简单选择排序算法的性能
参数 | 结果 |
---|---|
排序类别 | 选择排序 |
排序方法 | 简单选择排序 |
时间复杂度平均状况 | O(N2) |
时间复杂度最坏状况 | O(N2) |
时间复杂度最好状况 | O(N2) |
空间复杂度 | O(1) |
稳定性 | 不稳定 |
复杂性 | 简单 |
简单选择排序的比较次数与序列的初始排序无关。 假设待排序的序列有 N 个元素,则**比较次数老是 N (N - 1) / 2 **。
而移动次数与序列的初始排序有关。当序列正序时,移动次数最少,为 0.
当序列反序时,移动次数最多,为 3N (N - 1) / 2。
因此,综合以上,简单排序的时间复杂度为 O(N2)。
简单选择排序须要占用一个临时空间,在交换数值时使用。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
在介绍堆排序以前,首先须要说明一下,堆是个什么玩意儿。
堆是一棵顺序存储的彻底二叉树。
其中每一个结点的关键字都不大于其孩子结点的关键字,这样的堆称为小根堆。 其中每一个结点的关键字都不小于其孩子结点的关键字,这样的堆称为大根堆。 举例来讲,对于 n 个元素的序列 {R0, R1, ... , Rn} 当且仅当知足下列关系之一时,称之为堆:
其中 i=1,2,…,n/2 向下取整;
如上图所示,序列 R{3, 8,15, 31, 25} 是一个典型的小根堆。
堆中有两个父结点,元素 3 和元素 8。
元素 3 在数组中以 R[0] 表示,它的左孩子结点是 R[1],右孩子结点是 R[2]。
元素 8 在数组中以 R[1] 表示,它的左孩子结点是 R[3],右孩子结点是 R[4],它的父结点是 R[0]。能够看出,它们知足如下规律:
设当前元素在数组中以 R[i] 表示,那么,
以上思想可概括为两个操做:
当输出完最后一个元素后,这个数组已是按照从小到大的顺序排列了。
先经过详细的实例图来看一下,如何构建初始堆。
设有一个无序序列 { 1, 3,4, 5, 2, 6, 9, 7, 8, 0 }。
构造了初始堆后,咱们来看一下完整的堆排序处理:
仍是针对前面提到的无序序列 { 1,3, 4, 5, 2, 6, 9, 7, 8, 0 } 来加以说明。
相信,经过以上两幅图,应该能很直观的演示堆排序的操做处理。
核心代码
public void HeapAdjust(int[] array, int parent, int length) {
int temp = array[parent]; // temp保存当前父节点
int child = 2 * parent + 1; // 先得到左孩子
while (child < length) {
// 若是有右孩子结点,而且右孩子结点的值大于左孩子结点,则选取右孩子结点
if (child + 1 < length && array[child] < array[child + 1]) {
child++;
}
// 若是父结点的值已经大于孩子结点的值,则直接结束
if (temp >= array[child])
break;
// 把孩子结点的值赋给父结点
array[parent] = array[child];
// 选取孩子结点的左孩子结点,继续向下筛选
parent = child;
child = 2 * child + 1;
}
array[parent] = temp;
}
public void heapSort(int[] list) {
// 循环创建初始堆
for (int i = list.length / 2; i >= 0; i--) {
HeapAdjust(list, i, list.length);
}
// 进行n-1次循环,完成排序
for (int i = list.length - 1; i > 0; i--) {
// 最后一个元素和第一元素进行交换
int temp = list[i];
list[i] = list[0];
list[0] = temp;
// 筛选 R[0] 结点,获得i-1个结点的堆
HeapAdjust(list, 0, i);
System.out.format("第 %d 趟: \t", list.length - i);
printPart(list, 0, list.length - 1);
}
}
复制代码
堆排序算法的整体状况
参数 | 结果 |
---|---|
排序类别 | 选择排序 |
排序方法 | 堆排序 |
时间复杂度平均状况 | O(nlog2n) |
时间复杂度最坏状况 | O(nlog2n) |
时间复杂度最好状况 | O(nlog2n) |
空间复杂度 | O(1) |
稳定性 | 不稳定 |
复杂性 | 较复杂 |
堆的存储表示是顺序的。由于堆所对应的二叉树为彻底二叉树,而彻底二叉树一般采用顺序存储方式。
当想获得一个序列中第 k 个最小的元素以前的部分排序序列,最好采用堆排序。
由于堆排序的时间复杂度是 O(n+klog2n),若 k ≤ n/log2n,则可获得的时间复杂度为 O(n)。
堆排序是一种不稳定的排序方法。
由于在堆的调整过程当中,关键字进行比较和交换所走的是该结点到叶子结点的一条路径,
所以对于相同的关键字就可能出现排在后面的关键字被交换到前面来的状况。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
归并排序是创建在归并操做上的一种有效的排序算法,该算法是采用**分治法(Divide and Conquer)**的一个很是典型的应用。
将已有序的子序列合并,获得彻底有序的序列;即先使每一个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
将待排序序列 R[0...n-1] 当作是 n 个长度为 1 的有序序列,将相邻的有序表成对归并,获得 n/2 个长度为 2 的有序表;将这些有序序列再次归并,获得 n/4 个长度为 4 的有序序列;如此反复进行下去,最后获得一个长度为 n 的有序序列。
综上可知:
归并排序其实要作两件事:
咱们先来考虑第二步,如何合并?
在每次合并过程当中,都是对两个有序的序列段进行合并,而后排序。
这两个有序序列段分别为 R[low, mid] 和 R[mid+1, high]。
先将他们合并到一个局部的暂存数组R2 中,带合并完成后再将 R2 复制回 R 中。
为了方便描述,咱们称 R[low, mid] 第一段,R[mid+1, high] 为第二段。
每次从两个段中取出一个记录进行关键字的比较,将较小者放入 R2 中。最后将各段中余下的部分直接复制到 R2 中。
通过这样的过程,R2 已是一个有序的序列,再将其复制回 R 中,一次合并排序就完成了。
核心代码
public void Merge(int[] array, int low, int mid, int high) {
int i = low; // i是第一段序列的下标
int j = mid + 1; // j是第二段序列的下标
int k = 0; // k是临时存放合并序列的下标
int[] array2 = new int[high - low + 1]; // array2是临时合并序列
// 扫描第一段和第二段序列,直到有一个扫描结束
while (i <= mid && j <= high) {
// 判断第一段和第二段取出的数哪一个更小,将其存入合并序列,并继续向下扫描
if (array[i] <= array[j]) {
array2[k] = array[i];
i++;
k++;
} else {
array2[k] = array[j];
j++;
k++;
}
}
// 若第一段序列还没扫描完,将其所有复制到合并序列
while (i <= mid) {
array2[k] = array[i];
i++;
k++;
}
// 若第二段序列还没扫描完,将其所有复制到合并序列
while (j <= high) {
array2[k] = array[j];
j++;
k++;
}
// 将合并序列复制到原始序列中
for (k = 0, i = low; i <= high; i++, k++) {
array[i] = array2[k];
}
}
复制代码
掌握了合并的方法,接下来,让咱们来了解如何分解。
在某趟归并中,设各子表的长度为 gap,则归并前 R[0...n-1] 中共有 n/gap 个有序的子表:R[0...gap-1]
, R[gap...2*gap-1]
, ... , R[(n/gap)*gap ... n-1]
。
调用 Merge 将相邻的子表归并时,必须对表的特殊状况进行特殊处理。
若子表个数为奇数,则最后一个子表无须和其余子表归并(即本趟处理轮空):若子表个数为偶数,则要注意到最后一对子表中后一个子表区间的上限为 n-1。
核心代码
public void MergePass(int[] array, int gap, int length) {
int i = 0;
// 归并gap长度的两个相邻子表
for (i = 0; i + 2 * gap - 1 < length; i = i + 2 * gap) {
Merge(array, i, i + gap - 1, i + 2 * gap - 1);
}
// 余下两个子表,后者长度小于gap
if (i + gap - 1 < length) {
Merge(array, i, i + gap - 1, length - 1);
}
}
public int[] sort(int[] list) {
for (int gap = 1; gap < list.length; gap = 2 * gap) {
MergePass(list, gap, list.length);
System.out.print("gap = " + gap + ":\t");
this.printAll(list);
}
return list;
}
复制代码
归并排序算法的性能
参数 | 结果 |
---|---|
排序类别 | 归并排序 |
排序方法 | 归并排序 |
时间复杂度平均状况 | O(nlog2n) |
时间复杂度最坏状况 | O(nlog2n) |
时间复杂度最好状况 | O(nlog2n) |
空间复杂度 | O(n) |
稳定性 | 稳定 |
复杂性 | 较复杂 |
归并排序的形式就是一棵二叉树,它须要遍历的次数就是二叉树的深度,而根据彻底二叉树的能够得出它的时间复杂度是 O(n*log2n)。
由前面的算法说明可知,算法处理过程当中,须要一个大小为 n 的临时存储空间用以保存合并序列。
在归并排序中,相等的元素的顺序不会改变,因此它是稳定的算法。
若从空间复杂度来考虑:首选堆排序,其次是快速排序,最后是归并排序。
若从稳定性来考虑,应选取归并排序,由于堆排序和快速排序都是不稳定的。
若从平均状况下的排序速度考虑,应该选择快速排序。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。
基数排序与本系列前面讲解的七种排序方法都不一样,它不须要比较关键字的大小。
它是根据关键字中各位的值,经过对排序的 N 个元素进行若干趟“分配”与“收集”来实现排序的。
不妨经过一个具体的实例来展现一下,基数排序是如何进行的。
设有一个初始序列为: R {50, 123, 543, 187, 49, 30,0, 2, 11, 100}。
咱们知道,任何一个阿拉伯数,它的各个位数上的基数都是以 0~9 来表示的。
因此咱们不妨把 0~9 视为 10 个桶。
咱们先根据序列的个位数的数字来进行分类,将其分到指定的桶中。例如:R[0] = 50,个位数上是 0,将这个数存入编号为 0 的桶中。
分类后,咱们在从各个桶中,将这些数按照从编号 0 到编号 9 的顺序依次将全部数取出来。
这时,获得的序列就是个位数上呈递增趋势的序列。
按照个位数排序: {50, 30, 0, 100, 11, 2, 123,543, 187, 49}。
接下来,能够对十位数、百位数也按照这种方法进行排序,最后就能获得排序完成的序列。
基数排序的性能
参数 | 结果 |
---|---|
排序类别 | 基数排序 |
排序方法 | 基数排序 |
时间复杂度平均状况 | O(d(n+r)) |
时间复杂度最坏状况 | O(d(n+r)) |
时间复杂度最好状况 | O(d(n+r)) |
空间复杂度 | O(n+r) |
稳定性 | 稳定 |
复杂性 | 较复杂 |
经过上文可知,假设在基数排序中,r 为基数,d 为位数。则基数排序的时间复杂度为 O(d(n+r))。
咱们能够看出,基数排序的效率和初始序列是否有序没有关联。
在基数排序过程当中,对于任何位数上的基数进行“装桶”操做时,都须要 n+r 个临时空间。
在基数排序过程当中,每次都是将当前位数上相同数值的元素统一“装桶”,并不须要交换位置。因此基数排序是稳定的算法。
样本包含:数组个数为奇数、偶数的状况;元素重复或不重复的状况。且样本均为随机样本,实测有效。