卷演算法——排序

悲劇不上演發表於2022-01-08
  • 原地排序:特指時間複雜度為 O(1) 的排序演算法。

  • 穩定性:如果待排序的資料中存在相同的資料,如 [{"name":"cx", "age": 18}, {"name":"cx3", "age":18}], 我們根據年齡排序這兩個資料都等於 18。 如果排序之後的這兩個資料順序沒有發生變化則稱為該演算法具有穩定性。

  • 有序度:一組資料有中有多少對能滿足 i<j ; a[i] < a[j]。如 2,3,4,1 這樣一組資料中,有序度分別為 2,3, 2,4, 3,4

  • 滿有序度:所有的資料都是有序的。如 1,2,3,4,值為 n*(n-1)/2

    (n-1)+(n-2)+(n-3)+...+1+0 = \\ \frac { n \times(n-1)}2

  • 逆序度:與有序度相反,組資料有中有多少對能滿足 i< j ; a[i] > a[j]。逆有序度 = 滿有序度 - 有序度

1. 氣泡排序(Bubble Sort)

氣泡排序只會操作相鄰的兩個資料,每次冒泡操作都會對相鄰的兩個元素比較。看是否滿足大小關係要求。如果不能滿足則交換。一次冒泡至少讓一個資料放到它該放到位置。重複 n 次 即可實現資料排序

  • 過程詳解

    file

  • 程式碼段

    package main
    
    import "fmt"
    
    func main() {
        data := [...]int{4, 5, 6, 3, 2, 1}
        BubbleSort(&data)
        fmt.Println(data)
        data2 := [...]int{1, 2, 3, 4, 5, 6}
        BubbleSort(&data2)
        fmt.Println(data2)
    }
    
    func BubbleSort(data *[6]int)  {
        for j := 0; j < len(data); j++ {
            // 為啥是 len(data)-j-1
            //    因為後面有的資料已經拍好序了
            // 比如說第一次冒泡後 a[5] = 6 這個已經確定了。所以迴圈比較的時候就不用比較a[5]
            flag := false
            for i := 0; i < len(data)-j-1; i++ {
                if data[i] > data[i+1] {
                    data[i+1], data[i] = data[i], data[i+1]
                    flag = true
                } else {
                    flag = false
                }
            }
    
            fmt.Printf("第%d次氣泡排序後的資料:%v\n", j+1, data)
            if !flag {
                break
            }
        }
    }
    
  • 是否是原地排序

    是,空間複雜度為 O(1)

  • 是否是穩定排序

    是。因為想兩個資料相等時候沒有發生交換。

  • 時間複雜度

    • 最好時間複雜度 O(n), 因為已經排好序了

    • 最壞時間複雜度O(n^2), 需要進行 n 次冒泡

    • 平均時間複雜度 O(n^2),推導公式

      \\ 滿有虛度 = \frac { n \times(n-1)}2 \\ 初始有序度:[0,\frac { n \times(n-1)}2],取中間值 \frac { n \times(n-1)}4

      交換次數 = 逆序度 = 滿有序度 - 初始有序度 \\ = \frac { n \times(n-1)}2 - \frac { n \times(n-1)}4 \\ = \frac { n \times(n-1)}4 \\ = O(n^2)


