个人主页:C++忠实粉丝
欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 C++忠实粉丝 原创排序算法(4)之快速排序(1)
收录于专栏【数据结构初阶】
本专栏旨在分享学习数据结构学习的一点学习笔记,欢迎大家在评论区交流讨论💌
目录
1.常见排序算法
2.快速排序
2.1快速排序的概念
2.2快速排序的版本
2.2.1 hoare版本
2.2.2 挖坑法
2.2.3 前后指针版本
3. 快速排序的时间复杂度
4.总结
1.常见排序算法
2.快速排序
2.1快速排序的概念
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
if (right - left <= 1)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int div = partion(array, left, right);
}
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
QuickSort(array, left, div);
// 递归排[div+1, right)
QuickSort(array, div + 1, right);
上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,同学们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。
2.2快速排序的版本
将区间按照基准值划分为左右两半部分的常见方式有:
2.2.1 hoare版本
在 Hoare 版本中,划分过程如下:
选择基准值: 通常选择数组的第一个元素作为基准值,也可以随机选择数组中的某个元素。
划分过程: 使用两个指针,一个从左边开始(左指针),一个从右边开始(右指针)。它们分别向中间移动,直到找到需要交换的元素。
具体步骤:
- 左指针移动: 从左往右找到第一个大于或等于基准值的元素。
- 右指针移动: 从右往左找到第一个小于或等于基准值的元素。
- 交换元素: 如果左指针所指元素大于基准值,并且右指针所指元素小于基准值,则交换这两个元素。
- 继续移动指针: 继续移动左右指针,直到它们相遇。
交换基准值: 最后将基准值与右指针所指的元素进行交换,使得基准值左侧的元素都小于或等于基准值,右侧的元素都大于或等于基准值。
假设有一个数组 arr
如下:
arr = [6, 5, 3, 1, 8, 7, 2, 4]
我们选择数组的第一个元素作为基准值(pivot)。现在,我们按照 Hoare 版本的方法来进行划分和排序。
-
初始状态:
数组:[ [6, 5, 3, 1, 8, 7, 2, 4] ],左指针 (left
) 初始在数组开头,右指针 (right
) 初始在数组结尾。 -
第一轮划分过程:
现在数组变为:[ [4, 5, 3, 1, 6, 7, 2, 8] ]
继续交换
arr[left]
和arr[right]
:数组变为:[ [4, 5, 3, 1, 6, 2, 7, 8] ]
现在
left
和right
相遇,停止第一轮划分。 - 左指针
left
开始向右移动,直到找到一个大于或等于基准值6
的元素。在这个例子中,left
移动到8
处。- 右指针
right
开始向左移动,直到找到一个小于或等于基准值6
的元素。在这个例子中,right
移动到4
处。 - 交换
arr[left]
和arr[right]
,因为8 > 6
且4 < 6
,所以交换它们。
- 右指针
left
继续向右移动,移动到7
处。right
继续向左移动,移动到6
处。
-
基准值位置确定:
将基准值6
与arr[right]
(即2
)交换,数组变为:[ [4, 5, 3, 1, 2, 6, 7, 8] ]此时6
左侧的元素小于或等于6
,右侧的元素大于或等于6
。 -
递归调用:
分别对基准值左右两侧的子数组递归进行快速排序。
代码展示:
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;
//[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) { if (left >= right) return; int keyi = left; int begin = left, end = right;
QuickSort
函数接收一个整数数组a
,以及排序范围的左右边界left
和right
。- 如果
left >= right
,即排序范围为空或只有一个元素,直接返回,不需要继续排序。keyi
初始化为left
,表示选取第一个元素作为基准值的索引。begin
和end
初始化为left
和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]); }
这是 Hoare 分区的实现部分:
- 交换这两个元素的位置,确保所有小于基准值的元素都位于基准值的左侧,所有大于基准值的元素都位于右侧。
- 从数组的左端开始,找到第一个大于基准值
a[keyi]
的元素。- 从数组的右端开始,找到第一个小于基准值
a[keyi]
的元素。确定基准值的最终位置:
Swap(&a[keyi], &a[begin]); keyi = begin;
将基准值a[keyi]
移动到它最终应该处于的位置begin
,并更新keyi
。递归调用排序左右子数组:
QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right);
递归调用QuickSort
函数对基准值左右两侧的子数组进行排序。
这里的递归过程就拿我们上面图解的序列举例
经过一次排序,我们得到左边比key小,右边比key大,然后便是递归的过程.
2.2.2 挖坑法
void QuickSort(int* a, int left, int right) {
if (left >= right)
return;
int key = a[left]; // 选择第一个元素作为基准值
int low = left, high = right;
while (low < high) {
// 从右向左找到第一个小于基准值key的元素
while (low < high && a[high] >= key)
high--;
if (low < high) {
a[low] = a[high]; // 使用 a[high] 的值填充 a[low] 的坑
low++;
}
// 从左向右找到第一个大于基准值key的元素
while (low < high && a[low] <= key)
low++;
if (low < high) {
a[high] = a[low]; // 使用 a[low] 的值填充 a[high] 的坑
high--;
}
}
a[low] = key; // 将基准值放入最终的坑中
int pivot = low; // 基准值的最终位置
QuickSort(a, left, pivot - 1); // 对左子数组递归排序
QuickSort(a, pivot + 1, right); // 对右子数组递归排序
}
挖坑法(Lomuto分区)是快速排序的一种经典实现方式,与 Hoare分区方案相比,其主要区别在于如何选择基准元素和如何进行元素交换。下面是使用挖坑法实现的快速排序算法的详细解释:
实现步骤:
函数定义和初始条件:
QuickSort
函数接收整数数组a
,以及排序范围的左右边界left
和right
。如果left >= right
,说明排序范围为空或只有一个元素,直接返回。挖坑并进行分区:将找到的大于
将找到的小于key
的元素填充到high
所指的坑中,并将high
向左移动。接着从数组左端low
开始,向右寻找第一个大于基准值key
的元素。key
的元素填充到low
所指的坑中,并将low
向右移动。从数组右端high
开始,向左寻找第一个小于基准值key
的元素。填坑完成分区:
将基准值放入最终的坑中,确定基准值的最终位置
递归排序左右子数组:
pivot
是基准值的最终位置,左侧元素都小于基准值,右侧元素都大于基准值。递归地对左右两个子数组进行快速排序。总结:
- 挖坑法是快速排序的一种高效分区方案,其核心思想是通过不断填坑的方式将数组分为小于和大于基准值的两部分,然后递归地对这两部分进行排序。
- 比较与 Hoare分区方案的区别在于处理基准值的交换策略和分区过程中的具体操作顺序。
- 在实际应用中,挖坑法通常比 Hoare分区方案更高效,因为它减少了元素的移动次数。
2.2.3 前后指针版本
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
keyi
初始化为left
,作为当前分区的基准元素索引。prev
初始化为left
,表示当前小于基准值的元素应该放置的位置。cur
初始化为prev + 1
,从基准元素的下一个位置开始向右遍历数组。在
while
循环中:
- 如果
a[cur]
小于a[keyi]
,则将a[cur]
与a[prev+1]
位置的元素交换,并增加prev
的值。- 遍历完成后,将基准元素
a[keyi]
与a[prev]
位置的元素交换,这样基准元素就被放置到了正确的位置,并返回基准元素的新索引prev
。
在实现上,它遵循了快速排序的基本思路:选择一个基准元素,通过分区操作将数组分为两部分,然后递归地对每部分进行排序。
图解:
3. 快速排序的时间复杂度
平均情况时间复杂度
在平均情况下,快速排序的时间复杂度为 O(n log n)。
- 分析:
- 快速排序的核心是分区操作,选择一个基准元素并将数组分为两部分。在理想情况下,每次分区后基准元素大约将数组划分为两个大小相等的子数组。
- 假设每次分区的时间复杂度为 O(n),其中 n 是当前数组的长度。在最均匀的情况下,每次递归都会将问题规模减半。
- 因此,递归树的高度是 O(log n),每层的分区操作总共需要 O(n) 的时间,因此总体时间复杂度是 O(n log n)。
最坏情况时间复杂度
在最坏情况下,快速排序的时间复杂度为 O(n^2)。
- 分析:
- 最坏情况发生在每次选择的基准元素都是当前子数组中的最大或最小元素,导致分区后一个子数组为空,另一个子数组包含 n-1 个元素。
- 这种情况下,递归树退化为一个类似于冒泡排序的结构,递归深度为 n,每层的时间复杂度为 O(n)。
- 因此,总体时间复杂度为 O(n) * O(n) = O(n^2)。
最佳情况时间复杂度
在最佳情况下,即每次分区都能均匀划分数组的情况下,时间复杂度也是 O(n log n)。
4.总结
快速排序的平均时间复杂度为 O(n log n),它的实际性能优秀且适用于大部分情况。然而,在处理大部分已经有序的数组时,快速排序可能会退化到 O(n^2),这时可以考虑采用随机化快速排序或者其他改进版本来避免最坏情况的发生。这也就是我下个章节需要讲到的快速排序的优化以及用非递归实现快速排序.