深入解析快速排序(Quick Sort)

本文将对快速排序进行深入的分析和介绍。通过学习本文,您将

  • 秒杀快速排序面试
  • 掌握高效实现快排
  • 加深范型编程意识

八卦花絮

快速排序是由图灵奖获得者、计算机语言设计大佬C. A. R. Hoare在他26岁时提出的。说起C. A. R. Hoare老爷爷,可能很多人的第一印象就是快速排序,但是快排仅仅是他人生中非常小的成就而已。例如,他在1978年提出的Communicating Sequential Processes(CSP)理论,则深深的影响了并行程序设计,Go语言中的Goroutine就是这种典范。


基本思想

快速排序的思想非常简单:对于一个数组S,我们选择一个元素,称为pivot。将数组S中小于等于pivot的元素放在S的左边,大于等于pivot的元素放在S的右边。左右两部分分别记为S1和S2,然后我们递归的按上述方式对S1、S2进行排序。

具体说来,我们维护两个指针,采用两边扫描。从左到右扫描,当遇到一个元素大于等于pivot时,暂停。从右到左扫描,当遇到一个小于等于pivot元素时,暂停。然后交换这两个元素。继续扫描,直到两个指针相遇或者交叉。

从直观上看,每次递归处理的两个子数组S1、S2的大小最好是相等或者接近的,这样所花费的时间最少。


实现细节

说起来容易,做起来难了。要想正确实现快速排序非常不容易,很容易犯错。简单的修改就可能导致程序死循环或者结果错误。如果你一度感到很难在几分钟内实现一个正确的快速排序,说明你是正常人。那些五分钟内就能把快速排序写对的,几乎都是背代码。

我在实现以下代码时,就反复调试了十几分钟。而且,我会告诉你曾经JDK的某个版本实现中都存在bug么?

在给出完整代码之前,我们来考虑几个非常重要的问题。

如何选择pivot?

至少有几种显而易见的方法:

  • 尝试一:选择数组中的第一个元素。成本低,但是当输入数组已经有序时,将导致O(n2)的复杂度。例如S={1,2,3,4,5,6,7,8,9},如果选择第一个元素也就是1作为pivot,那么S1={1}, S2={2,3,4,5,6,7,8,9},两个子数组非常的不平衡。当递归对S2排序时,选择2也就是S2中第一个元素作为pivot排序时,又会将S2分成两个极其不平衡的子数组。经过简单分析可知,此时算法复杂度为O(n2)。因此这不是一个理想、健壮的方法。
  • 尝试二:随机选择一个。这种方法一般都能很好work,但是随机子程序可能非常昂贵,这可能拖慢整个程序。
  • 尝试三:取中位数。取中位数可以保证S的两个子数组是等大小的(或者相差1),但是计算中位数可不是一个轻轻松松的活儿,将会严重拖慢算法速度。
  • 尝试四:三数取中。尝试3方法太昂贵,我们可以稍微改变下:取数组第一个元素、最后一个元素、中间元素这三个元素的中位数。

遇到相等的元素怎么办?

左右扫描,如果遇到和pivot相等的元素怎么办?是暂停扫描还是继续扫描?

首先,两个方向采取的策略应该是一样的,也就是要么都暂停(然后交换),要么都继续扫描。否则将导致两个子数组不平衡。

其次,为了更好分析这个问题,我们不妨考虑所有元素都相同的情形。如果我们遇到和pivot相等的时候不停止,那么从左到右扫描时,两指针将相遇,此次过程结束。结果呢?什么都没做,却得到了两个大小极其不均衡的数组。算法时间复杂度为O(n2)。如果我们选择遇到相等元素时停止扫描,然后交换,那么虽然看上去交换的次数变多了,但是我们将得到大小相等(或者差1)的两个子数组。算法的时间复杂度为O(nlgn)。

因此,遇到和pivot相等的元素时候我们都暂停扫描,交换元素后继续,直到指针相遇或者交叉。

小数组怎么处理?

随着不断的递归,待排序的子数组大小越来越小,所含元素越来越少。当子数组所含元素较少(比如说,20个)时,由于它们已经基本有序,我们改变策略,对它们改用插入排序。这也方便了三数取中策略的实现,否则我们在三数取中的时候还得特殊考虑子数组有0个、1个、2个元素的情形。当子数组多大时我们转换排序方法呢?这个最优值就依赖于体系结构了。为了找到你系统中它的最优值,请多测试!测试!测试!


完整实现

