概念:
快速排序是一种非常高效的排序算法,由C. A. R. Hoare在1960年提出。它采用了分治法(Divide and Conquer)的策略,通过递归将问题分解为更小的子问题来解决。
- 分治法:将问题分解成多个小问题,递归解决小问题,然后将结果合并。
- 基准元素(Pivot):选择数组中的一个元素作为基准,根据这个基准将数组分为两部分,一部分包含比基准小的元素,另一部分包含比基准大的元素。
- 分区操作:根据基准元素将数组分为两个子数组的过程。
算法步骤:
思路:
![](https://i-blog.csdnimg.cn/direct/644de6c20bed40399575f439d7c253bd.gif)
- 选择基准:从数组中选择一个元素作为基准(key)。有多种选择方法,如选择第一个元素、最后一个元素、中间元素或随机元素。(关键)
- 分区:重新排列数组,所有比基准小的元素放在基准的左边,所有比基准大的元素放在基准的右边。这个过程称为分区(Partitioning)。
- 递归排序:递归地将上述步骤应用到基准左边和右边的子数组上。
在选择快速排序算法中的基准元素(Key)时,有几种不同的策略,每种策略都有其优缺点。以下是一些常见的选择基准的方法:
-
首元素:选择数组的第一个元素作为基准。这种方法简单,但当输入数组已经部分排序时,可能会导致最坏情况的性能。(递归深度太大,导致栈溢出)
-
末元素:选择数组的最后一个元素作为基准。这与首元素类似,但当数据集中存在多个重复元素时,可能会有不同的性能表现。
-
中位数:选择数组中间位置的元素作为基准。这可以提供比首元素或末元素更好的平均性能。
-
三数中位数:从数组的首元素、末元素和中间元素中选择中位数作为基准。这种方法旨在减少当数组已经部分排序时的不利影响。
-
随机选择:随机选择数组中的一个元素作为基准。这可以避免最坏情况的发生,特别是在输入数据可能已经排序的情况下。
-
尾递归优化:在递归调用中,选择较小的分区首先进行排序,这可以减少递归深度。
-
荷兰国旗算法:这是一种多路快速排序算法,它使用三个指针将数组分为三部分:小于、等于和大于基准值的元素。
-
第九个元素:选择数组长度的1/9、2/9、...、8/9位置的元素作为基准。这种方法在某些情况下可以提供更好的性能。
-
平均值:取数组中若干个元素(例如三个)的平均值作为基准。这种方法可以减少选择极端值作为基准的可能性。
-
Bogdanov和Ducreux方法:这是一种基于统计学的方法,通过计算数组中元素的中位数来选择基准。
选择基准的策略对快速排序的性能有重要影响。在实践中,随机选择通常被认为是一种很好的策略,因为它可以减少最坏情况发生的概率,并且对于各种类型的数据集都表现良好。然而,选择哪种策略可能取决于具体的应用场景和数据特性。
常见的,我们可以对快速排序进行以下优化:
优化:
- 三数取中法:选择数组的第一个、中间的和最后一个元素的中位数作为基准,以提高基准选择的公平性。
// 选择三个元素的中位数
int medianOfThree(std::vector<int>& arr, int low, int high) {
int mid = low + (high - low) / 2; // 中间的索引
// 比较三个元素,确保arr[low] <= arr[mid] 和 arr[mid] <= arr[high]
if (arr[low] > arr[mid]) {
std::swap(arr[low], arr[mid]);
}
if (arr[low] > arr[high]) {
std::swap(arr[low], arr[high]);
}
if (arr[mid] > arr[high]) {
std::swap(arr[mid], arr[high]);
}
// 将中间值放到最左边
std::swap(arr[mid], arr[low]);
return arr[high];
}
- 尾递归优化:在递归调用中,总是先处理较小的部分,这样可以减少递归栈的深度。
- 小数组(区间)优化:当子数组的大小小于某个阈值(如10)时,使用插入排序,因为小数组上插入排序可能更快。
- 并行化:快速排序可以并行处理,因为每个分区操作是独立的。
- 随机化:随机选择基准元素,以减少最坏情况发生的概率。
#include <vector>
#include <cstdlib> // for std::rand and std::srand
// 随机化基准选择
int randomizedPivot(std::vector<int>& arr, int low, int high) {
return low + std::rand() % (high - low);
}
实现:
在实现快速排序有以下三种方法:
hoare:
![](https://i-blog.csdnimg.cn/direct/644de6c20bed40399575f439d7c253bd.gif)
使用Hoare快速排序算法,并将基准(key)放在左侧的分区优化版本,可以通过以下步骤实现:
- 使用三数取中法选择一个好的基准。
- 利用Hoare分区方法将基准放到正确的位置,同时确保所有小于基准的元素都在它的左边。
- 递归地对基准左边和右边的子数组进行排序。
以下是使用Hoare快速排序算法的优化版本,并将基准放在左侧的C++代码示例:
#include <iostream>
#include <vector>
// 插入排序函数,用于小区间排序
void insertionSort(std::vector<int>& arr, int left, int right) {
for (int i = left + 1; i <= right; ++i) {
int key = arr[i];
int j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
--j;
}
arr[j + 1] = key;
}
}
// 三数取中法选择基准
void medianOfThree(std::vector<int>& arr, int low, int mid, int high) {
if (arr[low] > arr[mid]) std::swap(arr[low], arr[mid]);
if (arr[low] > arr[high]) std::swap(arr[low], arr[high]);
if (arr[mid] > arr[high]) std::swap(arr[mid], arr[high]);
std::swap(arr[mid], arr[low]);
}
// Hoare快速排序的分区函数,选择最左侧的元素作为基准
int hoarePartition(std::vector<int>& arr, int low, int high) {
medianOfThree(arr, low, (low + high) / 2, high); // 选择三数中位数作为基准
int pivot = arr[low]; // 将基准放到最左侧
int left = low + 1;
int right = high;
while (left < right) {
// 从右向左找,直到找到小于或等于基准的元素
while (left < right && arr[right] >= pivot) {
right--;
}
// 从左向右找,直到找到大于基准的元素
while (left < right && arr[left] <= pivot) {
left++;
}
// 如果left和right指向的元素都满足条件,则交换它们
if (left < right) {
std::swap(arr[left], arr[right]);
left++;
right--;
}
}
// 将基准放到分区索引处
std::swap(arr[low], arr[left]);
return left; // 返回基准的最终位置
}
// Hoare快速排序递归函数
void hoareQuickSort(std::vector<int>& arr, int low, int high) {
if (low >= high)
{
return;
}
// 小区间优化
const int THRESHOLD = 10; // 阈值设为10
if (low + THRESHOLD - 1 < high) { // 区间长度大于阈值
int pi = hoarePartition(arr, low, high);
hoareQuickSort(arr, low, pi - 1);
hoareQuickSort(arr, pi + 1, high);
}
else {
insertionSort(arr, low, high);
}
}
Tip:
为什么相遇时,要与基准交换(或者说:基准位置的值一定比相遇位置的值大)?
针对上述代码:
左边做key(基准),右边先走,可以保证相遇位置比key小
相遇的场景:
L遇R:因为L是要找大,R要找小,R先走,停下来,R停下来的位置一定比key小,然而L要找大,找到就停下来了,但是没找到大的就遇到R,然后停下来了;
R遇L:R先走,找小,没有找到比key小的,直接跟L相遇了。L停留的位置是上一轮交换的位置,上一轮交换,把比key小的值换到L的位置;
总之:是右边先走的影响,左边先走就有影响了。我们可以推出:如果让右边做key,左边先走,可以保证相遇位置比key要大(升序:哪边做key,哪边先走)
在快速排序算法的递归实现中,通常不需要在每个递归调用中显式地写return
语句,因为递归调用的返回值是通过函数调用栈隐式返回的。递归函数在完成其工作后,会自动返回到调用它的上一级函数。这是递归函数的一个基本特性。
在快速排序的递归函数quickSort
中,递归调用quickSort(arr, low, pi - 1);
和quickSort(arr, pi + 1, high);
不需要显式返回,因为它们的作用是将排序过程继续在子数组上执行,而不是返回一个值。排序的结果是通过原地(in-place)修改数组来实现的,而不是通过返回值。
void quickSort(std::vector<int>& arr, int low, int high) {
if (low < high) {
// 分区操作,pi是分区索引
int pi = partition(arr, low, high);
// 递归地对基准左边的子数组进行排序
quickSort(arr, low, pi - 1);
// 递归地对基准右边的子数组进行排序
quickSort(arr, pi + 1, high);
}
// 递归调用自动返回,不需要显式return语句
}
在上面的代码中,quickSort
函数在每次递归调用之后不需要显式地写return
语句。当递归到达数组的最底部(即low
等于high
时),递归开始逐步返回,此时数组的排序过程也随之完成。
然而,如果你想要显式地在递归函数中添加return
语句,你可以在函数的最后添加return;
,这在大多数情况下是多余的,但不会对函数的行为产生影响:
void quickSort(std::vector<int>& arr, int low, int high) {
if (low < high) {
// 分区和递归调用
// ...
// 最后一个递归调用之后
return; // 这实际上是多余的
}
}
在实际编程中,通常遵循简洁性原则,避免添加不必要的代码,除非这样做可以提高代码的可读性或有其他明确的好处的呢。
递归本质上就是建立栈帧,在栈桢中重点存的核心是区间,我们就可以以非递归形式(用栈)实现快排:
#include <iostream>
#include <stack>
#include <vector>
void quickSort(std::vector<int>& arr, int low, int high) {
std::stack<std::pair<int, int>> stack;
stack.push({ low, high });
while (!stack.empty()) {
std::pair<int, int> range = stack.top();
stack.pop();
int start = range.first;
int end = range.second;
if (start >= end) {
continue;
}
// 选择枢轴,这里简单选择区间的第一个元素
int pivot = arr[start];
int i = start;
int j = end;
while (i < j) {
while (i < j && arr[j] >= pivot) {
j--;
}
while (i < j && arr[i] <= pivot) {
i++;
}
if (i < j) {
std::swap(arr[i], arr[j]);
}
}
// 将枢轴放到正确的位置
arr[start] = arr[i];
arr[i] = pivot;
// 将新的区间压入栈中
stack.push({ start, i - 1 });
stack.push({ i + 1, end });
}
}
int main() {
std::vector<int> arr = { 10, 7, 8, 9, 1, 5 };
int n = arr.size();
quickSort(arr, 0, n - 1);
for (int num : arr) {
std::cout << num << " ";
}
return 0;
}
使用栈实现快速排序(或其他递归算法)的好处主要包括以下几点:
-
避免递归调用开销:递归调用可能会增加函数调用的开销,包括栈帧的创建和销毁。使用栈来模拟递归可以减少这种开销。
-
减少递归深度:在递归实现中,如果数据集很大,可能会导致递归深度过大,从而增加调用栈溢出的风险。使用栈可以更好地控制递归深度。
-
提高效率:通过迭代的方式,可以减少函数调用的开销,从而提高算法的效率。
挖坑法:
![](https://i-blog.csdnimg.cn/direct/56ea15f7f8a546a5bd951033821798ac.gif)
- 随机选择基准:避免选择数组两端的元素作为基准,减少已经排序数组的最坏情况。
- 挖坑法分区:使用挖坑法进行分区,将小于基准的元素移到数组的前端。
- 递归排序:递归地对基准元素左边和右边的子数组进行排序。
#include <iostream>
#include <vector>
#include <cstdlib> // For std::rand and std::srand
// 初始化随机数种子
void initRandomSeed() {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
}
// 随机选择基准元素的索引
int randomizedPivotIndex(const std::vector<int>& arr, int low, int high) {
if (low == high) return low;
return low + std::rand() % (high - low + 1);
}
// 挖坑法分区函数
int lomutoPartition(std::vector<int>& arr, int low, int high) {
int pivotIndex = randomizedPivotIndex(arr, low, high);
int pivot = arr[pivotIndex];
std::swap(arr[pivotIndex], arr[high]); // 将基准移到末尾
int i = low; // '坑'的位置
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
std::swap(arr[i], arr[j]);
i++;
}
}
std::swap(arr[i], arr[high]); // 将基准移到'坑'的位置
return i;
}
// 快速排序递归函数
void quickSort(std::vector<int>& arr, int low, int high) {
while (low < high) {
// 挖坑法分区
int pivotIndex = lomutoPartition(arr, low, high);
// 递归地对基准左边的子数组进行排序
if (pivotIndex - low < high - pivotIndex) {
quickSort(arr, low, pivotIndex - 1);
low = pivotIndex + 1;
} else {
quickSort(arr, pivotIndex + 1, high);
high = pivotIndex - 1;
}
}
}
前后指针:
![](https://i-blog.csdnimg.cn/direct/9714c63cf36e4b28922737403fa77ce3.gif)
-
选择两个基准:通常选择数组的首元素、中间元素和尾元素,然后确定两个基准,使得一个基准小于或等于另一个。
-
初始化三个指针:
low
指针、mid
指针和high
指针分别指向数组的起始位置、中间位置和结束位置。 -
使用双向指针进行分区:
- 两个
i
和j
指针分别从low+1
和high-2
开始,向中间移动。 - 所有小于第一个基准的元素交换到
low
指针的右侧。 - 所有在两个基准之间的元素保持在两个
i
和j
指针之间。 - 所有大于第二个基准的元素交换到
high
指针的左侧。
- 两个
-
递归排序:对三个分区进行递归排序。
#include <iostream>
#include <vector>
#include <algorithm> // For std::swap
// 选择两个基准的中位数
int choosePivot(std::vector<int>& arr, int l, int m, int r) {
if (arr[l] > arr[m]) std::swap(arr[l], arr[m]);
if (arr[m] > arr[r]) std::swap(arr[m], arr[r]);
if (arr[l] > arr[m]) std::swap(arr[l], arr[m]);
return m;
}
// 双路快速排序的分区函数
void dualPivotPartition(std::vector<int>& arr, int low, int high) {
if (high - low <= 10) { // 对小数组使用插入排序
std::sort(arr.begin() + low, arr.begin() + high + 1);
return;
}
int p1 = low, p2 = high;
int pivot1 = arr[choosePivot(arr, low, low + (high - low) / 2, high)];
int pivot2 = arr[high];
int i = low, j = high;
while (i < j) {
while (i < p2 && arr[i] <= pivot1) i++;
while (j > p1 && arr[j] >= pivot2) j--;
if (i < j) {
if (arr[i] < pivot2) {
std::swap(arr[i++], arr[j--]);
} else if (arr[j] <= pivot1) {
std::swap(arr[i], arr[j--]);
} else {
i++;
}
}
}
std::swap(arr[i], arr[high]); // 将最大的元素放到其最终位置
p2 = i - 1; // 左边分区的上界
i = low;
j = high;
while (i < j) {
while (i < p1 && arr[i] < pivot1) i++;
while (j > p2 && arr[j] > pivot1) j--;
if (i < j) {
std::swap(arr[i++], arr[j--]);
} else if (i == j) {
std::swap(arr[i], arr[j]);
break;
}
}
// 递归地对三个分区进行排序
dualPivotPartition(arr, low, i - 1);
dualPivotPartition(arr, i, p2);
dualPivotPartition(arr, p2 + 1, high);
}
特性:
- 时间复杂度为 O(nlogn)、非自适应排序:在平均情况下,哨兵划分的递归层数为 logn ,每层中的总循环数为 n ,总体使用 O(nlogn) 时间。在最差情况下,每轮哨兵划分操作都将长度为 n 的数组划分为长度为 0 和 n−1 的两个子数组,此时递归层数达到 n ,每层中的循环数为 n ,总体使用 O(n2) 时间。
- 空间复杂度为 O(n)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 n ,使用 O(n) 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
- 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。