快速排序(Quick Sort)

快速排序(Quick Sort)

一、核心思想

快速排序是实际工程中应用最广泛的排序算法,它的设计哲学可以概括为一个词------分治(Divide and Conquer)

核心思路:选一个元素作为基准(pivot),把数组分成"比 pivot 小"和"比 pivot 大"两部分,然后对这两部分各自递归排序。当子数组缩小到只有一个元素时自然有序,递归回溯后整个数组就有序了。

这个过程就像整理书架:先挑一本书作为"中间参考",把比它薄的放左边、比它厚的放右边,然后对左边和右边各自再做同样的事。

复制代码
原始数组: [8, 3, 7, 1, 5, 2, 6, 4]

第1轮 pivot=5:
  左 [3,1,2,4]  | 5 |  右 [8,7,6]

第2轮:
  左边 pivot=2 → [1] | 2 | [3,4]
  右边 pivot=7 → [6] | 7 | [8]

第3轮:
  [3,4] → pivot=3 → [] | 3 | [4]

合并结果: [1, 2, 3, 4, 5, 6, 7, 8]

二、分区策略

快排的关键在于如何分区,不同的分区策略直接影响性能。

2.1 Lomuto 分区(单指针)

最简单的分区方式:从左到右扫描,遇到比 pivot 小的元素就交换到左侧区域。

优点 :代码简洁,容易理解

缺点:对已有序数据效率低,交换次数多

2.2 Hoare 分区(双指针)

原始的快排分区方式:两个指针从两端相向而行,左指针找大于 pivot 的,右指针找小于 pivot 的,然后交换。

优点 :交换次数更少,平均性能更好

缺点:边界条件复杂,容易写出 bug

2.3 三路分区(Dutch National Flag)

当数组中有大量与 pivot 相等的元素时,把它们集中到中间区域,避免重复比较和交换。

优点 :对大量重复元素的数据效率极高

缺点:代码复杂度高,一般场景收益不大

三、Go 语言实现

基础版(Lomuto 分区)

go 复制代码
package main

import "fmt"

// QuickSort 对整数切片执行快速排序
func QuickSort(arr []int) {
    if len(arr) <= 1 {
        return
    }
    quickSortHelper(arr, 0, len(arr)-1)
}

func quickSortHelper(arr []int, lo, hi int) {
    if lo >= hi {
        return
    }
    p := partitionLomuto(arr, lo, hi)
    quickSortHelper(arr, lo, p-1)   // 排左半部分
    quickSortHelper(arr, p+1, hi)   // 排右半部分
}

// partitionLomuto 以最后一个元素为pivot,返回pivot最终位置
func partitionLomuto(arr []int, lo, hi int) int {
    pivot := arr[hi]              // 选最后一个做pivot
    i := lo                       // i标记"小于pivot区"的右边界
    for j := lo; j < hi; j++ {
        if arr[j] < pivot {
            arr[i], arr[j] = arr[j], arr[i]
            i++
        }
    }
    arr[i], arr[hi] = arr[hi], arr[i] // pivot归位
    return i
}

func main() {
    data := []int{8, 3, 7, 1, 5, 2, 6, 4}
    fmt.Println("排序前:", data)
    QuickSort(data)
    fmt.Println("排序后:", data)
}

Hoare 分区版(双指针)

go 复制代码
package main

import "fmt"

func QuickSortHoare(arr []int) {
    if len(arr) <= 1 {
        return
    }
    quickSortHoare(arr, 0, len(arr)-1)
}

func quickSortHoare(arr []int, lo, hi int) {
    if lo >= hi {
        return
    }
    pivot := arr[lo+(hi-lo)/2]   // 选中间元素做pivot(避免极端情况)
    i, j := lo, hi
    for i <= j {
        for arr[i] < pivot { i++ }
        for arr[j] > pivot { j-- }
        if i <= j {
            arr[i], arr[j] = arr[j], arr[i]
            i++
            j--
        }
    }
    quickSortHoare(arr, lo, j)
    quickSortHoare(arr, i, hi)
}

func main() {
    data := []int{8, 3, 7, 1, 5, 2, 6, 4, 9, 0}
    fmt.Println("排序前:", data)
    QuickSortHoare(data)
    fmt.Println("排序后:", data)
}

三路分区版(处理大量重复元素)