#define INLINE __attribute__((always_inline))
template<typename T>
class MyCompareOperator
{
public:
  INLINE bool operator() (const T &a, const T &b) const { return a < b;}
};
template<typename T, typename CompareOperator, int64_t threshold = 20>
class SoupenSort
{
public:
  static void sort(T *data, int64_t size);
private:
  static void sort_(T *data, int64_t left, int64_t right, const CompareOperator &co);
  static void insertion_sort(T *data, int64_t left, int64_t right, const CompareOperator &co);
  static const T& get_pivot(T *data, int64_t left, int64_t right, const CompareOperator &co);
};
template<typename T, typename CompareOperator, int64_t threshold>
INLINE void SoupenSort<T, CompareOperator, threshold>::sort(T *data, int64_t size)
{
  CompareOperator co;
  sort_(data, 0, size - 1, co);
}

template<typename T, typename CompareOperator, int64_t threshold>
void SoupenSort<T, CompareOperator, threshold>::sort_(T *data, int64_t left, int64_t right, const CompareOperator &co)
{
  if(right - left > threshold) {
    const T& pivot = get_pivot(data, left, right, co);
    int64_t i = left;
    int64_t j = right - 1;
    while(true) {
      do
      {
        ++i;
      }while(co(data[i], pivot));
      do
      {
        --j;
      }while(co(pivot, data[j]));
      if (i < j) {
        std::swap(data[i], data[j]);
      } else {
        break;
      }
    }
    std::swap(data[i], data[right - 1]);//restore pivot
    sort_(data, left, i - 1, co);
    sort_(data, i + 1, right, co);
  } else {
    insertion_sort(data, left, right, co);
  }
}

template<typename T, typename CompareOperator, int64_t threshold>
INLINE void SoupenSort<T, CompareOperator, threshold>::insertion_sort(T *data, int64_t left, int64_t right,const CompareOperator &co)
{
  int64_t begin = left + 1;
  int64_t end = right + 1;
  for (int64_t i = begin; i < end; i++) {
    //insert data[i]. data[left to i-1] are ordered already
    int64_t j = i - 1;
    T tmp = data[i];
    while(j >-1 && co(tmp, data[j])) {
      data[j+1] = data[j];
      j--;
    }
    data[j+1] = tmp;
  }
}

template<typename T, typename CompareOperator, int64_t threshold>
INLINE const T& SoupenSort<T, CompareOperator, threshold>::get_pivot(T *data, int64_t left, int64_t right, const CompareOperator &co)
{
  int64_t mid = (left + right) / 2;
  if (co(data[mid], data[left])) {
    std::swap(data[mid], data[left]);
  }
  if (co(data[right], data[mid])) {
    std::swap(data[mid], data[right]);
  }
  if (co(data[mid], data[left])) {
    std::swap(data[mid], data[left]);
  }
  //Store pivot there to facilitate bound processing in sort_
  //data[right - 1] <= data[right]
  std::swap(data[mid], data[right - 1]);
  return data[right - 1];
}

我们把以上实现的快速排序称为SoupenSort。是的,90行不到。


测试结果

我们的对象包括STL中的sort,以及C语言里大名鼎鼎的qsort。我们的平台是Ubuntu 64位系统 + gcc 4.8

测试结果:

1000W个随机打乱的32位无符号整数

开启O2优化(单位:秒):

Sort SoupenSort Qsort
1.06 1.20 2.08

未开启O2优化(单位:秒):

Sort SoupenSort Qsort
3.29 2.93 2.91

1000W个相同的整数.

开启O2优化(单位:秒):

Sort SoupenSort Qsort
0.23 0.27 0.59

未开启O2优化(单位:秒):

Sort SoupenSort Qsort
2.60 1.56 0.76

什么结论?

没开优化,那么所需时间 Qsort < SoupenSort < Sort

开了优化,那么所需时间 Sort < SoupenSort < Qsort

为什么sort可以这么叼?据说它综合了插入排序、快速排序和堆排序。


Further Thinking

1,66行的 while(j >-1 && co(tmp, data[j])) 能否改为while(j >-1 && !co(data[j], tmp)) ? 同理,36和40行能否作相应的改动?

2,31-47行能否改为:

int64_t i = left + 1;
int64_t j = right - 2;
while(true) {
  while(co(data[i], pivot)) {
    ++i;
  }
  while(co(pivot, data[j])) {
    --j;
  }
  if (i < j) {
    std::swap(data[i], data[j]);
  } else {
    break;
  }
}

深入分析这样的case,将会对编写正确的快速排序的困难性有更深的体会,虽然我们已经有循环不变式这个强大的工具。

3,快速排序所需的栈空间是多少?能否进一步优化?

4,SoupenSort的时间复杂度是多少?O(n2)还是O(nlgn)?如果是前者,那么,什么情况下是二次的?

时间: 2024-10-02 03:29:43

