A: 希爾排序因計算機科學家Donald L.Shell而得名,他在1959年發現了希爾排序算法。html
A: 希爾排序基於插入排序,可是增長了一個新的特性,大大地提升了插入排序的執行效率。java
A: 回憶以前的簡單排序的「插入排序」一節,在插入排序執行一半的時候,標記位i左邊這部分數據項都是排過序的,而標記位右邊的數據項則沒有排過序。這個算法取出標記位所指的數據項,把它存儲在一個臨時變量裏,接着,從剛剛被移除的數據項的左邊第一個元素開始,每次把有序的數據項向右移動一個元素,直到存儲在臨時變量裏的數據項可以有序回插。git
A: 假設一個很小的元素在很靠近右端的位置,要把這個很小的元素移動到在左邊的正確位置上,全部的中間元素都必須向右移動一位。這個步驟對每個元素都執行了近N次的複製,雖不是全部的元素都必須移動N個位置,可是數據項平均移動了N/2個位置,就至關於執行了N次N/2個移位,總共是N2/2次複製,所以插入排序的執行效率是O(N2)。算法
A: 若是能以某種方式沒必要一個一個地移動全部中間的數據項,就能把較小的數據項移動到左邊,那麼這個算法的執行效率就會有很大的改進。數組
A: 希爾排序經過加大插入排序中元素之間的間隔,並在這些有間隔的元素中進行插入排序,從而使數據項能大跨度地移動。當這些數據項排過一趟序後,希爾排序算法減小數據項的間隔再進行排序,依次進行下去。數據結構
A: 進行這些排序時數據項以前的間隔被稱爲增量,而且習慣上用字母h表示。
下圖顯示了增量爲4時對包含10個數據項的數組進行排序的第一個步驟狀況,在0、4和8號的位置上的數據項已經有序了。
當對0、4和8號數據項完成排序以後,算法向右遊一步,對一、5和9號數據項進行排序,這個排序過程持續進行,直到全部的數據項已經完成了增量爲4的排序。這個過程以下圖所示:
在完成增量爲4的希爾排序以後,數組能夠當作是有4個子數組組成:(0, 4, 8), (1, 5, 9), (2, 6), (3, 7)。這4個子數組分別徹底有序,這些子數組相互交錯排列,然而彼此獨立。數據結構和算法
A: 上面圖解了以4爲增量對包含10個數據項的數組進行排序的狀況。對於更大的數組,開始的間隔也應該更大,而後間隔不斷減少,直到間隔變成1。接下來就是對於任意大小的數組,如何選擇間隔呢?ide
A: 舉例來講,含有1000個數據項的數組可能先以364爲增量,而後以121爲增量,而後以40爲增量,接着以13爲增量,再接着以4爲增量,最有以1爲增量進行希爾排序。用來造成間隔的數列(121,40,13,4,1)被稱爲間隔序列。這裏所表示的間隔序列由Knuth提出。函數
A: 數列以逆向的形式從1開始,經過遞歸表達式h = 3 * h + 1來產生,初始值爲1。下表的前兩欄顯示了這個公式的序列。
性能
A: 在排序算法中,首先在一個短小的循環中使用序列的生成公式來計算出最初的間隔。h值最初被賦值爲1,而後用公式h = 3 * h + 1生成序列1,4,13,40,121,364等等。當間隔大於數組大小的時候這個過程中止。
A: 對於一個含有1000個數據項的數組,序列的第七個數字1093太大了。所以使用序列的第6個數字364做爲最大的數字來開始這個排序過程,做增量爲364的排序。而後,每完成一次排序全程的外部循環,用前面提供的此公式倒推式來減少間隔: h = (h - 1) / 3
。這個倒推的公式生成逆置的序列364,121,40,13,4,1。從364開始,以每個數字做爲增量進行排序。當數組用增量爲1排序後,算法結束。
A: 示例:ShellSort.java
A: 選擇間隔序列能夠稱得上是一種魔法,除了h = h * 3 + 1生成間隔序列外,還有其餘間隔序列。這些間隔只有一個絕對條件,就是逐漸減少的間隔最後必定要等於1,所以最後一趟排序是一次普通的插入排序。
A: 在最開始的時候,希爾排序初始的間隔爲N/2,簡單地把每一趟排序分紅兩半,所以對於大小爲100的數組逐漸減少的間隔序列爲50,25,12,6,3,1。這個方法的好處是不須要開始排序前爲找到初始的間隔而計算序列,而只需用2整除N。可是這種證實並非最好的數列。儘管對於大多數的數據來講這個方法仍是比插入排序效果好,可是這種方法有時會使運行時間降到O(2)。
A: Flaming間隔的代碼以下:
if (h < 5) { h = 1; } else { h = (5 * h - 1) / 11; }
這個方法是用2.2而非2來整除每個間隔。對於n=100的數組,會產生序列45,20,9,4,1。這比用2整除好多了,由於這樣避免了某些致使時間複雜度O(N2)的最壞狀況發生。
A: 間隔序列的數字互質一般被認爲很重要,也就是說除了1以外它們沒有公約數,這個約束條件使每一趟排序更有可能保持前一趟排序已排好的效果。而以N/2爲間隔的低效性就是歸咎於它沒有遵循這個準則。
A: 或許還能夠設計出像上面講述的間隔序列同樣好甚至更好的序列。可是無論怎麼樣,都應該可以快速地計算,而不會下降算法的執行速度。
A: 迄今爲止,除了在一些特殊的狀況下,尚未人可以從理論上分析希爾排序的效率。有各類各樣基於試驗的評估,估計它的時間級是從O(N3/2)到O(N7/6) 。
A: 下表對比了速度較慢的插入排序和速度較快的快速排序,中間還列出了希爾排序的一些估計的大O值。注意Nx/y表示N的x方的y次方根(N等於100,N3/2就是1003的平方根,結果是1000)。另外(logN)2表示N對數的平方,一般協做log2N。
A: 劃分(partitioning)是後面討論的快速排序的根本基礎,所以把它做爲單獨的一節來說解。
A: 劃分數據就是把數據分爲兩組,使全部關鍵字大於特定值的數據項在一組,使全部關鍵字小於特定值的數據項在另外一組。
A: 劃分算法:當leftPointer遇到比樞紐小的數據項時,它繼續右移,由於這個數據項的位置已經處在數組的正確一邊了。可是,當遇到比樞紐大的數據項時,它就停下來。同理rightPointer。兩個內層的while循環,第一個應用於leftPointer,第二個應用於rightPointer,控制這個掃描過程,由於指針退出了while循環,因此它中止移動。下面是一段掃描不在適當位置上的數據項的簡化代碼:
while (leftPointer < right && mLArray[++leftPointer] < pivot) {} while (rightPointer > left && mLArray[--rightPointer] > pivot) {} swap(leftPointer, rightPointer);
當這兩個循環都退出以後,leftPointer和rightPointer都指着在數組的錯誤一方位置上的數據項,因此交換這兩個數據項。交換以後,繼續移動這兩個數據項。當兩個指針最終相遇的時候,劃分過程結束,而且退出外層while循環。
A: 劃分算法的運行時間爲O(N)。
A: 毫無疑問,快速排序是最流行的排序算法,由於有充足的理由,在大多數狀況下,快速排序都是最快的,執行時間爲O(N * logN)級。快速排序是在1962年由C.A.RHoare發現的。
A: 有了前面劃分算法的介紹,再來理解快速排序就很容易了。快速排序算法本質上經過把一個數組劃分爲兩個子數組,而後遞歸地調用自身爲每個子數組進行快速排序。
A: 基本的遞歸的快速排序算法代碼很簡單,下面是一個示例:
public void recQuickSort(int left, int right) { if (right - left <= 0) { // if size is 1, it's already sorted return; } else { // size is 2 or larger // partition range int partitionIndex = partitioning(left, right); // sort left side recQuickSort(left, partitionIndex - 1); // sort right side recQuickSort(partitionIndex + 1, right); } }
有三個基本的步驟:
1) 把數組或者子數組劃分左邊和右邊;
2) 調用自身對左邊的進行排序;
3) 調用自身對右邊的進行排序。
通過一次劃分以後,全部在左邊子數組的數據項都小於在右邊子數組的。
只要對左邊子數組和右邊子數組分別進行排序,整個數組就是有序的了。
A: 如何對子數組進行排序呢?經過遞歸來實現。這個方法首先檢查數組是否只包含一個數據項,若是數組只包含一個,那麼數組就已經有序,方法當即返回,這個就是遞歸過程當中的基值條件。
若是數組包含兩個或者更多的數據項,算法就調用前面講過的partitioning()方法對這個數組進行劃分。方法返回分割邊界的下標index。劃分pivot給出兩個子數組的分界,以下圖所示。
對數組進行劃分以後,recQuickSort()遞歸地調用自身,數組左邊的部分調用一次(從left到partitionIndex - 1位置上的數據項進行排序),數組右邊的部分也調用一次(從partitionIndex + 1到right位置上的數據項進行排序)。注意這兩個遞歸調用都不包含數組下標partitionIndex的數據項。爲何不包含這個數據項呢?難道下標爲partitionIndex的數據項不須要排序?
A: 那麼partitioning()方法如何選擇樞紐呢?如下是一些相關思想:
1) 應該選擇具體的一個數據項的關鍵字的值做爲樞紐:成這個數據項爲pivot(樞紐);
2) 能夠選擇任意一個數據項做爲樞紐。爲了簡便,咱們假設老是選擇待劃分的子數組最右端的數據項做爲pivot;
3) 劃分完成以後,若是樞紐被插入到左右子數組之間的分界處,那麼樞紐就落在排序以後的最終位置上了。
下圖顯示了用關鍵字爲36的項做爲樞紐的狀況。由於不能真正像圖中顯示的那樣把一個數組分開,因此這個圖只是一個想象的狀況。那麼怎樣才能把樞紐移動到它正確的位置上來呢?
能夠把右邊子數組的全部數據項都像右移動一位,以騰出樞紐的位置。可是,這樣作即低效又沒必要要。記住儘管右邊子數組的全部數據項都大於樞紐,但它們都尚未排序,因此它們能夠在右邊子數組內部移動,而沒有任何影響。所以,爲了簡化把樞紐插入正確位置的操做,只要交換樞紐和右邊子數組的最左邊的數據項(目前是63)便可。
這個交換操做把樞紐放在了正確的位置上,也就是左右子數組之間。63跳到了最右邊,以下圖所示:
當樞紐被換到分界的位置時,它落在它最後應該在的位置上。之後全部的操做或者發生在左邊或者右邊,樞紐自己不會再移動了。
示例: QuickSort.java
A: 若是數據是逆序的,而後採用上面的程序進行排序,就會發現算法運行得至關緩慢。
A: 問題出在樞紐的選擇上,理想狀態下,應該選擇被排序的數據項的中值數據項做爲樞紐。也就是說,應該由一半的數據項大於樞紐,一半的數據項小於樞紐。對快速排序算法來講擁有兩個大小相等的子數組是最優的狀況。若是快速排序算法必需要對劃分的一大一小兩個子數組排序,那麼將會下降算法的效率,這是由於較大的子數組必需要被劃分更屢次。
A: N個數據項數組的最壞的劃分是一個子數組只有一個數據項,另外一個子數組含有N-1個數據項。
A: 在這種狀況下,劃分所帶來的好處就沒有了,算法的執行效率下降到O(N2)。除了慢,還有另一個潛在的問題,當劃分的次數增長時,遞歸方法的調用次數也增長,每個方法調用都要增長所需遞歸工做棧的大小。若是調用次數太多,遞歸工做棧可能會發生溢出,從而使系統癱瘓。那麼可否改進選擇樞紐的方法呢?
A: 方法應該簡單但能避免出現選擇最大或者最小數據項做爲樞紐的狀況。能夠檢測全部的數據項,而且實際計算哪個數據項是中值數據項,這應該是理想的樞紐,但是因爲這個過程須要比排序自己更長的時間,所以它不可行。
A: 折衷的解決方案是找到數組的第一個、最後和中間元素的中間值,並將其用於樞紐。這個方案被稱爲「三數據項取中」,以下圖:
查找三個數據項的中值數據項天然比查找全部數據項的中值數據項快得多,同時這也有效地避免了在數據已經有序或者逆序的狀況下,選擇最大的或者最小的數據項做爲樞紐的機會。
A: 固然極可能存在一些很特殊的數據排列使得三數據項取中的執行效率很低,可是一般狀況下,對於選擇樞紐它都是一個又快又有效的好方法。
A: 由於在選擇的過程當中使用三數據項取中的方法不只選擇了樞紐,並且還對三個數據項進行了排序。這時就能夠保證子數組最左端的數據項小於樞紐,最右端的數據項大於樞紐,這就意味着即使取消了leftPointer > right
和rightPointer < left
的檢測,leftPointer和rightPointer也不會分別越過數組。以下圖:
A: 三數據項取中的另外一個好處是,對左端、中間以及右端的數據項排序以後,劃分過程就不須要再考慮這三個數據項了。劃分能夠從left + 1和right - 1開始,由於left和right已經被有效地劃分了。
A: 示例:QuickSort.java
A: 若是使用三數據項取中劃分的方法,則必需要遵循快速排序不能執行三個或者少於三個數據項的劃分規則,在這種狀況下,數字3則被稱爲切割點(cutoff)。在上面的示例中,是用一段代碼手動地對兩個或者三個數據項的子數組進行排序。那麼這個是最好的方法嗎?
A: 處理小劃分的另外一個選擇是使用插入排序。當使用插入排序的時候,不用限制以3爲切割點。能夠把界限定爲十、20或者其餘任何數。Knuth推薦使用9做爲切割點。可是最好的選擇值取決於計算機、操做系統、編譯器(或者解釋器)等。
A: 示例:QuickSort.java
A: 另外一個選擇是對數組整個使用快速排序。當快排結束時,數組已是基本有序了,而後能夠對整個數組應用插入排序。插入排序對基本有序的數組執行效率很高,並且不少專家都提倡使用這個方法。
A: 示例:QuickSort.java
A: 不少人提倡對快速排序的算法採用循壞代替遞歸來執行子數組的劃分,這個思想源於早起的編譯器以及計算機體系結構,對於每一次方法調用那種舊的系統都會致使大量的時間消耗。對於如今的系統來講,消除遞歸所帶來的改進不是很明顯,由於如今的系統能夠更爲有效地處理方法調用。
A: 快速排序的時間複雜度爲O(N*logN)。對於分治算法整體都是這樣的,遞歸的方法把一列數據項分爲2組,而後調用自身來分別處理每一組數據項。這種狀況下,算法實際以2爲底,運行時間和N*log2N成正比。
A: 基數排序(Radix Sort)也稱爲桶排序,是一種當關鍵字爲整數類型時很是高效的排序方法。
A: 設待排序的數據元素的關鍵字是m位d進制整數(不足m位的關鍵字在高位上補0),設置d個桶,令其編號爲0,1,2,3,...,d-1。
A: 首先,按關鍵字最低位的數值依次把各數據元素放在對應的桶中。而後,按照桶號從小到大和進入桶中的前後次序收集分配在個桶中的數據元素,這樣就造成了數據元素集合的一個新的排列。稱這樣的依次排序過程爲一次基數排序。
A: 再對一次基數排序所獲得的數據元素序列按關鍵字次低位的數值依次把各數據元素放到對應的桶中,而後按照桶號從小到大和進入桶中數據元素的前後次序收集分配在各桶中的數據元素。
A: 這樣的過程重複進行,當完成了第m次基數排序後,就能夠獲得了排好序的數據元素的序列。
A: 下面是一個例子,有7個數據項{421, 240, 035, 532, 305, 430, 124},每一個數據項都有三位。
A: 分析基數排序算法,由於要求進出桶中的數據元素序列知足FIFO原則,所以這裏所說的桶實際就是隊列。隊列有順序隊列和鏈式隊列,所以在實現中就有這兩種方式。
A: 考慮到個位,十位,百位…每一位數值的個數不可能徹底相同,所以很難肯定隊列的大小,所以採用鏈式隊列最好,由於它能夠任意擴展。請參閱:用鏈表實現的隊列
A: 基於鏈式隊列基數排序算法的存儲結構示意圖:
A: 一個十進制關鍵字K的第i位數值Ki的計算公式:
其中,int()函數爲取整函數,如int(3.5) = 3。
設k = 6321, K1, K2, K3, K4的計算結果以下:
K1 = int(6321 / 100) - 10 * (int(6321 / 101)) = 6321 - 6320 = 1;
K2 = int(6321 / 101) - 10 * (int(6321 / 102)) = 632 - 630 = 2;
K3 = int(6321 / 102) - 10 * (int(6321 / 103)) = 63 - 60 = 3;
K4 = int(6321 / 103) - 10 * (int(6321 / 104)) = 6 - 0 = 6;
A: 示例:RadixSort.java
A: 全部要作的只是把原始的數據項從數組拷貝到鏈表,而後再拷貝回來。若是有10個數據項,則有20次拷貝。拷貝的次數和數據項的個數成正比,即O(N)。
A: 對每一位重複一次這個過程,假設對5位的數字排序,就須要20*5次拷貝。位數咱們設爲M。所以基於鏈式隊列的基數排序算法的時間複雜度爲O(MN)。
A: 儘管從數字中提取出每一位須要花費時間,可是沒有比較。現代計算機中位提取操做要快於比較操做。