目录
引言
在数据结构------冒泡、选择、插入和希尔排序 中我们已经学习了一部分排序的方法,接下来我们接着学习:快速排序。
求点赞收藏关注!!!
快速排序
1.算法思想
快速排序(Quick Sort)是一种高效的排序算法,由C. A. R. Hoare在1960年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
2.算法步骤
由于快速排序的实现方式有很多,下面我们主要介绍三种方法:
(1)Hoare法
1.设定left和right指针分别指向数组首尾,选择数组首元素为基准key。
2.right先从右往左依次遍历找到比key小的数,left从左往右依次遍历找到比key大的数。然后交换left与right下标对应的值。重复步骤2直至right>=left。
3.之后交换key与left或者right对应的值,并且把该位置记为mid。划分数组为两部分。
4.对[left, mid-1]和[mid+1, right]子数组重复上述过程,直至整个数组排序完成。
下面我们来看个例子:
1)起始状态,key为数组的起始位置,left在起始位置,right在末尾。
2)right先出发,寻找比key小的数字。找到则停下。
3)left出发,寻找比key大的数字。找到则停下。
4)交换left和right的数字。
5)接着走,right找比key大的数,left找比key小的数,交换。
6)right接着走,遇到left,此时指向同一位置。
7)将left与right指向的数与key进行交换,则单趟排序就完成了,最后将基准值的下标返回给函数调用者
动图演示:
如何保证相遇位置比key小:因为right先走
right 停下时, left 与 right 相遇,由于 right 找比 key 小的值,所以此时 right 的位置一定比key小。
left 停下时,right 与 left 进行交换,交换后 left 指向的值比 key 小,此时 right 遇到 left 的位置一定比 key 小。
代码实现
// 交换函数
void swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
int PartSort1(int* arr, int begin, int end)
{
int left = begin;
int right = end;
int keyi = begin;
// 左指针小于右指针,循环继续
while (left < right)
{
while (left < right && arr[right] >= arr[keyi])
//寻找比key小的值
{
right--;
}
while (left < right && arr[left] <= arr[keyi])
//寻找比key大的值
{
left++;
}
swap(&arr[left], &arr[right]);
}
int mid = left;
swap(&arr[keyi], &arr[mid]);
return mid;
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
// 获取中间值
int mid = PartSort1(arr, left, right);
// 递归地对基准值左边的子数组进行快速排序
QuickSort(arr, left, mid - 1);
// 递归地对基准值右边的子数组进行快速排序
QuickSort(arr, mid + 1, right);
}
(2)挖坑法
这种方法通过选择一个基准值(key),通常是数组的第一个元素,然后将数组分为两部分:一部分包含所有小于基准值的元素,另一部分包含所有大于基准值的元素。基准值最终位于这两部分的中间。
1.设置两个指针left和right,分别指向数组的最左端和最右端。
2.将起始位置 key值 设为坑,之后right从右往左找比key值小的值,找到之后放入坑位,此时right就形成新的坑。
3.然后left从左往右找比key大的值, 找到之后放入坑位,此时left就又形成新的坑。
4.最后left与right相遇,将key放入最后一个坑,并将该位置记为mid。·
5.划分区间[left,mid-1]与[mid+1,right]继续重复 1, 2,3,4步骤。直至不能划分。
下面我们来看个例子:
1)先定义变量key,存储数组第一个数作为key,此时left指向的位置就是坑。
2)right开始找小于key的数, 找到后停止,将right位置的数放进坑里,此时right位置作为新的坑。
3)left行动寻找比key大的数,找到后停止,并将值放进坑里,此时left位置作为新坑。
4)以此循环,直到二者相遇。
5)将key值放到坑中,排序完毕,将key值下标返回。
动图演示
代码实现
int PartSort2(int* arr, int begin, int end)
{
int left = begin, right = end;
int hole = begin; // 记录当前需要填充的坑位(hole)
int key = arr[left]; // 选取最左边的元素作为基准(key)
while (left < right)
{
// 从右向左扫描,找到第一个小于等于key的元素
while (left < right && arr[right] >= key)
{
right--;
}
// 将这个小于等于key的元素放到hole位置
arr[hole] = arr[right];
hole = right; // 更新hole的位置
// 从左向右扫描,找到第一个大于key的元素
while (left < right && arr[left] <= key)
{
left++;
}
// 将这个大于key的元素放到hole位置
arr[hole] = arr[left];
hole = left; // 更新hole的位置
}
// 循环结束时,left和right相遇,将基准元素放到正确的位置
arr[hole] = key;
return hole; // 返回基准元素的位置
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
int mid = PartSort2(arr, left, right);//单趟排序
// 递归地对基准元素左边的子数组进行快速排序
QuickSort(arr, left, mid - 1);
// 递归地对基准元素右边的子数组进行快速排序
QuickSort(arr, mid + 1, right);
}
(3)前后指针法
1.将数组第一个元素作为key基准值,定义前指针prev指向第一个数,后指针cur指向第二个数。
2.cur从左往右依次遍历找key小的值,找到之后++prev,然后交换prev与cur指向的值。
3.之后cur++继续遍历。(key为起始位置的值) 当cur遍历完之后,此时交换prev指向的值与key。
4.将此时位置记为mid。
5.最后划分区间[left,mid-1]与[mid+1,right]继续重复1, 2, 3, 4步骤。直至不能划分。
来看个例子:
1)将数组第一个元素作为key基准值,定义前指针prev指向第一个数,后指针cur指向第二个数。cur从左往右依次遍历找key小的值。此时cur位置的数比key基准值小,所以prev加一后与cur位置的数交换,由于此时prev+1 == cur,所以交换后没有变化
2)cur继续走,找到比key小的数。找到后prev加一,交换二者的数。
3)重复以上步骤。
4)cur遍历完数组,将prev与key交换数值,完成排序,并将key下标返回
动图演示
代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
int PartSort3(int* arr, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int keyi = begin; // 基准元素的索引
while (cur <= end) // 遍历整个区间
{
if (arr[cur] < arr[keyi])//小于则交换
{
// 将小于基准的元素与prev+1位置的元素交换,并递增prev
Swap(&arr[++prev], &arr[cur]);
}
cur++;
}
Swap(&arr[prev], &arr[keyi]);
// 返回基准元素的最终位置
return prev;
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)//不能划分
{
return;
}
int mid = PartSort3(arr, left, right);//单趟排序
// 递归排序基准元素左边的子数组
QuickSort(arr, left, mid - 1);
// 递归排序基准元素右边的子数组
QuickSort(arr, mid + 1, right);
}
3.复杂度分析
时间复杂度:通常情况下,需要递归logN层,每层都需要遍历,因此时间复杂度为O(NlogN)。
空间复杂度:通常情况下,需要递归logN层,因此空间复杂度为O(logN)。
4.算法优化
(1)改变基准值
在我们选择基准值时,都是以数组中第一个数作为基准值进行排序,这样写的好处是非常方便且易懂,但是也有个大问题。
如果基准值是数组中的最大或最小值时,会导致快速排序的递归深度会非常深,排序效率会很低。
若是一个有序数组使用快速排序,则递归深度为n,单趟排序也为n,时间复杂度为O(n^2)。
为了防止出现这种情况,我们需要改变基准值:
1)随机数
在数组中随机选择一个数作为基数,每次都选到最大或最小的概率很小,但是有概率会选到最大或最小值。
#include <stdlib.h>
#include <time.h>
int GetRandIndex(int* arr, int left, int right)
{
srand((size_t)time(NULL));
return rand() % (right-left+1) +left;
}
2)三数取中
int GetMid(int* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
else //arr[left] > arr[mid]
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[mid] > arr[left])
{
return left;
}
else
{
return right;
}
}
}
(2)三指针分划区间
除了数组有序的情况外,当数组的重复元素较多时,也会导致快排的效率降低。这时我们需要用上三指针分划区间。
三指针分划区间是快速排序算法中的一种分区策略,它主要用于处理包含多个相同基准值元素的数组。
1)步骤
1.分别定义三个指针left,cur,right分别指向数组首元素,第二个元素,最后一个元素
2.cur从左往右遍历数组:
·当arr[cur]<key,交换arr[cur]与arr[left],再让cur++``left++。
·当arr[cur]>key,交换arr[cur]与arr[right],再让right--。
·当arr[cur]==key,直接让cur++。
3.重复步骤2直至cur>right,成功划分区间。小于key:[begin,left-1],等于key:[left, right] 大于key:[right + 1, end]。
2)代码实现
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
int GetMid(int* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
else //arr[left] > arr[mid]
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[mid] > arr[left])
{
return left;
}
else
{
return right;
}
}
}
void ThreeDivision(int* arr, int* left, int* right)
{
int key = arr[*left]; // 以数组的第一个元素作为基准值
int cur = *left + 1; // 从数组的第二个元素开始遍历
while (cur <= *right)
{
if (arr[cur] < key) // 如果当前元素小于基准值
{
// 与left指向的元素交换,并移动left和cur
Swap(&arr[(*left)++], &arr[cur++]);
}
else if (arr[cur] > key)
{
// 与right指向的元素交换,并移动right
// 注意:这里不移动cur
// 因为新交换到cur位置的值可能需要再次比较
Swap(&arr[cur], &arr[(*right)--]);
}
else
{
cur++;
}
}
}
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int mid = GetMid(arr, begin, end);
Swap(&arr[begin], &arr[mid]);
int left = begin;
int right = end;
ThreeDivision(arr, &left, &right);//三指针划分区间
//[begin, left - 1][left, right][right + 1, end]
// 递归排序基准元素左边的子数组
QuickSort(arr, left, mid - 1);
// 递归排序基准元素右边的子数组
QuickSort(arr, mid + 1, right);
}
(3)区间优化
在快速排序算法中,当递归到较深层级且处理的子数组长度较短时,继续使用递归快速排序可能会导致效率降低。
为什么效率会降低:
快速排序的递归类似于二叉树的形式,每次递归调用都会涉及函数调用和返回的开销,包括保存和恢复调用栈的上下文。当递归层级很深且处理的子问题规模很小时,这些开销会变得更加明显。
为了优化这种情况,可以设定一个数组长度的下限阈值。当子数组长度小于这个阈值时,不再采用递归快速排序,而是改用效率更高的插入排序算法。
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
//数组长度小于10时,调用插入排序
if (right - left + 1 < 10)
{
InsertSort(arr + left, right - left + 1);
return;
}
int mid = PartSort3(arr, left, right);//单趟排序
// 递归排序基准元素左边的子数组
QuickSort(arr, left, mid - 1);
// 递归排序基准元素右边的子数组
QuickSort(arr, mid + 1, right);
}
5.非递归实现
当递归太深时会存在栈溢出的风险,因此,为了避免这种风险我们除了采用尾递归优化空间外,我们还可以采用非递归的形式实现快速排序。
非递归实现的方法需要使用数据结构------栈,利用其后进先出的形式模拟实现递归。
也可以来看看我的文章:
具体步骤如下:
1.将左右边界压入栈中。
2.如果栈不为空,则先出栈左边界 left ,再出栈右边界 right ,然后将[left,right]进行单趟排序得到基准点 keyi 。
3.接着判断 [left,keyi-1] , [keyi+1,right] 区间是否合法,合法就继续入栈。
4.重复2,3,直到栈空。
void QuickSortNonR(int* arr, int left, int right)
{
ST st;
STInit(&st);
// 将初始的排序范围(整个数组)的左右边界压入栈中
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
// 先从栈中弹出的为左边界
int begin = STTop(&st);
STPop(&st);
// 后从栈中弹出的为右边界
int end = STTop(&st);
STPop(&st);
// 对当前子数组进行分区,并返回基准值的最终位置
int keyi = PartSort(arr, begin, end);
// 区间划分为[begin, keyi-1] keyi [keyi+1, end]
// 如果基准值右侧还有元素需要排序
// 则将右边界和基准值右侧第一个元素的索引压入栈
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
// 如果基准值右侧还有元素需要排序
// 则将右边界和基准值右侧第一个元素的索引压入栈
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestroy(&st);
}
结束语
本篇博客大致说明了有关快速排序的一些知识。
感谢各位大佬的支持!!!
求点赞收藏关注!!!