快速排序(QuickSort)方法的历史、代码、测试,以及Hoare-Partition和Lomuto-Partition的区别(还有很多其他快速排序算法)

在学习快速排序的时候,发现很多人说的快速排序方法不一样(比如教材和网上说到的快速排序过程不一样),很好奇是为什么,而且开发、考试等很多场景都需要用到。就去翻阅了一下《算法导论》想搞明白,发现这种情况的原因和快速排序的发展史有关。

于是就查阅了很多资料,来写下本文作为记录。

由于篇幅有点长,建议通过左边的目录阅读,方便跳转。

快速排序的发展史

如果你看《算法导论》上关于快速排序这一章,那么书上会告诉你快速排序最早是由 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的区别之前,当然需要先了解快速排序算法的原理。

快速排序算法的原理是:

  1. 从需要排序的数组中选择一个值作为“Pivot Element”(算法导论中将其译作“主元”,下文中为了简洁就使用“主元”一词),该值一般为左边第一个元素。
  2. 然后使用主元将数组划分为两个子数组,这个过程被称为“Partition”。一般情况下,左边的子数组中的任何值小于主元,右边的子数组中的任何值大于主元。
  3. 对于左、右的两个子数组进行迭代排序。
  4. 经过迭代,最后可以得到排序好了的数组。

因此,快速排序算法是有一个模板的,符合该模板的便被称为“快速排序”。这个模板就是之前出现的《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 时间
100.000005s
200.000150s
300.012972s
402.463117s
50180.790424s
6018241.949540s

在 U 盘上运行结果如下:

数组元素个数CPU 时间
100.000004s
200.000188s
300.012851s
402.536276s
50183.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 时间
100.000002s
200.000006s
300.000014s
400.000032s
500.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 时间
100.000006s
200.000181s
300.028264s
402.305088s
5098.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;
}

这个算法每次只会排列后半部分的数组,将数组中比主元小的元素放到后半部分的左半部分,这样比主元大的元素就在后半部分的右半部分。这样多次迭代就可以实现数组的排序。

其他没有列出的快速排序算法

还有其他的快速排序算法,例如三数取中、模糊排序等等等的,由于在下还不是很了解,等以后了解了补上吧。

希望能帮到有需要的人~

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 快速排序是一种常用的排序算法,它的基本思想是: 1. 从数列中挑出一个元素,称为"基准"(pivot); 2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作; 3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。 下面是快速排序的 C 语言实现: ``` #include <stdio.h> void quick_sort(int array[], int left, int right) { int i = left, j = right; int pivot = array[(left + right) / 2]; while (i <= j) { while (array[i] < pivot) i++; while (array[j] > pivot) j--; if (i <= j) { int temp = array[i]; array[i] = array[j]; array[j] = temp; i++; j--; } } if (left < j) quick_sort(array, left, j); if (i < right) quick_sort(array, i, right); } int main() { int array[] = { 9, 8, 7, 6, 5, 4, 3, 2, 1 }; int n = sizeof(array) / sizeof(array[0]); quick_sort(array, 0, n - 1); for (int i = 0; i < n; i++) printf("%d ", array[i]); return 0; } ``` 在这个代码中,`quick_sort` 函数实现了快速排序的递归过程。参数 `array` ### 回答2: 这里为您提供一个使用C语言编写的计算快速排序次数的代码。 ```c #include <stdio.h> // 快速排序的函数 void quickSort(int *arr, int low, int high, int *count) { if (low < high) { int i = low, j = high, pivot = arr[low]; while (i < j) { while (i < j && arr[j] >= pivot) { (*count)++; // 每次比较操作增加次数 j--; } if (i < j) { arr[i] = arr[j]; i++; } while (i < j && arr[i] <= pivot) { (*count)++; // 每次比较操作增加次数 i++; } if (i < j) { arr[j] = arr[i]; j--; } } arr[i] = pivot; quickSort(arr, low, i - 1, count); quickSort(arr, i + 1, high, count); } } int main() { int arr[] = {10, 7, 8, 9, 1, 5}; int n = sizeof(arr) / sizeof(arr[0]); int count = 0; quickSort(arr, 0, n - 1, &count); printf("快速排序次数:%d\n", count); return 0; } ``` 这段代码首先定义了一个快速排序的函数`quickSort`,其参数包括待排序数组`arr`、起始索引`low`、结束索引`high`,以及一个用于记录比较次数的指针`count`。在函数内部,我们使用了Hoare划分的方法进行快速排序,并在每次比较操作中将`count`加1。 在`main`函数中,我们创建一个待排序的数组`arr`(在这里只是举例,您可以根据需要进行更改),然后计算数组的长度`n`。我们还定义了一个变量`count`来记录比较次数,并将其传递给`quickSort`函数。最后,我们打印出快速排序的比较次数。 请注意,这只是一个简单的示例代码,您可以根据您的具体需求进行修改和扩展。 ### 回答3: 以下是一个使用C语言编写的计算快速排序次数的代码: ```c #include <stdio.h> int count = 0; // 全局变量,用于计算快速排序次数 void quickSort(int arr[], int low, int high) { if (low < high) { int pivot = arr[low]; // 选取第一个元素作为基准点 int i = low + 1; // 左指针 int j = high; // 右指针 while (i <= j) { while (i <= j && arr[i] < pivot) { i++; // 左指针向右移动 count++; // 每次比较次数+1 } while (i <= j && arr[j] > pivot) { j--; // 右指针向左移动 count++; // 每次比较次数+1 } if (i <= j) { int temp = arr[i]; // 交换左指针和右指针所指向的元素 arr[i] = arr[j]; arr[j] = temp; i++; j--; count++; // 每次交换次数+1 } } int temp = arr[low]; // 将基准点放在正确的位置上 arr[low] = arr[j]; arr[j] = temp; quickSort(arr, low, j - 1); // 递归处理左半部分 quickSort(arr, j + 1, high); // 递归处理右半部分 } } int main() { int arr[10] = {2, 4, 1, 6, 8, 3, 5, 7, 9, 0}; quickSort(arr, 0, 9); printf("快速排序次数:%d\n", count); return 0; } ``` 该代码定义了一个全局变量`count`来统计快速排序的比较次数和交换次数。在`quickSort`函数中使用双指针的方式进行快速排序,并在比较和交换操作时将`count`加1。最后在`main`函数中输出`count`的值即为快速排序的次数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值