翻譯:瘋狂的技術宅 medium.com/@jimrotting…
插入排序的工作原理是選擇當前索引 i 處的元素,並從右向左搜尋放置專案的正確位置。
實現插入排序
插入排序是一種非常簡單的演算法,最適合大部分已經被排好序的資料。在開始之前,通過視覺化演示演算法如何運作一個好主意。你可以參考前面的動畫來了解插入排序的工作原理。
演算法的基本思想是一次選擇一個元素,然後搜尋並插入到正確的位置。由此才有了這個名字:插入排序。這種操作將會導致陣列被分為兩個部分 —— 已排序部分和未排序的元素。有些人喜歡把它描繪成兩個不同的陣列 —— 一個包含所有未排序的元素,而另一個的元素是完全排序的。但是將其描述為一個陣列更符合程式碼的工作方式。
先來看看程式碼,然後再進行討論。
const insertionSort = (nums) => {
for (let i = 1; i < nums.length; i++) {
let j = i - 1
let tmp = nums[i]
while (j >= 0 && nums[j] > tmp) {
nums[j + 1] = nums[j]
j--
}
nums[j+1] = tmp
}
return nums
}
複製程式碼
在插入排序的程式碼中有兩個索引:i
和 j
。 i
用來跟蹤外迴圈並表示正在排序的當前元素。它從 1 開始而不是0,因為當我們在新排序的陣列中只有一個元素時,是沒有什麼可做的。所以要從第二個元素開始,並將它與第一個元素進行比較。第二個索引 j
從 i-1
開始,從右往左迭代,一直到找到放置元素的正確位置。在此過程中,我們將每個元素向後移動一個位置,以便為要排序的新元素騰出空間。
這就是它的全部過程!如果你只是對實現感興趣,那你就不用再看後面的內容了。但如果你想知道怎樣才能正確的實現這個演算法,那麼請繼續往下看!
檢查迴圈不量變條件
為了確定演算法是否能夠正常工作而不是恰好得出了給定輸入的正確輸出,我們可以建立一組在演算法開始時必須為真的條件,在演算法結束時,演算法的每一步都處於條件之中。這組條件稱為迴圈不變數,並且必須在每次迴圈迭代後保持為真。
迴圈不變數並不是總是相同的東西。它完全取決於演算法的實現,是我們作為演算法設計者必須確定的。在例子中,我們每次迭代陣列中的一個元素,然後從右向左搜尋正確的位置以插入它。這將會導致陣列的左半部分(到當前索引為止)始終是最初在該陣列切片中找到的元素的排序排列。換一種說法是
插入排序的迴圈不變數表示到當前索引的所有元素“A [0..index]”構成在我們開始排序前最初在“A [0..index]”中找到的元素的排列順序。
要檢查這些條件,我們需要一個可以在迴圈中呼叫的函式,該函式作為引數接收:
- 新排序的陣列
- 原始輸入
- 當前的索引。
一旦有了這些,就能將陣列從 0 開始到當前索引進行切片,並執行我們的檢查。第一個檢查是新陣列中的所有元素是否都包含在舊陣列中,其次是它們都是有序的。
//用於檢查插入排序迴圈不變的函式
const checkLoopInvariant = (newArr, originalArr, index) => {
//need to slice at least 1 element out
if (index < 1) index = 1
newArr = newArr.slice(0,index)
originalArr = originalArr.slice(0, index)
for (let i=0; i < newArr.length; i++) {
//check that the original array contains the value
if (!originalArr.includes(newArr[i])) {
console.error(`Failed! Original array does not include ${newArr[i]}`)
}
//check that the new array is in sorted order
if (i < newArr.length - 1 && newArr[i] > newArr[i+1]) {
console.error(`Failed! ${newArr[i]} is not less than ${newArr[i+1]}`)
}
}
}
複製程式碼
如果在迴圈之前、期間和之後呼叫此函式,並且它沒有任何錯誤地通過,就可以確認我們的演算法是正常工作的。修改我們的程式碼以包含此項檢查,如下所示:
const insertionSort = (nums) => {
checkLoopInvariant(nums, input, 0)
for (let i = 1; i < nums.length; i++) {
...
checkLoopInvariant(nums, input, i)
while (j >= 0 && nums[j] > tmp) {
...
}
nums[j+1] = tmp
}
checkLoopInvariant(nums, input, nums.length)
return nums
}
複製程式碼
注意下圖中在索引為2之後的陣列狀態,它已對3個元素進行了排序。
如你所見,我們已經處理了3個元素,前3個元素按順序排列。你還可以看到已排序陣列的前3個數字與原始輸入中的前3個數字相同,只是順序不同。因此保持了迴圈不變數。
分析執行時間
我們將要使用插入排序檢視的最後一件事是執行時。執行真正的執行時分析需要大量的數學運算,你可以很快找到自己的雜草。如果你對此類分析感興趣,請參閱Cormen的演算法導論,第3版。但是,就本文而言,我們只會進行最壞情況的分析。
插入排序的最壞情況是輸入的陣列是按逆序排序的。這意味著對於我們需要迭代每個元素,並在已經排序的元素中找到正確的插入點。外部迴圈表示從 2 到 n 的總次數(其中 n 是輸入的大小),並且每次迭代必須執行 i-1
次操作,因為它從 i-1
迭代到零。
這個結論的證明超出了本文的範圍。老實說,我只是將它與最佳情況進行比較,其中元素已經排序,因此每次迭代所需要的時間都是固定的......
就 big-O 表示法而言,最壞情況是 Ɵ(n²),最好的情況是Ɵ(n)。我們總是採用最壞情況的結果,因此整個演算法的複雜度是Ɵ(n²)。
總結
當輸入的陣列已經大部分被排好序時,插入排序的效果最佳。一個好的程式應該是將一個新元素插入已經排好序的資料儲存中。即便是你可能永遠不必編寫自己的排序演算法,並且其他型別(例如歸併排序和快速排序)更快,但是我認為用這種方式去分析演算法的確很有趣。