2. 插入排序(Insert Sort)

  • 思路

    1. 將陣列分為兩個區間。已排序區和未排序區,已排序區初始只有第一個元素。
    2. 取未排序陣列區間中的元素,在已排序區間中找到合適的位置插入,並保證一直有序
    3. 重複上述過程,直到資料取完
  • 過程圖

    file

  • 程式碼

      package main
    
      import "fmt"
    
      func main() {
          data := [...]int{4, 5, 6, 3, 2, 1}
          InsertSort(&data)
          fmt.Println(data)
      }
    
      func InsertSort(data *[6]int) {
          for i := 1; i < len(data); i++ {
              t := data[i]
              j := i - 1
              for ; j >= 0; j-- {
                  // 大於這個數了,證明找到了這個數的正確插入位置
                  if t > data[j] {
                      data[j+1] = t
                      break
                  } else {
                      // 複製資料
                      // 比如將a[5] = a[4], 因為最後一個元素是沒有用的
                      data[j+1] = data[j]
                  }
              }
    
              // 即使沒有找到,j = -1,這個時候就應該給 data[0] 賦值
              data[j+1] = t
    
          }
      }
    
  • 是否是原地排序

    是,空間複雜度為 O(1)

  • 是否是穩定排序

    是。假設有相同的資料,我們可以將其插入到上一個元素前面,因為我們是順序遍歷。

  • 時間複雜度

    • 最好時間複雜度 O(n), 因為已經排好序了
    • 最壞時間複雜度O(n^2), 資料完全是逆序
    • 平均時間複雜度 O(n^2),往陣列中插入一個元素平均複雜度 O(n), 我們需要插入 n 次。就是 n^2

3. 選擇排序(Select Sort)

  • 思路

    1. 將陣列分為兩個區間。已排序區和未排序區
    2. 選擇排序每次從未排序的區間找到最小的元素,將其放到有序區間的末尾
    3. 重複上述過程,直到資料取完
  • 過程圖

    file

  • 程式碼

    package main
    
    import "fmt"
    
    func main() {
        data := [...]int{4, 5, 6, 3, 2, 1}
        SelectSort(&data)
        fmt.Println(data)
    }
    
    func SelectSort(data *[6]int) {
        // i 表示有序集合的指標
        for i := 0; i < len(data) - 1; i++ {
            min := i
            // j 表示無序集合的指標
            for j := i + 1; j < len(data); j++ {
                if data[min] > data[j] {
                    min = j
                }
            }
    
            // 交換資料
            data[min], data[i] = data[i], data[min]
            fmt.Printf("第%d交換排序後的資料%v\n", i+1, data)
        }
    }
    
  • 是否是原地排序

    是,空間複雜度為 O(1)

  • 是否是穩定排序

    否。假設有相同的資料,我們在交換的過程中就會導致其位置發生了變化

  • 時間複雜度

    複雜度 O(n^2)


4. 歸併排序(Merge Sort)

  • 思路

    思想就是分治思想。就是把一組資料分為兩部分,然後在對這兩部分進行歸併排序。再將排好序的兩部分合為一個,然後進行合併。這樣整個資料就有序了。公式如下:

    tmp = \frac {(start - end)} {2} \\ result = merge(S(0,tmp),S(tmp+1,end)) \\ S(0,tmp) = merge(S(0,\frac {tmp}2),S(\frac {tmp}2+1,tmp)) \\ S(0,\frac {tmp}2) = merge(S(0,\frac {tmp}4),S(\frac {tmp}4+1,{tmp}2)) \\ ... \\ 直到 S 中只有一個元素的時候,我們就可以將其帶入上面的表示式層層計算

  • 過程圖解

    file

  • 程式碼段

    package main
    
    import (
        "fmt"
    )
    
    func main() {
        data := []int{6, 5, 10, 3, 2, 1}
        mergeSort(0, len(data)-1, data)
        fmt.Println(data)
    }
    
    func mergeSort(start, end int, data []int) {
        if start >= end {
            return
        }
    
        mid := (start + end) / 2
        mergeSort(start, mid, data)
        mergeSort(mid+1, end, data)
        merge(data, start, mid, end)
    
    }
    
    func merge(data []int, start, mid, end int) {
        i, j, k := start, mid+1, 0
        result := make([]int, end-start+1)
        for ; i <= mid && j <= end; k++ {
            if data[i] > data[j] {
                result[k] = data[j]
                j++
            } else {
                result[k] = data[i]
                i++
            }
        }
    
        for ; i <= mid; i++ {
            result[k] = data[i]
            k++
        }
    
        for ; j <= end; j++ {
            result[k] = data[j]
            k++
        }
    
        copy(data[start:end+1], result)
    
    }
    
  • 是否是原地排序

    否,空間複雜度為 O(n)

  • 是否是穩定排序

    是。因為當資料相同的時候 merge 可以保證前後順序不變

  • 時間複雜度O(nlog n)

    已知:C是常量:T_{(1)} = C \\ 遞迴的公式為: T_{(n)} = T_{(a)} + T_{(b)} + K \\ 在歸併排序中 k = merge 函式時間複雜度 = O_{(n)} \\ 帶入到遞迴公式: \\ T_{(n)} = 2 \times T_{(\frac n 2)} + n \\ = 2 \times (2 \times T_{(\frac n 4)} + \frac n 2) + n =4 \times T_{(\frac n 4)} + 2n \\ = 4 \times (2 \times T_{(\frac n 8)} + \frac n 4) + 2n = 8 \times T_{(\frac n 8)} + 3n \\ = 8 \times ( 2 \times T_{(\frac n {16})} + \frac n 8) + 3n = 16 \times T_{(\frac n {16})} +4n \\ = ... \\ = 2^k \times T_{(\frac n {2^k})} + kn \\ \because T_{(1)} = C \\ \therefore T_{(1)} = T_{(\frac n {2^k})} \\ \therefore k = log_2 n \\ 帶入上面公式 T_{(n)} = 2^k \times T_{(\frac n {2^k})} + kn \\ T_{(n)} = n \times C + log_2 n \times n \\ \therefore 時間複雜度為 O_{(nlog n)}


