赞
踩
对于包含n个数的输入数组来说,快速排序是一种最坏情况时间复杂度为 O ( n 2 ) O(n^2) O(n2)的排序算法。虽然最坏情况时间复杂度是实际排序应用中最好的选择,因为它的平均性能非常好:它的期望时间复杂度是 O ( n l g n ) O(nlgn) O(nlgn),而且 O ( n l g n ) O(nlgn) O(nlgn)中隐含的常数因子非常小。并且,它还能够进行原址排序。
提示:以下是本篇文章正文内容,是作者本人在自主学习过程中的学习思考,也相当于是作为学习笔记把自己的一些思考和感悟记录下来。其中的各种算法也都是借鉴于不同的地方。本篇文章一些介绍描述性的文字参考自《算法导论》。大部分对代码的分析可以不用在意,这是自己的一些拙见与多想的地方。
快速排序是使用了分治的思想。比如对一个典型的子数组 A[p...r]
进行快速排序的三个步骤:
分解:数组 A[p...r]
被划分为两个(可能为空)子数组 A[p...q-1]
和 A[q+1...r]
,使得 A[p....q-1]
中的每一个元素都小于等于A[q]
,而 A[q]
也小于等于A[q+1...r]
中的每一个元素。其中,计算下标 q
也是划分过程的一部分。
解决:通过递归调用快速排序。对子数组 A[p...q-1]
和 A[q+1...r]
进行排序。
合并:因为子数组都是原址进行排序的,所以不需要进行合并操作,数组 A[p...r]
已经有序。
代码如下(示例):
//数组的划分 int Partition(int A[],int l,int r){ int x=A[r]; //标记1 int i=l-1; //标记2 for(int j=l;j<=r;j++){ if(A[j]<=x){ //标记3 i++; swap(A[j],A[i]); } } return i; //标记4 } //递归调用数组划分函数完成对数组的排序 void QuickSort(int A[],int l,int r){ if(l>=r) return; int loc = Partition(A,l,r); QuickSort(A,l,loc-1); QuickSort(A,loc+1,r); }
下面将对上述代码从实现的角度进行分析,并且就上面所做的标记进一步思考这么做原因。
(1) 实例分析
对于一个包含5个数据的数组使用此种方法来进行排序,第一次进行划分的具体步骤如下所示。
(a)2 8 7 1 3 (b)2 8 7 1 3 (c)2 8 7 1 3 (d)2 8 7 1 3 (e)2 1 7 8 3 (f)2 1 3 8 7
上述过程a~f 便是对数组初始数组进行排序的一个过程及元素在过程中的中间状态。其中,初始的x便是我们所选定的枢轴元素,以它为基准进行划分。黑色加粗的元素表示已经确定的小于等于枢轴的元素,正常且非加粗的字体表示大于枢轴的元素,标记(黄底)元素表示暂时还未确定大小的元素。(a) 初始待划分的数组,此时 i=-1,j=0,x=3
。(b) 2与自身进行交换,并放入了元素值较小的那一部分。此时 i=0,j=0
。(c)、(d) 将7和8添加到元素值较大的那一部分。此时 i=0,j=2
。(e)1 和 8 进行交换,数值较小的部分规模增加。此时,i=1,j=3
。(f)3和7进行交换,数值较小的部分规模增加。此时 i=2,j=4
。循环结束,枢轴元素3 位于两个部分之间。
在一次划分过程中,i,j
将数组分为了三部分。A[l...i]
中元素都小于等于 x
,A[i+1...j]
中元素都大于 x
,A[j+1...r]
中元素不确定它们与 x
的大小关系。每当对当前元素进行比较之后再决定是否将其加入较小的元素集合中,或者是不做任何操作,继续往后遍历。
(2) 标记1的思考
从代码的角度来说所选取的枢轴元素是数组最右的一个元素,那我们可以选择别的元素来作为枢轴元素吗?比如左侧元素,或者中间某一个元素。
答案是不可以的。如果选取最右元素之外的其他元素来作为枢轴元素的话,当此元素为最大的值时,就不会对这个序列造成任何的改动,也就是划分起不到任何效果。比如对 9 8 7
进行的一次划分,当选取 9
作为枢轴元素时,第一次划分之后的序列仍然是 9 8 7
,返回 i=2
,但是 9
还在 A[0]
处,因此就会出现枢轴元素的右边存在小于等于枢轴元素,并且x 的最终位置与返回位置不符的情况。
那将 标记3处的 <=
换为 <
是否就可行了呢?详细分析见(4)。
但是如果非要使用数组中间的某个元素作为枢轴元素的话,可以先将该元素和 A[r]
交换之后再 按照上述算法进行排序,依然还是成立的。
(3)标记2 的说明
通过前面的分析可以知道 i
所指示的位置是所有小于等于枢轴元素的最后一个位置,因此在开始时还没有进行判断,所有元素的大小还是不确定的,故先将 i-1
。
(4)标记3的思考
在从前向后的遍历中对每个 <=
枢轴元素的数加入到前面较小的区域中。那如果把 <=
换为 <
算法是否还是可行呢?
试想一下这样一种情况:初始的元素序列为 6 5 3
,调用 QuickSort(A,0,2)
,当选取 3
为枢轴元素时,由于所有的元素都是大于等于3
的,即不存在小于 3
的元素,所以第一次划分不会对数组改变,即 返回 i= -1
。那么接下来就会调用 QuickSort(A,0,-2)
和QuickSort(A,0,2)
,出现了和初始参数相同 的递归调用,因此会陷入无限递归中,导致栈溢出。
(5)标记4的思考
前面在介绍快速排序的基本实现思路的时候,提到过在划分的过程中要对每次的划分的最终位置进行计算,此处返回的 i
就是枢轴元素在此次划分之后所在的最终位置。那为什么返回的是 i
?
前面的实力分析中提到在一次划分技术后 A[l...i]
中所有元素都是小于等于枢轴元素的,按说枢轴元素在其中的某一个不确定的位置,怎么会是i
呢?其实在枢轴元素就是 A[r]
,那么在对 A[r-1]
比较之后,A[l...i]
中都小于等于A[r]
,A[i+1]
一定大于 A[r]
,然后再对 A[r]
进行比较,满足小于等于 x
,那么就会交换 A[i+1]
和 A[r]
,注意交换的 A[r]
的值也是等于枢轴元素值的,所以最后遍历完成时的i
便是枢轴元素的最终位置,返回该位置没有任何问题。
(6)缺点
从大体上一看不出来此算法有太大的问题,对于每次划分都是遍历一次数组。但是如果当数组所有元素都是相等的值时,此时算法的复杂度就会变为
O
(
n
2
)
O(n^2)
O(n2),在数据很大的情况下,不能提供很好的性能。
原因就是从头向后进行遍历,当元素值相同时,每次返回的枢轴元素位置都是 A[r]
,也就是说,不能很好的将数组相等的元素值划分开,最佳的情况就是返回
(
l
+
r
)
/
2
(l+r)/2
(l+r)/2。接下来所介绍的两种方法将弥补这一缺点。
代码如下(示例):
void QuickSort(int A[],int l,int r){
if(l>=r)
return;
int x=A[l]; //标记1
int i=l-1,j=r+1; //标记2
while(i<j){
while(A[++i]<x);
while(A[--j]>x);
if(i<j)
swap(A[i],A[j]);
}
QuickSort(A,l,j); //标记3
QuickSort(A,j+1,r);
}
(1)实例分析
对如下数据调用该算法所进行的第一次划分状态如下所示。
(a)3 2 1 5 3 (b)3 2 1 5 3(c)3 2 1 5 3 (d)3 2 1 5 3
上面状态中除了(a)是初始状态之外,粗体表示此时使内循环退出时i
所指向的数字,斜体表示此时使内循环退出时 j 所指向的数字,标记数字表示已经判断完成不需再移动的数字。(a)为初始待排序的数组 i=-1,j=5
,枢轴元素为3。(b)互换两个3的位置,此时i=0,j=4
。(c)内循环结束时,i
指向5位置,j
指向1 位置。(d)外循环也结束,这就是第一轮互换的结果。
可以看到使用这种方法在进行实际的排序时,中间的一轮划分可能使得数组元素不会发生变化,并且主轴元素也不一定在其最终的位置,这与前面所说的每一次划分完成都能确定枢轴元素位置的说法有时候矛盾。但是这却不影响继续对元素的判断继续排序。要注意到,虽然枢轴元素的最终位置是没有确定的,但是可以发现一个规律,那就是 A[l...j]
中的元素都小于等于枢轴元素x
,A[j+1...r]
中的元素都大于等于枢轴元素x。又使用的是递归进行调用划分函数的,当子数组中只有一个元素时,那么就一定确定其是有序的,即在依次递归划分的过程中就已经排好序了。
还有需要注意的一点是,在递归排序时并没有舍弃一个中间元素,那是因为没有元素的最终位置被确定。
在对一个子数组进行划分的过程中,A[l...i]
中元素都 小于等于 x
,A[j...r]
中元素都大于等于 x
,A[i...j]
中元素是未确定大小。而在结束一轮划分时,j
要么是比 i
小 1,要么是 j
就等于i
。
(2)标记1的思考
此处选择的枢轴元素是 A[l]
,那么可以是其他的元素吗?
答案是可以的,选取 A[l]
,A[(l+r)/2]
等作为枢轴元素都是可以。原因就在于这种方法只是在判定过程中对等于枢轴元素的值也做了互换,保证了对枢轴元素的处理,但是并不要求最终确定 枢轴元素的位置。因此选取哪个元素都是可以实现对数组的排序的。
(3)标记2 的思考
此处将 l
提前 -1,将 r
提前+1的值分别赋给 i 和 j 目的是什么?
目的就是为了简化代码,使得每次使得内循环退出的 i
和 j
的值都是对应于正好不符合条件元素位置。
如果代码如下所示:
void QuickSort(int A[],int l,int r){
if(l>=r)
return;
int x=A[l];
int i=l,j=r; //修改1
while(i<j){
while(A[i]<x)i++; //修改2
while(A[j]>x)j--;
if(i<j)
swap(A[i],A[j]);
}
QuickSort(A,l,j);
QuickSort(A,j+1,r);
}
那么对于(1)中实例就会出现死循环的现象。i = 0
,使得 A[i]<x
一直不成立;j=4
,使得 A[j]>x
也一直不成立,导致 i
和 j
的值一直不会改变 ,程序陷入死循环。这与一开始设想的互换完两个元素之后,i
自动加1,j 自动减1有所相悖。所以说,此处的代码能使 i
和 j
能正常变化。
(4)标记3的思考
在递归调用 QuickSort()
函数时使用的分界变量是 j
和 j +1
。如果选取 j、j-1
或者 i
、i-1
能否可行呢?
首先,如果选取 j、j-1
和 i、i+1
肯定是不行的。因为根据代码中的判定规则在外层循环结束时 i>=j
,并且还满足 A[l...j]
都是小于等于 x
的,A[j+1...r]
都是大于等于 x
的,如果选取 j、j-1
就有可能会在 A[j+1...r]
都大于等于 x
的元素加入一个小于 x
的元素 A[j]
;如果选择 i
、i+1
就有可能会在 A[l...j]
都小于等于 x
的元素中加入一个大于 x
的元素 A[i]
。可以通过分析 上面例子 3 2 1 5 3 第一次划分结束时 i=3、j=2
来带入。
其次,j、j+1
和 i-1、i
的选取是具有相对性的,这个相对性是和每次划分所选定元素的位置所决定的。如果每次划分都有可能选择 A[l]
作为枢轴元素,则就选择 j、j+1
;如果每次划分都有可能选择 A[r]
作为枢轴元素,则就选择 i-1、i
。因为首先这两种选择都是不会破坏已经划分好的性质的;然后就是要保证对任何序列都不会出现重复的调用,否则就无限递归。
假设 一个序列 1 2 3 已经有序,初始调用为QuickSorrt(A,0,2),如果选择 A[l]
作为枢轴元素,那么在第一次划分结束时 i = 0、j = 0
,若选取 i-1、i
为基准进行递归划分的话,就有 QuickSort(A,0,-1)
和QuickSort(A,0,2) 被递归调用,调用了和初始调用具有同样参数的函数,且序列没有改变,因此就导致了无限递归,直至栈满;如果选择A[r] 作为枢轴元素,则在第一次划分结束时 i = 2、j = 2
,若选择 j、j+1
为基准,就有QuickSort(A,0,2) 和 QuickSort(A,2,3)
被调用,同理,还是会出现无限递归的情况。当选取一个中间元素作为枢轴元素时,无论如何 i
都会大于 l
且 j
都会小于 r
的,此时使用 j、j+1
和 i-1、i
均可,因为此时不会出现调用与初始递归相同的参数出现。
在实际应用中,可能对于已经有序的数列为了每次划分都尽可能平均会选择 A[(l+r)/2]
作为枢轴元素,此时如果只有两个元素时其枢轴元素实际相当于 A[l]
,因此这时应选择 j、j+1
作为划分基准。
(5)补充
这个算法基本性能是最好的快速排序算法了,只要选取的枢轴元素适当能满足对各种不同情况的快速排序。并且解决了第一个算法不能很好的排序所有元素 都相等的情况,即在元素都相等的情况下,能较好的划分出两个子数组的长度。此外,还能随意的选择枢轴元素,比较灵活。
最明显的一个特点就是,在一次划分结束后,枢轴元素的最终位置并不一定能确定,只是划分了两个区域。
代码如下(示例):
int Partition(int A[],int l,int r){ int x=A[l]; //标记1 while(l<r){ while(l<r&&A[r]>=x)r--; //标记2 A[l]=A[r]; while(l<r&&A[l]<=x)l++; A[r]=A[l]; } A[r]=x; //标记3 return r; } void QuickSort(int A[],int l,int r){ if(l>=r) return; int pos= Partition(A,l,r); QuickSort(A,l,pos-1); QuickSort(A,pos+1,r); }
(1)实例分析
(a)6 5 1 9 8 3 (b)3 5 1 9 8 3 (c)3 5 1 9 8 9 (d)3 5 1 6 8 9
上述(a)是待排序列初始状态,初始时选定枢轴元素为 6,i=0、j=5
。(b)从后向前找到第一个小于6 的数字放到 A[i]
中。(c)从前向后寻找第一个大于6 的数字放到 A[j]
中。(d)退出外部循环时,l=r
,将枢轴元素放在 A[r]
中即可,r
便是 x
的最终位置。
其实,在划分的某一时刻 待排序列的数组满足这样一种情况,假设用 i、j
分别代表 l、r
的变化,即 A[l...i]
中都小于等于 x
,A[j...r]
中的元素都大于等于 x
,A[i...j]
中都是未确定与 x
大小的元素。
(2)标记1处的思考
此处选择的是数组最左边的元素作为枢轴元素,那么可以选取其他元素作为枢轴元素吗?答案是不可以的。因为此处选定 A[l]
作为枢轴元素时有深意的 ,这样先从后向前找到第一个小于 x
的值赋给 A[l]
,此时 A[r]
空闲了。也就是说,先把 枢轴元素记录下来,那么它的位置就空出来了,就能存放一个比它小的数;然后再从前向后找到第一个大于 x
的值赋给 A[l]
。这样依次寻找第二个、第三个小于/大于 x
的值赋给相应位置即可。
如果选择数组中间某一位置为 x
,那么在第一次赋值时就会出现问题,把没有记录下来可能独一无二的 A[l]
的值覆盖掉了,导致数据丢失。如果非要选取 p(l<p<r)
位置作为枢轴元素的话,那么要先交换 A[l]
和 A[p]
的值,然后再进行划分就可以了。
(3)标记2 的 思考
此处的循环中在比较 A[p]
与 x
大小关系的同时还要比较 i
和 j
的大小,这真的有必要吗?那它紧接着的一行就不加也没必要比较 i
和 j
的 大小了吗?
首先,在循环处比较 A[p]
与 x
大小的同时比较 i
和 j
的大小是有必要的。因为如果所选定的枢轴元素 A[l]
是最小的,在不加 l < r
的限定条件下,r
就会一直减少,直至为 -1,访问地址 A[-1]
出错。所以要加上 l<r
的限制条件。
其次,由于上面循环的限制,在退出循环后 i
要么 j
,满足赋值条件进行赋值;要么 i
等于 j
,自身对自身赋值,不会出现异常,所以无须再加 i<j
的限制,因为不会出现 i > j
的情况发生。
(4)标记3 的思考
此处是将最后退出循环时 r 的值作为枢轴元素的最终位置,此时所有元素都已经比较完成,只剩早先记录下的 x
还未恢复,所以将其放置在 A[r]
即可。那么 为什么 r
就是 x
的最终位置呢?为什么不是 l
呢?
首先,根据在划分过中的数组的各部分的性质可知 A[l...i]
都小于等于 x
,A[j...r]
都大于等于 x
,当 i
与 j
相邻时,即 j=i+1
时,此时不论接下来 i++
,还是 j--
都会导致 i=j
的发生,那么在每个循环的条件判断中就不成立,退出循环了,也就是说,循环结束时,必有 i=j
。
其次,任意时刻都有一个位置 A[i]
或 A[j]
是多余的,要被赋予新的值,那么在退出循环时,i=j
,即 A[i]
原本的值已经多余,所以 A[i]
就是 提前记录下的枢轴元素的最终位置。
(5)改进
其实这种算法已经能很好的适合各种情况了,但是如果是所有元素值都相等的情况,就会出现每次划分所返回的 x
的最终位置都是 l
,但是理想情况下应该是返回
(
l
+
r
)
/
2
(l+r)/2
(l+r)/2 出现这种问题的原因就是对等于
x
的元素每次处理的时候选择忽略,导致不会对它们进行操作,因此还可以对此算法的划分函数进行改进。如下代码所示。
int Partition(int A[],int l,int r){
int x=A[l];
while(l<r){
while(l<r&&A[r]>x)r--; //标记1
if(l<r) //标记2
A[l++]=A[r]; //标记3
while(l<r&&A[l]<x)l++;
if(l<r)
A[r--]=A[l];
}
A[l]=x;
return l;
}
其中标记1的循环中将判定条件中的 =
去掉了以实现对等于 枢轴元素 x
值进行操作。标记 2 再进行一次判断的必要是因为A[l++] = A[r]
会在 l=r
时使 l
加一,不便于之后对最终位置的确定。标记 3 使用 A[l++] = A[r]
是为了防止存在多个等于 x
的值造成的位置 l
和 r
滞留。在最后划分结束时 l=r
。
综合上面的三种快速排序算法,第二、三种可以较好的适应于各种情况,第一种算法则不太适应于元素值都相等的情况。并且第二种方法在一次划分结束时,作为基准的枢轴元素并不会处在其最终的位置,而是在多次递归中确定了最终位置。
本文是我在学习过程对各种疑问的一种记录与想法,可能有的地方过于冗杂,希望不要过于纠结于此。大可以不必阅读代码下面的分析,只关乎代码则可直接使用即可。欢迎大家提出更好的建议和方法,第一次发文,意在分享与交流,如有错误之处,欢迎指正。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。