目录
基本概念
Hoare版本
动图演示
思路
代码实现:
性能分析
取Key优化
三数取中法选择基准(Median-of-Three Partitioning)
实现步骤
代码实现
挖坑法
基本步骤
动图
示例说明
代码实现
前后指针法
动图示范
思路
代码实现:
迭代实现的快速排序
代码:
复杂度分析
时间复杂度
空间复杂度
稳定性与适用场景
稳定性:
适用场景:
基本概念
快速排序(Quick Sort)是一种高效的排序算法,由C. A. R. Hoare在1960年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
对于如何按照基准值将待排序列分为两子序列,常见的方式有:
1、Hoare版本
2、挖坑法
3、前后指针法
下面将对这三个方法进行一一讲解。
Hoare版本
动图演示
思路
Hoare版本的单趟排序的基本步骤如下:
1、选出一个key,一般是最左边或是最右边的。
2、定义一个L和一个R,L从左向右走,R从右向左走。(需要注意的是:若选择最左边的数据作为key,则需要R先走;若选择最右边的数据作为key,则需要L先走)。
3、在走的过程中,若R遇到小于key的数,则停下,L开始走,直到L遇到一个大于key的数时,将L和R的内容交换,R再次开始走,如此进行下去,直到L和R最终相遇,此时将相遇点的内容与key交换即可。(选取最左边的值作为key)
经过一次单趟排序,最终使得key左边的数据全部都小于key,key右边的数据全部都大于key。
然后我们在将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作,因为这种序列可以认为是有序的。
代码实现:
int PartSort_Hoare(int* a, int left,int right)
{
int keyi = left;
while (left < right)
{
while (left<right&&a[right] >= a[keyi])
{
right--;
}
while (left<right&&a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
int meeti = left;
Swap(&a[meeti], &a[keyi]);
return meeti;
}
void QuickSort(int* a,int begin, int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort_Hoare(a, begin, end-1);
QuickSort(a, begin, keyi);
QuickSort(a, keyi + 1, end);
}
这里一定要注意边界的处理,PartSort_Hoare函数中的[left,right]为闭区间,传参的时候也应该注意,递归的时候要也分清边界。
代码测试:
性能分析
- 时间复杂度:平均时间复杂度为O(n log n),但最坏情况下的时间复杂度为O(n^2)(当数组已经是有序或逆序时)。
- 空间复杂度:快速排序的空间复杂度主要是递归引起的栈空间使用,最优为O(log n),最坏为O(n)。
快速排序是一种非常高效的排序算法,在实际应用中非常广泛。虽然其最坏情况下的时间复杂度不如某些排序算法(如归并排序),但其平均性能非常出色,并且在实际应用中经常可以通过一些策略(如随机选择基准、三数取中等)来避免最坏情况的发生。
所以我们接下来对它进行一下优化
取Key优化
三数取中法选择基准(Median-of-Three Partitioning)
在快速排序中,选择一个合适的基准值至关重要,因为它直接影响到排序的效率。如果基准值选择不当,特别是在数据已经部分有序或包含大量重复元素的情况下,可能会导致排序过程退化为冒泡排序,从而增加排序的时间复杂度。三数取中法通过比较并选取三个位置上的元素的中位数作为基准值,试图在一定程度上缓解这个问题。
实现步骤
- 选择三个元素:从数组的开始(mid)、中间(mid = (left + right) / 2)和结束(right)位置选择三个元素。
- 比较并排序:将这三个元素进行比较,并按照从小到大的顺序进行排序(如果需要的话,可以交换它们在数组中的位置)。
- 选择中位数:将排序后的中间元素作为基准值(keyi)。
- 进行分区:以基准值为界,将数组分为两个子数组,一个包含所有小于基准值的元素,另一个包含所有大于基准值的元素。
- 递归排序:对这两个子数组递归地应用快速排序算法。
优点
- 减少最坏情况的发生:通过选择中位数作为基准值,减少了排序过程中最坏情况(即时间复杂度退化到O(n^2))的出现概率。
- 提高排序效率:在处理包含大量重复元素或接近有序的数据集时,三数取中法能够显著提高排序的效率
代码实现
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (left - right)/2;
if (a[mid] > a[left])
{
if (a[right] > a[mid])
{
return mid;
}
else if (a[left] > a[right])
return left;
else
return right;
}
else//a[mid]<a[left]
{
if (a[right] < a[mid])
return mid;
else if (a[right] > a[left])
return left;
else
return right;
}
}
注意:当大小居中的数不在序列的最左或是最右端时,我们不是就以居中数的位置作为key的位置,而是将key的值与最左端的值进行交换,这样key就还是位于最左端了,所写代码就无需改变,而只需在单趟排序代码开头加上以下两句代码即可:
int keyi = GetMidIndex(a, begin, end);//获取大小居中的数的下标
Swap(&a[begin], &a[keyi]);//将该数与序列最左端的数据交换
//以下代码保持不变
挖坑法
快排挖坑法(也称为挖坑填补法)是快速排序算法的一种实现方式,其思路主要基于分治策略。以下是快排挖坑法的基本思路:
基本步骤
- 选择基准元素:
- 通常选择数组的第一个元素作为基准元素(key),但也可以采用更复杂的策略,这里我们用三数取中法,来选取一个更合适的基准元素。
- 挖坑:
- 将基准元素保存起来(通常保存在一个临时变量中),并将基准元素所在的位置视为一个“坑”。
- 填坑:
- 从数组的右端开始向左遍历,找到第一个比基准元素小的元素,将其填入“坑”中,并将这个位置视为新的“坑”。
- 然后,从数组的左端开始向右遍历,找到第一个比基准元素大的元素,将其填入新的“坑”中,并将这个位置再次视为新的“坑”。
- 重复上述过程,直到左右指针相遇,此时左右指针所指的位置即为基准元素的最终位置。
- 递归排序:
- 将基准元素填入最终的“坑”中后,基准元素左边的所有元素都比它小,右边的所有元素都比它大。
- 接着,对基准元素左边和右边的子数组递归地进行上述操作,直到整个数组有序。
动图
示例说明
假设有一个数组 [6, 1, 4, 7, 9, 2, 5, 6, 7, 3, 10]
,选择第一个元素 6
作为基准元素。
-
挖坑:将
6
保存在临时变量中,数组变为[_, 1, 4, 7, 9, 2, 5, 6, 7, 3, 10]
,其中_
表示“坑”。 -
填坑:
- 从右向左找到第一个比
6
小的元素3
,填入“坑”中,数组变为[3, 1, 4, 7, 9, 2, 5, 6, 7, _, 10]
。 - 从左向右找到第一个比
6
大的元素7
,填入新的“坑”中,数组变为[3, 1, 4, _, 9, 2, 5, 6, 7, 7, 10]
。 - 重复此过程,直到左右指针相遇,最终将基准元素
6
填入相遇的位置,数组变为[3, 1, 4, 5, 6, 2, 9, _, 7, 7, 10]
。
- 从右向左找到第一个比
-
递归排序:对基准元素
6
左边和右边的子数组[3, 1, 4, 5]
和[2, 9, 7, 7, 10]
分别进行快速排序。
代码实现
int PartSort_Hole(int* a, int left, int right)
{
int keyi = GetMidIndex(a, left, right);
Swap(&a[keyi], &a[left]);
int tmp = a[keyi];
int hole = left;
while (left < right)
{
while(left < right&&a[right] >= tmp)
{
--right;
}
a[hole] = a[right];
hole = right;//新的坑在右边
while (left < right&&a[left]<=tmp)
{
++left;
}
a[hole] = a[left];
hole = left;
}
a[hole] = tmp;
return hole;
}
void QuickSort(int* a,int begin, int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort_Hole(a, begin, end-1);
QuickSort(a, begin, keyi);
QuickSort(a, keyi + 1, end);
}
前后指针法
动图示范
思路
这里我们依然用(三数取中法)获得key,前指针定义为prev = left,后指针定义为cur = left+1,由于我们上传的是闭区间,所以循环是cur<=right,越界循环即截止。
从动图中我们能看到,cur一直在向后++,找小于key位置上的值,如果小于,和prev位置的值交换,然后prev++。
当结束循环,把key与prev的值进行交换。
代码实现:
int PartSort_Double_p(int* a, int left, int right)
{
int mid = GetMidIndex(a, left, right);
Swap(&a[mid], &a[left]);
int cur = left + 1;
int prev = left;
int keyi = left;
while (cur <= right)//闭区间
{
//代码优化
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
++cur;
//if (a[cur] < a[keyi])//当cur = prev的时候没有必要交换
//{
// ++prev;
// Swap(&a[prev], &a[cur]);
//}
//cur++;
}
int meeti = prev;//cur越界时,prev的位置
Swap(&a[keyi], &a[meeti]);//交换key和prev指针指向的内容
return meeti;//返回key的当前位置
}
void QuickSort(int* a,int begin, int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort_Double_p(a, begin, end-1);
QuickSort(a, begin, keyi);
QuickSort(a, keyi + 1, end);
}
迭代实现的快速排序
迭代实现的快速排序通常使用栈(stack)来模拟递归调用栈。这种方法可以避免递归调用带来的栈溢出风险,尤其是在处理大数据集时。
代码:
void QuickSortNonR(int* a, int begin, int end)
{
Stack st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
if (left >= right)
{
continue;
}
int keyi = PartSort_double(a, left, right);
StackPush(&st, keyi+1);
StackPush(&st, right);
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
StackDestroy(&st);
}
复杂度分析
时间复杂度
快速排序的时间复杂度依赖于基准元素(pivot)的选择方式以及数据的初始状态。
-
最佳情况:当每次分区操作都能将数组均匀地划分为两个子数组时,即两个子数组的大小都接近n/2,此时的时间复杂度最优。在这种情况下,递归树的高度为log2(n),每一层的时间复杂度为O(n)(因为需要遍历整个数组进行分区),所以总的时间复杂度为O(n) * log2(n) = O(nlogn)。
-
平均情况:在实际应用中,由于基准元素的选择通常是随机的或采用某种策略(如三数取中法),分区操作大致能将数组均匀地划分为两部分。因此,平均情况下的时间复杂度也是O(nlogn)。
-
最坏情况:当输入的数组已经有序(或接近有序)时,每次分区操作都会得到一个大小为0的子数组和一个大小为n-1的子数组,递归树退化为一个线性结构。此时,时间复杂度为O(n) + O(n-1) + ... + O(1) = O(n^2)。然而,通过采用随机选择基准元素或使用优化策略(如三数取中法),可以避免最坏情况的发生,使时间复杂度保持在O(nlogn)。
空间复杂度
快速排序的空间复杂度主要取决于递归栈的深度以及算法中使用的辅助空间。
-
递归栈:快速排序是递归的,需要使用递归栈来保存中间状态。在最好情况下,递归栈的深度为O(logn)(与递归树的高度相同)。在最坏情况下,递归栈的深度可能达到O(n)(当输入数组已经有序时)。
-
辅助空间:除了递归栈外,快速排序还需要额外的空间来保存分区操作的临时数据(如pivot元素的索引或临时数组)。然而,这部分空间通常是O(1)的,因为只需要几个变量来保存临时数据。如果考虑使用额外数组来存储分区结果,则空间复杂度会增加到O(n)。但通常情况下,快速排序采用原地排序(in-place sorting),即直接在原数组上进行排序,不需要额外的存储空间。
综上所述,快速排序的平均时间复杂度为O(nlogn),最坏情况下为O(n^2),但可以通过优化策略避免。空间复杂度方面,主要取决于递归栈的深度,通常为O(logn)到O(n),但在原地排序时额外空间复杂度为O(1)。
稳定性与适用场景
稳定性:
快速排序是一种不稳定的排序算法。因为快速排序在分区过程中可能会改变相等元素的相对 顺序。
适用场景:
快速排序在大多数情况下表现优异,特别适用于大规模数据的排序。由于它的平均时间复 杂 度为O(nlogn),且实现简单、效率高,因此在实际应用中非常广泛。然而,在处理小规 模数据或已经接近有序的数据时,快速排序的性能可能不如其他排序算法(如插入序)。