视频讲解: 「C语言」快速排序,STL源码的实现方式_哔哩哔哩_bilibili
快速排序的思想
相信大家对快排的思路并不陌生,这里简单梳理一下:
假设有一个序列
1、当序列中的元素个数为0或者1时,排序结束
2、在序列中选择一个合适的元素作为基准元素(《STL源码剖析》里称为枢轴pivot)
3、以基准元素为界,将序列分割为左右两个区域,若为升序,保证左边区域的数不大于基准元素,右边区域的数不小于基准元素
4、对左右区域进行快速排序,也就是递归
《STL源码剖析》这本书里是这样描述的:
可以看到书中的第2点描述的是“任何一个元素”,而我写的则是合适的元素,这是因为我看了书后面的内容基准元素的合适与否会影响快排的效率,STL源码是通过三点中值的方法来决定基准元素。
另外,本文会大量引用《STL源码剖析》这本书里的内容,力求不误导大家。
快速排序的时间复杂度
大部分情况下快排的时间复杂度都是O(n*log n),但在某些特殊情况下,会导致快排的时间复杂度变为O(n^2),为何会如此呢?其根本原因在于每次分割左右区域时,两边的元素个数极不均衡。
合适的元素
那么,如何使得每次分割较为理想呢?选择的基准元素很关键,STL采用的是三点中值(有些地方也叫三数取中)的方式来决定基准元素。相信这个方法大家都听过,我这里就简单介绍一下
源代码如下,注意,STL源码使用的语言是C++(毕竟是人家的标准库),不过问题不大,思路是相通的,我会用C语言再写一遍,但我暂时还没有学C++,如果代码有问题,烦请大家批评指正。
C语言
int Medium(const int a, const int b, const int c)
{
if (a < b)
if (b < c)
return b;
else if (a < c)
return c;
else
return a;
else if (a < c)
return a;
else if (b < c)
return c;
else
return b;
}
我当时看到这一堆if,else时不禁蹙眉,觉得比较绕人,于是我自己又另外写了一种方法
int Medium(const int x, const int y, const int z)
{
//x为中值
if ((x <= y && x >= z) || (x <= z && x >= y))
return x;
//y为中值
if ((y <= x && y >= z) || (y <= z && y >= x))
return y;
return z;
}
可以看到逻辑更简单一些,但比较的次数和逻辑判断的次数明显增加了,我一开始认为这会对性能造成一些影响,但实际测试下来发现几乎没有影响。后来思考了一会,没能想明白。
分割Partition(核心)
分割的方法不止一种,我们来看看STL是怎么实现的:
C语言(注意,STL源码里partition的参数用的是地址first和last,而我写的代码多加了一个参数,即数据地址a,并改变了两个参数,分别将first和last改为了对应的地址偏移量left和right)
//分割:数据地址a,左边界left,右边界right,基准元素pivot,返回值为分割后的右区间的左边界
int Partition(int* a, int left, int right, int pivot)//[left, right)
{
assert(a);
while (1)
{
//left找大于等于
while (a[left] < pivot)
{
++left;
}
--right;
//right找小于等于
while (a[right] > pivot)
{
--right;
}
if (left >= right)//>=
return left;
Swap(&a[left], &a[right]);
++left;
}
}
单看代码比较抽象,正好书里给了一些图(原本我是准备自己画的),大家可以对照着代码走一遍,还是比较简单的。
递归的边界:插入排序InsertSort
在面对一段较小的区间时,将快速排序换为插入排序效率会更高。相信你一定有所疑问,插入排序?那玩意的时间复杂度不是O(n^2)吗,效率咋还变高了呢?事实上插入排序在面对较为有序的数据时,其时间复杂度可变为O(n),可以说是奇快无比(睡眠排序:这个排序就是逊啦。快速排序:哦?听你这么说,你很勇喔?睡眠排序:开玩笑,我超快的好不好,超会排的啦。),加之函数递归调用时需要开辟新的函数栈帧,且快排会为了很小的一段区间而产生很多的函数递归调用,虽然开辟函数栈帧很快,但也是一笔时间。
那么问题来了,这个区间要多小才能算“较小”呢?答案是并无定论,5~20都可能导致差不多的结果。
《STL源码剖析》中是这样描述的
另外,有些地方称此举为小区间优化。 在我实际测试中,此举确实能提高效率,但我也曾在其他同志那边见到过负提升的情况,即加了插入排序后性能反而下降了。
因为插入排序比较简单,所以我插入排序的代码我并没有照着STL源码写(绝不是因为之前已经写好了不想再重写一遍),代码如下:
//插入排序:数据地址a,数据个数n
//时间复杂度O(n^2),空间复杂度O(1),稳定
void InsertSort(int* const a, const int n)
{
assert(a);
//10个数,cur从0到8,即从第2个数开始到第10个数结束,循环9次
for (int cur = 0; cur < n - 1; cur++)
{
int temp = a[cur + 1];
//temp是要被前面的数比较的数,直到前面的某个数比temp大或者小
//抑或是temp比前面所有的数都大或者比前面所有的数都小
while (cur >= 0)
{
if (a[cur] > temp)//如果a[cur]比temp大,将cur移动到后一个
{
a[cur + 1] = a[cur];
cur--;
}
else
{
break;
}
}
a[cur + 1] = temp;//将tmpe插入到满足他条件的地方
}
}
有了插入排序,那么只需要在递归的函数里写上这样几行代码,就可以轻松实现对递归边界的控制:
//小区间改插入
if (right - left < 16)
{
InsertSort(a + left, right - left);
return;
}
如果有想看看STL源码是怎么写插入排序的朋友,请看下面这张图:
排序的尽头:内省排序IntroSort
内省式排序Introspective Sorting,顾名思义这是一种能够自我反省的排序算法,那么为何要提到他呢?那当然是与快排有关(废话),即使在有了三点中值确定基准元素的前提下,当快排的分割行为仍有朝着O(n^2)去的倾向,那么快排会自我反省,转用老大哥堆排序HeapSort。这样一来在大部分情况下就正常走快排,某些特殊情况转用堆排,(需要注意的是,有了三点中值的快排虽然和堆排的时间复杂度都是O(n*log n),但大部分情况下快排是要比堆排快的)这样就保证了排序的效率。
《STL源码剖析》中是这样描述的
(“此君于STL领域大大有名”,这操的一口流利的中文)
堆排的代码也是我自己写的,代码如下:
//向下调整:父节点下标parent,数据地址data,数据个数size
static void AdjustDown(int parent, int* data, int size)
{
//向下调整的条件是左树和右树必须是堆结构
//左孩子
int child = parent * 2 + 1;
//到叶子停止
while (child < size)
{
//存在右孩子且右孩子的值大于左孩子
if (child + 1 < size && data[child + 1] > data[child])
{
child++;//不能用Swap
}
if (data[child] > data[parent])
{
Swap(&data[child], &data[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆排序:数据地址a,数据个数size
//时间复杂度O(n*log n),空间复杂度O(1),不稳定
void HeapSort(int* const a, const int size)
{
assert(a);
//若排升序,则建大堆
//成堆的时间复杂度为O(n)
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(i, a, size);
}
//排序,先将堆顶元素和堆尾元素互换,再将进行向下调整的数据个数-1
//此步的时间复杂度为O(nlogn)
int end = size - 1;
while (end)
{
Swap(&a[0], &a[end]);
//从根节点向下调整
AdjustDown(0, a, end);
end--;
}
}
若想知道STL源码是怎样实现堆排的,请见《STL源码剖析》中的4.7.2小节,这里就不做介绍了。
那么问题又来了,如何能知道分割这一行为有O(n^2)倾向呢?(别真以为代码能平白无故的自我反省)STL源码中是这样解决的:在函数递归的过程中加了一个参数depth_limit(深度限制),先根据数据个数算出depth_limit,在一趟递归的线路中每次 --depth_limit ,当 depth_limit==0 时,快排觉得自己不行了(快排:这数据,遭不住),转用堆排。
STL源码如下:
C语言:
//深度限制:数据个数n
int DepthLimit(int n)
{
int depth_limit = 0;
while (n > 1)
{
++depth_limit;
n >>= 1;
}
return depth_limit;
}
和插入排序一样,只需要在递归的函数里写上这样几行代码,就可以轻松实现内省排序:
//分割次数过多,改用堆排
if (depth_limit == 0)
{
HeapSort(a + left, right - left);
return;
}
代码一览
至此,STL对快排的实现就结束了,下面将给出完整的STL源代码:(这里再次说明一下:注意,STL源码使用的语言是C++(毕竟是人家的标准库),不过问题不大,思路是相通的,我会用C语言再写一遍,但我暂时还没有学C++,如果代码有问题,烦请大家批评指正。)
C语言:(我一共使用了3个文件来实现内省排序,分别是IntroSort.h,IntroSort.c,Test.c。其中Test.c中包含了验证排序正确性的代码和测试排序性能的代码,我也一并给出,稍后我会简单讲讲如何验证排序的正确性以及如何测试排序的性能)
//--------------------IntroSort.h--------------------
#pragma once
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <time.h>
//introspection n.反省
//显示数据:数据地址a,数据个数n
void Print(int* a, int n);
//插入排序:数据地址a,数据个数n
void InsertSort(int* const a, const int n);
//堆排序:数据地址a,数据个数size
void HeapSort(int* const a, const int size);
//内省排序:数据地址a,左边界left,右边界right
void IntroSort(int* a, int left, int right);//[left, right)
//--------------------IntroSort.c--------------------
#include "IntroSort.h"
void Swap(int* x, int* y)
{
assert(x && y);
int temp = *x;
*x = *y;
*y = temp;
}
void Print(int* a, int n)
{
assert(a);
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
//插入排序:数据地址a,数据个数n
//时间复杂度O(n^2),空间复杂度O(1),稳定
void InsertSort(int* const a, const int n)
{
assert(a);
//10个数,cur从0到8,即从第2个数开始到第10个数结束,循环9次
for (int cur = 0; cur < n - 1; cur++)
{
int temp = a[cur + 1];
//temp是要被前面的数比较的数,直到前面的某个数比temp大或者小
//抑或是temp比前面所有的数都大或者比前面所有的数都小
while (cur >= 0)
{
if (a[cur] > temp)//如果a[cur]比temp大,将cur移动到后一个
{
a[cur + 1] = a[cur];
cur--;
}
else
{
break;
}
}
a[cur + 1] = temp;//将tmpe插入到满足他条件的地方
}
}
//向下调整:父节点下标parent,数据地址data,数据个数size
static void AdjustDown(int parent, int* data, int size)
{
//向下调整的条件是左树和右树必须是堆结构
//左孩子
int child = parent * 2 + 1;
//到叶子停止
while (child < size)
{
//存在右孩子且右孩子的值大于左孩子
if (child + 1 < size && data[child + 1] > data[child])
{
child++;//不能用Swap
}
if (data[child] > data[parent])
{
Swap(&data[child], &data[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆排序:数据地址a,数据个数size
//时间复杂度O(n*log n),空间复杂度O(1),不稳定
void HeapSort(int* const a, const int size)
{
assert(a);
//若排升序,则建大堆
//成堆的时间复杂度为O(n)
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(i, a, size);
}
//排序,先将堆顶元素和堆尾元素互换,再将进行向下调整的数据个数-1
//此步的时间复杂度为O(nlogn)
int end = size - 1;
while (end)
{
Swap(&a[0], &a[end]);
//从根节点向下调整
AdjustDown(0, a, end);
end--;
}
}
int Medium(const int x, const int y, const int z)
{
if ((x <= y && x >= z) || (x <= z && x >= y))
return x;
if ((y <= x && y >= z) || (y <= z && y >= x))
return y;
return z;
}
//int Medium(const int a, const int b, const int c)
//{
// if (a < b)
// if (b < c)
// return b;
// else if (a < c)
// return c;
// else
// return a;
// else if (a < c)
// return a;
// else if (b < c)
// return c;
// else
// return b;
//}
//分割:数据地址,左边界left,右边界right,基准元素pivot,返回值为分割后的右区间的左边界
int Partition(int* a, int left, int right, int pivot)//[left, right)
{
assert(a);
while (1)
{
//left找大于等于
while (a[left] < pivot)
{
++left;
}
--right;
//right找小于等于
while (a[right] > pivot)
{
--right;
}
if (left >= right)//>=
return left;
Swap(&a[left], &a[right]);
++left;
}
}
void IntroSortRe(int* a, int left, int right, int depth_limit)
{
assert(a);
//if (right - left <= 1)
// return;
//小区间改插入
if (right - left < 16)
{
InsertSort(a + left, right - left);
return;
}
//分割次数过多,改用堆排
if (depth_limit == 0)
{
HeapSort(a + left, right - left);
return;
}
//三数取中
int pivot = Medium(a[left], a[left + (right - left) / 2], a[right - 1]);
//分割左右
int cut = Partition(a, left, right, pivot);
//[left, cut) [cut, right)
IntroSortRe(a, left, cut, depth_limit - 1);
IntroSortRe(a, cut, right, depth_limit - 1);
}
//深度限制:数据个数n
int DepthLimit(int n)
{
int depth_limit = 0;
while (n > 1)
{
++depth_limit;
n >>= 1;
}
return depth_limit;
}
void IntroSort(int* a, int left, int right)//[left, right)
{
assert(a);
int depth_limit = DepthLimit(right - left);
IntroSortRe(a, left, right, 2 * depth_limit);
}
//--------------------Test.c--------------------
#include "IntroSort.h"
void IntroSortTest()
{
//int a[] = { 1,3,5,7,9,0,2,4,6,8 };
int a[] = { 1,4,2,1,6,7,6,8,6,4,6,9,3,0,7,22,44,789,32,41,22,66,784,23,54,78,90,2,1,3 };
#define N 1000
//int a[N];
for (int i = 0; i < N; ++i)
{
//a[i] = rand();
//a[i] = i;
}
IntroSort(a, 0, sizeof(a) / sizeof(a[0]));
Print(a, sizeof(a) / sizeof(a[0]));
}
int cmp_int(const void* x, const void* y)
{
assert(x && y);
return *((int*)x) - *((int*)y);
}
void Time()
{
int n = 10000000;
int* a1 = (int*)malloc(sizeof(int) * n);
int* a2 = (int*)malloc(sizeof(int) * n);
if (!(a1 && a2))
return;
for (int i = 0; i < n; ++i)
{
a1[i] = rand();//随机数,但数据量大时重复数据较多
//a1[i] = i;//升序
//a1[i] = 1;//全是相同的数
a2[i] = a1[i];
}
int time1 = clock();
IntroSort(a1, 0, n);
int time2 = clock();
int time3 = clock();
qsort(a2, n, sizeof(int), cmp_int);
int time4 = clock();
printf("IntroSort:%d\n", time2 - time1);
printf("qsort:%d\n", time4 - time3);
free(a1);
a1 = NULL;
free(a2);
a2 = NULL;
}
int main()
{
srand((unsigned int)time(NULL));
//IntroSortTest();
Time();
return 0;
}
如何验证排序的正确性
如何验证排序的正确性相信大家应该都会,这里我简单分享一下我的经验,我写了个显示数据的函数,也就是把数据打印到显示屏上(如果你喜欢调试,看监视窗口,也完全没有问题),代码如下:
//显示数据:数据地址a,数据个数n
void Print(int* a, int n)
{
assert(a);
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
非常简单,我就不说了,之后便是准备两组数,一组简单一点,一组复杂一点,先看看简单的能不能排好,然后再试试复杂的(无序、量多、多次重复),如果一开始就用复杂的,如果出错,调试起来会非常痛苦(痛,太痛了)。如果还不放心就写个循环,小打印1000个数,速度还是很快的。代码如下:
void IntroSortTest()
{
//int a[] = { 1,3,5,7,9,0,2,4,6,8 };
int a[] = { 1,4,2,1,6,7,6,8,6,4,6,9,3,0,7,22,44,789,32,41,22,66,784,23,54,78,90,2,1,3 };
#define N 1000
//int a[N];
for (int i = 0; i < N; ++i)
{
//a[i] = rand();
//a[i] = i;
}
IntroSort(a, 0, sizeof(a) / sizeof(a[0]));
Print(a, sizeof(a) / sizeof(a[0]));
}
如果你仍不放心,还有一招,力扣上有一个排序数组的题,把你的代码提交上去,看看能不能通过(力扣的测试用例还是很严格的),以此来验证排序的正确性,这是该题的链接:力扣
另外,这题对时间是有要求的,时间复杂度是O(n^2)的排序算法无法通过。
如何测试排序的性能
正所谓“十年磨一剑,霜刃未曾试”,现在就让我们来试试这把宝剑是否锋利。
如果要排序,那必须先有数据可排,那么该如何造出大量数据呢?一个一个手动造肯定是不行的,所以需要依靠循环实现。另外数据的内容十分关键,这里我给出我用的三种数据:随机数据、升序数据、相同数据。
借助time.h里的clock函数,可以计算出不同排序算法所消耗的时间,以此来比较不同排序算法的性能。下面代码中的qsort是C语言官方库stdlib.h里的排序函数,其底层逻辑是快速排序,所以叫q(uick)sort,拿他来作为比较的对象,还是比较合适的。
void Time()
{
int n = 10000000;
int* a1 = (int*)malloc(sizeof(int) * n);
int* a2 = (int*)malloc(sizeof(int) * n);
if (!(a1 && a2))
return;
for (int i = 0; i < n; ++i)
{
a1[i] = rand();//随机数据,但数据量大时重复数据较多
//a1[i] = i;//升序数据
//a1[i] = 1;//相同数据
a2[i] = a1[i];
}
int time1 = clock();
IntroSort(a1, 0, n);
int time2 = clock();
int time3 = clock();
qsort(a2, n, sizeof(int), cmp_int);
int time4 = clock();
printf("IntroSort:%d\n", time2 - time1);
printf("qsort:%d\n", time4 - time3);
free(a1);
a1 = NULL;
free(a2);
a2 = NULL;
}
测试性能时,一定要在Release版本下进行。因为Debug版本对性能的优化并不完全,所以在Debug版本下测试性能没有意义。我用的开发工具是Visual Studio 2022,Release切换如下:
1千万数据量,10次测试取平均值,单位毫秒,结果如下:
可以看到,在随机数据和升序数据方面IntroSort的性能是要优于qsort的,但在面对相同数据时,IntroSort的性能不如qsort(人家好歹是官方库的排序算法,给人留点面子),怀疑是qsort对这种情况专门做了优化,不过他能优化,我就不能优化了吗?之前在分割的时候讲过,分割的方法不止一种,我自己也写了一种分割方法,在面对这三种数据的时候,其性能完全超过qsort,但本文说的是STL源码的实现方式,这里就不分析我自己的分割方法了。
结语
快速排序是一种非常优秀的排序算法,本文主要介绍了STL源码是如何实现快排的。其中如何选择合适的元素作为基准元素和如何分割是关键,而之后加入的插入排序和堆排序更像是对快排的一种优化,小区间改用插入排序可以提高排序的效率,分割次数过多改用堆排序可以让代码拥有自我反省的能力,使得时间复杂度不会变为O(n^2)。然笔者自知水平有限,故多次引用了《STL源码剖析》中的内容,力求不误导大家,但难免有疏漏之处,若诸位慧眼如炬(我的眼睛就是尺),发现了问题,烦请批评指正,不胜感激。
《STL源码剖析》下载:STL源码剖析(简中).pdf - 蓝奏云