排序算法
排序算法
code: sorting_algorithm
1. 问题分析
1.1 问题定义
问题:给定一个包含n个整数的无序数组,将其按升序排列。
示例:
- 输入:[64, 34, 25, 12, 22, 11, 90]
- 输出:[11, 12, 22, 25, 34, 64, 90]
1.2 排序算法分类
排序算法可以根据不同的特性进行分类:
按时间复杂度分类:
- 简单排序:O(n²) - 冒泡排序、插入排序
- 高效排序:O(n log n) - 快速排序、归并排序、堆排序
按空间复杂度分类:
- 原地排序:O(1) - 冒泡排序、插入排序、快速排序、堆排序
- 非原地排序:O(n) - 归并排序
按稳定性分类:
- 稳定排序:相等元素的相对位置不变 - 冒泡排序、插入排序、归并排序
- 不稳定排序:相等元素的相对位置可能改变 - 快速排序、堆排序
2. 各算法实现详细解析
2.1 冒泡排序 (BubbleSort)
2.1.1 算法原理
冒泡排序是最基础的排序算法,通过重复遍历数组,比较相邻元素并交换位置,使得较大的元素像”气泡”一样逐渐”浮”到数组末尾。
核心思想:
- 从数组开始处遍历,比较相邻的两个元素
- 如果前一个元素大于后一个元素,则交换它们的位置
- 每轮遍历后,最大的元素会”冒泡”到数组末尾
- 重复n-1轮,直到整个数组有序
2.1.2 算法图解
1 | 初始数组: [64, 34, 25, 12, 22] |
2.1.3 代码实现
1 | void BubbleSort::Sort(std::vector<int32_t> &data) |
2.1.4 复杂度分析
时间复杂度:
- 最好情况:O(n) - 数组已经有序,只需一轮遍历
- 最坏情况:O(n²) - 数组逆序,需要n(n-1)/2次比较
- 平均情况:O(n²)
空间复杂度:O(1) - 只需要常数个临时变量
稳定性:✅ 稳定 - 相等元素不会交换位置
适用场景:数据量小或基本有序的情况
2.2 插入排序 (InsertionSort)
2.2.1 算法原理
插入排序的工作方式类似于整理扑克牌:将每张牌插入到已排序的牌中的正确位置。
核心思想:
- 将数组分为”已排序区域”和”未排序区域”
- 初始时,第一个元素被视为已排序
- 从第二个元素开始,依次将每个元素插入到已排序区域的正确位置
- 插入时,需要将大于当前元素的已排序元素向后移动
2.2.2 算法图解
1 | 初始数组: [64, 34, 25, 12, 22] |
2.2.3 代码实现
1 | void InsertionSort::Sort(std::vector<int32_t> &data) |
2.2.4 复杂度分析
时间复杂度:
- 最好情况:O(n) - 数组已经有序,每个元素只需比较一次
- 最坏情况:O(n²) - 数组逆序,需要n(n-1)/2次移动
- 平均情况:O(n²)
空间复杂度:O(1) - 原地排序
稳定性:✅ 稳定 - 相等元素保持原有顺序
适用场景:
- 数据量较小
- 数组基本有序(此时性能接近O(n))
- 在线排序(数据陆续到达)
2.3 快速排序 (QuickSort)
2.3.1 算法原理
快速排序采用**分治法(Divide and Conquer)**策略,通过选择一个”基准值(pivot)”,将数组分为两部分:小于基准值的元素和大于基准值的元素,然后递归地对这两部分进行排序。
核心思想:
- **选择基准(Pivot)**:从数组中选择一个元素作为基准值
- **分区(Partition)**:将数组重新排列,使得:
- 所有小于基准值的元素都在基准值左边
- 所有大于基准值的元素都在基准值右边
- 递归排序:对基准值左右两个子数组递归应用快速排序
- 合并结果:由于是原地排序,不需要额外的合并步骤
2.3.2 分区过程详解
双指针法(本实现采用):
1 | 初始数组: [64, 34, 25, 12, 22, 11, 90] |
更复杂的例子:
1 | 数组: [50, 30, 70, 20, 60, 40, 80] |
2.3.3 代码实现
1 | void QuickSort::Sort(std::vector<int32_t> &data) |
2.3.4 复杂度分析
时间复杂度:
- 最好情况:O(n log n) - 每次分区都能均分数组
- 最坏情况:O(n²) - 数组已排序或逆序,每次只减少一个元素
- 平均情况:O(n log n)
空间复杂度:
- O(log n) - 递归栈空间(平均情况)
- O(n) - 最坏情况的递归深度
稳定性:❌ 不稳定 - 分区交换可能改变相等元素的相对位置
适用场景:
- 通用排序算法,性能优秀
- 不需要稳定性的场景
- 数据量大且随机分布
优化技巧:
- 三数取中法选择pivot
- 小数组使用插入排序
- 三路快排处理大量重复元素
2.4 归并排序 (MergeSort)
2.4.1 算法原理
归并排序同样采用分治法,但与快速排序不同,它的核心在于”合并”而非”分区”。
核心思想:
- **分解(Divide)**:将数组不断二分,直到每个子数组只有一个元素
- **合并(Merge)**:将两个有序的子数组合并成一个有序数组
- 递归执行:自底向上逐层合并,最终得到完全有序的数组
关键洞察:
- 单个元素天然有序
- 合并两个有序数组是简单的O(n)操作
- 通过递归合并,最终整个数组有序
2.4.2 算法图解
1 | 初始数组: [64, 34, 25, 12, 22, 11, 90, 88] |
2.4.3 合并过程详解
双指针合并法:
1 | 合并两个有序数组: left=[12, 25, 34] right=[11, 22, 64] |
2.4.4 代码实现
1 | void MergeSort::Sort(std::vector<int32_t> &data) |
2.4.5 复杂度分析
时间复杂度:
- 最好情况:O(n log n)
- 最坏情况:O(n log n)
- 平均情况:O(n log n)
- **稳定的O(n log n)**:无论什么情况都是这个复杂度
空间复杂度:O(n) - 需要额外的临时数组
稳定性:✅ 稳定 - 合并时相等元素保持原有顺序
递归深度:O(log n)
适用场景:
- 需要稳定排序的场景
- 数据量大且对空间要求不严格
- 外部排序(处理大文件)
- 链表排序(可以O(1)空间)
2.5 堆排序 (HeapifySort)
2.5.1 算法原理
堆排序利用**堆(Heap)**这种数据结构进行排序。堆是一个完全二叉树,分为最大堆和最小堆。
堆的性质:
- 最大堆:每个节点的值都大于或等于其子节点的值(根节点是最大值)
- 最小堆:每个节点的值都小于或等于其子节点的值(根节点是最小值)
核心思想(使用最大堆升序排序):
- 构建最大堆:将无序数组调整为最大堆
- 堆顶与末尾交换:将堆顶(最大值)与数组末尾交换
- 重新堆化:对剩余元素重新调整为最大堆
- 重复步骤2-3:直到所有元素都排序完成
2.5.2 堆的数组表示
在数组中表示完全二叉树:
1 | 数组索引: [0, 1, 2, 3, 4, 5, 6] |
2.5.3 堆化(Heapify)过程详解
堆化:将以某个节点为根的子树调整为最大堆。
1 | 例子: 对索引i=1进行堆化 |
完整的堆排序过程:
1 | 初始数组: [64, 34, 25, 12, 22, 11, 90] |
2.5.4 代码实现
1 | void HeapifySort::Sort(std::vector<int32_t> &data) |
2.5.5 复杂度分析
时间复杂度:
- 构建堆:O(n)
- 排序:O(n log n)
- 总体:O(n log n)(所有情况都相同)
空间复杂度:O(1) - 原地排序(递归栈深度O(log n))
稳定性:❌ 不稳定 - 堆化过程中会改变相等元素的相对位置
适用场景:
- 需要O(n log n)保证且空间受限
- Top-K问题(找最大/最小的K个元素)
- 优先队列的实现
3. 算法性能对比
3.1 时间复杂度对比
| 算法 | 最好情况 | 平均情况 | 最坏情况 | 备注 |
|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | 优化版可提前终止 |
| 插入排序 | O(n) | O(n²) | O(n²) | 小数组或基本有序时很快 |
| 快速排序 | O(n logn) | O(n log n) | O(n²) | 平均性能最好 |
| 归并排序 | O(n logn) | O(n log n) | O(n log n) | 性能稳定 |
| 堆排序 | O(n logn) | O(n log n) | O(n log n) | 不受数据分布影响 |
3.2 空间复杂度对比
| 算法 | 空间复杂度 | 是否原地排序 |
|---|---|---|
| 冒泡排序 | O(1) | ✅ 是 |
| 插入排序 | O(1) | ✅ 是 |
| 快速排序 | O(log n) | ✅ 是 |
| 归并排序 | O(n) | ❌ 否 |
| 堆排序 | O(1) | ✅ 是 |
3.3 稳定性对比
| 算法 | 稳定性 | 说明 |
|---|---|---|
| 冒泡排序 | ✅ 稳定 | 相邻元素比较,不会改变相对位置 |
| 插入排序 | ✅ 稳定 | 向前插入时保持相对顺序 |
| 快速排序 | ❌ 不稳定 | 分区交换可能打乱相对位置 |
| 归并排序 | ✅ 稳定 | 合并时可以保持相对顺序 |
| 堆排序 | ❌ 不稳定 | 堆化过程会打乱相对位置 |
3.4 实际性能测试
基于不同数据规模的性能对比(仅供参考):
**小数据量 (n < 50)**:
- 插入排序 - 最快(缓存友好,常数小)
- 冒泡排序(优化版)
- 快速排序、归并排序、堆排序(递归开销大)
**中等数据量 (50 < n < 10000)**:
- 快速排序 - 通常最快
- 堆排序
- 归并排序
- 插入排序
- 冒泡排序
**大数据量 (n > 10000)**:
- 快速排序 - 平均最快
- 归并排序 - 稳定且可预测
- 堆排序 - 空间优势
- 插入排序、冒泡排序 - 不适用
4. 算法选择指南
4.1 根据需求选择
| 需求场景 | 推荐算法 | 理由 |
|---|---|---|
| 数据量小(< 50) | 插入排序 | 实现简单,性能好 |
| 数据基本有序 | 插入排序 | 时间复杂度接近O(n) |
| 需要稳定排序 | 归并排序 | 唯一稳定的O(n log n)算法 |
| 空间受限 | 堆排序 | 原地排序,O(1)空间 |
| 通用场景,追求平均性能 | 快速排序 | 平均性能最优 |
| 需要可预测的性能 | 归并排序/堆排序 | 所有情况都是O(n log n) |
| 在线排序(数据陆续到达) | 插入排序 | 可以高效处理新数据 |
| Top-K问题 | 堆排序 | 只需要部分有序 |
4.2 实际应用建议
生产环境推荐:
- C++ STL std::sort:混合算法(快排+插入排序+堆排序)
- Java Arrays.sort:
- 基本类型:双轴快速排序
- 对象类型:TimSort(归并+插入的混合)
- **Python sorted()**:TimSort
混合策略:
1 | void HybridSort(std::vector<int> &data) { |
5. 核心概念总结
5.1 分治法(Divide and Conquer)
快速排序、归并排序、堆排序都使用了分治思想:
- **分解(Divide)**:将问题分解为子问题
- **解决(Conquer)**:递归解决子问题
- **合并(Combine)**:合并子问题的解
区别:
- 快速排序:分解复杂(分区),合并简单(无需操作)
- 归并排序:分解简单(二分),合并复杂(需要合并操作)
- 堆排序:利用堆结构,每次提取最大值
5.2 稳定性的重要性
稳定性在某些场景很重要:
1 | // 学生记录 |
5.3 原地排序的意义
原地排序在以下场景重要:
- 嵌入式系统(内存受限)
- 大数据处理(减少内存占用)
- 实时系统(避免内存分配)
6. 扩展知识
6.1 其他排序算法
- 希尔排序:插入排序的改进,O(n^1.3)
- 计数排序:O(n+k),适用于整数且范围小
- 桶排序:O(n+k),将数据分布到多个桶中
- 基数排序:O(d(n+k)),按位排序
6.2 下界证明
基于比较的排序算法理论下界是**O(n log n)**:
- 决策树模型证明
- n个元素有n!种排列
- log₂(n!) ≈ n log n
非比较排序(计数、桶、基数)可以突破这个下界。
6.3 工程实践
实际生产中的排序通常是混合算法:
Introsort(STL中的实现):
- 主要使用快速排序
- 递归深度过深时切换到堆排序(避免O(n²))
- 小数组使用插入排序(减少递归开销)
这种混合方式综合了各算法的优点,达到了最佳的实际性能。