5. 快速排序(Quick Sort)

  • 思路

    ​ 利用的也是分治思想。

    1. 如果要排序的陣列中下標P到R之間的一組資料,任意選擇一個資料作為 pivot(分割槽點)
    2. 遍歷P到R,小於 pivot 在左邊,大於 pivot 在右邊。
    3. 遞迴左邊區域繼續執行1-2,遞迴右邊區域執行1-2。直到資料取完
    4. 完成
  • 過程圖

    file

  • 原地 partition 過程圖

    file

  • 程式碼

    package main
    
    import "fmt"
    
    func main() {
        data := []int{8, 10, 2, 3, 6, 1, 5}
        QuickSort(data, 0, len(data)-1)
        fmt.Println(data)
    }
    
    func QuickSort(data []int, p, r int) {
        if p >= r {
            return
        }
    
        index := partitionV2(data, p, r)
        if index > 0 {
            QuickSort(data, p, index-1)
    
        }
    
        if index < len(data)-1 {
            QuickSort(data, index+1, r)
        }
    }
    
    // 分割槽函式,通過分割槽點,將[p,r] 之間的資料排列為 [<pivot, pivot, > pivot]
    // 原地函式
    func partitionV2(data []int, p int, r int) int {
        pivot := data[r]
        i := p
        for j := p; j < r; j++ {
            if data[j] < pivot {
                // 交換 I J
                data[i], data[j] = data[j], data[i]
                i++
            }
        }
    
        // 交換分割槽點
        data[i], data[r] = data[r], data[i]
        fmt.Printf("分割槽[%d,%d]完成,分割槽點為%d\n", p, r, i)
        return i
    }
    
    // 分割槽函式,通過分割槽點,將[p,r] 之間的資料排列為 [<pivot, pivot, > pivot]
    // 建立兩個臨時變數來滿足
    func partition(data []int, p, r int) int{
        pivot := data[r]
        a := make([]int, 0, r-p+1)
        b := make([]int, 0, r-p+1)
    
        for i := p; i <= r-1; i++ {
            if data[i] < pivot {
                a = append(a, data[i])
            } else {
                b = append(b, data[i])
            }
        }
    
        i := p
        for ; i < len(a)+p; i++ {
            data[i] = a[i-p]
        }
    
        data[i] = pivot
        for j := r; j > r-len(b); j-- {
            data[j] = b[r-j]
        }
    
        fmt.Printf("分割槽[%d,%d]完成,分割槽點為%d\n", p, r, i)
    
        return i
    }
    
  • 是否是原地排序

    是,partitionV2 為原地函式

  • 是否是穩定排序

    否。

  • 時間複雜度

    • 最好時間複雜度 O(nlog n), 推導公式如歸併排序。我們每次選擇的分割槽點將其一分為二
    • 最壞時間複雜度O(n^2), 當資料完全有序的時候,每次都選擇最後一個分割槽點
    • 平均時間複雜度O(nlog n)

