中文 Walking in Code

快速排序算法 - 5种分区方案完全指南

掌握快速排序的5种分区方案:Lomuto、Hoare、三路分区(荷兰国旗)、三数取中和双轴。包含JavaScript、Java和Python的交互式可视化和代码实现。

#算法 #排序 #可视化 #计算机科学 #分治

概述

快速排序是最高效、应用最广泛的排序算法之一。它采用分治策略:选择一个基准元素,将数组分区使较小的元素在左边、较大的在右边,然后递归排序各个分区。

虽然基本思想很简单,但选择基准分区的方式对性能影响很大。本文探讨5种流行的变体,每种都解决特定问题或针对不同场景进行优化。

⏱️ 时间复杂度

情况复杂度
平均O(n log n)
最好O(n log n)
最坏O(n²) — 基准选择不当

💾 空间复杂度

O(log n) — 递归调用栈


方案对比

变体核心思想平均/最坏重复元素有序输入适用场景
Lomuto单指针,末元素为基准O(n log n) / O(n²)⚠️ 差⚠️ 差教学、简单场景
Hoare双指针从两端开始O(n log n) / O(n²)✓ 较好✓ 一般通用场景
三路分区三个区域:<=>O(n log n) / O(n²)✅ 优秀✓ 良好大量重复值
三数取中取首/中/末的中位数O(n log n) / O(n²)*✓ 良好✅ 优秀有序/逆序输入
双轴快排双基准,三分区O(n log n) / O(n²)✓ 良好✓ 良好JDK的 Arrays.sort()

* 最坏情况发生概率大大降低


1. Lomuto 分区

教科书标准的分区方案

💡 工作原理

Lomuto 分区使用最后一个元素作为基准,维护一个指针 i 来跟踪小于基准的元素和大于等于基准的元素之间的边界。

📝 算法步骤

1. pivot ← array[right]
2. i ← left - 1  // 较小元素的边界
3. FOR j FROM left TO right-1:
   └─ IF array[j] < pivot:
        i++
        SWAP array[i] ↔ array[j]
4. SWAP array[i+1] ↔ array[right]  // 放置基准
5. RETURN i+1  // 基准的最终位置

💻 实现代码

Loading...

✅ 解决的问题

提供一个简单直观、易于理解的分区方案,非常适合教学简单实现

⚠️ 缺点

当数组有大量重复值时,Lomuto 会退化到 O(n²),因为相等的元素都会分到一侧,造成不平衡的分区。

🎮 交互式可视化

观察指针 i 如何在 j 扫描数组时扩展”小于基准”区域。

1/0

2. Hoare 分区

Tony Hoare 发明的原始快速排序分区

💡 工作原理

Hoare 分区使用两个指针从数组两端开始。左指针找到 ≥ 基准的元素,右指针找到 ≤ 基准的元素,然后交换。如此反复直到指针交叉。

📝 算法步骤

1. pivot ← array[left]
2. i ← left - 1,  j ← right + 1
3. LOOP:
   └─ MOVE i → until array[i] ≥ pivot
   └─ MOVE j ← until array[j] ≤ pivot
   └─ IF i ≥ j: BREAK, RETURN j
   └─ SWAP array[i] ↔ array[j]
4. RETURN j  // 分区点

💻 实现代码

Loading...

✅ 解决的问题

平均比 Lomuto 减少约 3 倍的交换次数。双指针方式更高效,因为元素会自然地向正确的一侧移动。

🔄 与 Lomuto 的关键区别

分区后基准不会在固定位置。相反,我们得到一个分区点 j,j 左边的元素 ≤ 基准,j+1 右边的元素 ≥ 基准。

🎮 交互式可视化

观察两个指针 ij 从两端收敛,交换位置不对的元素。

1/0

3. 三路分区(荷兰国旗)

处理重复元素的荷兰国旗算法

💡 工作原理

三路分区将数组分成三个区域

  • 小于基准的元素
  • 等于基准的元素
  • 大于基准的元素

这也被称为**荷兰国旗(DNF)**算法,以 Dijkstra 解决按荷兰国旗三种颜色排序的方案命名。

📝 算法步骤

