演算法基礎--時間複雜度,三個常規O(N²)的排序演算法(冒泡、選擇、插入)

本文只是自己的筆記,並不具備任何指導意義。
程式碼的初衷是便於理解,網上大神優化過的程式碼很多,也不建議在專案中copy本文程式碼。
目錄
- 時間複雜度
- 常數時間的操作
- 時間複雜度的計算
- 常數操作表示式型別的時間複雜度
- 時間複雜度相同的比對
- 氣泡排序
- 改進版的氣泡排序
- 選擇排序
- 二元選擇排序
- 插入排序
- 時間複雜度的最差情況,最好情況,平均情況
- 對數器
ofollow,noindex">時間複雜度
衡量程式碼的好壞,包括兩個非常重要的指標:執行時間與佔用空間。
而時間複雜度正代表前者,後者由 空間複雜度 ( 即演算法在執行過程中臨時佔用儲存空間大小的量度 )表示。
-
常數時間的操作
一個操作如果和資料量沒有關係,每次都是固定時間內完成的操作,叫做常數操作。
比如陣列下標的定址,一對下標交換。
-
常數運算元量
單次常數時間的操作,寫作做O(1)。讀作big O 1 。
-
時間複雜度的計算
法中基本操作重複執行的次數是問題規模n的某個函式,用T(n)表示。
若有一個函式,f(N)。當N趨近於無窮大時,使得T(n)/f(n)趨近於一個不為0的常數。
則稱f(n)是T(n)的同數量級函式。記作T(n)=O(f(n)),稱O(f(n)) 為演算法的漸進時間複雜度,簡稱時間複雜度。
-
線性的時間複雜度
比如一個演算法共需要執行N次迴圈,每次迴圈內部都是常數操作O(1)
for i in 1..<N+1 { //常數操作 let firstItem = arr[i-1] let secondItem = arr[i] if firstItem > secondItem { arr.swapAt(i-1, i) } }
的T(N)=F(N)=N,時間複雜度為O(F(N))=O(N)。
-
常數操作表示式型別的時間複雜度
對於T(N)為達式型別的時間複雜度,F(N)簡而言之就是要簡化成當N趨近無窮大時,表示式中對其影響最大的一項的表示式。
具體來說。在常數運算元量的表示式中, 只要高階項,不要低階項,也不要高階項的係數,剩下的部分 如果記為f(N),那麼時間複雜度為O(f(N))。
借用百度百科上的例子:
for(i=1; i<=n; ++i) { c[i];//該步驟屬於基本操作執行次數:n for(j=1; j<=n; ++j) { c[i][j] = 0;//該步驟屬於基本操作執行次數:n的平方次 for(k=1; k<=n; ++k) c[i][j] += a[i][k] * b[k][j];//該步驟屬於基本操作執行次數:n的三次方次 } }
T(N) = A×N³+B×N²+C×N。當N趨近於無窮大時,三次方的影響遠大於二次方以及一次方。當然也大於常數項A的影響。
所以表示式f(N)=N³。
時間複雜度為O(N)=O(f(N))=O(N³)
領附一張圖方便理解高階項在基數變大時的變化:

-
時間複雜度相同的比對
評價一個演算法流程的好壞,先看時間複雜度的指標,然後再分
析不同資料樣本下的實際執行時間,也就是常數項時間。
氣泡排序
在氣泡排序過程中會將陣列分成兩部分,前半部分是無序的數列,後半部分為有序的數列。無序數列中不斷的將其中最大的值往有序序列中冒泡,泡冒完後,我們的序列就建立好了。
具體操作上,如果相鄰的兩個數字前者較大,則將二者交換,到達無序陣列邊界則停止。

func bubbleSort(arr: inout [Int]) { if arr.count < 2 { return } for i in 0..<arr.count { for j in 0..<arr.count - (i+1) { if arr[j] > arr[j+1] { let temp = arr[j] arr[j] = arr[j+1] arr[j+1] = temp } } } }
時間複雜度O(N²),額外空間複雜度O(1)。
時間複雜度的來源f(N) = N +( N -1) + (N-2) + ...+ 2 + 1 為一個 等差數列 。前N項和的通用公式為:N*(N-1)/2化簡後f(N)=N²。
-
改進版的氣泡排序
經典的氣泡排序,無論陣列是否已經有序。都會一次次的遍歷,從這一點上我們可以進行改進
func bubbleSort2(arr: inout [Int]) { if arr.count < 2 { return } var swapped = false //記錄是否有交換動作的變數 for i in 0..<arr.count { swapped = false for j in 0..<arr.count - (i+1) { if arr[j] > arr[j+1] { let temp = arr[j] arr[j] = arr[j+1] arr[j+1] = temp swapped = true //有交換動作則記錄 } } if swapped == false { break //沒有交換動作,說明已經有序 } } }
極端情況下(對於一個已經有序的陣列),演算法完成第一次外層迴圈後就會返回。
實際上只發生了 N - 1次比較,所以最好的情況下,該演算法複雜度是O(N)。
選擇排序
基本思想與氣泡排序相同。前半部分為序的數列,只不過後半部分是無序的數列。無序數列中不斷的將其中最大的值往有序序列中冒泡,泡冒完後,我們的序列就建立好了。
具體操作上,每次遍歷記錄無序序列中最小值的位置,並在結束時與無序序列的首位置交換,使其變成有序序列的最後一位。

func selectionSort(arr : inout [Int]) { if arr.count<2 { return } var minIndex :Int for i in 0..<arr.count { minIndex = i for j in i+1..<arr.count { //迴圈從i+1開始,也就是無序陣列的第二位開始 minIndex = arr[j]<arr[minIndex] ? j:minIndex //比對當前位置與記錄位置的值,記錄其中最小的。 } arr.swapAt(i, minIndex) //將無序陣列的第一位與最小一位交換 } }
-
二元選擇排序
選擇排序本身沒有什麼可改進的,但是我們可以左右開弓。
將序列分成三個部分,前段有序部分,中段無序部分,後端有序部分。
每次迴圈,將最大值與最小值分別置入前後兩個有序序列。
func selectionSort2(arr : inout [Int]) { if arr.count<2 { return } var minIndex :Int var maxIndex :Int for i in 0..<arr.count { minIndex = i maxIndex = i if i+1 >= arr.count - i { return // 由於這一步的存在,實際上會在i=arr.count/2處結束迴圈 } for j in i+1..<arr.count - i { //迴圈從i+1開始,也就是無序陣列的第二位開始。並且在後端有序序列的前一位停止 minIndex = arr[j]<arr[minIndex] ? j:minIndex //比對當前位置與記錄位置的值,記錄其中最小的。 maxIndex = arr[j]>arr[maxIndex] ? j:maxIndex //比對當前位置與記錄位置的值,記錄其中最大的。 } if maxIndex == i && minIndex == arr.count - (i+1) { //如果最大值與最小值恰好處於邊界,直接交換會導致亂序。需要手動賦值 let maxValue = arr[maxIndex]; let minValue = arr[minIndex]; let maxToValue = arr[arr.count - (i+1)] let minToValue = arr[i] arr[maxIndex] = maxToValue arr[arr.count - (i+1)] = maxValue arr[minIndex] = minToValue arr[i] = minValue }else if maxIndex == i{ //如果最大值位置處於最小值將要交換的位置,先交換最大值 arr.swapAt(arr.count - (i+1) , maxIndex) //將無序陣列的最後一位與最大一位交換 arr.swapAt(i, minIndex) //將無序陣列的第一位與最小一位交換 }else{ arr.swapAt(i, minIndex) //將無序陣列的第一位與最小一位交換 arr.swapAt(arr.count - (i+1) , maxIndex) //將無序陣列的最後一位與最大一位交換 } } }
這樣雖然複雜度還是O(N²),但實際上的表示式係數比經典選擇排序不止縮小了1/2。
插入排序
基本思想也是前半部分為序的數列,後半部分是無序的數列。無序數列不斷將其首位元素推給有序數列,有序數列將其插入適當的位置。
具體操作上,會從有序數列的尾部依次向前比較,若前位大於後位則進行交換。

func insertionSort(arr : inout [Int]) { if arr.count<2 { return } for i in 1..<arr.count { //無序陣列從i=1到末尾 for j in (0...i-1).reversed() {//從 i-1 位置到 0位置的有序陣列內進行迴圈 if arr[j+1] > arr[j] {//j+1當第一次執行的時候,正位於無序陣列的首位置 break //如果後位置大於前位置,說明已經有序。退出當前迴圈 } arr.swapAt(j, j+1)//否則交換 } } }
改進的話,或許可以試試用二分法確定具體位置然後進行整體後移並插入。
時間複雜度的最差情況,最好情況,平均情況
對於插入排序這種有明確終止條件的排序,實際的時間複雜度與資料的實際狀況有關。
最好情況是最開始便有序,我們只需要執行一次大迴圈,複雜度為O(N)。
最差情況是將整個陣列倒序排列一次,複雜度為O(N²)。
平均情況是指在大數狀況下的平均期望複雜度。
在資料的實際狀況對演算法流程存在影響時,使用最差情況作為時間複雜度。
不過,我們可以利用主動打亂資料狀況影響的方式。將複雜度易數學期望的方式表達(參考隨機快排)。
對數器
對數器是用來測試程式碼正確性的,我們在找不到合適的oj系統測試自己的程式碼時,可以自己寫一個對數器對程式碼進行測試。
-
設計對數器的一般步驟為:
自己寫的方法
2.實現一個絕對正確即使複雜度不好的方法b;
系統自帶方法即可
3.實現一個隨機樣本產生器;
4.實現比對的方法;比對兩個結果最後是否一致
5.把方法a和方法b比對很多次來驗證方法a是否正確
6.如果有一個樣本使得比對出錯,列印樣本分析是哪個方法出錯
7.當樣本數量很多時比對測試依然正確,可以確定方法a已經正確
-
實現對數器
其中1,2,4已經說了。6,7也沒啥好說的。
3.實現一個隨機樣本產生器
/// 隨機陣列生成器 /// /// - Parameters: ///- size: 最大長度 ///- value: 最大值 /// - Returns: 隨機陣列 func generateRandomArray(size : Int ,value : Int) -> [Int] { var arr :[Int] arr = Array.init() for i in 0..<Int(arc4random() % 10) * size / 10{ let item = Int(arc4random() % 10)*value/10 arr.append(item) } print(arr) return arr }
-
把方法a和方法b比對很多次來驗證方法a是否正確
var checkOK = true for i in 0..<10000 { var arr1 = generateRandomArray(size: 5, value: 20) var arr2 = arr1 //陣列在swift裡屬於值型別,賦值動作會自動copy let originalArr = arr1 arr1.sort() heapSort(arr: &arr2) if arr1 != arr2 { checkOK = false print(originalArr) print(arr2) break } } print(checkOK ? "比對成功":"比對失敗") //列印 [0, 6, 2, 12, 18] [0, 6, 12, 2, 18] 比對失敗
錯誤的原始資料已經打印出來了,你就可以隨意重現這個資料進行除錯了。
var arr = [0, 6, 12, 2, 18]; print(arr) heapSort(arr: &arr) print(arr)