算法基礎--快速排序

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

爲了理解很多都使用了遞歸,而不是自己通過while進行壓棧處理。
代碼的初衷是便於理解,網上大神優化過的代碼很多,也不建議在項目中copy本文代碼。


目錄

  • 快速排序的基本思想
  • 單次遍歷,確定一個值在數組中的最終位置
    • 將小於等於給定值num的數放在數 組的左邊,大於num的數放在數組的右
    • 將數組中某個元素的值作爲基準值num,確定其在最終排序數組中的位置
  • 經典快排
    • 在每段小數組上確定最後元素的最終位置
    • 將大數組拆分成小數組
    • 經典快排的動圖
  • 經典快排的改進
    • 雙路快排
    • 三路快排
  • 基準值對快排時間複雜度的影響
    • 隨機快排
    • 將中位數作爲基準值

快速排序的基本思想

每次排序,確定一個任意值在數組中的最終位置
具體操作上:
  1. 以數組中某個值作爲基準值
  2. 遍歷數組,將小於基準值的放在左側,大於他的放在右側
  3. 最終,確定該元素在數組中的位置。
對於經典快排
  1. 繼續在該元素左側與右側重複1,2,3步驟。每次確定一個元素的位置最終確定整個數組的所有元素。

單次遍歷,確定一個值在數組中的最終位置。

遍歷數組,某個元素作爲基準值,將小於基準值的放在左側,大於他的放在右側。最終,確定該元素在數組中的位置。

  • 將小於等於給定值num的數放在數 組的左邊,大於num的數放在數組的右邊
/// 給定一個數組arr,把小於等於給定值num的數放在數 組的左邊,大於num的數放在數組的右邊。並返回其位置
///
/// - Parameters:
///   - arr: 數組
///   - num: 劃分值
/// - Returns: 最終位置
func partition0(arr: inout [Int] ,num:Int) -> Int {
    if arr.count<2 {
        return 0
    }
    var p = 0-1  //小於等於區域結束位置。
    
    for i in 0..<arr.count { //遍歷整個數組
        if arr[i]<=num { //如果小於給定的num,則擴大小於等於區域,並將其交換進該區域末尾
            p=p+1
            arr.swapAt(p, i)
        }
    }
    
    //最終,p左側爲小於等於區域,右側爲大於區域
    return p
}

需要注意小於等於區域的初始值爲-1,因爲最初並沒有任何元素被確定小於num。

  • 將數組中某個元素的值作爲基準值num,確定其在最終排序數組中的位置

既然其左側必然小於等於他,右側必然大於他。那麼他的位置一定不變。

那麼,我們只需要對上述方法進行一些小改動。比如將數組中最後一位的值作爲基準,這樣每次就能確定最後一位的最終位置。

/// 給定一個數組arr,把小於等於末尾值num的數放在數組的左邊,大於num的數放在數組的右邊。並返回其位置
///
/// - Parameters:
///   - arr: 數組
/// - Returns: 最終位置
func partition(arr: inout [Int]) -> Int {
    if arr.count<2 {
        return 0
    }
    let num = arr[arr.count-1]
    var p = 0-1  //小於等於區域結束位置。
    for i in 0..<arr.count { //遍歷整個數組
        if arr[i]<=num { //如果小於給定的num,則擴大小於等於區域,並將其交換進該區域末尾
            p=p+1
            arr.swapAt(p, i)
        }
    }
    
    //最終,p左側爲小於等於區域,右側爲大於區域
    return p
}

let num = arr[arr.count-1]所做的,就是上述將數組中最後一位的值作爲基準的操作。

對於單次遍歷,可以完成下面的結果



經典快排

  • 在每段小數組上確定最後元素的最終位置

很簡單,值需要將上面的方法添加left,right參數。在大數組中確定小數組的左右邊界即可。

/// 在一個數組的left,right範圍內。確定最後一個元素的最終位置
///
/// - Parameters:
///   - arr: 數組
///   - left: 左邊界
///   - right: 右邊界
/// - Returns: 基準元素的最終位置
func partition(arr:inout [Int] ,left:Int ,right:Int) ->Int {

    var l = left - 1  //小於等於區域末端位置
    var p = left  //遍歷的起始位置,從最左端開始
    while p < right { //保證不越界,並且遍歷的範圍不包含右側邊界位置。
        if arr[p] <= arr[right] {
            l+=1 //滿足小於等於,擴大小於等於區域
            arr.swapAt(l, p) //並將其交換進該區域末尾
        }
        p+=1
    }

    arr.swapAt(right, l+1) //最後,將右側邊界位置與大於區域首位置(p+1)交換
    return l+1; //返回最後一個值的最終位置
}
  • 將大數組拆分成小數組

partition劃分時找出的最終位置作爲再次劃分成左側與右側,要求兩個小數組繼續進行處理。

/// 快速排序
///
/// - Parameter arr: 數組
func quickSort(arr:inout [Int]) {
    quickSortProcess(arr: &arr, left: 0, right: arr.count-1)
}


/// 快速排序遞歸方法
///
/// - Parameters:
///   - arr: 大數組
///   - left: 左邊界
///   - right: 右邊界
func quickSortProcess (arr:inout [Int] ,left:Int ,right:Int) {

    if left<right { //如果右側小於左側,說明數組只有一個元素
        let p = partition(arr: &arr, left: left, right: right)
        quickSortProcess(arr: &arr, left: left, right: p-1)
        quickSortProcess(arr: &arr, left: p+1, right: right)
    }
}
需要注意的時再次劃分時,左側(left~p-1)與右側(p+1~right)已經將p位置排除在外了,因爲其的位置已經確定。
  • 經典快排的動圖

