快速排序如何实现子问题平衡

简介

快速排序是由C.A.R.Hoare在1960年发明的。快速排序是应用最广泛的排序算法之一,快速排序的实现简单,平均时间复杂度是O(NlgN),而且空间复杂度为\(O(n)\),是一种原地排序算法,但是如果我们选取的分区点不同,对同一组数据排序的时间复杂度也就不同,假如算法每一次调用时都出现了划分的区域一个包含\(n-1\)个元素和一个0元素这种最坏的情况,这时快速排序算法的优势就不在显现。其中影响算法复杂度的最主要的一个因素就是分区点的选取,这时算法的子问题平衡问题就转化为了如何选取一个“优秀”的分区点,最佳的分区点就是中位数,那么如何选取一个中位数呢?

快速排序原理解释

快速排序的思路

像合并排序一样,快速排序也是利用了一种分治思想,下面是对一个典型子数组\(A[L..R]\)排序的分治过程的三个步骤:

  1. 分解:通过选取一个分区点p,将\(A[L..R]\)划分为两个子数组\(A[L..p-1]\)\(A[p+1..R]\),使得\(A[L..p-1]\)中的每一个元素都小于等于A[p]并且\(A[p+1..R]\)中的每一个元素大于\(A[p]\)
  2. 解决:通过递归调用快速排序,再对子数组\(A[L..p-1]\)\(A[p+1..R]\)进行排序。
  3. 合并:因为两个子数组是就地排序的,将他们合并不需要操作:整个数组\(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。
在《算法导论》中,作者对该算法步骤的描述:

  1. 将输入阵列的\(n\)个元素分成\([N/5]\)组,每组5个元素,最多一组由剩余的\(N mod 5\)元素组成。
  2. 通过首先对每个组(最多有5个)的元素进行插入排序,然后从组元素的排序列表中挑选中值,找到每个\([n/5]\)组的中值。
  3. 递归地使用SELECT来查找在步骤2中找到的\([n/5]\)个中值的中值\(X\)(如果存在偶数个中值,则根据我们的约定,X是较低的中值)。
  4. 使用PARTITION的修改版本围绕中值\(x\)对输入数组进行分区。令K比划分的低侧上的元素的数目多1,使得\(x\)是第\(k\)个最小元素,并且在划分的高侧上有\(n-k\)个元素。
  5. 如果\(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)\)
递归式:

\[T(n)=T(\frac n5)+O(n) \]

可以使用主方法求解:得到复杂度为\(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})\)
最后的递归式:

\[T(n)=T(\frac n5)+O(n) <=\frac{cn}5+\frac{7cn}{10}+an =\frac{9cn}{10}+an =cn+(-\frac{cn}{10}+an) \]

使用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时,改用插入排序。

posted @ 2024-03-13 01:19  YJQING  阅读(64)  评论(0)    收藏  举报