go 复制代码
package main

import "fmt"

// QuickSort3Way 三路快速排序,适合大量重复元素
func QuickSort3Way(arr []int) {
    if len(arr) <= 1 {
        return
    }
    quickSort3Way(arr, 0, len(arr)-1)
}

func quickSort3Way(arr []int, lo, hi int) {
    if lo >= hi {
        return
    }
    pivot := arr[lo]
    lt, gt, i := lo, hi, lo+1
    // [lo..lt-1] < pivot  |  [lt..gt] == pivot  |  [gt+1..hi] > pivot
    for i <= gt {
        if arr[i] < pivot {
            arr[lt], arr[i] = arr[i], arr[lt]
            lt++
            i++
        } else if arr[i] > pivot {
            arr[gt], arr[i] = arr[i], arr[gt]
            gt--
            // i不前进,因为交换过来的元素还没检查
        } else {
            i++
        }
    }
    quickSort3Way(arr, lo, lt-1)
    quickSort3Way(arr, gt+1, hi)
}

func main() {
    // 大量重复元素的场景
    data := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9}
    fmt.Println("排序前:", data)
    QuickSort3Way(data)
    fmt.Println("排序后:", data)
}

四、复杂度分析

情况 时间复杂度 说明
最好 O(n log n) pivot 每次都恰好将数组对半分
平均 O(n log n) 实际工程中绝大多数场景
最坏 O(n²) pivot 每次都是最大或最小值(如已有序数组)
  • 空间复杂度:O(log n)(递归栈深度),最坏 O(n)
  • 稳定性 :❌ 不稳定------分区过程中相等的元素可能被交换到不同分区

最坏情况为什么是 O(n²)

考虑已排序数组 [1, 2, 3, 4, 5],如果总是选最后一个元素做 pivot:

  • 第1轮:pivot=5,分区后 [1,2,3,4] | 5,左半4个元素
  • 第2轮:pivot=4,分区后 [1,2,3] | 4,左半3个元素
  • ......
  • 每轮只减少1个元素,共 n 轮,每轮扫描 n、n-1、n-2...... → O(n²)

如何避免最坏情况

  1. 随机选 pivot:随机选择一个元素做基准,使最坏情况概率极低
  2. 三数取中:取 lo、mid、hi 三个位置的中位数做 pivot
  3. 三路分区:对大量重复数据天然友好

五、递归深度与栈空间

快排的递归深度取决于分区是否均匀:

  • 均匀分区:深度 ≈ log₂n,栈空间 O(log n)
  • 极不均匀分区:深度 ≈ n,栈空间 O(n),可能栈溢出

尾递归优化:先递归排序较小的那半部分,较大的那半部分用循环处理,可以把最坏栈空间降到 O(log n)。

go 复制代码
// 尾递归优化版
func quickSortTailRecursive(arr []int, lo, hi int) {
    while lo < hi {
        p := partitionLomuto(arr, lo, hi)
        // 先排较小的一半(递归)
        if p-lo < hi-p {
            quickSortTailRecursive(arr, lo, p-1)
            lo = p + 1  // 较大的一半用循环
        } else {
            quickSortTailRecursive(arr, p+1, hi)
            hi = p - 1
        }
    }
}

六、实际工程中的快排

Go 标准库 sort.Sort 的内部实现(pdqsort)不是单纯的快排,而是多种算法的混合体:

  1. 对小区间(≤12元素)切换为插入排序
  2. 遇到递归深度过大时切换为堆排序(保证最坏 O(n log n))
  3. 检测到数据近乎有序时切换为插入排序
  4. 采用 pattern-defeating(模式击败)策略自适应选择

这种混合策略叫做 Introsort(内省排序),是工程实践的典范------既享受快排的平均高效,又规避了最坏情况。

七、今日练习

go 复制代码
package main

import (
    "fmt"
    "math/rand"
    "time"
)

// 练习1: 基版快排 + 随机pivot
func QuickSortRandom(arr []int) {
    if len(arr) <= 1 {
        return
    }
    rand.Seed(time.Now().UnixNano())
    qsRandom(arr, 0, len(arr)-1)
}

