在学习快速排序的时候,发现很多人说的快速排序方法不一样(比如教材和网上说到的快速排序过程不一样),很好奇是为什么,而且开发、考试等很多场景都需要用到。就去翻阅了一下《算法导论》想搞明白,发现这种情况的原因和快速排序的发展史有关。
于是就查阅了很多资料,来写下本文作为记录。
由于篇幅有点长,建议通过左边的目录阅读,方便跳转。
快速排序的发展史
如果你看《算法导论》上关于快速排序这一章,那么书上会告诉你快速排序最早是由 C.A.R Hoare(1980 年图灵奖得主)于 1962 年发表在《The Computer Journal》第五卷上发表的论文《Quicksort》中首先提出的(https://doi.org/10.1093/comjnl/5.1.10)。但是在1961年底《Communications of the ACM》中,C.A.R Hoare 就已经在《Algorithm 64: Quicksort》中使用 Algol 60 算法编程语言给出了一个简单的解释,如下:
并且如果你查看了刚才列出的论文《Quicksort》,那么就会发现,论文唯一的引用文献就是1961年底的《Algorithm 64: Quicksort》,如下:
而《Quicksort》只是描述了这个方法,以及很多特性和性能,并且使用数学方法证明了快速排序的时间复杂度。
那么快速排序算法到底是什么时候被发明的呢?
这就需要了解一下 Hoare 先生的经历了。
C.A.R Hoare 全名 Charles Antony Richard Hoare。
1952 年至 1956 年期间,Hoare 先生就读于牛津莫顿学院的人文科学专业(Literae Humaniores)。
毕业之后,由于当时英国的要求,就去英国皇家海军进行两年的国家服务(National Service)了。由于大学时的文学背景,Hoare 参加了一个俄语课程来学习现代军用俄语。
1958年,Hoare 回到牛津开始学习统计学(Statistics),并且在这年,Hoare 第一次接触到了编程(Mercury Autocode)。
1959 年,Hoare 以访问学生进入莫斯科国立大学学习了一年。在此期间,Hoare 开始使用计算机科学技术来将俄国文学翻译成英文由于需要处理字典的内容,但是 Hoare 当时不了解排序算法,就自己重新发现冒泡排序算法(Bubblesort),但是觉得太慢了。又自己发明了第二种算法,于是发明了快速排序算法(Quicksort)。
1960 年,Hoare 回到英国,开始工作。
所以说 Hoare 为了将俄语翻译成英语,于 1959-1960 年发明了快速排序算法,并且于 1962 年正式通过论文《Quicksort》提出快速排序算法。
然后在 1986 年首次出版的《Programming Pearls》(编程珠玑)中,作者提到了 Nico Lomuto 有一种更简洁的方法,这便是《算法导论(第三版)》中出现的快速排序算法,一般将其称为“Lomuto-Partition”;而国内一些教材上出现的快速排序算法是 Hoare 版本的变形,被称为“Hoare-Partition”。下一节将详细讲述二者的区别,如果想看更多其他快速排序算法之间的区别,可以去看《编程珠玑》第 12 章。
参考文献
Hoare 先生经历参考文献:
大英百科全书-Tony Hoare
Interview: An Interview with C.A.R. Hoare
Hoare-Partition和Lomuto-Partition的区别
快速排序算法原理
在介绍Hoare-Partition和Lomuto-Partition的区别之前,当然需要先了解快速排序算法的原理。
快速排序算法的原理是:
- 从需要排序的数组中选择一个值作为“Pivot Element”(算法导论中将其译作“主元”,下文中为了简洁就使用“主元”一词),该值一般为左边第一个元素。
- 然后使用主元将数组划分为两个子数组,这个过程被称为“Partition”。一般情况下,左边的子数组中的任何值小于主元,右边的子数组中的任何值大于主元。
- 对于左、右的两个子数组进行迭代排序。
- 经过迭代,最后可以得到排序好了的数组。
因此,快速排序算法是有一个模板的,符合该模板的便被称为“快速排序”。这个模板就是之前出现的《ALGORITHM 64》中的模板,简单来说大致如下:
//quicksort(A, m, n)。 A是数组,m、n是整数,m表示需要排序的数组开头,m表示结尾
//这里比较m和n是为了防止排序的数组是一个少于2个元素的数组,这就没有排序的意义了
if m<n {
partition(...)
quicksort(...)
quicksort(...)
}
不同的快速排序算法区别在于partition
部分的算法,这也是算法的关键部分。所以下面先详细说说每个方法具体的实现,再进行对比。
首先来准备一个数组用于测试,为了方便理解,数组短一点,如下:
int testArr[10]={6,10,8,4,2,5};
Hoare-Partition
如何使用 C 实现
#include <stdio.h>
void myqsort(int a[], int lo, int hi);
void swap(int a[], int i, int j);
void displayArr();
int main()
{
//可以通过总尺寸除以单个尺寸方式来获取数组元素的个数
int num = sizeof(testArr)/sizeof(int);
//开始快速排序
myqsort(testArr, 0, 50);
displayArr();
return 0;
}
int hoare_partition(int a[], int lo, int hi)
{
int x=a[lo];
//这里+1/-1是因为下面使用do-while结构
int i = lo - 1;
int j = hi + 1;
while (i<j){
//从左边开始和主元x比,如果不小于主元x,就跳出循环
do {
i++;
} while (a[i]<x);
//从右边开始和主元x比,如果不大于主元x,就跳出循环
do {
j--;
} while (a[j]>x);
//在上面两次筛选大过程中,i和j在双向“奔赴”(准确说是i先j后)。
if (i<j){
//如果此时二者还没“”相遇,那么目前a[i]应该在主元x的右边,a[j]应该在主元x大左边,所以交换第i个和第j个元素。
swap(a, i, j);
}else{
//如果此时i==j了,那么就返回j,作为下一次迭代排序中的主元(这里用i也可以,但是j是当初Hoare使用的,形成惯例了)
return j;
}
}
return -1;
}
void myqsort(int a[], int lo, int hi)
{
if (lo<hi){
//开始划分数组
int m=hoare_partition(a, lo, hi);
//进行迭代排序
myqsort(a, 0, m-1);
myqsort(a, m+1, hi);
}
}
void swap(int a[], int i, int j)
{
int temp;
temp=a[i];
a[i]=a[j];
a[j]=temp;
}
void displayArr(){
for (int i=0; testArr[i]!=0; i++) {
printf("%d ",testArr[i]);
}
printf("\n");
}
运行时间测试
测试平台为 Mac mini 2018 i5-8500B 4.1GHz 32g+512g,风扇使用 Mac Fan Control 调至 4400 满转速,硬盘温度 40 度左右,CPU 温度 50~75 (Intel Power Gadget 显示为 60~80 徘徊),总之没有硬盘过热降速拖后腿。
测试数据为 100 个在0~1000000000(10^9)
范围内的整数,不过由于只用前 60 个的数据就不短了,所以就只测了 10~50 个数据的情况。60 个的情况根据数据和计算公式来说,大概需要四个多小时(实际还是测试了一下,CPU Time: 18241.949540s
,五个小时出头)。
下面数据有点离谱,等我研究一下哪里出现了问题再进行修改文章
本机 NVMe 上运行结果如下:
数组元素个数 | CPU 时间 |
---|---|
10 | 0.000005s |
20 | 0.000150s |
30 | 0.012972s |
40 | 2.463117s |
50 | 180.790424s |
60 | 18241.949540s |
在 U 盘上运行结果如下:
数组元素个数 | CPU 时间 |
---|---|
10 | 0.000004s |
20 | 0.000188s |
30 | 0.012851s |
40 | 2.536276s |
50 | 183.398252s |
二者都大致符合平均时间复杂度曲线:
n
l
o
g
2
n
nlog_2n
nlog2n
NVMe 和 U 盘性能差别如下:
Lomuto-Partition
如何使用 C 实现
这里为了简化代码,所以只列出lomuto_partition
,除了要注意声明的时候要改一下函数名之外,其余部分和上一节一模一样:
int lomuto_partition(int a[], int lo, int hi)
{
//将最右边的最高位设置为主元
int x=a[hi];
//设置i用于划分数组,左边小于主元,右边大于主元
int i = lo-1;
//这里设置j为最左边的最低位,j用于控制右边子数组的边界,不能等于主元的下标hi
for (int j=lo; j <= hi-1; j++) {
//如果a[j]小于等于主元x,那么i加1,并且交换a[i]和a[j]的元素值
if (a[j]<=x) {
i++;
swap(a, i, j);
}
}
//整个需要排序的数组比较完了,那么就需要将最高位一直用于比较的主元的元素与i位置的元素交
swap(a, i+1, hi);
return i+1;
}
运行时间测试
同样级别的测试 Lomuto-Partition 要快很多,如下:
数组元素个数 | CPU 时间 |
---|---|
10 | 0.000002s |
20 | 0.000006s |
30 | 0.000014s |
40 | 0.000032s |
50 | 0.000058s |
二者区别
逻辑上,“Hoare-Partition”是“双向奔赴,判断交换”;而“Lomuto-Partition”则是“开始-追击”。
在速度上,“Lomuto-Partition”更快一些。
在理解难易程度上,“Lomuto-Partition”也更简单。虽然很多人可能看下来会觉得“Hoare-Partition”更简单,但是可以自己尝试写一下,会发现很容易出错。
其他的快速排序算法
某些教材上的快速排序算法
在某些数据结构的教材上,可以看到以下形式的“partition”。
仔细阅读即可发现,这正是“Hoare-Partition”的一种变形,从两头往中间排序,但是又和“Lomuto-Partition”一样最后需要将主元x
的位置是最后再变的,但是先比一头,比完交换完,再比另一头。以及由于没有使用do-while
,而是使用while
语法,为了防止溢出,不得不在while (lo<hi)
中还要加上lo<hi
的限制条件。
除了要注意声明的时候要改一下函数名之外,其余部分和之前一模一样:
int partition(int a[], int lo, int hi)
{
int x=a[lo];
while (lo<hi){
while (lo<hi && a[hi]>=x){
hi--;
}
if (lo<hi){
a[lo]=a[hi];
lo++;
}
while (lo<hi && a[lo]<=x){
lo++;
}
if (lo<hi){
a[hi]=a[lo];
hi--;
}
}
a[lo]=x;
return lo;
}
测试下来时间也在二者之间,比较接近“Hoare-Partition”:
数组元素个数 | CPU 时间 |
---|---|
10 | 0.000006s |
20 | 0.000181s |
30 | 0.028264s |
40 | 2.305088s |
50 | 98.516586s |
K&R 上的qsort
在“C 语言圣经”的《The C Programming Language》(中文名:《C程序设计语言》,有时缩写为“K&R”,这是作者名的缩写) 上,写了一个快速排序算法。不过原文中是用于比较输入字符的,而且有一些新手可能看不懂不带大括号的简洁写法,所以这里的代码修改了一下。
这种快速排序算法是最简单的快速排序算法之一,同时速度也很快。由于逻辑和前面说的相似点不多,看上去也不太一样,所以原理在代码之后再说。
除了要注意声明的时候要改一下函数名之外,其余部分和之前一模一样:
int K_R_partition(int a[], int lo, int hi)
{
//i用于循环对比
//
int i, last;
//这里也就是将lo位置的元素与中间的元素交换了
swap(a, lo, (lo+hi)/2);
//这种算法中的主元其实是初始数组中间元素的a[(lo+hi)/2],因为上文已经更改了“lo”的含义
//这里将lo的初始值赋值给last,是为了让last代替lo去往右“走”,lo就可以保持住主元的值,而不用移动
last = lo;
//初始状态下,i是要比last大1大,也就是last左边开始移动
//这也说明了一点,该算法每次只处理一半的数组,直到递归到最小,即可处理完毕
for(i = lo+1; i <= hi; i++) {
//在循环的过程中,如果a[i]小于主元a[lo],那么last加1之后,将last位置的元素和i位置的元素互换
//也就是把比a[lo]小的值放到a[lo]的右边,这样最后只要将lo与last的值互换,就可以保证所有比主元小的值都在主元左边了
if (a[i] < a[lo]) {
swap(a, ++last, i);
}
}
//将last的值换回到lo上,这样所有比主元a[lo]小的值都在主元a[lo]左边了
swap(a, lo, last);
//返回last,也就是本轮划分中一直作为主元的last的值
return last;
}
这个算法每次只会排列后半部分的数组,将数组中比主元小的元素放到后半部分的左半部分,这样比主元大的元素就在后半部分的右半部分。这样多次迭代就可以实现数组的排序。
其他没有列出的快速排序算法
还有其他的快速排序算法,例如三数取中、模糊排序等等等的,由于在下还不是很了解,等以后了解了补上吧。
希望能帮到有需要的人~