经典排序算法
排序算法有很多,但最经典最常用的排序算法逃不过:冒泡排序、插入排序、选择排序、快速排序、归并排序、计数排序、基数排序和桶排序。
1 衡量排序算法好坏的三方面
1.1 执行效率
- 最好情况、最坏情况、平均情况时间复杂度
- 时间复杂度的系数、常数和低阶(在数据规模小的时候,往往需要考虑)
- 比较次数和交换或移动次数
1.2 内存消耗
针对排序算法而言,内存消耗即空间复杂度。空间复杂度为O(1)的排序算法也被称为原地排序算法。
1.3 稳定性
如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,即是稳定的排序算法
2 有序度、逆序度、满有序度
有序元素对:a[i] <= a[j],如果i<j,那么(a[i],a[j])是一个有序对。
如2,1,3,4按从小到大排序,有序元素对为(1,3),(1,4),(3,4),(2,3),(2,4),有序度为5。
同理,逆序元素对的个数为(2,1),逆序度为1。
满有序度:完全有序的数组的有序度,如一个数组有n个元素,那么两两组成的数对都是有序的。即满有序度 = 有序度 = n*(n-1)/2。
所以,满有序度 = 有序度 + 逆序度
3 冒泡排序
基本原理:每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让他俩互换(有序度+1)。一次冒泡会让至少一个元素移动到它应该在的位置,重复n-1次,就完成了n个数据的排序工作。
冒泡优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用在继续后续的冒泡操作。
// 冒泡排序,从小到大排序
public void bubbleSort(int[] a) {
int n = a.length;
if (n <= 1) {
return;
}
// 循环n次
for (int i = 0; i < n; ++i) {
// 数据交换标志位
boolean flag = false;
// 第(n-i-1)及其之后的元素已经有序
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
flag = true;
}
}
if (!flag) {
break;
}
}
}
冒泡排序特点:
-
原地排序:空间复杂度O(1)
-
稳定排序:
-
最好时间复杂度O(n):原数据已经全部有序,只需进行一次冒泡,如[1,2,3,4,5,6,7,8]。
-
最坏时间复杂度O(n²):原数据刚好是倒序,需进行n次冒泡,如[8,7,6,5,4,3,2,1]。
-
平均时间复杂度O(n²):对包含n个元素的数组进行冒泡排序,最坏情况下初始状态有序度是0,需要进行n(n-1)/2次交换。最好情况下,初始状态有序度为满有序度,即n(n-1)/2,无需进行交换。平均情况下,有序度取中间值为n(n-1)/4,表示初始有序的的平均情况。也就是平均情况下需要n(n-1)/4次交换操作,比较操作肯定要比交换操作多,而这个复杂度的上限是O(n²),所以可粗略地认为冒泡排序平均情况下时间复杂度是O(n²)。
4 插入排序
基本原理:动态地往有序集合中添加数据。将数组分成已排序区和未排序区,初始已排序区只有一个元素,就是数组的第一个元素。插入排序取未排序区间中的元素,在已排序区间找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
// 插入排序,从小到大排序
public static void insertionSort(int[] a){
int n = a.length;
if (n <= 1) {
return;
}
for (int i = 1; i < n; ++i){
int value = a[i];
int j = i-1;
// 查找插入位置
for(; j >= 0; --j){
if (a[j] > value){
// 数据移动
a[j+1] = a[j];
}else{
break;
}
}
a[j+1] = value;
}
}
插入排序特点:
- 原地排序:空间复杂度O(1)
- 稳定排序:
- 最好时间复杂度O(n):原数据已经全部有序,只需遍历一遍,不需要移动数据,如[1,2,3,4,5,6,7,8]。
- 最坏时间复杂度O(n²):原数据刚好是倒序,每一次插入都需移动大量数据,如[8,7,6,5,4,3,2,1]。
- 平均时间复杂度O(n²):在有序数组中插入一个数据的平均时间复杂度为O(n),插入排序每次插入操作都相当于在有序数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度是O(n²)
5 选择排序
基本原理:选择排序与插入排序一样,会将数组分为已排序区和未排序区,但选择排序每次会从未排序区间中找最小的元素(如果是从小到大排序),将其放到已排序区间的末尾。
// 选择排序,从小到大排序
public static void selectionSort(int[] a) {
int n = a.length;
if (n <= 1) {
return;
}
for (int i = 0; i < n - 1; ++i) {
int k = i;
// 查找未排序区的最小值的下标
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[i]) {
k = j;
}
}
// 将最小值与未排序区的第一个值交换(即放到已排序区末尾)
if (k != i) {
int temp = a[i];
a[i] = a[k];
a[k] = temp;
}
}
}
选择排序特点:
- 原地排序:空间复杂度O(1)
- 不稳定排序:选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样会破坏稳定性。如[3(A),2,3(B),1],在第一遍检索到最小值[1],并且与[3(A)]互换位置,此时为[1,2,3(B),3(A)],稳定性被破坏。
- 最好、最坏、平均时间复杂度O(n²):就算原数据全部有序,也得遍历n边,找n遍的最小值,只是少了交换的过程,所以最好、最坏、平均时间复杂度均为O(n²)
冒泡VS插入VS选择
因为选择排序是不稳定排序,所以相比冒泡和插入逊色。
冒泡排序不管怎么优化,元素交换的次数是一个固定值,即原始数据的逆序度。
插入排序不管怎么优化,元素的移动次数也等于原始数据的逆序度。
但从代码实现上看,冒泡排序的数据交换比插入排序的数据移动要复杂,冒泡需要三个赋值操作,插入排序只需要一个。
所以在数据规模大的时候,插入排序还是会优于冒泡排序。
6 归并排序
基本原理:如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。(分解–>合并,其实就是分治思想,分而治之,将大问题分解成小子问题来解决,小的子问题解决了,大问题也就解决了。而分治思想可以用递归去实现)
写递归代码的技巧:分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。
归并排序的递推公式:
merge_sort(p...r) = merge(merge_sort(p...q),merge_sort(q+1...r))
终止条件:
p >= r,不能在继续分解
// 归并排序,从小到大排序
// 递归调用时或者多次循环里不要有申请大数组的操作,否则会导致超时
// 所以临时数组tmp,外层传入更合适
public static void mergeSort(int[] a, int[] tmp, int left, int right) {
// 递归终止条件
if (left >= right) {
return;
}
// 取left到right的中间位置mid
int mid = (left + right) / 2;
// 分治递归
mergeSort(a, tmp, left, mid);
mergeSort(a, tmp, mid + 1, right);
// 将a[left...mid]和a[mid+1...right]合并为a[left...right]
merge(a, tmp, left, mid, right);
}
public static void merge(int[] a, int[] tmp, int left, int mid, int right) {
int p1 = left;
int p2 = mid + 1;
int i = left;
while (p1 <= mid && p2 <= right) {
if (a[p1] <= a[p2]) {
tmp[i++] = a[p1++];
} else {
tmp[i++] = a[p2++];
}
}
// 如果第一个序列未检测完,就将第一个序列剩余部分直接拷贝到合并的序列中
while (p1 <= mid) {
tmp[i++] = a[p1++];
}
// 如果第二个序列未检测完,就将第二个序列剩余部分直接拷贝到合并的序列中
while (p2 <= right) {
tmp[i++] = a[p2++];
}
// 将tmp中的数组拷贝回a[left...right]
for (i = left; i <= right; ++i) {
a[left++] = tmp[i];
}
}
归并排序特点:
非原地排序:空间复杂度O(n)。
稳定排序:在合并的过程中,如果a[left…mid],a[mid+1…right]之间有相同的元素,我们只要先把a[left…mid]中的元素放tmp数组。即可保证排序稳定。
时间复杂度:最好、最坏、平均时间复杂度均为O(nlogn)
递归的适用场景时,一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决后,我们再把b、c的结果合并成a的结果。
如果我们定义求解问题a的时间是T(a),求解问题b、c的时间是T(b)、T©,那么我们可以得到这样的递推关系式:
T(a)=T(b)+T©+k,其中k为将子问题b、c的结果合并成问题a的结果所消耗的时间。
假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。而merger()函数的时间复杂度显而易见是O(n),所以归并排序的时间复杂度计算公式为:
T(1) = C; n = 1时,只需要常量级的执行时间。
T(n) = 2*T(n/2) + n; n>1
通过递推公式,求解T(n)
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
...
= 2^k * T(n/2^k) + k*n
当T(n/2^k) = T(1)时,也就是n/2^k = 1,即k=logn。
则T(n) = Cn + nlogn
用大O标记法来表示的话,T(n)就等于O(nlogn)。
7 快速排序
基本原理:如果要排序数组是下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。我们遍历p到r之间的数据,将小于pivot的放在左边,将大于pivot的放在右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q到r之间的数据,直到区间缩小为1,就说明所有数据都有序了。
快排的递推公式:
quick_sort(p...r) = quick_sort(p...q-1) + quick_sort(q+1...r)
终止条件:
p >= r,不能在继续分解
public static void quickSort(int[] a, int left, int right) {
if (left >= right) {
return;
}
// 获取分区点
int q = partition(a, left, right);
quickSort(a, left, q - 1);
quickSort(a, q + 1, right);
}
// 分区函数
public static int partition(int[] a, int left, int right) {
int pivot = a[right];
int i = left;
for (int j = left; j <= right - 1; ++j) {
if (a[j] < pivot) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
i++;
}
}
int tmp = a[i];
a[i] = a[right];
a[right] = tmp;
return i;
}
分区过程示意图:
快速排序特点:
原地排序:空间复杂度O(1)。
不稳定排序:分区过程中,相对顺序可能会改变。
时间复杂度:最好、平均复杂度为O(nlogn),分析过程与归并相同。
T(1) = C; n = 1时,只需要常量级的执行时间。
T(n) = 2*T(n/2) + n; n>1
上诉公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等的一分为二,但实际上这种情况很难达到。
极端例子1:[1,3,5,8,9],数组中原来的数据已经是有序的,如果我们每次选最后一个元素作为pivot,那么每次分区得到的两个取件都是不均等。我们大约需要n次分区操作,才能完成快排的整个过程,每次分区我们平均都要扫描n/2个元素,这种情况下,快排的时间复杂度就会退化成O(n²)。这就是快排的最坏时间复杂度。
归并VS快排
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。
快排的处理过程是由上到下的,先分区,然后在处理子问题。
归并是稳定的非原地排序。
快排是不稳定的原地排序。