【排序】快速排序
算法介绍
快速排序(Quick Sort)有多种实现方式,包括左右指针法、前后指针法、挖坑法和荷兰国旗法等等。这些方法的核心思想都是通过分治策略将数组划分为两部分,但具体的划分方式和实现细节有所不同。下面我会详细解释它们的区别和特点。
本文所有代码用c++实现,想要c语言代码以及更多细节分析,可以到我的GitHub查看更多内容,以下是快速排序的c语言代码链接:
GitHub: dante329/Data-Structure/QuickSort/QuickSort/QuickSort.c
核心原理
- 选取一个基准值(pivot),按照基准值的大小将数组分为[小于基准值,等于基准值,大于基准值]三部分,当这三个区域被分好时,基准值已经处于正确的位置了。
- 对基准值两侧的区间递归处理,直到区间不存在或者区间长度为1。
基准值的选择
基准值选择的是否恰当对快排整体的时间复杂度影响是非常大的,如果基准值选择不当,可能导致分区不平衡,从而使算法的时间复杂度退化为 O(n^2)。
随机选择基准值
int get_random(int left,int right) //随机选基准值
{
return rand() % (right - left + 1) + left; //返回下标
}
注意:要引用头文件 <ctime>
,在主函数中种下随机数种子srand(time(0));
。
三数取中法
从数组的左端、右端和中间位置选择三个元素,取它们的中位数作为基准值。减少了选择到极端值的概率,使分区更平衡。
int medianOfThree(int a[], int left, int right)
{
int mid = (left + right) >> 1;
if (a[left] > a[mid]) swap(a[left], a[mid]);
if (a[left] > a[right]) swap(a[left], a[right]);
if (a[mid] > a[right]) swap(a[mid], a[right]);
return mid; // 返回中位数的下标
}
排序实现方式
为了方便作演示,这里的基准值直接选择第一个元素或者最后一个元素。
左右指针法
这是最经典的快速排序实现方式。
实现步骤:
-
选择一个基准值。
-
使用两个指针,begin 从左向右移动,end 从右向左移动。
初始时 left 指向数组的第一个元素,end 指向数组的最后一个元素 -
left 指针找到第一个大于基准值的元素,end 指针找到第一个小于基准值的元素,然后交换它们。
-
重复上述过程,直到 left 和 end 指针相遇。
-
将基准值放到相遇的位置,此时数组被划分为两部分。
-
递归处理左右两部分。
void quickSort(int arr[], int left, int right)
{
if (left >= right) return;
int begin = left, end = right;
int pivot = arr[left]; // 选择第一个元素作为基准值
while (begin < end)
{
while (begin < end && arr[end] >= pivot) end--; // 从右向左找小于基准值的元素
while (begin < end && arr[begin] <= pivot) i++; // 从左向右找大于基准值的元素
if (begin < end) swap(arr[begin], arr[end]); // 交换
}
swap(arr[left], arr[begin]); // 将基准值放到正确的位置
quickSort(arr, left, begin - 1); // 递归处理左半部分
quickSort(arr, begin+ 1, right); // 递归处理右半部分
}
注意!!!:在快速排序的左右指针法中,先从右向左找第一个小于基准值的元素,再从左向右找第一个大于基准值的元素这个顺序非常重要,必须先找小后找大。如果你先从左向右找大,那么就会在一个比基准值大的位置停下,最终就会导致基准值的归位错误。
Q:为什么 end 先从右向左找小,相遇点的元素就一定小于或等于基准值?
A:end 如果先移动,那么就会先 begin 一步,end 的任务就是找小,所以当 end 找小成功,end 就会占据那个位置。即使 begin 向右找不到大数,那也会被迫停在 end 目前的位置上(当 begin 和 end 相遇时,begin 和 end 指向同一个元素),而end位置的元素就是小于基准值的,这时候再与基准值交换,就满足左边的元素都小于基准值。
即使end没有找到小的,而是碰到 begin 停下了。
1.此时如果 begin 在while (begin < end)这个大循环范围内没有与 end 交换过的话,那么此时停下的位置的元素就是基准值本身;
2.如果 begin 在while (begin < end)这个大循环范围内与 end 交换过的话,那么此时停下的位置的元素肯定小于基准值,因为在上一轮找大找小过程中,begin 找的是大值,end 找的是小值,交换过后 这时 begin 的位置上的值就是小于基准值的数,此时 end 在 begin 处停下,停下的位置的值就是小于基准值的,再与数组最左边的元素交换,得到的数列就是基准值左边的数都是小于等于基准值的,不管是哪种情况,都是符合要求的。
演示数列 [6,1,2,9,8] ,先找小和先找大的区别。
先从右向左找小:
先从左向右找大:
补充(基准值的位置):
取数组第一个元素为基准值就要将最终停下的位置与第一个元素(基准值所在位置)交换,取数组最后一个元素为基准值就要将最终停下的位置与最后一个元素(基准值所在位置)交换。
如果想要使用优化方法取基准值,那么只需要将通过优化方法得到的基准值先与数组的第一个元素或者最后一个元素先交换,再执行各种排序方法。
例如随机选择基准值+左右指针法代码实现:
#include <iostream>
#include <algorithm>
#include <cstdlib> // 用于 rand() 函数
#include <ctime> // 用于 time() 函数
// 随机选择一个基准值,并将其与第一个元素交换
int choosePivot(int arr[], int left, int right) {
// 生成一个随机索引
int randomIndex = left + rand() % (right - left + 1);
// 将随机选择的基准值与第一个元素交换
std::swap(arr[left], arr[randomIndex]);
return arr[left]; // 返回基准值
}
// 快速排序的左右指针法实现
void quickSort(int arr[], int left, int right) {
if (left >= right) return; // 递归终止条件:子数组长度为 0 或 1
// 随机选择基准值,并将其与第一个元素交换
int pivot = choosePivot(arr, left, right);
// 初始化左右指针
int i = left, j = right;
// 划分过程
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]);
}
// 将基准值放到正确的位置(i 和 j 相遇的位置)
std::swap(arr[left], arr[i]);
// 递归处理左半部分(小于基准值的部分)
quickSort(arr, left, i - 1);
// 递归处理右半部分(大于基准值的部分)
quickSort(arr, i + 1, right);
}
// 打印数组
void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
// 测试代码
int main() {
// 设置随机种子
std::srand(std::time(0));
// 测试用例 1
int arr1[] = {3, 6, 8, 10, 1, 2, 1};
int n1 = sizeof(arr1) / sizeof(arr1[0]);
std::cout << "Original array 1: ";
printArray(arr1, n1);
quickSort(arr1, 0, n1 - 1);
std::cout << "Sorted array 1: ";
printArray(arr1, n1);
// 测试用例 2
int arr2[] = {9, 4, 2, 7, 5, 10, 1, 3};
int n2 = sizeof(arr2) / sizeof(arr2[0]);
std::cout << "Original array 2: ";
printArray(arr2, n2);
quickSort(arr2, 0, n2 - 1);
std::cout << "Sorted array 2: ";
printArray(arr2, n2);
// 测试用例 3(包含重复元素)
int arr3[] = {5, 3, 8, 3, 2, 5, 1};
int n3 = sizeof(arr3) / sizeof(arr3[0]);
std::cout << "Original array 3: ";
printArray(arr3, n3);
quickSort(arr3, 0, n3 - 1);
std::cout << "Sorted array 3: ";
printArray(arr3, n3);
return 0;
}
前后指针法
实现步骤:
-
选择一个基准值。
-
使用两个指针,prev 和 cur,
指针意义:
prev:指向小于基准值部分的最后一个元素。(初始时 prev 指向数组的第一个元素的左边)
curr:遍历数组,用于检查当前元素是否小于基准值。(初始时 curr 指针指向数组的第一个元素) -
cur 指针从左向右遍历数组,如果当前元素小于基准值,先++prev,然后将其与 prev 位置的元素交换,
-
将小于基准值的元素移动到 prev 指针的左侧,大于等于基准值的元素保留在右侧。最后将基准值放到 prev + 1 的位置,完成一次划分。
-
递归处理左右两部分。
void quickSort(int arr[], int left, int right)
{
if (left >= right) return;
int pivot = arr[right]; // 选择最后一个元素作为基准值
int prev = left - 1;
for (int cur = left; cur < right; cur++)
{
if (arr[cur] < pivot)
{
prev++;
swap(arr[prev], arr[cur]);
}
}
swap(arr[prev + 1], arr[right]); // 将基准值放到正确的位置
quickSort(arr, left, prev); // 递归处理左半部分
quickSort(arr, prev + 2, right); // 递归处理右半部分
}
挖坑法
实现步骤:
-
选择一个基准值,并将其位置作为坑。
-
使用两个指针,一个从左向右移动,另一个从右向左移动。
从右向左找到第一个小于基准值的元素,将其填入坑中,然后该位置成为新的坑。
从左向右找到第一个大于基准值的元素,将其填入坑中,然后该位置成为新的坑。 -
重复上述过程,直到两个指针相遇。
-
将基准值填入最后的坑中。
-
递归处理左右两部分。
void quickSort(int arr[], int left, int right)
{
if (left >= right) return;
int pivot = arr[left]; // 选择第一个元素作为基准值
int i = left, j = right;
while (i < j)
{
while (i < j && arr[j] >= pivot) j--; // 从右向左找小于基准值的元素
if (i < j) arr[i++] = arr[j]; // 填坑
while (i < j && arr[i] <= pivot) i++; // 从左向右找大于基准值的元素
if (i < j) arr[j--] = arr[i]; // 填坑
}
arr[i] = pivot; // 将基准值填入最后的坑中
quickSort(arr, left, i - 1); // 递归处理左半部分
quickSort(arr, i + 1, right); // 递归处理右半部分
}
荷兰国旗法
这种方法是对快速排序的改进,适用于有大量重复元素的情况。
建议在看之前学习下面这篇文章对于荷兰国旗问题的三指针法思想,就方便理解在快排中的荷兰国旗法
leetcode 75.颜色分类(荷兰国旗问题)
实现步骤:
-
选择一个基准值。
-
将数组划分为三部分:小于基准值、等于基准值、大于基准值。
-
使用三个指针:left、i 、right。
left 指向小于基准值的部分的最后一个元素。
right 指向大于基准值的部分的第一个元素。
i 是当前遍历的指针。 -
遍历数组,根据当前元素与基准值的关系,将其放入对应的部分。
-
递归处理小于和大于基准值的部分。
void quick_sort(int a[],int left,int right)
{
if(left >= right) return;
int p = a[left]; //选基准值p
int l = left - 1, i = left, r = right + 1;
while(i < r)
{
if(a[i] > p) swap(a[--r],a[i]); //大于p,与右边的交换,i不能加,新换来的元素还没判断
else if(a[i] < p) swap(a[++l],a[i++]); //小于p,与左边的交换
else i++; //与p相等,i++
}
//数组被分为[left,l][l+1,r-1][r,right]三个区间,左边都是小于p的,中间是等于p的,右边是大于p的
quick_sort(left,l);
quick_sort(r,right);
}
* 双基准值快速排序(选看)
选择两个基准值(通常是最左端和最右端的元素),将数组分为三部分:
小于左基准值的部分。
介于两个基准值之间的部分。
大于右基准值的部分。
这种方法在某些情况下比单基准值快速排序更快。
void dualPivotQuickSort(int a[], int left, int right) {
if (left >= right) return;
// 选择双基准值
if (a[left] > a[right]) swap(a[left], a[right]);
int p1 = a[left], p2 = a[right];
// 分区
int low = left + 1, high = right - 1;
for (int i = low; i <= high; i++)
{
if (a[i] < p1)
{
swap(a[i], a[low]);
low++;
} else if (a[i] > p2)
{
swap(a[i], a[high]);
high--;
i--; // 重新检查交换后的元素
}
}
// 将基准值放到正确位置
swap(a[left], a[low - 1]);
swap(a[right], a[high + 1]);
// 递归排序
dualPivotQuickSort(a, left, low - 2);
dualPivotQuickSort(a, low, high);
dualPivotQuickSort(a, high + 2, right);
}