目录
一. 快速排序的基本思想
二. 快速排序的递归实现
2.1 单趟快速排序的实现
2.1.1 Hoare法实现单趟快排
2.1.2 挖坑法实现单趟快排
2.1.3 前后指针法实现单趟快排
2.2 递归快排的整体实现
三. 快速排序的时间复杂度分析
四. 快速排序的非递归实现
4.1 快速排序非递归实现的思路
4.2 非递归实现快排函数代码
五. 对于快速排序的两种优化方案
5.1 三数取中优化
5.2 小区间非递归优化
前置说明:本文以排升序为例进行讲解
一. 快速排序的基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:对于任意一组待排序数据,选取其中任何一个数据作为关键字key,将这组数据进行调整,排升序时,应使位于key前面的数据都小于等于key,使key后面的数据都大于等于key。假设我们称key之间的数据集合为左子序列,key后面的数据集合为右子序列,在左右子序列中选key,进一步划分左右子序列,直到所有数据都按顺序(升序或降序)排好。
一般来说,选取待排序数组的第一个元素或最后一个元素作为key值。
二. 快速排序的递归实现
2.1 单趟快速排序的实现
所谓的单趟快速排序,就是给定一组数据并选取key值,调整数据,让左子序列都小于或等于key,右子序列都大于或等于key的过程。
实现单趟快速排序的方法有三种:Hoare法(由发明快排的人提出)、挖坑法、前后指针法。
2.1.1 Hoare法实现单趟快排
Hoare法的基本思路为:选定key值之后,给定位于左侧和右侧的两个指针left和right,left和right分别运动,排升序时在数据集左边找大于key的值,在数据集右边找小于key的值,将右边小于key的值和左边大于key的值进行交换。当left和right相遇时,将相遇点的值和key值交换,完成单趟快速排序。
注意:left和right指针并不是同时运动的,而是有一定的先后顺序。
- 如果选取最左侧的数据作为key值,则right先向左运动。
- 如果选择最右侧的数据作为key值,在left先向右运动。
left和right遵循这样的先后运动规则的目的是:交换key和左右指针相遇点数据后,保证key前面的左子序列中没有大于key的数,key后面的右子序列中没有小于key的数。
图2.1以数组arr[]={6,1,2,7,9,3,4,5,10,8}为例,展示了以最左侧数据为key的单趟快速排序实现过程。
Hoare法单趟快排实现代码:
//数据交换函数
void swap(int* px, int* py)
{
assert(px && py);
int tmp = *px;
*px = *py;
*py = tmp;
}
//单趟快速排序(hoare法)
int PartionSort1(int* a, int left, int right)
{
assert(a);
//最左侧数据做key值
int keyi = left; //key的下标
int key = a[keyi];
while (left < right)
{
//找右侧小于key的数
while (left < right && a[right] >= key)
{
--right;
}
//找左侧大于key的数
while (left < right && a[left] <= key)
{
++left;
}
//交换大于key和小于key的数
swap(&a[left], &a[right]);
}
//将key放到right和left相遇的位置
swap(&a[keyi], &a[left]);
return left;
}
2.1.2 挖坑法实现单趟快排
挖坑法,顾名思义,就是在某一位置挖一个坑,填入相应数据。假设取数组中最左边的值为key,定义左指针left和右指针right,挖坑法的基本实现流程为:
- 保存key值,初始化坑为hole=left。
- 右指针先向左移动,找最右侧比key小的数,并将这个数放入左侧的坑中,更新坑为此时的right值。
- 左侧指针再向右移动,找左侧比key大的数,并将这个数放入右侧的坑中,更新坑为此时的left值。
- 重复步骤2和3,直到left和right相遇。将坑更新为相遇点的下标,将key值放入坑中。
图2.2以arr[]={6,1,2,7,9,3,4,5,10,8}为例,展示了挖坑法实现单趟快排的过程。
挖坑法单趟快排的实现代码:
//单趟快速排序(挖坑法)
int PartionSort2(int* a, int left, int right)
{
assert(a);
int keyi = left;
int key = a[keyi];
int hole = left; //坑的下标
while (left < right)
{
//找右边小于val的数,放入左边的坑中
while (left < right && a[right] >= key)
{
--right;
}
a[hole] = a[right];
hole = right;
//找左边大于val的数,放入右边的坑中
while (left < right && a[left] <= key)
{
++left;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole; //返回最终坑的下标(左右子序列的分界)
}
2.1.3 前后指针法实现单趟快排
定义前后两个指针prev和cur,排升序,cur向右运动,找比key小的数据,当找到比key小的数据时,prev先执行自加操作,然后再交换cur处的数据和prev处的数据,当cur走出数组时,交换key和prev处数据。图2.3展示了以最左侧数据为key值和最右侧数据为key值prev和cur的初始位置,以最左侧数据为key时,选取初值prev=0、cur=1,以最右侧数据为key时,选取初值prev=-1、cur=0,图2.4以arr[] = {6,1,2,7,9,3,4,5,10.8}为例,展示了前后指针法实现单趟排序的过程。
前后指针法实现单趟快排的函数代码:
//单趟快速排序(前后指针法)
int PartionSort3(int* a, int left, int right)
{
assert(a);
int key = a[left];
int cur = left + 1; //前指针
int prev = left; //后指针
while (cur <= right)
{
//如果cur处的数据小于key,prev先自加,然后交换prev和cur处的数据
//++prev != cur是为了避免数据与本身交换而造成的运算资源浪费
if (a[cur] <= key && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
++cur;
}
swap(&a[left], &a[prev]);
return prev;
}
2.2 递归快排的整体实现
递归实现快排,是一种类似二叉树的递归调用。其整体思路为:
- 对整组数组进行单趟快排,使key值到它应该放置的位置,单趟快排后的左子序列值都小于或等于key,右子序列值均大于或等于key。
- 递归,分别对左子序列和右子序列进行单趟快排,然后进一步分解左子序列和右子序列,直到所有数据均被放置到正确位置上。
图2.5展示了快排的递归调用逻辑,图中长方块表示进行单趟快排的数组,短方块表示单趟快排后key所处的位置,每次对一组数据执行完一次单趟快排,数组就会被key分割为左右两部分,再采用递归的方式的左右两部分数组单趟快排直到快排全部完成即可。
注:单趟排序方法的选择不改变快排的整体递归逻辑,也不影响排序效果。
快速排序函数代码:
//快速排序
void quickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int midi = PartionSort3(a, left, right); //单趟快排
quickSort(a, left, midi - 1); //左子序列单趟快排
quickSort(a, midi + 1, right); //右子序列单趟快排
}
三. 快速排序的时间复杂度分析
快速排序最理想情况下的时间复杂度
如图3.1所示,在理想情况下,快速排序的每次单趟排序选出来的key值都恰好是待排序数组中的中位数,因此,每次快排后的左子序列和右子序列的数据个数相同。假设待排序数据的个数为N,每次递归要遍历的数据个数都大体为N,因此,整个快排过程要遍历数据NlogN次。
综上,最理想情况下,快排时间复杂度为。
快速排序最坏情况下的时间复杂度
如果待排序数据顺序有序或者逆序有序,如arr[]={9,8,7,6,5,4,3,2,1},那么无论选择数组最左边的元素为key还是最右边的元素为key,每次单趟快排完成后,key都位于被排序数组的最左侧或最右侧。如图3.2所示,对N个有序数据进行排序,共需要进行N层递归,每次递归遍历的数据都大体为N。因此,最坏情况下快排的时间复杂度为。
除此之外,如果待排序数据已经有序,造成递归深度过大,还很有可能存在栈溢出的问题。
四. 快速排序的非递归实现
在本文第三章中写道,如果对大量的有序数据或接近有序的数据进行排序,则很有可能会造成栈溢出的问题,为了能让快排适用于规模庞大的接近有序的数据,用非递归的方法实现快排显得尤为重要。
4.1 快速排序非递归实现的思路
对于快速排序的递归实现,需要记录单趟快排之后key值所处的下标,然后以key值为分界线,分别通过递归的方法,对左子序列和右子序列进行单趟快排。
非递归实现快排的思想与递归相同,我们只需要在一次快排之后记录下左子序列和右子序列的起始位置即可。这里,就需要借助栈这种数据结构来实现。假设给定含有10个数据,下标范围为0-9的数组arr[10],对arr采用非递归方法进行快速排序的过程如下:
- 设left = 0,right = 9,left先入栈,right再入栈。
- 判断栈是否为空,如果不为空从栈中两次提取数据,先取end为栈顶元素,然后删除栈顶元素,再取begin为栈顶元素,再删除栈顶元素。对下标位于[begin, end]之间的数据进行单趟快排,获取key在单趟快排之后的下标keyi。
- 进行完步骤2的单趟快排后,左子序列的下标范围是[begin, keyi - 1],右子序列的下标范围是[keyi+1, end],先判断end > keyi + 1是否成立,如果成立,end和keyi+1先后入栈,再判断keyi - 1 > begin是否成立,如果成立,keyi - 1和begin先后入栈。
- 重复步骤2和步骤3,直到栈为空。
图4.1以arr[10]为例,假设每次单趟快排后的key值的下标都满足关系keyi = (begin + end) / 2,对非递归快排的实现流程及入栈出栈数据进行图解演示。
4.2 非递归实现快排函数代码
//快速排序函数(非递归)
void quickSortNonR(int* a, int left, int right)
{
assert(a);
ST ps; //建立栈
StackInit(&ps); //初始化栈
StackPush(&ps, left); //第一趟排序的左右区间入栈
StackPush(&ps, right);
while (!StackEmpty(&ps)) //栈不为空
{
int end = StackTop(&ps);
StackPop(&ps); //拿出单趟排序右侧区间
int begin = StackTop(&ps);
StackPop(&ps); //拿出单趟排序左侧区间
int keyi = Partion3(a, begin, end);
if (keyi + 1 < end)
{
StackPush(&ps, keyi + 1);
StackPush(&ps, end);
}
if (begin < keyi - 1)
{
StackPush(&ps, begin);
StackPush(&ps, keyi - 1);
}
}
StackDestroy(&ps); //栈销毁
}
五. 对于快速排序的两种优化方案
快速排序在对有序数组或接近有序的数组进行排序时,效率低下,很容易出现栈溢出的问题,采用三数取中选key值的方法进行优化。同时,对于数据量较小的数据,为了减少栈帧的建立,采用小区间非递归的方法进行优化。
5.1 三数取中优化
三数取中优化是为了对付接近有序的数据而采用的,三数取中就是在选取key值时,选择待排序数组中最左侧、最右侧和中间位置的三个数据中次大的那个作为key值,并将key值放到数组的头部或其尾部位置,以此来避免单趟排序后key值位于数组起始位置或末尾位置而造成大量的栈帧开辟。三数取中优化除了key值选取与不加优化有所不同以外,其余相同。
下面的代码以前后指针法实现单趟排序为例,展示了经三数取中优化后的快排函数代码。
//获取中间值下标函数
int GetMidPoint(int* a, int left, int right)
{
assert(a);
int mid = (left + right) / 2; //中间节点下标
//找出左边、右边和中间位置的三个数中次大的那个的下标
if (a[left] > a[mid])
{
if (a[right] > a[left])
{
return left;
}
else if (a[right] > a[mid])
{
return right;
}
else
{
return mid;
}
}
else
{
if (a[right] > a[mid])
{
return mid;
}
else if (a[right] > a[left])
{
return right;
}
else
{
return left;
}
}
}
//数据交换函数
void swap(int* px, int* py)
{
assert(px && py);
int tmp = *px;
*px = *py;
*py = tmp;
}
/单趟快速排序(前后指针法)
int PartionSort3(int* a, int left, int right)
{
assert(a);
int keyi = GetMidPoint(a, left, right); //获取key值坐标
swap(&a[keyi], &a[left]);
int key = a[left];
int cur = left + 1; //前指针
int prev = left; //后指针
while (cur <= right)
{
if (a[cur] < key && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
++cur;
}
swap(&a[left], &a[prev]);
return prev;
}
//快速排序
void quickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int midi = PartionSort3(a, left, right); //对整个数组排序
quickSort(a, left, midi - 1);
quickSort(a, midi + 1, right);
}
5.2 小区间非递归优化
小区间优化就是在进行单趟快排的数据量较小时,为了减少函数栈帧的开辟,采用插入排序或冒泡排序等方法对数据进行排序。一般来说,当right - left + 1 < 10,即数据量小于10时,采用插入或冒泡排序法进行排序。
小区间非递归优化快排代码:
//快速排序
void quickSort(int* a, int left, int right)
{
assert(a);
if(right - left + 1 < 10) //如果数据量小于10,采用插入排序
{
InsertSort(a + left, right - left + 1);
}
else
{
int midi = Partion3(a, left, right); //整个数组单趟快排
quickSort(a, left, midi - 1); //左子序列单趟快排
quickSort(a, midi + 1, right); //右子序列单趟快排
}
}