排序算法总结
我们来总结一下那些耳熟能详的排序算法并对其中几种算法使用Java进行实现
基于比较的排序算法
最常用的排序算法都是基于元素之间的两两比较来进行。这样的算法适用性强,它不关心元素的类型,元素的取值范围等,它只关心元素之间如何比较。而这之中,又有两类排序算法:简单排序算法和高效排序算法。
简单排序算法
1、插入排序
插入排序几乎是人们最自然去使用的一种排序算法,它尤其体现在扑克牌从牌堆摸牌的过程中。我们每次从牌堆摸一张牌到手上,就把牌插入相应的位置。
这样的排序怎么写呢?我们来列一个框架:
for (int i = 0; i < a.length; i++) { // 循环不变式 }
插入排序的性质:
- 时间复杂度。平均和最坏显然是O(n2)。
- 最好情况呢,我们看上面里层的for是会提前退出的,因此如果它本身就是一个有序数组的话,只需要O(n)的时间。因此插入排序的最好情况时间复杂度是O(n)。
- 空间复杂度。O(1),不需要额外空间。
- 稳定性。是稳定的。我们看到上面,我们只在a[j]和a[j-1]不相等的情况下才进行交换。
2、选择排序
选择排序也是比较常用的方法。我们考虑桌上有一堆面相上的明牌,我们要排序的话,通常会先选择A,然后选择2,3…K,完成排序。选择排序就是这样,每次从数组中剩下的元素中选择最小的那个即可。
要完成这个选择,每一轮for里面,就要在a[i, i+1, …]一直到结束的这些数里面,挑出最小的那个数,并且将它和a[i]交换。
选择排序的性质:
- 时间复杂度:平均和最坏显然也是O(n2)。最好其实也是O(n2)。因为每次我们都需要扫描完所有剩下的数,才知道谁是最小的。
- 空间复杂度:O(1)
- 稳定性。选择排序是不稳定的。因为在交换过程中会打乱原来数据的顺序。
- 交换次数。选择排序有一个优点,就是交换次数至多只有O(n)次。
3、冒泡排序
冒泡排序可能是因为其名称和独特的方法,所以很多同学一说排序就是冒泡排序。但其实它的性能并不优秀,理解上也不是太直接。 冒泡排序的外层循环其实跟选择排序一样:
for (int i = 0; i < a.length; i++) { // a[0...i)为原数组中最小的i个元素排序完成的结果 }
只是比较和交换的方式比一样,我们来看代码
for (int i = 0; i < a.length; i++) { for (int j = a.length - 1; j > i; j--) { if (a[j] < a[j - 1]) { swap(a, j, j - 1); } } }
在里层的for里面,我们j是反向移动的,这个非常重要。每次这样反向比较+交换一轮之后,我们总能保证剩下的数据中最小的数被换到a[i]的位置,这个交换的过程就像在模拟气泡在水中慢慢升起的过程。由此再结合上述对应于外层循环的循环不变式,因此冒泡排序也是正确的。
冒泡排序的性质:
- 时间复杂度:平均和最坏都是O(n2)。最好情况呢?我上述的实现当然也是O(n2)。不过对于已经排好序的数组,很容易优化到O(n),这个就交给同学们了。因此我们说冒泡排序的最好情况是O(n)。
- 空间复杂度:O(1)。
- 稳定性:冒泡排序是稳定的。相邻的数最终会被换到相邻位置,我们只在a[j]<a[j-1]的情况下才进行交换就能保证其稳定性。
- 应用:冒泡排序对于已经排好序或者基本有序的数组(同学可以尝试简单优化我上述代码)的表现非常好,因此在数据量不大又基本有序的情况下可以使用。比如图形学中检测数据错误等。
4、高效排序算法
可以证明,基于比较的排序算法平均复杂度的上限是O(nlogn)。那么我们就来看看这些平均复杂度为O(nlogn)的算法。其中快速排序和归并排序更是“分治”思想方法的典型体现。
快速排序
快速排序是应用最为广泛的。大部分的基础库里面都会或多或少的使用快速排序。快速排序是一个递归的算法,我们可以这样来
实现
void sort(int[] a) { // 分组:扫描a,把比a[0]小的数移到左边,把比a[0]大的数移到a[0]右边。 // 递归:对于左右两半边分别递归调用sort }
在实现上,我们主要的难度在于分组这一步。通常我们教科书上会见到有左右两个指针分别从0和n-1两端开始扫描,这是一种编码技巧,但并非理解快速排序的核心。我们也可以选择很朴素的扫描两边的方法来实现分组。
快速排序的性质:
- 平均时间复杂度:我们观察sort函数里面,分组需要花O(n)的时间,每次递归平均的来说,左右两半的大小都分别是n/2。那么快速排序的时间复杂度递推式就是:T(n)=2T(n/2)+O(n)。得出快速排序的平均复杂度是O(nlogn)。
- 最坏,最好时间复杂度:最坏情况有些讽刺的发生在已经排好序的数组上,以及完全倒序的数组上。这两种情况由于无法把问题的规模有效平分,导致上述时间递推式变成T(n)=T(0)+T(n-1)+O(n),所以反而需要O(n2)的时间。那么我们思考,为啥一定要选择a[0]作为分界点呢?我们也叫做pivot item。的确我们可以选择任何一个元素作为pivot。但理论上,不论我们选择哪个元素作为pivot,都有可能很不幸的成为最坏情况。不过对于最好情况下的时间复杂度,我们容易理解它也是O(nlogn)。
- 空间复杂度:快速排序需要使用递归,递归会产生一个调用栈,这个栈的深度是logn,所以空间复杂度为O(logn)。当然,根据上述最坏情况时间复杂度的分析,最坏情况的空间复杂度也为O(n2)
- 稳定性:“正统”的快速排序,分组的过程会打乱元素的顺序,因此快速排序是不稳定的。但我们在分组的时候如果小心的保留元素的顺序,还是可以写出稳定的快速排序的。
5、归并排序
实现
void sort(int[] a) { // 分组:把a分成左右两边,这次我们不管数据大小,直接中间切一刀,分组就完成了 // 递归:把左右两边分别递归调用归并排序,获得两个排好序的子数组 // 归并:把两个排好序的子数组进行归并 }