1. 程式人生 > 程式設計 >24-插入排序(Insertion Sort)

24-插入排序(Insertion Sort)

插入排序(Insertion Sort)

插入排序,非常類似於撲克牌的排序,相信各位讀者,都有玩過撲克牌,如逢年過節可能會和親朋好友一起鬥地主,當我們拿到牌以後,一般都會對牌進行排序,這樣會比較方便出牌。例如現在手裡有2,4,5,104張牌,當摸到一張7的時候,就會把7插入下圖中的位置。將7插入合適的位置後,保證原來的序列有序,這就叫做插入排序。

插入排序的執行流程

  1. 在執行過程中,插入排序會將序列分為2部分;例如下圖的這一堆資料屬於待排序資料
    其中,頭部是已經排好序的,尾部是待排序的
    與打牌抓牌進行對比的話,就類似於左邊是手裡的牌,右邊為待抓的牌
  2. 從頭開始掃描每一個元素
    • 每當掃描到一個元素,就將它插入到頭部合適的位置,使得頭部依然保持有序

最終,可以實現的程式碼如下

protected void sort() {
    for (int begin = 1; begin < array.length; begin++) {
        int cur = begin;
        while (cur >0 && cmp(cur,cur - 1) < 0) {
            swap(cur,cur - 1);
            cur--;
        }
    }
}
複製程式碼

現在將插入排序與前面的幾種排序演演算法進行堆10000個資料排序比較,可以發現排序結果是沒有問題的,並得到的結果是

從目前來看,插入排序的效能和選擇排序的效能差不多。接下來,看一下插入排序的另外一個概念。

逆序對(Inversion)

什麼是逆序對?

如果是正序的話,前面元素只小,如果是逆序的話,則前面的元素值更大,所以現在有如下陣列<2,3,8,6,1>,可以組成這5對逆序對<2,1>,<3,1>,<8,6>,<6,1>

如果某個陣列,組成的逆序對為0,那麼該陣列中元素肯定是升序的,所以如果逆序對多的話,會進行的交換次數多,所以插入排序的時間複雜度與逆序對的數量成正比

逆序對的數量越多,插入排序的時間複雜度越高

為什麼呢?假設現在有如下的一堆元素

可以發現,當前這些元素,可以組成的逆序對數量達到最大,為什麼逆序對這麼高的一堆元素,會導致插入排序的效率是最低的,時間複雜度是最高的呢?

當現在對元素8進行排序,則9,8需要交換位置,得到下面的結果

重複依次排序,最終的結果為

通過觀察可以發現,當新拿到一個待排序的元素時,該元素總是要被插入到最前面,這樣需要做的交換操作是最多的,並且以現在的演演算法的話,是一個一個的比較進行交換的,所以像這種情況,時間複雜度肯定是最高的。

所以插入排序有以下的結論

  1. 最壞,平均時間複雜度為:O(n^2)
  2. 最好時間複雜度為:O(n)【沒有逆序對的時候】
  3. 空間複雜度:O(1)
  4. 屬於穩定排序

當逆序對的數量極少時,插入排序的效率特別高,甚至速度比O(nlogn)級別的快速排序還要快

資料量不是特別大的時候,插入排序的效率也是非常好的

優化

由於發現現在的程式碼中,比較後都需要進行交換,並且不一定一次就能交換到最期望的位置,所以可以想想,是否能將交換轉為挪動呢?是可以的。操作步驟如下

  1. 先將待插入的元素備份;例如現在需要將下圖中的下標尾5的元素插入到合適的位置,這個時候,先將該元素內容進行備份
  2. 頭部有序資料中比待插入元素大的,都往尾部方向挪動一個位置
  3. 將待插入的元素,放到最終合適的位置

為什麼這種就可以優化呢?如果按照以前的思路,交換一對元素,需要3行程式碼才能實現,但是現在挪動一個元素,只需要一行程式碼就實現了。所以優化後的程式碼為

protected void sort() {
    for (int begin = 1; begin < array.length; begin++) {
        int cur = begin;
        E element = array[cur];
        while (cur >0 && cmp(element,array[cur - 1]) < 0) {
            array[cur] = array[cur - 1];
            cur--;
        }
        array[cur] = element;
    }
}
複製程式碼

通過優化後,對比前面的排序演演算法,得到的結果為【注:InsertionSort2為優化後,InsertionSort1為優化前】

並且,由於現在是對while迴圈中的程式碼進行優化,所以進入while迴圈次數越多的話,優化會更明顯。不過現在的插入排序,還可以進一步優化,在瞭解如何進一步優化之前,需要了解另外一個知識。二分搜尋(Binary Search)

二分搜尋(Binary Search)

說到二分搜尋,你可能會馬上想起一個東西,就是二叉搜尋樹,如果還不瞭解二叉搜尋樹,可以點選這裡,有專門的文章進行介紹。是的,二叉搜尋樹的思想和這裡的二分搜尋是很相似的,那二分搜尋有什麼用呢?那可以先來思考下面一個問題

如何確定一個元素在陣列中的位置?(假設陣列裡面全部都是整數)

  • 如果是下圖中的這種無序陣列,可以從第0個位置開始遍歷搜尋,平均時間複雜度為:O(n)
  • 如果陣列是下圖中的有序陣列,則可以使用二分搜尋,其最壞時間複雜度為:O(logn)

那二分搜尋,具體是怎麼去工作的呢?現在通過一個下圖來統一表示二分搜尋

  1. 圖片中線條長度表示陣列長度
  2. m表示中間的元素,其下標尾mid
  3. 所以m左邊部分的元素大小都是≤m的
  4. m右邊部分的元素大小都是≥m的
  5. begin表示最前面元素的索引
  6. end表示最尾部元素的索引+1

