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

本文只是自己的筆記,並不具備任何指導意義。

代碼的初衷是便於理解,網上大神優化過的代碼很多,也不建議在項目中copy本文代碼。


目錄

  • 時間複雜度
    • 常數時間的操作
    • 時間複雜度的計算
    • 常數操作表達式類型的時間複雜度
    • 時間複雜度相同的比對
  • 冒泡排序
    • 改進版的冒泡排序
  • 選擇排序
    • 二元選擇排序
  • 插入排序
  • 時間複雜度的最差情況,最好情況,平均情況
  • 對數器

時間複雜度

衡量代碼的好壞,包括兩個非常重要的指標:運行時間與佔用空間。

而時間複雜度正代表前者,後者由空間複雜度(即算法在運行過程中臨時佔用存儲空間大小的量度)表示。

  • 常數時間的操作

一個操作如果和數據量沒有關係,每次都是固定時間內完成的操作,叫做常數操作。
比如數組下標的尋址,一對下標交換。

  • 常數操作數量

單次常數時間的操作,寫作做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系統測試自己的代碼時,可以自己寫一個對數器對代碼進行測試。

  • 設計對數器的一般步驟爲:

1.有一個你要測的方法a; 自己寫的方法
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)

參考資料

左神牛課網算法課
時間複雜度和空間複雜度的簡單講解
選擇排序及改進方法

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章