快速排序是霍尔(Hoare)于1962年提出的一种二叉树结构的交换排序方法,其基本思路为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
[!Tips] 学习快速排序的建议:
- 画图理解单趟排序,画递归展开图
- 注意体会数据逐渐有序的过程(每经过一趟排序,该趟排序的基准值的位置是它最终的位置)
快速排序将待排序序列逐步划分为较小的子序列,并对每个子序列再进行单趟排序。被嵌套调用的一系列单趟排序构成了完成的快速排序算法。
在快速排序的单趟排序中,我们通过选择一个基准元素(代码中用Key表示,其下标用KeyIndex表示),并根据其值将序列进行划分。
我们提供霍尔法、挖坑法、前后指针法,3种实现方法。
霍尔(Hoare)是最初发现快速排序的人,它使用的单趟排序算法被称为霍尔法。
// Hoare法单趟排序
int PartSort(int* a, int left, int right)
{
// 选择基准元素的索引为 left
int KeyIndex = left;
while (left < right)
{
// right 从右侧开始,找到第一个小于基准元素的值
while (left < right && a[right] >= a[KeyIndex])
{
--right;
}
// left 从左侧开始,找到第一个大于基准元素的值
while (left < right && a[left] <= a[KeyIndex])
{
++left;
}
// 交换 left 和 right 的元素
Swap(&a[left], &a[right]);
}
// 将基准元素放置在最终的位置
Swap(&a[KeyIndex], &a[left]);
return left;
}
[!Question] 为什么能够保证相遇位置比
key
小?
因为right
先走,使得结局为以下两种:
right
停下之后,left
向右靠近并与right
相遇,由于right
位置定在比key
小的值上,所以最终left
和right
都在比key
小的位置处。left
停下之后,right
与left
进行交换,交换后left
指向的值比key
小,此时right
遇到left
的位置一定比key
小
挖坑法是由 Tony Hoare(托尼·霍尔)在1960年提出的。他作为著名的计算机科学家,也是快速排序算法的发明者之一。
HoleIndex
是坑的位置,初始位置为基准元素的位置。left
和right
分别指向待排数组的最左侧和最右侧,right
找比key
基准值小的数,left
找比key
基准值大的数。
返回坑的位置 HoleIndex
作为划分子序列的分界点。
// 挖坑法单趟排序
int PartSort2(int* a, int left, int right)
{
// 选择基准元素为左侧第一个元素
int Key = a[left];
int HoleIndex = left; // 坑的下标,初始为基准元素位置
while (left < right)
{
// 从右侧开始,找到第一个小于基准元素的值
while (left < right && a[right] >= Key)
{
--right;
}
// 将右侧小于基准元素的值填充到左侧的坑中
a[HoleIndex] = a[right];
HoleIndex = right;
// 从左侧开始,找到第一个大于基准元素的值
while (left < right && a[left] <= Key)
{
++left;
}
// 将左侧大于基准元素的值填充到右侧的坑中
a[HoleIndex] = a[left];
HoleIndex = left;
}
// 将基准元素放置到最后一个坑中,即填充到最终的位置
a[HoleIndex] = Key;
return HoleIndex;
}
相比于霍尔法和挖坑法,前后指针法只进行元素的比较和少量交换操作,没有显式地创建临时变量来记录基准值。这样能够简化代码实现,并且在实际运行中减少了一些内存和计算开销。
// 前后指针法
int PartSort3(int* arr, int left, int right)
{
int key = left; // 将最左边的元素作为基准值
int prev = left; // 后指针(动得慢)
int cur = left + 1; // 前指针(动得快)
while (cur <= right)
{
// 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置
if (arr[cur] <= arr[key] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
// 最后将基准值交换到prev指针指向的位置,以完成单趟排序
Swap(&arr[key], &arr[prev]);
return prev; // 返回基准值的位置,用于后续的划分
}
以一个数为基准,将数组分为两个子序列,左子序列放比基准数小的,右子序列放比基准数大的数,然后再将左右两段子序列以同样方式分割,数组有序。
![[霍尔法排序.png]]
// 快速排序的递归实现
void QuickSort(int* a, int begin, int end)
{
//两种情况下直接返回:1. 区间只有一个值 2. 区间不存在
if (begin >= end)
{
return;
}
// 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置
int KeyIndex = PartSort(a, begin, end);
// 对基准元素左侧的子序列排序
QuickSort(a, begin, KeyIndex - 1);
// 对基准元素右侧的子序列排序
QuickSort(a, KeyIndex + 1, end);
}
快速排序的非递归实现是使用栈存储每一次分割后的数组下标区间,以数组下标区间传入单趟排序,以实现对递归思想的模拟。
[!attention] 注意:
栈的特点是先进后出。
所以,获取区间以先右后左顺序时,就要以先左再右压栈。同样,如果要求左子列先出栈,右子列后出栈,那么必须先让右子列入栈,再让左子列入栈。
//栈的接口函数
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;//标记栈顶
int capacity;
}ST;
void STInit(ST* pst); //初始化栈
void STDestroy(ST* pst); //销毁栈
void STPush(ST* pst, STDataType x); //压栈
void STPop(ST* pst); //出栈
STDataType STTop(ST* pst); //取出栈顶元素
bool STEmpty(ST* pst); //栈判空
int STSize(ST* pst); //栈大小
// 非递归快速排序实现
void QuickSortNonR(int* a, int begin, int end)
{
ST st; // 定义一个栈,用于模拟递归过程
STInit(&st); // 初始化栈
// 将初始的 begin 和 end 压入栈,相当于递归的入口
STPush(&st, end);
STPush(&st, begin);
while (!STEmpty(&st))
{
// 从栈中取出当前处理的 begin 和 end
int left = STTop(&st);
STPop(&st);
int right = STTop(&st);
STPop(&st);
// 调用 PartSort 函数对序列进行划分,并得到基准元素的位置
int KeyIndex = PartSort(a, left, right);
// 判断是否需要对右侧子序列进行排序,并将其入栈
if (KeyIndex + 1 < right)
{
STPush(&st, right);
STPush(&st, KeyIndex + 1);
}
// 判断是否需要对左侧子序列进行排序,并将其入栈
if (left < KeyIndex - 1)
{
STPush(&st, KeyIndex - 1);
STPush(&st, left);
}
}
// 栈中所有任务处理完毕,排序完成
STDestroy(&st);
}
[!Attention] 注意
快速排序无论采用哪种单趟排序策略,无论采用递归还是非递归它的时间复杂度是:O(N*logN)
它的空间复杂度是:O(logN) ——logN也是递归层数,即创建的栈帧的数量
优化快速排序的基准数选择对算法的性能和稳定性有很大的影响。其中,三值取中法是一种常用的优化策略,它通过选取子序列中某三个元素的中位数作为基准数(一般选择首、中、尾三数),从而尽可能防止选择的基准数为待排数组的最大、最小值,而导致最坏情况出现。
[!Example] 最坏情况发生在以下两种情景下:
数组已经有序(升序或降序):当输入的数组是有序的时候,如果每次选择第一个或最后一个元素作为基准数,快速排序每次只能将数组划分为一个元素和剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。
数组具有相同元素:当输入的数组中所有元素都相同(例如,数组元素全部为2),无论选择哪个元素作为基准数,每次划分都只能将数组分为一个元素和一坨剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。
在这些最坏情况下,快速排序的性能大大降低,它退化成了一个效率较低的排序算法。为了避免最坏情况的发生,我们在选择基准数时更加谨慎,确保快速排序的平均性能得到保证。
// 三值取中法优化的快速排序递归实现
void QuickSortOptimized(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 三值取中法选取基准数
int mid = begin + (end - begin) / 2;
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
if (a[mid] > a[end])
{
Swap(&a[mid], &a[end]);
}
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
// 将基准数交换到子序列的第二个位置
Swap(&a[mid], &a[begin + 1]);
// 使用霍尔法进行划分
int KeyIndex = PartSort(a, begin, end);
// 对左右子序列递归进行排序
QuickSortOptimized(a, begin, KeyIndex - 1);
QuickSortOptimized(a, KeyIndex + 1, end);
}
为了解决递归到较深处时,调用大量栈帧来实现短小的排序的“小题大做”问题,可以采用小区间优化的方法,即当待排序的子序列长度小于某个阈值时,不再使用递归方式进行排序,而是采用其他排序算法(如插入排序)对该子序列进行排序。通过直接对较短的子序列使用简单而高效的排序算法,避免了递归调用带来的开销,从而提高了排序的效率。
问题1:待排数组较短时,此时再使用递归方式对短数组进行快速排序为什么会导致效率下降?(小区间优化的必要性)
问题2:为什么我们选择了“插入排序”进行“小区间优化”?
为了实现小区间优化,我们选择了插入排序作为替代算法。插入排序算法的特点是对几乎有序的序列具有较好的性能。在较短的子序列中,由于经过快速排序的初步划分,可能已经接近有序或完全有序。这种情况下,插入排序能够利用序列的局部有序性,通过较少的比较和交换操作来完成排序。
相比于其他排序算法(如冒泡排序或选择排序),插入排序在局部有序性较好的情况下,具有更好的性能。它的时间复杂度为 O(n^2),但在较短的子序列中,由于交换的次数较少,实际的运行时间较快。因此,选择插入排序作为小区间优化的替代算法,能够有效地提高快速排序在短数组上的性能,进一步优化算法的效率。
[!Attention] 需要注意的是,当序列长度小于某个较小的固定值(如10或20)时,可以考虑应用小区间优化。
//插入排序
void InsertSort(int* a, int n)
{
for (int i = 0; i < n-1; ++i)
{
// [0, end] 有序,插入tmp依旧有序
int end = i;
int tmp = a[i+1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
// 三数取中法优化的快速排序递归实现(带小区间优化)
void QuickSortOptimizedWithInsertion(int* a, int begin, int end)
{
// 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序
if (end - begin + 1 <= 10)
{
InsertSort(a + begin, end - begin + 1);
return;
}
// 三数取中法选取基准数
int mid = begin + (end - begin) / 2;
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
if (a[mid] > a[end])
{
Swap(&a[mid], &a[end]);
}
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
// 将基准数交换到子序列的第二个位置
Swap(&a[mid], &a[begin + 1]);
// 使用霍尔法进行划分
int KeyIndex = PartSort(a, begin, end);
// 对左右子序列递归进行排序
QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1);
QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end);
}
#include
#include
#include
#include "Stack.h"
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
void Swap(int* a, int* b)
{
int tmp = *b;
*b = *a;
*a = tmp;
}
// Hoare法单趟排序
int PartSort(int* a, int left, int right)
{
// 选择基准元素的索引为 left
int KeyIndex = left;
while (left < right)
{
// right 从右侧开始,找到第一个小于基准元素的值
while (left < right && a[right] >= a[KeyIndex])
{
--right;
}
// left 从左侧开始,找到第一个大于基准元素的值
while (left < right && a[left] <= a[KeyIndex])
{
++left;
}
// 交换 left 和 right 的元素
Swap(&a[left], &a[right]);
}
// 将基准元素放置在最终的位置
Swap(&a[KeyIndex], &a[left]);
return left;
}
// 挖坑法单趟排序
int PartSort2(int* a, int left, int right)
{
// 选择基准元素为左侧第一个元素
int Key = a[left];
int HoleIndex = left; // 坑的下标,初始为基准元素位置
while (left < right)
{
// 从右侧开始,找到第一个小于基准元素的值
while (left < right && a[right] >= Key)
{
--right;
}
// 将右侧小于基准元素的值填充到左侧的坑中
a[HoleIndex] = a[right];
HoleIndex = right;
// 从左侧开始,找到第一个大于基准元素的值
while (left < right && a[left] <= Key)
{
++left;
}
// 将左侧大于基准元素的值填充到右侧的坑中
a[HoleIndex] = a[left];
HoleIndex = left;
}
// 将基准元素放置到最后一个坑中,即填充到最终的位置
a[HoleIndex] = Key;
return HoleIndex;
}
// 前后指针法
int PartSort3(int* arr, int left, int right)
{
int key = left; // 将最左边的元素作为基准值
int prev = left; // 后指针(动得慢)
int cur = left + 1; // 前指针(动得快)
while (cur <= right)
{
// 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置
if (arr[cur] <= arr[key] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
// 最后将基准值交换到prev指针指向的位置,以完成单趟排序
Swap(&arr[key], &arr[prev]);
return prev; // 返回基准值的位置,用于后续的划分
}
// 快速排序的递归实现
void QuickSort(int* a, int begin, int end)
{
//两种情况下直接返回:1. 区间只有一个值 2. 区间不存在
if (begin >= end)
{
return;
}
// 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置
int KeyIndex = PartSort(a, begin, end);
// 对基准元素左侧的子序列排序
QuickSort(a, begin, KeyIndex - 1);
// 对基准元素右侧的子序列排序
QuickSort(a, KeyIndex + 1, end);
}
// 非递归快速排序实现
void QuickSortNonR(int* a, int begin, int end)
{
ST st; // 定义一个栈,用于模拟递归过程
STInit(&st); // 初始化栈
// 将初始的 begin 和 end 压入栈,相当于递归的入口
STPush(&st, end);
STPush(&st, begin);
while (!STEmpty(&st))
{
// 从栈中取出当前处理的 begin 和 end
int left = STTop(&st);
STPop(&st);
int right = STTop(&st);
STPop(&st);
// 调用 PartSort 函数对序列进行划分,并得到基准元素的位置
int KeyIndex = PartSort(a, left, right);
// 判断是否需要对右侧子序列进行排序,并将其入栈
if (KeyIndex + 1 < right)
{
STPush(&st, right);
STPush(&st, KeyIndex + 1);
}
// 判断是否需要对左侧子序列进行排序,并将其入栈
if (left < KeyIndex - 1)
{
STPush(&st, KeyIndex - 1);
STPush(&st, left);
}
}
// 栈中所有任务处理完毕,排序完成
STDestroy(&st);
}
// 三值取中法优化的快速排序递归实现
void QuickSortOptimized(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 三值取中法选取基准数
int mid = begin + (end - begin) / 2;
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
if (a[mid] > a[end])
{
Swap(&a[mid], &a[end]);
}
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
// 将基准数交换到子序列的第二个位置
Swap(&a[mid], &a[begin + 1]);
// 使用霍尔法进行划分
int KeyIndex = PartSort(a, begin, end);
// 对左右子序列递归进行排序
QuickSortOptimized(a, begin, KeyIndex - 1);
QuickSortOptimized(a, KeyIndex + 1, end);
}
//插入排序
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
// [0, end] 有序,插入tmp依旧有序
int end = i;
int tmp = a[i + 1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
// 三数取中法优化的快速排序递归实现(带小区间优化)
void QuickSortOptimizedWithInsertion(int* a, int begin, int end)
{
// 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序
if (end - begin + 1 <= 10)
{
InsertSort(a + begin, end - begin + 1);
return;
}
// 三数取中法选取基准数
int mid = begin + (end - begin) / 2;
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
if (a[mid] > a[end])
{
Swap(&a[mid], &a[end]);
}
if (a[begin] > a[mid])
{
Swap(&a[begin], &a[mid]);
}
// 将基准数交换到子序列的第二个位置
Swap(&a[mid], &a[begin + 1]);
// 使用霍尔法进行划分
int KeyIndex = PartSort(a, begin, end);
// 对左右子序列递归进行排序
QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1);
QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end);
}
int main()
{
int arr[8] = { 55,74,32,67,12,23,49,87 };
printf("Before:\n");
PrintArray(arr, (sizeof(arr) / sizeof(int)));
printf("After:\n");
QuickSortOptimizedWithInsertion(arr, 0, (sizeof(arr) / sizeof(int))-1);
PrintArray(arr, (sizeof(arr) / sizeof(int)));
return 0;
}
void STInit(ST* pst)
{
assert(pst);
pst->a = NULL;
pst->top = 0; //top是栈顶的下一个位置的下标
pst->capacity = 0;
}
void STDestroy(ST* pst)
{
assert(pst);
free(pst->a);
pst->a = NULL;
pst->capacity = pst->top = 0;
}
void STPush(ST* pst, STDataType x)
{
if (pst->top == pst->capacity)
{
int newCapacity = pst->capacity == 0 ? 4 : pst->capacity*2;
STDataType* tmp = realloc(pst->a, newCapacity*sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
pst->a = tmp;
pst->capacity = newCapacity;
}
pst->a[pst->top] = x;
pst->top++;
}
void STPop(ST* pst)
{
assert(pst);
assert(!STEmpty(pst));
pst->top--;
}
STDataType STTop(ST* pst)
{
assert(pst);
assert(!STEmpty(pst));
return pst->a[pst->top - 1];
}
int STSize(ST* pst)
{
assert(pst);
return pst->top;
}
bool STEmpty(ST* pst)
{
assert(pst);
if (pst->top == 0)
{
return true;
}
else
{
return false;
}
}
本文参考了:
- 书籍:
《大话数据结构》- 程杰- 博客:
- CSDN博主「手眼通天王水水」的原创文章http://t.csdn.cn/2ZpHx
遵循CC 4.0 BY-SA版权协议