快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
本章会重点讲解快速排序的四种思想:
以及快速排序的一些小优化,和递归与非递归的实现方式。
阅读本章建议有一点二叉树的基础,如果对二叉树不太了解的可以看看:《初阶二叉树》这篇文章
我们先看一下Hoare的思想是怎么样实现的:
这里来解释一下这个思想的意思:
key
key
小的数key
大的数key
已经排到了它所应该在的位置(就是排好序以后的位置),我们介绍的这几种方法本质都是将key放到它所应该在的位置注意⚠️:
在实现这个代码之前,我们要注意几个点:
说了那么多,我们一起来看看Hoare版的快排用代码是怎么实现的:
int PartSort_Hoare(int* a, int begin, int end)
{
int left = begin;
int right = end - 1;
// 在这块将key定义成下标,
// 因为我们在后面的过程中会涉及将key与我们锁定的位置的数交换位置,
// 如果写成int key = a[left]就不方便后面的交换
int key_i = left;
while (left < right)
{
// 先走右边,再走左边
while (left < right && a[right] >= a[key_i]) // 防止越界 && 找小
{
--right;
}
while (left < right && a[left] <= a[key_i]) // 防止越界 && 找大
{
++left;
}
// 找到大的数和小的数,将它们交换位置
Swap(&a[left], &a[right]);
}
// 出循环以后此刻left=right
// 将在left位置和key_i位置的数交换一下位置
Swap(&a[left], &a[key_i]);
return left;
}
void Quick_Sort(int* a, int begin, int end)
{
if (begin >= end)
return;
int key = PartSort_Hoare(a, begin, end);
// 区间分为: [begin, key) key[key + 1, end), 这里要注意, 我们给的区间是左闭右开的
Quick_Sort(a, begin, key);
Quick_Sort(a, key + 1, end);
}
这里有一个细节不知道大家有没有发现
我在处理这两个地方的时候是先判断left
是否小于right
,这样处理的好处是有效的防止了我们对数组的越界访问,如果是后判断left
是否小于right
,那么在进行a[right] >= a[key_i]
这步操作的时候,我们可能会进行越界访问数组的操作。
我们先看一下挖坑法是怎么样实现的:
这里来解释一下这个思想的意思:
key
小的数,将这个数放进坑位中,然后又将这个位置设置成坑位key
大的数,将这个数放进坑位中,然后又将这个位置设置成坑位key
放入坑位中,此刻key
已经排到了它所应该在的位置(就是排好序以后的位置)我们来看一下挖坑法实现快排用代码是怎么实现的:
int PartSort_Hole(int* a, int begin, int end)
{
int left = begin;
int right = end - 1;
// 这里不用像Hoare法那样设置成下标,
// 因为我们在最后是直接将数据放入坑位中
int key = a[begin];
int hole = left; // 设置坑位
while (left < right)
{
// 先从右边走
while (left < right && a[right] >= key) // 防止越界 && 找小
{
--right;
}
//找到小于key的数了,将该数放在hole中,再将该位置设置为hole
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key) // 防止越界 && 找大
{
++left;
}
//找到小于key的数了,将该数放在hole中,再将该位置设置为hole
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
void Quick_Sort(int* a, int begin, int end)
{
if (begin >= end)
return;
int key = PartSort_Hole(a, begin, end);
// 区间分为: [begin, key) key[key + 1, end), 这里要注意, 我们给的区间是左闭右开的
Quick_Sort(a, begin, key);
Quick_Sort(a, key + 1, end);
}
我们先看一下前后指正法是怎么样实现的:
这里来解释一下这个思想的意思:
prev
和cur
和key
,prev
和key
指向数组的起始位置,cur
指向prev
的下一个位置cur
,找到比key小的数,就将cur
位置的数据和 prev+1
位置的数据交换位置,但这里出于考虑省略一些交换数据的消耗,我们可以判断prev+1
位置的数据是否等于cur
位置的数据,如过a[cur] < cur && a[prev+1] != a[cur]
,就将两个位置的数据进行交换cur
指针越界,我们这个过程就结束了,然后再将key的值放到prev
的位置,此刻key
已经排到了它所应该在的位置(就是排好序以后的位置)我们来看一下前后指针实现快排用代码是怎么实现的:
int PartSort_PC_Point(int* a, int begin, int end)
{
// 创建两个变量来指向数组
int prev = begin;
int cur = prev + 1;
// 这里将key设置成
int key_i = begin;
while (cur < end)
{
if (a[cur] < a[key_i] && ++prev != cur) // 找比key小的值 && 防止与自身交换
{
Swap(&a[cur], &a[prev]);
}
cur++;
}
// 此时cur已经比right大了,将prev与key_i位置的数换位置
Swap(&a[key_i], &a[prev]);
return prev;
}
void Quick_Sort(int* a, int begin, int end)
{
if (begin >= end)
return;
int key = PartSort_PC_Point(a, begin, end);
// 区间分为: [begin, key) key[key + 1, end), 这里要注意, 我们给的区间是左闭右开的
Quick_Sort(a, begin, key);
Quick_Sort(a, key + 1, end);
}
三路划分这个思想用来处理大量重复数据的时候,效率特别高。
我们先看一下三路划分是怎样实现的:
这里来解释一下这个思想的意思:
L
指针,在L
的下一个位置定义一个cur
指针,最后在末尾位置定义一个R
指针cur
和L
的位置的值,并且++cur
,++L
++cur
cur
和R
的位置的值,并且--R
,这里要注意不能动cur
的值,因为从R位置交换过来的值,我们没办法判断它与key的大小关系,所以保持cur的位置不变,再继续将cur位置的值与key做比较cur > R
的时候,这个过程就结束了在实现这个代码的过程中,我们需要返回的是一个范围:[begin,L)
和[R + 1,end)
为什么是这样呢?
因为我们在进行一次三路划分过后,已经将key与key相等的数排好位置了,剩下的就只剩[begin,L)
和[R + 1,end)
需要排序(这里我是设置成左闭右开区间,大家写的时候,也可以自己设置成左闭右闭区间),因为实现三路划分需要得到一个范围,所以写成函数不太好控制,所以我就直接将这个过程写在Quick_Sort
函数中了
我们来看一下三路划分法实现快排用代码是怎么实现的:
void Quick_Sort(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin;
int right = end - 1;
int cur = left + 1;
int key = a[left];
while (cur <= right) // cur > right 循环才结束
{
if (a[cur] < key) // cur位置的值小于key
{
// 交换cur和L的位置的值,并且++cur ,++left
Swap(&a[cur], &a[left]);
++cur;
++left;
}
else if (a[cur] > key) // cur位置的值大于key
{
// 交换cur和L的位置的值,并且--right
Swap(&a[cur], &a[right]);
--right;
}
else // cur位置的值等于key
{
++cur;
}
}
// [begin, left) 和 [right + 1, end)
Quick_Sort(a, begin, left);
Quick_Sort(a, right + 1, end);
}
我们在上面实现排序的时候,面对一些情况我们写的快排也还是有些吃力,所以我们也还需要对一些情况做一些处理
这里有一点,可以注意一下:
就是我在上面写4种思想实现快排的时候,前三种思想我是将递归过程单独写成子函数来调用的,这样做的目的是使我写这篇博客时的可读性提高。但如果是我们自己写的时候,也可以不用单独写成一个子函数来调用,可以直接将这些思想的过程写进函数体里面。毕竟函数调用时,还是会有一点消耗的,当然这些消耗可以忽略不计
三数取中有两种取法(当然不止两种),这里我要介绍的是两种思想:
这个思想主要是:我们通过比较数组起始位置和中间位置还有末尾位置的数据,取第二大的数作为key
具体我们写代码来看一下:
int GetMidi(int* a, int begin, int end) // 三数取中
{
int left = begin;
int right = end - 1;
int mid = (left + right) / 2;
if (a[left] < a[mid]) // a[left] < a[mid]
{
if (a[mid] < a[right]) // a[left] < a[mid] < a[right]
return mid;
else if (a[left] < a[right]) // a[mid] >= a[right] > a[left]
return right;
else // a[mid] > a[left] >= a[right]
return left;
}
else // a[left] >= a[mid]
{
if (a[left] < a[right]) // a[mid] <= a[left] < a[right]
return left;
else if (a[right] > a[mid]) // a[left] >= a[right] > a[mid]
return right;
else // a[left] > a[mid] >= a[right]
return mid;
}
}
这个思想主要是:我们通过比较数组起始位置和数组中一个随机的位置,和他们中间位置的数据,取第二大的数作为key
具体实现过程如下:
int GetMidi_random(int* a, int begin, int end)
{
int left = begin;
int right = begin + rand() % (end - begin); // 后面加的数是整个数组的范围
int mid = (left + right) / 2;
if (a[left] < a[mid]) // a[left] < a[mid]
{
if (a[mid] < a[right]) // a[left] < a[mid] < a[right]
return mid;
else if (a[left] < a[right]) // a[mid] >= a[right] > a[left]
return right;
else // a[mid] > a[left] >= a[right]
return left;
}
else // a[left] >= a[mid]
{
if (a[left] < a[right]) // a[mid] <= a[left] < a[right]
return left;
else if (a[right] > a[mid]) // a[left] >= a[right] > a[mid]
return right;
else // a[left] > a[mid] >= a[right]
return mid;
}
}
int main()
{
srand((unsigned)time(NULL)); // 生成随机数种子
return 0;
}
我们在用三数取中时,获取到的是想要的数的位置,然后将这个位置的数和数组起始位置的数交换一下,这样就方便我们定义key了。具体实现方式,我写在了本小节的最后。
我们在学过二叉树以后,应该知道,完全二叉树,倒数第二层和最后一层加起来占了整个节点75%的节点,而我们在处理大量数据使用快排实现递归时当只有10个数据时,还会递归多次:
而在处理这些大量数据,我们递归下来时,无疑会产生大量范围为10及10以下的多组数据,如果我们此时继续递归,无疑会产生很多组小范围的数据。所以范围小于等于10的时候,就没必要使用快排继续递归了,这里可以考虑使用直接插入排序来排每组范围小于等于10的数组。这样就大大降低了递归时开辟栈帧的消耗了。在使用插入排序时,我们必须要弄清楚所需排序的范围:
下面我以三路划分的思想写一个我上述优化后的排序,里面有用到直接插入排序,这个不是本章的重点,不了解的可以看看这篇博客:《简单排序》:
int GetMidi_random(int* a, int begin, int end)
{
int left = begin;
int right = begin + rand() % (end - begin); // 后面加的数是整个数组的范围
int mid = (left + right) / 2;
if (a[left] < a[mid]) // a[left] < a[mid]
{
if (a[mid] < a[right]) // a[left] < a[mid] < a[right]
return mid;
else if (a[left] < a[right]) // a[mid] >= a[right] > a[left]
return right;
else // a[mid] > a[left] >= a[right]
return left;
}
else // a[left] >= a[mid]
{
if (a[left] < a[right]) // a[mid] <= a[left] < a[right]
return left;
else if (a[right] > a[mid]) // a[left] >= a[right] > a[mid]
return right;
else // a[left] > a[mid] >= a[right]
return mid;
}
}
void Quick_Sort(int* a, int begin, int end)
{
if (begin >= end)
return;
if (end - begin > 10) // 小区间优化
{
int left = begin;
int right = end - 1;
int cur = left + 1;
int mid = 0;
if (left < end)
{
mid = GetMidi(a, left, end); //三数取中,获取我们要找的数
Swap(&a[mid], &a[left]); // 将三数取中得到的数换到第一个位置
}
int key = a[left];
while (cur <= right)
{
if (a[cur] < key)
{
Swap(&a[cur], &a[left]);
++left;
++cur;
}
else if (a[cur] > key)
{
Swap(&a[cur], &a[right]);
--right;
}
else
++cur;
}
// 分区间进行递归
Quick_Sort(a, begin, left);
Quick_Sort(a, right + 1, end);
}
else
{
// 使用插入排序时,要注意使用的范围
InsertSort(a + begin, end - begin);
}
}
在我们使用快排时,其实用递归也递归不了多少层。所以在平时我们要使用快排时,使用递归版的完全够用了。但由于现在还在学习阶段,所以掌握一下非递归版的快排还是有必要的。
我们实现非递归版本的快排时,用到的思想是我上面介绍的几种思想,只不过接下来讲的不是用递归去完成这个过程,这里我们要使用一个工具——栈,能够看到这里的我相信大家都对栈有一定的了解了,所以我在下面代码中,就将直接使用我写的栈了。不太了解栈的也可以看看这篇文章:《栈和队列》
void QuickSortNonR(int* a,int begin, int end)
{
int left = begin;
int right = end;
Stack stack; // 创建一个栈的数据结构
StackInit(&stack); // 将这个栈初始化一下
StackPush(&stack, left); // 插入最开始的数
StackPush(&stack, right); // 插入最后一位数的下一位,因为我们上面实现的三种方法都是左闭右开的
while (!StackEmpty(&stack))
{
right = StackTop(&stack); // 我们是先插入最右边的数,所以我们应该先拿出最右边的数
StackPop(&stack); // 栈顶元素删除
left = StackTop(&stack); // 在上面删除right时,栈顶元素就是left了
StackPop(&stack); // 栈顶元素删除
int key = PartSort_Hoare(a, left, right); // 将这段区间进行排序
// [left,key) key [key+1,right)
if (right > key + 1) // 如果这个区间存在,就将这个区间表示的范围入栈(这里要注意入栈顺序和出栈顺序)
{
StackPush(&stack, key + 1);
StackPush(&stack, right);
}
if (left < key) // 如果这个区间存在,就将这个区间表示的范围入栈(这里要注意入栈顺序和出栈顺序)
{
StackPush(&stack, left);
StackPush(&stack, key);
}
}
// 销毁栈
StackDestroy(&stack);
}
有人可能会问我们递归时用到的栈帧和我们自己做的栈,有什么区别呢?
因为我自己做的栈是在堆上开辟的空间,而如果使用递归版本的栈帧是在栈上开辟的空间。32位机器下,栈上的空间只有8MB左右,而堆上的空间有4G左右。所以这里选择在堆上开辟空间,可以很好的节约空间,也能减少递归时产生的消耗。