快速排序(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²)
如何避免最坏情况
- 随机选 pivot:随机选择一个元素做基准,使最坏情况概率极低
- 三数取中:取 lo、mid、hi 三个位置的中位数做 pivot
- 三路分区:对大量重复数据天然友好
五、递归深度与栈空间
快排的递归深度取决于分区是否均匀:
- 均匀分区:深度 ≈ 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)不是单纯的快排,而是多种算法的混合体:
- 对小区间(≤12元素)切换为插入排序
- 遇到递归深度过大时切换为堆排序(保证最坏 O(n log n))
- 检测到数据近乎有序时切换为插入排序
- 采用 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) |