1.排序的概念及运用
1.1概念
排序:所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作,以便更容易查找、组织或分析数据。
1.2运用
购物筛选排序
院校排名
1.3常见排序算法
2.实现常见排序算法
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
2.1 插入排序
把待排序的记录按其关键码值的大小逐个插入到⼀个已经排好序的有序序列中,直到所有的记录插入完为止,得到⼀个新的有序序列 。
2.1.1 直接插入排序
直接插入排序(Direct Insertion Sort)是一种简单直观的排序算法,它通过逐步构建有序序列,将未排序部分的元素插入到已排序部分的适当位置,直到整个数组排序完成。
基本思想
1.将数组分为 已排序部分 和 未排序部分。
初始时,已排序部分只有第一个元素。
未排序部分为剩余的元素。
2.依次从未排序部分取出一个元素,插入到已排序部分的正确位置。
3.重复以上操作,直到未排序部分为空。
算法步骤
1.从数组的第二个元素(索引为 1)开始,依次向后遍历。
对当前元素,在已排序部分从后向前查找其插入位置:
如果已排序部分的元素大于当前元素,则将其向后移动。
2.找到插入位置后,将当前元素放入。
3.重复此过程,直到所有元素排序完成。
代码实现
void InsertSort(int* a, int n) {
for (int i = 1; i < n; i++) {
// 当前要插入的元素
int tmp = a[i];
// 已排序部分的最后一个元素的索引
int j = i - 1;
// 从后向前查找插入位置
while (j >= 0 && a[j] > tmp) {
a[j + 1] = a[j]; // 后移元素
j--;
}
// 将当前元素插入到正确的位置
a[j + 1] = tmp;
}
}
特性总结
算法复杂度
- 时间复杂度:
- 最坏情况(数组逆序):
O(n^2)
- 最好情况(数组已排序):
O(n)
- 平均情况:
O(n^2)
- 最坏情况(数组逆序):
- 空间复杂度:原地排序,不需要额外的辅助空间,空间复杂度为
O(1)
。 - 稳定性:直接插入排序是稳定的,因为在插入过程中不会改变相等元素的相对顺序。
优缺点
优点:
- 简单直观:实现容易,适合小规模数据。
- 性能良好:当数组接近有序时,插入排序的效率很高,接近
O(n)
。
缺点:
- 效率低下:对大规模、随机分布的数组排序效率较低,时间复杂度较高。
- 大量数据移动:当数组逆序时,几乎每次插入都需要移动大量元素。
适用场景
- 数据量较小(如
n <= 100
)时,插入排序是一种简单有效的选择。 - 数据本身接近有序时(如几乎按升序排列),插入排序效率很高。
2.1.2 希尔排序
希尔排序又称缩小增量法,是一种基于插入排序的改进算法,得名于其发明者 Donald Shell。它通过分组和逐步缩小分组间距(增量),使得数据能够更快地接近有序,从而提高排序效率。
基本思想
先选定⼀个整数(通常是
gap = n/3 + 1
),把待排序文件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进⾏排序,然后gap = gap/3 + 1
得到下⼀个整数,再将数组分成各组,进入插入排序,当gap = 1
时,就相当于直接插入排序。它是在直接插入排序算法的基础上进行改进而来的,综合来说它的效率肯定是要高于直接插入排序算法的。
算法步骤
- 初始化
gap = n / 3 + 1
,其中n
是数组的长度。 - 按照当前的
gap
将数组分组,对每组进行插入排序。 - 缩小
gap
,继续分组排序,直到gap = 1
。 - 当
gap = 1
时,进行最后一次插入排序,数组有序。
代码实现
void ShellSort(int* a, int n) {
int gap = n;
// 缩小增量,直到 gap == 1
while (gap > 1) {
gap = gap / 3 + 1;
// 分组
for (int group = 0; group < gap; group++) { // 控制每组
// 每组独立进行插入排序
for (int i = group + gap; i < n; i += gap) {
int tmp = a[i];
int j = i - gap;
while (j >= group && a[j] > tmp) {
a[j + gap] = a[j];
j -= gap;
}
a[j + gap] = tmp;
}
}
}
}
显式地将数组分组,每组独立排序后再继续调整增量 gap,
这种更好理解分组排序的原理,但代码稍显冗长,所以我一般用这种
void ShellSort1(int* a, int n) {
int gap = n;
// 缩小增量,直到 gap == 1
while (gap > 1) {
gap = gap / 3 + 1;
// 从 gap 开始,模拟插入排序
for (int i = gap; i < n; i++) {
int tmp = a[i]; // 当前待插入的元素
int j = i - gap;
// 插入排序逻辑,按 gap 步长比较和移动
while (j >= 0 && a[j] > tmp) { // 在同组中向前查找
a[j + gap] = a[j]; // 将较大的元素后移
j -= gap; // 按 gap 步长继续比较
}
a[j + gap] = tmp; // 插入到合适位置
}
}
}
这种方法也叫“简单希尔排序”(Simple Shell Sort),或者直接叫并着走,它直接在外层控制增量 gap
,然后在整个数组中按步长进行插入排序,逻辑比较直观,效率高。这种实现方法更贴近希尔排序的本质,适合直接应用于实际场景。
它的核心思想是
希尔排序的分组本质上是用 步长
gap
控制哪些元素属于一个组。
- 在并着走的实现中,假设
gap = 3
:
- 对于索引为
0, 3, 6, 9...
的元素,它们属于第一组。- 对于索引为
1, 4, 7, 10...
的元素,它们属于第二组。- 对于索引为
2, 5, 8, 11...
的元素,它们属于第三组。- 并着走的逻辑是遍历整个数组,隐式地让这些组在同一个循环中排序。
在
gap
的控制下:
- 每个位置
i
的元素a[i]
,都会和同组的前一个元素(位置i - gap
)进行比较。- 如果需要交换位置,就不断向前检查,直到找到合适位置。
特性总结
算法复杂度
1.时间复杂度:
最坏情况:O(n^2)
(不理想的增量序列)。
平均情况:O(n^(3/2))
或更优(根据增量序列)。
最好情况:接近 O(n)
。
可以分成内外层来分析
外层循环(控制增量 gap):应该能直接看出来吧,无疑问的O(log(n)),就是增量序列的长度。
内层循环(分组插入排序):
- 当gap为n / 3时,移动总数为: n / 3 * (1 + 2) = n
- 当gap为n / 9时,移动总数为: n / 9 * (1 + 2 + 3 + .... + 8) = (n / 9) * (8 (1 + 8) / 2)= 4n
- 最后⼀趟,gap=1,即直接插入排序,内层循环排序消耗为n
可知,比较次数是先增加再下降的,或者画一张曲线图更好理解
因此,希尔排序在最初和最后的排序的次数都为n,即前⼀阶段排序次数是逐渐上升的状态,当到达某⼀顶点时,排序次数逐渐下降至n,而该顶点的计算暂时无法给出具体的计算过程
2.空间复杂度:使用原地排序,额外空间复杂度为 O(1)
。
3.稳定性:不稳定,因为在分组时可能会打乱相同元素的相对顺序。
优缺点
优点:
- 简单易实现。
- 对中等规模的数组(如 1 万以下)性能较好。
- 在基本有序的数组上效率较高。
缺点:
- 不稳定,不能保证相同元素的顺序。
- 对非常大的数组,效率不如快速排序。
增量序列的选择
- 希尔增量:
gap = gap / 3 + 1
。 - Hibbard增量:
gap = 2^k - 1
。 - Sedgewick增量:
gap = 9 * 4^i - 9 * 2^i + 1
等。 - 增量序列的选择会显著影响希尔排序的性能。
适用场景
- 数据规模适中(几百到几万)。
- 数据分布无规律,但需要排序效率较高的场景。
- 对性能要求高,但稳定性不重要的场景。
2.2 选择排序
每⼀次从待排序的数据元素中选出最小或最大的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2.1 直接选择排序
直接选择排序(Selection Sort)是一种简单的排序算法,其基本思想是每次从待排序的序列中选择最小(或最大)元素,将其放到已排序序列的末尾。该算法属于交换排序类,通过多次选择最小(或最大)元素并与当前序列中的元素交换来逐步完成排序。
基本思想
- 选择最小元素:在每一轮中,找到剩余未排序部分的最小元素。
- 交换位置:将找到的最小元素与当前已排序部分的末尾元素交换位置。
- 重复:每次排好一个元素后,缩小待排序部分的范围,继续执行选择和交换操作,直到所有元素排序完成。
算法步骤
- 假设数组长度为
n
,开始时将整个数组看作未排序部分。 - 从第一个元素开始,扫描剩余部分,找到最小的元素。
- 将最小元素与当前待排序部分的第一个元素交换。
- 重复此过程,直到排序完成。
代码实现
void SelectionSort(int* a, int n) {
// 外层循环控制已排序部分的末尾
for (int i = 0; i < n - 1; i++) {
int minIndex = i;
// 内层循环在未排序部分中寻找最小值
for (int j = i + 1; j < n; j++) {
if (a[j] < a[minIndex]) {
minIndex = j; // 记录最小元素的索引
}
}
// 交换最小元素与当前已排序部分的下一个元素
if (minIndex != i) {
int temp = a[i];
a[i] = a[minIndex];
a[minIndex] = temp;
}
}
}
特性总结
算法复杂度
1.时间复杂度:
最坏情况(数组逆序):O(n^2)
最好情况(数组已排序):O(n^2)
(即数组已经排序,选择排序仍需执行完整的循环)
平均情况:O(n^2)
2.空间复杂度:原地排序:不需要额外的辅助空间,空间复杂度为 O(1)
。
3.稳定性:不稳定:在排序过程中可能会改变相等元素的相对顺序,因为会交换位置。
优缺点
优点:
- 简单直观:直接选择排序算法的实现简单,易于理解,适合小规模数据。
- 空间效率高:只使用常数级的额外空间,内存开销小。
缺点:
- 效率低下:由于时间复杂度为
O(n^2)
,对于大规模数据,性能较差。 - 不稳定:相同元素的顺序可能被改变,导致排序不稳定。
- 交换次数较多:每次找到最小元素后都会交换,可能导致不必要的交换操作。
适用场景
- 数据量较小(如
n <= 100
)时,选择排序是一种简单有效的排序方法,适合小规模数据的排序。 - 数据本身接近有序时,直接选择排序的性能相对较好,尽管时间复杂度为
O(n^2)
,但相较于其他O(n^2)
算法,选择排序的交换次数较少。
2.2.2 堆排序
堆排序(Heap Sort)是一种基于堆数据结构的排序算法,堆是一种完全二叉树,满足堆的性质:
- 最大堆(Max Heap):父节点的值大于或等于其子节点的值,根节点是最大元素。
- 最小堆(Min Heap):父节点的值小于或等于其子节点的值,根节点是最小元素。
堆排序利用堆的性质,每次从堆中提取根节点(最大或最小),然后调整堆结构,从而实现排序。
基本思想
- 构建堆:将待排序数组转换成一个堆结构(可以是最大堆或最小堆),通常构建最大堆。
- 交换根节点与最后一个节点:将堆顶(最大元素)与堆的最后一个元素交换,这样最大元素就被放置到了正确的位置。
- 调整堆:交换后,堆的结构可能被破坏,需要重新调整堆,使其恢复堆的性质。
- 重复:将剩下的部分继续调整为堆,再次进行交换,直到所有元素都排序完成。
算法步骤
- 建堆:将给定的数组转换成一个最大堆。
- 交换根节点和最后一个节点:把堆顶元素(最大值)交换到数组的末尾。
- 调整堆:将剩余部分重新调整为最大堆。
- 重复交换和调整过程,直到堆的大小减小为1。
代码实现
#include <stdio.h>
// 调整堆结构
void AdjustDown(int* a, int n, int parent) {
int child = parent * 2 + 1; // 左子节点的索引
while (child < n) {
// 如果有右子节点,且右子节点更大,则选择右子节点
if (child + 1 < n && a[child + 1] > a[child]) {
child++;
}
// 如果父节点小于最大子节点,则交换
if (a[child] > a[parent]) {
int temp = a[parent];
a[parent] = a[child];
a[child] = temp;
parent = child;
child = parent * 2 + 1; // 继续向下调整
} else {
break; // 如果父节点大于等于最大子节点,则结束
}
}
}
// 堆排序主函数
void HeapSort(int* a, int n) {
// 建堆(从最后一个非叶子节点开始调整堆)
for (int i = (n - 1) / 2; i >= 0; --i) {
AdjustDown(a, n, i);
}
// 交换根节点(最大元素)和最后一个元素,然后调整堆
int end = n - 1;
while (end > 0) {
// 交换根节点与堆的最后一个元素
int temp = a[0];
a[0] = a[end];
a[end] = temp;
// 重新调整堆
AdjustDown(a, end, 0);
--end;
}
}
// 打印数组
void PrintArray(int* a, int n) {
for (int i = 0; i < n; ++i) {
printf("%d ", a[i]);
}
printf("\n");
}
int main() {
int arr[] = {4, 10, 3, 5, 1};
int n = sizeof(arr) / sizeof(arr[0]);
HeapSort(arr, n);
PrintArray(arr, n);
return 0;
}
堆排序的步骤演示
假设我们有一个数组 [4, 10, 3, 5, 1]
,我们来逐步演示堆排序的过程。
1.建堆:首先将数组转换为最大堆:
AdjustDown
会从最后一个非叶子节点开始,调整堆结构。
经过调整,最大堆应该是 [10, 5, 3, 4, 1]
。
2.交换根节点和最后一个元素:交换根节点 10 和数组末尾的元素 1,得到 [1, 5, 3, 4, 10]
。
通过 AdjustDown
调整堆,使其重新恢复堆的性质,得到 [5, 4, 3, 1, 10]
。
3.再次交换根节点和最后一个元素:交换根节点 5 和倒数第二个元素 1,得到 [1, 4, 3, 5, 10]
。
通过 AdjustDown
调整堆,得到 [4, 1, 3, 5, 10]
。
4.重复:继续交换并调整堆,直到整个数组排好序:
交换后得到 [3, 1, 4, 5, 10]
。
调整后得到 [3, 1, 4, 5, 10]
。
最终得到排序结果 [1, 3, 4, 5, 10]
。
特性总结
算法复杂度
1.时间复杂度:
最坏情况(数组逆序):O(n log n)
最好情况(数组已排序):O(n log n)
平均情况:O(n log n)
2.空间复杂度:原地排序:堆排序不需要额外的辅助空间,空间复杂度为 O(1)
。
3.稳定性:不稳定:堆排序在排序过程中可能改变相等元素的相对顺序,因此是一个不稳定的排序算法。
优缺点
优点:
- 时间复杂度稳定:无论在最坏、最好还是平均情况下,堆排序的时间复杂度始终为
O(n log n)
,表现稳定。 - 原地排序:堆排序只使用常数级额外空间,空间复杂度为
O(1)
,内存开销小。 - 适用于大规模数据:由于堆排序的时间复杂度较为稳定,因此适合用于处理大规模数据的排序任务。
缺点:
- 不稳定:堆排序不保持相等元素的相对顺序,因此不适用于需要稳定排序的场景。
- 常数因子较大:虽然时间复杂度为
O(n log n)
,但堆排序的常数因子较大,通常会比其他如快速排序的实际性能稍差。
适用场景
- 大规模数据的排序:堆排序在处理大数据时较为稳定,尤其是在对时间复杂度有较高要求时。
- 内存有限的情况:堆排序是原地排序,不需要额外的内存,适用于内存受限的情况。
- 需要稳定时间复杂度的场景:当不关心排序的稳定性时,堆排序是一个不错的选择,尤其是在无法预测输入数据分布的情况下。
下一章应该是介绍冒泡和快速排序(
那几种快速排序够写很多了(