一、简介
在我们本章节的课程中只讲最经典最常用的九种排序算法:
- 冒泡排序
- 选择排序
- 插入排序
- 希尔排序
- 归并排序
- 快速排序
- 计数排序
- 桶排序
- 基数排序
在这些排序算法中如果按照时间复杂度来分类大致可以分为三类:
- 冒泡排序,选择排序,插入排序
- 归并排序,快速排序,希尔排序
- 计数排序,基数排序,桶排序
我们学习了那么多的排序算法,除了学习其原理,实现代码外,还要评判出各种排序算法之间性能,效率。
那么我们应该从哪些方面来分析一个排序算法是好是坏呢?所以在正式进入排序算法之前,我们先说说排序算法的评判标准。
二、评判排序算法好坏的标准
对于众多的排序算法我们要将他们做一个对比需要从三个方面着手:
1.时间复杂度
时间复杂度其实就代表了一个算法执行的效率,我们在分析排序算法的时间复杂度时要分别给出最好情况、最坏情况、平均情况下的时间复杂度。为什么要区分这三种时间复杂度呢?
第一,有些排序算法会区分,为了好比对,所以我们最好都做一下区分;
第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
在之前的章节中学习复杂度分析的时候我们说过,复杂度反映的是一个算法随着n的变化的一个增长趋势,在表示的时候往往会忽略表达式中的系数,低阶,常量,但是在实际的软件开发中,我们排序的可能是50个,100个,1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们要把系数,常数,低阶也考虑进来。
在本章节讲的都是排序算法中的不同实现,其中有些排序算法都是基于数据比较的排序算法,这些排序在执行过程中会涉及到比较元素大小,然后元素的交换或者移动,所以在分析基于比较的排序算法时要将元素比较/交换、移动的次数也考虑进来。
2.空间复杂度
空间复杂度在一个层面代表了算法对存储空间的消耗程度,我们可以简单的理解为算法的内存消耗,在这里我们还引入另外一个概念:in-place 和 out-place; in-place 可以称为原地排序,就是特指空间复杂度为 o(1) 的排序算法,算法只占用常数内存,不占用额外内存,而 out-place 的算法需要占用额外内存。
3.算法稳定性
如果我们只用上面提到的时间复杂度和空间复杂度来度量一个排序算法其实是不够的,真对排序算法,还有一个指标就是:稳定性。所谓排序算法的稳定性是指:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
举个例子,有一组数据,3 7 2 7 5 8 9 ,我们按照大小排序之后的数据为 2 3 5 7 7 8 9 ,在这组数据中有两个7,如果经过某种算法排序后两个7的前后顺序没有发生改变则称该算法是稳定的排序算法,否则称为该算法是不稳定的排序算法。
在我们后续每学习一个排序算法,我们都应该使用刚刚所讲的这三个评判规则去分析该算法,接下来我们就依次的对每一个排序算法进行学习。
三、排序算法
1.冒泡排序
原理:冒泡排序(Bubble Sort)是一种简单的算法排序,它通过依次比较两个相邻的元素,看两个元素是否满足大小关系要求,如果不满足则交换两个元素。每一次冒泡排序让至少一个元素移动到它应该在的位置上,这样n次冒泡就完成了n个数据的排序工作。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
接下来对整个算法的过程进行描述:
- 比较相邻的元素,如果第一个比第二个大,就交换它们两个。
- 对每一个相邻元素作同样的工作,从开始的第一对到结尾的最后一对,这样再最后的元素应该会是最大的数。
- 真对所有的元素重复以上的两个步骤,除了最后一个。
- 重复前三步,直到排序完成。
实现:理解了冒泡排序的原理后代码实现如下:
public class BubbleSort { /** * 冒泡排序算法: * 冒泡排序是一种简单的排序算法,它重复地走访过要排序的数列,一次比较两个元素, * 如果它们的顺序错误就把它们交换过来。 * 走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。 * 这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。 * * 步骤: * 1:比较相邻的元素。如果第一个比第二个大,就交换它们两个; * 2:对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数; * 3:针对所有的元素重复以上的步骤,除了最后一个; * 4:重复步骤 1~3,直到排序完成 * */ public static void bubbleSort(int[] arr){ if(arr.length <= 1){ return; } // 外层控制有多少轮循环 // 有多少个元素,就应该有多少轮,只不过最后一轮(最后一个元素)不会比较(内层循环过滤掉了) for(int i=0;i<arr.length;i++){ // 内层控制每轮循环比较的次数; // j初始值为0是因为每轮循环开始都是从第一个元素开始比较 // 每轮的次数为 arr.length-i-1 是因为每一轮比较的次数都少i次,且因为arr[j+1]已经比较了,所以再-1; 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; } } } } public static void main(String[] args) { int[] arr = {11,33,22,44,99,66,77,88,55}; System.out.println("排序前:" + Arrays.toString(arr)); bubbleSort(arr); System.out.println("排序后:" + Arrays.toString(arr)); } /** * 冒泡排序优化: * 实际上,这里的冒泡排序算法还可以继续优化:因为当某次冒泡时发现已经没有数据需要进行交换时, * 说明所有元素都已经达到有效状态了,此时就不用再执行后续的冒泡排序了,接下来对之前的冒泡排序进行优化的代码 * */ public static void bubbleSort2(int[] arr){ if(arr.length <= 1){ return; } for(int i=0;i<arr.length;i++){ // 是否需要提前结束冒泡的标志 boolean flag = true; 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 = false; } } // 在当前这次冒泡排序中如果所有元素都不需要进行交换则证明所有 // 元素都已经有序,不需要进行后续的冒泡操作了 if(flag){ break; } } } }
总结 对于冒泡排序我们要使用之前学习的三个标准进行评判:
1:冒泡排序的时间复杂度是多少?
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡排序操作,就可以结束了,所以,最好情况的时间复杂度是o(n);
而最坏的情况是,要排序的数据刚好是倒叙排序的,我们需要进行n次冒泡排序,所以最坏的情况时间复杂度为o(n2);
2:冒泡排序的空间复杂度是多少?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为o(1),是一种 in-place 排序算法;
3:冒泡排序是稳定的排序算法吗?
在冒泡排序汇总,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相同时,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
2.插入排序
原理:插入排序(Insertion Sort)的原理是:将数组中的数据分成两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中的元素为空,算法结束。
算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2-5
实现:插入排序的算法实现如下
public class InsertionSort {
/**
* 插入排序算法
* 插入排序的算法描述是一种简单直观的排序算法。
* 我们将数组中的数据分成两个区间,已排序区间和未排序区间,初始已排序区间只有一个元素,
* 就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到
* 合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中
* 元素为空,算法结束。
*
* 步骤:
* 1.从第一个元素开始,该元素可以认为已经被排序
* 2.取出下一个元素,在已经排序的元素序列中从后往前扫描
* 3.如果该元素(已排序)大于新元素,将该元素移到下一位置
* 4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
* 5.将新元素插到该位置后
* 6.重复步骤2-5
* */
public static void insertionSort(int[] arr){
if(arr.length <= 1){
return;
}
// 开始排序
for(int i=0;i<arr.length;i++){
// 取出未排序的下一个元素,及当期参与比较的元素
int current = arr[i];
// 在已经排序的元素序列中从后往前扫描,定义前置索引
int preIndex = i-1;
// 从后往前依次和当前元素进行比较
while (preIndex >=0 && arr[preIndex] > current){
// 比较过程中如果元素大于当前的元素则将元素向后移一位
arr[preIndex+1] = arr[preIndex];
preIndex--;
}
// 比较过程中如果该元素小于当前元素,则将当前元素放到该元素后面
arr[preIndex+1] = current;
}
}
public static void main(String[] args) {
int[] arr = {0,6,2,4,3,6};
System.out.println("排序前:" + Arrays.toString(arr));
insertionSort(arr);
System.out.println("排序后:" + Arrays.toString(arr));
}
总结:
1.插入排序的时间复杂度是多少?
如果要排序的数据是有序的,我们并不需要搬移任何数据。如果从尾到头在有序的数组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以在这种情况下,最好时间复杂度为o(n)。注意,这里是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为o(n2)。还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是o(n2)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度为o(n2)。
2.插入排序的空间复杂度是多少?
从实现过程可以很明显的看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是o(1),也就是说,这是一个
in-place【原地排序】排序算法。
3.插入排序是稳定的排序算法吗?
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保存原有的前后顺序不变,所以插入排序是稳定的排序算法。
3.选择排序
原理:选择排序(Selection Sort)的原理有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从排序区间中找到最小的元素,将其放到已排序区间的末尾。
算法描述如下:
- 初始状态:无序区间为R[1..n],有序区间为空;
- 第 i 趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为 R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第 1 个记录交换,使 R[1..i]和 R[i+1..n)分别变为记录个数增加 1 个的新有序区和记录个数减少 1 个的新无序区;
- n-1 趟结束,数组有序化了
实现:选择排序的算法实现如下
public class SelectionSort {
/**
* 选择排序算法
* 选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。
* 但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾
* */
public static void selectionSort(int[] arr){
if(arr.length <= 1){
return;
}
for(int i=0;i<arr.length;i++){
// 找到未排序区间的最小值的下标
int minIndex = i;
for(int j=i;j<arr.length;j++){
if(arr[j] < arr[minIndex]){
minIndex = j;
}
}
// 交换未排序区间最小元素和当前元素的位置
int current = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = current;
}
}
/**
* 选择排序算法
* 另外一种理解:每一轮排序将第一个元素跟之后的每一个元素比较,如果比第一个元素大则不用处理,
* 如果比第一个元素小,则跟第一个元素换位,直到最后一个元素跟第一个元素比较,这样一轮下来,
* 最小值排在了第一位。
* 然后从第二个元素,依次跟后面的比较,重复之前的操作,这样每一轮下来,都有最小的元素排在前面
* */
public static void selectionSort1(int[] arr){
if(arr.length <= 1){
return;
}
for(int i=0;i<arr.length;i++){
for(int j=i+1;j<arr.length;j++){
if(arr[j] < arr[i]){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
}
public static void main(String[] args) {
int[] arr = {0,6,2,4,3,6};
System.out.println("排序前:" + Arrays.toString(arr));
selectionSort(arr);
System.out.println("排序后:" + Arrays.toString(arr));
}
}
总结:
1.选择排序的时间复杂度是多少?
结合之前的分析方式分析可知选择排序的最好情况时间复杂度为 O(n2),最坏情况时间复杂度为:O(n2),平均情况下的时间复杂度为: O(n2)。
2.选择排序的空间复杂度是多少?
通过算法的实现我们可以发现,选择排序的空间复杂度为 O(1),是一个 in-place排序算法
3:选择排序是一个稳定的排序算法吗?
注意: 选择排序不是一个稳定的排序算法,为什么呢?选择排序每次都要找剩余未排序元素中的最小值,并和未排序区间的第一个元素进行交换位置,这样破坏了稳定性,比如 5, 8, 5, 2, 9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,从稳定性上来说选择排序相对于冒泡排序和插入排序就稍微逊色了
4.归并排序
原理:归并排序(Merge Sort)的核心思想还是比较简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序使用的是分治思想。分治,顾名思义,就是分而治之,讲一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。从我刚才描述中,你有没有感觉到,分治思想和我们前面讲的递归思想很像。是的,分治思想一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。而对于递归就是要找到递推公式和终止条件,所以我们可以先写出归并排序的递推公式:
mergeSort(m->n) = merge(mergeSort(m->k),mergeSort(k+1->n)); 当m=n时终止。
我们来解释一下这个公式:我们要对m->n之间的数列进行排序,其实可以拆分成对 m->k 之间的数列进行排序,以及对 k+1->n 之间的数列排序,然后将两个排好序的数列进行合并,就成为了最终的数列,同样的道理,每一段数列的排序又可以继续往下拆分,形成递归。
算法描述:
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序
- 将两个排好序的子序列合并成一个最终的排序序列
实现代码:
public class MergeSort {
/**
* 归并排序算法
* 如果要排序一个数组,我们先将数组从中间分成前后两个部分,然后对前后两个部分分别排序,
* 再将排好序的两部分合并在一起,这样整个数组就有序了
* */
public static int[] mergeSort(int[] arr){
if(arr.length < 2){
return arr;
}
// 将数组从中间拆分成左右两个部分
int mid = arr.length/2;
int[] left = Arrays.copyOfRange(arr,0,mid);
int[] right = Arrays.copyOfRange(arr,mid,arr.length);
return merge(mergeSort(left),mergeSort(right));
}
/**
* 合并两个有序数组并返回新的数组
* */
public static int[] merge(int[] left,int[] right){
// 创建一个新数组,长度为两个有序数组的长度之和
int[] newArray = new int[left.length + right.length];
// 定义两个指针,分别代表两个数组的下标
int lindex=0;
int rindex=0;
for(int i=0;i<newArray.length;i++){
if(lindex >= left.length){
newArray[i] = right[rindex++];
}else if(rindex >= right.length){
newArray[i] = left[lindex++];
}else if(left[lindex] < right[rindex]){
newArray[i] = left[lindex++];
}else {
newArray[i] = right[rindex++];
}
}
return newArray;
}
public static void main(String[] args) {
int[] arr = {0,6,2,4,3,1};
System.out.println("排序前:" + Arrays.toString(arr));
int[] arr2 = mergeSort(arr);
System.out.println("排序后:" + Arrays.toString(arr2));
}
}
可以参考文档:【算法】排序算法之归并排序 - 知乎
总结: