注:學習本篇的前提是要會插入排序,數據結構與算法——排序算法-插入排序
插入排序存在的問題
簡單的插入排序可能存在的問題。
如數組 arr = {2,3,4,5,6,1} 這時需要插入的數 1(最小),過程是:
展示的是要移動 1 這個數,的過程,由於在最后,需要前面的所有數都往后移動一位
{2,3,4,5,6,6}
{2,3,4,5,5,6}
{2,3,4,4,5,6}
{2,3,3,4,5,6}
{2,2,3,4,5,6}
{1,2,3,4,5,6}
結論:當需要插入的數是較小的數時,后移的次數明顯增多,對效率有影響。
簡單介紹
希爾排序(Shell's Sort)是希爾(Donald Shell)於 1959 年提出的一種排序算法。
希爾排序也是一種 插入排序(Insertion sort),是將整個無序列分割成若干小的子序列分別進行插入排序的方法,它是簡單插入排序經過改進后的一個 更高效的版本,也稱為 縮小增量排序。時間復雜度 O(n^(1.3—2)
關於插入排序請看 數據結構與算法——排序算法-插入排序
基本思想
希爾排序把記錄按 下標的一定增量分組,對每組使用 直接插入排序算法 排序,隨着 增量逐漸減少,每組包含的關鍵詞越來越多(要排序的數),當增量減至 1 時,整個文件被分成一組,算法便終止。
光看上面的描述,對於首次接觸的人來說,不知道是啥意思,舉個例子,認真思考下面的說明:
-
原始數組:以下數據元素顏色相同為一組

-
初始增量為:
gap = length/2這里為gap = 10 / 2 = 5(length 是數組的大小)那么意味着整個數組被分為 5 組。分別為
[8,3][9,5][1,4][7,6][2,0]先看明白這里的增量為 5 ,就會分成 5 組。
[8,3]這一組來說,對比看下圖,它的意思是:從 8 開始,下標增加 5 既對應的數是 3,所以他們分為一組
-
對上面的這 5 組分別進行 直接插入排序 ,也就是每一組分為有序列 和 無序列,例如
[8,3]這組,8 為有序列倒數第一個數 3為無序列第一個數。結果如下圖:可以看到,像 3、5、6 這些小的元素被調整到了前面。

然后縮小增量
gap = 5 / 2 = 2,則數組被分為 2 組[3,1,0,9,7]和[5,6,8,4,2] -
對以上 2 組再分別進行 直接插入排序,即每一組分為有序列 和 無序列,例如
[3,1,0,9,7]這組,3為有序列的倒數第一個數1為無序列第一個數。結果如下圖:可以看到,此時整個組數的有序程度更進一步。

然后再縮小增量
gap = 2 / 2 = 1,則整個數組被當成一組,再進行一次直接插入排序,即每一組分為有序列 和 無序列,0為有序列的倒數第一個數2為無序列第一個數。由於基本上是有序的了,所以少了很多次的調整。
動圖:

經過上面的解析,應該對希爾排序有了初步的認識,為了更深入理解它的實現過程以及原理,下面通過代碼進行演示。
代碼實現
場景:有一群小牛,考試成績分布是 {8,9,1,7,2,3,5,4,6,0},請從小到大排序。

對於希爾排序時,對有序序列在 插入 時,有以下兩種方式:
- 交換法:容易理解,速度相對較慢 (初學者用來理解的)
- 移動法:不太容易理解,速度相對較快 (這個才算是真正的希爾排序)
先實現交換法,然后再優化成移動法。比較容易。但是我個人感覺,如果你學好了插入排序,移動法更容易理解。
特別注意
- 希爾排序,是一種插入排序,插入排序算法使用了的是移動法,上面基本思想也算是是用的移動法,但是基本思想是一個樣的,什么移動法、交換法的只是一個數據移動方式。(這里先講解交換法,便於理解)
- 希爾排序,對插入排序的改進,先分組,這里分組是通過增量步長和相關算法,來達到在循環中直接獲取到這一個組的元素
- 直接排序的基本思想一定要記得,最重要的兩個變量:無序列表中的第一個值,與有序列表中的最后一個值開始比較
/**
* 推到的方式來演示每一步怎么做,然后找規律
*/
@Test
public void processDemo() {
int arr[] = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
System.out.println("原始數組:" + Arrays.toString(arr));
processShellSort(arr);
}
public void processShellSort(int[] arr) {
// 按照筆記中的基本思想,一共三輪
// 第 1 輪:初始數組 [8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
// 將 10 個數字分成了 5 組( length / 2),增量也是 5,需要對 5 組進行排序
// 外層循環,並不是循環 5 次,只是這里巧合了。
// 一定要記得,希爾排序:先分組,在對每組進行插入排序
for (int i = 5; i < arr.length; i++) {
// 第 1 組:[8,3] , 分別對應原始數組的下標 0,5
// 第 2 組:[9,5] , 分別對應原始數組的下標 1,6
// ...
// 內層循環對 每一組 進行直接排序操作
// i = 5 ;j = 0, j-=5 = 0 - 5 = -5,跳出循環,arr[j] = 8 ,這是對第 1 組進行插入排序
// i = 6 ;j = 1, j-=5 = 1 - 5 = -4,跳出循環,arr[j] = 9 , 這是對第 2 組進行插入排序
// i = 7 ;j = 2, j-=5 = 2 - 5 = -3,跳出循環,arr[j] = 1 ,這是對第 3 組進行插入排序
// i = 8 ;j = 3, j-=5 = 3 - 5 = -2,跳出循環,arr[j] = 7 ,這是對第 4 組進行插入排序
// i = 9 ;j = 4, j-=5 = 4 - 5 = -1,跳出循環,arr[j] = 2 ,這是對第 5 組進行插入排序
for (int j = i - 5; j >= 0; j -= 5) {
// 如果當前元素大於加上步長后的那個元素,就交換
if (arr[j] > arr[j + 5]) {
int temp = arr[j];
arr[j] = arr[j + 5];
arr[j + 5] = temp;
}
}
}
System.out.println("第 1 輪排序后:" + Arrays.toString(arr));
// 第 2 輪:上一輪排序后的數組:[3, 5, 1, 6, 0, 8, 9, 4, 7, 2]
// 將 10 個數字分成了 2 組(上一次的增量 5 / 2),增量也為 2,需要對 2 組進行排序
for (int i = 2; i < arr.length; i++) {
// 第 1 組:[3,1,0,9,7] , 分別對應原始數組的下標 0,2,4,6,8
// 第 2 組:[5,6,8,4,2] , 分別對應原始數組的下標 1,3,5,7,9
// 內層循環對 每一組 進行直接排序操作
// i = 2 ;j = 0, j-=2 = 0 - 2 = -2,arr[j] = 3 ,跳出循環,
// 這是對第 1 組中的 3,1 進行比較,1 為無序列表中的比較元素,3 為有序列表中的最后一個元素,3 > 1,進行交換
// 交換后的數組:[1, 5, 3, 6, 0, 8, 9, 4, 7, 2]
// 第 1 組:[1,3,0,9,7]
// i = 3 ;j = 1, j-=2 = 1 - 2 = -1,arr[j] = 5 ,跳出循環
// 這是對第 2 組中的 5,6 進行比較,6 為無序列表中的比較元素,5 為有序列表中的最后一個元素,5 < 6,不進行交換
// 交換后的數組:[1, 5, 3, 6, 0, 8, 9, 4, 7, 2] , 沒有交換
// 第 2 組:[5,6,8,4,2]
// i = 4 ;j = 2, j-=2 = 2 - 2 = 0,arr[j] = 3 ,
// 這是對第 1 組中的 3,0 進行比較,0 為無序列表中的比較元素,3 為有序列表中的最后一個元素,3 > 0,進行交換
// 交換后的數組:[1, 5, 0, 6, 3, 8, 9, 4, 7, 2],
// 第 1 組:[1,0,3,9,7]
// 由於 2 - 2 = 0,此時 j = 0,滿足條件,繼續循環 i = 4 :j = 0, j-=2 = 0 - 2 = -2,arr[j] = 1 ,跳出循環
// 這是對第 1 組中的有序列表中的剩余數據進行交換,1,0, 1>0 ,他們進行交換(這里也就是完成有序列的一個排序)
// 第 1 組:[0,1,3,9,7]
//雖然有可能本次的排序完成了但是排序循環還是會循環下去,直到循環結束,后面的移動法會解決該問題
//以此類推
for (int j = i - 2; j >= 0; j -= 2) {
if (arr[j] > arr[j + 2]) {
int temp = arr[j];
arr[j] = arr[j + 2];
arr[j + 2] = temp;
}
}
}
System.out.println("第 2 輪排序后:" + Arrays.toString(arr));
// 第 3 輪:上一輪排序后的數組:[0, 2, 1, 4, 3, 5, 7, 6, 9, 8]
// 將 10 個數字分成了 1 組(上一次的增量 2 / 2),增量也為 1,需要對 1 組進行排序
for (int i = 1; i < arr.length; i++) {
// 第 1 組:[0, 2, 1, 4, 3, 5, 7, 6, 9, 8]
// i = 1 :j = 0, j-=1 = 0 - 1 = -1,arr[j] = 0 ,跳出循環
// 0 為有序列表中的最后一個元素,2 為無須列表中要比較的元素。 0 < 2,不交換
// [0, 2 有序 <-> 無序, 1, 4, 3, 5, 7, 6, 9, 8]
// i = 2 :j = 1, j-=1 = 1 - 1 = o,arr[j] = 2 ,
// 2 為有序列表中的最后一個元素,1 為無序列表中要比較的元素, 2 > 1,交換
// 交換后:[0, 1, 2, 4, 3, 5, 7, 6, 9, 8]
// 由於不退出循環,還要比較有序列表中的數據,0 與 1 ,0 < 1 ,不交換,退出循環
//后面的以此類推,就是一個插入排序
for (int j = i - 1; j >= 0; j -= 1) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
System.out.println("第 3 輪排序后:" + Arrays.toString(arr));
}
測試輸出信息
原始數組:[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
第 1 輪排序后:[3, 5, 1, 6, 0, 8, 9, 4, 7, 2]
第 2 輪排序后:[0, 2, 1, 4, 3, 5, 7, 6, 9, 8]
第 3 輪排序后:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
這里的兩層循環一定明白是在做什么:
-
外層循環:不是控制組數,是為了內層循環 每一輪開始,都能 拿到某一組的無序列表第一個元素
arr[i]難點:結束條件是數組長度,是為了能拿到數組中的所有元素,每增長一次,由於步長的因素,可能這一的元素就不是上一次的同一組了。
-
內層循環:拿到了這一組無序列表中第一個元素,只要減掉增量步長,就是有序列表中中的最后一個元素
arr[j](這里說的是 循環中的 int j = i - gap)
細品這里的含義,這就是 插入排序博客中 講解的直接插入排序法的兩個變量,不過之前講解的算法是使用 移動法,這里使用了 交換法,每一輪開始,都從有序列表最后一個開始交換,直到這個有序列表的第一個元素(在此之前,這個組可能已經排序完成了),就退出循環。
從上述推導可以找到規律,只有每次的增量在變化,因此可以修改為如下方式
@Test
public void shellSortTest() {
int arr[] = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
System.out.println("原始數組:" + Arrays.toString(arr));
shellSort(arr);
}
/**
* 根據前面的分析,得到規律,變化的只是增量步長,那么可以改寫為如下方式
*/
public void shellSort(int[] arr) {
int temp = 0;
// 第 1 層循環:得到每一次的增量步長
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
// 第 2 層和第 3 層循環,是對每一個增量中的每一組進行插入排序
for (int i = gap; i < arr.length; i++) {
for (int j = i - gap; j >= 0; j -= gap) {
if (arr[j] > arr[j + gap]) {
temp = arr[j];
arr[j] = arr[j + gap];
arr[j + gap] = temp;
}
}
}
System.out.println("增量為 " + gap + " 的這一輪排序后:" + Arrays.toString(arr));
}
}
測試輸出
原始數組:[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
增量為 5 的這一輪排序后:[3, 5, 1, 6, 0, 8, 9, 4, 7, 2]
增量為 2 的這一輪排序后:[0, 2, 1, 4, 3, 5, 7, 6, 9, 8]
增量為 1 的這一輪排序后:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
到這里其實希爾排序的思想就已經講完了,只是目前的代碼欠於優化,想必你們也覺得這個交換法有很多的不足,下面進行測試就一目了然了
大數據量耗時測試
/**
* 大量數據排序時間測試
*/
@Test
public void bulkDataSort() {
int max = 80_000;
// int max = 8;
int[] arr = new int[max];
for (int i = 0; i < max; i++) {
arr[i] = (int) (Math.random() * 80_000);
}
Instant startTime = Instant.now();
shellSort(arr);
// System.out.println(Arrays.toString(arr));
Instant endTime = Instant.now();
System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
}
多次測試輸出
共耗時:10816 毫秒
共耗時:11673 毫秒
共耗時:11546 毫秒
由於是交換法的插入排序,時間耗時較久
移動法實現希爾排序
由於交換法上面測試速度也看到了,很慢。采用 數據結構與算法——排序算法-插入排序 中的移動法來對每組進行排序
/**
* 移動法希爾排序
*/
@Test
public void moveShellSortTest() {
int arr[] = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
System.out.println("原始數組:" + Arrays.toString(arr));
moveShellSort(arr);
}
/**
* 插入排序采用移動法
*/
public void moveShellSort(int[] arr) {
// 第 1 層循環:得到每一次的增量步長
// 增量並逐步縮小增量
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
/**
這里的內層循環,除了是獲得每一組的值(按增量取),
移動法使用的是簡單插入排序的算法 {@link InsertionSortTest#processSelectSort2(int[])}
唯一不同的是,這里的組前一個是按增量來計算的
*/
// 每一輪,都是針對某一個組的插入排序中:待排序的起點
for (int i = gap; i < arr.length; i++) {
int currentInsertValue = arr[i]; // 無序列表中的第一個元素
int insertIndex = i - gap; // 有序列表中的最后一個元素
while (insertIndex >= 0
&& currentInsertValue < arr[insertIndex]) {
// 比較的數比前一個數小,則前一個往后移動
arr[insertIndex + gap] = arr[insertIndex];
insertIndex -= gap;
}
// 對找到的位置插入值
arr[insertIndex + gap] = currentInsertValue;
}
System.out.println("增量為 " + gap + " 的這一輪排序后:" + Arrays.toString(arr));
}
}
測試輸出信息
原始數組:[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
增量為 5 的這一輪排序后:[3, 5, 1, 6, 0, 8, 9, 4, 7, 2]
增量為 2 的這一輪排序后:[0, 2, 1, 4, 3, 5, 7, 6, 9, 8]
增量為 1 的這一輪排序后:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
移動法-大數據量耗時測試
/**
* 移動法,大數據量測試速度
*/
@Test
public void moveBulkDataSort() {
int max = 80_000;
// int max = 8;
int[] arr = new int[max];
for (int i = 0; i < max; i++) {
arr[i] = (int) (Math.random() * 80_000);
}
Instant startTime = Instant.now();
moveShellSort(arr);
// System.out.println(Arrays.toString(arr));
Instant endTime = Instant.now();
System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
}
多次測試輸出信息
共耗時:32 毫秒
共耗時:23 毫秒
共耗時:43 毫秒
共耗時:25 毫秒
可以看到,只需要幾十毫秒了
算法分析
希爾排序是基於插入排序的一種算法, 在此算法基礎之上增加了一個新的特性,提高了效率。希爾排序的時間的時間復雜度為O(
),希爾排序時間復雜度的下界是n*log2n。希爾排序沒有快速排序算法快 O(n(logn)),因此中等大小規模表現良好,對規模非常大的數據排序不是最優選擇。但是比O(
)復雜度的算法快得多。並且希爾排序非常容易實現,算法代碼短而簡單。 此外,希爾算法在最壞的情況下和平均情況下執行效率相差不是很多,與此同時快速排序在最壞的情況下執行的效率會非常差。專家們提倡,幾乎任何排序工作在開始時都可以用希爾排序,若在實際使用中證明它不夠快,再改成快速排序這樣更高級的排序算法. 本質上講,希爾排序算法是直接插入排序算法的一種改進,減少了其復制的次數,速度要快很多。 原因是,當n值很大時數據項每一趟排序需要移動的個數很少,但數據項的距離很長。當n值減小時每一趟需要移動的數據增多,此時已經接近於它們排序后的最終位置。 正是這兩種情況的結合才使希爾排序效率比插入排序高很多。Shell算法的性能與所選取的分組長度序列有很大關系。只對特定的待排序記錄序列,可以准確地估算關鍵詞的比較次數和對象移動次數。想要弄清關鍵詞比較次數和記錄移動次數與增量選擇之間的關系,並給出完整的數學分析,今仍然是數學難題。


