在上一篇博文中我們提到:要令排序算法的時間復雜度低於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),為什么不在考慮之列呢?我們下一篇博文就簡單地分析分析。