快速排序算法理解
几种排序算法的联系
-
希尔排序相当于直接插入排序的升级,同属于插入排序类;
-
堆排序相当于简单选择排序的升级,同属于选择排序类;
-
快速排序是最慢的冒泡排序的升级,属于交换排序类;
快速排序的基本思想
-
快速排序是通过不断比较和移动交换来实现排序的,只不过它的实现增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,减少了总的比较次数和移动交换次数
-
通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,分别对这两部分记录继续排序,达到整个序列有序
快速排序的基本实现
QSort()函数
//对顺序表L作快速排序
void QuickSort(SqList *L){
QSort(L, 1, L->length);
}
//对顺序表L中的子序列L->r[low..high]作快速排序
void QSort(SqList *L, int low, int high){
int pivot;
if (low < high){
//将L->r[low..high]一分为二,算出枢轴值pivot
pivot = Partition(L, low, high);
//对低子表递归排序
QSort(L, low, pivot - 1);
//对高子表递归排序
QSort(L, pivot + 1, high);
}
}
关于QSort()函数的内容很容易理解,就是一个很简单的递归过程
Partition()函数将序列分为两个部分,后面两个QSort()分别对前后的两个部分递归调用快速排序
Partition()函数
其中,Partition()函数要做的是先选取当中的一个关键字,如50,想尽办法将它放到一个位置,使得左边的值都比它小,右边的值比它大,这样的关键字称为枢轴(pivot)
经过第一次Partition(L,1,9)的执行之后,数组变成{20,10,40,30,50,70,80,60,90}
并返回值5给pivot,数字5表明50放置在数组下标为5的位置
计算机把原来的数组变成了两个位于50左和右小数组{20,10,40,30}和{70,80,60,90}
而后的递归调用“QSort(L,1,5-1);”和“QSort(L,5+1,9);”
对{20,10,40,30}和{70,80,60,90}分别进行同样的Partition操作,直到顺序全部正确为止
函数实现如下:
//交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置
//此时在它之前(后)的记录均不大(小)于它
int Partition(SqList *L, int low, int high){
int pivotkey;
//默认用子表的第一个记录作枢轴记录
pivotkey = L->r[low];
//从表的两端交替向中间扫描
while (low < high){
while (low < high && L->r[high] >= pivotkey)
high--; //注意这里,只有出现了r[high]<pivotkey的情况时,才会执行下一行的交换操作
//将比枢轴记录小的记录交换到低端
swap(L, low, high);
while (low < high && L->r[low] <= pivotkey)
low++;
//将比枢轴记录大的记录交换到高端
swap(L, low, high);
}
//返回枢轴所在位置
return low;
}
下面的图片简单展示了Partition()函数的执行过程,比较容易理解,不再文字说明:
可以看出,Partition()函数,其实就是将选取的pivotkey不断交换,将比它小的换到它的左边,比它大的换到它的右边,它也再交换中不断更改自己的位置,知道完全满足这个要求为止。
快速排序的复杂度分析
-
平均的情况的时间复杂度数量级为O(nlogn)
-
平均情况空间复杂度为O(logn)
-
由于关键字的比较和交换是跳跃进行的,快速排序是一种不稳定的排序方法
快速排序算法的优化——优化选取枢轴
- 排序速度的快慢取决于L.r[1]的关键字处在整个序列的位置,L.r[1]太小或者太大,都会影响性能
三数取中(median-of-three)法
取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取
这样至少这个中间数一定不会是最小或者最大的数
代码实现实例:
int pivotkey;
//计算数组中间的元素的下标
int m = low + (high - low) / 2;
if (L->r[low] > L->r[high])
//交换左端与右端数据,保证左端较小
swap(L, low, high); //使得r[low]<=r[high]
if (L->r[m] > L->r[high])
//交换中间与右端数据,保证中间较小
swap(L, high, m); //使得r[m]<=r[high]
if (L->r[m] > L->r[low])
//交换中间与左端数据,保证左端较小
swap(L, m, low); //使得r[m]<=r[low]
//此时L.r[low]已经为整个序列左中右三个关键字的中间值
//使用L->r[low]作枢轴记录 */
pivotkey = L->r[low];
九数取中(me-dian-of-nine)法
先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数当中再取出一个中数作为枢轴
更加保证了取到的pivotkey是比较接近中间值的关键字
快速排序算法的优化——优化不必要的交换
观察上文中Partition()函数的执行过程,我们会发现,50这个关键字,其位置变化为:1->9->3->6->5
可是其最终目标就是5,其中的很多交换是不必要的
因此进行如下的优化:
//快速排序优化算法
int Partition1(SqList *L, int low, int high){
int pivotkey;
//这里省略三数取中代码
//用子表的第一个记录作枢轴记录
pivotkey = L->r[low];
//将枢轴关键字备份到L->r[0]
L->r[0] = pivotkey;
//从表的两端交替向中间扫描
while (low < high){
while (low < high && L->r[high] >= pivotkey)
high--;
//采用替换而不是交换的方式进行操作
L->r[low] = L->r[high];
while (low < high && L->r[low] <= pivotkey)
low++;
//采用替换而不是交换的方式进行操作
L->r[high] = L->r[low];
}
//将枢轴数值替换回L.r[low]
L->r[low] = L->r[0];
//返回枢轴所在位置
return low;
}
其实就是将pivotkey备份到L.r[0]中,然后在swap时,将原本的数据交换的工作变为单方面的替换(覆盖)工作
最终,当low与high会和的时候,即找到了枢轴的位置时,再将L.r[0]的数值赋值回L.r[low]
因此,Partition()函数的执行过程也会变成下图这样:
快速排序算法的优化——优化小数组的排序方案
如果数组非常小,其实快速排序算法反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)
因此又对其做如下改进:
#define MAX_LENGTH_INSERT_SORT 7 //数组长度阀值
//对顺序表L中的子序列L.r[low..high]作快速排序
void QSort(SqList &L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
//当high-low大于常数时用快速排序
//将L.r[low..high]一分为二
//并算出枢轴值pivot
pivot = Partition(L, low, high);
//对低子表递归排序
QSort(L, low, pivot - 1);
//对高子表递归排序
QSort(L, pivot + 1, high);
}
else //当high-low小于等于常数时用直接插入排序
InsertSort(L);
}
快速排序算法的优化——优化递归操作(目前我对于这里通过这种方式提高算法效率的原理并不十分理解)
QSort函数在其尾部有两次递归操作
如果待排序的序列划分极端不平衡,递归深度将趋近于n,而不是平衡时的log2 n(2为底)
栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多
如果能减少递归,将大大提高性能
优化代码如下:
//对顺序表L中的子序列L.r[low..high]作快速排序
void QSort1(SqList *L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
while (low < high){
//L.r[low..high]一分为二
//算出枢轴值pivot
pivot = Partition1(L, low, high);
//对低子表递归排序
QSort1(L, low, pivot - 1);
//尾递归
low = pivot + 1;
}
}
else
InsertSort(L);
}
将if改成while,因为第一次递归以后,变量low就没有用处了,所以可以将pivot+1赋值给low
再循环后,来一次Partition(L,low,high),其效果等同于“QSort(L,pivot+1,high);”
结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高了整体性能
对于这个优化的操作,在很多地方(如《大话数据结构》)
将其视作尾迭代优化
但是实际上,这个应该并不算是尾迭代优化
知乎上关于这里不是尾迭代的一种说法
这里的优化跟尾递归优化应该没有关系,尾递归是因为递归在最后一句不需要保存当前环境的变量,所以没有导致栈的加深。这里有个while,怎么都不会是最后一句,肯定还是要保存当前环境信息的。
这里的优化核心这个wille,快排的调用过程,或说整个路线可以看成一个二叉树。一个区间A的排序,会调用子区间B1和B2的排序,B1是A的前半段,B2是A的后半段。但是在这里,调用前半段没变,但是没有直接调用后半段,而是直接变成了后半段。下一次while循环时,区间的开始位置变化,然后变成了后半段的区间。
没优化前是一颗二叉树,现在相当于把右节点擦掉,把右节点的子节点直接连接到当前节点。这样右边的叶深度,也就是最大栈深度就下降了。这个是不需要依赖编译器优化的。
作者:SeltonFD
链接:https://www.zhihu.com/question/285631475/answer/445282667
尾迭代
概念
如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。
当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归
尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码
原理
当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的
编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了
通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高
所有排序算法总结
从算法的简单性,将7种算法分为两类:
-
简单算法:冒泡、简单选择、直接插入
-
改进算法:希尔、堆、归并、快速
平均情况:最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法
最好情况:冒泡和直接插入排序要更胜一筹
最坏情况:堆排序与归并排序强过快速排序以及其他简单排序
执行算法的软件所处的环境非常在乎内存使用量的多少时,归并排序和快速排序不是一个较好的决策
非常在乎排序稳定性的应用中,归并排序是个好算法
从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适;反之,n越大,采用改进排序方法越合适