浅入浅出数据结构(18)——希尔排序

  在上一篇博文中我们提到:要令排序算法的时间复杂度低于O(n2),必须令算法执行“远距离的元素交换”,使得平均每次交换减少不止1逆序数。

  而希尔排序就是“简单地”将这个道理应用到了插入排序中,将插入排序小小的升级了一下。那么,希尔排序是怎么将这个道理应用于插入排序的呢?我们先来回顾一下插入排序的代码:

void InsertionSort(int *a, unsigned int size)
{//StartPos表示执行插入操作的元素开始插入时的下标
   //令StartPos从1递增至size-1,对于每个a[StartPos],我们执行向前插入的操作
   for (int StartPos = 1;StartPos < size;++StartPos) 
       for (int CurPos = StartPos;CurPos != 0;--CurPos)
           if(a[CurPos - 1] > a[CurPos])
              swap(&a[CurPos],&a[CurPos-1]);   //令当前元素与前一元素交换
}

  不难看出,在插入排序中,对于每一个元素,我们都令其执行“向前插入”操作,直至到达顺序位置。但是,在“向前插入”这个操作中,每一次“当前元素”都是与前一元素进行比较,而这也是插入排序时间复杂度没能低于O(n2)的原因。

  所以,希尔排序与插入排序之间的区别就是:希尔排序在“向前插入”时,“当前元素”总是与前k元素(若当前元素下标为n,则前k元素即下标为n-k的元素)进行比较,并且第一个开始“插队”的元素不再是[1],而是[k]。从代码角度来说,便是将插入排序的循环改为:

   //StartPos表示执行插入操作的元素开始插入时的下标
   //令StartPos从k递增至size-1,对于每个a[StartPos],我们执行向前插入的操作
   for (int StartPos = k;StartPos < size;++StartPos) 
       for (int CurPos = StartPos;CurPos >= k;CurPos-=k)
           if(a[CurPos - k] > a[CurPos])
              swap(&a[CurPos],&a[CurPos-k]);   //令当前元素与前k元素交换

  不难看出,插入排序就是k=1的情况。经过上述代码处理后,数据可以保证如下属性:

  a[n],a[n+k],a[n+2k]……a[n+x*k]有序,其中0=<n<size且n+x*k<size,也就是说:所有相隔距离为k的元素组成的数列都有序(当k为1时即全体有序)

 

  举个实例来看看,假设数组如下,间距为3的元素用同色标注:

  35,30,32,28,12,41,75,15,96,58,81,94,95

  令k=3,进行k=3的“插入排序”后,间距为3的元素互相有序:

  28,12,32,35,15,41,58,30,94,75,81,96,95

 

  分析上例可以看出,当k>1时,间距为k的k-插入排序的交换可以实现“远距离交换元素”,上例中,3-的插入排序交换了5次元素,逆序数减少了9,平均一次交换减少了1.8逆序数。

  同时可以看出,上述属性,只有在k为1时才能保证整个数组有序,也即普通插入排序的情况,而k>1时则不能。也就是说,要想“远距离交换元素”,就要令k>1,而k>1却又不能保证数组最后有序,那该怎么办呢?

  万幸的是,我们有这么一个定理:

  若数组已经进行过间距为k的k-插入排序,即已经确定间距为k的元素互相有序,则对数组进行间距为(k-1)的(k-1)-插入排序后,数组依然保持“间距为k的元素互相有序”

  用大白话来说,就是:虽然k>1的k-插入排序不能保证数组完全有序,但可以保证不增加数组的逆序数。

  于是,希尔排序的发明者唐纳德·希尔想出了这么一个办法,也就是希尔排序:先进行k比较大的“插入排序”,然后逐步减小k的值,直至k=1。这样一来,希尔排序就能保证最后数组有序。

  接下来的问题就是,k的初始值该如何选?k又该如何减小至1?这一点至关重要,其重要性类似于哈希函数对于哈希表的意义。我们称k从初始值kn减小至1的各值:kn,kn-1,kn-2……1组成的序列称为“增量序列”,即“增量”(Increment,意指k的大小)组成的序列。希尔本人推荐的增量序列是初始值为size/2,任一kn-1=kn/2。这样一来,使用希尔增量序列的希尔排序完整算法如下:

