最经典最常用的排序算法:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、计数排序、桶排序、基数排序。
在这些排序算法中如果按照时间复杂度来分类大致可分为三类:
- O(n^2): 冒泡排序、选择排序、插入排序。
- O(n*logn): 归并排序、快速排序、希尔排序。
- O(n): 计数排序、基数排序、桶排序。
学习这么多的排序算法,除了了解其原理、实现其代码外还要评判出各种排序算法之间性能、效率。
评判排序算法好坏的标准
对于众多的排序算法我们要将它们做一个对比需要从如下三个方面着手:时间复杂度、空间复杂度、算法稳定性。
时间复杂度
时间复杂度其实就代表了一个算法执行的效率,我们在分析排序算法的时间复杂度时要分别给出最好情况,最坏情况,平均情况下的时间复杂度。
空间复杂度
空间复杂度代表了算法对存储空间的消耗程度。可以简单的理解为算法的内存消耗。
在这里我们还引入另外一个概念:in-place (原地排序)和out-place(非原地排序) ;其中in-place 可以称为原地排序就是特指空间复杂度为O(1)的排序算法,算法只占用常数内存,不占用额外内存,而out-place 的算法需要占用额外内存。
算法稳定性
针对排序算法,还有一个指标就是:稳定性。
所谓排序算法的稳定性指的是:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
举个例子:有一组数据:3 7 2 7 5 8 9,我们按照大小排序之后的数据为:2 3 5 7 7 8 9,在这数据中有两个7,如果经过某种排序算法后两个7的前后顺序没有发生改变则称该算法是稳定的排序算法,否则称为该算法是不稳定的排序算法。
1. 冒泡排序:
(Bubble sort)是一种简单的排序算法。
原理:
它通过依次比较两个相邻的元素,看两个元素是否满足大小关系要求,如果不满足则交换两个元素。每一次冒泡会让至少一个元素移动到它应该在的位置上,这样n次冒泡就完成了n个数据的排序工作。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
接下来对整个算法的过程进行描述:
- 比较相邻的元素,如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数。
- 针对所有的元素重复以上两个步骤,除了最后一个;
- 重复前三步,直到排序完成。
实现:
public void bubbleSort1(int[] array) {
int length = array.length; // 待排序元素的个数
if( length <= 1) {
return;
}
//冒泡排序
for(int i = 0; i < length; i++){
//相邻比较
for(int j = 0; j < length - i - 1; j++) {
//判断前后数据是否需要交换,如果前一个数据大于后一个数据则进行交换否则不交换
if(array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
//冒泡排序优化
public void bubbleSort2(int[] array) {
int length = array.length; // 待排序元素的个数
if( length <= 1) {
return;
}
//开始冒泡
for(int i = 0; i < length; i++){
//是否需要提前结束冒泡的标识
boolean flag = true;
//相邻比较
for(int j = 0; j < length - i - 1; j++) {
if(array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
flag = false;
}
}
//在当前这次冒泡中如果所有元素都不需要进行交换则证明所有元素都已有序,则无需进行后续的冒泡操作了
if(flag){
break;
}
}
}
示意图:
总结:
对于冒泡排序我们要使用之前学习的三个标准来进行评判:
-
冒泡排序的时间复杂度是多少?
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n)。
而最坏的情况是,要排序的数据刚好是倒序排序的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n^2)。 -
冒泡排序的空间复杂度是多少?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一种in-place排序算法。 -
冒泡排序是稳定的排序算法吗?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序,为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
2. 插入排序:
(insertion Sort)
原理:
将数组中的数据分为两个区间,已排序区间和未排序区间,初始已排序区间只有一个元素,就是数组的第一个元素,插入算法的核心思想取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序,重复这个过程,直到未排序区间中元素为空,算法结束。
算法描述如下:
1.从第一个元素开始,该元素可以认为已经排序;
2.取出下一个元素,在已经排序的元素序列中从后向前扫描;
3.如果该元素(已排序)大于新元素,将该元素移到下一个位置;
4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
5.将新元素插入到该位置后;
6.重复步骤2-5。
实现:
public void insertionSort1(int[] arr) {
int len = arr.length;
if(len <= 1) {
return;
}
//默认第0个元素为已排序元素
for(int i = 1; i < len; 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;
}
}
示意图:
总结:
-
插入排序的时间复杂度是多少?
如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置,所以这种情况下,最好时间复杂度为O(n),注意:这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为
O(n^2)。 -
插入排序的空间复杂度是多少?
从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),也就是说,这是一个in-place (原地排序)排序算法。 -
插入排序是稳定的排序算法吗?
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
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 void selecionSort(int[] arr) {
int len = arr.length;
if(len <= 1) {
return;
}
for(int i = 0; i < len; i++) {
int minIndex = i;
//从未排序的序列中找到最小值
for(int j = i; j < len; j++) {
if(arr[minIndex] > arr[j]) {
minIndex = j;
}
}
//有序序列尾部追加最小值
int current = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = current;
}
}
示意图:
总结:
-
选择排序的时间复杂度时多少?
最好情况时间复杂度为O(n^2), 最坏情况时间复杂度为O(n^2) 。 -
选择排序的空间复杂度时多少?
通过算法的实现我们可以发现,选择排序的空间复杂度为O(1),是一个in-place排序算法。 -
选择排序是一个稳定的排序算法吗?
选择排序不是一个稳定的排序算法,因为旋转排序每次都要找剩余未排序元素中的最小值,并和未排序区间的第一个元素进行交换位置,这样破坏了稳定性,比如:5,8,5,2,9这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了,所以就不稳定了。
4. 归并排序:
(Merge Sort)
原理:
归并排序(Merge Sort)的核心思想还是蛮简单的,如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序使用的是分治思想,分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。从我刚才的描述,你可能感觉到,分治思想和递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突,而对于递归就是要找到地推公式及终止条件。
算法描述:
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
实现:
public 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 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;
}
}
示意图:
总结:
-
归并排序的时间复杂度是多少?
归并排序的时间复杂度为:O(n*logn) -
归并排序的空间复杂度是多少?
归并排序的空间复杂度是O(n),因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。 -
归并排序是稳定的排序算法吗?
归并排序算法稳定还是不稳定取决于合并函数merge(),也就是两个有序子数组合并成一个有序数组的那部分代码,通过分析merge函数我们发现,归并排序也是一个稳定的排序算法。
5. 快速排序:
(Quick Sort)
原理:
也是利用了分治的思想,初步看起来有点像归并排序,但是其实现思路完全不一样,快排的思路是:如果要对m->n之间的数列进行排序,我们选择m->n之间的任意一个元素数据作为分区点(pivot),然后我们遍历m->n之间的所有元素,将小于pivot的元素放到左边,大于pivot的元素放到右边,pivot放到中间,这样整个数列就被分成三部分了,m->k-1之间的元素是小于pivot,中间是pivot,k+1->n之间的元素是大于pivot的,然后再根据分治思想处理两边区间的元素数列,直到区间缩小为1,就说明整个数列都已有序了。
算法描述如下:
- 从数列中挑出一个元素,称为"基准"(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。
在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作; - 递归地(recursive)把小于基准值元素的子数列和大于基准元素的子数列排序。
实现:
//快速排序,借助递归和分区的思想来实现
public void quickSort(int[] arr, int begin, int end) {
//校验 递归终止条件
if(arr.length <= 1 || begin >= end) {
return;
}
//进行分区得到分区下标
int piovtIndex = partition(arr, beqin, end);
//递归 (左侧快排)
quickSort(arr, begin, piovtIndex - 1);
//递归 (右侧快排)
quickSort(arr, piovtIndex + 1, end);
}
//返回我们某一个序列的基准点
private int partition(int[] arr, int begin, int end) {
//默认使用基准点 位序列最后一个元素
int pivot = arr[end];
//定义分区后的piovt元素下标
int piovtIndex = begin;
for(int i = begin; i < end; i++) {
//判断 如果该区间内有元素小于piovt 则将该元素从区间头开始一直向后填充
if(arr[i] < pivot) {
if(i > piovtIndex) {
//数据元素交换
swap(arr, i, piovtIndex);
}
piovtIndex++;
}
}
swap(arr, piovtIndex, end);
return piovtIndex;
}
//交换方法
private void swap(int[] arr, int i, int j) {
int temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
}
示意图:
总结:
-
快速排序的时间复杂度是多少?
快排的时间复杂度最好以及平均情况下的复杂度都是O(nlogn),只有在极端情况下会变成O(n^2) -
快速排序的空间复杂度时多少?
通过快排的代码实现我们发现,快排不需要额外的存储空间,所有的操作都能在既定的空间内完成,因此快排的空间复杂度为O(1),也就是说快排是一种in-place 的排序算法。 -
快速排序是稳定的排序算法吗?
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列6,8,7,6,3,5,9,4在经过第一次分区操作之后,两个6的相对先后顺序就会改变,所以,快排并不是一个稳定的排序算法。 -
快排和归并的异同?
首先快排和归并都用到了分治递归的思想,在快排中对应的叫分区操作,地推公式和递归代码也非常相似,但是归并排序的处理过程是由下到上的由局部到整体,先处理子问题,然后再合并,而快排正好相反,它的处理过程是由上到下由整体到局部,先分区,然后再处理子问题,归并排序虽然是稳定的,时间复杂度为O(nlogn)的排序算法,但是它是一种out-place排序算法,主要原因是合并函数无法在原地(函数内)执行,快速排序通过设计巧妙的原地(数组内)分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
6. 桶排序:
(Bucket Sort)
顾名思义,会用到“桶”, 桶我们可以将其想象成一个容器,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序,桶内排完序之后,在把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了,换句话说:桶排序是将待排序集合中处于同一个值域的元素存入到一个桶中,也就是根据元素值的特性将集合拆分为多个区域,则拆分
后形成的多个桶,从值域上看处于有序状态的,对每个桶中元素进行排序,则所有桶中元素构成的集合是已排序的。
示意图: