每每遇到关于排序算法的问题总是不能很好的解决,对一些概念,思想以及具体实现的认识也是模棱两可。归根结底,还是掌握不够熟练。以前只是看别人写,看了就忘。现在打算自己写,写些自己的东西,做个总结。所以就有了这“常见排序算法汇总与分析”上中下三部曲。
本篇是这个汇总的开始,我们先来解决一些问题。
首先,排序是什么意思,大家应该都知道。但是排序涉及到一个概念,叫排序稳定性。那么稳定性是什么意思呢?
稳定性:就是有两个相同的元素,排序后它们相对位置是否发生变化,若未变化则该称该排序算法是稳定的。否则就是不稳定的。
排序是如何分类的?可以从不同的的角度对排序进行分类,这里我是根据排序的策略对本系列博客中涉及到的排序算法进行分类:
其中每个算法都有其相应的时间复杂度和空间复杂度,这里我也对它们做了一个汇总:
排序算法 |
时间复杂度 |
空间复杂度 |
稳定性 |
复杂性 |
||
平均情况 |
最坏情况 |
最好情况 |
||||
冒泡排序 |
O(n^2) |
O(n^2) |
O(n) |
O(1) |
稳定 |
简单 |
快速排序 |
O(nlog2n) |
O(n^2) |
O(nlog2n) |
O(nlog2n) |
不稳定 |
较复杂 |
直接插入排序 |
O(n^2) |
O(n^2) |
O(n) |
O(1) |
稳定 |
简单 |
希尔排序 |
O(nlog2n) |
O(nlog2n) |
- |
O(1) |
不稳定 |
较复杂 |
直接选择排序 |
O(n^2) |
O(n^2) |
O(n^2) |
O(1) |
不稳定 |
简单 |
堆排序 |
O(nlog2n) |
O(nlog2n) |
O(nlog2n) |
O(1) |
不稳定 |
较复杂 |
归并排序 |
O(nlog2n) |
O(nlog2n) |
O(nlog2n) |
O(n) |
稳定 |
较复杂 |
基数排序 |
O(d(n+r)) |
O(d(n+r)) |
O(d(n+r)) |
O(n+r) |
稳定 |
较复杂 |
计数排序 |
O(n+k) |
O(n+k) |
O(n+k) |
O(n+k) |
稳定 |
简单 |
一些声明:
本系列用到的所有算法均是以从小到大为例的。
文章后面将会提到的有序区,无序区是指,在待排序列中,已经排好顺序的元素,就认为其是处于有序区中,还没有被排序的元素就处于无序区中。
接下来,我们开始进入正文。
交换排序主要包括两种排序算法,分别是冒泡排序和快速排序
【基本思想】
两两比较待排序列中的元素,若其次序相反,则进行交换,直到没有逆序的元素为止。
通过无序区中相邻元素的比较和交换,使最小的元素如气泡一般逐渐往上漂浮至水面
【空间复杂度】O(1)
【时间复杂度】
平均情况:O(n^2)
最好情况:正序有序时,但对于普通冒泡仍然是O(n^2),比如说下面的算法。对于优化后的冒泡才是O(n),最后面我会给出优化后的代码,正序有序时只需要对n个元素进行一趟冒泡排序即可,即复杂度为O(n)。
最坏情况:逆序有序时,复杂度O(n^2)
【稳定性】稳定
【优点】
简单,稳定,空间复杂度低
【缺点】
时间复杂度高,效率不好,每次只能移动相邻两个元素,比较次数多
【算法实现】
/**
* 冒泡排序
* @param arr
*/
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = arr.length - 1; j > i; j--) {
if (arr[j] < arr[j - 1]) {
swap(arr, j, j - 1);
}
}
}
}
//实现将指定下标的两个元素在数组中交换位置
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
其中swap(int[] arr, int i, int j)方法,在下文的许多算法中都会用到,后面就不再单独写出该方法的实现
【本算法解读】
算法的外层循环可以理解为要进行n趟排序,每次排序都可以确定一个最小值。初始时有序区的个数为0,数组中的所有元素都在无序区。内层循环依次对无序区中相邻元素进行比较,如果位置靠后的元素小于位置靠前的元素,则交换两个元素。如此一来,无序区中最小的元素就被交换到了有序区的末尾,然后有序区的元素个数加1(对应代码:内存循环结束后,i+1),无序区的元素个数减1(对应代码:无序区是从arr.leng-1到i+1,每次执行i+1时,相应的无序区就会减少)。
重复上述操作,直到所有元素都处在有序区中(即i加到了arr.length),就完成了排序。
【举个栗子】
对于待排序列4,1,3,2
首先依次比较无序区(初始时为所有元素)中的相邻元素,比较3,2,位置靠后的元素小于位置靠前的元素,则交换。序列为4,1,2,3。继续比较1,2,位置靠后的元素大于位置靠前的元素,不交换。继续比较4,1,需要交换,此时无序区中的元素全部比较完毕,一趟冒泡排序结束,序列为1,4,2,3。可以看到最小元素1已经移动到序列首部,即处于有序区内,确定了一个元素的位置。重复上述操作完成最终排序。
【冒泡排序优化算法实现】
/**
* 冒泡排序优化
* @param arr
*/
public static void bubbleSortOptimize(int[] arr) {
boolean didSwap; //标志位,判断每完成一趟冒泡排序,是否发生数据交换
for (int i = 0; i < arr.length - 1; i++) {
didSwap = false;
for (int j = arr.length - 1; j > i; j--) {
if (arr[j] < arr[j - 1]) {
swap(arr, j, j - 1);
didSwap = true;
}
}
//如果没有发生数据交换则终止算法
//没有发生数据交换,意味着无序区中的每对相邻元素的位置都是正确的,即无序区中的元素已经是有序的了
if (didSwap == false) {
return;
}
}
}
【基本思想】
快速排序又称为分区交换排序,是目前已知的平均速度最快的一种排序方法,它采用了一种分治的策略,是对冒泡排序的一种改进。在待排序列中任取其中一个元素(称其为目标元素),通常选取第一个元素。以该元素为分界点(pivot)经过一趟排序后,将待排序列分成两个部分,所有比分界点小的元素都存放在目标元素之前,所有比分界点大的元素都存放在目标元素之后,然后再分别对这两个部分重复上述过程,直到每一部分只剩一个元素为止。显然,每趟快速排序后,分界点都找到了自己在有序序列中的位置
【空间复杂度】O(nlog2n)取决于递归深度
【时间复杂度】
平均情况:O(nlog2n)
最好情况:O(nlog2n)当每次划分的结果得到的pivot左,右两个分组的长度大致相等时,快速排序算法的性能最好
最坏情况:O(n^2)当每次选择分界点均是当前分组的最大关键字或最小关键字时,快速算法退化为冒泡算法。
【稳定性】不稳定
【优点】
极快,数据移动少
【缺点】
不稳定,难以在单向链表结构上实现
/**
* 快速排序
* @param arr
* @param left
* @param right
*/
public static void quickSort(int arr[], int left, int right) {
if (left >= right) {
return;
}
int pivot = partition(arr, left, right);
//通过递归不断将待排序列分成两部分
quickSort(arr, left, pivot - 1);
quickSort(arr, pivot + 1, right);
}
//分区,返回目标元素的最终位置
private static int partition(int[] arr, int left, int right) {
//每次以子部分的首元素作为目标元素,并保存在target中
int target = arr[left];
while (left < right) {
//右指针开始移动,找到比目标元素小的元素则停止
while (right > left && arr[right] >= target) {
right--;
}
if (left < right) {
//将找到的比目标元素小的元素移动到left指针指向的位置
arr[left] = arr[right];
left++;
}
while (left < right && arr[left] <= target) {
left++;
}
if (left < right) {
//将找到的比目标元素大的元素移动到right指针指向的位置
arr[right] = arr[left];
right--;
}
}
//将目标元素移动到最终位置
arr[left] = target;
return left;
}
【本算法解读】
算法首先选取待排序列的首元素,调用partition()方法,将待排序列分成两个子部分。然后通过递归继续将每个子部分分成两个子部分。直到每部分只剩一个元素(对应代码:当left>=right时return)。partition()方法内部,通过移动左右指针不断进行比较。首先准备移动右指针(因为当找到比目标元素小的元素时,可以先将其移动到左指针指向的位置,而left所指向位置的元素已经被保存到target中,不用担心被覆盖),找到比目标元素小的元素后移动到left指向的位置(此时left位置的元素是被移动过来的元素,肯定比目标元素小,所以左指针扫描时就可以不用比较该元素,对应代码:left++),右指针停止。准备移动左指针,找到比目标元素大的元素后,将其移动到right指向的位置(此时原来在right位置的元素已经被移动,可以直接覆盖),左指针停止。再次开始移动右指针,重复上述操作。
直到左指针和右指针重合,即它们所指向的位置,就是目标元素应该在的最终位置。
【举个栗子】
对于待排序列3,1,4,2
先看图:
首先将选取首元素3作为目标元素,并将其保存在临时变量target中。起始left指向3,right指向2。开始移动右指针,发现2比目标元素3小,则将2移动到left指向的位置,注意此时left向前移动一位,指向1。右指针停止。开始移动左指针,1比3小符合要求,继续移动,发现4比3大,不符合要求,将4移动到right位置(即原来2的位置,同理right也移动一位,图中未画出),left指针停止。
又重新准备移动右指针,发现right与left重合则第一次分区结束。3找到了它的最终位置即left,right指向的位置,将3移动到该位置。此时序列为2,1,3,4。
继续递归重复上述操作。
插入排序主要包括两种排序算法,分别是直接插入排序和和希尔排序
【基本思想】
每次将一个待排序列的元素,按其大小插入到前面已经排好序的记录序列中的适当位置,直到全部元素插完为止。
插入排序不是通过交换位置而是通过比较找到合适的位置插入元素来达到排序的目的
【空间复杂度】O(1)
【时间复杂度】
平均情况:O(n^2)
最好情况:O(n),正序有序
最坏情况:O(n^2),逆序有序
【稳定性】稳定
【优点】
稳定,快,如果序列是基本有序的,使用直接插入排序效率就非常高
【缺点】
比较次数不一定,比较次数越多,插入点后的数据移动越多,特别是数据量庞大的时候,但用链表可以 解决这个问题。
/**
* 直接插入排序
* @param arr
*/
public static void insertSort(int arr[]) {
//默认认为第一个元素是有序的
for (int i = 1; i < arr.length; i++) {
int j = i;
//待插入的目标元素
int target = arr[i];
while (j > 0 && target < arr[j - 1]) {
//后移
arr[j] = arr[j - 1];
j--;
}
arr[j] = target;
}
}
【本算法解读】
算法默认待排序列的第一个元素是排好序的,处于有序区。从第二个元素开始,直到到末尾元素,依次作为目标元素(对应代码:for(int i = 1, i < arr.length; i ++)),向有序区中插入。那么如何插入呢?将目标依次和有序区的元素进行比较,若目标元素小于该元素,则目标元素就应处于该元素的前面,则该元素后移一个位置(对应代码:arr[j] = arr[j - 1])。不断比较直到找到不比目标元素小的元素,则目标元素就应在该元素的后面位置,将目标元素插入到该位置。继续下一个目标元素,直到所有元素插入完毕。
在插入第i个元素时,前i-1个元素已经是排好序的。
首先认为3是有序的,处于有序区。将1作为目标元素,依次和有序区中的元素进行比较,和3进行比较,1<3,则3后移,有序区中没有待比较的数据了,所以将1插入到3原来的位置。此时序列:1,3,4,2。有序区内元素为1,3。继续将4作为目标元素,先和3比较,4>3,则插入到4的后面位置。此时序列1,3,4,2。此时有序区内元素为1,3,4。继续将2作为目标元素,和4比较,2<4,4后移,和3比较,2<3,3后移,和1比较,2>1,则插入到1的后面。此时序列1,2,3,4。所有元素插入完毕,即排序完成。
【基本思想】
希尔排序是插入排序的一种高效率的实现,又称缩小增量式插入排序。它也是直接插入排序算法的一种改进算法。
先选定一个数作为第一个增量,将整个待排序列分割成若干个组,就是将所有间隔为增量值的元素放在同一个组内。各组内部进行直接插入排序。然后取第二个增量,重复上述分组和排序操作,直到所取增量减少为1时,即所有元素放在同一个组中进行直接插入排序。
为什么希尔排序的时间性能是优于直接插入排序的呢?
开始时,增量较大,每组中的元素少,因此组内的直接插入排序较快,当增量减少时,分组内的元素增加,但此时分组内的元素基本有序,所以使得组内的直接插入排序也较快,因此,希尔排序在效率上较直接插入排序有较大的改进
【空间复杂度】O(1)
【时间复杂度】
平均情况:O(nlog2n)
最好情况:因为希尔排序的执行时间依赖于增量序列,如何选择增量序列使得希尔排序的元素比较次数和移动次数较少,这个问题目前还未能解决。但有实验表明,当n较大时,比较和移动次数约在 n^1.25~1.6n^1.25。
最坏情况:O(nlog2n)
【稳定性】不稳定
【优点】
快,数据移动少
【缺点】
不稳定,d的取值是多少,应取多少个不同的值,都无法确切知道,只能凭经验来取
/**
* 希尔排序
* @param arr
*/
public static void shellSort(int arr[]) {
//首先取增量为待排序列长度的一半
int d = arr.length / 2;
while (d >= 1) {
//对每个分组进行直接插入排序
for (int i = d; i < arr.length; i++) {
//目标值总是每个分组内无序区的首元素
int target = arr[i];
int j = i - d;
while (j >= 0 && target < arr[j]) {
arr[j + d] = arr[j]; //后移
j -= d;
}
//判读是否发生后移,如果发生后移,则将目标元素插入指定位置
if (j != i - d) {
arr[j + d] = target;
}
}
//增量每次减半
d /= 2;
}
}
【本算法解读】
希尔排序算法是对直接插入排序的改进,所以如果对直接插入排序还不够理解的话,建议先去看一下上面的直接插入排序算法。
算法首先取增量为待排序列长度的一半,通过增量进行分组,每个分组都进行直接插入排序。然后,增量减半,重复上述操作直至增量减少为1(对应代码:while(d>=1))。算法的要点在于对每个分组进行直接插入排序。首先从i=d位置上的元素开始,一直到待排序列的最后一个元素,依次作为目标元素,在它们所在的小组中进行直接插入排序。当目标元素位置为i时,则目标元素所在小组有序区内的元素位置分别为i-d,i-d-d,i-d-d-d...直到该位置大于0。目标元素只需要和所在小组有序区内的元素进行比较,找到自己的最终位置即可。当增量减少为1时,则相当于只有一个分组,此时就是对所有元素进行直接插入排序,完成最终的希尔排序。
【举个栗子】
对于待排序列4,1,3,2
先看图:
首先取增量为序列长度4的一半,即为2。此时的分组是(4,3),(1,2)。则从位置为2的元素3开始作为目标元素,位置2减去增量2,则找到目标元素3所在小组有序区内的元素4,和4进行比较,3<4,则4后移(这里的后移都是相对于自己所在小组里的元素),有序区内没有其它元素,则目标元素3插入到元素4之前的位置。继续从位置为3的元素2开始作为目标元素,找到目标元素2所在小组有序区内的元素1,比较2<1,不需要后移,目标元素插入到元素1的后面,其实就是没右移动。此时完成一趟希尔排序。序列为3,1,4,2。增量减半,即为1。此时就是对所有元素进行直接插入排序,不再赘述。
希尔排序的关键点在于分组,我们再举个栗子看是如何分组的:
对于序列25,36,48,59,13,21,74,32,60
第一次增量为序列长度9的一半,取下限是4,此时的分组情况是(25,13,60)(36,21)(48,74)(59,32),如下图:
那么到这里,交换排序中的冒泡排序与快速排序和插入排序中的直接插入排序与希尔排序,就总结完成了。下一篇我们会继续总结选择排序中的直接选择排序算法与堆排序算法,以及归并排序算法,感兴趣的朋友可以继续阅读常见排序算法汇总与分析(中)(选择排序与归并排序)
为了方便大家,我已将本系列中用到的所有算法源码(java实现)上传至CSDN,需要的朋友可以点此下载