深入解析快速排序(Quick Sort)的相关文章

经典排序算法 - 快速排序Quick sort

经典排序算法 - 快速排序Quick sort 原理,通过一趟扫描将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列 举个例子 如无序数组[6 2 4 1 5 9] a),先把第一项[6]取出来, 用[6]依次与其余项进行比较, 如果比[6]小就放[6]前边,2 4 1 5都比[6]小,所以全部放到[6]前边 如果比[6]大就放[6]后边,9比[6]大,放到[6

排序算法之快速排序(Quick Sort) -- 适用于Leetcode 75 Sort Colors

Quick Sort使用了Divide and Concur的思想: 找一个基准数, 把小于基准数的数都放到基准数之前, 把大于基准数的数都放到基准数之后 Worst case: O(n^2) Average case: O(nlogN) 步骤: 初始的数组 Array a[]: 0 1 2 3 4 5 6 7 8 9 51 73 52 18 91 7 87 73 48 3 基准数: X = a[0] = 51 i 的值: i = 0 j 的值: j = 9 (a.length) Step 1:

算法---快速排序(quick sort)

在前面介绍的排序算法中,最快的排序算法为归并排序,但是归并排序有一个缺陷就是排序过程中需要O(N)的额外空间.本文介绍的快速排序算法时一种原地排序算法,所需的额外空间复杂度为O(1). 算法介绍:快速排序其实一种根据需找某个元素的具体位置进行排序的方法.比如所存在如下数组 选择第一个元素5,找到5最终的位置,即5的左边的数都小于或者等于5,右边的数都大于或者等于5. 从"6"开始,可知6大于5,此处停住,从"2"开始2小于5,因此交换6与2的位置,然后接着往下走,将

排序算法 - 快速排序(Quick Sort)

算法思想 快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序.它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod). (1) 分治法的基本思想    分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题.递归地解这些子问题,然后将这些子问题的解组合为原问题的解. (2)快速排序的基本思想    设当前待排序的无序区为R[low..high],利用分治法可将快速排序的基本思想描述为:分解:      在R[low..hi

快速排序算法回顾 --冒泡排序Bubble Sort和快速排序Quick Sort(Python实现)

冒泡排序的过程是首先将第一个记录的关键字和第二个记录的关键字进行比较,若为逆序,则将两个记录交换,然后比较第二个记录和第三个记录的关键字.以此类推,直至第n-1个记录和第n个记录的关键字进行过比较为止.上述过程称为第一趟冒泡排序,接着第二趟对前面n-1个关键字进行同样操作,…… 快速排序是对冒泡排序的一种改进,通过一趟排序将记录分割成独立的两部分,其中一部分记录的关键字均比另一部分关键字小,可分别对这两部分记录以递归的方法继续进行排序,以达到整个序列有序. 单趟Partition()函数过程请看

快速排序Quick sort(转)

原理,通过一趟扫描将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列 举个例子 如无序数组[6 2 4 1 5 9] a),先把第一项[6]取出来, 用[6]依次与其余项进行比较, 如果比[6]小就放[6]前边,2 4 1 5都比[6]小,所以全部放到[6]前边 如果比[6]大就放[6]后边,9比[6]大,放到[6]后边,//6出列后大喝一声,比我小的站前边,比

排序:快速排序Quick Sort

原理,通过一趟扫描将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列 举个例子 如无序数组[6 2 4 1 5 9] a),先把第一项[6]取出来, 用[6]依次与其余项进行比较, 如果比[6]小就放[6]前边,2 4 1 5都比[6]小,所以全部放到[6]前边 如果比[6]大就放[6]后边,9比[6]大,放到[6]后边,//6出列后大喝一声,比我小的站前边,比

基础算法之快速排序Quick Sort

原理 快速排序(Quicksort)是对冒泡排序的一种改进. 从数列中挑出一个元素,称为"基准"(pivot); 排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边).在本次排序退出之后,该基准就处于数列的中间位置.这个称为分区(partition)操作; 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序. 例子 将无序数组[3,6,4,2,5,1]进行快速排序 1),先把第一项[3]取出来作为基准依次

快速排序(Quick Sort)的C语言实现

快速排序(Quick Sort)的基本思想是通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对着两部分记录继续进行排序,以达到整个序列有序,具体步骤为 设立枢轴,将比枢轴小的记录移到低端,比枢轴大的记录移到高端,直到low=high停止 分别对枢轴低高端部分再次快速排序(即重复第1步) 重复第1.2步,直到low=high停止 C语言实现(编译器Dev-c++5.4.0,源代码后缀.cpp) 原创文章,转载请注明来自钢铁侠Mac博客http:/