排序算法详解:从入门到精通

🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 | 《小游戏与项目》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。
📝 前言
提到排序,很多人的第一反应就是调库------C++的sort、Python的sorted、Java的Arrays.sort,一行代码搞定。但是,你是否真正理解这些排序函数背后的原理?如果你在面试中被问到"快速排序的最坏时间复杂度是多少",或者"为什么归并排序是稳定的",你能回答上来吗?
排序算法是数据结构中最基础、最重要的内容之一。无论是考研、求职面试,还是实际开发中的性能优化,排序算法都是必考知识点。更重要的是,排序算法蕴含了许多经典的算法思想:分治、递归、贪心、堆等,掌握排序算法能让你对算法设计有更深刻的理解。
不过不用担心------本文将系统地讲解八大经典排序算法,从最简单的冒泡排序到高效的快速排序、归并排序,每种算法都会给出详细的图解、代码实现和复杂度分析,让你真正理解排序的本质。
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 冒泡排序、选择排序、插入排序 | 理解排序基本思想,小规模数据排序 |
| 希尔排序 | 插入排序的优化版本,理解增量序列的作用 |
| 快速排序(递归与非递归实现) | 面试必考,理解分治思想,掌握三种partition方法 |
| 归并排序(递归与非递归实现) | 理解分治思想,掌握外部排序的核心 |
| 堆排序 | 理解堆结构,掌握优先队列的基础 |
| 计数排序 | 理解非比较排序的思想,处理范围固定的整数排序 |
📌 前置知识: 需要掌握C语言基础语法、数组操作、函数调用、递归的基本概念。
文章目录
- 排序算法详解:从入门到精通
-
- [📝 前言](#📝 前言)
- [一、🔥 排序算法概览](#一、🔥 排序算法概览)
-
- [1.1 按比较方式分类](#1.1 按比较方式分类)
- [1.2 按稳定性分类](#1.2 按稳定性分类)
- [1.3 时间复杂度对比](#1.3 时间复杂度对比)
- [二、🔷 冒泡排序](#二、🔷 冒泡排序)
-
- [2.1 算法思想](#2.1 算法思想)
- [2.2 动图演示](#2.2 动图演示)
- [2.3 代码实现](#2.3 代码实现)
- [2.4 复杂度分析](#2.4 复杂度分析)
- [2.5 优化版本](#2.5 优化版本)
- [三、🔷 选择排序](#三、🔷 选择排序)
-
- [3.1 算法思想](#3.1 算法思想)
- [3.2 动图演示](#3.2 动图演示)
- [3.3 代码实现](#3.3 代码实现)
- [3.4 复杂度分析](#3.4 复杂度分析)
- [四、🔷 插入排序](#四、🔷 插入排序)
-
- [4.1 算法思想](#4.1 算法思想)
- [4.2 动图演示](#4.2 动图演示)
- [4.3 代码实现](#4.3 代码实现)
- [4.4 复杂度分析](#4.4 复杂度分析)
- [五、🔷 希尔排序](#五、🔷 希尔排序)
-
- [5.1 算法思想](#5.1 算法思想)
- [5.2 增量序列的作用](#5.2 增量序列的作用)
- [5.3 代码实现](#5.3 代码实现)
- [5.4 复杂度分析](#5.4 复杂度分析)
- [六、🔷 堆排序](#六、🔷 堆排序)
-
- [6.1 堆的基本概念](#6.1 堆的基本概念)
- [6.2 算法思想(升序使用大根堆)](#6.2 算法思想(升序使用大根堆))
- [6.3 向下调整算法](#6.3 向下调整算法)
- [6.4 堆排序实现](#6.4 堆排序实现)
- [6.5 复杂度分析](#6.5 复杂度分析)
- [七、🔷 快速排序](#七、🔷 快速排序)
-
- [7.1 算法思想](#7.1 算法思想)
- [7.2 三种 Partition 方法](#7.2 三种 Partition 方法)
-
- [7.2.1 Hoare 法(左右指针法)](#7.2.1 Hoare 法(左右指针法))
- [7.2.2 挖坑法](#7.2.2 挖坑法)
- [7.2.3 双指针法(前后指针法)](#7.2.3 双指针法(前后指针法))
- [7.3 快速排序主体](#7.3 快速排序主体)
- [7.4 三数取中优化](#7.4 三数取中优化)
- [7.5 非递归实现](#7.5 非递归实现)
- [7.6 复杂度分析](#7.6 复杂度分析)
- [八、🔷 归并排序](#八、🔷 归并排序)
-
- [8.1 算法思想](#8.1 算法思想)
- [8.2 递归实现](#8.2 递归实现)
- [8.3 非递归实现](#8.3 非递归实现)
- [8.4 复杂度分析](#8.4 复杂度分析)
- [九、🔷 计数排序](#九、🔷 计数排序)
-
- [9.1 算法思想](#9.1 算法思想)
- [9.2 代码实现](#9.2 代码实现)
- [9.3 复杂度分析](#9.3 复杂度分析)
- [十、🔷 排序算法对比与选择](#十、🔷 排序算法对比与选择)
-
- [10.1 性能对比(100万元素测试)](#10.1 性能对比(100万元素测试))
- [10.2 选择建议](#10.2 选择建议)
- [十一、🔷 排序算法的稳定性分析](#十一、🔷 排序算法的稳定性分析)
-
- [11.1 什么是稳定性?](#11.1 什么是稳定性?)
- [11.2 为什么稳定性重要?](#11.2 为什么稳定性重要?)
- [11.3 各排序算法的稳定性](#11.3 各排序算法的稳定性)
- [十二、🤔 几个思考题](#十二、🤔 几个思考题)
-
- [1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?](#1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?)
- [2️⃣ 为什么归并排序需要额外的 O(n) 空间?](#2️⃣ 为什么归并排序需要额外的 O(n) 空间?)
- [3️⃣ 希尔排序为什么比插入排序快?](#3️⃣ 希尔排序为什么比插入排序快?)
一、🔥 排序算法概览
排序算法可以根据不同的维度进行分类:
1.1 按比较方式分类
- 比较排序:通过比较元素之间的大小关系来确定相对位置。如冒泡排序、快速排序、归并排序等。
- 非比较排序:不通过比较,而是利用元素的特性(如值的范围)进行排序。如计数排序、基数排序、桶排序。
1.2 按稳定性分类
- 稳定排序:排序后,相等元素的相对顺序保持不变。如冒泡排序、插入排序、归并排序。
- 不稳定排序:排序后,相等元素的相对顺序可能改变。如选择排序、快速排序、希尔排序。
1.3 时间复杂度对比
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3) | O(n²) | O(n) | O(1) | 不稳定 |
| 快速排序 | O(nlogn) | O(n²) | O(nlogn) | O(logn) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
💡 可以看到,没有哪种排序算法在所有情况下都是最优的,需要根据实际场景选择合适的排序算法。
二、🔷 冒泡排序
冒泡排序是最简单的排序算法之一。它的基本思想是:通过相邻元素的比较和交换,将最大的元素"冒泡"到数组的末尾。
2.1 算法思想
- 比较相邻的两个元素,如果前者大于后者,则交换它们。
- 对每一对相邻元素做同样的比较,从数组开头到结尾。这样,最大的元素就会"冒泡"到最后。
- 重复上述过程,每次比较的范围缩小一个元素(因为最后的元素已经有序)。
- 直到整个数组有序。
2.2 动图演示
以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:
第一轮:
- 比较 9 和 1,9 > 1,交换 →
{1, 9, 2, 5, 7, 4, 6, 3} - 比较 9 和 2,9 > 2,交换 →
{1, 2, 9, 5, 7, 4, 6, 3} - 比较 9 和 5,9 > 5,交换 →
{1, 2, 5, 9, 7, 4, 6, 3} - 比较 9 和 7,9 > 7,交换 →
{1, 2, 5, 7, 9, 4, 6, 3} - 比较 9 和 4,9 > 4,交换 →
{1, 2, 5, 7, 4, 9, 6, 3} - 比较 9 和 6,9 > 6,交换 →
{1, 2, 5, 7, 4, 6, 9, 3} - 比较 9 和 3,9 > 3,交换 →
{1, 2, 5, 7, 4, 6, 3, 9}
第一轮结束,最大的元素 9 已经"冒泡"到末尾。
第二轮到第七轮类似,最终得到有序数组 {1, 2, 3, 4, 5, 6, 7, 9}。
2.3 代码实现
c
// 冒泡排序
void BubbleSort(int* a, int sz)
{
for (int j = 0; j < sz - 1; j++) // 外层循环控制轮数
{
// 单趟:从0开始,每次排好一个最大的元素到末尾
for (int i = 0; i < sz - j - 1; i++) // 内层循环控制比较范围
{
if (a[i] > a[i + 1]) // 相邻元素比较
{
Swap(&a[i], &a[i + 1]); // 前者大于后者,交换
}
}
}
}
2.4 复杂度分析
-
时间复杂度:
- 最好情况(数组已有序):O(n),但需要优化(加标志位)才能达到。
- 最坏情况(数组逆序):O(n²),需要 n-1 轮,每轮比较 n-j-1 次。
- 平均情况:O(n²)。
-
空间复杂度:O(1),只需要常数个额外空间。
-
稳定性:稳定。相等元素不会交换,相对顺序保持不变。
2.5 优化版本
可以增加一个标志位,如果某一轮没有发生交换,说明数组已经有序,可以提前结束:
c
// 冒泡排序优化版
void BubbleSortOpt(int* a, int sz)
{
for (int j = 0; j < sz - 1; j++)
{
int flag = 0; // 标志位:本轮是否发生交换
for (int i = 0; i < sz - j - 1; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
flag = 1; // 发生了交换
}
}
if (flag == 0) // 本轮没有交换,数组已有序
break;
}
}
优化后,最好情况下时间复杂度为 O(n)。
⚠️ 冒泡排序虽然简单,但效率较低,仅适用于小规模数据或教学演示。
三、🔷 选择排序
选择排序的思想也很直观:每次从未排序部分选择最小的元素,放到已排序部分的末尾。
3.1 算法思想
- 在未排序部分找到最小元素的下标。
- 将最小元素与未排序部分的第一个元素交换。
- 已排序部分扩大一个元素。
- 重复上述过程,直到所有元素有序。
3.2 动图演示
以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:
第一轮: 找到最小元素 1(下标 1),与第一个元素 9 交换 → {1, 9, 2, 5, 7, 4, 6, 3}
第二轮: 在剩余元素中找到最小元素 2(下标 2),与第二个元素 9 交换 → {1, 2, 9, 5, 7, 4, 6, 3}
以此类推,最终得到有序数组。
3.3 代码实现
这里给出一个优化版本,每次同时找最大和最小值:
c
// 选择排序(优化版:每次找最大和最小)
void SelectSort(int* a, int sz)
{
int begin = 0, end = sz - 1;
while (begin < end)
{
int maxi = begin, mini = begin;
// 单趟:找最大和最小元素的下标
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
// 处理特殊情况:最大值在开头或最小值在末尾
if (maxi == begin || mini == end)
{
Swap(&a[mini], &a[maxi]);
int tmp = mini;
mini = maxi;
maxi = tmp;
}
// 交换:最大值放末尾,最小值放开头
Swap(&a[maxi], &a[end]);
Swap(&a[mini], &a[begin]);
end--;
begin++;
}
}
3.4 复杂度分析
-
时间复杂度:
- 最好/最坏/平均:都是 O(n²)。因为无论数组是否有序,都需要比较所有元素。
-
空间复杂度:O(1)。
-
稳定性 :不稳定。例如数组
{5, 5, 3},第一轮会将第一个 5 和 3 交换,导致两个 5 的相对顺序改变。
💡 选择排序的交换次数最少(最多 n-1 次),适合交换成本高的场景。
四、🔷 插入排序
插入排序类似于我们打扑克牌时整理手牌的方式:每次将一张牌插入到已排序部分的适当位置。
4.1 算法思想
- 将数组的第一个元素视为已排序部分。
- 取出下一个元素,在已排序部分从后向前扫描。
- 如果已排序元素大于新元素,则将该元素后移一位。
- 重复步骤3,直到找到已排序元素小于等于新元素的位置。
- 将新元素插入到该位置。
- 重复步骤2-5,直到所有元素有序。
4.2 动图演示
以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:
第一步: 已排序部分 {9},待插入元素 1
- 9 > 1,9 后移 →
{_, 9}(_表示空位) - 到达开头,插入 1 →
{1, 9}
第二步: 已排序部分 {1, 9},待插入元素 2
- 9 > 2,9 后移 →
{1, _, 9} - 1 < 2,停止,插入 2 →
{1, 2, 9}
以此类推。
4.3 代码实现
c
// 插入排序
void InsertSort(int* a, int sz)
{
for (int i = 1; i < sz; i++) // 从第二个元素开始,每次插入一个元素
{
// 单趟:将 a[i] 插入到 [0, i-1] 的有序区间
int end = i;
int tmp = a[end]; // 保存待插入元素
while (end > 0)
{
if (tmp < a[end - 1]) // 前面的元素比待插入元素大
{
a[end] = a[end - 1]; // 后移
end--;
}
else
{
break; // 找到合适位置
}
}
a[end] = tmp; // 插入
}
}
4.4 复杂度分析
-
时间复杂度:
- 最好情况(数组已有序):O(n),每个元素只需比较一次。
- 最坏情况(数组逆序):O(n²),每个元素都要比较和移动多次。
- 平均情况:O(n²)。
-
空间复杂度:O(1)。
-
稳定性:稳定。相等元素不会交换。
⚠️ 插入排序对于基本有序的数组效率很高,这也是希尔排序的基础。
五、🔷 希尔排序
希尔排序是插入排序的改进版本,又称"缩小增量排序"。它通过设置增量(gap),将数组分成若干子序列,分别进行插入排序。
5.1 算法思想
- 选择一个增量序列,通常取 gap = n/3 + 1(n为数组长度)。
- 按增量将数组分成若干子序列,每个子序列进行插入排序。
- 逐步缩小增量,重复步骤2。
- 当 gap = 1 时,进行一次标准插入排序,完成排序。
5.2 增量序列的作用
希尔排序的核心在于增量序列的选择:
- gap > 1 时:预排序,让大元素快速后移,小元素快速前移。
- gap = 1 时:进行一次标准插入排序。由于数组已经基本有序,这次排序会非常快。
5.3 代码实现
c
// 希尔排序
void ShellSort(int* a, int sz)
{
int gap = sz;
while (gap > 1)
{
// gap > 1 时是预排序
// gap == 1 时是插入排序
gap = gap / 3 + 1; // +1 保证最后一次 gap 一定是 1
// 对每个子序列进行插入排序
for (int i = 0; i < sz - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
5.4 复杂度分析
-
时间复杂度:
- 平均情况:约 O(n^1.3),具体取决于增量序列。
- 最坏情况:O(n²)。
-
空间复杂度:O(1)。
-
稳定性:不稳定。相同元素可能被分到不同的子序列。
💡 希尔排序打破了 O(n²) 的限制,是第一个突破这个时间复杂度的排序算法。
六、🔷 堆排序
堆排序利用堆这种数据结构进行排序。堆是一棵完全二叉树,分为大根堆和小根堆。
6.1 堆的基本概念
- 大根堆:每个节点的值都大于等于其子节点的值。根节点是最大值。
- 小根堆:每个节点的值都小于等于其子节点的值。根节点是最小值。
6.2 算法思想(升序使用大根堆)
- 建堆:从最后一个非叶子节点开始,向上调整,构建大根堆。
- 排序 :
- 将堆顶(最大值)与堆末尾元素交换。
- 堆大小减1,对堆顶进行向下调整,恢复大根堆性质。
- 重复上述过程,直到堆大小为1。
6.3 向下调整算法
c
// 向下调整建堆
void Adjustdown(int* a, int n, size_t parent)
{
size_t child = 2 * parent + 1; // 左孩子下标
while (child < n)
{
// 选出较大的孩子
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
// 如果孩子比父亲大,交换
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
return;
}
}
}
6.4 堆排序实现
c
// 堆排序
void HeapSort(int* a, int sz)
{
// 向下调整建大根堆
int end = sz - 1;
for (int i = (end - 1) / 2; i >= 0; i--) // 从最后一个非叶子节点开始
{
Adjustdown(a, sz, i);
}
// 排序:每次将堆顶(最大值)换到末尾
for (size_t i = 0; i < sz; i++)
{
end = sz - i - 1;
Swap(&a[0], &a[end]); // 堆顶与末尾交换
Adjustdown(a, end, 0); // 向下调整恢复堆性质
}
}
6.5 复杂度分析
-
时间复杂度:
- 建堆:O(n)
- 排序:n 次调整,每次调整 O(logn),共 O(nlogn)
- 总时间复杂度:O(nlogn)
-
空间复杂度:O(1),原地排序。
-
稳定性:不稳定。
⚠️ 堆排序是原地排序,不需要额外空间,但实际应用中由于缓存不友好,性能往往不如快速排序。
七、🔷 快速排序
快速排序是最常用的排序算法之一,采用分治思想。
7.1 算法思想
- 选择基准值(pivot):从数组中选择一个元素作为基准。
- 分区(Partition) :将数组分为两部分,使得:
- 左边部分的元素都小于等于基准值。
- 右边部分的元素都大于等于基准值。
- 基准值位于最终位置。
- 递归排序:对左右两部分分别递归进行快速排序。
7.2 三种 Partition 方法
7.2.1 Hoare 法(左右指针法)
c
// Hoare 法快排
int hoareSort(int* a, int left, int right)
{
int keyi = left; // 选择最左边的元素作为基准
int begin = left, end = right;
while (begin < end)
{
// 右边先走找小
while (begin < end && a[end] >= a[keyi])
end--;
// 左边再走找大
while (begin < end && a[begin] <= a[keyi])
begin++;
// 交换左边大值和右边小值
Swap(&a[begin], &a[end]);
}
// 基准值放到正确位置
Swap(&a[keyi], &a[begin]);
return begin; // 返回基准值的最终位置
}
7.2.2 挖坑法
c
// 挖坑法快排
int pitSort(int* a, int left, int right)
{
int piti = left; // 坑的位置
int pit = a[piti]; // 基准值
int begin = left, end = right;
while (begin < end)
{
// 右边找小,填到左边的坑
while (begin < end && a[end] >= pit)
end--;
a[piti] = a[end];
piti = end;
// 左边找大,填到右边的坑
while (begin < end && a[begin] <= pit)
begin++;
a[piti] = a[begin];
piti = begin;
}
// 基准值填到最后的坑
a[begin] = pit;
return begin;
}
7.2.3 双指针法(前后指针法)
c
// 双指针法快排
int ptrSort(int* a, int left, int right)
{
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi])
{
prev++;
if (a[cur] != a[prev])
{
Swap(&a[cur], &a[prev]);
}
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
7.3 快速排序主体
c
// 快速排序
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 小区间优化:元素较少时使用插入排序
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
// 三数取中:避免最坏情况
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = hoareSort(a, left, right); // 分区
QuickSort(a, left, keyi - 1); // 排左边
QuickSort(a, keyi + 1, right); // 排右边
}
}
7.4 三数取中优化
c
// 三数取中:选择 left、right、midi 中间大小的元素
int GetMidi(int* a, int left, int right)
{
int midi = (left + right) / 2;
if (a[left] < a[right])
{
if (a[midi] < a[left])
return left;
else if (a[right] < a[midi])
return right;
else
return midi;
}
else
{
if (a[midi] < a[right])
return right;
else if (a[left] < a[midi])
return left;
else
return midi;
}
}
7.5 非递归实现
使用栈模拟递归过程:
c
// 快排非递归
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st))
{
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
int midi = GetMidi(a, begin, end);
Swap(&a[begin], &a[midi]);
int keyi = hoareSort(a, begin, end);
// 将子区间入栈
if (keyi + 1 < end)
{
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
7.6 复杂度分析
-
时间复杂度:
- 平均情况:O(nlogn)
- 最坏情况:O(n²),当数组已有序且每次选择最左或最右元素作为基准时。通过三数取中可以避免。
-
空间复杂度:
- 递归版:O(logn),递归栈深度。
- 非递归版:O(logn),显式栈深度。
-
稳定性:不稳定。
💡 快速排序是实践中最快的排序算法之一,C标准库的
qsort就是快速排序的实现。
八、🔷 归并排序
归并排序采用分治思想,将数组分成两半,分别排序后再合并。
8.1 算法思想
- 分解:将数组从中间分成两半,递归处理。
- 合并:将两个有序子数组合并成一个有序数组。
8.2 递归实现
c
// 归并排序子函数
void _MergeSort(int* a, int* tmp, int left, int right)
{
if (left >= right)
return;
int midi = (left + right) / 2;
_MergeSort(a, tmp, left, midi); // 排左半边
_MergeSort(a, tmp, midi + 1, right); // 排右半边
// 合并两个有序区间
int i = left;
int begin1 = left, end1 = midi;
int begin2 = midi + 1, end2 = right;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
tmp[i++] = a[begin1++];
else
tmp[i++] = a[begin2++];
}
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
// 拷贝回原数组
memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}
// 归并排序
void MergeSort(int* a, int sz)
{
int* tmp = (int*)malloc(sizeof(int) * sz);
if (tmp == NULL)
{
perror("tmp malloc fail");
exit(1);
}
_MergeSort(a, tmp, 0, sz - 1);
free(tmp);
}
8.3 非递归实现
c
// 归并排序非递归子函数
void _MergeSortNonR(int* a, int* tmp, int left, int right)
{
int gap = 1;
while (gap < right - left + 1)
{
for (int i = 0; i < right - left + 1; i += 2 * gap)
{
int j = i;
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
// 处理边界情况
if (begin2 >= right - left + 1)
break;
if (end2 >= right - left + 1)
end2 = right - left + 1 - 1;
// 合并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[j++] = a[begin1++];
else
tmp[j++] = a[begin2++];
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));
}
gap *= 2;
}
}
8.4 复杂度分析
-
时间复杂度:
- 所有情况:O(nlogn),非常稳定。
-
空间复杂度:O(n),需要额外的临时数组。
-
稳定性:稳定。
⚠️ 归并排序需要额外的 O(n) 空间,但它是稳定排序中效率最高的,常用于外部排序。
九、🔷 计数排序
计数排序是一种非比较排序,适用于范围固定的整数排序。
9.1 算法思想
- 找出数组中的最大值和最小值,确定元素范围。
- 创建计数数组,统计每个元素出现的次数。
- 根据统计结果,将元素放回原数组。
9.2 代码实现
c
// 计数排序
void CountSort(int* a, int sz)
{
// 找出最大值和最小值
int max = a[0], min = a[0];
for (int i = 0; i < sz; i++)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
// 建立计数数组
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
// 统计次数
for (int i = 0; i < sz; i++)
count[a[i] - min]++;
// 填入原数组
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
a[j++] = i + min;
}
free(count);
}
9.3 复杂度分析
- 时间复杂度:O(n + k),其中 k 是元素范围(max - min + 1)。
- 空间复杂度:O(k),计数数组的大小。
- 稳定性:稳定(需要额外处理)。
⚠️ 计数排序适用于元素范围不大的整数排序,不适合浮点数或范围很大的整数。
十、🔷 排序算法对比与选择
10.1 性能对比(100万元素测试)
| 排序算法 | 耗时(毫秒) |
|---|---|
| 插入排序 | 很慢(O(n²)) |
| 希尔排序 | 较快 |
| 选择排序 | 很慢(O(n²)) |
| 堆排序 | 较快 |
| 快速排序 | 最快 |
| 归并排序 | 较快 |
| 冒泡排序 | 很慢(O(n²)) |
10.2 选择建议
| 场景 | 推荐排序算法 |
|---|---|
| 小规模数据(n < 50) | 插入排序 |
| 大规模数据,内存充足 | 快速排序 |
| 大规模数据,需要稳定排序 | 归并排序 |
| 数据范围固定的整数 | 计数排序 |
| 数据基本有序 | 插入排序 |
| 外部排序(数据在磁盘) | 归并排序 |
十一、🔷 排序算法的稳定性分析
11.1 什么是稳定性?
稳定性是指排序后,相等元素的相对顺序是否保持不变。
- 稳定排序:排序后,相等元素的相对顺序不变。
- 不稳定排序:排序后,相等元素的相对顺序可能改变。
11.2 为什么稳定性重要?
在实际应用中,我们可能需要对多个字段进行排序。例如,先按年龄排序,再按姓名排序。如果排序不稳定,第一次排序的结果可能会被第二次排序打乱。
11.3 各排序算法的稳定性
| 排序算法 | 稳定性 | 原因 |
|---|---|---|
| 冒泡排序 | 稳定 | 相等元素不交换 |
| 插入排序 | 稳定 | 相等元素不移动 |
| 归并排序 | 稳定 | 合并时相等元素先取左边 |
| 选择排序 | 不稳定 | 交换可能改变相对顺序 |
| 希尔排序 | 不稳定 | 相同元素可能分到不同组 |
| 快速排序 | 不稳定 | 交换可能改变相对顺序 |
| 堆排序 | 不稳定 | 堆调整可能改变相对顺序 |
| 计数排序 | 稳定 | 按顺序填入 |
十二、🤔 几个思考题
学完本文,来试试回答这些问题:
1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?
答: 快速排序的时间复杂度取决于分区是否均匀。
-
平均情况:每次分区都大致将数组分成两半,递归深度为 logn,每层处理 n 个元素,总时间复杂度为 O(nlogn)。
-
最坏情况:当数组已经有序(升序或降序),且每次选择最左或最右元素作为基准时,每次分区只能减少一个元素,递归深度变为 n,总时间复杂度为 O(n²)。
💡 通过三数取中法选择基准,可以有效避免最坏情况的发生。
2️⃣ 为什么归并排序需要额外的 O(n) 空间?
答: 归并排序在合并两个有序子数组时,需要一个临时数组来存储合并结果。如果直接在原数组上合并,可能会覆盖尚未处理的元素。
💡 这也是归并排序的一个缺点,但在外部排序中,归并排序是唯一可行的选择。
3️⃣ 希尔排序为什么比插入排序快?
答: 希尔排序通过增量序列将数组分成若干子序列,分别进行插入排序。当 gap 较大时,大元素可以快速后移,小元素可以快速前移,避免了插入排序中元素只能逐位移动的低效问题。
当 gap 减小到 1 时,数组已经基本有序,此时进行插入排序非常高效。
💡 希尔排序的时间复杂度取决于增量序列的选择,最佳增量序列可以使时间复杂度接近 O(nlogn)。
✅ 本节完...
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!