每次劃分都只確定最後一個元素的最終位置,重複進行直到整個數組有序。


經典快排的改進

  • 雙路快排

不管是當條件是大於等於還是小於等於v,當數組中重複元素非常多的時候,等於v的元素太多,那麼就將數組分成了極度不平衡的兩個部分,因爲等於v的部分總是集中在數組的某一邊,導致分割不均。
雙路快排當遇到重複元素的時候,也能近乎將他們平分開來。

簡而言之是前後兩個指針:
指針i,表示小於等於基準的區域。
指針j,表示大於等於基準的區域。

遍歷暫停的時機:
當i遇到大於等於基準的值時暫停,j遇到小於等於基準的時暫停。


此時交換arr[i]與arr[j],這是雙路快排最核心的思想。
  1. 若交換位置不處於連續重複元素區間。正好,將正確的元素放到了正確的位置。
  2. 若交換一段正好處於連續重複元素區間
    交換後另一端被交換後還會繼續遍歷,直到下一個暫停的時機,此時原本連續的重複元素之間將會穿插很多其他的值。

舉個例子:
有一個綠色的藍色的4和一個綠色的4,與基準值相同


此時交換橙色位置的4,以及黃色位置的2。
然後橙色指針繼續向右移動,被卡在7的位置。
黃色指針也繼續向左移動,被卡在綠色4的位置。


繼續交換...


綠色的4和藍色的4已經被分散到數組兩端。

這樣便保證不會出現由於經典快排中<=的邊界導致數組劃分不均的情況了。
  • 三路快排

經典快排值劃分的小於等於,大於區域,中間用一個基準值進行區分。
類似荷蘭國旗問題,我們可以將基準值以及與其相等的值劃分成一整個區域。
如此。每次將不止再確定一個值,而是幾個值的位置了

具體操作上:

改進的地方在於,右側新增了一個指針,指向大於基準值區域的首位。
最終生成一個小於區域,大於區域,剩下的差值便是等於區域。

/// 快速排序
///
/// - Parameter arr: 數組
func quickSort(arr:inout [Int]) {
    quickSortProcess(arr: &arr, left: 0, right: arr.count-1)
}

func quickSortProcess (arr:inout [Int] ,left:Int ,right:Int) {

    if left<right {
        let p = partition(arr: &arr, left: left, right: right)
        quickSortProcess(arr: &arr, left: left, right: p[0]-1) //右邊界爲小於區域首位再向前一個
        quickSortProcess(arr: &arr, left: p[1]+1, right: right)//左邊界爲大於區域首位再向後一個
    }
}

/// 三路快排
///
/// - Parameters:
///   - arr: 數組
///   - left: 左邊界
///   - right: 右邊界
/// - Returns: 數組類型,[等於區域左邊界,等於區域右邊界]
func partition(arr:inout [Int] ,left:Int ,right:Int) ->[Int] {

    var l = left - 1//小於區域,默認不在範圍內(上次的基準值)
    var r = right + 1//大於區域,默認不在範圍內(上次的基準值)
    var p = left//遍歷指針首位置
    let num = arr[right]//目標值

    while p < r { //注意這裏的r不是右邊界right,p==r則已經遍歷到大於區域了
        if arr[p] < num { //小於目標值,將小於區域擴大並將該值交換進小於區域。
            l+=1
            arr.swapAt(p, l)
            p+=1
        }else if arr[p] == num {//等於目標值,不動,繼續向下遍歷
            p+=1
        }else if arr[p] > num {//大於目標值,將大於區域擴大並將該值交換進大於區域。
            r-=1
            arr.swapAt(p, r)
            //需要注意這裏遍歷指針p沒有繼續右移,因爲當前p位置已經交換成了待定區域的某個值。需要再次判定
        }
    }

    //此時l爲小於區域末尾,r爲大於區域首部
    //等於區域位於小於區域之後一位,到大於區域之前一位
    return [l+1,r-1]
}

基準值對快排時間複雜度的影響

快速排序法事應用最廣泛的排序算法之一,最佳情況下時間複雜度是 O(nlogn)。但是最壞情況下(數組本身已經有序的情況下),每次基準值都會處於數組邊界處,時間複雜度將劣化到O(n^2)。

  • 隨機快排

將基準值位置通過隨機數的方式獲取,將複雜度的表達式轉化爲概率表達式。最終的表達式也會趨近於O(nlogn)。

這種方式與經典快排在隨機數組的情況下相差無幾,甚至由於獲取隨機數的成本速度略低於經典快排。但在出現有序數組的情況下,速度遠優於經典快排。
《快速排序與隨機化快排運行速度實驗比較》

隨機快排應該是目前最流行的快速排序。

  • 將中位數作爲基準值

來自《快速排序 改進快排的方法》
這種能將快排的時間複雜度確定在O(n^2),但是取中位數的過程究竟有多大的影響我也不確定。(目前我只會用堆來求,不妄加評論)。


參考資料

左神牛課網算法課
雙路快速排序法
經典排序之快排及其優化
快速排序與隨機化快排運行速度實驗比較
快速排序 改進快排的方法
十大經典排序算法(動圖演示)

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