所以,對於任何一個有序的陣列,都可以通過上圖中的方式來進行表達。

假設在[begin,end)範圍內搜尋某個元素v,可以讓mid == (begin + end) / 2

  • 如果mid位置對應的元素m,是v<m的,則去mid的左邊繼續搜尋,這時的搜尋範圍變為了[begin,mid)之間
  • 如果mid位置對應的元素m,是v>m的,則去mid的右邊繼續搜尋,這時的搜尋範圍變為了[mid +1,end)之間
  • 如果mid位置對應的元素m,是v == m,則直接返回mid

通過這種方式,就可以直接獲取到元素v對應的索引

二分搜尋實際應用

應用一:元素存在

假設現在要從下圖陣列中搜索元素10,則是先找計算中間元素的位置,(0 + 7) / 2 = 3,得到中間位置的元素8

發現,要搜尋的元素10的值是大於8的,所以現在就開始從8的右邊開始查詢元素。再利用(4 + 7) / 2 = 5,所以就得到了索引為5的元素,其值為12。

發現要搜尋的元素10的值是比12小的,所以就開始從剩下元素中的左邊進行查詢,最終剩下的範圍就變為了[4,5),然後再利用 (4 + 5) / 2 = 4,最終發現值與要搜尋的元素相等,就可以直接返回搜尋到的下標

應用二:元素不存在

假設現在要從下面的陣列中搜索元素3,同樣是先找計算中間元素的位置,(0 + 7) / 2 = 3,得到中間位置的元素8

發現要搜尋的元素3的值是小於中間元素8,則開始從8的左邊繼續搜尋,再利用(0 + 3) / 2 = 1,得到中間位置的索引為1,其值為4

通過比較,發現4是大於要搜尋的元素3,即繼續在4的左邊進行搜尋,計算索引(0 + 1)/ 2 = 0,得到索引為0的元素為2

繼續比較,發現元素2的值是小於要搜尋的元素3的,所以繼續在(1,1)範圍內搜尋。由於(1,1)範圍是不合理的,所以最終搜尋失敗

根據上面的流程,可以轉換為下面的程式碼

public static int indexOf(int[] array,int v) {
    if (array == null || array.length == 0) return -1;
    int begin = 0;
    int end = array.length;
    while (begin < end) {
        int mid = (begin + end) >> 1;
        if (v < array[mid]) {
            end = mid;
        } else if (v > array[mid]) {
            begin  = mid + 1;
        } else {
            return mid;
        }
    }
    return -1;
}
複製程式碼

程式碼實現以後,現在思考一個問題,如果存在多個重複的值,最終會返回哪一個?

這種情況下,最終會返回哪一個元素,是不確定的。所以這一點需要注意。

好的。瞭解了二分搜尋以後,現在可以繼續優化插入排序了。

優化-二分搜尋

結合二分搜尋,在插入排序時,可以先利用二分搜尋出合適的插入位置,然後再將元素v插入。

由於上面實現的二分搜尋,如果找不到時,是返回-1,這樣是不可行的。所以在插入排序時,還需要繼續改進二分搜尋

同樣的,假設是在[begin,mid == (begin + end) / 2

這一次的搜尋和前面的二分搜尋,是有一點不一樣的

如果v < m,去[begin,mid)範圍內二分搜尋

如果v ≥m,去右邊的部分中去查詢

插入位置例項分析

現在要從下列的陣列中插入元素5,可以計算從中間位置為3,對應的元素是8

通過比較,5是小於搜尋出來的元素的,所以在[0,3)位置繼承查詢,計算出中間位置為1,對應的元素為4

又通過比較,發現5大於搜尋出來的4,所以在[2,3)位置繼承查詢,計算出中間元素的位置為2,對應的元素為8

通過比較,發現5是小於8的。則往左邊找,但是現在的搜尋範圍為[2,2),即begin == end,是不合理的。但是恰巧,begin == end的位置就是要插入的位置。

所以結合插入排序,對二分搜尋進行改進後的結果為

public static int search(int[] array,int v) {
    if (array == null || array.length == 0) return -1;
    int begin = 0;
    int end = array.length;
    while (begin < end) {
        int mid = (begin + end) >> 1;
        if (v < array[mid]) {
            end = mid;
        } else {
            begin = mid + 1;
        }
    }
    return begin;
}
複製程式碼

利用改進後的思路,對插入排序進行優化後為

protected void sort() {
     for (int begin = 1; begin < array.length; begin++) {
        int insertIndex = search(begin);
        E v = array[begin];
         for (int i = begin; i > insertIndex; i--) {
             array[i] = array[i - 1];
         }
         array[insertIndex] = v;
     }
}

private int search(int index) {
    if (array == null || array.length == 0) return -1;
    int begin = 0;
    int end = index;
    while (begin < end) {
        int mid = (begin + end) >> 1;
        if (cmp(array[index],array[mid]) < 0) {
            end = mid;
        } else {
            begin = mid + 1;
        }
    }
    return begin;
}
複製程式碼

利用該優化,新建了一個InsertionSort3類,利用該類與前面的幾種演演算法進行比較。結果如下

可以看到,優化後的效能進一步得到了提升,並且比較的次數極少。所以InsertionSort3相對於InsertionSort2來講,優化的地方在較少了比較次數。

經過優化後,效率得到了提升,不過需要注意,使用二分搜尋,只是減少了比較次數,但是插入排序的平均時間複雜度依然是O(n^2)

demo下載地址

完!