希爾排序因電腦科學家Donald L. Shell而得名,他在1959年發現了希爾排序演算法。希爾排序基於插入排序,但是增加了一個新的特性,大大地提高了插入排序的執行效率。
依靠這個特別的實現機制,希爾排序對於多達幾千個資料項目的,中等大小規模的數組排序表現良好。希爾排序不像快速排序和其它時間複雜度為O(N*logN)的排序演算法那麼快,因此對非常大的檔案排序,它不是最優選擇。但是,希爾排序比選擇排序和插入排序這種時間複雜度為O(N2)的排序演算法還是要快得多,並且它非常容易實現。它在最壞情況下的執行效率和在平均情況下的執行效率相比沒有差很多。
插入排序:複製的次數太多
由於希爾排序是基於插入排序的。回想一下在插入排序執行的一半的時候,標記符左邊這部分資料項目都是排過序的,而標記右邊的資料項目則沒有排過序。這個演算法取出標記符所指的資料項目,把它儲存在一個臨時變數裡。接著,從剛剛被移除的資料項目的左邊第一個單元開始,每次把有序資料項目向右移動一個單元,直到儲存在臨時變數裡的資料項目能夠有序回插。
下面是插入排序帶來的問題。假設一個很小的資料項目在很靠近右端的位置上,這裡本來應該是值比較大的資料項目所在的位置。把這個小資料項目移動到在左邊的正確位置上,所有的中間資料項目都必須向右移動一位。這個步驟對每一個資料項目都執行了將近N次的複製。雖不是所有資料項目都必須移動N個位置,但是資料項目平均移動了N/2個位置,這就是執行了N次N/2個移位,總共是N2/2次複製。因此,插入排序的執行效率是O(N2)。
如果能以某種方式不必一個一個地移動所有中間的資料項目,就能把較小的資料項目移動到左邊,那麼這個演算法的執行效率就會有很大的改進。
N-增量排序
希爾排序通過加大插入排序中元素之間的間隔,並在這些有間隔的元素中進行插入排序,從而使資料項目能大跨度地移動。當這些資料項目排過一趟序後,希爾排序演算法減小資料項目的間隔再進行排序,依此進行下去。進行這些排序時資料項目之間的間隔被稱為增量,並且習慣上用字母H來表示。
現在有10個資料項目,增量為4。在0、4和8號位置上的資料項目已經有序了。
當對0、4和8號資料項目完成排序之後,演算法向右移一步,對1、5和9號資料項目進行排序。這個排序過程持續進行,直到所有的資料項目都已經完成了4-增量排序,也就是說所有間隔為4的資料項目之間都已經排列有序。
在完成以4為增量的希爾排序之後,數組可以看成是由4個子數組組成:(0,4,8),(1,5,9),(2,6)和(3,7),這四個子數組內分別是完全有序的。這些子數組相互交錯著排列,然而彼此獨立。
注意,在這個例子中,在完成以4為增量的希爾排序後,所有元素離它在最終有序序列中的位置相差都不到兩個單元。這就是數組“基本有序”的含義,也正是希爾排序的奧秘所在。通過建立這種交錯的內部有序的資料項目集合,把完成排序所必需的工作量降到了最小。
插入排序對基本有序的數組排序是非常有效。如果插入排序只需要把資料項目移動一位或者兩位,那麼演算法大概需要O(N)時間。這樣,當數組完成4-增量排序之後,可以進行普通的插入排序,即1-增量排序。4-增量排序和1-增量排序結合起來應用,比前面不執行4-增量排序而僅僅應用普通的插入排序要快得多。
減小間隔
上面已經示範了以4為初始間隔對包含10個資料項目的數組進行排序的情況。對於更大的數組開始的間隔也應該更大。然後間隔不斷減小,直到間隔變成1。
舉例來說,含有1000個資料項目的數組可能先以364為增量,然後以121為增量,以40為增量,以13為增量,以4為增量,最後以 1為增量進行希爾排序。用來形成間隔的數列被稱為間隔序列。這裡所表示的間隔序列由Knuth提出,此序列是很常用的。數列以逆向形式從1開始,通過遞迴運算式
h=3*b+1
來產生,初始值為1。
還有一些其他的方法也能產生間隔序列;後面會講到這個問題。首先,來研究使用Kunth序列進行希爾排序的情況。
在排序演算法中,首先在一個短小的迴圈中使用序列的產生公式來計算出最初的間隔。h值最初被賦為1,然後應用公式h=3*h+1產生序列1,4,13,40,121,364,等等。當間隔大於數組大小的時候,這個過程停止。對於一個含有1000個資料項目的數組,序列的第七個數字,1093就太大了。因此,使用序列的第六個數字作為最大的數字來開始這個排序過程,作364-增量排序。然後,每完成一次排序全程的外部迴圈,用前面提供的此公式反向推算式來減小間隔:
h=(h-1)/3
這個反向推算的公式產生逆置的序列364,121,40,13,4,1。從364開始,以每一個數字作為增量進行排序。當數組用1-增量排序後,演算法結束。
希爾排序比插入排序快很多,它是基於什麼原因呢?當h值大的時候,資料項目每一趟排序需要移動元素的個數很少,但資料項目移動的距離很長。這是非常有效率的。當h減小時,每一趟排序需要移動的元素的個數增多,但是此時資料項目已經接近於它們排序後最終的位置,這對於插入排序可以更有效率。正是這兩種情況的結合才使希爾排序效率那麼高。
注意後期的排序過程不撤銷前期排序所做的工作。例如,已經完成了以40-增量的排序的數組,在經過以13-增量的排序後仍然保持了以40-增量的排序的結果。如果不是這樣的話,希爾排序就無法實現排序的目的。
希爾排序的Java代碼
class ArraySh{
private long[] theArray;
private int nElems;
public ArraySh(int max){
theArray=new long[max];
nElems=0;
}
public void insert(long value){
theArray[nElems]=value;
nElems++;
}
public void display(){
System.out.print("A=");
for(int j=0;j<nElems;j++)
System.out.print(theArray[j]+" ");
System.out.println("");
}
public void shellSort(){
int inner,outer;
long temp;
int h=1;
while(h<=nElems/3)
h=h*3+1;
while(h>0){
for(outer=h;outer<nElems;outer++){
temp=theArray[outer];
inner=outer;
while(inner>h-1 && theArray[inner-h]>=temp){
theArray[inner]=theArray[inner-h];
inner-=h;
}
theArray[inner]=temp;
}
h=(h-1)/3;
}
}
}
其他間隔序列
選擇間隔序列可以稱得上是一種魔法。至此只討論了用公式h=h*3+1產生間隔序列,但是應用其他間隔序列也取得了不同程式的成功,只是一個絕對的條件,就是逐漸減小的間隔最後一定要等於1,因此最後一趟排序是一次普通的插入排序。
在希爾的原稿中,他建議初始的間距為N/2,簡單地把每一趟排序分成了兩半。因此,對於N=100的數組逐漸減小的間隔序列為50,25,12,6,3,1。這個方法的好處是不需要在不開始排序前為找到初始的間隔而計算序列;而只需要用2整除N。但是,這被證明並不是最好的數列。儘管對於大多數的資料來說這個方法還是比插入排序效果好,但是這種方法有時會使已耗用時間降到O(N2),這並不比插入排序的效率更高。
這個方法的一個變形是用2.2而非2來整除每一個間隔。對於N=100的數組來說,會產生序列45,20,9,4,1。這比用2整除顯著改善了效果,因為這樣避免了某些導致時間複雜度為O(N2)的最壞情況的發生。不論N為何值,都需要一些額外的代碼來保證序列的最後取值為1。這產生了和清單中所列的Knuth序列差不多的結果。
遞減數列的另一個可能是
if(h<5)
h=1;
else
h=(5*h-1)/11;
間隔序列中的數字互質通常被認為很重要:也就是說,除了1之外它們沒有公約數。這個約束條件使每一趟排序更有可能保持前一趟排序已排好的效果。希爾最初以N/2為間隔的低效性就是歸咎於它沒有遵守這個準則。
或許還可以設計出像如上講述的間隔序列一樣好的間隔序列。但是不管這個間隔序列是什麼,都應該能夠快速地計算,而不會降低演算法的執行速度。
希爾排序的效率
迄今為止,除了在一些特殊的情況下,還沒有人能夠從理論上分析希爾排序的效率。有各種各樣基於實驗的評估,估計它的時間級從O(N3/2)到O(N7/6)。