6. 桶排序(Bucket Sort)

  • 思路

    ​ 將要排序的資料分到幾個有序的桶裡。每個桶裡的資料在進行快速排序。桶內排完序之後,再把每個桶裡的資料按照順序依次取出。組裝的序列就是有序的。(如果桶內分佈不可將大桶在進行分解)

  • 使用場景

    1. 要排序的資料很容易劃分到 m 桶裡
    2. 桶與桶之間有著天然的的大小順序(如下圖)
    3. 資料在各個桶之間分佈均勻
  • 過程圖

    file

  • 程式碼段

  package main

  import "fmt"

  func main() {
      data := []int{7, 8, 9, 25, 20, 29, 40, 49, 48, 47, 42, 44, 35, 15, 13, 19}
      BucketSort(data)
      fmt.Println(data)
  }

  // 獲取待排序陣列中的最大值
  func getMax(a []int) int {
      max := a[0]
      for i := 1; i < len(a); i++ {
          if a[i] > max {
              max = a[i]
          }
      }
      return max
  }

  // 獲取待排序陣列中的最小值
  func getMin(a []int) int {
      min := a[0]
      for i := 1; i < len(a); i++ {
          if a[i] < min {
              min = a[i]
          }
      }
      return min
  }

  // 桶排序
  func BucketSort(data []int) {
      max := getMax(data)
      min := getMin(data)
      // 桶數量等於資料長度
      bucketNum := len(data)


      // 1. 建立 bucketNum 個桶
      bucket := make([][]int, bucketNum)

      // 2. 將資料放入桶中
      for i := 0; i < len(data); i++ {
          // 桶下標
          // 桶與桶之間區間跨度 d 公式 (最大數-最小數)/(桶數量 - 1)
          // 桶數量 - 1 是因為最大數佔了一個桶
          // 所有下標等於每個元素的偏移量(data[i] - min) / (d) = (data[i] - min) / ((最大數-最小數)/(桶數量 - 1)) = (data[i] - min) * (桶數量 - 1) / (最大數-最小數)
          index := (data[i] - min) * (bucketNum - 1) / (max - min)
          bucket[index] = append(bucket[index], data[i])
      }

      // 3. 桶內排序,這裡我們採用快排
      for i := 0; i < bucketNum; i++ {
          quitSortByArray(bucket[i])
      }

      // 4. 取出桶內資料然後進行輸出
      result := make([]int, 0, len(data))
      for i := 0; i < bucketNum; i++ {
          result = append(result, bucket[i]...)
      }

      copy(data, result)
  }

  func quitSortByArray(data []int) {
      QuickSort(data, 0, len(data) - 1)
  }

  func QuickSort(data []int, p, r int) {
      if p >= r {
          return
      }

      index := partition(data, p, r)
      if index > 0 {
          QuickSort(data, p, index-1)

      }

      if index < len(data)-1 {
          QuickSort(data, index+1, r)
      }
  }

  // 分割槽函式,通過分割槽點,將[p,r] 之間的資料排列為 [<pivot, pivot, > pivot]
  // 原地函式
  func partition(data []int, p int, r int) int {
      pivot := data[r]
      i := p
      for j := p; j < r; j++ {
          if data[j] < pivot {
              // 交換 I J
              data[i], data[j] = data[j], data[i]
              i++
          }
      }

      // 交換分割槽點
      data[i], data[r] = data[r], data[i]
      fmt.Printf("分割槽[%d,%d]完成,分割槽點為%d\n", p, r, i)
      return i
  }
  • 時間複雜度 O(n)

    已知:n 個元素, m個桶,每個桶的元素 k \\ \therefore k = \frac n m \\ \therefore T_{(n)} = m \times (k \times \log k) \\ = m \times (\frac n m \times \log {\frac n m}) \\ =n \times \log {\frac n m} \\ \because 當 桶的個數與元素 n 無限接近的時候 ,\frac n m 等於常量 \\ \therefore T_{(n)} = n \\ \therefore 時間複雜度:O(n)

