文章目录
前言
路漫漫其修远兮,吾将上下而求索;
一、什么是快速排序
快速排序是Hoare 于 1962年提出的一种二叉树结构的交换排序方法;
- 其基本思想为:任取待排序元素序列中的某元素作为基准值,然后再根据所得到的基准值的下标将待排序序列分割成两子序列,其中左子序列中所有元素均小于等于基准值,右子序列中给的所有的元素均大于等于基准值,然后其左右子序列又重复该过程,直到所有元素均排到其应该到的相应的位置上为止;
该算法要参考二叉树的根 、左、右的结构,会使用到递归;
Question1:快速排序用到递归的思想是什么?
如上图所示,当每一个数据都到了其最终应该在的位置上的时候,那么此时该序列必然有序;每找到一个基准值之后又会根据此基准值将当前序列划分为左子序列、右子序列……一直划分下去,直到所有划分的序列中只有一个数据;整个过程十分类似于二叉树的前序遍历;
总的来说,快排分为两步,一是找基准值,二是根据所找到的基准值的下标划分递归的区间;
找基准值的方法很多:hoare版本、挖坑法、lomuto 前后指针法;
递归需要注意的是:1、要有限制条件;2、每次递归便会更接近该递归的限制条件;
二、递归版本
(一)、hoare 版本的快排
1、思路:
光说究竟还是很抽象的,接下来我们画图理解一下:
如上图,提出了两个疑问;
一是在找的过程中当left==right 的时候,循环要结束吗?
- 显然,当left 等于right 的时候,有三种情况导致;一是left 和 right 交换数据后,各自迭代恰好left==right;二是当right 找到了小于基准值的数据,而left 向右找大于基准值的数据一直没有找到便会走到right 指向的位置上去;三是,right 在找小于基准值的数据一直没有找到便会走到left 指向的位置上去;即,此时left 与right 共同指向的空间中的数据,要么是未知大小,要么就是小于基准值的数据,要么就是大于基准值的数据;其中有一种情况下,left 和right 指向的数据的大小是未知的,故而left == right 不能够当作判断循环结束的标志;
循环结束之后基准值要与那个位置中的数据进行交换?
- 而当left>right 的时候,left 左边的数据均是小于等于基准值的数据(不包含left),而right 右边的数据均是大于等于基准值的数据(不包含right);基准值最终是要走到小于和大于它的数据的中间,即keyi 中的数据应该与right 中的数据进行交换;
如上图所示,不断地找基准值就可以让每一个数据都到自己本身应该到的位置上,那么此时该序列便会变成有序;将以上过程画为简略图便如下图:
如果每次找到的基准值都处于待排序列的中间的位置,即每次均以二分递归(数据量以二分的形式减少),这也正是快排速度很快的原因;
总结一下hoare 版本的快排:
2、参考代码
代码如下:
//hoare 版本
int _QuickSort1(int* a, int left, int right)
{
//默认最左边的数据为基准值
int keyi = left;
left++;
while (left <= right)
{
//right从右往左找小于(小于等于)基准值 的数据
while (left <= right && a[right] > a[keyi])
{
right--;
}
//left 从左往右找大于(大于等于)基准值的数据
while (left <= right && a[left] < a[keyi])
{
left++;
}
//当left 和right 处于合理范围内便可以将他们找到的值进行交换
//交换之后left 和right 均需要向自己需要的方向走
if (left <= right)
{
Swap(&a[left++], &a[right--]);
}
}
//出了循环便可以将基准值与right 中的数据进行交换
Swap(&a[keyi], &a[right]);
return right;
}
//快排
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//找基准值 - 获得基准值的下标
int keyi = _QuickSort1(a, left, right);
//根据基准值将序列划分区间
//left,keyi-1 keyi ,keyi+1 ,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
上文也说到过,基准值的选取会影响快排的效率;如果总是默认将最左边的数当作是基准值,靠的是运气,有没有什么办法让基准值是中间数的概率大一些?
三数取中;
- 即利用left 和right 求得中间下标mid ,将left 、right 、mid 中的数据进行比较,将这三个数中的中间数作为基准值;
三数取中的原理图:
优化版本- 三数取中
//三数取中
int GetMid(int* a, int left, int right)
{
int mid = (right - left) / 2 + left;
if (a[left] < a[right]) //left right
{
if (a[mid] >= a[right])
return right;
else if (a[mid] >= a[left])
return mid;
else
return left;
}
else//right left
{
if (a[mid] >= a[left])
return left;
else if (a[mid] >= a[right])
return mid;
else
return right;
}
}
//hoare 版本
//利用两个指针,一个从前往后找大于基准值的数据,一个从后往前找小于基准值的数据
//基准值的查找可以利用三数取中
int _QuickSort1(int* a, int left, int right)
{
//找基准值
int keyi = GetMid(a, left, right);
Swap(&a[keyi], &a[left]);
keyi = left;
left++;
while (left <= right)
{
//right 从又往左找小于等于基准值的数据
//right 在找的过程中,有可能会越界
while (left <= right && a[right] > a[keyi])
{
right--;
}
//left 从左往右找大于小于基准值的数据
//left 在找的过程中,有可能会越界
while (left <= right && a[left] < a[keyi])
{
left++;
}
//合法范围内可交换
if (left <= right)
{
Swap(&a[left++], &a[right--]);
}
}
//出了循环边代表走完了一趟,此时left>right
Swap(&a[keyi], &a[right]);
return right;
}
void QuickSort(int* a, int left, int right)
{
//所要查找基准值的范围不合法或者只有一个数据的均直接返回,无需进行下面找基准值的操作
if (left >= right)
{
return;
}
//获得基准值的下标
int keyi = _QuickSort1(a, left, right);
//利用基准值的下标对递归的区间进行划分
//left,keyi-1 keyi keyi+1,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
Question2: right 一定是从右往左找小于等于基准值的数据,left 一定是从左往右找大于等于基准值的数据吗?是否可以只找大于或者小于的数据呢?
- 与基准值相等的数据可以处理可以不处理,因为与基准值相等的数据其间是可以进行交换的;即等于基准值的数据无论是位于小于基准值的区间,还是大于基准值的区间,都不会影响该基准值的到自己正确的位置上;hoare 版本是处理了于基准值相等的数据,即在找大找小的时候也会顺便找与基准值相等的数据;
下图是待排序序列利用两种找的方式(是否找与基准值相同的数据)走了一次快排:
如上图所示:基准值最后会走到属于自己的位置上,对于与基准值相同的数据可以处理可以不处理;hoare 版本给的是要处理与基准值相等的数据;
上图中还为三个5标了序号,经过观察可以发现,处理了与基准值相同的数据会打乱其先后顺序;即,hoare 版本的快排的稳定性差;
注:稳定性:待排序数据中存在的重复数据经过排序算法排序之后,如若重复数据的相对位置没有改变便说明该算法的稳定性好;反之则差;
3、时间复杂度的计算
递归算法的时间复杂度 = 递归次数 * 单次递归的时间复杂度
显然单次递归的次数可以看作是二叉树的高度,即 logn ; 而单次递归的次数由数据个数决定的,即n;那么总的时间复杂度为 n*logn
(二)、挖坑法
1、思路:
创建左右指针。
首先从右向左找出比基准值小的数据,找到后立即放入左边的”坑“中,再将当前位置变为新的”坑“,然后从左往右找大于基准值的数据,找到后便放在右边的坑中,当前位置变成新的”坑“……结束循环后将最开始存储的基准值放入当前的”坑“中,此时”坑“的下标便是找到的基准值的下标,直接返回便可;
思路图:
图解如下:
Question3: 为什么循环条件为left<right 而不是left <= right?
- 不同于hoare 版本的快排,挖坑法的快排是需要将数据找到然后放到”坑“中,而”坑“也是移动的;当left 等于right时,相当于left 或者 right 在找其该找的值的时候从找到了坑位都没有找到,就代表着范围内的数据都找完了也没有找到,此时就应该停止寻找;
2、参考代码
//挖坑法
int _QuickSort2(int* a, int left, int right)
{
//默认最左值为基准值
int key = a[left];
//right 先找,就先将坑位放在left 中
int hole = left;
while (left < right)
{
//让right 从右往左找小于基准值的数据
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
//让left 从左往右找大于基准值的数据
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
//出了循环便可以将基准值放到坑位中
a[hole] = key;
return hole;
}
//快排
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//找基准值 - 获得基准值的下标
int keyi = _QuickSort2(a, left, right);
//根据基准值将序列划分区间
//left,keyi-1 keyi ,keyi+1 ,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
同理,挖坑法此处也可以用 “三数取中” 进行优化;
优化版本 - 三数取中
//三数取中
int GetMid(int* a, int left, int right)
{
int mid = (right - left) / 2 + left;
if (a[left] < a[right]) //left right
{
if (a[mid] >= a[right])
return right;
else if (a[mid] >= a[left])
return mid;
else
return left;
}
else//right left
{
if (a[mid] >= a[left])
return left;
else if (a[mid] >= a[right])
return mid;
else
return right;
}
}
//挖坑法
int _QuickSort2(int* a, int left, int right)
{
//同样也需要两个指针,只不过这个两个指针是来回找,并且会伴随着 坑 的移动
//三数取中获取基准值
int keyi = GetMid(a, left, right);
Swap(&a[keyi], &a[left]);
keyi = left;
int key = a[left];
//右边先找,故而坑首先放在left 处
int hole = left;
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
//找到了,将值放到坑位中;
//然后移动坑位
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
//出了循环便代表left==right ,此时 hole == left == right
//将基准值放到坑位中
a[hole] = key;
return hole;
}
void QuickSort(int* a, int left, int right)
{
//所要查找基准值的范围不合法或者只有一个数据的均直接返回,无需进行下面找基准值的操作
if (left >= right)
{
return;
}
//获得基准值的下标
int keyi = _QuickSort2(a, left, right);
//利用基准值的下标对递归的区间进行划分
//left,keyi-1 keyi keyi+1,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
Question4:挖坑法中开始时为什么left 不自增?
- 注意把握挖坑法的主逻辑,挖坑法相当于是将right 找到的数据放到left 指向的空间中,将left 找到的数据放到right 指向的空间中;只要将小于基准值的数据放到基准值的左边,大于基准子的数据放到基准值的右边,显然当left==right 的时候循环停止,此时的hole 就是保存的基准值key 最终正确的位置;
Question5: 一定是right 从右往左找小于基准值的数据,left 从左往右找大于基准值的数据;可以找等于基准值的数据吗?为什么?
- 不能;因为坑位是在left 和 right 之间流动的,如若right 从右往左找小于等于基准值的数据,left 从左往右找大于等于基准值的数据 ;当right 和 left 对应的位置中均值等于基准值的数据的时候,此时便会陷入死循环;如下图所示:
(三)、lomuto前后指针法
1、思路
lomuto 前后指针法又称为双指针法;创建前后指针prev、cur ,让cur 往前走找小于基准值的数据,找到之后让其中的数据与prev 中的数据进行交换;使得小于基准值的数据都排在基准值的左边;
思路图:
具体过程图解如下:
从上图可以看到,当a[cur]<a[keyi] 并且++prev==cur 的时候,此时无需进行交换的操作;这一点可以作为一个优化;
2、参考代码
//lomuto 前后指针法
int _QuickSort3(int* a, int left, int right)
{
//默认最左值为基准值
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
//cur 找小于基准值的数据
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
//cur 也需要迭代
cur++;
}
//当循环结束后,将keyi 中的数据与prev 中给的数据进行交换
Swap(&a[keyi], &a[prev]);
return prev;
}
//快排
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//找基准值 - 获得基准值的下标
int keyi = _QuickSort3(a, left, right);
//根据基准值将序列划分区间
//left,keyi-1 keyi ,keyi+1 ,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
优化版本 - 三数取中
//三数取中
int GetMid(int* a, int left, int right)
{
int mid = (right - left) / 2 + left;
if (a[left] < a[right]) //left right
{
if (a[mid] >= a[right])
return right;
else if (a[mid] >= a[left])
return mid;
else
return left;
}
else//right left
{
if (a[mid] >= a[left])
return left;
else if (a[mid] >= a[right])
return mid;
else
return right;
}
}
//lomuto 前后指针法
int _QuickSort3(int* a, int left, int right)
{
//三数取中获取基准值
int keyi = GetMid(a, left, right);
Swap(&a[keyi], &a[left]);
keyi = left;
//prev 指向小于等于基准值的尾数据,故而Left 无需++
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
while (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
//cur 迭代
cur++;
}
//出了循环便代表cur 走完了,此时prev 指向小于等于数据的尾数据
//prev 中给的数据与keyi 中的数据交换便可
Swap(&a[keyi], &a[prev]);
return prev;
}
void QuickSort(int* a, int left, int right)
{
//所要查找基准值的范围不合法或者只有一个数据的均直接返回,无需进行下面找基准值的操作
if (left >= right)
{
return;
}
//获得基准值的下标
int keyi = _QuickSort3(a, left, right);
//利用基准值的下标对递归的区间进行划分
//left,keyi-1 keyi keyi+1,right
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
三、非递归版本
上述的方法都是基于递归完成的,也可以使用非递归的方式来实现快排,但是需要借助于一个数据结构:栈;
快排的核心就是划分区域找基准值;利用栈记录划分区域的下标,利用栈的特性“后进先出”就可以实现类似于树的“前序遍历”;
代码如下:
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//非递归版本快排
void QuickSortNonR(int* a, 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);
//对区间[begin , end ] 找基准值
//此处采用lomuto 前后指针法
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
++cur;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
//利用基准值下标再将序列的区域划分,将其下标入栈
//入栈的前提是范围合法,并且数据个数大于等于2
//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);
}
其中函数 STInit STPush STPop STTop STEmpty STDestroy 是需要自己在栈中实现的接口函数;
四、优化
上面进行了简单的优化:三数取中,这个优化程度远远不够;
除了三数取中来优化取基准值,还可以用取随机值来优化,代码如下:
//随机取基准值
//左闭右闭的区间(eg. [left,right])计算数据个数: right - left + 1
int keyi = left + (rand() % (right - left + 1));
Swap(&a[left], &a[keyi]);
keyi = left;
决定快排性能的关键是每次单趟排序后,基准值key 对于数组的分割;
如果每次选key 基本二分居中,那么快排的递归树就是这颗均匀的满二叉树,性能最佳。但是实践当中虽然不可能每次基准值都是二分居中,但是快排的性能我们依然是可以控制的;但是如果出现每次选到最大值/最小值,划分为0 个和N-1 个的子问题,那么此时的时间复杂度最坏,为O(N^2),数组有序的时候就会出现这样的问题,我们前面已经用到三数取中或者随机值选key 来解决这个问题,也就是说我们解决了绝大多数的问题,但是仍然存在一些场景无法解决(eg.数组当中有大量重复的数据);
我们先来考虑如果待排序数据中有很多重复数据的时候该如何优化?
- 三路划分,专治有大量重复数据的待排序列;
1、三路划分
思想:
当待排序列中有大量重复数据的时候,三路划分的思想类似于hoare 版本和lomuto 前后指针的结合;其核心思想是将数组中的数据划分为三段:小于基准值的数据、等于基准值的数据、大于基准值的数据,故而叫做三路划分算法;
图解如下:
代码如下:
//三路划分版本快排
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
//提前保存一下范围
int begin = left;
int end = right;
//随机取基准值
//左闭右闭的区间(eg. [left,right])计算数据个数: right - left + 1
int keyi = left + (rand() % (right - left + 1));
Swap(&a[left], &a[keyi]);
keyi = left;
int key = a[left];
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < key)
{
Swap(&a[left], &a[cur]);
//迭代
++left;
++cur;
}
else if (a[cur] > key)
{
Swap(&a[cur], &a[right]);
--right;
}
else
{
++cur;
}
}
//根据left 和 right 划分区间
QuickSort(a, begin,left - 1);
QuickSort(a, right + 1, end);
}
注:记得在调用QuickSort 的函数中种下随机种子
//种下随机数种子
srand((unsigned int)time(NULL));
三路划分可以解决待排序列中有大量重复数据的情况,但是仍然存在极端情况会导致快排的效率低下(eg.选基准值时的运气不好,总是选不到靠近中间的数据);此时便可以使用introsort 自省排序(introsort 是 introspective sort采用了缩写);
2、自省排序
自省排序即进行自我侦测和反省;
(sgi 中STL库中使用的就是深度为两倍排序元素的对数值)当递归的深度太深那就说明再这种数据序列下,选基准值key 就出现了问题,性能在快速地退化,此时最好就不要再用快排对待排序列进行分割递归了,改换为堆排序(也可以使用其他性能高得排序,只不过在sgi STL官方使用的是堆排序)进行排序;
自省排序是大佬 David Musser 提出来的,在工业当中非常实用,不惧怕任何一种情况;无论是在那种情况下,只要递归地深度太深便会改为堆排序;
Question6: 如何界定深度太深?
- 倘若待排序列有n个数据,那么计算出logn 是多少,然后再将2*logn 作为判断深度的标准;再创建一个变量用来记录当前递归的深度,与2*logn 进行比较便可;
Question7: 如何计算 logn?
注:循环变量i 是从1开始的!!!
Question8: 为什么深度地判定标准是 2*logn?
- 因为官方认为 2*logn 是一个非常合适的值;实际上也可以用 logn 、3*logn……但是其中2*logn 最合适;倘若递归已经很深了再转换为堆排序,此时已经有点晚了;太早转换为堆排序也不合适;故而此处的 2*logn 是有一定的“容忍度”在其中;
代码如下:
//直接插入排序
void InsertSort(int* a, int n)
{
//n 个数据需要处理 n-1 次
//将第一个数据看作有序序列
for (int i = 0; i < n-1 ; i++)
{
//每个数据会向前进行比较
int end = i;
int tmp = a[end + 1];
//让待排的数据与有序序列中的数据进行比较
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
}
else
{
break;//符合大小关系循环便结束
}
end -= 1;
}
//循环结束便说明找到了待插入数据应该在的位置上
a[end + 1] = tmp;
}
}
//向下调整
void AdjustDown(int* a, int parent , int n)
{
//假设法 - 假设该父节点有一个左孩子
int child = 2 * parent + 1;
while (child < n)//有左孩子
{
if (child + 1 < n && a[child + 1] > a[child])//有右孩子,将左孩子与右孩子进行比较
{
child++;
}
//大堆 : 父节点>=子节点
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
//调整
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
//排升序 - 建大堆
void HeapSort(int* a, int n)
{
//向下调整建堆
for (int i = (n-1-1)/2; i>= 0; i--)//从最后一个非叶节点开始向下调整建堆
{
AdjustDown(a, i, n);
}
//堆排序
int sz = n - 1;// 有n 个数据只需要处理 n-1 次
while (sz)
{
//将堆顶的数据与尾数据进行交换
Swap(&a[0], &a[sz]);//下标为个数-1
//再次进行向下调整
AdjustDown(a, 0, sz);
--sz;//迭代
}
}
//introSort 版本快排
void QuickSort2(int* a, int left, int right)
{
//计算深度
int depth = 0;
int logn = 0;
int N = right - left + 1;
for (int i = 1; i < N; i *= 2)
{
logn++;
}
//调用introsort
introsort(a, left, right, depth, logn);
}
//自省排序
void introsort(int* a, int left, int right, int depth, int defaultDepth)
{
if (left >= right)
{
return;
}
//判断当前的深度
//当待排数据个数小于16个的时候适用插入排序
if (right - left + 1 < 16)
{
InsertSort(a+left, right - left + 1);
return;
}
//当递归的深度超过 2*logn 的时候采用堆排序
if (depth > defaultDepth)
{
HeapSort(a + left, right - left + 1);
return;
}
++depth;
//保存当前待排序列的区间范围
int begin = left;
int end = right;
//快排 - lomuto 前后指针
//随机数选基准值
int keyi = left + (rand() % (right - left + 1));
Swap(&a[left], &a[keyi]);
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[keyi], &a[prev]);
keyi = prev;
//根据基准值划分区域
//begin,keyi-1 keyi keyi+1 ,end
introsort(a, begin, keyi - 1, depth, defaultDepth);
introsort(a, keyi + 1, end, depth, defaultDepth);
}
总结
1、快排的核心思想是找基准值然后通过基准值将待排序列进行划分,然后再递归下去;
2、找基准值的方法有三种:hoare 版本、挖坑法、lomuto 前后指针法
3、优化:三数取中、取随机数、三路划分、自省排序