func qsRandom(arr []int, lo, hi int) {
    if lo >= hi {
        return
    }
    // 随机选pivot,交换到hi位置,然后走Lomuto分区
    r := rand.Intn(hi-lo+1) + lo
    arr[r], arr[hi] = arr[hi], arr[r]

    pivot := arr[hi]
    i := lo
    for j := lo; j < hi; j++ {
        if arr[j] < pivot {
            arr[i], arr[j] = arr[j], arr[i]
            i++
        }
    }
    arr[i], arr[hi] = arr[hi], arr[i]
    p := i

    qsRandom(arr, lo, p-1)
    qsRandom(arr, p+1, hi)
}

// 练习2: 使用快排找到数组中第k小的元素(QuickSelect)
// 平均O(n),比排序后取第k个更快
func QuickSelect(arr []int, k int) int {
    if k < 0 || k >= len(arr) {
        return -1
    }
    return qsSelect(arr, 0, len(arr)-1, k)
}

func qsSelect(arr []int, lo, hi, k int) int {
    if lo == hi {
        return arr[lo]
    }
    pivot := arr[hi]
    i := lo
    for j := lo; j < hi; j++ {
        if arr[j] < pivot {
            arr[i], arr[j] = arr[j], arr[i]
            i++
        }
    }
    arr[i], arr[hi] = arr[hi], arr[i]

    if k == i {
        return arr[i]
    } else if k < i {
        return qsSelect(arr, lo, i-1, k)
    } else {
        return qsSelect(arr, i+1, hi, k)
    }
}

func main() {
    // 验证快排(随机pivot)
    data := []int{8, 3, 7, 1, 5, 2, 6, 4}
    fmt.Println("快排前:", data)
    QuickSortRandom(data)
    fmt.Println("快排后:", data)

    // 验证QuickSelect
    data2 := []int{7, 10, 4, 3, 20, 15}
    fmt.Println("\n数组:", data2)
    for k := 0; k < len(data2); k++ {
        copy := make([]int, len(data2))
        for i, v := range data2 { copy[i] = v }
        fmt.Printf("第%d小的元素: %d\n", k+1, QuickSelect(copy, k))
    }

    // 对已有序数组排序(验证随机pivot避免退化)
    sorted := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    fmt.Println("\n已有序数组:", sorted)
    QuickSortRandom(sorted)
    fmt.Println("排序后:", sorted)
}

运行结果:

复制代码
快排前: [8 3 7 1 5 2 6 4]
快排后: [1 2 3 4 5 6 7 8]

数组: [7 10 4 3 20 15]
第1小的元素: 3
第2小的元素: 4
第3小的元素: 7
第4小的元素: 10
第5小的元素: 15
第6小的元素: 20

已有序数组: [1 2 3 4 5 6 7 8 9 10]
排序后: [1 2 3 4 5 6 7 8 9 10]

八、知识要点总结

要点 说明
分治思想 选pivot分区 → 递归排序子区间 → 合治
分区策略 Lomuto(简单)、Hoare(高效)、三路(重复元素)
最坏情况 O(n²),有序数据+固定pivot;随机pivot/三数取中可规避
不稳定 分区时相等元素可能跨越分区边界
实际最优 工程中混合插入排序+堆排序(Introsort)
QuickSelect 利用分区找第k小元素,平均O(n)
相关推荐
高铭杰2 小时前
Golang sync.Mutex实现原理学习
java·学习·golang
ttwuai17 小时前
AI 生成后台改数据后,操作日志别只记按钮:Go + MySQL 怎么验
数据库·人工智能·mysql·golang
Wang's Blog19 小时前
Go-Zero项目开发34: 微服务超时控制与重试机制实践
开发语言·微服务·golang·go-zero
FfHUCisI1 天前
循环链表(Circular Linked List)
golang
Wang's Blog1 天前
Go-Zero项目开发30: 微服务配置中心的设计与实现
开发语言·微服务·golang·go-zero
名字还没想好☜1 天前
Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)
开发语言·后端·golang·go
会编程的土豆1 天前
功能详解 01 · 用户注册:从表单到数据库一行
开发语言·数据库·后端·mysql·golang
go不是csgo1 天前
从模板到五道 LeetCode:完全背包的 Golang 教学笔记
笔记·leetcode·golang
Wang's Blog1 天前
Go-Zero项目开发33: IM实时通信前后端对接与微服务基础设施实践
开发语言·微服务·golang·go-zero