快速排序是一种由英国计算机科学家Hoare于1962年提出的排序算法,也就是Hoare法,后续有人认为Hoare法难以理解,又发明了“挖坑法”,“前后指针法”等(这些方法都叫快速排序),但其基本思想和复杂度等均与Hoare法大同小异,此篇博客我们重点讲解Hoare法。
基本思想:
快速排序的基本思想是分治法。其核心是选择一个基准值(key),然后将待排序的数组分成两部分,使得左侧的所有元素都不大于基准值,而右侧的所有元素都不小于基准值。这个过程称为“划分”。之后,递归地对左右两部分继续进行快速排序,直至每一部分只有一个元素或为空,整个数组就变成了有序的。
算法步骤:
- 选择基准值:通常选择数组的第一个元素、最后一个元素或中间元素作为基准值。
- 划分操作:将数组分成两个子数组,一个包含小于基准值的元素,另一个包含大于基准值的元素。
- 递归排序:对划分后的两个子数组分别进行快速排序。
- 合并结果:由于递归的排序过程,不需要额外的合并步骤,最终排序结果自然形成。
示例代码(递归)
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
// 快速排序算法
void QuickSort(int* a, int left, int right)//注意传参和其他排序不一样
{
if (left >= right)
return;
int keyi = left; // 基准元素索引
int begin = left, end = right; // 左右指针
while (begin < end)
{
// 从右边开始,找到第一个比基准元素小的元素(必须先从右边开始)
while (begin < end && a[end] >= a[keyi])
{
end--;
}
// 从左边开始,找到第一个比基准元素大的元素
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
// 交换找到的两个元素
Swap(&a[begin], &a[end]);
}
// 将基准元素放到正确的位置上
Swap(&a[keyi], &a[begin]);
keyi = begin;
// 递归调用快速排序对左右两部分进行排序
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi+1, right);
}
问题和解决
在排序过程中,我们怎么保证相遇位置一定比key
小?
直接说结论:左边做key,右边先走,可以保证相遇位置比key小。
两种情况:
- left遇right :right先走,遇到小于key的停下来,left没有找到大于key的值,遇到right停下来。
- right遇left:right先走,找小,没有找到,直接与left相遇。left停留的位置是上一次交换的位置,上一次交换,把比key小的值换到left的位置了。
优化
为了避免有序情况下,排序效率退化,在进行选key时,我们尽量选择相对中间的值作为key,所以可以加入三数取中
来优化我们的算法。
int GetMidi(int* a, int left, int right)
{
int mid = (right - left) / 2; //计算中间位置
if (a[left] < a[right]) //如果左边的元素小于右边的元素
{
if (a[mid] < a[left]) //如果中间元素小于左边元素
{
return left; //返回左边位置
}
else if (a[mid] > a[right]) //如果中间元素大于右边元素
{
return right; //返回右边位置
}
else //如果中间元素处于左边元素和右边元素之间
{
return mid; //返回中间位置
}
}
else //如果左边的元素大于右边的元素
{
if (a[mid] < a[right]) //如果中间元素小于右边元素
{
return right; //返回右边位置
}
else if (a[mid] > a[left]) //如果中间元素大于左边元素
{
return left; //返回左边位置
}
else //如果中间元素处于左边元素和右边元素之间
{
return mid; //返回中间位置
}
}
}
快速排序的递归过程类似与二叉树,越往下递归次数越多,为了减少递归次数并提高效率,我们可以在排序元素个数比较少的时候使用插入排序,进行小区间优化
优化后的代码(递归)
这段代码添加了优化,并且把单次排序的代码单独封装成一个函数(PartSort)便于我们一会实现非递归的快速排序。
int PartSort(int* a, int left, int right)
{
//三数取中
int mid = GetMidi(a, left, right);
Swap(&a[left], &a[mid]);
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
{
end--;
}
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[keyi]);
return begin;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//小区间优化
if ((right - left + 1) < 10)
{
InsertSort(a + left, right - left + 1);//使用插入排序
}
else
{
int keyi = PartSort(a, left, right);
//[left,keyi-1]keyi[keyi+1,right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
非递归代码
非递归实现我们使用了栈这个数据结构(代码可以看我之前的博客)由于栈的特性后进先出
,所以我们在用栈模拟递归时需要先将右边界压入栈,再将左边界压入栈,这样取出栈中的两个元素就相当于取出了一个区间,每次取出一个区间就把该区间分割后的两个小区间再压进栈,如此往复直到栈为空,我们就完成了排序。
// 快速排序函数
void QuickSort(int* a, int left, int right)
{
Stack ST; // 定义一个栈ST
StackInit(&ST); // 初始化栈ST
StackPush(&ST, right); // 将右边界right压入栈ST
StackPush(&ST, left); // 将左边界left压入栈ST
while (!StackEmpty(&ST)) // 当栈ST非空时,循环执行以下操作
{
int begin = StackTop(&ST); // 取出栈顶的begin
StackPop(&ST); // 弹出栈顶元素
int end = StackTop(&ST); // 取出栈顶的end
StackPop(&ST); // 弹出栈顶元素
int keyi = PartSort(a, begin, end); //调用刚才的函数找到keyi
//[begin,keyi-1]keyi[keyi+1,end]
// 将子数组[keyi+1,end]压入栈ST中
if (keyi + 1 < end)
{
StackPush(&ST, end);
StackPush(&ST, keyi + 1);
}
// 将子数组[begin,keyi-1]压入栈ST中
if (begin < keyi - 1)
{
StackPush(&ST, keyi - 1);
StackPush(&ST, begin);
}
}
}
时间复杂度:
快速排序的平均时间复杂度是O(NlogN),这是因为在每次划分操作中,平均需要比较logN次来找到基准值的正确位置,而这样的划分操作需要进行N次(每次划分排除一个元素)。
空间复杂:
快速排序的空间复杂度在平均情况下是O(logN),这是因为递归调用栈的深度。最坏的情况下(当输入数组已经有序或接近有序时),空间复杂度可能退化到O(N)。
优点:
- 快速排序在平均情况下非常快,效率高。
- 它是一种原地排序算法,不需要额外的存储空间。
- 实现相对简单。
缺点:
- 快速排序在最坏情况下的时间复杂度是O(N^2),尽管这种情况不常见,但仍然是它的一个弱点。(可以通过三数取中解决)
- 它不是一个稳定的排序算法,即相等的元素可能在排序过程中交换位置。
- 对递归调用栈的使用意味着在极端情况下可能会有栈溢出的风险。