稳定性
在排序算法中,稳定性是一个重要的概念,指的是在排序过程中,如果两个元素的值相等,它们在排序后的相对位置与排序前的相对位置保持不变的特性。
稳定排序与不稳定排序
-
稳定排序:在排序时,相等的元素的相对顺序不会改变。例如,在对一组学生按成绩排序时,如果两个学生的成绩相同,它们在排序后的顺序与原来顺序相同(例如,原来是学生 A 和 B,排序后仍然是 A 和 B)。
-
不稳定排序:在排序时,相等的元素的相对顺序可能会改变。例如,如果两个成绩相同的学生在排序后顺序发生变化,则该排序算法是不稳定的。
稳定性的重要性
稳定性在某些情况下非常重要,特别是当需要多次排序时。例如,如果你首先按姓氏排序,然后按名字排序,稳定排序可以确保在按名字排序时,同一姓氏的人的顺序不会被打乱。
稳定排序算法
- 插入排序:稳定。
- 归并排序:稳定。
- 冒泡排序:稳定。
- 计数排序:稳定。
- 基数排序:稳定。
不稳定排序算法
- 快速排序:通常是不稳定的。
- 选择排序:不稳定。
- 堆排序:不稳定。
图来自网络
名词解释:
- n:数据规模
- k:"桶"的个数
- In-place:占用常数内存,不占用额外内存
- Out-place:占用额外内存
冒泡排序(Bubble Sort)
冒泡排序(Bubble Sort)是一种简单的排序算法,它通过重复比较相邻的元素并交换它们的顺序,直到整个数组有序。
工作原理
- 比较相邻元素:从数组的开始位置开始,比较相邻的两个元素。
- 交换:如果第一个元素大/小于第二个元素(对于升/降序排序),则交换这两个元素。
- 重复:继续向数组的末尾移动,重复步骤 1 和 2,直到最后一个元素。
- 多轮比较:每完成一轮操作,最大/小元素就"冒泡"到数组的末尾。重复这个过程,直到没有需要交换的元素为止。
总轮数
冒泡排序需要进行 n-1 轮比较,其中 n 是待排序数组的元素个数。
每轮比较次数
- 第一轮:比较 n−1 次
- 第二轮:比较 n−2 次
- 第三轮:比较 n−3 次
- ...
- 第 i 轮:比较 n−i 次
- 第 n−1 轮:比较 1 次
轮数与总比较次数
在最坏情况下(如逆序排列),总比较次数为:
(n−1)+(n−2)+(n−3)+...+1 =
这个公式是等差数列求和公式的结果。
时间复杂度
- 最坏情况:O(n^2)
- 最好情况:如果数组已经是有序的,冒泡排序只需要进行 n−1 次比较,判断第一轮为有序则终止排序,时间复杂度为 O(n)。
- 平均情况:仍然是 O(n^2)。
空间复杂度
冒泡排序是原地排序算法,空间复杂度为 O(1)。
冒泡排序的稳定性
冒泡排序是一种稳定的排序算法。在冒泡排序中,当相邻的两个元素相等时,算法不会改变它们的相对顺序,因为只有在第一个元素大于第二个元素时才会进行交换。因此,相等元素的相对位置在排序后保持不变。
优缺点
优点
- 简单易懂,易于实现。
- 不需要额外的存储空间(原地排序)。
缺点
- 效率较低,不适合大规模数据排序。
- 时间复杂度高,尤其是在数据量大的情况下。
冒泡排序 C 语言实现
cpp
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// 随机生成数组函数
int* Generate_Random_Array(int n, int min, int max)
{
int f = -1;
if (min < 0)
f = 1;
int* array = (int*)malloc(n * sizeof(int)); // 动态分配内存
if (array == NULL)
exit(-1);
for (int i = 0; i < n; i++)
{
array[i] = min + rand() % (max + f * min + 1);// 生成 min 到 max 之间的随机数
}
return array;
}
// 复制数组函数
int* Copy_Array(int* source, int n)
{
int* copy = (int*)malloc(n * sizeof(int));
if (copy == NULL)
exit(-1);
for (int i = 0; i < n; i++)
{
copy[i] = source[i];
}
return copy;
}
// 排序计时函数
void SortTime(void(*p)(int*, int, int), int* arr, int n, int isAscending)
{
// 记录开始时间
clock_t start = clock();
p(arr, n, isAscending);//调用排序函数
// 记录结束时间
clock_t end = clock();
// 计算并打印排序时间
double time_taken = (double)(end - start) / CLOCKS_PER_SEC;
printf("排序所需时间: %f 秒\n", time_taken);
}
// 打印数组的函数
void print(int* p, int n)
{
for (int i = 0; i < n; ++i) // 遍历数组
{
printf("%d ", p[i]);
}
printf("\n");
}
// 冒泡排序函数最初版
void Bubble_Sort1(int* p, int n, int isAscending)
{
// 外层循环,控制总轮数
for (int i = n - 1; i > 0; --i)
{
// 内层循环,进行相邻元素比较和交换
for (int j = 0; j < i; ++j)
{
// 根据升序或降序规则进行比较
if ((p[j] > p[j + 1] && isAscending) || (p[j] < p[j + 1] && !isAscending))
{
// 交换元素
int tmp = p[j];
p[j] = p[j + 1];
p[j + 1] = tmp;
}
}
}
}
// 冒泡排序函数优化版(增加isSorted标志位,用于判断每轮循环后是否有序,避免已经有序还在排序的情况,减少了比较次数)
void Bubble_Sort2(int* p, int n, int isAscending)
{
// 外层循环,控制总轮数
for (int i = n - 1; i > 0; --i)
{
int isSorted = 1;//重置有序标志
// 内层循环,进行相邻元素比较和交换
for (int j = 0; j < i; ++j)
{
// 根据升序或降序规则进行比较
if ((p[j] > p[j + 1] && isAscending) || (p[j] < p[j + 1] && !isAscending))
{
// 交换元素
int tmp = p[j];
p[j] = p[j + 1];
p[j + 1] = tmp;
isSorted = 0; //无序标志
}
}
// 如果一轮中没有发生交换,说明数组已经有序,提前退出
if (isSorted)
break;
}
}
// 冒泡排序函数最优版(增加了SortInidex用于记录序列尾部局部有序时最后一次交换的位置,通过赋值给i改变循环的轮数,减少了比较次数)
void Bubble_Sort3(int* p, int n, int isAscending)
{
// 外层循环,控制总轮数
for (int i = n - 1; i > 0; --i)
{
int SortInidex = 0;//该初始值是为数组完全有序时循环提前退出准备的(该值小于等于0时在外层循环判断为false)
// 内层循环,进行相邻元素比较和交换
for (int j = 0; j < i; ++j)
{
// 根据升序或降序规则进行比较
if ((p[j] > p[j + 1] && isAscending) || (p[j] < p[j + 1] && !isAscending))
{
// 交换元素
int tmp = p[j];
p[j] = p[j + 1];
p[j + 1] = tmp;
SortInidex = j + 1;// 记录最后一次交换的位置
}
}
// 更新 i 为最后一次交换的位置,以缩小未排序的范围
// 这意味着在此位置之后的元素已排序
i = SortInidex;
}
}
// 冒泡排序函数改良版鸡尾酒排序
void Cocktail_Sort(int* p, int n, int isAscending)
{
// 交换临时变量
int tmp = 0;
// 无序数列的左边界
int leftBorder = 0;
// 无序数列的右边界
int rightBorder = n - 1;
// 外层循环,控制排序的轮数
for (int i = 0; i < n / 2; i++)
{
// 记录右侧最后一次交换的位置
int lastRightExchange = leftBorder; // 初始值设为左边界
// 奇数轮,从左向右比较和交换
for (int j = leftBorder; j < rightBorder; j++)
{
// 根据升序或降序规则进行比较
if ((p[j] > p[j + 1] && isAscending) || (p[j] < p[j + 1] && !isAscending))
{
// 交换元素
tmp = p[j];
p[j] = p[j + 1];
p[j + 1] = tmp;
// 记录最后一次交换的位置
lastRightExchange = j;
}
}
// 更新右边界为最后一次交换的位置
rightBorder = lastRightExchange;
// 记录左侧最后一次交换的位置
int lastLeftExchange = n; // 初始值设为 n,表示未发生交换
// 偶数轮,从右向左比较和交换
for (int j = rightBorder; j > leftBorder; j--)
{
// 根据升序或降序规则进行比较
if ((p[j] < p[j - 1] && isAscending) || (p[j] > p[j - 1] && !isAscending))
{
// 交换元素
tmp = p[j];
p[j] = p[j - 1];
p[j - 1] = tmp;
// 记录最后一次交换的位置
lastLeftExchange = j;
}
}
// 更新左边界为最后一次交换的位置
leftBorder = lastLeftExchange;
}
}
// 主函数
int main()
{
//排序函数测试
int num = 20;
int* p = Generate_Random_Array(num, -9, 100);
printf("原数组:");
print(p, num);
Bubble_Sort1(p, num, 1);
printf("Bubble_Sort1升序:");
print(p, num);
Bubble_Sort1(p, num, 0);
printf("Bubble_Sort1降序:");
print(p, num);
Bubble_Sort2(p, num, 1);
printf("Bubble_Sort2升序:");
print(p, num);
Bubble_Sort2(p, num, 0);
printf("Bubble_Sort2降序:");
print(p, num);
Bubble_Sort3(p, num, 1);
printf("Bubble_Sort3升序:");
print(p, num);
Bubble_Sort3(p, num, 0);
printf("Bubble_Sort3降序:");
print(p, num);
Cocktail_Sort(p, num, 1);
printf("Cocktail_Sort升序:");
print(p, num);
Cocktail_Sort(p, num, 0);
printf("Cocktail_Sort降序:");
print(p, num);
//排序时间测试
int n = 20000;
printf("\n\n排序时间测试,排序个数:%d\n", n);
int* arr = Generate_Random_Array(n, -100, 100000);
int* arr1 = Copy_Array(arr, n);
int* arr2 = Copy_Array(arr, n);
int* arr3 = Copy_Array(arr, n);
int* arr4 = Copy_Array(arr, n);
printf("Bubble_Sort1");
SortTime(Bubble_Sort1, arr1, n, 1);
printf("Bubble_Sort2");
SortTime(Bubble_Sort2, arr2, n, 1);
printf("Bubble_Sort3");
SortTime(Bubble_Sort3, arr3, n, 1);
printf("Cocktail_Sort");
SortTime(Cocktail_Sort, arr4, n, 1);
// 释放动态分配的内存
free(arr);
free(arr1);
free(arr2);
free(arr3);
free(arr4);
return 0;
}
鸡尾酒排序 (Cocktail Sort)
-
基本原理:
- 鸡尾酒排序是冒泡排序的变种,采用双向遍历方法。
- 它在每一轮中先从左到右排序,然后再从右到左排序。
-
遍历方向:
- 具有双向遍历:第一部分是从左到右,第二部分是从右到左,这样可以在每一轮中同时将最小和最大元素移动到各自的边界。
-
效率:
- 在最坏和平均情况下的时间复杂度也是 O(n²),但由于双向遍历,它在某些情况下可能会比简单的冒泡排序更快,尤其是在数据已经部分有序的情况下。
总结
- 遍历方向:冒泡排序单向遍历,而鸡尾酒排序双向遍历。
- 性能:鸡尾酒排序在某些情况下比冒泡排序更高效,尤其是在处理部分有序的列表时。
- 实现复杂性:鸡尾酒排序的实现略复杂,因为需要处理两个方向的遍历。
选择排序 (Selection Sort)
选择排序是一种简单的排序算法,它通过不断选择未排序部分中的最小(或最大)元素,并将其放到已排序部分的末尾,最终实现整个数组的排序。
工作原理
- 选择最小/大元素:从未排序的部分中找到最小/大元素。
- 交换:将找到的最小/大元素与未排序部分的第一个元素交换。
- 更新边界:已排序部分的边界向右移动一位,未排序部分的大小减小。
- 重复:继续对未排序部分重复上述步骤,直到没有未排序的元素。
总轮数
选择排序需要进行 n-1 轮比较,其中 n 是待排序数组的元素个数。
每轮比较次数
- 第一轮:比较 n−1 次
- 第二轮:比较 n−2 次
- 第三轮:比较 n−3 次
- ...
- 第 i 轮:比较 n−i 次
- 第 n−1 轮:比较 1 次
轮数与总比较次数
选择排序的总比较次数为:
(n−1)+(n−2)+(n−3)+...+1 =
这个公式是等差数列求和公式的结果。
时间复杂度
- 最坏情况:O(n²)
- 最好情况:O(n²)(选择排序不受初始数据顺序影响,始终进行相同的比较次数)
- 平均情况:O(n²)
空间复杂度
选择排序是原地排序算法,空间复杂度为 O(1)。
选择排序的稳定性
选择排序是一种不稳定的排序算法。在选择最小元素的过程中,相等元素的相对顺序可能会改变,因为最小元素的交换可能会导致相等元素的顺序发生变化。
优缺点
优点
- 实现简单,易于理解。
- 不需要额外的存储空间(原地排序)。
缺点
- 效率较低,不适合大规模数据排序。
- 时间复杂度较高,尤其是在数据量大的情况下。
选择与冒泡比较与交换次数
- 选择排序 :
- 每一轮都会进行 n-1 次比较,但只进行一次交换(如果需要交换的话)。因此,选择排序的交换次数相对较少。
- 冒泡排序 :
- 每一轮都需要进行多次交换,尤其是在逆序排列的情况下,交换次数会显著增加。
实际性能
- 选择排序通常比冒泡排序稍快,尤其是在交换操作较昂贵的情况下,因为选择排序每轮只进行一次交换。
- 冒泡排序在已经部分有序的情况下表现较好(可以提前终止),但在大多数情况下仍然较慢。
总结
在一般情况下,选择排序 的效率通常会优于冒泡排序,尤其是在需要减少交换次数时。然而,在大规模数据排序时,这两种算法都不是最佳选择,更高效的排序算法(如快速排序、归并排序或堆排序)更为适合。
双向选择排序(Bidirectional Selection Sort)
双向选择排序是一种改进的选择排序,它在每一轮中同时选择未排序部分的最小和最大元素。这种方法可以减少排序所需的总轮数。
双向选择排序其基本思想是同时在未排序部分找到最小和最大元素,并将它们放置到已排序部分的两端。
基本思想
-
分区:
- 将数组分为已排序部分和未排序部分。初始时,已排序部分为空,未排序部分为整个数组。
-
同时选择:
- 在未排序部分中同时寻找最小值和最大值。
- 找到后,将最小值放到未排序部分的起始位置(已排序部分的末尾),最大值放到未排序部分的末尾。
具体过程
假设我们有一个数组 arr = {5, 3, 8, 6, 2}
,我们想要进行升序排序:
-
第一次选择:
- 在未排序部分
{5, 3, 8, 6, 2}
中找到最小值2
和最大值8
。 - 将
2
放到数组的开头,将8
放到数组的末尾。 - 结果:
{2, 3, 6, 5, 8}
(已排序部分为{2}
,未排序部分为{3, 6, 5}
)。
- 在未排序部分
-
第二次选择:
- 在未排序部分
{3, 6, 5}
中找到最小值3
和最大值6
。 - 将
3
放到已排序部分的末尾(即当前最小位置),将6
放到数组的末尾。 - 结果:
{2, 3, 5, 6, 8}
(已排序部分为{2, 3}
,未排序部分为空)。
- 在未排序部分
优点
- 减少比较次数:双向选择排序每轮同时找出未排序部分的最小和最大元素,相比标准选择排序减少了比较和交换的次数。
- 适用于小规模数据:尽管其时间复杂度仍为 O(n²),但在小规模数据排序时表现良好。
缺点
- 对大规模数据效率低:由于时间复杂度较高,双向选择排序不适合用于大数据集的排序。
- 相等元素的顺序可能改变:双向选择排序是一个不稳定的排序算法。相同元素的相对顺序可能会因为交换而发生变化,这在某些应用场景下可能是不可接受的。
cpp
#include "All_Sort.h"
// 选择排序函数
void Selection_Sort(int* arr, int n, int isAscending)
{
for (int i = 0; i < n - 1; i++)
{
// 假设当前元素是最小/大值
int Index = i;
for (int j = i + 1; j < n; j++)
{
if (arr[j] < arr[Index] && isAscending || arr[j] > arr[Index] && !isAscending)
Index = j;// 更新最小/大值的索引
}
// 交换当前元素与最小/大值
if (Index != i)
{
int temp = arr[i];
arr[i] = arr[Index];
arr[Index] = temp;
}
}
}
// 双向选择排序函数
void Bidirectional_Selection_Sort(int* arr, int n, int isAscending)
{
for (int i = 0; i < n / 2; i++)
{
int minIndex = i;
int maxIndex = i;
for (int j = i + 1; j < n - i; j++)
{
if (arr[j] < arr[minIndex] && !isAscending || arr[j] > arr[minIndex] && isAscending)
minIndex = j;
if (arr[j] > arr[maxIndex] && !isAscending || arr[j] < arr[maxIndex] && isAscending)
maxIndex = j;
}
// 交换最小/大元素
if (minIndex != i)
{
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
// 交换最大/小元素(注意 maxIndex 的位置可能已变)
if (maxIndex == i)
maxIndex = minIndex; // 如果最小/大元素在 maxIndex 位置,要更新 maxIndex
if (maxIndex != n - 1 - i)
{
int temp = arr[n - 1 - i];
arr[n - 1 - i] = arr[maxIndex];
arr[maxIndex] = temp;
}
}
}
以下是关键的代码段:
cpp
// 交换最大/小元素(注意 maxIndex 的位置可能已变)
if (maxIndex == i)
maxIndex = minIndex; // 如果最小/大元素在 maxIndex 位置,要更新 maxIndex
逻辑分析
-
初始假设:
- 假设当前元素
i
是未排序部分的最小值。 - 假设
maxIndex
也是当前未排序部分的最大值。
- 假设当前元素
-
找到最小和最大元素:
- 在内部循环中,你会遍历未排序部分的所有元素,更新
minIndex
和maxIndex
的值,以找到未排序部分的最小值和最大值。
- 在内部循环中,你会遍历未排序部分的所有元素,更新
-
交换:
- 在完成查找后,你会交换最小元素(指向
minIndex
)到当前的i
位置。 - 但是,如果 当前的
i
是最大值的位置(即maxIndex == i
),那么在交换最小值后,最大值的位置可能会发生变化。
- 在完成查找后,你会交换最小元素(指向
更新 maxIndex
- 为什么要更新
maxIndex
:- 当最小值被放到
i
位置时,原本在i
位置的元素(即最大值)现在位于未排序部分的其他位置。这时,maxIndex
指向的可能是一个不再是最大值的位置。 - 因此,我们需要将
maxIndex
更新为minIndex
的位置,以确保在后续的交换中,我们能正确交换最大值。
- 当最小值被放到
示例
假设我们有一个数组 arr = {5, 3, 8, 6, 2}
,我们想要进行升序排序。
迭代过程
第一次迭代(i = 0)
-
初始状态:
minIndex = 0
(指向5
)maxIndex = 0
(指向5
)
-
内部循环(查找最小值和最大值):
- j = 1:
arr[1] = 3
→minIndex
更新为1
(3 是当前最小值) - j = 2:
arr[2] = 8
→maxIndex
更新为2
(8 是当前最大值) - j = 3:
arr[3] = 6
→maxIndex
仍为2
(8 仍然是最大值) - j = 4:
arr[4] = 2
→minIndex
更新为4
(2 是当前最小值)
- j = 1:
-
找到的最小和最大:
- 最小值
2
(索引4
),最大值8
(索引2
)。
- 最小值
-
交换最小值:
- 将
2
交换到位置0
: - 结果数组变为
{2, 3, 8, 6, 5}
。
- 将
-
更新
maxIndex
:- 当前最大值
8
的原始位置是2
,但现在数组的结构已经改变。 - 由于最小值在位置
0
被交换,原本在0
位置的5
现在在未排序部分的其他位置。 - 由于
maxIndex
仍然指向2
,我们需要检查这个索引是否仍然是最大值。 - 由于
maxIndex
没有指向i
(即maxIndex
仍然是2
),我们继续。
- 当前最大值
-
交换最大值:
- 将
8
(最大值)交换到未排序部分的末尾(即位置4
): - 结果数组变为
{2, 3, 5, 6, 8}
。
- 将
为什么要更新 maxIndex
在某些情况下,如果最小值在 maxIndex
位置(例如如果初始数组为 {8, 3, 5, 6, 2}
),而我们在交换最小值后,最大值的位置可能会被改变:
-
假设数组为
{8, 3, 5, 6, 2}
:- 初始时,
minIndex = 4
(指向2
),maxIndex = 0
(指向8
)。 - 在交换后,数组变为
{2, 3, 5, 6, 8}
。 - 因为
maxIndex
仍然指向0
(原始最大值的位置),而8
被交换到了末尾。
- 初始时,
-
更新
maxIndex
:- 由于
minIndex
是4
(当前最小值的索引)与maxIndex
是0(
当前最大值的索引) 交换了
,所有需要更新maxIndex
为minIndex
的位置(因为8
已经不在原来的位置)。
- 由于
总结
更新 maxIndex
是为了确保在后续的交换中,我们能正确交换最大值。通过这个例子,可以看到在交换最小值后,原本的最大值位置可能会发生变化,因此需要根据新的数组状态进行更新,以确保最大值的正确处理。
堆排序(Heap Sort)
堆排序(Heap Sort)是一种基于堆数据结构的排序算法,通过构建最大堆(或最小堆)来实现排序。其主要思想是利用堆的特性来进行排序。堆是一种特殊的完全二叉树,其中每个节点的值都大于或等于(最大堆)或小于或等于(最小堆)其子节点的值。
堆:堆是一种特殊的完全二叉树,具有以下特性:
- 最大堆:每个父节点的值大于或等于其子节点的值,根节点是最大值。
- 最小堆:每个父节点的值小于或等于其子节点的值,根节点是最小值。
计算左子节点和右子节点的索引
对于一个索引为 i
的节点,其左右子节点的索引可以通过以下规则计算:
-
左子节点:在数组中,左子节点总是位于当前节点的下方,并且在同一层的节点之前。因此,左子节点的索引为:
left = 2 * ``i`` + 1
-
右子节点:右子节点位于当前节点的下方,并且在左子节点之后。因此,右子节点的索引为:
right = 2 * ``i ``+ 2
这种关系的原因如下:
- 每个节点的左子节点在数组中的位置是按层次从左到右填充的。
- 在完全二叉树中,给定节点的左子节点总是在它的下一个层级的前面,右子节点在左子节点之后。
示例说明
考虑以下完全二叉树及其对应的数组表示:
bash
0
/ \
1 2
/ \ / \
3 4 5 6
- 根节点
0
的索引是0
。 - 左子节点
1
的索引是2 * 0 + 1 = 1
。 - 右子节点
2
的索引是2 * 0 + 2 = 2
。 - 节点
1
的左子节点3
的索引是2 * 1 + 1 = 3
,右子节点4
的索引是2 * 1 + 2 = 4
。 - 节点
2
的左子节点5
的索引是2 * 2 + 1 = 5
,右子节点6
的索引是2 * 2 + 2 = 6
。
计算最后一个非叶子节点的索引
-
叶子节点的起始索引:
- 在一个包含
n
个节点的完全二叉树中,叶子节点的起始索引为n/2
(向下取整)。 - 例如,如果
n = 7
,叶子节点的索引为3
,4
,5
,6
(即n/2 = 3.5
,取整为3
)。
- 在一个包含
-
最后一个非叶子节点的索引:
- 因此,最后一个非叶子节点的索引就是叶子节点起始索引之前的一个节点即
n/2 - 1
。 - 例如,对于
n = 7
,最后一个非叶子节点的索引为3 - 1 = 2
,对应的节点值为2
。
- 因此,最后一个非叶子节点的索引就是叶子节点起始索引之前的一个节点即
示例
假设有一个包含 8 个节点的完全二叉树(n = 8
):
bash
0
/ \
1 2
/ \ / \
3 4 5 6
/
7
- 叶子节点的起始索引为
8/2 = 4
,对应的叶子节点为4
,5
,6
,7
(索引4
,5
,6
,7
)。 - 最后一个非叶子节点的索引为
8/2 - 1 = 3
,对应的节点是3
。
总结
- 最后一个非叶子节点的索引
n/2 - 1
是基于完全二叉树的结构特性。 - 通过这种方式,我们能够高效地找到非叶子节点,进而应用于堆的构建和维护。这一特性在实现堆排序和其他基于堆的数据结构时非常重要。
最大堆(Max Heap)
定义:在最大堆中,每个节点的值都大于或等于其子节点的值,根节点是最大值。
最小堆(Min Heap)
定义:在最小堆中,每个节点的值都小于或等于其子节点的值,根节点是最小值。
构建最大/小堆的步骤
- 从最后一个非叶子节点开始 :最后一个非叶子节点的索引为
n/2 - 1
。 - 逐步向上调整 :对每个非叶子节点调用大/小堆
heapify
函数,确保以该节点为根的子树满足最大/小堆的性质。
cpp
void buildHeap(int* arr, int n, int IsMax)
{
for (int i = n / 2 - 1; i >= 0; i--)
{
heapify(arr, n, i, IsMax);
}
}
n / 2 - 1
:从最后一个非叶子节点开始。for
循环 :从这个索引向下到0
,确保整个树的每个非叶子节点都被堆化。- 完全二叉树的性质:在完全二叉树中,节点的索引从 0 开始,根节点的索引为 0。
- 父节点索引计算 :
- 对于任何节点
i
:- 如果
i
是 0(根节点),则没有父节点。 - 对于其他节点,父节点的索引计算为
(i - 1) / 2
。
- 如果
- 对于任何节点
cpp
// 构建堆函数递归法
void heapify_recursion(int* arr, int n, int i, int IsMax)
{
int m = i; // 假设当前节点为最大/小值
int left = 2 * i + 1; // 左子节点
int right = 2 * i + 2; // 右子节点
// 如果左子节点比当前最大值大/小,更新最大/小
if (left < n && ((IsMax && arr[left] > arr[m]) || (!IsMax && arr[left] < arr[m])))
{
m = left;
}
// 如果右子节点比当前最大值大/小,更新最大/小值
if (right < n && ((IsMax && arr[right] > arr[m]) || (!IsMax && arr[right] < arr[m])))
{
m = right;
}
// 如果最大值不是当前节点,进行交换并继续堆化
if (m != i)
{
// 交换当前节点与找到的最大/小值节点
int temp = arr[i];
arr[i] = arr[m];
arr[m] = temp;
// 递归堆化受影响的子树
heapify_recursion(arr, n, m, IsMax);
}
}
// 构建堆函数递迭代法
void heapify_iterate(int* arr, int n, int i, int IsMax)
{
while (1)
{
int m = i; // 假设当前节点为最大/小值
int left = 2 * i + 1; // 左子节点
int right = 2 * i + 2; // 右子节点
// 如果左子节点比当前最大值大/小,更新最大/小
if (left < n && ((IsMax && arr[left] > arr[m]) || (!IsMax && arr[left] < arr[m])))
{
m = left;
}
// 如果右子节点比当前最大值大/小,更新最大/小值
if (right < n && ((IsMax && arr[right] > arr[m]) || (!IsMax && arr[right] < arr[m])))
{
m = right;
}
// 如果最大值不是当前节点,进行交换并继续堆化
if (m != i)
{
// 交换当前节点与找到的最大/小值节点
int temp = arr[i];
arr[i] = arr[m];
arr[m] = temp;
// 更新当前节点为被交换的子节点
i = m; // 更新索引为最大(或最小)子节点
}
else
{
break;// 如果没有交换,结束循环
}
}
}
cpp
// 排序最大/小堆的函数
void heap_sort_recursion(int* arr, int n, int IsMax)
{
// 第一步:建立最大堆
for (int i = n / 2 - 1; i >= 0; i--)
{
heapify_recursion(arr, n, i, IsMax);
}
// 第二步:一个一个从堆中取出元素
for (int i = n - 1; i > 0; i--)
{
// 将当前最大/小值移动到数组末尾
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify_recursion(arr, i, 0, IsMax); // 重新堆化根节点
}
}
void heap_sort_iterate(int* arr, int n, int IsMax)
{
// 第一步:建立最大/小堆
for (int i = n / 2 - 1; i >= 0; i--)
{
heapify_iterate(arr, n, i, IsMax);
}
// 第二步:一个一个从堆中取出元素
for (int i = n - 1; i > 0; i--)
{
// 将当前最大/小值移动到数组末尾
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify_iterate(arr, i, 0, IsMax); // 重新堆化根节点
}
}
基本原理
堆排序的基本步骤如下:
-
构建最大/小堆:
- 将待排序的数组构建成一个最大/小堆。最大/小堆的性质是每个父节点的值大/小于或等于其子节点的值。这样,根节点就是最大/小值。
-
排序过程:
- 将根节点(最大/小值)与数组的最后一个元素交换,然后将堆的大小减 1。
- 重新调整堆,使其保持最大堆的性质。
- 重复以上步骤,直到堆的大小为 1。
时间复杂度
- 堆排序的时间复杂度为 O(n log n),其中 n 是待排序的元素数量。
- 空间复杂度为 O(1),因为堆排序是原地排序。
堆排序的稳定性
堆排序是一种不稳定的排序算法。在排序过程中,可能会改变相等元素的相对位置,因为堆的调整过程依赖于树的结构。
优缺点
优点
- 高效:在大多数情况下,堆排序的时间复杂度为 O(n log n),适合大规模数据排序。
- 原地排序:不需要额外的存储空间,空间复杂度为 O(1)。
缺点
- 不稳定:相等元素的相对顺序可能会被改变。
- 实现相对复杂:相比于简单的排序算法(如冒泡排序),堆排序的实现相对复杂。
Shift Down(下移)
作用:将一个节点向下移动,以维护堆的性质。通常在删除堆顶元素(最大值或最小值)时使用。
操作步骤
- 比较:将当前节点与其左右子节点进行比较。
- 确定最大/最小:找出当前节点与其子节点中的最大值(在最大堆中)或最小值(在最小堆中)。
- 交换:如果当前节点不是最大/最小值,交换它与子节点中较大/较小的那个。
- 递归/迭代:重复此过程,直到堆的性质得以恢复(即所有父节点都大于或小于其子节点)。
Shift Up(上移)
作用:将一个节点向上移动,以维护堆的性质。通常在插入新元素时使用。
操作步骤
- 比较:将当前节点与其父节点进行比较。
- 交换:如果当前节点的值大于(或小于)其父节点的值(在最大堆中),则交换它们。
- 递归/迭代:重复此过程,直到堆的性质得以恢复(即所有父节点都大于或小于其子节点)。
时间复杂度
-
shiftUp
:- 时间复杂度:O(log n)
- 说明:
shiftUp
在最坏情况下需要沿着树的高度向上移动,树的高度为 log(n),因此时间复杂度为 O(log n)。
-
shiftDown
:- 时间复杂度:O(log n)
- 说明:
shiftDown
也在最坏情况下需要沿着树的高度向下移动,时间复杂度同样为 O(log n)。
平均性能
- 两者的平均性能通常也是 O(log n),但在具体实现中,
shiftDown
可能在某些情况下更加频繁地进行交换,因为它需要检查两个子节点,而shiftUp
只需检查一个父节点。
总结
- Shift Down:用于在删除堆顶元素时,确保堆的性质通过将节点向下移动。
- Shift Up:用于在插入新元素时,确保堆的性质通过将节点向上移动。
插入排序(Insertion Sort)
插入排序(Insertion Sort)是一种简单的排序算法,它通过构建一个有序的序列,将待排序的元素逐个插入到已排序的序列中,直到整个数组有序。
工作原理
- 选择元素:从数组中选择一个元素(通常是第一个元素),将其视为已排序的部分。
- 插入位置:将选中的元素与已排序部分的元素进行比较,找到合适的位置插入。
- 移动元素:为了腾出插入的位置,将比选中元素大的元素向后移动。
- 插入:将选中元素插入到正确的位置。
- 重复:重复步骤 1 到 4,直到所有元素都被插入到已排序部分。
时间复杂度
- 最坏情况 :O(n²)
当数组逆序排列时,需要进行最多的比较和移动。 - 最好情况 :O(n)
如果数组已经是有序的,插入排序只需进行 n-1 次比较,并立即判断为有序。 - 平均情况 :O(n²)
通常情况下,插入排序的时间复杂度为 O(n²)。
空间复杂度
插入排序是原地排序算法,空间复杂度为 O(1)。
插入排序的稳定性
插入排序是一种稳定的排序算法。在插入过程中,相邻的两个相等元素的相对顺序不会改变,因为只有在第一个元素大于第二个元素时才会进行移动。
优缺点
优点
- 实现简单,容易理解。
- 适合小规模数据的排序。
- 对于部分有序的数组,性能较好。
缺点
- 效率较低,不适合大规模数据排序。
- 时间复杂度较高,尤其是在数据量大的情况下。
cpp
#include "All_Sort.h"
//插入排序
void Insert_Sort0(int* p, int n, int isAscending)
{
for (int i = 1; i < n; ++i)
{
int cur = i;
while ((cur > 0) && (p[cur] < p[cur - 1] && isAscending || p[cur] > p[cur - 1] && !isAscending))
{
int temp = p[cur];
p[cur] = p[cur - 1];
p[cur - 1] = temp;
--cur;
}
}
}
//插入排序优化交换为移动
void Insert_Sort1(int* p, int n, int isAscending)
{
for (int i = 1; i < n; ++i)
{
int key = p[i]; // 当前要插入的元素
int j = i - 1; // 已排序部分的最后一个索引
// 根据排序方式调整插入逻辑
while (j >= 0 && ((isAscending && p[j] > key) || (!isAscending && p[j] < key)))
{
p[j + 1] = p[j]; // 移动元素
j--; // 向前移动
}
p[j + 1] = key; // 插入元素
}
}
// 二叉搜索返回要插入的元素的下标
int Binary_Search_Index(int* p, int n, int v, int isAscending)
{
int begin = 0;
int end = n; // 注意这里的 end 应为 n
while (begin < end)
{
int mid = (begin + end) >> 1; // 相当于 (begin + end) / 2
if ((v < p[mid] && isAscending) || (v > p[mid] && !isAscending))
{
end = mid; // 找到左侧
}
else
{
begin = mid + 1; // 找到右侧
}
}
return begin; // 返回插入的位置
}
// 插入排序优化为二分查找
void Insert_Sort2(int* p, int n, int isAscending)
{
for (int i = 1; i < n; ++i)
{
int index = Binary_Search_Index(p, i, p[i], isAscending);
int key = p[i]; // 备份当前要插入的元素
// 根据排序方式调整插入逻辑
for (int j = i - 1; j >= index; --j)
{
p[j + 1] = p[j]; // 移动元素
}
p[index] = key; // 插入元素
}
}
归并排序(Merge Sort)
归并排序是一种基于分治法的排序算法,具有较好的时间复杂度和稳定性。它的基本思想是将数组分成两个子数组,分别进行排序,然后将已排序的子数组合并成一个有序的数组。
工作原理
- 分割:将待排序数组递归/迭代地分成两个子数组,直到每个子数组只有一个元素(单个元素是有序的)。
- 排序与合并:将两个已排序的子数组合并成一个新的有序数组。
- 重复:重复上述步骤,直到所有子数组合并为一个有序数组。
时间复杂度
- 最坏情况:O(n log n)
- 最好情况:O(n log n)
- 平均情况:O(n log n)
空间复杂度
- O(n):需要额外的空间来存储合并后的数组。
稳定性
归并排序是一种稳定的排序算法,因为相同元素的相对顺序在排序后保持不变。
cpp
#include "All_Sort.h"
void Merge(int* p, int* arr, int begin, int mid, int end, int isAscending)
{
int li = 0; // 左半部分数组开始下标
int le = mid - begin + 1; // 左半部分数组结束下标
int ri = mid + 1; // 右半部分数组开始下标
int ai = begin; // 将来要覆盖的位置下标
// 备份数组左半部分
for (int i = 0; i < le; i++)
{
arr[i] = p[begin + i];
}
// 合并两个已排序的部分
while (li < le && ri <= end)
{
if (arr[li] <= p[ri] && isAscending || arr[li] >= p[ri] && !isAscending)
{
p[ai++] = arr[li++];
}
else
{
p[ai++] = p[ri++];
}
}
// 复制剩余左半部分
while (li < le)
{
p[ai++] = arr[li++];
}
// 右半部分不需要复制,因为它已经在原数组中
}
//归并排序递归法
void Merge_Sort_recursion(int* p, int* arr, int begin, int end, int isAscending)
{
if (begin < end)
{
int mid = begin + (end - begin) / 2; // 防止整数溢出
// 递归排序左半部分
Merge_Sort_recursion(p, arr, begin, mid, isAscending);
// 递归排序右半部分
Merge_Sort_recursion(p, arr, mid + 1, end, isAscending);
// 合并已排序的部分
Merge(p, arr, begin, mid, end, isAscending);
}
}
void Merge_Sort0(int* p, int n, int isAscending)
{
// 创建临时数组
int* arr = (int*)malloc(sizeof(int) * (n / 2 + 1)); // 只分配一次
if (arr == NULL) {
perror("Failed to allocate memory");
exit(EXIT_FAILURE);
}
int begin = 0;
Merge_Sort_recursion(p, arr, begin, n - 1, isAscending);
// 释放临时数组
free(arr);
}
void Merge1(int* p, int* arr, int begin, int mid, int end, int isAscending)
{
int li = begin; // 左半部分的开始索引
int ri = mid + 1; // 右半部分的开始索引
int ai = begin; // 最终合并数组的索引
// 合并两个已排序的部分
while (li <= mid && ri <= end)
{
if ((p[li] <= p[ri] && isAscending) || (p[li] >= p[ri] && !isAscending))
{
arr[ai++] = p[li++];
}
else
{
arr[ai++] = p[ri++];
}
}
// 复制剩余左半部分
while (li <= mid)
{
arr[ai++] = p[li++];
}
// 复制剩余右半部分
while (ri <= end)
{
arr[ai++] = p[ri++];
}
}
// 非递归归并排序
void Merge_Sort1(int* p, int n, int isAscending)
{
// 为归并过程分配一个临时数组
int* arr = (int*)malloc(sizeof(int) * n); // 全部分配一次
if (arr == NULL) {
perror("Failed to allocate memory"); // 检查内存分配是否成功
exit(EXIT_FAILURE);
}
// 从大小为1的子数组开始,逐步增大子数组的大小
for (int size = 1; size < n; size *= 2)
{
// 遍历数组,合并相邻的子数组
for (int begin = 0; begin < n; begin += 2 * size)
{
// 计算子数组的中间位置和结束位置
int mid = (begin + size - 1 < n - 1) ? (begin + size - 1) : (n - 1);
int end = (begin + 2 * size - 1 < n - 1) ? (begin + 2 * size - 1) : (n - 1);
// 合并子数组 [begin, mid] 和 [mid + 1, end]
Merge1(p, arr, begin, mid, end, isAscending);
}
// 将合并后的结果复制回原数组
for (int i = 0; i < n; i++)
{
p[i] = arr[i]; // 更新原数组为合并后的结果
}
}
free(arr); // 释放临时数组的内存
}
归并排序是一种高效的排序算法,具有以下优缺点:
优点
-
稳定性:
- 归并排序是稳定的排序算法,在相等元素的顺序上不会改变。
-
时间复杂度:
- 最坏情况、平均情况和最好情况的时间复杂度均为 O(nlogn),性能稳定。
-
适用于大规模数据:
- 归并排序可以处理大量数据,特别适合外部排序,例如处理磁盘文件。
-
分治思想:
- 归并排序采用分治策略,易于理解和实现。
-
可并行化:
- 归并的过程可以被并行化,从而提高排序速度。
缺点
-
空间复杂度:
- 归并排序需要额外的 O(n) 空间来存储临时数组,对于内存有限的环境可能不适用。
-
不适合小规模数据:
- 对于小规模数据,归并排序可能不如简单的排序算法(如插入排序)高效,因为其额外的开销。
-
实现复杂性:
- 相比于简单的排序算法(如冒泡排序),归并排序的实现相对复杂。
-
递归调用开销:
- 在递归实现中,函数调用会增加栈的使用,可能导致栈溢出。
总结
归并排序是一种高效且稳定的排序算法,适合大规模数据的排序,但其空间复杂度和实现复杂性可能在某些情况下限制其使用。
快速排序(Quick Sort)
快速排序(Quick Sort)是一种常用的排序算法,采用分治法策略对数据进行排序。
工作原理
快速排序的基本步骤如下:
-
选择基准:
从数组中选择一个元素作为"基准"(pivot)。常见的选择方法包括选择第一个元素、最后一个元素、中间元素或随机选择。
-
分区操作:
将数组重新排列,使得所有小于基准的元素放在基准的左侧,所有大于基准的元素放在基准的右侧。此时基准元素的位置就是它在最终排序数组中的位置。
-
递归/迭代排序:
对基准左侧和右侧的子数组分别递归/迭代应用快速排序。
-
终止条件:
当子数组的大小为1或0时,递归终止。
时间复杂度
- 平均情况:O(nlogn)
- 最坏情况:O(n^2)当数组已经是有序的或逆序的,且每次选择的基准都是极端值时。
- 最好情况:O(nlogn)
空间复杂度
- 空间复杂度:O(logn)(主要由于递归调用栈的空间)
优点
-
时间效率高:
在大多数情况下,快速排序比其他 O(nlogn) 的排序算法(如归并排序和堆排序)更快。
-
原地排序:
快速排序只需要少量的额外空间,通常是 O(logn),因此适合大规模数据。
缺点
-
最坏情况性能差:
在某些情况下(如已排序数组),快速排序的性能会降到 O(n^2)。
-
递归深度:
递归调用的深度可能导致栈溢出,尤其是在处理大数据集时。
-
不稳定排序:
快速排序不保证相等元素的相对顺序不变。
cpp
#include "All_Sort.h"
// 分区函数
int Partition(int* arr, int low, int high, int isAscending)
{
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = low - 1; // 小于基准的元素索引
for (int j = low; j < high; j++)
{
if (arr[j] < pivot && isAscending || arr[j] > pivot && !isAscending)
{
i++;
// 交换 arr[i] 和 arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 交换 arr[i + 1] 和 arr[high](基准)
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1;
}
// 快速排序递归函数
void Quick_Sort_D(int* arr, int low, int high,int isAscending)
{
if (low < high)
{
// 分区操作
int pivotIndex = Partition(arr, low, high, isAscending);
// 递归排序基准左侧和右侧的子数组
Quick_Sort_D(arr, low, pivotIndex - 1, isAscending);
Quick_Sort_D(arr, pivotIndex + 1, high, isAscending);
}
}
void Quick_Sort0(int* p, int n, int isAscending)
{
int low = 0;
Quick_Sort_D(p, low, n - 1, isAscending);
}
// 快速排序非递归函数
void Iterative_Quick_Sort(int* arr, int n, int isAscending)
{
int* stack = (int*)malloc(sizeof(int) * n); // 创建栈
int top = -1; // 栈顶指针
// 将初始的整个数组范围推入栈
stack[++top] = 0;
stack[++top] = n - 1;
// 当栈不为空时
while (top >= 0)
{
// 弹出范围
int high = stack[top--];
int low = stack[top--];
// 进行分区
int pivotIndex = Partition(arr, low, high, isAscending);
// 如果基准左侧还有元素,推入栈
if (pivotIndex - 1 > low)
{
stack[++top] = low;
stack[++top] = pivotIndex - 1;
}
// 如果基准右侧还有元素,推入栈
if (pivotIndex + 1 < high)
{
stack[++top] = pivotIndex + 1;
stack[++top] = high;
}
}
free(stack); // 释放栈内存
}
希尔排序(Shell Sort)
希尔排序(Shell Sort)是一种基于插入排序的排序算法,通过将待排序的数组分成多个子序列来进行排序,从而提升插入排序的效率。希尔排序是非稳定的排序算法,其时间复杂度取决于所选择的间隔序列。
工作原理
-
选择间隔:
初始时,选择一个间隔(gap),将数组分为若干个子序列。每个子序列由间隔为 gap 的元素组成。
-
排序子序列:
对每个子序列进行插入排序。这一步骤可以看作是对多个小数组进行排序。
-
缩小间隔:
逐步减小间隔,直到间隔为 1,此时整个数组已基本有序。
-
最终插入排序:
对最后一个间隔为 1 的子序列进行一次插入排序,完成排序。
时间复杂度
- 最坏情况:O(n^2)
- 平均情况:O(nlogn) 到 O(n3/2)(依赖于间隔序列)
- 最好情况:O(n)
希尔排序的平均时间复杂度取决于所使用的间隔序列(gap sequence)。以下是一些常见间隔序列对应的平均时间复杂度:
-
简单间隔序列(例如:每次将间隔减半):
- 时间复杂度: O(n^2)
- 这是最简单的实现,效率较低。
-
Hibbard 间隔序列(1, 3, 7, 15, ...,即 2^k - 1):
- 时间复杂度: O(n^(3/2))
- 相较于简单间隔序列,性能有显著提升。
-
Sedgewick 间隔序列(例如:1, 5, 19, 41, ...):
- 时间复杂度: O(n^(4/3))
- 这种序列在实践中表现良好。
-
Knuth 间隔序列(1, 4, 13, 40, ...,即 (3^k - 1) / 2):
- 时间复杂度: O(n log2 n)
- 这种序列通常被认为是最优的实现之一,性能非常出色。
空间复杂度
- 空间复杂度:O(1)(原地排序)
cpp
#include "All_Sort.h"
// 希尔排序函数
void Shell_Sort0(int* arr, int n, int isAscending)
{
// 初始间隔
for (int gap = n / 2; gap > 0; gap /= 2)
{
// 对每个间隔进行插入排序
for (int i = gap; i < n; i++)
{
int temp = arr[i];
int j = 0;
// 底层使用插入排序 因为插入排序的时间复杂度与逆序对个数成正比
for (j = i; j >= gap && (arr[j - gap] > temp && isAscending || arr[j - gap] < temp && !isAscending); j -= gap)
{
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
// 计算 Knuth 间隔序列
void Knuth_Gaps(int n, int* gaps, int* gap_count)
{
int k = 0;
int gap = 1;
while (gap < n)
{
gaps[k++] = gap;
gap = gap * 3 + 1; // 生成 (3^k - 1) / 2 的序列
}
*gap_count = k; // 返回间隔个数
}
// Knuth 希尔排序函数
void Shell_Sort1(int* arr, int n, int isAscending)
{
int gaps[100]; // 存储间隔序列
int gap_count = 0;
// 计算间隔序列
Knuth_Gaps(n, gaps, &gap_count);
// 从大的间隔开始排序
for (int k = gap_count - 1; k >= 0; k--)
{
int gap = gaps[k]; // 当前间隔
// 对每个间隔进行插入排序
for (int i = gap; i < n; i++)
{
int temp = arr[i];
int j = i;
// 使用插入排序进行排序
while (j >= gap && ((isAscending && arr[j - gap] > temp) || (!isAscending && arr[j - gap] < temp)))
{
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp; // 插入当前元素
}
}
}
希尔排序的优缺点
优点
-
较快的排序速度:
相比于简单的排序算法(如冒泡排序和插入排序),希尔排序在处理较大的数据集时通常更快,尤其是当数据部分有序时。
-
原地排序:
希尔排序是一种原地排序算法,不需要额外的存储空间,除了几个辅助变量。
-
适用范围广:
不仅适用于小型数据集,也能有效处理中等规模的数据集。
缺点
-
不稳定排序:
希尔排序在交换元素时可能会改变相同元素的相对顺序,因此不是稳定的排序算法。
-
时间复杂度不确定:
希尔排序的时间复杂度依赖于间隔序列的选择。最坏情况下时间复杂度为 O(n^2),而使用良好的间隔序列时可以达到 O(nlog2n)。但没有统一的标准,可能导致性能不稳定。
-
间隔序列的选择:
不同的间隔序列对排序效率有很大影响,选择合适的间隔序列需要经验,且没有一种通用的方法。
-
对于非常大的数据集性能有限:
尽管希尔排序比简单排序算法快,但对于非常大的数据集,仍然不如一些高级排序算法(如快速排序、归并排序)高效。
计数排序(Counting Sort)
计数排序是一种非比较的排序算法,适用于范围有限的整数排序。它通过计算每个元素出现的次数,进而确定每个元素在排序后位置的算法。
工作原理
- 确定范围:首先找出待排序数组中的最大值和最小值。
- 创建计数数组:生成一个计数数组,长度为最大值与最小值之差加一,用于记录每个元素的出现次数。
- 填充计数数组:遍历待排序数组,统计每个元素的出现次数,并存储在计数数组中。
- 计算位置:对计数数组进行累加,以确定每个元素在输出数组中的最终位置。
- 构建输出数组:根据计数数组,将元素放入输出数组中。
总轮数
计数排序的时间复杂度与待排序数组的元素个数 n 和元素值的范围 k 相关,通常表示为 O(n + k)。
时间复杂度
- 最坏情况:O(n + k),其中 n 是待排序元素的个数,k 是元素值的范围。
- 最好情况:O(n + k),同样适用。
- 平均情况:O(n + k)。
空间复杂度
计数排序需要额外的空间来存储计数数组,因此空间复杂度为 O(k),其中 k 是元素值的范围。
稳定性
计数排序是一种稳定的排序算法。相同元素的相对顺序在排序后保持不变,因为我们在填充输出数组时是按照计数数组的顺序进行的。
优缺点
优点
- 时间复杂度为 O(n + k),在适当条件下效率高。
- 是稳定的排序算法,适合需要保留相对顺序的场景。
- 不需要比较操作,适合特定范围的整数排序。
缺点
- 仅适用于整数或可以映射到整数的对象,不适合大范围分散的元素(例如,值的范围很大)。
- 需要额外的存储空间,尤其是当 k 较大时,可能导致空间浪费。
cpp
#include "All_Sort.h"
void Count_Sort(int* p, int n, int isAscending)
{
// 找到最大值和最小值
int max = p[0];
int min = p[0];
for (int i = 1; i < n; ++i)
{
if (p[i] > max)
max = p[i];
if (p[i] < min)
min = p[i];
}
// 计算范围
int range = max - min + 1;
int* c = (int*)calloc(range, sizeof(int)); // 创建计数数组
if (NULL == c)
exit(-1);
// 统计原数组数值出现的次数
for (int i = 0; i < n; i++)
{
c[p[i] - min]++; // 使用偏移量处理小于0的值
}
// 根据计数数组构建输出数组
int j = 0;
if (isAscending)
{
for (int i = 0; i < range; i++)
{
while (c[i]--)
{
p[j++] = i + min; // 填充回原数组
}
}
}
else
{
// 降序排序
for (int i = range - 1; i >= 0; i--)
{
while (c[i]--)
{
p[j++] = i + min; // 填充回原数组
}
}
}
free(c); // 释放计数数组的内存
}
void Count_Sort1(int* p, int n, int isAscending)
{
// 找到最大值和最小值
int max = p[0];
int min = p[0];
for (int i = 1; i < n; ++i)
{
if (p[i] > max)
max = p[i];
if (p[i] < min)
min = p[i];
}
// 计算范围
int range = max - min + 1;
int* c = (int*)calloc(range, sizeof(int)); // 创建计数数组
if (c == NULL)
exit(-1);
// 统计原数组数值出现的次数
for (int i = 0; i < n; i++)
{
c[p[i] - min]++;
}
// 根据计数数组构建输出数组
int* output = (int*)malloc(n * sizeof(int)); // 创建输出数组
if (output == NULL)
{
free(c);
exit(-1);
}
// 计算累积计数
if (isAscending)
{
for (int i = 1; i < range; i++)
{
c[i] += c[i - 1]; // 累加计数
}
// 从后往前填充输出数组,以保持稳定性
for (int i = n - 1; i >= 0; i--)
{
output[c[p[i] - min] - 1] = p[i];
c[p[i] - min]--; // 减少计数
}
}
else
{
for (int i = range - 2; i >= 0; i--)
{
c[i] += c[i + 1]; // 反向累加计数
}
// 从后往前填充输出数组,以保持稳定性
for (int i = n - 1; i >= 0; i--)
{
output[c[p[i] - min] - 1] = p[i];
c[p[i] - min]--; // 减少计数
}
}
// 将排序后的数据拷贝回原数组
for (int i = 0; i < n; i++)
{
p[i] = output[i];
}
free(c); // 释放计数数组的内存
free(output); // 释放输出数组的内存
}
计算累积计数是计数排序中的一个重要步骤,它的目的是为了确定每个元素在最终排序结果中应该放置的位置,保证了排序的稳定性。
假设我们有以下数组需要排序:
arr = [4, 2, 2, 8, 3, 3, 1]
我们要对这个数组进行升序排序。
步骤 1: 找到最大值和最小值
首先,我们需要找到数组中的最大值和最小值:
- 最大值 (
max
) = 8 - 最小值 (
min
) = 1
步骤 2: 计算范围
计算值的范围:
range = max - min + 1 = 8 - 1 + 1 = 8
步骤 3: 创建计数数组
创建一个大小为 range
的计数数组并初始化为0:
c = [0, 0, 0, 0, 0, 0, 0, 0] // 对应 1, 2, 3, 4, 5, 6, 7, 8
步骤 4: 统计出现次数
遍历原数组,统计每个元素出现的次数:
对于 arr[0] = 4: c[4-1]++ -> c[3]++
对于 arr[1] = 2: c[2-1]++ -> c[1]++
对于 arr[2] = 2: c[2-1]++ -> c[1]++
对于 arr[3] = 8: c[8-1]++ -> c[7]++
对于 arr[4] = 3: c[3-1]++ -> c[2]++
对于 arr[5] = 3: c[3-1]++ -> c[2]++
对于 arr[6] = 1: c[1-1]++ -> c[0]++
计数数组现在变为:
c = [1, 2, 2, 1, 0, 0, 0, 1] // 代表 1, 2, 3, 4, 5, 6, 7, 8 的出现次数
步骤 5: 计算累积计数
将计数数组转换为累积计数数组:
c[1] += c[0] -> c[1] = 3
c[2] += c[1] -> c[2] = 5
c[3] += c[2] -> c[3] = 6
c[4] += c[3] -> c[4] = 6
c[5] += c[4] -> c[5] = 6
c[6] += c[5] -> c[6] = 6
c[7] += c[6] -> c[7] = 7
现在累积计数数组为:
c = [1, 3, 5, 6, 6, 6, 6, 7]
步骤 6: 创建输出数组
创建一个输出数组,其大小与输入数组相同:
output = [0, 0, 0, 0, 0, 0, 0]
步骤 7: 填充输出数组
从后向前遍历原数组,根据累积计数数组填充输出数组:
对于 arr[6] = 1:
output[c[1-1]-1] = 1 -> output[0] = 1
c[1-1]--
对于 arr[5] = 3:
output[c[3-1]-1] = 3 -> output[4] = 3
c[3-1]--
对于 arr[4] = 3:
output[c[3-1]-1] = 3 -> output[3] = 3
c[3-1]--
对于 arr[3] = 8:
output[c[8-1]-1] = 8 -> output[6] = 8
c[8-1]--
对于 arr[2] = 2:
output[c[2-1]-1] = 2 -> output[2] = 2
c[2-1]--
对于 arr[1] = 2:
output[c[2-1]-1] = 2 -> output[1] = 2
c[2-1]--
对于 arr[0] = 4:
output[c[4-1]-1] = 4 -> output[5] = 4
c[4-1]--
最终,输出数组为:
output = [1, 2, 2, 3, 3, 4, 8]
步骤 8: 拷贝回原数组
将排序后的数据拷贝回原数组 arr
:
arr = [1, 2, 2, 3, 3, 4, 8]
总结
通过上述步骤,我们成功地对数组进行了计数排序,得到了升序排列的结果。计数排序的时间复杂度为 O(n + k),其中 n 是输入数组的大小,k 是计数数组的范围。
基数排序(Radix Sort)
基数排序(Radix Sort)是一种非比较排序算法,适用于整数和字符串的排序。它通过将数据分成单个数字或字符,并逐位进行排序,通常使用计数排序作为子过程。基数排序的关键在于它可以在 O(nk) 的时间复杂度内完成排序,其中 n 是待排序元素的数量,k 是数字的位数。
基本原理
- 分位排序 :
- 基数排序将整数分解为多个"数字位",通常从最低有效位(Least Significant Digit, LSD)到最高有效位(Most Significant Digit, MSD)进行排序。
- 稳定的排序算法 :
- 基数排序通常使用稳定的排序算法(如计数排序)来对每一位进行排序,这样可以保持相同数字的相对顺序。
工作步骤
-
确定最大数的位数:
- 首先找出待排序数组中最大的数字,以确定排序需要的位数。
-
按位排序:
- 从最低位开始,对每一位进行排序。
- 使用稳定的排序算法(如计数排序)对当前位进行排序。
-
重复:
- 重复上述步骤,直到所有位都被处理。
时间复杂度
- 时间复杂度 :O(d * (n + k)),其中
d
是数字的位数,n
是数组的大小,k
是基数(例如,0-9 的数字,k=10)。 - 空间复杂度:O(n + k),用于存储临时数据。
优缺点
优点:
- 对于大量整数的排序效率高。
- 稳定性好,适合需要保持相对顺序的场景。
缺点:
- 只适用于整数或字符串,不适合浮点数。
- 内存使用较多,尤其是在处理大量数据时。
适用场景
基数排序适合用于处理大量的整数数据,尤其是当数据范围相对较小且位数不多时,能够显著提高排序效率。
cpp
#include "All_Sort.h"
//基数排序
void Radix_Sort(int *arr, int n, int isAscending)
{
// 找到最大值,以确定位数
int max = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] > max) {
max = arr[i];
}
}
// 从最低位到最高位进行排序
for (int exp = 1; max / exp > 0; exp *= 10)
{
Count_Sort1(arr, n, isAscending);//计数排序
}
}
桶排序(Bucket Sort)
基本概念
桶排序(Bucket Sort)是一种分布式排序算法,适用于将数据分布在特定范围内的情况。它通过将数据划分到多个"桶"中,然后对每个桶内的数据进行排序,最后再将所有桶中的数据合并起来。
基本原理
-
创建桶:
根据待排序数据的范围和数量,创建若干个桶,以便将数据均匀分布到这些桶中。
-
分配数据:
将待排序数据分配到各个桶中。每个元素根据其值被放入相应的桶中。
-
桶内排序:
对每个非空的桶进行排序。可以使用任何排序算法(如插入排序、快速排序等),但一般选择适合桶内小数据量的排序算法。
-
合并桶:
将所有桶中的数据按顺序合并,得到最终的排序结果。
工作步骤
- 确定桶的数量:根据数据的分布情况选择适当的桶数量。
- 初始化桶:创建空桶并准备存放数据。
- 分配元素:遍历待排序数组,将每个元素放入其对应的桶中。
- 对每个桶排序:对每个非空桶中的元素进行排序。
- 合并结果:将所有桶中的元素按顺序合并,形成最终的排序数组。
时间复杂度
- 平均时间复杂度:O(n + k),其中 n 是待排序元素的数量,k 是桶的数量。
- 最坏时间复杂度:O(n^2),当所有元素都分配到同一个桶中时。
- 空间复杂度:O(n + k),用于存储桶和临时数据。
优缺点
优点
- 高效:当数据均匀分布时,桶排序的效率非常高。
- 适合特定数据:对于特定范围内的浮点数和整数,桶排序表现优异。
缺点
- 内存消耗大:需要额外的内存空间来存储桶。
- 不适用所有数据:对于数据分布不均的情况,可能会导致性能下降。
适用场景
桶排序适合用于处理浮点数和整数数据,尤其是数据范围已知且分布相对均匀时,能够显著提高排序效率。
算法总结
不同的排序算法适用于不同的应用场景。以下是一些常见排序算法的应用场景总结:
1. 冒泡排序 (Bubble Sort)
- 应用场景 :
- 小规模数据集时,因为实现简单。
- 教学和演示排序算法的基本概念。
2. 选择排序 (Selection Sort)
- 应用场景 :
- 适合小规模的数据集。
- 内存要求较严格时,因为使用常数空间。
- 当数据基本有序时,性能表现较好。
3. 插入排序 (Insertion Sort)
- 应用场景 :
- 小规模数据集。
- 数据几乎有序时,效率高。
- 实时插入数据的场景,比如在线算法。
4. 归并排序 (Merge Sort)
- 应用场景 :
- 大规模数据集,时间复杂度稳定的场景。
- 需要稳定排序的应用,如链表排序。
- 外部排序(处理超出内存的数据)。
5. 快速排序 (Quick Sort)
- 应用场景 :
- 大规模数据集,平均性能优越。
- 在内存中处理数据时,性能极佳(尽管最坏情况下为 O(n²))。
- 实现复杂度低的情况下。
6. 堆排序 (Heap Sort)
- 应用场景 :
- 不需要额外的空间,适合内存有限的情况。
- 堆排序可以用来实现优先队列,允许在 O(log n) 时间内插入和删除最大(或最小)元素。
7. 计数排序 (Counting Sort)
- 应用场景 :
- 当待排序元素的范围较小(k)时,适合使用。
- 整数排序,特别是在数据较为集中时。
8. 基数排序 (Radix Sort)
- 应用场景 :
- 整数或字符串排序,且对数字位数有上限。
- 大规模数据集,且需要稳定排序。
9. 桶排序 (Bucket Sort)
- 应用场景 :
- 特定分布的数值排序,比如均匀分布。
- 适合大数据集,能够并行处理。
排序算法选择总结
在选择合适的排序算法时,以下因素需要考虑:
-
数据规模:
- 小规模数据集:简单算法如插入排序或选择排序可能更合适。
- 大规模数据集:需要 O(n log n) 性能的算法,如快速排序、归并排序或堆排序。
-
数据分布:
- 数据几乎有序:插入排序表现良好。
- 数据范围较小:计数排序和基数排序可能更高效。
- 随机分布:快速排序和归并排序通常是优选。
-
内存限制:
- 内存受限:堆排序和原地的快速排序适合在内存使用上比较节省的场景。
- 额外空间需求:归并排序需要额外空间,而计数排序对范围有要求。
-
稳定性:
- 需要稳定排序:归并排序和插入排序是稳定的,而快速排序和堆排序通常是不稳定的。
- 不需要稳定性:可以选择快速排序、堆排序等不稳定算法。
总结
选择合适的排序算法时,需要考虑数据规模、数据分布、内存限制和是否需要稳定排序等因素。