前半部分转自:
博客园:
圣骑士Wind的博客
无知的小七何时你能长大的博客
供交流,无商业用途。
快速排序 Quick Sort
快速排序的基本思想是,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
一趟快速排序(或一次划分)的过程如下:首先任意选取一个记录(通常可选第一个记录)作为枢轴(或支点)(pivot),然后按下列原则重新排列其余记录:将所有关键字比它小的记录都安置在它的位置之前,将所有关键字比它大的记录都安置在它的位置之后。
经过一趟快速排序之后,以该枢轴记录最后所落的位置i作分界线,将序列分割成两个子序列,之后再分别对分割所得的两个子序列进行快速排序。
可以看出这个算法可以递归实现,可以用一个函数来实现划分,并返回分界位置。然后不断地这么分下去直到排序完成,可以看出函数的输入参数需要提供序列的首尾位置。
快速排序的实现
划分实现1 (枢轴跳来跳去法)
一趟快速排序的实现:设两个指针low和high,设枢轴记录的关键字为pivotkey,则首先从high所指位置起向前搜索找到第一个关键字小于pivotkey的记录和枢轴记录互相交换,然后从low所指位置起向后搜索,找到第一个关键字大于pivotkey的记录和枢轴记录互相交换,重复这两步直至low==high为止。
下面的代码例子元素类型为int,并且关键字就是其本身。
Partition 实现1typedef int ElemType;
int Patition(ElemType A[], int low, int high)
{
ElemType pivotkey=A[low];
ElemType temp;
while(low<high)
{
while(low <high && A[high]>=pivotkey)
{
--high;
}
temp=A[high];
A[high]=A[low];
A[low]=temp;
while(low<high && A[low]<=pivotkey)
{
++low;
}
temp=A[high];
A[high]=A[low];
A[low]=temp;
}
return low;
}
划分实现2 (枢轴一次到位法)
从上面的实现可以看出,枢轴元素(即最开始选的“中间”元素(其实往往是拿第一个元素作为“中间”元素))在上面的实现方法中需要不断地和其他元素交换位置,而每交换一次位置实际上需要三次赋值操作。
实际上,只有最后low=high的位置才是枢轴元素的最终位置,所以可以先将枢轴元素保存起来,排序过程中只作元素的单向移动,直至一趟排序结束后再将枢轴元素移至正确的位置上。
代码如下:
Partition 实现方法2
int Patition(ElemType A[], int low, int high)
{
ElemType pivotkey=A[low];
ElemType temp = A[low];
while(low<high)
{
while(low <high && A[high]>=pivotkey)
{
--high;
}
A[low]=A[high];
while(low<high && A[low]<=pivotkey)
{
++low;
}
A[high]=A[low];
}
A[low] = temp;
return low;
}
可以看到减少了每次交换元素都要进行的三个赋值操作,变成了一个赋值操作。
细节就是每次覆盖掉的元素都已经在上次保存过了,所以不必担心,而第一次覆盖掉的元素就是枢轴元素,最后覆盖在了它应该处于的位置。
递归形式的快速排序算法
Quick Sortvoid QuickSort(ElemType A[], int low, int high)
{
if(low<high)
{
int pivotloc=Patition(A,low, high);
QuickSort(A, low, pivotloc-1);
QuickSort(A, pivotloc+1, high);
}
}
不管划分是上面哪一种实现,都可以用这个递归形式进行快速排序。
需要注意的是这个if语句不能少,不然没法停止,会导致堆栈溢出的异常。
快速排序的性能分析
时间复杂度
快速排序的平均时间为Tavg(n)=knln(n),其中n为待排序列中记录的个数,k为某个常数,在所有同数量级的先进的排序算法中,快速排序的常数因子k最小。
因此,就平均性能而言,快速排序是目前被认为是最好的一种内部排序方法。通常认为快速排序在平均情况下的时间复杂度为O(nlogn)。
但是,快速排序也不是完美的。
若初始记录序列按关键字有序或基本有序,快速排序将蜕化为冒泡排序,其时间复杂度为O(n2)。
原因:因为每次的枢轴都选择第一个元素,在有序的情况下,性能就蜕化了。
如下图:
快速排序的空间利用情况
从空间上看,快速排序需要一个栈空间来实现递归。
若每一趟排序都将记录序列分割成长度相接近的两个子序列,则栈的最大深度为log2n+1(包括最外层参量进栈);但是,若每趟排序之后,枢轴位置均偏向子序列的一端,则为最坏情况,栈的最大深度为n。
如果在一趟划分之后比较分割所得两部分的长度,且先对长度短的子序列中的记录进行快速排序,则栈的最大深度可降为O(logn)。
性能改善
为改进快速排序算法,随机选取界点或最左、最右、中间三个元素中的值处于中间的作为界点,通常可以避免原始序列有序的最坏情况。
然而,即使如此,也不能使快速排序在待排记录序列已按关键字有序的情况下达到O(n)的时间复杂度(冒泡排序可以达到)。
为此,可以如下修改划分算法:在指针high减去1和low增加1的同时进行“起泡”操作,即在相邻两个记录处于“逆序”时进行互换,同时在算法中附设两个布尔型变量分别指示指针low和high在从两端向中间移动的过程中是否进行过交换记录的操作,若没有,则不需要对低端或高端子表进行排序,这将进一步改善快速排序的平均性能。
另外,将递归算法改为非递归算法,也将加快速度,因为避免了进出栈和恢复断点等工作。
改进方法:如第二篇博客:如下:
对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。也就是说,基准的选择是很重要的。选择基准的方式决定了分割后两个子序列的长度,进而对整个算法的效率产生决定性影响。最理想的方法是,选择的基准恰好能把待排序序列分成两个等长的子序列。
我们介绍三种选择基准的方法:
方法一:固定位置
取序列的第一个或最后一个元素作为基准,这是很常用的方法,但是,这也是一种很不好的处理方法。
如果输入序列是随机的,处理时间还是可以接受的。如果数组已经有序时,此时的分割就是一个非常不好的分割。因为每次划分只能使待排序序列减一,此时为最坏情况,快速排序沦为起泡排序,时间复杂度为Θ(n^2)。而且,输入的数据是有序或部分有序的情况是相当常见的。因此,使用第一个元素作为基元是非常糟糕的。
方法二:随机选取基准
在待排序列是部分有序时,固定选取基元会使快速排序效率低下,要缓解这种情况,就引入了随机选取基元。这和先使序列为随即序列,在用方法一取固定位置的元素作为基元类似。
随机化算法:
随机选择枢轴的位置,区间在low和high之间
这是一种相对安全的策略。由于枢轴的位置是随机的,那么产生的分割也不会总是会出现劣质的分割。在整个数组数字全相等时,仍然是最坏情况,时间复杂度是O(n^2)。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。一位前辈做出了一个精辟的总结:“随机化快速排序可以满足一个人一辈子的人品需求。”
方法三:三数取中(median-of-three)
虽然随机选取基元时,减少了出现不好分割的几率,但是最坏情况下还是O(n^2),要缓解这种情况,就引入了三数取中选取基元。
最佳的划分是将待排序的序列分成等长的子序列,最佳的状态我们可以使用序列的中间的值,也就是第N/2个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为基元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基元。
举例:待排序序列为:8 1 4 9 6 3 5 2 7 0
左边为:8,右边为0,中间为6.
我们这里取三个数排序后,中间那个数作为枢轴,则枢轴为6
注意:在选取中轴值时,可以从由左中右三个中选取扩大到五个元素中或者更多元素中选取,一般的,会有(2t+1)平均分区法(median-of-(2t+1),三平均分区法英文为median-of-three)。
对待排序序列中low、mid、high三个位置上数据进行排序,取他们中间的那个数据作为枢轴,并用0下标元素存储枢轴。
即:采用三数取中,并用0下标元素存储枢轴。
int
{
}
使用三数取中选择枢轴优势还是很明显的,但是还是处理不了多数元素重复的数组。
快速排序的二次优化:
1.使用插入排序
当待排序序列的长度分割到一定大小后,使用插入排序。
原因:对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。
截止范围的理想待排序序列长度N = 10。
if
{
}//else时,正常执行快排
针对随机数组,使用三数取中选择基元+插排,效率还是可以提高一点,但是针对已排序的数组,是没有任何用处的。因为待排序序列是已经有序的,那么每次划分只能使待排序序列减一。此时,插排是发挥不了作用的。另外,三数取中选择基元+插排还是不能处理重复数组。
2.聚合与基元相等的元素
在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割。
具体过程:在处理过程中,会有两个步骤:
第一步,在划分过程中,把与key相等元素放入数组的两端;
第二步,划分结束后,把与key相等的元素移到枢轴周围。
举例:
待排序序列: 1
三数取中选取枢轴:下标为4的数6
转换后,待分割序列:6
第一步,在划分过程中,把与key相等元素放入数组的两端
结果为:6
此时,与6相等的元素全放入在两端了
第二步,划分结束后,把与key相等的元素移到枢轴周围
结果为:1
此时,与6相等的元素全移到枢轴周围了
之后,在1
如果不采用聚合基元的方法:
待排序序列:1
三数取中选取枢轴:下标为4的数6
转换后,待分割序列:6
本次划分后,未对与key元素相等处理:
结果为:1
下次的两个子序列为:1
经过对比,我们可以看出,在一次划分后,把与key相等的元素聚在一起,能减少迭代次数,效率会提高不少。
void
{
}
在数组中,如果有相等的元素,那么就可以减少不少冗余的划分。这点在重复数组中体现特别明显。
3.优化递归操作
快排函数在函数尾部有两次递归操作,我们可以对其使用尾递归优化。
如果待排序的序列划分极端不平衡,递归的深度将趋近于n,而栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。优化后,可以缩减堆栈深度,由原来的O(n)缩减为O(logn),将会提高性能。
void
{
}
在第一次递归后,low就没用了,此时第二次递归可以使用循环代替。
其实这种优化编译器会自己优化,相比不使用优化的方法,差不了多少。
4.多种优化同时使用
这里效率最好的快排组合是:三数取中+插排+聚集相等元素,它和STL中的Sort函数效率差不多。
都写的非常好!!!非常感谢两位。