外部排序基石——合并排序

来源:互联网 发布:单反吧口碑好的淘宝店 编辑:程序博客网 时间:2024/05/16 15:57

正如可以从堆数据结构中发现堆排序一般,我们这一次要讲的合并排序可以说也是一种“灵感”所诞生的排序算法,而这个“灵感”就是我们现实生活中的做事方法:


假设现在有两盒巧克力(盒子容量可以不一样!)和一个可以装两盒巧克力的盒子,假设盒子们都是直的一颗一颗放的巧克力,并且原先有巧克力的两个盒子中的巧克力大小全都是互不相同并且在同一个盒子内都是按从小到大放好的,那现在如果要你讲两个盒子的巧克力拿到大盒子里去,并且也要从小到大排好序,你会怎么做呢??

显然,我们都会先拿两个盒子各自的第一个中更小的那个,然后再比较此时两个盒子各自的第一个的大小并拿掉小的那个……当一个盒子空了,另一个盒子内的巧克力只要依次拿到大盒子就完成了这项工作~~~这个做法不仅是我们很容易想到的,而且的确是最好的办法!


那么在用于数据的排序时,如果有两个各自内部已经排好序的数组,现在要求将它们合并而且保持排序状态,那么移动巧克力的思想可以直接用于这样的排序,即每次都比较两个数组各自的第一个元素并且选出自己需要的那个(看你是要从小到大排序还是相反)放入新数组,直到一个数组空了,将剩下那个数组的剩余元素全部依次移到新数组即可。


但我们做排序可不是排这种~我们往往是需要将一个数组内未排序的元素排序,那么上面所说的“合并”的思想又该如何实现呢?其实不会难!

我们要继续套用“合并”的思想,那么显然我们需要:

1.两个数组

2.两个数组都已排好序

那么我们现在只有一个数组,该怎么办呢?

我们先看第一个条件,我们需要两个数组来“合并”成一个数组(元素个数是两个数组之和),那我们是否可以“投机取巧”一把,让要排序的数组对半分一下,变成两个数组然后让它们“合并”呢?我们显然可以这么做!那么接下来就需要考虑第二个条件,也是两个条件中“更难达成”的一个(很显然)。如果我们只是简单的把原数组对半分成两个数组,那么这两个数组显然是未排序的,这个情况下我们是不能进行合并的!!但我们可以略微深入的 思考一下:什么情况下,一个数组对半分之后的两个子数组一定是排好序的(即可以对半分后直接合并实现排序)?显然,当原数组只有2个元素的时候对半分后的子数组只有一个元素,显然子数组是“排序”的(重点在于子数组只有一个元素,所以两个子数组可以直接进行合并)!!当数组元素大于2个时简单的对半分是满足不了的,但是我们再想想,如果一个西瓜对半切后,比你想要的要大,你是不是会再对半切一下?同样的,如果数组“对半切”后不满足子数组元素只有1个,我们是否可以继续切下去,直到子数组就是一个元素?


好吧,可能废话讲的有点多了,但是现在我想我们已经知道该如何通过合并来完成我们想要的排序了,那就是将数组“对半分”,并且分出来的两个子数组也“对半分”一直到子数组只有一个元素,我们开始反向合并,1数组合并为2数组,2数组合并为4数组,4数组合并为8数组……(元素个数为偶数的情况,奇数情况性质相似,只是中间两个子数组大小会有不一样的情况,但这没关系)   很显然的,这种排序算法用递归实现会非常的方便易懂直观,并且它的这种“1合2,2合4,4合8”的“属性”可以使得它时间复杂度低至O(N*logN)!!(但是实际上的运行效率并不是特别高,原因很简单我们后面说)



另外这里有一点需要注意,合并两个数组并不是仅仅使用它们自身的空间即可的,必然需要借助新的空间!(可以直接作为排序后存储,也可以只作为暂存数据的地方,但总之需要借助新的空间)


我们先给出合并排序的驱动例程

void  MergeSort ( ElementType a [ ], int  N )

{         ElementType *  TempArray;

          TempArray = malloc ( N * sizeof ( ElementType) );

          if ( TempArray != NULL )

          {           

                    MSort ( a, TempArray , 0 , N-1 );

                    free ( TempArray );

          }

          else

                  FatalError ( "No space for temparray!!!" );

}

显而易见,这个驱动程序做的工作就是开辟一个新的同等大小的空间用于“暂存”数据即为了排序而开辟空间,然后检查是否成功开辟空间,然后调用MSort函数进行排序。


void  MSort ( ELementType a [ ] , ElementType TempArray [ ] , int start , int end )

{

         int  Center;      //Center用于“划分”子数组

         if ( start < end )

         {        Center = ( start + end )/2;

                  MSort ( a, TempArray , start , Center );

                  MSort ( a, TempArray , Center + 1 , end );

                  Merge (a, TempArray, start , Center +1, end );

         }

}

可以看出,其实MSort函数依然不是真实的排序过程,它的作用是检查传递来的数组是否为1个元素(比较start和end),如果不是则继续“对半切”数组,(递归到底层时整个原数组会被分成N个一个元素的数组)然后进行Merge排序,当调用Merge函数时,start到Center和Center+1到end的两个数组已经是排好序的(数组一个元素时必然“排好序”,Merge将会让这两个数组合并,这样在递归返回至上一层时,又能使得两个子数组是排好序的,这里主要还是要学好递归思想及理解过程)


