快速排序如何实现子问题平衡
简介
快速排序是由C.A.R.Hoare在1960年发明的。快速排序是应用最广泛的排序算法之一,快速排序的实现简单,平均时间复杂度是O(NlgN),而且空间复杂度为\(O(n)\),是一种原地排序算法,但是如果我们选取的分区点不同,对同一组数据排序的时间复杂度也就不同,假如算法每一次调用时都出现了划分的区域一个包含\(n-1\)个元素和一个0元素这种最坏的情况,这时快速排序算法的优势就不在显现。其中影响算法复杂度的最主要的一个因素就是分区点的选取,这时算法的子问题平衡问题就转化为了如何选取一个“优秀”的分区点,最佳的分区点就是中位数,那么如何选取一个中位数呢?
快速排序原理解释
快速排序的思路
像合并排序一样,快速排序也是利用了一种分治思想,下面是对一个典型子数组\(A[L..R]\)排序的分治过程的三个步骤:
- 分解:通过选取一个分区点p,将\(A[L..R]\)划分为两个子数组\(A[L..p-1]\)和\(A[p+1..R]\),使得\(A[L..p-1]\)中的每一个元素都小于等于A[p]并且\(A[p+1..R]\)中的每一个元素大于\(A[p]\)。
- 解决:通过递归调用快速排序,再对子数组\(A[L..p-1]\)和\(A[p+1..R]\)进行排序。
- 合并:因为两个子数组是就地排序的,将他们合并不需要操作:整个数组\(A[L..R]\)已经排好序。
下面是分解的步骤:

由此可以得到推导公式:
递推公式
quick_sort(A[p...r]) = quick_sort(A[p...q-1]) + quick_sort(A[q+1...r])
终止条件
p >= q
完整的排序过程:

快速排序的代码实现
递归过程:
public static void quickSort(int[] array, int L, int R) {
if(L>=R) return; // 如果子数组的长度为0或1就表明已经排好序,直接返回即可。
int p = partSort(array, L, R); // 定义一个整型p用来表示分区点。
quickSort(array, L, p - 1); // 递归到左子数组
quickSort(array, p + 1, R); // 递归到右子数组
}
对单个数组使用选取的分区点进行划分
public static int partSort(int[] array, int L, int R) {
int x = array[R]; // 该代码选取分区点为右端点
int i = L-1;
for (int j = L; j < R; j++) {
if (array[j] <= x) //如果比分区点小,就交换
{
i++;
swap(array,i,j);
}
}
swap(array, i+1, R); // 将分区点移至中间
return i+1;
}
常见的分区点的选取
1.固定位置选取基准值
基本思想:选取第一个或最后一个元素作为基准值。
这种选取基准值的方法在整个数列已经趋于有序的情况下,效率很低。比如有序序列(0,1,2,3,4,5,6,7,8,9),当我们选取0为基准值的时候,需要将后面的元素每个都交换一遍,效率很低。所以这种以固定位置选取基准值的方式,只适用于该序列本身并不是趋于有序的情况下。
2.随机选取基准
基本思想:选取待排序列中任意一个数作为基准值。
引入随机化快速排序的作用,就是当该序列趋于有序时,能够让效率提高,大量的测试结果证明,该方法确实能够提高效率。但在整个序列数全部相等的时候,随机快排的效率依然很低,它的时间复杂度为O(N^2),但出现这种最坏情况的概率非常的低,所以它还是一种效率比较好的方法,一般情况下都能够达到O(N*lgN)。
3.三数取中法选取基准值
采用三数取中法很好的解决了很多特殊的问题,但对于很多重复的序列,效果依然不好。
由此,我们需要找到一个可以较为稳定的查找一个中位数的算法。
BFPTR算法介绍
BFPTR算法是由Blum、Floyed、Pratt、Tarjan、Rivest这五位牛人一起提出来的,在维基百科上,该算法被称为Median of medians,因为中位数在这里起到了至关重要的角色,其特点在于可以以最坏复杂度为\(O(n)\)地求解\(top−k\)问题,这个算法首先将集合中的数分5个一组,然后找到每组的中位数,将中位数放入一个集合,然后再使用第一次的方法递归求这个集合的中位数m。
在《算法导论》中,作者对该算法步骤的描述:
- 将输入阵列的\(n\)个元素分成\([N/5]\)组,每组5个元素,最多一组由剩余的\(N mod 5\)元素组成。
- 通过首先对每个组(最多有5个)的元素进行插入排序,然后从组元素的排序列表中挑选中值,找到每个\([n/5]\)组的中值。
- 递归地使用SELECT来查找在步骤2中找到的\([n/5]\)个中值的中值\(X\)(如果存在偶数个中值,则根据我们的约定,X是较低的中值)。
- 使用PARTITION的修改版本围绕中值\(x\)对输入数组进行分区。令K比划分的低侧上的元素的数目多1,使得\(x\)是第\(k\)个最小元素,并且在划分的高侧上有\(n-k\)个元素。
- 如果\(i=k\),则返回\(x\)。否则,如果\(i<k\),则递归使用SELECT查找低端的第\(i\)个最小元素;如果\(i>k\),则递归使用SELECT查找高端的第\((i-k)\)个最小元素。
BFPTR算法代码实现
因为我们已经写出了快速排序,所以我们只需要BFPTR算法的求中位数的过程:
//查找中位数
public static int selectMiddle(int a[], int l, int r) {
if (l == r) //只有一个数的时候
return a[l];
int len = r - l + 1; //数组长度
int lenMiddle = len / 5; //lenMiddle表示有多少个中位数
if (len % 5 != 0) lenMiddle += 1;
//寻找中位数的中位数
int i = l;
for (int j = 0; j < lenMiddle; j++) {
if ((i + 5) < r) { //五个五个为一组
insort(a, i, i + 4);
swap(a, l + j, i + 2);
i = i + 5;
} else {//最后剩余的数
int num = r - i + 1;//计算剩余几个数
insort(a, i, i + num - 1);
swap(a, l + j, i + num / 2);
break;
}
}
if (lenMiddle - 1 == l) //此时数组只有一个元素
return a[lenMiddle];
return selectMiddle(a, l, l + lenMiddle - 1);
}
复杂度分析:
- selectMiddle(a, l, l + lenMiddle - 1);
该代码决定了每一次递归后子问题仅为一个 - 而每一次我们将数据分为5个一组决定了每一次递归的子问题的数据规模为原问题的1/5
- 最后,在组内排序的过程复杂度为O(n)
在该部分代码中先对\(\frac N5\)个数组执行插入排序,找出每一组的中位数,该过程用时\(O(n)\)。然后对这个\(\frac N5\)数递归调用BFPRT,找到中位数,该过程用时\(T(\frac n5)\)。
递归式:
可以使用主方法求解:得到复杂度为\(O(n)\)。
两部分代码相结合
首先需要添加插入查询用来排序每个分组:
//插入排序
public static void insort(int []a, int l, int r) {
for (int m = l + 1; m <= r; m++)
for (int i = m; i > l; i--) {
if (a[i] < a[i - 1]) {
swap(a, i, i - 1);
}
}
}
为什么使用插入排序而不是其他的排序算法?
对于待排序的序列长度很小或是基本趋于有序时,插入排序(Insertion Sort)通常比其他排序算法更适用,原因主要有以下几点:
-
简单直观:
插入排序的算法逻辑简单直观,易于理解和实现。对于小规模数据,不需要复杂的数据结构或递归调用,直接通过简单的比较和移动操作即可完成排序。 -
局部性:
当序列基本趋于有序时,插入排序的效率非常高。这是因为插入排序在每次迭代中,都会将当前元素插入到已排序部分的正确位置。对于有序序列或几乎有序的序列,这种插入操作通常只需要少量的比较和移动。 -
空间效率:
插入排序是一种原地排序算法,只需要常数级别的额外空间来存储临时变量。这意味着它不需要额外的存储空间来存放待排序的数据,非常适合内存受限的环境。 -
对小规模数据的高效性:
对于小规模数据,插入排序的时间复杂度可以达到O(n),其中n是待排序序列的长度。由于数据规模较小,算法中的常数因子对性能的影响较小,因此插入排序能够表现出较好的效率。
相比之下,其他排序算法如快速排序、归并排序等,在处理小规模数据或有序数据时可能并不如插入排序高效。快速排序在处理有序数据时可能会遇到最坏情况,导致时间复杂度退化到O(n^2)。而归并排序虽然具有稳定的O(nlogn)时间复杂度,但由于其涉及到递归和合并操作,对于小规模数据而言,这些操作的开销可能相对较大。
再添加根据元素的值找到下标的函数:
public static int findIndex(int a[], int l, int r, int t) {
for (int i = l; i <= r; i++)
if (a[i] == t) return i;
return -1;
}
最后修改原来快速排序的代码:
public static int partSort(int[] array, int L, int R) {
int x = selectMiddle(array, L, R); //找到中位数的中位数
int xIndex = findIndex(array, L, R, x); //找到中位数的中位数的下标
System.out.println(x);
int i = L-1;
for (int j = L; j < R; j++) {
if (array[j] <= x) //如果比分区点小,就交换
{
i++;
swap(array,i,j);
}
}
swap(array, i+1, xIndex); // 将分区点移至中间
return i+1;
}
复杂度分析:
选取刚才找到的中位数作为主元,执行一次划分过程,用时\(O(n)\)。接下来,根据快速选择算法的要求,我们需要判断选择主元的左侧还是右侧进一步递归。显然,选择哪一侧并不重要,重要的是选择的那一侧数据规模的上限是多少。前面刚刚分析过近似中位数所处的区间在\(30%~70%\)范围内,那么最坏情况下,近似中位数恰好是30%或者70%分位点,于是左右两侧的数据规模将分别是\(\frac {3N}{10}\)和\(\frac {3N}{10}\),若选择最坏情况,认为每次都对\(\frac {3N}{10}\)的那一侧进行递归,于是该过程用时\(T(\frac{7N}{10})\)
最后的递归式:
使用BFPTR算法在每一趟快速排序中添加了查找中位数的过程,由于两者复杂度都是\(O(n)\),所以没有增加原来的快速排序的复杂度。这样,无论在什么情况下,算法的时间复杂度就始终是\(O(nlogn)\)。
为什么sort函数效率那么高
首先,STL的sort 不仅仅是快排,所以你只是手写快排,哪怕是尾递归式的快排,也不会有它快。
它会先走快排主递归逻辑,但是递归深度是有限的,而且当子区间够小时,会走插入排序,下面是标准库里几行代码,具体直接看代码就可以了。
template<class _RanIt,
class _Diff,
class _Pr> inline
void _Sort_unchecked1(_RanIt _First, _RanIt _Last, _Diff _Ideal, _Pr& _Pred)
{ // order [_First, _Last), using _Pred
_Diff _Count;
while (_ISORT_MAX < (_Count = _Last - _First) && 0 < _Ideal)
{ // divide and conquer by quicksort
pair<_RanIt, _RanIt> _Mid =
_Partition_by_median_guess_unchecked(_First, _Last, _Pred);
_Ideal /= 2, _Ideal += _Ideal / 2; // allow 1.5 log2(N) divisions
if (_Mid.first - _First < _Last - _Mid.second)
{ // loop on second half
_Sort_unchecked1(_First, _Mid.first, _Ideal, _Pred);
_First = _Mid.second;
}
else
{ // loop on first half
_Sort_unchecked1(_Mid.second, _Last, _Ideal, _Pred);
_Last = _Mid.first;
}
}
if (_ISORT_MAX < _Count)
{ // heap sort if too many divisions
_Make_heap_unchecked(_First, _Last, _Pred);
_Sort_heap_unchecked(_First, _Last, _Pred);
}
else if (2 <= _Count)
_Insertion_sort_unchecked(_First, _Last, _Pred); // small
}
_ISORT_MAX =32:如果待排序个数小于32个,会采用插入排序;
_Ideal 用来控制递归深度,如果递归太多会采用堆排序;
针对大数据量,使用快排,时间复杂度是O(NlogN);
若快排递归深度超过阈值_Ideal ,改用堆排序,防止快排递归过深,同时保持时间复杂度仍是O(NlogN);
当数据规模小于阈值_ISORT_MAX时,改用插入排序。

使用BFPTR算法实现快速排序的子问题平衡
浙公网安备 33010602011771号