1. lt ← left,  i ← left + 1,  gt ← right
2. pivot ← array[left]
3. WHILE i ≤ gt:
   ├─ IF array[i] < pivot:
   │    SWAP array[lt] ↔ array[i], lt++, i++
   ├─ ELIF array[i] > pivot:
   │    SWAP array[i] ↔ array[gt], gt--
   └─ ELSE: i++
4. RECURSE on [left, lt-1] and [gt+1, right]

💻 实现代码

Loading...

✅ 解决的问题

当所有元素相等时实现 O(n) 时间复杂度!通过将重复元素聚集在一起,避免对相同值的区域递归,非常适合有大量重复值的数组

🇳🇱 为什么叫”荷兰国旗”?

Dijkstra 提出了在线性时间、常数空间内对三个不同值的数组排序的问题(就像荷兰国旗的 🔴 红、⚪ 白、🔵 蓝三条纹)。这个分区方案就是解决方案,将值视为 <基准=基准>基准

🎮 交互式可视化

观察 ltigt 指针如何在元素分类时维护三个不同区域。

1/0

4. 三数取中

智能选择基准以避免最坏情况

💡 工作原理

不是盲目选择第一个或最后一个元素作为基准(这会导致有序数组的 O(n²)),三数取中检查第一个、中间和最后一个元素,选择中位值作为基准。

📝 算法步骤

1. COMPARE array[left], array[mid], array[right]
2. SORT these three elements in place
3. pivot ← array[mid]  // 中位数
4. PROCEED with standard partitioning

💻 实现代码

Loading...

✅ 解决的问题

有序或近乎有序的数组是基本快速排序的最坏情况。三数取中大大降低了选择极端基准的概率,确保更平衡的分区。

🏭 实际应用

这种技术被许多标准库实现使用(如 C++ 的 std::sort),并结合其他优化。

🎮 交互式可视化

观察算法如何首先比较三个候选值(首、中、末)来找到中位数,然后再分区。

1/0

5. 双轴快排

JDK Arrays.sort() 使用的双基准算法

💡 工作原理

双轴快速排序使用两个基准 (P1 ≤ P2) 将数组分成三个区域

  • < P1 的元素 (左区域)
  • P1 和 P2 之间的元素 (中间区域)
  • > P2 的元素 (右区域)

这是由 Vladimir Yaroslavskiy 发明的,被 Java 的 Arrays.sort() 用于原始类型。

📝 算法步骤

1. ENSURE array[left] ≤ array[right]; else SWAP
2. P1 ← array[left],  P2 ← array[right]
3. lt ← left + 1,  k ← left + 1,  gt ← right - 1
4. WHILE k ≤ gt:
   ├─ IF array[k] < P1: SWAP to left, lt++, k++
   ├─ ELIF array[k] > P2: SWAP to right, gt--
   └─ ELSE: k++  // 中间区域
5. PLACE pivots in final positions
6. RECURSE on three partitions

💻 实现代码

Loading...

✅ 解决的问题

双轴快速排序具有更好的缓存性能,因为平均每个元素的内存访问次数更少。研究表明,实践中它比单轴快速排序减少约 10% 的比较次数

☕ 为什么 Java 使用它

从 Java 7 开始,Arrays.sort(int[]) 对原始类型使用双轴快速排序。结合小子数组的插入排序和避免最坏情况的自省机制,它提供了出色的实际性能。

🎮 交互式可视化

注意两个基准值 (P1 和 P2) 如何创建三个不同区域,导致三次递归调用而不是两次。

1/0

总结

每种快速排序变体都解决特定的挑战:

变体最适合权衡
Lomuto学习和教学处理重复值差
Hoare通用场景更少交换,经典效率
三路分区重复值多的数据全等时 O(n)!
三数取中有序/逆序输入平衡分区
双轴快排现代应用缓存友好,JDK 使用

🔧 生产级实现

实践中,生产级排序实现会组合多种技术:

  • Java Arrays.sort() — 双轴 + 插入排序 + 自省
  • C++ std::sort() — 三数取中 + 插入排序 + 堆排序回退
  • Python sorted() — Timsort(归并排序 + 插入排序混合)

💡 关键要点:理解这些变体让你能够根据特定用例和数据特征选择正确的方法。