注:所有的代码在我的Github中有均具体C++代码实现。
这里主要讲的是时间复杂度为O(nlogn)的两种排序算法:快速排序(Qiuck sort)和 归并排序 (Merge sort)。
这两种排序都是用了分治的思想,我们因此可以借鉴这个思想来解决非排序的一些问题,例如:如何在O(n)的时间复杂度内查找一个无序数组中的第K大的元素?
归并排序 (Merge sort)
简介
简单地说,如果要排序一个数组,我们首先吧数组从中间分成前后两个部分,然后对前后两个部分分别排序,最后将排序好的两部分进行合并。
这里使用了分治的思想,也就是分而治之,将一个大问题分成几个小的子问题来解决,小的问题解决了,大的问题也就解决了。
这里,主要就是merge函数的实现问题了,merge(A[p...r], A[p...q], A[q+1...r])
也就是将已经有序的A[p...q]
和A[q+1...r]
合并成一个有序的数组,这里我们使用了一个额外的临时数组,其空间带下为r - p + 1
,具体操作如下:
代码
void merge(int arr[], int l, int m, int r)
{
int n = r - l + 1;
// 分配临时的数组空间
int *tmp = new int[n];
int i, j, k;
for(i = l, j = m + 1, k = 0; i <= m && j <= r;)
{
if(arr[i] <= arr[j])
tmp[k++] = arr[i++];
else
tmp[k++] = arr[j++];
}
if(i == m + 1)
{
for(; j <= r;)
tmp[k++] = arr[j++];
}else
{
for(; i <= m;)
tmp[k++] = arr[i++];
}
for(i = l; i < n + l; i++)
arr[i] = tmp[i - l];
delete tmp;
}
// 另外一种实现方式
//void merge(int arr[], int l, int m, int r)
//{
// int i, j, k;
// int n1 = m - l + 1;
// int n2 = r - m;
//
// int L[n1], R[n2];
//
// for (i = 0; i < n1; i++)
// L[i] = arr[l + i];
// for (j = 0; j < n2; j++)
// R[j] = arr[m + 1 + j];
//
// i = 0;
// j = 0;
// k = l;
// while (i < n1 && j < n2)
// {
// if (L[i] <= R[j])
// {
// arr[k] = L[i];
// i++;
// }
// else
// {
// arr[k] = R[j];
// j++;
// }
// k++;
// }
//
// while (i < n1)
// {
// arr[k] = L[i];
// i++;
// k++;
// }
//
// while (j < n2)
// {
// arr[k] = R[j];
// j++;
// k++;
// }
//}
void mergeSort(int arr[], int l, int r)
{
if (l < r)
{
int m = l + ((r - l) >> 1);
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
性能分析
-
归并排序是稳定排序,具体的过程可以看代码中的merge()函数的实现过程。
-
归并排序的时间复杂度为O(nlogn),我们可以使用递推公式的方法来进行计算。
我们知道假设对 n 个元素进行归并排序需要的时间为T(n),那么分解为两个子数组排序的时间为T(n/2)。并且merge()函数的时间复杂度为O(n)。所以,套用公式,计算可为:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1 T(n) = 2*T(n/2) + n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n ...... = 2^k * T(n/2^k) + k * n ......
当**T(n/2^k) = T(1)**时,我们可以得到:
将 k 的值带入进去 我们可以得到: -
因为在marge()函数中,我们使用了额外的数组空间,所以时间复杂度为O(n)。注意:虽然在合并的过程中,每次合并都需要申请额外的内存空间,但是合并之后,临时开辟的空间就释放了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。
快速排序(Quick sort)
简介
快排利用的也是分治的思想:如果要排序数组中下标从 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+1到r的数据集进行排序,直到区间缩小为1,这就说明所有的数据都有序了。
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)
终止条件:
p >= r
这里有个重点就是partition函数,就是随机选择一个元素作为 pivot(分界点)(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后将分界左边就是比pivot小的数据,分界点右边就是比pivot大的数据。
例如:
【9,8,7,6,5,4,3,2,1,0】如果选择第一个点9作为pivot,那么最终就分为了===》
【8,7,6,5,4,3,2,1,0,9】,所以选择pivot点是很重要的。
代码
partition()函数的实现常见的有两种实现方式:
int partition(int *a, int p, int r)
{
int i, j;
i = j = p;
int pivot = a[r];
for(; j < r; j ++)
{
if(a[j] < pivot)
{
if(i != j)
{
swap(a[i], a[j]);
}
i ++;
}
}
swap(a[i], a[r]);
return i;
}
这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分。A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。
int partition(int *a, int p, int r)
{
int pivot = a[p];
while(p < r)
{
// 从右向左边扫描,找比pivot小的
while (p < r && a[r] > pivot) r --;
a[p] = a[r];
// 从左向右边扫描,找pivot大的
while (p < r && a[p] < pivot) p ++;
a[r] = a[p];
}
a[p] = pivot;
return r;
}
这种方法貌似是在算法导论这本书中实现方法,其实和第一种不同的就是,这里是两个指针同时移动。
经过调查,貌似第一种方式要比第二种的速度略快一点。
性能分析
如果我们每次都分的很均匀的情况下,也就是每次都能分到n/2的区间的话,利用上面的公式可以得到快速排序的时间复杂度为 O(nlogn),只有在极端情况下,例如在数组本身就是排序的情况下,才会退化到 O(n^2)。
针对极端情况下的情况,有很多优化的方法:见此===》博客
优化一:三数取中法,解决数据基本有序的(就是找到数组中最小下标,最大下标,中间下标的数字,进行比较,把中间大的数组放在最左边)。
优化二:随机选取基准。
原因:在待排序列是部分有序时,固定选取枢轴使快排效率底下,要缓解这种情况,就引入了随机选取枢轴。
优化三:优化小数组的交换,就是为了解决大才小用问题。
原因:对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。
截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。
排序算法的结合
为了让你对如何实现一个排序函数有一个更直观的感受,我拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。
如果你去看源码,你就会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。
所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。那 qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是“三数取中法”。是不是也并不复杂?
还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。
实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为,在小规模数据面前,O(n^2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。
对于小规模数据的排序,O(n^2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。
这样的方法还有很多,例如Java中Arrays.sort的方法,也是采用了一些优化的方法,并将几种方法结合起来。
总结
- 归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
- 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题
- 我们知道,快速排序是用递归来实现的。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。
来源:CSDN
作者:小耗子Deng
链接:https://blog.csdn.net/HaoTheAnswer/article/details/104629884