那么接下来是将两个“排好序”的子数组合并的真正排序函数

void  Merge ( ElementType a [ ], ElementType TempArray [ ] , int Lstart , int  Rstart , int  Rend )

{

         int  Lend, NumElements , TmpPosition;    //Lstart、Rstart、Lend等变量是用于“区分”子数组的始终位置

         Lend = Rstart - 1;

         TmpPosition = Lstart;                           //因为TempArray“足够大”,所以数据可以直接拷贝至TempArray中对应                                                                           //位置,不过个人感觉这个变量和i变量其实可以省略,从TempArray的                                                                          //下标0开始拷贝 


         NumElements = Rend - Lstart +1 ;        //NumElements用于存储元素个数,将用于从Temp数组拷贝回来时的参                                                                            //照

         while ( Lstart <= Lend && Rstart <= Rend )          //两个数组如果都没有“取完”则继续取数据至TempArray

                if ( a [ Lstart ] < a [ Rstart ] )

                        TmpArray [ TmpPosition++ ] = a[ Lstart++ ];

                else 

                        TmpArray [ TmpPosition++ ] = a [ Rstart++ ];

      

         //下方两个while是用于将“子数组”的剩余元素全部拷贝至TempArray

         while ( Lstart <= Lend )

                TmpArray [ TmpPosition++ ] = a [ Lstart++ ];

         while ( Rstart <= Rend )

                TmpArray [ TmpPosition ++ ] = a [ Rstart ++ ];


         //将数据从TempArray拷贝回原数组

         for ( i = 0 ; i < NumElements ; i++, Rend-- )

                a [ Rend ] = TmpArray [ Rend ] ; 

}

Merge函数即真正将数组元素进行排序的函数,这里不再赘述,希望注释已经能够使人明白


接下来是对合并排序的时间复杂度分析,也就是解释我们之前为什么说它时间复杂度为 O(N * logN)


首先我们知道,将总元素个数为N的两个有序数组合并需要作的比较最多是N-1 ,因为一次比较就能排好一个,然后让我们假设元素个数N为2的幂(如果想要更精细的分析其他情况将会比较复杂,但最后的结果几乎是一样的)

那么,合并两个1元素数组比较次数为T(1)= 1,而T(N)= 2 T(N/2)+ N(这个应该很好理解,即N个元素的数组合并需要两个子数组各自的合并比较词素加上合并两个子数组的比较N)

T(N)= 2T(N/2)+ N如果两边同除以N

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

……

T(4)/4 = 2 T (2)/4 + 1

T(2)/2 = 2 T(1)/2 + 1

将这些等式全部加起来,会发现左右可以“抵消”,最后会得到

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

即 T ( N ) =N + N * logN

所以O(N)=N*logN



虽然合并排序的运行时间为O(N * logN),但是它很难用于主存排序,主要问题在于合并两个排序的表需要线性附加内存(TmpArray),并且在整个算法中还要花费将数据拷贝来拷贝去的操作,这些严重拖慢了排序的速度。虽然可以通过“压榨”使这些拷贝操作再减少一些,并且合并排序也可以用循环的方法来实现,但对于重要的内存排序,人们还是会选择快速排序。不过,合并排序将会是大多数外部排序算法的基石!


对拷贝操作的可“压榨”之处是原函数在一个数组空了之后,另一个数组不论是“左数组”还是“右数组”都必须全部拷贝至TmpArray然后再拷贝回来,其实我们可以在这里小做文章,即只将“左数组”剩余拷贝过去,如果是右数组有剩余,则不拷贝过去,然后将TmpArray中的元素从此时“右数组”的起始位置前开始逐一向前拷贝回来,如果这样做,那么NumElements和i将不再需要,TmpPosition的初始值也应该为0并且最后的比较是比较TmpPosition > 0


关于非递归实现合并排序,这里给一个链接,里面有非递归实现合并排序的C代码(它的代码对拷贝部分的操作已经有过了“压榨”,所以理解可能有点困难):

http://www.cnblogs.com/bluestorm/archive/2012/09/06/2673138.html

因为其中注释过少的原因,这里给出一点思路“指引”:原递归实现即将数组不断对半分至“极限”然后再反过来合并合并再合并,重点就在于先分成“一元”数组,然后逐步合并(2元素数组,4元素数组,8元素数组……),每次合并后下一次合并的“子数组”长度是本次合并的“子数组”的两倍大

所以用循环实现的想法就是:设定一个变量为“步长”(当前子数组长度),初始为1,然后以这个“步长”为度量,从原数组的开头开始“划分”子数组然后让它和后一个子数组合并,不断循环至整个原数组按当前“步长”合并一次,然后让“步长”*2


例如:

8, 5, 6, 7, 1, 3, 2, 4   (原)


5, 8, 6, 7, 1, 3, 2, 4    (步长为1排序后)


5, 6,7,8, 1, 2, 3,4     (步长为2排序后)


1, 2, 3, 4, 5, 6, 7, 8      (步长为4排序后)


而用循环排序时需要注意的一个地方是:数组元素个数不一定是2的幂,所以“步长”的设定以及子数组的划分要注意!


非递归实现合并排序是一个“挺有意思”的事情,同样用循环实现递归的另外一个有趣的事情就是用循环实现二叉树的三种遍历,这个我们以后会写博客专门提到~


合并排序就讲到这了,期待下次的快速排序!

0 0