24-插入排序(Insertion Sort)
插入排序(Insertion Sort)
插入排序,非常類似於撲克牌的排序,相信各位讀者,都有玩過撲克牌,如逢年過節可能會和親朋好友一起鬥地主,當我們拿到牌以後,一般都會對牌進行排序,這樣會比較方便出牌。例如現在手裡有2,4,5,104張牌,當摸到一張7的時候,就會把7插入下圖中的位置。將7插入合適的位置後,保證原來的序列有序,這就叫做插入排序。
插入排序的執行流程
- 在執行過程中,插入排序會將序列分為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需要交換位置,得到下面的結果
重複依次排序,最終的結果為
通過觀察可以發現,當新拿到一個待排序的元素時,該元素總是要被插入到最前面,這樣需要做的交換操作是最多的,並且以現在的演演算法的話,是一個一個的比較進行交換的,所以像這種情況,時間複雜度肯定是最高的。
所以插入排序有以下的結論
- 最壞,平均時間複雜度為:O(n^2)
- 最好時間複雜度為:O(n)【沒有逆序對的時候】
- 空間複雜度:O(1)
- 屬於穩定排序
當逆序對的數量極少時,插入排序的效率特別高,甚至速度比O(nlogn)級別的快速排序還要快
資料量不是特別大的時候,插入排序的效率也是非常好的
優化
由於發現現在的程式碼中,比較後都需要進行交換,並且不一定一次就能交換到最期望的位置,所以可以想想,是否能將交換轉為挪動呢?是可以的。操作步驟如下
- 先將待插入的元素備份;例如現在需要將下圖中的下標尾5的元素插入到合適的位置,這個時候,先將該元素內容進行備份
- 頭部有序資料中比待插入元素大的,都往尾部方向挪動一個位置
- 將待插入的元素,放到最終合適的位置
為什麼這種就可以優化呢?如果按照以前的思路,交換一對元素,需要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)
那二分搜尋,具體是怎麼去工作的呢?現在通過一個下圖來統一表示二分搜尋
- 圖片中線條長度表示陣列長度
- m表示中間的元素,其下標尾mid
- 所以m左邊部分的元素大小都是≤m的
- m右邊部分的元素大小都是≥m的
- begin表示最前面元素的索引
- 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)
完!