void ShellSort(int *a, unsigned int size)
{
    unsigned int CurPos, Increment;  //CurPos表示执行插入的元素当下所处的下标,Increment即增量k
    int temp;   //用于暂存当前执行插入操作的元素值,可以减少交换操作
    
    //Increment从size/2开始,按Increment/=Increment的方式递减至1
    for (Increment = size / 2;Increment > 0;Increment /= 2)
        //下方代码与插入排序几乎相同,只是比较对象由[CurPos-1]变为[CurPos-Increment]
        for (unsigned int StartPos = Increment;StartPos < size;++StartPos)
        {
            temp = a[StartPos];
            for (CurPos = StartPos;CurPos >= Increment&&a[CurPos - Increment] > temp;CurPos -= Increment)
                a[CurPos] = a[CurPos - Increment];
            a[CurPos] = temp;
        }
}

  接下来,我们以希尔增量序列为例,说明为什么增量序列的设定对于希尔排序性能至关重要:

  设数据为:1,9,2,10,3,11,4,12,则对应增量序列为4,2,1

  4-插入排序后:1,9,2,10,3,11,4,12

  2-插入排序后:1,9,2,10,3,11,4,12

  1-插入排序后:1,2,3,4,9,10,11,12

  不难发现,这个例子中的增量序列很不好,4-排序和2-排序都没有任何的有效操作。这个例子告诉我们两件事:

  1.增量序列对于希尔排序的性能非常重要,差的增量序列会减少需要本可以执行的“远距离交换”

  2.希尔推荐的增量序列编程实现简单,但实际应用中表现并不好,原因在于其增量序列不互素。

  并且可以确定的是,若需排序的数组a大小n为2的幂,任一x为偶数的a[x]均大于x为奇数的a[x],且a[x]>a[x-2],则希尔的增量序列只有在进行1-排序时才有交换操作。

  举例来说:9,1,10,2,11,3,12,4,13,5,14,6,15,7,16,8。

  其增量序列为8,4,2,1,但是8-排序、4-排序与2-排序都没有交换元素。

  此外,若某元素排序前位于下标奇数处,排序后所在位置为i,则进行1-排序前,其位置在2*i+1处(如例中元素4,其下标为奇数,其有序位置应为3,1-排序前位置为7),而将其从位置2*i+1移动至i需要执行i+1次交换,这样的元素(下标奇数)共有n/2个,所以将这些元素移动至正确位置就需要(0+1)+(1+1)+(2+1)+……+(N/2+1)共N2/8-N/4,时间复杂度为O(n2)。可见,使用希尔增量序列希尔排序的最坏情况是O(n2)

 

  那么,希尔排序的增量序列该如何选择呢?本文给出两个序列,它们都比希尔增量序列要好:

  1.Hibbard序列:{1,3,7……2k-1},k为大于0的自然数,使用Hibbard序列的希尔排序平均运行时间为θ(n5/4),最坏情形为O(n3/2)。

  2.Sedgewick序列:令i为自然数,将9*4-9*2+1的所有结果与4-3*2+1的所有结果进行并集运算,所得数列{1,5,19,41,109……}。使用此序列的希尔排序最坏情形为O(n4/3),平均情形为O(n7/6)

  如何实现这两个序列的希尔排序并不是难事,Hibbard序列可以直接通过计算得出初始值(小于数组大小即可),而后每次令Increment=(Increment-1)/2即可。Sedgewick序列则稍稍麻烦点,需要先将序列计算出足够项(最后一项小于数组大小),而后存于某个数组,再不断从中取出元素作为增量。

 

  希尔排序的性能(使用Sedgewick序列)在数据量较大时依然是不错的。如果说插入排序是我们的“初级排序”,用于较少数据或趋于有序数据的情况,那么希尔排序就是我们的“中级排序”,用于数据量偏多的情况。当然,当数据量极大时,我们将用上我们的“高级排序”——快速排序。至于怎么样算数据量偏多,这个就需要因情境而异了,数据的存储形式等都是需要考虑的问题,一般来说数据量为万级时我们使用希尔排序,数据量为十万、百万级时使用快速排序,而数据量为百、千级时插入排序和希尔排序都可以考虑。并且需要再次说明的是,数据越趋于有序,则插入排序越快。从这个角度来说,插入排序也不失为一个“高级排序”。

  那么,我们学习堆时提到的用堆进行排序的想法,明明有着很好的时间界O(N*logN),为什么不在考虑之列呢?我们下一篇博文就简单地分析分析。

  

原文地址:https://www.cnblogs.com/mm93/p/7544886.html