目录
一、快速排序的简单介绍
1.快速排序主要是由英国计算机科学家--霍尔发明的;在面对大量数据排序时性能较好,因而被广泛的应用;
注:本篇文章的排序均为升序,如要降序,自行更改即可;
二、快速排序的思想
1.主要排序思想:
选定一个元素作为基准元素,将数组中比该元素小的元素放在其左边,比它大的元素放在其右边,
通过不断递归,将每个大区间分成若干个小区,重复上面操作,使得每个小区间有序,进而使每个
大区间有序;
2.过程图解:
1.给定一个数组a[ 8 ] = { 0 , 2 , 3 , 1 , 3 , 4 , 5 ,7};其中我们通常以第一个元素为基准,也就是‘ 0 ’;
2.右指针开始向左移动找小
3.移动到left位置,左右指针相遇,相遇位置元素与基准元素交换(这里是自己和自己交换)
4.左区间为空,开始从右区间重新开始排,重新选定基准元素;
5. 右指针找到比基准元素小的元素,右指针停下来,左指针开始向右找大;
6.左指针开始移动,找到比基准元素大的元素后停下;
7.交换左指针跟右指针所指向的元素,然后右指针再向左找小,重复上述操作,直到两指针相遇;
8.两指针相遇,相遇位置与基准位置元素交换;
9.重复上述操作,直到整体有序(这里情况比较特殊,实际上在图8之后还排了许多次,不是直接停了);
三、快速排序的单趟排序以及细节问题
注: 这里先介绍霍尔版本的快速排序,再对比介绍其他的版本;
上面已经介绍了大体步骤,这里再简单叙述一遍;
单趟步骤:1.选定一个数作为基准值(一般是最左边的元素),左指针起始位置为第一个元素,右指针的起始元素为最右边的元素;
2.右指针向左先走(稍后解释为什么右指针先走),找小,找到后停下;
3.左指针向右走,找大,找大后停下;
4.交换左右指针所指向的元素值;
5.重复上述步骤,直至相遇;
6.相遇后,交换相遇位置与基准元素的值(原因稍后解释),这趟快排结束,此时基准元素左边的数全比基准元素小,右边的数全部比基准元素大;
int left = begin;// begin 代表第一个元素的下标
int right = end;//end 代表最后一个元素的下标
int keyi = begin;
while (left < right)
{
//右边找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
//左边找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
细节问题:
1. 为什么left 指针要从左边第一个元素开始?
首先,在正常情况下,left可以从左边第二个值开始比,因为第一个值本身就是keyi 元素,自己跟自己比好像没啥意义,但在特殊情况下就会导致排序失效,下面举个例子:
<1>当有序数组进行排序时,就会出现问题;
<2> 这里右指针向左移动找小,但数组已经有序,所以right 指针会一直向左移移动,直到遇到left指针,遇到left 后排序结束,相遇位置元素与keyi位置的元素进行交换(如下图)。但是,数组整体已经有序了,不需要交换了,交换后这里就会导致数组变成无序。
<3>把左边第一个元素作为left的起始位置即可解决这个问题,此时遇到上述情况时,right 会移动到第一个元素,基准元素与相遇元素相等,自己与自己交换,值不变;
2. 左右指针的while循环里,为什么还要加" left < right "作为条件之一,最外面的while 不是有这个条件了吗?
这里加这个条件是为了防止右指针或左指针越过另一个指针去找小或找大。
3.为什么要右指针先走,而不是左指针先走,以及为什么两指针相遇位置元素的大小比基准元素小?
右指针先走是为了保持两指针相遇位置比基准元素小,右指针先走会遇到两种相遇情况
<1> 第一种情况 -R遇L:R没有找到比key小,一直走,直到遇到L,相遇位置是L,比key小;
<2> 第二种情况 -L遇R: R先走找到了停下,L找大,没有找到,遇到R停下来了,相遇位置是R,比key小;
四、快速排序的完整过程(递归)
1.步骤:
通过递归将一个数组不断分割成若干小区间(如上图,上图是特殊情况,一般不会有这种一直将数组分为一半的情况)直到分割成只有一个元素的数组,此时肯定有序,返回上一层递归,重复此步骤,直到返回到最外层就停止;
2.代码
void QuickSort(int* a, int begin,int end)
{
if (begin >= end)//有些区间是空区间,所以不能进行操作
{
return;
}
int left = begin; // 有序时,如果left == begin + 1; 会导致直接交换;
int right = end;
int keyi = begin;
while (left < right)
{
//右边找小
while (left < right && a[right] >= a[keyi])// 第一个条件防止left 越过 right;
{
--right;
}
//左边找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//区间 [begin,keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
五、快速排序算法的局部优化
在快速排序中,总会遇见一些特殊情况使得快速排序的效率下降;这里介绍一些常见的局部优化的操作,能够有效提高快速排序的效率;
1. 三数取中
在数组有序的情况下,数组最左边值就是最小值,如果我们一直取最左边的数作为基准值,就会导致每次排序时左区间都空,每次都只能对右区间进行递归,这就会导致快排效率低下;
解决方案:
如果我们能让keyi 位置的元素接近中位数,那么每次递归就是类似于二分,就不会出现上述情况,(如下图)。
虽然在实际情况中,在数组无序的情况下是很难取到数组的中位数的,但我们可以比较第一个数,最后一个数,中间的一个数的大小,取第二大的数与keyi 位置的元素(也就是第一个数)交换,此时就能让a[keyi]的大小更接近数组的中位数,提高整体效率;在数组有序的情况下,a[keyi]就为整个数组的中位数,此时的快排的时间复杂度为O(N * logN);
具体代码:
int GetMid(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])//a[begin]比mid小
{
if (a[end] > a[mid])
{
return mid;
}
else if (a[end] > a[begin])
{
return end;
}
else
{
return begin;
}
}
else//a[begin] > a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[end] > a[begin])
{
return begin;
}
else
{
return end;
}
}
}
这里用函数直接返回第二大数的下标,后面再交换keyi 位置的元素和返回下标的元素即可;
2.小区间优化
快速排序的排序结构就类似于一颗二叉树,在面对数量庞大的数据时,递归到最底层时开辟栈的数量非常大,这点跟二叉树非常类似(如下图),为了提高效率,我们可以在区间范围缩小到一定大小时,直接挪用其他排序(这里建议插入排序),这里的范围大小没有要求,不能太大就行。
注意:这里的小区间优化在运行效率上的优化并不是很明显,现在各种编译器优化功能很强大,所以这个优化可以看着写;
其他版本的快排与快排的非递归版本将在下节介绍,文章中如有不对之处,还望各位读者指正,谢谢!