数据结构——快速排序

来源:互联网 发布:淘宝会员抓取系统下载 编辑:程序博客网 时间:2024/05/22 11:54

我想不用我刻意介绍,只要对排序有那么一些了解的人应该都听过快速排序的大名吧!~

如果是之前没有学过快速排序的人,估计在乍一听到快速排序的名字时会觉得这个排序算法很难!然而实际上它的理解并不算复杂,正如先前已经讲过的那些排序算法一样,都说不上难(如果你真的觉得难,请学会,然后回头去自己理顺思路再想想是否真的很难?),除了算法分析部分有时候会浅尝辄止。



那么我们第一次提到快速排序是在合并排序(归并排序)中,这说明什么呢?说明快速排序其实和归并排序是比较相似的!快速排序也同样运用了分治的思想,但是它与归并排序之间的“小小不同”导致了它比归并排序更让人愿意使用在内存排序(最明显的一点就是快速排序不用额外的Temp空间)。


快速排序的基本思路就是:

1.如果数组S中元素个数为0或1,则返回(和归并排序是否相似?)

2.选取一个“枢纽元”(与归并排序开始不同)

3.将比“枢纽元”小的元素划分至S1,比“枢纽元”大的元素划分至S2(此处亦与归并排序不同)

4.继续对S1和S2进行快速排序(显然使用递归会很直观!)



所以说,快速排序其实很“简单”,至少思路上就是四个步骤。那么这四个步骤中与归并排序不同的是哪些呢?2和3步,而且这不仅是快速排序和归并排序不同之处,也是快速排序需要注意之处!!!


先说第二步!在上面讲基本思路时我们并没有提到该“怎么选取”枢纽元,那么枢纽元到底该怎么选取呢?这里是需要考虑的地方!因为枢纽元的选取会很明显的影响快速排序的效率!!!


那么接下来我们讲一讲枢纽元选取常用的三种方法:

