【參考資料】
《算法(第4版)》 — — Robert Sedgewick, Kevin Wayne
在本篇筆記裏,我從簡單的插入排序,到希爾排序,中間的一系列算法,看起來就像是插入排序的「發展史」通常。這些點分別是:
- 直接插入排序(插入排序1.0版本)
- 基於插入排序的簡單優化(插入排序1.1和1.2版本)
- 折半插入排序(插入排序2.0版本)
- 希爾排序(插入排序3.0版本)
直接插入排序(插入排序1.0)
直接插入排序的概念
將一個數組元素插入到已經有序的序列中, 並使得比它大的元素所有右移一位,如此對全部元素處理的排序方式, 叫作直接插入排序。
(文章的排序默認是左小右大的順序)
單個元素的插入過程
對待插入元素來講, 它的左邊是一堆已經有序但將來可能發生位置變更(右移)的元素。
這兩點很重要: 已經有序和位置變更。
- 待排序元素左邊序列已經有序, 這是正確插入的基礎, 只有在這個前提下, 待排序元素才能在從左到右的比較和交換中插入正確的位置。
- 待排序元素的插入須要騰出空間, 這就須要使已有序序列中比它大的元素所有右移一位。
(下圖中顯示的是a[0]<a[4]<a[1]的狀況)
上面的圖示範告訴咱們要作兩件事: 「將元素放入適當位置」,「將有序序列中大於元素的部分所有右移一位」, 具體應該怎麼作呢?
咱們是這樣作的:
- 和相鄰的左邊元素的值比較大小
- 若是左邊元素大於待排序元素,則交換二者的值,左邊元素的值「右移」一位。
- 若是左邊元素小於等於待排序元素,說明已經插入到「合適位置」了,一趟插入結束。
單個元素插入結束後,原來的有序序列的長度增長了一位, 當這個長度不斷增加直到覆蓋整個數組時,直接插入排序就完成了。
直接插入排序的代碼
咱們通常用兩個嵌套的for循環來處理上面的邏輯, 在外部for循環中,設置變量 i 控制當前待插入元素的下標的移動;在內部for循環中,設置變量j用於控制待插入的值的比較和交換(左移到合適位置)
代碼以下:
/**
* @Author: HuWan Peng
* @Date Created in 23:16 2017/12/1
*/
public class InsertSort {
/**
* @description: 交換a[i]和a[j]的值
*/
private static void exch (int []a,int i,int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
/**
* @description: 插入排序
*/
public static void sort (int []a) {
int N = a.length;
for(int i=1;i<N;i++){
for(int j=i;j>0&&a[j]<a[j-1];j--){
exch(a,j,j-1);
}
}
}
}
測試:
public class Test {
public static void main (String args[]){
int [] a = {1,6,3,2,9,7,8,1,5,0};
InsertSort.sort(a);
for(int i=0;i<a.length;i++){
System.out.println(a[i]);
}
}
}
時間複雜度
對於隨機排列的長度爲N的值不重複的數組
最好狀況下: 數組是徹底有序的,那麼這個時候,每插入一個元素的時候,只要和前一個元素作比較就能夠了,並且不須要交換。總共: 比較次數爲N-1, 交換次數爲0。時間複雜度爲O(N)
最壞狀況下: 數組是徹底逆序的,插入下標爲N的元素的時候, 要作N次比較和N次交換。例如對{5,4,3,2,1}。插入下標爲1的4時候,4要和5比較和交換,數組變成{4,5,3,2,1};這時到下標爲2的3插入,3要和4,5比較和交換。 因此,總的比較、交換次數是1+2+3...+(N-1) = N(N-1)/2≈ (N^2) / 2,時間複雜度爲O(N^2)
平均狀況: 須要(N^2) / 4次比較和(N^2) / 4次交換,時間複雜度爲O(N^2)
直接插入排序的軌跡
對插入排序簡單優化(插入排序1.1版本)
在排序中,有兩項重要的任務,分別是「條件判斷」和「元素移動」。所以,咱們優化插排的着眼點也在於次,如何「減小條件判斷」和「減小元素移動」,從而優化插排的性能
優化點一: 去除內循環中j>0的判斷條件
先來看看咱們的內循環的判斷條件
for(int j=i;j>0&&a[j]<a[j-1];j--){
exch(a,j,j-1);
}
緣由
j>0的判斷是爲了防止j不斷自減的過程當中到達a[-1]致使數組越界的錯誤。
更直接,具體一點說, 這個可能發生的錯誤是針對j>0後面的a[j]<a[j-1]這個表達式的。(就爲了防止a[0]<a[-1]的發生)
思路
基於這一點,去除j>0判斷條件的思路是: 只要a[j]<a[j-1]能「主動防護」數組越界的錯誤,例如在j=1這個臨界點變爲false跳出循環, 咱們不就不須要加j>0這個條件判斷了嗎?
方法
基於上面的思路,咱們的方法是:
在排序開始前將數組裏最小的元素移動到數組的最左邊即a[0]。這樣,當j減少到1的時候,不管a[1]是數組中任何一個元素, 對 a[1]<a[0]都是false ! 循環自動跳出,這樣,就算沒有j>0的保護, 咱們的a[j]<a[j-1]也是安全的! 因而咱們就能夠把j>0去除了
代碼以下:
/**
* @Author: HuWan Peng
* @Date Created in 23:16 2017/12/1
*/
public class InsertSort {
/**
* @description: 交換a[i]和a[j]的值
*/
private static void exch (int []a,int i,int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
/**
* @description: 將數組a中最小的元素移到最左邊即a[0];
*/
private static void moveMinLeft (int []a) {
int min=0;
for (int i=0;i<a.length;i++) {
if(a[i]<a[min]){
min = i;
}
}
exch(a,0,min);
}
/**
* @description: 插入排序
*/
public static void sort (int []a) {
moveMinLeft(a);
int N = a.length;
for(int i=1;i<N;i++){
for(int j=i;a[j]<a[j-1];j--){ // j<0的條件已經去除
exch(a,j,j-1);
}
}
}
}
優化點二:避免交換,減小移動(元素)
在原始的插入排序中,我使用了元素交換的方法exch:
private static void exch (int []a,int i,int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
這會在待插入值左移的過程當中,每移一位, 形成兩次的元素移動:a[i] = a[j];和 a[j] = a[i];
但實際上,咱們能夠實現插入值左移一位時, 只移動一次元素的操做:
咱們能夠先用一個臨時變量保存待插入的值,將「插入」的操做留給最後一步(4),這樣,在忽略最後一步的狀況下,咱們的確把數組元素的移動次數減小了一半!
代碼以下:
/**
* @Author: HuWan Peng
* @Date Created in 23:16 2017/12/1
*/
public class InsertSort {
/**
* @description: 不用exch的插入排序
*/
public static void sort (int []a) {
int N = a.length;
for(int i=1;i<N;i++){
int t = a[i];
int j = i;
while (j>0&&t<a[j-1]) {
a[j] = a[j-1];
j--;
}
a[j] = t;
}
}
}

折半插入排序(插入排序2.0)
雖然上面咱們作了減小元素移動量的優化, 元素的移動量已經被減至最低了,在這一部分已經「沒有油水能夠壓榨了」,若是想要進一步「削減開支」的話,就要從另外一方面——「元素比較」入手啦
以下圖所示,咱們要把3插入到0 1 2 4 5 6 7 8 9中的話,總共要作8次a[j]<a[j-1]的比較,這種比較操做量感受有點昂貴,有什麼減小這個比較量的方法嗎?
讓咱們思考下已有的條件和要解決的問題: 有序序列, 插入元素到合適位置。
等等!! 彷佛這讓咱們想起了什麼。
是二分法! 這個需求簡直就是爲二分法私人定製的,由於二分法最擅長處理的情景,就是:在一個有序數組中找到目標元素,或者確認該元素並不存在。在這種情景需求下,二分法顯得至關簡潔高效。咱們的任務是在「在有序數組中尋找一個值的合適位置」, 這和二分法的「主體業務」很相似,嗯,就決定是它了!
和二分法相結合的插入排序, 叫作折半插入排序
二分法的思想以及高效的緣由
二分法查找:設置一個循環,不斷將數組的中間值(mid)和被查找的值比較,若是被查找的值等於a[mid],就返回mid; 不然,就將查找範圍縮小一半。若是被查找的值小於a[mid], 就繼續在左半邊查找;若是被查找的值大於a[mid], 就繼續在右半邊查找。 直到查找到該值或者查找範圍爲空時, 查找結束。
以下圖所示:
(注意:前提是數組有序!)
更準確地說,折半插入排序的場景相似於二分法中的未命中查找(如上圖所示)
經過二分法查找插入位置時的軌跡
若是咱們把二分查找的思想運用到插入排序中去就能夠把原來須要8次的比較減小至3次!
未使用二分法: 8次比較
使用二分法: 3次比較
這個差距隨着數組規模的擴大會愈加劇烈。
咱們的目標是: 在a[0]到a[9]中查找數值3的插入位置。
第一輪二分
首先取中間值: mid = (0 + 9)/2 = 4; 又由於a[4] = 5 > 3, 結合數組有序性可知: 數值3插入的目標位置必定在a[0]到a[4]之間(必定不在a[5]到a[10]之間), 因而將查找範圍縮小一半,下一輪在a[0]到a[4]間查找
第二輪二分
取得中間值: mid = (0 + 4)/2 =2; 由於a[2] = 2< 3, 數值3插入的目標位置必定在a[3]到a[4]之間(必定不在a[0]到a[2]之間)。 因而將查找範圍縮小一半,下一輪在a[3]到a[4]間查找
第三輪二分
取中間值: mid = (3 + 4)/2 = 3, 由於a[3] = 4 >3, 因此a[3]就是最終插入的位置。
找到插入位置以後, 將插入位置後面全部比插入元素大的有序元素所有右移一位,再將待插入的值放入對應位置。 這一點和上面介紹的優化點二作的事情同樣。 也就是說:折半插入排序在減小比較次數的同時, 也減小了元素移動次數。
折半插入排序代碼以下:
/**
* @Author: HuWan Peng
* @Date Created in 11:27 2017/12/2
*/
public class BinaryInsertSort {
private static int binarySearch (int []a,int low,int high, int target) {
int mid;
while (low<=high) {
mid = (low + high)/2;
if(a[target]>a[mid]) { low = mid+1; }
else { high = mid-1; }
}
return low;
}
public static void sort (int []a) {
int N = a.length;
for (int i=1;i<N;i++) {
int temp = a[i];
int low = binarySearch(a,0,i-1,i);
for(int j=i;j>low;j--){
a[j]=a[j-1];
}
a[low]= temp;
}
}
}
時間複雜度
折半插入排序在必定程度上優化了插排的性能, 可是由於它僅僅減小了關鍵字間的比較次數,而記錄的移動次數保持不變。所以,折半插入排序的時間複雜度仍然是O(n^2)
希爾排序(插入排序3.0)
出現的緣由
總的來講,插入排序是一種基礎的排序方法,由於移動元素的次數較多, 對於大規模的複雜數組,它的排序性能表現並不理想。 而對於長度較短的、部分有序(或有序)的數組的處理,性能表現比較良好。
因此根據插排優於處理小型,部分有序數組的特性, 人們在插入排序的基礎上設計出一種可以較好地處理中等規模的排序算法: 希爾排序
實現的過程
希爾排序又叫作步長遞減插入排序或增量遞減插入排序
(下面的h就是步長)
1. 選擇一個遞增序列。並在遞增序列中,選擇小於數組長度的最大值,做爲初始步長 h。
2. 開始的時候,將數組分爲h個獨立的子數組(1中h), 每一個子數組中每一個元素等距離分佈,各個元素距離都是h。
3. 對2中分割出的子數組分別進行插入排序
4. 第一輪分組的插入排序完成後,根據遞增序列(逆向看)減小h的值並進行第二輪分組, 一樣對各個子數組分別插入排序。 不斷循環一、二、4, 直到h減小到1時候, 進行最後一輪插入排序,也就是針對整個數組的直接插入排序(這個時候分組只有1個,即整個數組自己)
5. 一開始的時候h的值是比較大的(例如能夠佔到整個數組長度的一半),因此一開始的時候子數組的數量不少,而每一個子數組長度很小。 隨着h的減少,子數組的數量愈來愈少,而單個子數組的長度愈來愈大。
【注意】 遞增序列的選擇是任意的 , 但不一樣的遞增序列會影響排序的性能
下面的代碼中, 咱們選擇的遞增序列是1,4 ,13,40,121,364,1093... 假設數組長度是N的話,一開始經過
選擇初始步長, 並經過h = h/3,使h按照逆序的遞增序列減小,一直到1, 分別進行多輪分組插入排序。算法
代碼以下:
/**
* @Author: HuWan Peng
* @Date Created in 12:54 2017/12/2
*/
public class ShellSort {
private static void exch(int []a,int i,int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
public static void sort (int [] a) {
int N = a.length;
int h = 1;
while(h<N/3){ h=3*h+1; } // 在遞增序列1,4,13,40...中選擇小於數組長度的最大值,做爲初始步長。
while (h>=1) {
for (int i = h; i < N; i++) {
for (int j = i; j >=h && a[j] < a[j - h]; j-=h) {
exch(a, j, j - h);
}
}
h = h/3; // 按照選定的遞增序列逆向減小
}
}
}
希爾排序圖示
爲了方便展現,下面展現的是選定遞增序列爲1...N/4,N/2的希爾排序的圖示(初始步長爲h =N/2= 5, 並按照 h = h / 2的趨勢遞減)
(顏色相同表示的是同一個分組)
第一輪分組排序: h=5,因此將數組分爲5組:7 1, 6 8, 3 1, 2 5, 9 0分別進行直接插入排序,獲得
1 7, 6 8, 1 3, 2 5,9 0。 數組總體變爲1 6 1 2 0 7 8 3 5 9
第二輪分組排序, h=2,因此將數組分爲2組: 1 1 0 8 5, 6 2 7 3 9;通過分別的插入排序獲得:
0 1 1 5 8, 2 3 6 7 9。數組總體變成 0 2 1 3 1 6 7 9。
第三輪分組排序, h=1, 因此就是對整個數組進行直接插入排序了
希爾排序分析
人們設計希爾排序的思路能夠簡單描述爲: 「對症下藥」, 由於插入排序擅長處理長度較短的, 部分有序(或有序)的數組, 那麼就按這兩點着手好了:
- 一開始的時候,每一個子數組的長度短, 插入排序效率較高
- 每一輪分組排序後,數組有序性加強, 也提升了插入排序的效率。
關於第二點能夠從希爾排序的柱狀軌跡圖看出(40-sorted表示此時 h =4)
每一輪的分組排序, 有序性都逐漸加強, 到最後一輪直接插入排序以前,數組已經接近徹底有序了,這時候插排的效率是比較高的。
跑下題,這裏我再用一個比喻描述一下直接插入排序和希爾排序的關係:
奧特曼打小怪獸的時候,爲何不直接上來就用大招消滅(對整個數組直接插入排序),而是到最後一步才放光波呢? 理性一點能夠這樣看: 一開始的時候怪獸仍是滿血狀態(無序的大規模數組), 這個時候放大招可能連怪獸都沒打死本身能量就全耗光了(整體排序性能太差)。 因此要採用「分步打法」(步長遞減插入排序),先用小招耗掉怪獸的血量(小型數組插排), 等怪獸防護力漸弱的時候(數組總體逐漸有序)才用祭出較強的大招(大型數組插排)和怪獸正面剛,到最後的時機到來的時候, 用光波滅掉血線已經降到斬殺階段的小怪獸,並不須要消耗太多的體力。(h=1時候對整個數組直接插排)
爲什麼希爾排序後必定有序?
由於h到最後必定會減小到1,到最後就是對整個數組的直接插入排序
時間複雜度
關於希爾排序的時間複雜度,它和咱們選擇的遞增序列有關, 在數學上研究起來比較複雜 ,且尚無定論
目前最重要的結論是:它的運行時間不到平方級別,也即時間複雜度小於O(n^2), 例如咱們上面選擇的1,4 ,13,40,121遞增序列的算法, 在最壞狀況下比較次數和N^(3/2)成正比。