快速排序是Hoare提出的一种二叉树结构的交换排序方法
> 基本思想:
任取待排序元素序列中的某个元素作为基准值key,按照该基准值将待排序列分成两个子序列,左子序列都比key小,右基准值都比key大。然后左右子序列重复上面的操作,直到所有元素都排列到相应的位置上为止。
而为什么说它是一种二叉树结构的交换排序方法呢?
是因为它的排序框架和二叉树的前序遍历规则十分相似。
进行一趟排序的效果是什么?
将小于key的值都放到key的左边,大于key的值都放到key的右边。
key将序列分成两个左右子序列,再对左右子序列重复上面的操作,就可以实现排序。而这一操作可以由递归实现。对左右子序列递归,直到没有子序列为止。
【快排基本框架】
void QuickSort(int *a, int left, int right)
{
if (left>=right)
return;
// 按照基准值key对a数组的 [left, right)区间中的元素进行划分
int keyi = partion(a, left, right);
// 划分成功后以key为边界形成了左右两部分 [left, keyi-1] 和 [keyi+1, right)
// 递归排[left, keyi-1]左子序列
QuickSort(a, left, keyi-1);
// 递归排[keyi+1+1, right)右子序列
QuickSort(keyi, keyi + 1, right);
}
我们发现它与二叉树的前序遍历规则非常像,前序遍历是先访问根节点,然后再到左子树,右子树。
而这里类似,先将key排序好,然后再到左子序列,右子序列中去。所以我们在写递归框架时,可以想象二叉树前序遍历规则即可快速写出来。
> 如何找key?
key的选值没有规定必须选哪一个,通常序列的最左边或者最右边作为key。
> 单趟排序:
单趟排序的效果也就是让key到正确的位置上去,key左边都比它小,右边都比它大。那如何做到这样呢?
void Quicksort1(int*a,int left,int right)//需要区间范围
{ int keyi = left;
while (left < right)
{
//右找小
while (left<right && a[right]>=a[keyi])
right--;
//左找大
while (left < right && a[left] <= a[keyi])
left++;
//交换右边的小和左边的大
Swap(&a[left], &a[right]);
}
//最后将key值与left和right相遇位置交换
Swap(&a[keyi], &a[left]);
}
> 细节1
在找小找大的过程中,要注意lleft和right的范围,虽然在外面已经控制left小于right,但找小和找大的过程不受该限制,所以还需要判断一下。
> 细节2
在找小和找大的过程中,当遇到与key大小一样的值,该怎么办,当遇到与key一样的值时,想一下,这样的值在key的左边和右边都不影响结果,所以直接跳过即可,所以在找小找大时遇到相等的也跳过去。
> 问题1:为什么左边作key右边先走
> 问题2:为什么最后相遇点比key小
单趟排序写好了接下来就是写总趟了。
其实单趟写完后,剩下的就简单多了,单趟将key放入正确的位置,并且将序列分割成两个区间,在左区间中再选取个key,放入正确位置后,又分割成两个区间,在去左子序列中分割,直到最后子序列无法分割,递归就往回返。当左区间有序后再去分割右区间,右区间选key后再分割,跟左区间操作一致。
void Quicksort1(int*a,int left,int right)//需要区间范围
{
//递归结束条件--子序列无法再分割
if (left >= right)
return;
int keyi = left;
int begin = left;//递归需要用到左右区间,需要改动区间的值,这里我们保存左右区间的值。
int end = right;
while (left < right)
{
//右找小
while (left<right && a[right]>=a[keyi])
right--;
//左找大
while (left < right && a[left] <= a[keyi])
left++;
//交换右边的小和左边的大
Swap(&a[left], &a[right]);
}
//最后将key值与left和right相遇位置交换
Swap(&a[keyi], &a[left]);
keyi = left;//要将最后keyi的位置更新下
//begin keyi-1 key keyi+1 end
Quicksort1(a, begin, keyi - 1);//递归左区间
Quicksort1(a, keyi + 1, end);//递归右区间
}
> 单趟排序:
挖坑法,本质上也是选出一个key,最后让key的左边都小于key,key的右边都大于key。
1.先将key值保存下来,那key原先的位置上就形成一个坑
2.右边找小,找到后将小的放入坑内,自己位置上形成坑
3.左边找大,找到后将大的放入坑内,自己位置上形成坑
4.最后左右相遇,将key的值放入坑内。
挖坑法其实和霍尔的方法目的是一样的,都是将小的甩到左边,大的甩到右边。只是方法不同。
int key = a[left];
int hole = begin;
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相遇,将key插入到坑里
a[hole] = key;
单趟排序完后的结果也是让key的左边都比key小,key的右边都比key大。
而接下来的总趟跟霍尔的一样,都是递归左右子序列即可。
//2.挖坑法
void Quicksort2(int* a, int left, int right)
{
if (left >= right)
return;
//将key的值保留
int key = a[left];
int begin = left;
int end = right;
int hole = begin;
//三数取中,不是取数组中间的数,而是取三个数中中间的数--begin mid end
int midi = Midi(a, begin, end);
Swap(&key, &a[midi]);
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相遇,将key插入到坑里
a[hole] = key;
Quicksort2(a, begin, hole - 1);//递归左子序列
Quicksort2(a, hole + 1, end);//递归右子序列
}
第三种方法:前后指针法,顾名思义有两个指针cur和prev,该思路与霍尔和挖坑法截然不同。
它是将大的数据往右"推",将大的数据翻滚到右边去,小的数据交换到左边去。
> 单趟排序:
void Quicksort3(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = left;//最左边作key
int cur = left + 1;//cur指向prev的后面
int prev = left;//prev指向序列起始位置
//cur用来找小,当cur找到小时,prev++,prev的值与cur的值交换,cur继续往后走
while (cur <= right)//当cur遍历完序列才可以结束
{
if (a[cur] <= a[keyi] && ++prev != cur)//如果cur找到比key小的数,那cur和prev直接的就是紧挨着,prev++后就会跟cur一样那指向的值也是一样,那就没有必要交换。当cur没到到比key小的数时,prev和cur之间就会出现距离。
{
Swap(&a[cur], &a[prev]);
}
++cur;//不管cur找没找到比key小的数,cur都要走的
}
Swap(&a[prev], &a[keyi]);//最后再将prev指向的数与key交换,这个数一定是比key要小的。
keyi = prev;//交换完后需要更新key的位置。以便递归使用。
}
当一趟结束之后,将key排在合适的位置,就将序列分成两个子序列了,那再对两个子序列做相同的操作即可,用递归分治思想。
void Quicksort3(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = left;
int cur = left + 1;
int prev = left;
//cur用来找小,当cur找到小时,prev++,prev的值与cur的值交换,cur继续往后走
while (cur <= right)
{
if (a[cur] <= a[keyi] && ++prev!= cur)
{
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;//更新key的位置
Quicksort3(a, begin, keyi - 1);//递归左子序列
Quicksort3(a, keyi + 1,end);//递归右子序列
}
> 注意:
prev与cur之间位置上有两种关系,要么prev紧跟着cur。
要么prev和cur之间隔着一段数据,该数据就是比prev大的数据。
当prev紧挨着cur的时,prev++就跳到cur的位置上去了,那prev的值和cur的值交换就没有意义了,因为它们代表的是同一个值。也就是如果一开始cur找小找到了,那么prev和cur就是紧挨着,当一旦找到一个比key大的数时,它们之间就会出现距离,不再紧挨着了。
1.快速排序整体比其他排序都要好,所以才敢叫快排
2.时间复杂度O(N*logN)
3.空间复杂度:O(logN)
4.稳定性:不稳定。
想一想快排的时间复杂度是多少呢?如果我们从最坏情况讨论的话,当序列完全有序和完全逆序情况下,快速排序效率最低。
虽然从最后的情况下来看快速排序的时间复杂度为O(N^2)但是它是可以抢救的,它可以通过某种方法来提高效率,从而从O(N ^2)变成O(N*logN)。
//随机选key
int randi = rand() % (right - left) + begin;
Swap(&a[keyi], &a[randi]);
在单趟排序中:
void Quicksort1(int*a,int left,int right)//需要区间范围
{
//递归结束条件
if (left >= right)
return;
int keyi = left;
int begin = left;
int end = right;
//随机选key
int randi = rand() % (right - left) + begin;
Swap(&a[keyi], &a[randi]);
while (left < right)
{
//右找小
while (left<right && a[right]>=a[keyi])
right--;
//左找大
while (left < right && a[left] <= a[keyi])
left++;
//交换右边的小和左边的大
Swap(&a[left], &a[right]);
}
//最后将key值与left和right相遇位置交换
Swap(&a[keyi], &a[left]);
keyi = left;
随机选key的意义,本来最左边可能是最小值或者最大值,有可能会遇到有序情况和完全逆序情况,但是随机选key大大降低了这种可能,从而提高效率。
三路哪三路呢?
左下标,右下标,和中间下标。
取中是什么意思?
我们要取的是三个下标中所代表的值是中间值的那个数。
找到之后,就将该中间值与最左边的key交换。那么key的值就不可能为最小值或者最大值。
这样就完全确定key不是最小值了,不像随机选key还有一丢丢的可能。
所以三路取中更科学更有可用性。
怎么取出三个数中的中中间值呢?就比较即可。
int Midi(int* a, int left, int right)
{
int mid = (left + right) / 2;//获得中间下标
if (a[left] > a[mid])
{
if (a[mid] > a[right])//a[left]>a[mid]>a[great]
{
return mid;//mid的是中间值下标
}
else//a[left]>a[mid]且a[right]>a[mid] 左右两个值都大于中间的,那左右两个值再比较得中间
{
if (a[left] > a[right])
{
return right;//right是两个数中小的,也就是三个数中中间数
}
else
{
return left;//否则left就是中间数的下标
}
}
}
else//a[left]
{
if (a[mid] < a[right])
{
return mid;
}
else//a[left]
{
if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
}
}
最后返回的都是三个数中中间值。
利用三路取中单趟排序:
void Quicksort1(int*a,int left,int right)//需要区间范围
{
//递归结束条件
if (left >= right)
return;
int keyi = left;
//三数取中,不是取数组中间的数,而是取三个数中中间的数--begin mid end
int midi=Midi(a, left, right);
Swap(&a[keyi], &a[midi]);//将中间值与key交换,key就永远不可能为最小值或最大值了。
while (left < right)
{
//右找小
while (left<right && a[right]>=a[keyi])
right--;
//左找大
while (left < right && a[left] <= a[keyi])
left++;
//交换右边的小和左边的大
Swap(&a[left], &a[right]);
}
//最后将key值与left和right相遇位置交换
Swap(&a[keyi], &a[left]);
keyi = left;
我们想一颗满二叉树它最后一层节点的个数是多少?
如果这颗数的深度为h,那么最后一层的节点数就是2^(h-1)而这么多节点,这么多节点需要大量的栈帧,而且最后几层的节点数是总课数节点的一半还多。而且最后一层要递归很多次才可以到达,这样的递归的缺点就显现出来了。所以有些人就在想当递归到一定程度就不再递归,用另一种方式进行排序。
也就是当区间不断的缩小,缩小到一定范围,用直接插入排序的效果要比递归好,这样也可以做到优化效果。
因为对于数据量小的,虽然区间多,但插入排序嘎嘎快,对于快速排序这些区间需要递归很多次才可以完成所以使用这样的小区间优化来提高效率。
//3.前后指针方法
void Quicksort3(int* a, int left, int right)
{
if (left >= right)
return;
//三数取中,不是取数组中间的数,而是取三个数中中间的数--begin mid end
int midi = Midi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int cur = left + 1;
int prev = left;
//cur用来找小,当cur找到小时,prev++,prev的值与cur的值交换,cur继续往后走
while (cur <= right)
{
if (a[cur] <= a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
//小区间优化--当区间小时,直接使用插入排序
if(right-left+1>10)//当区间大于10时,仍然进行递归
{
Quicksort3(a, left, keyi - 1);
Quicksort3(a, keyi + 1,right);
}
else
{
InsertSort(a+left,right-left+1);//注意这里是将a+left传过去,因为不一定就是左区间,还可能是右区间
}
}
递归真正的问题是什么?
1.效率不行,但现阶段效率方面都差不多
2.深度太深,系统跑不过去会造成栈溢出。
因为递归是要消耗空间的,递归一次就需要建立一个栈帧,最后栈区域的内存不够了,就会造成栈溢出。
所以我们需要掌握这样的能力:将递归改成非递归。
递归改成非递归有两种思路:
1.直接改成循环—如斐波那契数列
2.使用(栈)辅助来改循环。
这里我们将会用栈来辅助将递归改成循环。
> 基本思路:
我们要思考排序递归函数里到底存放的是什么,每次递归什么发生了改变?对!
其实本质就是区间
每次递归函数区间都会被分割,缩小。所以我们在栈里存放的也就是区间通过栈里的区间对序列进行分割,不再使用递归分治的方法分割区间。
1.首先将最初的序列左右区间入栈,要想使用左区间需要先将右区间入栈,然后左区间再入栈(栈的特点)。
2.然后就开始使用栈来辅助修改循环。当栈不为空栈时,就将栈里区间出栈,一次出两个值,也就是左右区间。当左右区间出栈后,我们就可以根据左右区间对序列进行单趟排序选出key,key就将序列分成两个子序列,而这两个子序列又代表着两个区间,再将它们入栈,注意要先将右子序列区间入栈再将左序列区间入栈,然后再出栈重复上面的操作。
3.当出栈的区间拿来分割子序列时,子序列无法再分割了没有区间,就不用入栈。
void QuickSortNoN(int* a, int left, int right)
{
Stack st;
StackInit(&st);//初始化栈
//首先将序列的左右区间入栈
StackPush(&st, right);
StackPush(&st, left);
//开始使用栈辅助修改循环
while (!StackEmpty(&st))
{
//当栈不为空时,将区间出栈--出两个元素
int begin = StackTop(&st);
int end = StackTop(&st);
//将区间出来后,就利用改区间对序列进行单趟排序
int midi = Midi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int cur = left + 1;
int prev = left;
//cur用来找小,当cur找到小时,prev++,prev的值与cur的值交换,cur继续往后走
while (cur <= right)
{
if (a[cur] <= a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
//[left keyi-1] [keyi+1 right]
//单趟排序完后选出key ,而key又将序列分成两个子序列,相当于两个区间,将这两个区间入栈,注意先将右区间入栈,再入左区间
//还有要注意的是,当子序列无法再分割时,无法产生区间时就不入栈
if (left < keyi - 1)
{
StackPush(&st, right);
STackPush(&st, keyi + 1);
}
if (keyi + 1 < right)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
//当栈里没有区间时序列就已经排序好了。
}
接下来就是测试快排的效率如何了。
我们可以拿希尔排序进行比较,也可以拿快排自己的三种方法进行比较。
void QuickTest1()
{
srand(time(0));
const int N = 1000000;
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a2[i] = rand();
}
int begin1 = clock();
Quicksort1(a2, 0, N - 1);
int end1 = clock();
printf("霍尔-Quicksort:%d\n", end1 - begin1);
free(a2);
}
void QuickTest2()
{
srand(time(0));
const int N = 1000000;
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a2[i] = rand();
}
int begin1 = clock();
Quicksort2(a2, 0, N - 1);
int end1 = clock();
printf("挖坑法-Quicksort:%d\n", end1 - begin1);
free(a2);
}
void QuickTest3()
{
srand(time(0));
const int N = 1000000;
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a2[i] = rand();
}
int begin1 = clock();
Quicksort3(a2, 0, N - 1);
int end1 = clock();
printf("前后指针-Quicksort:%d\n", end1 - begin1);
free(a2);
}
void ShellsortTest()
{
srand(time(0));
const int N = 1000000;
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a2[i] = rand();
}
int begin1 = clock();
Shellsort(a2, N);
int end1 = clock();
printf("希尔Shellsort:%d\n", end1 - begin1);
free(a2);
}
int main()
{
QuickTest1();
QuickTest2();
QuickTest3();
ShellsortTest();
return 0;
}