參考連結

漫畫:什麼是桶排序?


7. 計數排序(Count Sort)

  • 思路

    1. 將所有的數值設定為每一個獨立的桶,一個桶存的就是等於這個資料的人數,eg: 桶 n 存的就是小於等於 n 的值的數量
    2. 然後遍歷原始資料,給該桶填充資料。
    3. 然後在遍歷原始資料,每一個元素都對應一個桶,桶中的值就是該元素在結果集中的位置,然後減一
  • 應用場景

    1. 資料範圍不大
  • 過程圖

    file

  • 程式碼段

    package main
    
    import "fmt"
    
    func main() {
        data := []int{2, 5, 3, 0, 2, 3, 0, 3}
        countSort(data)
        fmt.Println(data)
    }
    
    func countSort(data []int) {
        // 1. 尋找最大元素
        max := data[0]
        for i := 1; i < len(data); i++ {
            if data[i] > max {
                max = data[i]
            }
        }
    
        // 2. 組裝桶資料
        countBucket := make([]int, max+1)
        for i := 0; i < len(data); i++ {
            countBucket[data[i]]++
        }
        for i := 1; i < len(countBucket); i++ {
            countBucket[i] += countBucket[i-1]
        }
    
        // 3.計算結果資料
        result := make([]int, len(data))
        for i := 0; i < len(data); i++ {
            countBucket[data[i]]--
            result[countBucket[data[i]]] = data[i]
        }
    
        copy(data, result)
    }
    
  • 時間複雜度 O(n)

8. 基數排序(Radix Sort)

  • 思路

    ​ 對要排序的資料是要求,要求可以分割出獨立的”位“來比較,而且位之間有遞進關係,如果資料a的高位比b大,那麼剩下的位就不用比較了。而且,為每一位的資料範圍不能太大,要可以用線性排序演算法來排序(必須是穩定排序演算法)。這樣時間複雜度就可以做到 O(n)

  • 應用場景

    1. 可以按位比較(如手機號)
    2. 位內的資料範圍不大可使用線性排序
  • 過程圖

  • 程式碼

  • 時間複雜度 O(n)


排序對比

排序演算法 原地排序 穩定排序 (最好,最壞,平均)時間複雜度 說明
氣泡排序 Y Y O(n) , O(n^2), O(n^2) 效率不高,適用資料量少
插入排序 Y Y O(n) , O(n^2), `O(n^2) 效率不高,適用資料量少
選擇排序 Y N O(n^2) 效率不高,適用資料量少
歸併排序 N Y O(n log n) 適用與資料量多,但是由於不是原地排序,不常用
快速排序 Y N O(n log n) , O(n^2), O(n log n) 快速排序的效率幾乎都接近於 O(n log n)
桶排序 N Y看桶內演算法 O(n) 使用限制非常多
計數排序 N Y O(n+k) k是資料範圍 資料範圍不能大,數值都可以用標下表示。如 年齡k=0-100
基數排序 N Y O(dn) d是維度 如手機號例子中d時表示11位

優化排序

優化快速排序

優化分割槽點

​ 我們知道快排的時間複雜度有可能退化到 O(n^2), 就是資料近乎有序的時候,我們選擇分割槽點還是選擇第一位、或者是最後一位這種思路。要想保證快排的時間複雜度不會退化太差,因此我們需要合理的選擇分割槽點

  1. 三數取中

    從區間的首尾中三部分取出三個資料比較大小,中間的資料作為分割槽點。這樣只比單純取一個數要好。當資料量多的時候我們可以 五數取中、時數取中

  2. 隨機法

    每次要排序的資料中隨機選擇一個元素作為分割槽點,從概率上來看,不太可能出現分割槽點都很差的情況

遞迴小心堆疊溢位

​ 這也是我們在遞迴中描述的方案:設定最大遞迴層數。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章