1.直接选取第一个元素作为枢纽元   (注意这是错误的方法!!

首先要说明的是,如果数据是随机输入的,那么选取第一个元素是可取的(这将和第二个方法起到一样的效果),但是如果数据输入是有序的,那么选取第一个元素有可能导致快速排序耗时O(N^2)!(而且预排序的数据是较为常见的)因为这将导致在第三步划分出S1和S2时所有的元素都被划分到同一边!!(每次递归划分时都选取第一个元素作为枢纽元)

所以我们引出一个想法:枢纽元选取得越是接近数组中所有元素的中值越好!!


2.随机选取枢纽元(安全的做法)

如果每次选取枢纽元都使用随机数(递归的每一步都要选枢纽元),那么随机数产生的枢纽元不可能总是接连不断的产生差的分割,所以使用随机数来进行选取枢纽元可以说是比较安全的一个做法。但是使用这个方法的问题在于生成随机数是“昂贵”的,这可能使得通过选取枢纽元获得的时间节省又被生成随机数给浪费掉了


3.三数中值分割法 (真正常用的方法)

从上面我们可以看到,其实枢纽元的选取之所以让人纠结,就是因为存在“预排序”的情况,即使数据不是完全排好序而只是接近排序状态,那么第一种方法也是非常耗时的。所以我们选取枢纽元其实担心的就是“每次选取都是最小或最大的元素”,使用随机数可以避免这个情况,但是代价昂贵,所以我们想出了一个“巧妙”的办法:选取下标0的元素,下标(N-1)/2的元素,下标为N-1的元素三个数值的中值作为枢纽元!!这样一来,如果数据是随机的,那么我们也只是多做了“一个步骤”而已,而如果数据是预排序的,那么我们就可以尽可能的选取到接近中值的那个元素!!!



那么讲到这儿,第二步选取枢纽元的问题就算是解决了~(只是还没有给出代码而已,但是思路已经很明确了)


接下来就是第三步的问题,可能乍一看,不会觉得有什么问题,因为第三步的目的就是将小的元素(对于枢纽元)分到一边,大的元素分到另一边罢了。其实本来也没多大问题,这里想要讲的其实只是如何“更快速有效”的完成这个目的罢了


首先,我们将我们选取的枢纽元和数组最后一个元素换一下位置,这么做的好处是我们可以不用去想就能确定数组前N-1个元素就是S1和S2应有的元素而不用考虑枢纽元原位置是哪儿,在划分好S1和S2后,我们只要把枢纽元移回它应该在的位置就好了(怎么移回来待会儿说,这将借助待会儿提到的自定义变量)


那么,我们绝对没法避免的事情,就是将数组中的其他元素和枢纽元进行有一一比较来确定它们分别应该归属S1还是S2,。那么问题就出来了,我们该怎么划分S1和S2呢?要知道S1和S2不一定是等长的,准确来说大部分情况都是不等长的。再者,假设我们比较了a [ i ]后,发现它归属S1,那么我们接下来该怎么做呢?


有一种很简单很直接的方式就是开辟Temp空间,但这显然不是我们想要的,而且我先前也说了我们可以做到不适用额外开辟的空间来完成排序。(还有很重要的一点就是一一比较然后分配到新的S1和S2数组中将使得排序不再快速,因为我们曾经说过如果要使一个排序算法以亚二次或o(N^2)时间运行,必须执行一些比较,特别要对相距较远的元素进行交换,它必须每次交换删除不止一个逆序数!


那么办法就是:

1.设Int变量i,使它初始为第一个元素下标,然后检查它所标记的元素是否大于枢纽元,如果小于则i++,否则i不再增加,记录这个大于枢纽元的元素的下标(我们先假设所有元素互异)

2.设Int变量j,使它初始为倒数第二个元素下标(因为倒数第一个为枢纽元),然后检查它所标记的元素是否小于枢纽元,如果大于则j--,否则j不再减小,记录这个小于枢纽元的元素下标

3.如果i<j,交换i和j对应的元素,然后继续1,2步,否则令i所指元素与最后一个元素(枢纽元)交换位置(i>j时应该是i=j+1)


先不考虑i>j时的情况,其他情况下,i和j停止,然后交换位置,然后再一起向着中间推进的做法,可以很好的做到:i的左边都是小元素,j的右边都是搭元素,且j在i的右边,这很显然能做到我们想要的划分S1和S2的效果,并且这里已经开始“使较远的元素进行比较”了!

而当i>j或者说i=j+1时,显然此时j所指元素小于枢纽元,i所指元素大于枢纽元,那么我们此时让i元素和枢纽元交换,很明显可以使得枢纽元左边即S1数组,右边即S2数组


(如果这里真的不好理解,可以自己拿纸笔写一个互异的数组试一试,每一次停止,交换以及最后的结果!)




那么我们接下来面对的问题就是,如果数组元素并不全是互异的该如何处理。

其实稍微思考一下就就会发现,其实也就是当i或者j遇到等于枢纽元的元素时该继续推进(i++或者j--)还是停在当前位置?


我们假设一个数组,所有元素都是相等的

如果我们选择停下,那么i和j将会交换很多相同的元素,这看似没有什么意思,但是正面效果则是i和j将在中间“交错”,划分出来的S1和S2将是几乎相等的,并且排序时间将会是O(N* logN),并不坏


如果我们选择不停下,那么问题就大了,如果没有额外的代码使i和j停下,那么它们将会越界!!并且即使没有越界,i和j停下后(j到了第一个元素,i到了倒数第二个元素),交换i和枢纽元将划分出两个极不平衡的数组!!


所以,进行不必要的交换建立均衡子数组比蛮干毛线得到两个不均衡的数组好,因此,如果i和j遇到等于枢纽元的元素,我们将使它们停下!


现在,我们可以提一个观念或者是问题了,那就是对于“小数组”来说,快速排序的效率并不高,而且最坏的情况就是数组的元素不足以进行快速排序,比如只有2个元素。这个问题很好解决,只要在快速排序的函数中对数组大小进行判断即可,如果小于一定值则使用插入排序



讲到这儿,其实快速排序的主要部分已经算讲完了,但我们实际上还可以有一点小小的改进~


我们可以在选取枢纽元时,直接对a [ left ] ,a[ center ] 和a [ right ]直接排好序,将最小的那个放在a [ left ],将最大的放在 a [ right ] ,然后将枢纽元放在 a[ right-1 ],这样做的话可以使i和j都有一个警戒标记,即可以保证它们不会“越界”,这是《数据结构与算法分析——C语言描述》书中的意思,然而我并没有发现能使得i和j越界的情况,但这么做还是有它的好处,因为将小者放在a [ left ]和大者放在a [ right ]是显然已经到了正确划分位置上的,所以我们可以使i初始时指向left+1,j初始指向right-2,这么做可以使得每次划分都“少”划分两个元素,因为选取枢纽元时进行一个小小的排序只是“举手之劳”,而它却可以对整个快速排序提升一点效率,另外,如果我们只是选取出枢纽元然后放在right位置上,对于某些特殊顺序的数据也可能有坏结果(此处待考证)



接下来我想我们可以开始贴代码了

首先是快速排序的驱动程序

void  QuickSort ( ElementType  a [ ] , int N )

{

          Qsort ( a, 0 , N-1 );

}


然后是用于选取枢纽元的三数中值分割法


ElementType  Median3 ( ElementType  a [ ],  int  left ,  int  right )

{

           int  Center=( left + right ) /2 ;

           if ( a [ left ] >  a [ right ] )

                swap ( &a [ left ] , &a [ right ] );

           if ( a [ left ] > a [ Center ] )

                swap ( &a[ left ] , &a [ Center ] );

           if ( a [ Center ] > a [ right ] )

                swap ( &a [ Center ] , &a [ right-1 ] );

           swap ( &a [ right ], &a [ Center ] );

           return  a [ right-1 ] ;

}


接下来就是快速排序的程序了


void   Qsort ( ElementType  a [ ] , int left ,  int  right )

{

          int  i , j ;

          ElementType  posit;     //枢纽元

          if ( left + 2 <= right )

              InsertSort ( a , left , right );

          else 

          {        posit = Median3 ( a , left ,right );

                   i = left ;   j = right -1 ;

                   while (1)

                   { 

                          

                        while ( ++i < posit ){}     //此处注意!!下面会讲到为什么是++i而不是i++并且不能放在循环体内

                        while ( -- j > posit ){}

                       if ( i < j )

                           swap ( &a [ i ],&a [ j ] );

                       else

                           break;

                   }

                   swap ( &a[ i ],&a[ right-1 ] );      //right-1位置上存储着枢纽元

                   Qsort ( a,  left , i-1 );

                   Qsort ( a, i , right );

          }

}


这里需要注意,如果将代码中的

while ( ++ i < posit ) {}

while ( -- j >posit ){}

改为

while ( i <posit ) {}

while ( j >posit) {}

是不正确的,特殊情况就是a [ i ] = a [ j ] = a [ posit ]时!!!!!!

其实这段代码里运用了三数中值分割法的附加作用(小排序)



快速排序的时间分析


分析的基本原理与归并排序的分析类似,都是采用递推抵消


分析分为三个情况,最坏情况,最好情况和平均情况


最坏情况假定每次枢纽元都是最小(或最大)元素,那么

T(N )= T(N-1)+ cN   ,   N>1

通过使N不断N--然后递推抵消,可得到

T(N)= T(1)+ c * ∑i(从2到N)= O(N^2)



最好情况则假设每次枢纽元都恰好为中值,那么

T(N)= 2T(N/2)+ cN

左右同时除以N

T(N)/ N = 2T (N/2)/ N + c

同样通过递推可得到

T(N)/  N =  T (1)/ N + c*logN

T(N)=c*N*logN + N = O(N* logN)




平均情形的分析略微复杂,此处不予贴出,最终结果为O(N*logN)

0 0
原创粉丝点击