
🔥铅笔小新z:个人主页
🎬博客专栏:数据结构
💫滴水不绝,可穿石;步履不休,能至渊。

引言
排序,是计算机科学中最经典、最基础的问题之一。它看似简单,背后却蕴藏着丰富的算法思想和智慧火花。
从简单直观的"冒泡排序"到高效莫测的"快速排序",不同的算法在时间与空间的权衡中,演绎着各自的精彩。
无论你是编程新手还是资深开发者,深入理解排序,都是锤炼算法思维不可或缺的一步。
本文将带你一览众"序",看懂它们的门道。
在这篇博客中,我们将从最基础的冒泡排序和选择排序开始,理解它们的思想;然后我们会探讨更高效的归并排序和快速排序,分析它们为何如此强大;最后,我们还会对比各种算法的性能,帮助你在实际场景中最初最佳选择。让我们开始这段奇妙的排序之旅吧!
常见排序算法

一、冒泡排序
1.1 一句话概括
冒泡排序是一种简单的排序算法,它通过反复交换相邻的、顺序错误的元素,将较大的元素逐渐"浮"到数组的末尾,就像气泡上浮一样。
1.2 核心思想
- **比较相邻元素:**从数组的第一个元素开始,依次比较相邻的两个元素。
- **交换位置:**如果前一个元素比后一个元素大(升序排序),就交换它们的位置。
- **重复过程:**每一轮遍历都会将当前未排序部分的最大元素"冒泡"到正确位置。
- **优化:**如果某一轮没有发生任何交换,说明数组已经有序,可以提前终止。
1.3 代码实现
cpp
void BubbleSort(int* a, int n)
{
int exchange = 0; // 优化:记录是否发生交换
for (int i = 0; i < n; i++)
{
for (int j = 0; j <n-i-1 ; j++)
{
if (a[j] > a[j + 1])
{
exchange = 1;
swap(&a[j], &a[j + 1]);
}
}
if (exchange == 0) // 为交换说明有序
{
break;
}
}
}
1.4 复杂度分析
-
时间复杂度:
-
最坏情况(完全逆序):O(n²)
-
最好情况(已经有序):O(n)(优化后)
-
平均情况:O(n²)
-
-
空间复杂度:O(1)(原地排序)
-
稳定性:稳定(相等元素不会交换)
1.5 优缺点
1.5.1 优点:
-
实现简单,易于理解。
-
原地排序,空间效率高。
-
稳定排序。
1.5.2 缺点:
-
效率低,不适合大规模数据。
-
相比其他 O(n²) 算法(如选择排序),交换操作可能更频繁。
1.6 应用场景
- 教学或理解排序算法原理。
- 数据量极小且基本有序的情况。
- 对稳定性有要求且数据量小。
1.7 进阶讨论
- **与插入排序、选择排序的比较:**冒泡排序在实际中较少使用,因为其常数因子较大。
- **优化变体:**双向排序适用于大部分元素已有序的情况。
- **为什么叫"冒泡":**因为较大的元素像气泡一样逐渐上浮至末尾。
1.8 综合理解
冒泡排序是一种基础的比较排序算法。它的核心思想是通过多次遍历数组,每次比较相邻元素,如果顺序错误就交换它们,这样每一轮都会将当前未排序部分的最大元素"冒泡"到正确位置。它的时间复杂度在最坏和平均情况下是O(
),最好情况下(已有序)优化后可以达到O(n),空间复杂度是O(1),并且是稳定的。由于效率较低,它通常用于教学或数据量很小的场景。
二、选择排序
2.1 一句话概括
选择排序是一种简单的原地比较排序算法,接下来要说到的双向选择排序是普通选择排序的一种优化。它在每一轮遍历中同时找出未排序部分的最小值和最大值,分别放到已排序部分的首尾,从而减少排序轮数。
2.2 核心思想(分布解释)
- **双指针维护边界:**用 begin 和 end 分别指向未排序部分的起始和结束位置。
- **同时查找最值:**在每一轮中,同时查找未排序部分的最小值和最大值。
- **两端同时归位:**将最小值放到 begin 位置,最大值放到 end 位置。
- **边界收缩:**begin++, end--,每次减少未排序部分的范围,直到全部有序。
2.3 代码逻辑讲解
cpp
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end) // 直到未排序部分为空
{
int max = begin, min = begin; // 假设当前 begin 位置既是最大值也是最小值
// 遍历未排序部分 [begin, end] 查找真正的最小值和最大值下标
for (int i = begin; i <= end; i++) // 注意:应该是 i <= end
{
if (arr[max] < arr[i])
max = i;
if (arr[min] > arr[i])
min = i;
}
// 将最小值交换到 begin 位置
swap(&arr[begin], &arr[min]);
// 关键处理:如果 begin 位置原本就是最大值
if (begin == max)
max = min; // 最大值的位置被最小值交换了,更新最大值下标
// 将最大值交换到 end 位置
swap(&arr[end], &arr[max]);
// 收缩未排序范围
begin++;
end--;
}
}
2.4 复杂度分析
- **时间复杂度:**O(
)
比较次数:(n - 1) + (n - 3) + (n - 5) + ... ≈ / 2
但轮数减少到原来的一半左右。
- **空间复杂度:**O(1)(原地排序)
- **稳定性:**不稳定
2.5 优缺点
2.5.1 优点:
-
比普通选择排序快约一倍(轮数减半)
-
仍然保持 O(1) 的空间复杂度
-
实现相对简单
2.5.2 缺点:
-
时间复杂度仍为 O(n²)
-
不稳定排序
-
代码逻辑稍复杂(需要处理最大值的特殊情况)
2.6 综合理解
这段代码实现了双向选择排序。它使用 begin 和 end 指针标记未排序部分的边界,在每一轮中同时查找最小值和最大值。先将最小值交换到 begin 位置,如果发现 begin 位置原本就是最大值,需要更新最大值下标,然后在将最大值交换到 end 位置。这样每轮能确定两个元素的位置,排序轮数减少到原来的一半左右。时间复杂度仍然是 O(
),但实际比普通选择排序更快,却保持了 O(1) 的空间复杂度。需要注意的是,这是一个不稳定的排序算法。
三、插入排序
3.1 一句话概括
插入排序是一种简单直观的排序算法,它的工作原理类似于整理扑克牌:将每个未排序元素插入到已排序部分的正确位置,从而逐步构建有序数列。
3.2 核心思想
- **划分已排序和未排序:**初始时已排序部分只有第一个元素(下标 0 )。
- **逐个插入:**将未排序部分的第一个元素取出,在已排序部分中从后向前找到合适的插入位置。
- **移动元素:**在查找过程中,将比待插入元素大的元素都往后移动一位,为插入腾出空间。
- **插入元素:**将待插入元素放到正确位置。
3.3 代码逻辑讲解
cpp
void InsertSort(int* arr, int n)
{
for (int i = 0; i <= n - 2; i++) // 遍历未排序部分,i 指向已排序部分的最后一个元素
{
int end = i; // 已排序部分的末尾下标
int tmp = arr[end + 1]; // 取出待插入元素(未排序部分的第一个)
// 从后向前查找插入位置
while (end >= 0)
{
if (arr[end] > tmp) // 如果当前元素比待插入元素大
{
arr[end + 1] = arr[end]; // 向后移动一位
end--; // 继续向前比较
}
else
break; // 找到合适位置,停止查找
}
arr[end + 1] = tmp; // 将待插入元素放到正确位置
}
}
3.4 关键细节讲解
3.4.1 为什么从后向前比较?
-
效率高:可以边比较边移动,避免额外的交换操作
-
提前终止:遇到不大于tmp的元素就可以停止,因为前面都是有序的
3.4.2 循环条件 i <= n - 2 的意义
-
i表示已排序部分的最后一个索引 -
当
i = n-2时,arr[end+1]就是最后一个元素 -
所以只需要遍历到倒数第二个元素
3.4.3 arr[end + 1] = tmp 的位置
-
退出while循环时,
end指向最后一个小于等于tmp的元素 -
或者
end = -1(tmp是最小值) -
所以插入位置是
end + 1
3.5 复杂度分析
3.5.1 时间复杂度:
-
最坏情况(完全逆序):O(n²)
-
每个元素都需要比较并移动前面所有元素
-
比较次数:1+2+3+...+(n-1) = n(n-1)/2
-
-
最好情况(已经有序):O(n)
-
每个元素只需比较一次就确定位置
-
比较次数:n-1
-
-
平均情况:O(n²)
**3.5.2 空间复杂度:**O(1) (原地排序)
**3.5.3 稳定性:**稳定排序
-
只有
arr[end] > tmp时才移动,等于时不移动 -
相等元素的相对顺序保持不变
3.6 优缺点
- 优点:
-
简单直观,易于实现
-
对小数据集效率高(实际性能常优于其他O(n²)算法)
-
稳定排序
-
自适应:对部分有序数组效率接近O(n)
-
原地排序,空间效率高
- 缺点:
-
大数据集效率低,不适合大规模数据
-
需要大量移动元素,对链表结构不友好
3.7 应用场景
- 小规模数据(n <= 50)
- 基本有序的数据(如日志追加时间戳排序)
- 作为其他排序算法的子过程(如快速排序的递归小数组使用插入排序)
- 在线算法(数据流逐个到达时的实时排序)
- 稳定排序需求且数据量小
3.8 综合理解
插入排序的工作原理是将数组分为已排序和未排序两部分。初始时已排序部分只有一个元素,然后逐个取出来未排序部分的元素,在已排序部分中从后向前查找合适的插入位置。在查找过程中,比待插入元素大的元素都向后移动一位,为插入腾出空间。这个算法的最好时间复杂度是O(n)(已有序),最坏是O(
)(完全逆序),平均O(
)。它是稳定的原地排序算法,特别适合小规模数据或基本有序的数据集。在实际应用中,它常作为其他高级排序算法的优化子过程。
四、希尔排序
4.1 一句话概括
希尔排序是插入排序的改进版,它通过将原始数组按一定间隔(gap)分组,对每组进行插入排序,然后逐步缩小间隔直至1,最终完成整体排序。
4.2 核心思想
-
分组插入:不是直接对整个数组排序,而是先按间隔gap将数组分成多个子序列。
-
逐步细化 :从较大的gap开始排序,使数组宏观基本有序,然后逐渐减小gap。
-
最终插入排序:当gap=1时,就是普通的插入排序,但此时数组已经基本有序,插入排序效率很高。
-
克服插入排序缺陷:插入排序每次只能移动一位,希尔排序可以一次移动gap位,更快地将元素送到大致正确的位置。
4.3 代码逻辑讲解
cpp
void ShellSort(int* arr, int n)
{
int gap = n; // 初始间隔设为数组长度
while (gap > 1) // 当gap>1时,继续分组排序
{
gap = gap / 3 + 1; // 动态计算下一个间隔(常见增量序列)
// 对每个分组进行插入排序
for (int i = 0; i < gap; i++) // i表示第i个分组
{
// 对第i个分组进行插入排序
for (int j = i; j <= n - 1 - gap; j += gap)
{
int end = j; // 已排序部分的末尾
int tmp = arr[end + gap]; // 待插入元素
// 插入排序过程
while (end >= 0)
{
if (arr[end] > tmp) // 需要移动
{
arr[end + gap] = arr[end]; // 向后移动gap位
end -= gap; // 向前移动gap位
}
else
break; // 找到插入位置
}
arr[end + gap] = tmp; // 插入元素
}
}
}
}
4.4 关键细节讲解
4.4.1 增量序列选择(gap = gap / 3 + 1)
- gap / 3 + 1:这是Knuth提出的增量序列,效率较高
- +1 的作用:确保 gap 最终能减少到 1
- 其他常见序列:
Shell原始序列:n/2, n/4, ..., 1
Hibbard序列:1, 3, 7, 15, ..., 2^k-1
Sedgewick序列:1, 5, 19, 41, ...(更优)
4.4.2 三层循环结构
cpp
while (gap > 1) // 控制gap变化
for (int i = 0; i < gap; i++) // 遍历每个分组
for (int j = i; j <= n-1-gap; j+=gap) // 对当前分组插入排序
4.4.3 优化版本(常用写法)
实际中常用更简洁的写法,合并了分组循环:
cpp
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
// 合并分组,从gap开始遍历所有元素
for (int i = gap; i < n; i++)
{
int end = i - gap;
int tmp = arr[i];
while (end >= 0 && arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
arr[end + gap] = tmp;
}
}
}
4.5 复杂度分析
4.5.1 时间复杂度:
-
最坏情况:取决于增量序列,一般为O(n²)
-
使用Shell原始序列(n/2, n/4, ...):O(n²)
-
使用Hibbard序列:O(n^{1.5})
-
使用Sedgewick序列:O(n^{4/3})
-
-
平均情况:优于O(n²),通常为O(n^{1.3}~O(n^{1.5}))
-
最好情况:O(n * log n)(数组已有序)
4.5.2 空间复杂度:O(1)(原地排序)
4.5.3 稳定性:不稳定排序
- 分组插入可能改变相等元素的相对顺序
4.6 优缺点
4.6.1 优点:
-
比简单排序快得多:突破了O(n²)的瓶颈
-
原地排序:空间效率高
-
易于实现:代码相对简单
-
对中等规模数据有效:在n<5000时表现良好
-
不需要递归:无栈溢出风险
4.6.2 缺点:
-
不稳定:可能改变相等元素的顺序
-
时间复杂度分析复杂:依赖于增量序列
-
不如高级排序算法:对大数据不如快排、归并
-
增量序列选择影响大:不同序列性能差异明显
4.7 应用场景
-
中等规模数据排序(几千到几万个元素)
-
嵌入式系统:空间有限,需要原地排序
-
作为其他算法的子过程:某些特定场景的预处理
-
对稳定性要求不高的场景
-
教学用途:展示如何改进简单算法
4.8 增量序列选择的重要性
cpp
// 不同增量序列的性能对比
1. Shell原始序列: gap = n/2, n/4, ..., 1
- 简单但效率不高,最坏O(n²)
2. Hibbard序列: 1, 3, 7, 15, ..., 2^k-1
- 最坏O(n^{3/2}),较好
3. Knuth序列: 1, 4, 13, 40, ..., (3^k-1)/2
- gap = gap/3(代码中的变体)
- O(n^{3/2})
4. Sedgewick序列: 1, 5, 19, 41, ...
- 理论最优,O(n^{4/3})
4.9 要点
-
说明改进思想:"希尔排序是对插入排序的改进,通过分组排序克服插入排序只能移动一位的缺点"
-
强调增量序列:"核心在于gap的选择和变化,不同的增量序列性能不同"
-
解释代码逻辑:"外层控制gap减小,内层对每个分组进行插入排序"
-
分析复杂度:"时间复杂度在O(n log n)到O(n²)之间,取决于增量序列"
-
对比其他算法:"比插入排序快,比快排简单,但不稳定"
4.10 综合理解
希尔排序是插入排序的高效改进版本。它通过引入间隔gap的概念,先将数组按间隔分成多个子序列分别进行插入排序,然后逐步减小间隔直至1。这样做的好处是,早期的大间隔排序可以让元素大幅度移动,快速到达大致正确的位置;后期小间隔排序时,数组已经基本有序,插入排序的效率很高。代码中使用
gap = gap/3 + 1是Knuth增量序列的变体,确保gap最终能减少到1。希尔排序的时间复杂度取决于增量序列,一般为O(n^{1.3}~O(n^{1.5})),空间复杂度O(1),是不稳定的原地排序算法。它特别适合中等规模数据的排序。
五、快速排序(hoare版本)
5.1 一句话概括
这是 Hoare 分区方案的快速排序,通过选择一个基准值(key),将数组划分为左右两部分,左边都小于等于基准值,右边都大于等于基准值,然后递归地对左右两部分排序。
5.2 核心思想
-
选择基准值:通常选择最左边的元素作为基准
-
双指针分区 :使用两个指针
begin和end从两端向中间扫描 -
交换逆序对 :
end找小于基准值的元素,begin找大于基准值的元素,然后交换 -
基准值归位 :最后将基准值放到正确位置(
begin和end相遇点) -
递归排序:对基准值左右两部分递归进行相同操作
5.3 代码逻辑讲解
cpp
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return; // 递归终止条件
int key = left; // 选择最左边元素作为基准值
int begin = left, end = right; // 初始化双指针
// Hoare 分区过程
while (begin < end)
{
// 从右向左找第一个小于基准值的元素
while (begin < end && arr[end] >= arr[key])
{
end--;
}
// 从左向右找第一个大于基准值的元素
while (begin < end && arr[begin] <= arr[key])
{
begin++;
}
// 交换这两个逆序元素
swap(&arr[begin], &arr[end]);
}
// 将基准值放到正确位置(相遇点)
swap(&arr[key], &arr[begin]);
// 更新基准值位置
key = begin;
// 递归排序左右两部分
QuickSort(arr, left, key - 1); // 左子数组
QuickSort(arr, key + 1, right); // 右子数组
}
5.4 关键细节解析
5.4.1 为什么先移动 end 指针?
-
关键点 :Hoare 分区必须先移动右指针(end)
-
原因:如果先移动 begin,可能停在大于基准值的位置,最终交换会导致大的元素被换到左边
-
正确顺序 :
end先找小的,begin再找大的
5.4.2 循环条件中的等号
cpp
while (begin < end && arr[end] >= arr[key]) // 包含等号
while (begin < end && arr[begin] <= arr[key]) // 包含等号
-
等号的意义:允许相等元素出现在任一边
-
避免死循环:如果没有等号,遇到相等元素会死循环
5.4.3 基准值归位的正确性
-
最后
begin == end,这个位置是第一个小于等于基准值的位置 -
将基准值交换到这个位置,保证左边≤基准值,右边≥基准值
5.5 复杂度分析
5.5.1 时间复杂度:
-
最好情况:每次分区平衡,O(n log n)
-
递归深度:log n
-
每层比较次数:n
-
-
最坏情况:完全有序或逆序,O(n²)
-
递归深度:n
-
每层比较次数:n
-
-
平均情况:O(n * log n)
5.5.2 空间复杂度:
-
最好情况:O(log n)(递归栈深度)
-
最坏情况:O(n)(递归栈深度)
-
原地排序:额外空间仅用于递归栈
5.5.3 稳定性 :不稳定排序
- 分区交换会改变相等元素的相对顺序
5.6 优缺点
5.6.1 优点
-
平均效率高:O(n log n) 的排序算法中最快的之一
-
原地排序:空间效率高
-
内部排序:适合内存数据排序
-
可分治并行:左右分区可并行处理
-
实际应用广:很多语言标准库的排序实现
5.6.2 缺点
-
最坏情况差:O(n²),需优化避免
-
不稳定排序
-
递归深度:可能栈溢出
-
基准值选择敏感:影响性能
5.7 优化策略
5.7.1 基准值选择优化
cpp
// 三数取中法
int GetMidIndex(int* arr, int left, int right)
{
int mid = left + (right - left) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right]) return mid;
else if (arr[left] < arr[right]) return right;
else return left;
}
else // arr[left] >= arr[mid]
{
if (arr[mid] > arr[right]) return mid;
else if (arr[left] > arr[right]) return right;
else return left;
}
}
// 使用:swap(&arr[left], &arr[mid]); 再执行原逻辑
5.7.2 小数组优化
cpp
if (right - left < 10) // 当数据量小时
{
InsertSort(arr + left, right - left + 1); // 使用插入排序
return;
}
5.8 应用场景
-
通用排序:大多数情况下的首选排序算法
-
大规模数据:内存足够时效率很高
-
随机数据:对随机数据表现优异
-
需要原地排序:空间受限的情况
-
不稳定可接受:不要求稳定性的场景
5.9 要点
-
说明算法思想:"分治思想,通过分区将问题分解"
-
强调分区过程:"双指针从两端扫描,交换逆序元素"
-
解释关键细节:"必须先移动右指针,注意等号处理"
-
分析复杂度:"平均O(n log n),最坏O(n²),不稳定"
-
提及优化:"三数取中、小数组优化、尾递归优化"
5.10 综合理解
这段代码实现了 Hoare 分区的快速排序。它选择最左边的元素作为基准值,然后用双指针 begin 和 end 从两端向中间扫描。end 指针从右向左找小于基准值的元素,begin 指针从左向右找大于基准值的元素,找到后交换它们。当两个指针相遇时,将基准值交换到相遇位置,这样就完成了一次分区。然后递归地对左右两个子数组进行相同操作。这个版本必须注意先移动右指针,否则可能导致错误。快速排序的平均时间复杂度是 O(n log n),最坏 O(n²),空间复杂度 O(log n)~O(n),是不稳定的原地排序算法。实际使用中常通过三数取中等优化避免最坏情况。
六、快速排序(挖坑法)
6.1 一句话概括
挖坑法是快速排序的一种实现方式,它通过创建一个'坑位'(hole),交替从两端寻找元素填入坑中,最终将基准值放入最后一个坑位,完成一次分区。
6.2 核心思想
-
挖第一个坑:选择基准值后,将其位置作为初始坑位(hole)
-
填坑-挖坑交替:
-
从右向左找小于基准值的元素,填入当前坑,该元素原位置成为新坑
-
从左向右找大于基准值的元素,填入当前坑,该元素原位置成为新坑
-
-
基准值归位:当左右指针相遇时,将基准值放入最后一个坑位
-
返回分界点:返回基准值的最终位置,用于递归
6.3 代码逻辑讲解
cpp
int QuickSort(int* a, int left, int right) // 实际上是分区函数,通常命名为Partition
{
int mid = a[left]; // 选择最左边元素作为基准值
int hole = left; // 初始坑位在最左边
int key = a[hole]; // 保存基准值(与mid相同,冗余)
while (left < right)
{
// 第一步:从右向左找小于基准值的元素
while (left < right && a[right] >= key) // 跳过大于等于基准值的元素
{
--right;
}
a[hole] = a[right]; // 将找到的小元素填入当前坑
hole = right; // 该元素原位置成为新坑
// 第二步:从左向右找大于基准值的元素
while (left < right && a[left] <= key) // 跳过小于等于基准值的元素
{
++left;
}
a[hole] = a[left]; // 将找到的大元素填入当前坑
hole = left; // 该元素原位置成为新坑
}
a[hole] = key; // 将基准值放入最后的坑位
return hole; // 返回基准值最终位置
}
6.4 具体执行过程
数组 :[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],left=0, right=9
初始:
-
key=6, hole=0, left=0, right=9
-
数组:
[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],hole=0
第一轮:
-
从右找<6的元素:8≥6, 10≥6, 5<6 → right=7
-
a[0]=a[7]=5,hole=7
数组:
[5, 1, 2, 7, 9, 3, 4, 5, 10, 8](两个5) -
从左找>6的元素:5≤6, 1≤6, 2≤6, 7>6 → left=3
-
a[7]=a[3]=7,hole=3
数组:
[5, 1, 2, 7, 9, 3, 4, 7, 10, 8](两个7)
第二轮:
-
从右找<6的元素:right从7左移,4<6 → right=6
-
a[3]=a[6]=4,hole=6
数组:
[5, 1, 2, 4, 9, 3, 4, 7, 10, 8](两个4) -
从左找>6的元素:left从3右移,9>6 → left=4
-
a[6]=a[4]=9,hole=4
数组:
[5, 1, 2, 4, 9, 3, 9, 7, 10, 8](两个9)
第三轮:
-
从右找<6的元素:right从6左移,3<6 → right=5
-
a[4]=a[5]=3,hole=5
数组:
[5, 1, 2, 4, 3, 3, 9, 7, 10, 8](两个3) -
从左找>6的元素:left从4右移,与right相遇(left=5, right=5)
结束:
-
left=right=5,hole=5
-
a[5]=key=6
数组:
[5, 1, 2, 4, 3, 6, 9, 7, 10, 8] -
返回hole=5
6.5 关键细节解析
循环条件的等号
cpp
while (left < right && a[right] >= key) // 包含等号
while (left < right && a[left] <= key) // 包含等号
-
等号的作用:相等元素可以跳过,不会死循环
-
稳定性:相等元素可能移动,所以不稳定
6.6 完整快排实现
cpp
// 挖坑法分区函数
int Partition(int* a, int left, int right)
{
int key = a[left]; // 基准值
int hole = left; // 初始坑位
while (left < right)
{
// 从右找小
while (left < right && a[right] >= key)
--right;
a[hole] = a[right];
hole = right;
// 从左找大
while (left < right && a[left] <= key)
++left;
a[hole] = a[left];
hole = left;
}
a[hole] = key; // 基准值归位
return hole; // 返回分界点
}
// 快速排序主函数
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = Partition(a, left, right); // 挖坑法分区
// 递归排序左右两部分
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
6.7 复杂度分析
与Hoare版本相同:
-
时间复杂度:
-
平均:O(n log n)
-
最坏:O(n²)(有序/逆序时)
-
-
空间复杂度:
-
平均:O(log n)(递归栈)
-
最坏:O(n)
-
-
稳定性 :不稳定
6.8 优化策略
6.8.1 基准值优化
cpp
// 三数取中法选择基准值
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
// 返回中间值的索引
}
// 使用:swap(&a[left], &a[mid_index]); 再开始挖坑
6.8.2 小区间优化
cpp
if (right - left < 15) // 小数组使用插入排序
{
InsertSort(a + left, right - left + 1);
return;
}
6.9 综合理解
挖坑法快速排序是快速排序的一种实现方式。它选择最左边的元素作为基准值,并将其位置作为初始'坑位'。然后从右向左寻找小于基准值的元素,将其填入当前坑位,该元素原位置成为新坑位;接着从左向右寻找大于基准值的元素,填入当前坑位,该元素原位置成为新坑位。如此交替进行,直到左右指针相遇,此时将基准值放入最后一个坑位。这样就完成了一次分区,基准值左边的元素都小于等于它,右边的元素都大于等于它。最后递归地对左右两部分进行相同操作。挖坑法比传统的交换法赋值次数更少,效率稍高,平均时间复杂度O(n log n),最坏O(n²),是不稳定的原地排序算法。
七、快速排序(前后指针法)
7.1 一句话概括
双指针法(前后指针法)是快速排序的一种实现方式,通过维护两个指针
prev和cur,将小于基准值的元素逐步交换到前面,最后将基准值放到正确位置,完成分区。
7.2 核心思想
-
双指针移动 :
prev指向最后一个小于基准值的位置,cur遍历未处理部分 -
交换策略 :当
cur找到小于基准值的元素时,先让prev前进一位,然后交换prev和cur位置的元素 -
基准值归位 :遍历完成后,将基准值与
prev位置的元素交换 -
分区结果 :
prev左边都小于基准值,右边都大于等于基准值
7.3 代码逻辑讲解
cpp
int QuickSort(int* a, int left, int right) // 实际是分区函数
{
int prev = left; // prev指向最后一个小于基准值的位置(初始为left)
int cur = left + 1; // cur用于遍历数组(从left+1开始)
int key = left; // 基准值位置(最左边)
while (cur <= right) // 遍历[left+1, right]区间
{
// 关键判断:当前元素小于基准值
if (a[cur] < a[key] && ++prev != cur)
{
swap(&a[cur], &a[prev]); // 将小元素交换到前面
}
++cur; // cur指针始终前进
}
swap(&a[key], &a[prev]); // 将基准值放到正确位置
return prev; // 返回基准值最终位置
}
7.4 关键细节讲解
7.4.1 双指针的含义
cpp
int prev = left; // 指向"小于基准值区域"的末尾
int cur = left + 1; // 遍历指针,寻找小于基准值的元素
-
prev 区域 :
[left+1, prev]都是小于基准值的元素 -
cur 区域 :
[prev+1, cur-1]都是大于等于基准值的元素 -
未处理区域 :
[cur, right]待检查
7.4.2 巧妙的判断条件
cpp
if (a[cur] < a[key] && ++prev != cur)
-
a[cur] < a[key]:找到小于基准值的元素 -
++prev:先让 prev 前进一位,扩展"小值区域" -
!= cur:如果 prev 和 cur 相同,说明它们同步前进,无需交换 -
短路求值:先判断大小,再执行 ++prev
7.4.3 为什么需要 ++prev != cur 判断?
cpp
// 当 prev 和 cur 相邻时(prev+1 == cur)
// 说明它们同步前进,不需要交换
// 交换相同位置是冗余操作
// 示例:prev=2, cur=3
// a[3] < key → ++prev=3, prev==cur → 不交换
// 如果交换,就是 a[3] 和 a[3] 交换,无意义
7.5 完整快排实现
cpp
// 双指针法分区
int Partition(int* a, int left, int right)
{
int keyi = left;
int prev = left;
for (int cur = left + 1; cur <= right; cur++)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
swap(&a[prev], &a[cur]);
}
}
swap(&a[keyi], &a[prev]);
return prev;
}
// 快速排序主函数
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 可在此处添加三数取中优化
// int mid = GetMidIndex(a, left, right);
// swap(&a[left], &a[mid]);
int keyi = Partition(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
7.6 复杂度分析
与快排其他实现相同:
-
时间复杂度:
-
平均:O(n log n)
-
最坏:O(n²)(当数组有序时)
-
-
空间复杂度:
-
平均:O(log n)(递归栈)
-
最坏:O(n)
-
-
稳定性 :不稳定(交换会改变相等元素顺序)
7.7 综合理解
双指针法快速排序通过两个同向移动的指针实现分区。prev指针指向小于基准值区域的末尾,cur指针遍历未处理部分。当cur找到小于基准值的元素时,先让prev前进一位扩展小值区,如果prev和cur不同(说明中间有大元素),就交换它们位置的元素。这样遍历完成后,所有小于基准值的元素都被交换到了前面。最后将基准值与prev位置的元素交换,基准值就归位到了正确位置。这种方法代码简洁,交换次数少,平均时间复杂度O(n log n),最坏O(n²),是不稳定的原地排序算法。它的优势在于实现简单且对重复元素处理较好。
八、快速排序(非递归版)
8.1 一句话概括
非递归快速排序使用栈(Stack)模拟递归过程,显式地保存待排序区间,通过循环不断取出区间进行分区操作,直到所有区间都排序完成。
8.2 核心思想
-
栈替代递归 :用栈显式保存
[begin, end]区间,代替递归调用的函数栈 -
循环处理:循环从栈中取出区间,进行分区排序
-
区间入栈:将分区产生的左右子区间压入栈中,等待后续处理
-
迭代完成:当栈为空时,所有区间都已排序完成
8.3 代码逻辑讲解
cpp
// 假设 ST 是栈结构,STPush 入栈,STPop 出栈,STTop 获取栈顶,STEmpty 判断栈空
while (!STEmpty(&st)) // 栈不为空时继续处理
{
// 1. 从栈中取出一个待排序区间
int begin = STTop(&st); // 获取区间起始位置(栈顶)
STPop(&st); // 弹出
int end = STTop(&st); // 获取区间结束位置
STPop(&st); // 弹出
// 2. 对区间 [begin, end] 进行单趟排序(双指针法)
int keyi = begin; // 基准值位置(最左边)
int prev = begin; // 双指针法分区
int cur = begin + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[keyi], &a[prev]); // 基准值归位
keyi = prev; // 更新基准值位置
// 3. 分区结果:区间分为三部分
// [begin, keyi-1] keyi [keyi+1, end]
// keyi 已在正确位置
// 4. 将需要继续排序的子区间压入栈中
// 注意:先处理右子区间,再处理左子区间(栈是LIFO)
if (keyi + 1 < end) // 右子区间有2个以上元素
{
STPush(&st, end); // 先压入end
STPush(&st, keyi + 1); // 再压入begin(右子区间的起始)
}
if (begin < keyi - 1) // 左子区间有2个以上元素
{
STPush(&st, keyi - 1); // 先压入end
STPush(&st, begin); // 再压入begin(左子区间的起始)
}
}
STDestroy(&st); // 销毁栈,释放资源
8.4 具体执行过程示例
数组 :[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],n=10
初始:
- 栈初始状态:push end=9, push begin=0 → 栈:[9, 0](底部是0,顶部是9)
第一轮:
-
出栈:end=9, begin=0
-
分区:[6,1,2,7,9,3,4,5,10,8] → [5,1,2,3,4,6,9,7,10,8],keyi=5
-
子区间:[0,4] 和 [6,9]
-
入栈:先右后左
-
keyi+1=6 < end=9 → push 9, push 6(右区间[6,9])
-
begin=0 < keyi-1=4 → push 4, push 0(左区间[0,4])
-
-
栈状态:[4,0,9,6](栈顶是6)
第二轮:
-
出栈:end=9, begin=6(右区间[6,9])
-
分区:[9,7,10,8] → [8,7,9,10],keyi=8(相对于原数组索引8)
-
子区间:[6,7] 和 [9,9]
-
入栈:
-
keyi+1=9 < end=9? 否(9<9不成立)→ 右区间不压栈
-
begin=6 < keyi-1=7 → push 7, push 6(左区间[6,7])
-
-
栈状态:[4,0,7,6]
继续处理...(按LIFO顺序)
最终:栈为空,数组有序
8.5 关键细节解析
8.5.1 栈中元素的存储顺序
cpp
// 入栈顺序:先压end,再压begin
STPush(&st, end); // 区间右边界
STPush(&st, begin); // 区间左边界
// 出栈顺序:先出begin,再出end(LIFO)
int begin = STTop(&st); STPop(&st);
int end = STTop(&st); STPop(&st);
-
为什么这样存储?:确保出栈时先得到begin,再得到end
-
栈的特性:后进先出(LIFO),所以入栈顺序与出栈顺序相反
8.5.2 区间入栈的条件
cpp
if (keyi + 1 < end) // 右子区间至少有2个元素
if (begin < keyi - 1) // 左子区间至少有2个元素
-
为什么是
<而不是<=?-
keyi+1 < end:表示从 keyi+1 到 end 至少有2个元素 -
begin < keyi-1:表示从 begin 到 keyi-1 至少有2个元素
-
-
单个元素无需排序:区间长度为1时已有序,不需要入栈
8.5.3 入栈顺序:先右后左
cpp
// 先处理右子区间
if (keyi + 1 < end) {
STPush(&st, end);
STPush(&st, keyi + 1);
}
// 再处理左子区间
if (begin < keyi - 1) {
STPush(&st, keyi - 1);
STPush(&st, begin);
}
-
栈的LIFO特性:后入栈的先处理
-
先右后左的结果 :实际执行时是先处理左区间(因为左区间后入栈)
-
模拟递归顺序:递归快排通常是先递归左子树,这刚好相反
8.5.4 模拟递归的遍历顺序
cpp
// 递归快排:先左后右(深度优先)
QuickSort(arr, left, keyi-1); // 先处理左
QuickSort(arr, keyi+1, right); // 后处理右
// 非递归快排:取决于入栈顺序
// 先右后左入栈 → 实际先处理左(栈顶)
// 先左后右入栈 → 实际先处理右(栈顶)
8.5.5 栈与队列的选择
使用栈(Stack):
cpp
// LIFO:深度优先遍历
// 模拟递归的调用顺序
// 内存使用:O(log n) ~ O(n)
-
优点:模拟递归的自然顺序
-
缺点:可能深度较大
使用队列(Queue):
cpp
// FIFO:广度优先遍历
// 按层级处理所有区间
-
优点:避免深度过大
-
缺点:不模拟递归顺序,可能缓存不友好
8.6 复杂度分析
8.6.1 时间复杂度
-
平均:O(n log n)(与递归版本相同)
-
最坏:O(n²)(与递归版本相同)
8.6.2 空间复杂度
-
栈空间:O(log n) ~ O(n)(取决于分区平衡性)
-
总空间:O(log n) ~ O(n)(与递归版本相当)
8.6.3 优势
-
避免递归深度限制:可处理深度很大的排序
-
可控制内存:可检测栈大小,避免溢出
-
性能稳定:无函数调用开销
-
调试友好:线性执行流程
8.7 完整非递归快排实现
cpp
// 栈结构定义(示例)
typedef struct {
int* data;
int top;
int capacity;
} Stack;
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
STInit(&st);
// 初始区间入栈
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int begin = STTop(&st); STPop(&st);
int end = STTop(&st); STPop(&st);
// 小区间优化
if (end - begin + 1 < 16)
{
InsertSort(a + begin, end - begin + 1);
continue;
}
// 三数取中优化
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
// 双指针法分区
int keyi = Partition(a, begin, end);
// 区间入栈(优化:先处理小区间)
int left_len = keyi - begin;
int right_len = end - keyi;
if (left_len < right_len) // 左区间较小
{
// 先压右区间(较大)
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
// 再压左区间(较小)
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
else // 右区间较小
{
// 先压左区间(较大)
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
// 再压右区间(较小)
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
}
}
STDestroy(&st);
}
8.8 应用场景
-
深度限制环境:递归深度受限的系统
-
性能敏感场景:需要避免函数调用开销
-
调试需求:需要清晰执行流程的调试
-
教学演示:展示如何用循环代替递归
-
嵌入式系统:栈大小可控,避免溢出
8.9 综合理解
非递归快速排序通过手动维护一个栈来模拟递归过程。首先将整个数组区间入栈,然后循环从栈中取出区间进行分区排序。每次分区后,将产生的左右子区间(如果长度≥2)压入栈中继续处理。栈中存储区间边界时,采用先右边界后左边界的顺序,确保出栈时能正确重建区间。与递归版本相比,非递归版本避免了函数调用开销和递归深度限制,可以处理更深层的排序,同时调试更直观。时间复杂度仍然是平均O(n log n),最坏O(n²),空间复杂度取决于分区平衡性。实际使用中可以添加三数取中、小区间优化等策略提升性能。
九、堆排序
9.1 一句话概括
堆排序是一种基于完全二叉树-堆数据结构的比较排序算法,它通过构建最大堆(或最小堆),反复将堆顶元素(最大/最小值)与堆尾交换并调整堆,从而逐步得到有序序列。
9.2 核心思想
-
建堆:将无序数组构建成一个堆(通常是大顶堆用于升序排序)
-
调整堆:维护堆的性质(父节点 ≥ 子节点 或 父节点 ≤ 子节点)
-
交换与调整:将堆顶元素(当前最大值)与堆尾交换,堆大小减1
-
重复:对剩余元素重新调整,重复交换直到堆大小为1
9.3 代码逻辑框架
cpp
// 堆排序主函数
void HeapSort(int* arr, int n)
{
// 1. 建堆:从最后一个非叶子节点开始向下调整
for (int i = n/2 - 1; i >= 0; i--)
{
AdjustDown(arr, n, i);
}
// 2. 排序:交换堆顶与堆尾,调整堆
for (int end = n - 1; end > 0; end--)
{
Swap(&arr[0], &arr[end]); // 堆顶最大值放到末尾
AdjustDown(arr, end, 0); // 对剩余元素调整堆
}
}
// 向下调整函数
void AdjustDown(int* arr, int n, int parent)
{
int child = parent * 2 + 1; // 左孩子索引
while (child < n) // 孩子存在
{
// 选择较大的孩子(大顶堆)
if (child + 1 < n && arr[child + 1] > arr[child])
{
child++; // 右孩子更大
}
// 如果孩子大于父亲,交换并继续向下
if (arr[child] > arr[parent])
{
Swap(&arr[parent], &arr[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break; // 堆性质已满足
}
}
}
9.4 关键细节解析
9.4.1 父子节点索引关系
cpp
// 对于索引 i 的节点:
parent(i) = (i - 1) / 2; // 父节点索引
left_child(i) = 2*i + 1; // 左孩子索引
right_child(i) = 2*i + 2; // 右孩子索引
-
最后一个非叶子节点 :
n/2 - 1(n是元素个数) -
数组下标从0开始的计算公式
9.4.2 建堆的两种方法
cpp
// 方法1:向下调整法(O(n))
for (int i = n/2 - 1; i >= 0; i--)
AdjustDown(arr, n, i);
// 方法2:向上调整法(O(n log n))
for (int i = 1; i < n; i++)
AdjustUp(arr, i);
-
向下调整更高效:从最后一个非叶子节点开始,复杂度O(n)
-
向上调整:逐个插入,复杂度O(n log n)
9.4.3 向下调整算法
cpp
void AdjustDown(int* arr, int n, int parent)
{
int child = parent * 2 + 1; // 左孩子
while (child < n) {
// 1. 选择更大的孩子(大顶堆)
if (child + 1 < n && arr[child+1] > arr[child])
child++;
// 2. 如果孩子大于父亲,交换
if (arr[child] > arr[parent]) {
Swap(&arr[parent], &arr[child]);
parent = child; // 继续向下
child = parent * 2 + 1;
} else {
break; // 堆性质已满足
}
}
}
9.4.4 升序与降序的堆选择
cpp
// 升序排序:使用大顶堆
if (arr[child] > arr[parent]) // 建大堆
if (arr[child + 1] > arr[child]) // 选大孩子
// 降序排序:使用小顶堆
if (arr[child] < arr[parent]) // 建小堆
if (arr[child + 1] < arr[child]) // 选小孩子
9.5 复杂度分析
9.5.1 时间复杂度
-
建堆:O(n)(向下调整法)
-
看起来是O(n log n),但通过数学证明是O(n)
-
最后一层n/2个元素不需要调整,倒数第二层n/4个最多调整1次...
-
-
排序过程:O(n log n)
- 每次调整堆O(log n),执行n-1次
-
总时间复杂度:O(n log n)
- 最好、最坏、平均都是O(n log n)
9.5.2 空间复杂度:O(1)
- 原地排序,只使用常数额外空间
9.5.3 稳定性 :不稳定
- 示例:
[2₁, 2₂, 1]建大堆时,2₁和2₂可能交换顺序
9.6 优缺点
9.6.1 优点
-
时间复杂度稳定:始终是O(n log n),没有最坏情况退化
-
空间效率高:原地排序,O(1)空间复杂度
-
适合外排序:可以处理无法全部装入内存的大数据
-
并行潜力:建堆和调整可以并行化
9.6.2 缺点
-
缓存不友好:对数组跳跃访问,缓存命中率低
-
不稳定排序:相等元素可能改变相对顺序
-
实际性能:通常比快速排序慢(常数因子大)
-
实现复杂:比简单排序算法复杂
9.7 综合理解
堆排序利用堆这种完全二叉树数据结构进行排序。首先将数组构建成一个大顶堆(父节点≥子节点),这个过程从最后一个非叶子节点开始向前进行向下调整,时间复杂度O(n)。然后进入排序阶段:将堆顶元素(最大值)与堆尾交换,堆大小减1,再对堆顶进行向下调整恢复堆性质,重复这个过程直到堆大小为1。堆排序总是O(n log n)时间复杂度,没有最坏情况退化,空间复杂度O(1)是原地排序,但它是稳定排序,且由于对数组的跳跃访问,缓存不友好。它特别适合需要稳定时间复杂度或空间受限的场景,也是优先级队列和Top-K问题的基础算法。
十、归并排序
10.1 一句话概括
归并排序是一种典型的分治算法,它将数组递归地分成两半分别排序,然后将两个有序子数组合并成一个有序数组,最终完成整体排序。
10.2 核心思想
-
分:将数组递归地分成两半,直到每个子数组只有一个元素(自然有序)
-
治:对最小单元(单元素数组)来说,已经是有序的
-
合:将两个有序子数组合并成一个更大的有序数组
-
递归回溯:从最底层开始,层层合并,最终得到完全有序的数组
10.3 代码逻辑讲解
cpp
// 归并排序主函数(对外接口)
void MergeSort(int* a, int n)
{
int* tmp = new int[n]; // 创建临时数组(用于合并)
_MergeSort(a, 0, n - 1, tmp); // 调用递归函数
delete[] tmp; // 释放临时数组
}
// 递归归并排序函数
void _MergeSort(int* a, int left, int right, int* tmp)
{
// 1. 递归终止条件:区间只有一个元素或为空
if (left >= right)
{
return;
}
// 2. 分:计算中点,将数组分成两半
int mid = (right + left) / 2; // 等价于 left + (right - left) / 2
// 3. 递归排序左右两半
// [left, mid] 和 [mid+1, right]
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
// 4. 合:合并两个有序子数组
int begin1 = left, end1 = mid; // 左子数组范围
int begin2 = mid + 1, end2 = right; // 右子数组范围
int index = begin1; // 临时数组的写入位置
// 5. 合并过程(双指针法)
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++]; // 取左子数组元素
}
else
{
tmp[index++] = a[begin2++]; // 取右子数组元素
}
}
// 6. 处理剩余元素(只有一个子数组还有剩余)
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
// 7. 将临时数组的内容拷贝回原数组
for (int i = left; i <= right; i++)
{
a[i] = tmp[i];
}
}
10.4 具体执行过程示例
数组 :[8, 3, 1, 7, 0, 4, 6, 2],n=8
递归分解过程:
初始:[8,3,1,7,0,4,6,2]
第一层分:[8,3,1,7] 和 [0,4,6,2]
第二层分:[8,3] [1,7] | [0,4] [6,2]
第三层分:[8] [3] [1] [7] | [0] [4] [6] [2] ← 单元素,自然有序
递归合并过程(从底层向上):
第三层合并:
3,8\] ← 合并 \[8\] 和 \[3
1,7\] ← 合并 \[1\] 和 \[7
0,4\] ← 合并 \[0\] 和 \[4
2,6\] ← 合并 \[6\] 和 \[2
第二层合并:
1,3,7,8\] ← 合并 \[3,8\] 和 \[1,7
0,2,4,6\] ← 合并 \[0,4\] 和 \[2,6
第一层合并:
0,1,2,3,4,6,7,8\] ← 合并 \[1,3,7,8\] 和 \[0,2,4,6
一次合并的详细步骤 (以合并 [3,8] 和 [1,7] 为例):
左数组:[3,8] (begin1=0, end1=1)
右数组:[1,7] (begin2=2, end2=3)
临时数组:tmp[0..3]
-
比较 a[0]=3 和 a[2]=1 → 1小 → tmp[0]=1, begin2=3
-
比较 a[0]=3 和 a[3]=7 → 3小 → tmp[1]=3, begin1=1
-
比较 a[1]=8 和 a[3]=7 → 7小 → tmp[2]=7, begin2=4
-
右数组用完,复制剩余左数组元素:tmp[3]=8
结果:[1,3,7,8] 复制回原数组
10.5 关键细节解析
10.5.1 分治策略
cpp
// 分
int mid = left + (right - left) / 2; // 防止溢出
// 递归左右
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
// 合
MergeTwoArrays(...);
-
中点计算 :使用
left + (right - left) / 2避免整数溢出 -
递归深度:log₂n 层
10.5.2 合并过程(双指针法)
cpp
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2]) // 注意:这里用 <= 保持稳定性
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
-
稳定性 :当元素相等时,优先取左子数组元素(
<=),保证稳定性 -
时间复杂度:合并两个长度为m和n的数组需要O(m+n)时间
10.5.3 临时数组的使用
cpp
// 创建
int* tmp = new int[n];
// 使用
tmp[index++] = a[begin1++];
// 拷贝回
a[i] = tmp[i];
// 释放
delete[] tmp;
-
作用:避免在合并时覆盖原数组数据
-
空间复杂度:O(n) 额外空间
-
优化:可以只创建一个临时数组,全程复用
10.5.4 递归终止条件
cpp
if (left >= right) // 区间为空或只有一个元素
{
return;
}
-
>=而不是>:当left == right时,区间只有一个元素,自然有序 -
最小子问题:单元素数组已经是有序的,不需要继续分解
10.6 复杂度分析
10.6.1 时间复杂度
-
最好情况:O(n log n)
-
最坏情况:O(n log n)
-
平均情况:O(n log n)
-
推导:
-
递归树深度:log₂n
-
每层合并总工作量:O(n)
-
总时间:O(n) × O(log n) = O(n log n)
-
10.6.2 空间复杂度
-
递归栈:O(log n)(递归深度)
-
临时数组:O(n)
-
总空间:O(n)(临时数组主导)
10.6.3 稳定性:稳定排序
- 合并时使用
<=,相等元素保持原有相对顺序
10.7 优缺点
10.7.1 优点
-
时间复杂度稳定:总是 O(n log n),没有最坏情况
-
稳定排序:保持相等元素的相对顺序
-
适合外排序:可以处理无法全部装入内存的大数据
-
链表友好:对链表排序时不需要额外空间
-
并行化容易:分治策略天然适合并行计算
10.7.3 缺点
-
空间复杂度高:需要 O(n) 额外空间
-
非原地排序:需要复制数据
-
小数据效率低:递归开销较大
-
实现较复杂:比简单排序算法复杂
10.8 综合理解
归并排序采用分治策略,将数组递归地分成两半直到每个子数组只有一个元素(自然有序),然后通过合并操作将有序子数组合并成更大的有序数组。合并过程使用双指针法,比较两个子数组的当前元素,将较小的放入临时数组。归并排序的时间复杂度始终是O(n log n),空间复杂度O(n),是稳定排序算法。它的优势在于稳定性和对大数据、链表排序的适用性,但需要额外存储空间。归并排序也是外排序和并行计算的基础算法。
结语
掌握这些排序方法后,不妨尝试在项目中实践。如果有其他高效算法,期待你的分享!
看到这里请各位动动小手给博主点个四连吧,谢谢啦!!!
