文章目录
- 排序
- 排序的基本概念
- 排序方法的分类
- 插入排序
- 直接插入排序
- 性能分析
- 代码实现
- 折半插入排序
- 性能分析
- 代码实现
- 希尔排序
- 性能分析
- 代码实现
- 交换排序
- 冒泡排序
- 分析和改进
- 拓展(提高冒泡效率的方法)
- 短路冒泡代码实现
- 双向冒泡法(鸡尾酒排序)
- 快速排序
- Hoare版本
- 代码实现
- 分析
- 前后指针版
- 代码实现
- 快速排序优化
- 三数取中法选基准值
- 插入排序优化
- 快速排序非递归
- 选择排序
- 简单选择排序
- 代码实现
- 堆排序
- 堆的定义
- 堆的调整
- 堆的建立
- 堆排序完整代码
- 分析
- 归并排序
- 代码实现
- 基数排序
- 分析
- 代码实现
- 排序算法的分析和应用
- 解释补充
排序
排序的基本概念
排序是将一组杂乱无序的数据按照一定规则进行重新排列的过程。它的目标是将数据按照升序(由小到大)或降序(由大到小)的顺序排列起来,以便更方便地进行搜索、查找、比较和其他操作。
排序的应用范围非常广泛。在软件开发中,排序是非常常见和基础的操作,用于对数据进行整理和组织,以提高程序的性能和功能。排序也是许多其他算法和数据结构的基础,例如二分法查找、最短路径算法、最小生成树算法等。此外,排序还可以用于数据分析、数据挖掘、统计学等领域。
算法的稳定性:在排序算法中,稳定性是指在排序过程中,如果存在两个相等的元素,它们在排序前后的相对顺序是否保持不变。如果排序算法在排序后能够保持相等元素的相对顺序不变,那么它被称为稳定的排序算法。相反,如果排序算法会改变相等元素的相对顺序,那么它是不稳定的排序算法。稳定性对于某些特定的应用场景非常重要,例如对于保留原始顺序的需求或者依赖于相对顺序的后续处理。
排序方法的分类
- 按数据存储介质分类:
-
内部排序:数据量较小且能够完全加载到内存中进行排序的情况。排序过程中不需要进行内外存交换。
- 一般内部排序算法通常涉及两种主要操作:比较和移动。通过比较两个关键字的大小,确定元素的顺序关系,然后通过移动元素来达到有序的目的。
- 内部排序算法的性能取决于其时间复杂度和空间复杂度,其中时间复杂度通常由比较和移动的次数决定。
- 需要注意的是,大多数内部排序算法仅适用于顺序存储的线性表,即将数据存储在连续的内存空间中,如数组。对于其他数据结构,如链表,可能需要进行适当的修改才能应用排序算法。
-
外部排序:数据量较大,无法一次性加载到内存中进行排序的情况。需要将数据分批调入内存,中间结果及时放入外部存储器(如磁盘)。
- 按比较器个数分类:
- 串行排序:在单个处理机上进行排序,每次同时比较一对元素。
- 并行排序:在多个处理机上进行排序,可以同时比较多对元素。
- 按主要操作分类:
- 比较排序:通过比较元素的大小来确定它们的顺序,包括插入排序、交换排序、选择排序和归并排序等。
- 基数排序:不通过比较元素的大小,而是根据元素本身的取值来确定它们的有序位置。
- 按辅助空间分类:
- 原地排序:排序过程中辅助空间的使用量为常数,不随数据规模的增加而增加,例如冒泡排序和插入排序。
- 非原地排序:排序过程中需要额外的辅助空间,其使用量随着数据规模的增加而增加,例如归并排序和堆排序。
- 按稳定性分类:
- 稳定排序:能够保持相等元素的相对顺序不变的排序方法,即相等元素在排序前后的相对位置保持一致,例如插入排序和归并排序。
- 非稳定排序:排序后相等元素的相对顺序可能改变的排序方法,例如快速排序和选择排序。
- 按自然性分类:
- 自然排序:当输入数据越接近有序状态时,排序速度越快的排序方法,例如插入排序。
- 非自然排序:输入数据较有序时排序速度反而较慢的排序方法,例如快速排序。
每种排序算法都有其独特的优点和缺点,并且适用于不同的环境和数据情况。没有一种算法被广泛认为是最好的,因为它们在不同的场景和数据集上表现可能不同。一般来说,排序算法可以分为插入排序、交换排序、选择排序、归并排序和基数排序这五大类。
插入排序
插入排序是一种基础的排序算法,它通过逐步构建有序序列来完成排序。
插入算法的基本思想:每一步将一个待排序的元素,按其关键字大小插入前面已排好的子序列中,直到所有元素全部插入完成。这种思想实质上是“边插入边排序”,保证每次插入后的子序列都是有序的。
直接插入排序
根据上述的插入排序思想,直接插入排序是一种插入排序最基本的排序算法。其不同之处,是在有序序列表中用到O(1)的额外空间,即采用in-place排序。
算法步骤:
- 将待排序的记录存在数组 r[1…n] 中,r[1] 是一个有序序列。
- 循环 n-1 次,每次将 r[i](i = 2,…,n)插入到已排好序的序列 r[1…i-1]中。通过顺序查找法找到适当的插入位置,然后将 r[i] 插入。最后将 r[n] 插入到 r[1…n-1],得到一个长度为 n 的有序序列。
在查找过程中,首先需要保存带插入元素x=a[i],因为在后续的元素后移过程中,a[i]的位置可能被覆盖。然后,从有序序列的尾部开始比较,如果a[i] > x,则将a[j]后移一位,即a[j+1]=a[j],然后继续比较 a[j-1] 和 x,直到找出第一个不大于 x 的元素 a[j] ,这就是 x 应该插入的位置。最后,将 x 插入到 a[j+1] 的位置。
为了实现这个保存带插入元素 a[i],我们通常使用哨兵来简化。在查找的开始 ,先将待插入的元素存到哨兵位置,这样就不需要每次都检查数组下标是否越界,只需要在找到插入位置后,将哨兵上的元素插入到正确的位置。
此外,如果待插入的元素大于有序序列的最后一个元素,那么它应该被插入到序列的末尾,这种情况下可以直接插入,无需进行查找。
性能分析
直接插入排序的性能受到输入数据的有序性影响,其性能主要依赖于两种基本操作:比较关键字的大小和移动记录。
- 最优情况:在最优情况下,输入数据是完全有序的。此时每个待插入元素都比有序序列中的最大元素还要大,因此无需移动任何元素,只需要进行n-1次比较操作。此时最优情况下的时间复杂度为O(n)。
- 最坏情况:在最坏情况下,输入数据是完全逆序的,此时每个待插入元素都需要移动到有序序列的最前面。因此,最坏情况下的时间复杂度为O(n2)。
- 平均情况:在平均情况下,待插入的元素在有序序列中的平均位置是中间,因此平均需要移动一半的元素。所以,平均情况下的时间复杂度仍然是O(n2)。
代码实现
#include <stdio.h>
void InsertSort(int arr[], int n) {
int i, j;
for (i = 2; i <= n; i++) { // 因为 arr[0] 作为哨兵,所以从 arr[2] 开始排序
if (arr[i] < arr[i - 1]) { // 如果待插入元素小于有序序列的最后一个元素,需要找到插入位置
arr[0] = arr[i]; // 将待插入元素存储到哨兵位置
for (j = i - 1; arr[0] < arr[j]; --j) { // 查找插入位置
arr[j + 1] = arr[j]; // 元素后移
}
arr[j + 1] = arr[0]; // 插入到正确位置
}
}
}
int main() {
int arr[] = {0, 5, 3, 4, 6, 2}; // 第一个元素 arr[0] 作为哨兵
int n = sizeof(arr) / sizeof(arr[0]) - 1; // 计算元素个数,减1是因为哨兵位置不算
InsertSort(arr, n);
// 打印排序结果
for (int i = 1; i <= n; ++i) {
printf("%d ", arr[i]);
}
return 0;
}
折半插入排序
折半插入排序(也叫二分插入排序)是直接插入排序的改进版,与直接插入排序不同的是,它是通过二分查找来减少比较次数。
具体操作过程容易实现就不具体阐述。
性能分析
折半查找比顺序查找快,它所需要的关键码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第 i 个对象时,需要经过 log2 i + 1 次关键码比较,才能确定它应该插入的位置。
- 时间复杂度:折半插入排序的最好、最坏和平均时间复杂度都为 O(n²)。虽然折半插入排序减少了比较次数,但这并没有改变其时间复杂度级别,因为记录的移动次数并没有减少。
- 空间复杂度:折半插入排序的空间复杂度为 O(1),它是一种原地排序算法,不需要额外的存储空间。
- 稳定性:折半插入排序是稳定的,因为在插入过程中不会改变相等元素的相对顺序。
- 适用场景:由于折半插入排序的时间复杂度为 O(n²),所以它更适合于小规模或基本有序的数据排序。对于大规模的数据,可以考虑使用时间复杂度更低的排序算法,例如快速排序、归并排序等。
代码实现
#include <stdio.h>
void BinaryInsertionSort(int arr[], int n) {
int i, j, low, high, mid;
int key;
for (i = 1; i < n; i++) {
key = arr[i]; // 当前待插入的元素
low = 0;
high = i - 1;
// 折半查找应该插入的位置
while (low <= high) {
mid = (low + high) / 2;
if (arr[mid] > key) {
high = mid - 1;
}
else {
low = mid + 1;
}
}
// 移动元素,为插入位置腾出空间
for (j = i - 1; j >= high + 1; j--) {
arr[j + 1] = arr[j];
}
// 将元素插入找到的位置
arr[high + 1] = key;
}
}
int main() {
int arr[] = { 12, 11, 13, 5, 6 };
int n = sizeof(arr) / sizeof(arr[0]);
BinaryInsertionSort(arr, n);
printf("Sorted array: \n");
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}
希尔排序
希尔排序的基本思想是将待排序的记录序列分割成若干个子序列,然后对每个子序列进行插入排序,从而使整个序列基本有序,然后再对整个序列进行一次直接插入排序。
希尔排序的主要特点是“缩小增量”。它首先以一个较大的增量(gap)将待排序表分成若干个子序列,然后在每个子序列中进行直接插入排序。这这个过程中,元素会一次性移动到离其最终位置比较近的地方。然后逐渐减小增量,重复上述过程,直到增量为1,也就是进行一次全体元素的插入排序。
在实际应用中,希尔排序的效率相对较高,主要因为它将原序列分为多个子序列,每个子序列的元素个数较少,因此插入排序的效率较高。另外,由于初识时元素间隔较大,可以跳跃式移动元素,使每个元素尽快接近它的最终位置,从而加快排序的速度。这使得希尔排序在处理大型数据时具有较高的效率。
选择合适的增量序列也是希尔排序的一个重要问题。增量序列必须是递减的,且最后一项必须是1,以保证最后一次排序是一次全体元素的直接插入排序,也就是说,当增量为1时,整个序列已经基本有序,插入排序的效率就会很高。另外,如果增量序列中的数互质,那么在每次插入排序后,序列的有序性会更好,从而使整个排序过程更高效。
常见的有“希尔增量”(gap的取值为n/2,n/4,…直到1)和“Hibbard增量”(gap的取值为2^n-1,如1, 3, 7, 15,…)等。这些增量序列的效率有所不同,具体选择哪一种增量序列,需要根据实际情况来定。
举例:
- 增量为5的插入排序:我们将原始数组分成5组,每组内的元素是间隔5个位置的元素。这样分之后,我们得到的五个组为 {23, 13}, {1, 5}, {17, 11}, {4, 7}, {31}。在每组内部进行直接插入排序之后,我们得到的数组为 [13,1,11,4,31,23,5,17,7]。
- 增量为3的插入排序:我们将数组分为3组,即 {13, 4, 5}, {1, 31, 17}, {11, 23,7}。在每组内部进行直接插入排序,排序后我们得到的数组为 [4,1,7,5,17,11,13,31,23]。
- 增量为1的插入排序:当增量为1时,就是对整个数组进行直接插入排序。此时,由于数组已经基本有序,所以插入排序的效率会很高。排序后得到的数组为 [1, 4, 5, 7, 11, 13, 17, 23, 31],完成排序
性能分析
希尔排序的性能和所选的增量序列的性质有很大关系。例如,Hibbard的增量序列(1, 3, 7, 15,…)以及Sedgewick的增量序列(1, 5, 19, 41, 109,…)都被证明在实践中表现得相当不错。
Hibbard的增量序列的最坏情况时间复杂度是O(n(3/2)),Sedgewick的增量序列的猜测平均时间复杂度是O(n(7/6)),最坏情况时间复杂度是O(n(4/3))。
选择最佳的增量序列仍然是一个开放的研究问题。虽然Hibbard和Sedgewick的增量序列在实践中表现得相当好,但是没有一个公认的最佳选择。
值得注意的是,希尔排序是不稳定的。也就是说,如果两个元素相等,希尔排序不能保证它们的相对顺序不变。这可能在某些情况下是一个重要的考虑因素。
空间复杂度方面,希尔排序只需要常数级别的额外空间,所以它的空间复杂度是O(1)。这使得希尔排序对于内存有限的系统来说是一个有吸引力的选择。
另外,希尔排序不适合在链式存储结构上实现。因为它需要频繁地访问距当前位置固定距离的元素,这在数组中是常数时间的操作,但在链表中却需要线性时间。因此,如果数据存储在链表中,使用其他排序算法可能会更加高效。
代码实现
#include<stdio.h>
//插入排序函数
void insertSortWithGap(int arr[],int n, int gap )
{
// 从第gap个元素,逐个对其所在组进行直接插入排序操作
for(int i = gap; i<n ; i++)
{
int temp = arr[i]; // 记录要插入的数据
int j;
// 从后向前,找到要插入的位置
for (j=i; j>=gap&&arr[j-gap]>temp;j-=gap)
{
arr[j] = arr[j-gap]; // 向后挪动
}
// 插入数据
arr[j] = temp;
}
}
// 希尔排序函数
void shellSort(int arr[], int n, int gaps[], int g_length) {
for (int g = 0; g < g_length; g++) {
int gap = gaps[g];
insertSortWithGap(arr, n, gap);
}
}
// 打印数组
void printArray(int arr[], int size) {
int i;
for (i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n");
}
// 主函数
int main() {
int arr[] = {23, 1, 17, 4, 31, 13, 5, 11, 7};
int n = sizeof(arr) / sizeof(arr[0]);
int gaps[] = {5, 3, 1}; // 定义增量序列
int g_length = sizeof(gaps) / sizeof(gaps[0]);
printf("Array before sorting: \n");
printArray(arr, n);
shellSort(arr, n, gaps, g_length);
printf("\nArray after sorting: \n");
printArray(arr, n);
return 0;
}
交换排序
所谓交换,是指在序列中两个关键字的比较结果正好是逆序排序,需要交换两个记录在序列中的位置。下面重点介绍冒泡排序和快速排序。
冒泡排序
冒泡法的基本思想是通过不断的比较和交换,使得每一趟排序后的序列比上趟更有序,在每一趟排序中,都将相邻的两个元素进行比较,如果他们的顺序为逆序就把他们交换过来。这样每一趟都将最大或最小的关键字的元素“浮”到顶端。
实现步骤:
- 比较相邻的元素。如果第一个比第二个大(即,如果它们是逆序的),就交换它们的位置。
- 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
#include <stdio.h>
void bubble_sort(int arr[], int n) {
int i, j, temp;
for(i = 0; i < n-1; i++) {
// 这个外层循环控制冒泡排序的趟数,一共需要n-1趟,因为每一趟都会将一个最大值放到后面
for(j = 0; j < n-i-1; j++) {
// 这个内层循环控制每一趟的比较次数,每一趟需要比较的次数都会减1,因为每一趟都会将一个最大值放到后面
if(arr[j] > arr[j+1]) {
// 比较相邻两个元素,如果前一个元素比后一个元素大,那么就交换它们的位置
temp = arr[j]; // 使用临时变量进行交换
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr)/sizeof(arr[0]); // 计算数组长度
bubble_sort(arr, n); // 调用冒泡排序函数
printf("Sorted array is: \n");
for(int i=0; i < n; i++) {
printf("%d ", arr[i]); // 打印排序后的数组
}
return 0;
}
分析和改进
冒泡法的优点: 它能在每趟排序中不仅将最大(或最小)的值放到最后面,而且还能使部分其他元素变得有序。
复杂度分析:
- 时间复杂度:
- 最好情况(已经排序):O(n)
- 最坏情况(完全逆序):O(n²)
- 平均情况:O(n²)
- 空间复杂度:O(1),冒泡排序是原地排序算法,它只需要常量级别的额外空间。
- 稳定性:冒泡排序是稳定的排序算法,即相等的元素的顺序在排序后不会改变。
拓展(提高冒泡效率的方法)
提高冒泡排序效率的方法:如果在某一趟排序中没有发生任何交换,那么我们就可以确定列表已经排序完成,从而提前结束算法以提高效率。这种优化通常被称为“短路冒泡排序”或“冒泡排序的提前停止”。
实现这个短路方法,我们可以在代码中添加一个bool型标志位,用来标记在一趟排序中是否进行了交换。如果在一趟中没有发生任何交换,那么标志位就不会改变,我们就可以提前结束排序。
短路冒泡代码实现
#include<stdio.h>
void bubble_sort (int arr[], int n)
{
int i, j ,temp;
int swapped; //标记
for (i = 0; i<n-1 ; i++) {
swapped = 0; //初始化为0,表示没有交换过
for (j = 0; j < n - i - 1; j++)
{
if (arr[j] > arr[j+1]) {
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1]=temp;
swapped = 1; //表示交换过
}
}
if(swapped == 0) break ; //如果没有交换过,说明已经有序,退出排序
}
}
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr)/sizeof(arr[0]); // 计算数组长度
bubble_sort(arr, n); // 调用冒泡排序函数
printf("Sorted array is: \n");
for(int i=0; i < n; i++) {
printf("%d ", arr[i]); // 打印排序后的数组
}
return 0;
}
双向冒泡法(鸡尾酒排序)
鸡尾酒排序是冒泡排序的一种变种。在普通的冒泡排序中,每一趟排序都是从序列头部开始,向尾部检查并交换元素。而鸡尾酒排序中,每一轮排序由两个部分组成:首先,它会向普通的冒泡排序一样,从头部开始检查并交换元素,使得较大的元素向尾部移动;然后,它会反过来,从尾部开始,向头部检查并交换元素,使得较小的元素向头部移动。
#include<stdio.h>
void cocktailSort(int a[], int n)
{
int swapped = 1;
int start = 0;
int end = n - 1;
while(swapped)
{
// 进入循环时重置swapped标记,因为上一轮迭代可能将它设置为1
swapped = 0;
// 从头到尾进行一次冒泡排序
for(int i = start; i<end;i++)
{
if(a[i]>a[i+1])
{
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
swapped = 1;
}
}
// 如果没有元素交换,那么数组已经排序完成
if (!swapped)
break;
// 否则,重置swapped标记,以便在下一阶段使用
swapped = 0;
// 将end指针向前移动一位,因为最后的元素已经在正确的位置
end--;
// 从尾到头进行一次冒泡
for (int i = end - 1; i >= start; i--) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
swapped = 1;
}
}
// 将start指针向后移动一位,因为上一轮的冒泡已经将最小的元素移动到了正确的位置
start++;
}
}
int main() {
int arr[] = {5, 1, 4, 2, 8, 0, 2};
int n = sizeof(arr) / sizeof(arr[0]);
cocktailSort(arr, n);
printf("Sorted array :\n");
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}
优点:
- 适应性:在某些情况下,鸡尾酒排序可能会比传统的冒泡排序更高效。例如,如果输入列表已经部分排序过,鸡尾酒排序可能会在几轮迭代之后完成排序,而冒泡排序则可能需要更多的迭代。
- 在列表两端都进行排序:与普通冒泡排序不同的是,鸡尾酒排序在每一轮迭代中都会从两个方向对列表进行排序。这意味着每轮迭代后,列表的两端都可能会有元素到达其正确的位置。
缺点:
- **时间复杂度依然是O(n2)**:尽管鸡尾酒排序在某些情况下可能会比冒泡排序更快,但其最坏和平均时间复杂度仍然是O(n2),这与冒泡排序是相同的。因此,对于大型数据集,这种排序算法的效率仍然很低。
- 复杂性增加:相比于冒泡排序,鸡尾酒排序的实现更复杂。它需要在列表的两个方向上进行迭代,这使得代码更难理解和维护。
快速排序
快速排序是非常重要的排序方法。它的基本思想是
- 通过在序列中选取一个基准值(pivot)
- 然后将序列中其他元素和pivot进行比较,将小于pivot的元素放在pivot的左侧;将大于pivot的元素放在pivot的右侧。这个过程成为分割操作,也可以称为划分操作。
- 接着进行递归排序,对pivot左侧的子序列和右侧的子序列分别进行快速排序。递归地重复上述过程,知道子序列的长度为1或0,即所有元素都排列在相应的位置。
- 合并操作,由于快速排序是原地排序算法,不需要显式的合并操作。在每一次递归排序后,子序列的元素已经在正确的位置上,所以整个序列的排序结果就得到了。
Hoare版本
具体思路:
- 选择基准值:选择最左边的值作为基准值pivot
- 确定指针:初始化两个指针‘left’和’right’,分别指向待排序序列的最左边和最后边。
- 进行分区操作:left指针向右移动,知道找到一个大于pivot的元素停下来,然后right指针向左移动,知道找到一个小于基准值的元素停下来。
- 交换元素:交换left和right指针所对应的元素。
- 重复步骤3和4:继续移动left和right指针,并交换元素,知道left和right指针相遇为止。
- 将基准值放到正确的位置:将pivot与left指针(或right指针)所在的位置的元素进行交换,此时pivot左边的所有元素都小于它,右边所有元素都大于它。
- 递归排序:对pivot左侧的子序列和右侧的子序列分别进行递归排序,重复上述步骤,知道子序列的长度为1或0,即所有元素都排列在相应的位置上。
代码实现
#include<stdio.h>
#include<stdbool.h>
void swap(int *a, int *b);
int partition(int arr[],int low, int high);
void quicksort (int arr[], int low, int high)
{
//如果low小于high,说明至少有两个元素待排序。
if(low<high)
{
//找到基准元素的正确位置pi
int pi = partition(arr,low,high);
//分别对基准元素左侧和右侧的子数组进行递归排序
quicksort(arr,low,pi);
quicksort(arr,pi+1,high);
}
}
// 此函数以第一个元素为基准,将所有小于基准的元素放在基准的左边,
// 所有大于基准的元素放在基准的右边,并返回基准的位置
int partition(int arr[],int low, int high)
{
int pivot = arr[low]; //基准元素
int left = low+1; //左指针
int right = high; //右指针
while(true)
{
//找到左边第一个大于基准的元素
while (left <= right && arr[left] <= pivot)
{
left++;
}
//找到右边第一个小于基准的元素
while(arr[right] > pivot )
{
right--;
}
if(left<right)
{
//交换左右指针所指向的元素
swap (&arr[left],&arr[right]);
}
else
{
break;
}
}
//交换基准元素和右指针所指向的元素
swap(&arr[low],&arr[right]);
return right;
}
//交换两个元素的值
void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
// 创建一个未排序的数组
int arr[] = {10, 7, 8, 9, 1, 5};
int n = sizeof(arr) / sizeof(arr[0]);
// 调用quicksort函数对数组进行排序
quicksort(arr, 0, n - 1);
// 打印排序后的数组
printf("排序后的数组: \n");
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
分析
平均时间复杂度:O(nlog₂n),因为在每次划分操作中,序列被平均地分成两个子序列,递归地对子序列进行排序。每次划分操作的时间复杂度为O(n),递归的深度为O(log₂n),因此整体的时间复杂度为O(nlog₂n)。
空间复杂度: Hoare版本的快速排序算法也需要使用递归调用栈来实现递归排序。在平均情况下,递归调用栈的深度为O(log₂n),因此空间复杂度为O(log₂n)。在最坏情况下,递归调用栈的深度可以达到O(n),因此空间复杂度为O(n)。尽管快速排序不是原地排序算法,但由于递归的特性,额外的空间占用是可以接受的。
稳定性: Hoare版本的快速排序算法也是一种不稳定的排序算法,因为在划分操作中,相同的元素可能会被分到不同的子序列中,导致相对位置可能发生改变。
优点:
- Hoare版本大大减少了交换次数,Hoare算法在划分过程中只进行了元素交换,没有进行元素复制操作。这减少了算法的时间和空间复杂度,提高了排序的性能。
- Hoare版本算法使用了双指针的方式进行划分,这种划分方式可以更快的将序列划分为左右两个子序列,加快排序的速度。
- Hoare不需要额外空间来存储中间结果,它是一种原地排序算法,节省了内存空间的使用。
- 适用于大规模数据:由于Hoare版本的快速排序减少了元素交换次数和空间复杂度,因此在处理大规模数据时效率更高。它是一种高效的排序算法,广泛应用于各种排序场景。
限制:
- 对于具有大量重复元素的数组,可能出现不均匀的划分情况,导致算法的性能下降。这是因为Hoare版本的划分方式只根据基准值进行大小判断,而不考虑相等的情况。
- 在最坏情况下,即每次划分都将序列划分为一个较小的子序列和一个较大的子序列,Hoare版本的快速排序的时间复杂度可能退化为O(n^2)。这种情况通常出现在序列已经有序或接近有序的情况下。
前后指针版
步骤:
- 选定基准值,定义prev和cur指针(cur = prev + 1)
- cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置
- 将prev对应值与cur对应值交换
- 重复上面的步骤,直到cur走出数组范围
- 最后将基准值与prev对应位置交换
- 递归排序以基准值为界限的左右区间
代码实现
#include <stdio.h>
void quicksort(int arr[], int low, int high);
int partition(int arr[], int low, int high);
void swap(int* a, int* b);
int main() {
int arr[] = {10, 7, 8, 9, 1, 5};
int n = sizeof(arr) / sizeof(arr[0]);
quicksort(arr, 0, n - 1);
printf("排序后的数组: \n");
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
// 快速排序的主函数
void quicksort(int arr[], int low, int high) {
if (low < high) {
// pi是划分的索引,arr[pi]现在位于正确的位置
int pi = partition(arr, low, high);
// 分别对划分后的两部分进行递归排序
quicksort(arr, low, pi - 1);
quicksort(arr, pi + 1, high);
}
}
// 采用前后指针法进行划分
int partition(int arr[], int low, int high) {
int pivot = arr[low]; // 基准值
int prev = low; // 前指针
int cur = low + 1; // 后指针
// 当后指针还在数组范围内时,执行循环
for (; cur <= high; cur++) {
// 如果后指针指向的元素小于基准值
if (arr[cur] < pivot) {
// 将前指针向后移动一个位置
prev++;
// 交换前指针和后指针所指向的元素
swap(&arr[prev], &arr[cur]);
}
}
// 将基准值与前指针所指向的位置进行交换
swap(&arr[low], &arr[prev]);
return prev; // 返回基准值的位置
}
// 用于交换两个元素的函数
void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
快速排序优化
上述的几种方法都存在着一些缺陷:
如果选择的基准值恰好为最小值,那就没必要再进行不必要的递归了。
在排序大量有序或者接近有序的数据时,效率会很低,甚至可能出现崩溃的情况,这是因为在排序有序数据时,快速排序的递归次数过多,会导致栈溢出的情况。
为了解决这些问题,这里有两种优化方法:
- 三数取中法选基准值
- 插入排序优化
三数取中法选基准值
三叔取中法是指在待排序序列的头,中,尾三个位置上的元素,选择他们中间大小的元素作为基准值。这种方法在待排序序列的元素分布比较均匀或者元素数量较多的情况下,可以有效地防止快速排序退化为O(n2)的时间复杂度。
#include <stdio.h>
// 交换函数,用于交换两个元素
void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
// 三数取中法
int medianOfThree(int arr[], int low, int high) {
int mid = low + (high - low) / 2; // 计算中间位置
// 使用if-else语句确保arr[low] <= arr[mid] <= arr[high]
if (arr[mid] > arr[high]) {
swap(&arr[mid], &arr[high]);
}
if (arr[low] > arr[high]) {
swap(&arr[low], &arr[high]);
}
if (arr[mid] > arr[low]) {
swap(&arr[mid], &arr[low]);
}
// 将基准值交换到数组末尾
swap(&arr[low], &arr[high]);
return arr[high];
}
// 快速排序的一次划分
int partition(int arr[], int low, int high) {
int pivot = medianOfThree(arr, low, high); // 获取基准值
while (low < high) {
while (low < high && arr[low] <= pivot) {
low++;
}
arr[high] = arr[low];
while (low < high && arr[high] >= pivot) {
high--;
}
arr[low] = arr[high];
}
arr[high] = pivot;
return high;
}
// 快速排序
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
// 打印数组
void printArray(int arr[], int size) {
int i;
for (i=0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int arr[] = {9, 2, 10, 7, 5, 1, 8, 3, 6};
int len = sizeof(arr) / sizeof(arr[0]);
quickSort(arr, 0, len - 1);
printf("Sorted array: \n");
printArray(arr, len);
return 0;
}
插入排序优化
每次选择一个基准值的时,将数组小于基准值和大于基准值两部分元素,然后我们将这两部分数组分别进行快速排序,这个过程就好像是在创建一个二叉树,每个子数组对应于树的一个子树。
这个递归过程会一直进行下去,直到每个子数组只包含一个元素。由于每次递归都涉及到函数调用,所以当子数组的大小变小到一定程度时,这个开销可能会变得相当大。为了解决这个问题,我们可以再子数组的大小小于某个阈值时,切换到另一种排序算法。比如插入排序,这样做既可以利用快速排序在大数组上的高效性,也可以利用插入算法在小数组上的高效性,避免了过多的递归调用。
#include <stdio.h>
#define THRESHOLD 10 // 定义插入排序的阈值
// 交换两个元素的值
void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
// 三数取中法选择基准值
int medianOfThree(int arr[], int low, int high) {
int mid = low + (high - low) / 2;
// 将中间值与末尾值比较,如果中间值更大,则交换这两个值
if (arr[mid] > arr[high]) {
swap(&arr[mid], &arr[high]);
}
// 将起始值与末尾值比较,如果起始值更大,则交换这两个值
if (arr[low] > arr[high]) {
swap(&arr[low], &arr[high]);
}
// 将中间值与起始值比较,如果中间值更大,则交换这两个值
if (arr[mid] > arr[low]) {
swap(&arr[mid], &arr[low]);
}
// 将基准值放到末尾
swap(&arr[low], &arr[high]);
return arr[high];
}
// 划分数组
int partition(int arr[], int low, int high) {
// 获取基准值
int pivot = medianOfThree(arr, low, high);
// 划分数组
while (low < high) {
// 找到一个大于基准值的元素
while (low < high && arr[low] <= pivot) {
low++;
}
arr[high] = arr[low];
// 找到一个小于基准值的元素
while (low < high && arr[high] >= pivot) {
high--;
}
arr[low] = arr[high];
}
// 将基准值放回正确的位置
arr[high] = pivot;
return high;
}
// 插入排序
void insertionSort(int arr[], int low, int high) {
for (int i = low + 1; i <= high; i++) {
int key = arr[i];
int j = i - 1;
// 将当前元素插入到已排序数组中的正确位置
while (j >= low && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
arr[j + 1] = key;
}
}
// 快速排序
void quickSort(int arr[], int low, int high) {
// 当子数组的大小大于阈值时,使用快速排序
if (high - low > THRESHOLD) {
// 划分数组,并获取基准值的位置
int pi = partition(arr, low, high);
// 对左右两部分进行递归排序
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
// 当子数组的大小小于等于阈值时,使用插入排序
else {
insertionSort(arr, low, high);
}
}
int main() {
int arr[] = {10, 7, 8, 9, 1, 5};
int n = sizeof(arr) / sizeof(arr[0]);
quickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
return 0;
}
快速排序非递归
快速排序非递归实现,需要借助栈,栈中存放的是需要排序的左右区间。
而且非递归可以彻底解决栈溢出的问题。
具体思想:
- 将数组左右下标入栈,
- 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限
- 将区间中的元素按照前后指针法排序(其余两种也可)得到基准值的位置
- 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈
- 重复上述步骤直到栈为空
#include <stdio.h>
#include <stdlib.h>
#define MAX_LEVELS 1000
void swap(int *x, int *y) {
int temp;
temp = *x;
*x = *y;
*y = temp;
}
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = (low - 1);
for (int j = low; j <= high - 1; j++) {
if (arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return (i + 1);
}
void quickSortIterative(int arr[], int l, int h) {
int stack[h - l + 1];
int top = -1;
stack[++top] = l;
stack[++top] = h;
while (top >= 0) {
h = stack[top--];
l = stack[top--];
int p = partition(arr, l, h);
if (p - 1 > l) {
stack[++top] = l;
stack[++top] = p - 1;
}
if (p + 1 < h) {
stack[++top] = p + 1;
stack[++top] = h;
}
}
}
int main() {
int arr[] = {4, 3, 5, 2, 1, 3, 2, 3};
int n = sizeof(arr) / sizeof(arr[0]);
quickSortIterative(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
return 0;
}
选择排序
选择排序的基本思想是:每一趟(如第i趟)在后面n-i+1(i=1,2…,n-1)个待排序元素中选取关键字最小的元素,作为有序子序列的第i个元素,知道第n-1趟完成,待排序元素只剩1个,就不必再选了。
简单选择排序
简单选择排序是在待排序数据中选出最大(最小)的元素将其放在最终的位置。这种算法的核心操作时每次从待排序的数据中找出最小的元素,并将它和剩余的部分的第一个元素进行交换。这样的过程将反腐进行,直至所有数据排序完毕。
操作步骤:
- 首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录进行交换。
- 接着通过n-2次比较,从剩余的n-1个记录中找出关键字第二小的记录,将它与第二记录进行交换。
- 重复上述操作,直到只剩 1 个元素,即进行n-1趟排序后,完成。
寻找最小值的过程
这个过程很容易操作,需要遍历整个数组,即假定第一个元素为最小值,然后依次将这个最小值和后面的元素进行比较,如果发现比它小的元素,则更新最小值记录,直到遍历完整个待排序序列。
性能:虽然选择排序在理论上的时间复杂度是 O(n^2),但在处理小数据量或近乎有序的数据时,它的性能是可以接受的。
代码实现
#include<stdio.h>
void swap(int *a, int *b){
int temp = *a;
*a = *b;
*b = temp;
}
void selectionSort(int arr[], int n)
{
int i , j, min_idx;
// 遍历所有数组元素,每次寻找剩余元素中最小元素
for (i = 0; i<n-1; i++) {
// 假设当前元素为最小元素
min_idx = i;
// 寻找剩余元素中最小元素
for (j = i+1; j<n; j++) {
if (arr[j] < arr[min_idx]) {
min_idx = j;
}
}
// 将最小元素与当前元素交换
if (min_idx != i){
swap(&arr[min_idx], &arr[i]);
}
}
}
void printArray(int arr[], int size) {
// 打印数组元素的函数
int i;
for (i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n");
}
int main() {
// 创建一个需要排序的数组
int arr[] = {64, 25, 12, 22, 11};
int n = sizeof(arr) / sizeof(arr[0]);
selectionSort(arr, n);
printf("Sorted array: \n");
printArray(arr, n);
return 0;
}
注意点:
选择排序是一种不稳定的排序算法,当存在相同的元素时,可能会改变它们的相对位置。此外,无论初始输入的序列如何,选择排序的比较次数总是固定的。这是因为每一趟排序,我们总会遍历所有未排序的元素。
堆排序
堆的定义
一个堆可以被视为一颗完全二叉树,树中的每一个结点都满足特定的性质。给定关键字序列L[1…n],当且仅当序列满足以下条件之一时,我们称其为堆:
- L(i) >= L(2i) 且 L(i) >= L(2i+1) ,其中 1 <= i <= ⌊n/2⌋。这种情况下,我们称这个堆为大根堆(大顶堆)。
- L(i) <= L(2i) 且 L(i) <= L(2i+1) ,其中 1 <= i <= ⌊n/2⌋。这种情况下,我们称这个堆为小根堆(小顶堆)。
在大根堆中,根节点存放着整个堆中最大的元素,且除了根节点以外的任意节点的值都小于或等于其父节点的值。与之相反,小根堆中的根节点存放着最小的元素,且任意节点的值都大于或等于其父节点的值。例如如图是一个大根堆。
若此时输出堆顶的最大值或最小值后,使得剩余的n-1个元素的序列重新又建立成一个新堆,则得到n个元素的次小值(次大值)…反复如此,直到堆中只剩下一个元素,从而得到一个有序序列。这个过程叫做堆排序。
实现堆排序需要解决两个问题:
- 堆建立:如何由一个无序序列建成一个堆?
- 堆调整:如何在输出堆顶元素后,调整剩余的元素为一个新堆?
堆的调整
堆调整:如何在输出堆顶元素后,调整剩余的元素为一个新堆?
- 首先,我们将最后一个结点(也就是最后一个叶子结点)移动到根节点的位置
- 然后,我们比较新的跟结点和其左右子节点,如果新的根节点不满足堆的性质,我们就将其和值较大(对于大根堆)或值较小(对于小根堆)的子节点进行交换。
- 重复这个过程,直到新的根节点满足堆的性质,我们就得了一个新堆。
#include <stdio.h>
// 交换两个元素的值
void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}
// 堆调整函数,i是需要调整的节点索引,n是堆的大小
void heapify(int arr[], int n, int i) {
int largest = i; // 初始化最大元素为当前节点
int left = 2 * i + 1; // 左子节点索引
int right = 2 * i + 2; // 右子节点索引
// 如果左子节点存在且大于当前最大元素,则更新最大元素为左子节点
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点存在且大于当前最大元素,则更新最大元素为右子节点
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大元素不是当前节点,将其与最大元素交换,并对被交换的子节点进行调整
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
// 输出堆顶元素并调整堆的函数
void popAndHeapify(int arr[], int *n) {
if (*n <= 0) {
printf("Heap is empty\n");
return;
}
// 输出堆顶元素
printf("%d ", arr[0]);
// 将堆的最后一个元素移动到堆顶
arr[0] = arr[*n - 1];
// 缩小堆的大小
(*n)--;
// 对新的堆进行调整
heapify(arr, *n, 0);
}
int main() {
// 初始化一个堆
int arr[] = {100, 50, 30, 20, 10};
int n = sizeof(arr)/sizeof(arr[0]);
// 打印原始堆
printf("Original heap: ");
for (int i = 0; i < n; ++i) {
printf("%d ", arr[i]);
}
printf("\n");
// 输出堆顶元素并调整堆
printf("Popped and heapified elements: ");
while (n > 0) {
popAndHeapify(arr, &n);
}
printf("\n");
return 0;
}
堆的建立
以下是建立小根堆的详细过程:
- 将给定的无序序列看作是一个完全二叉树的线性存储结构。这里我们用数组来存储,数组的索引就是完全二叉树的节点编号。
- 从最后一个非叶子节点开始(也就是数组的 n/2 - 1 个元素,n 是数组的大小),对每个非叶子节点执行以下操作:
- 比较当前节点和它的子节点(如果存在)。
- 如果当前节点比它的任何一个子节点大(对于小根堆),则将当前节点与它的最小的子节点交换。
- 交换后,可能会破坏下级子树的堆结构,所以需要继续对交换后的子节点进行调整,重复步骤2直到该子树成为堆。
- 一直重复这个过程,直到根节点。此时,整个树成为一个堆。
例:建造一个小根堆
有关键字为:49,38,65,97,76,13,27,49 的一组记录,将其按照关键字调整为一个小根堆。
- 先把这些元素按照序号自上而下、自左而右的顺序建立一棵初始完全二叉树。
- 从最后一个非叶子结点开始,开始往前依次调整:
- 调整从第 n / 2 个元素开始,将以该节点为根的二叉树调整为小根堆。
- 将 97 和 49 两个结点交换一下,对应的数组位置也要交换。
- 将以序号为n/2 - 1的结点作为根的二叉树调整为堆。
- 现在 3 号位置的结点调整完了,接下来该调整 2 号结点了。
- 现在该调整 2 号结点了,将以序号为n/2 - 2的结点为根的二叉树调整为堆;
- 发现 38 的左右孩子都比它大,不需要调整。
- 再将以序号为n/2 - 3的结点为根的二叉树调整为堆。
- 将根节点 49 与 13 交换位置,此时还没有调整结束,还需要将根结点 49 调整到叶子结点才行。
- 最后将 49 与 27 交换位置,至此,整个无序树就变成了一个小根堆了。
代码实现:
// 构建小根堆
void buildHeap(int arr[], int n) {
// 最后一个非叶子节点的索引
int startIdx = (n/2) - 1;
// 从最后一个非叶子节点开始,反向层序遍历,对每个节点进行调整
for (int i = startIdx; i >= 0; i--) {
heapify(arr, n, i);
}
}
堆排序完整代码
若对一个无序序列建堆,然后输出根;重复该过程就可以有一个无序序列输出位一个有序序列。
实际上,堆排序就是利用完全二叉树中父节点与孩子结点之间的内在关系来排序的。
#include <stdio.h>
// 交换两个整数
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
// 调整堆
void heapify(int arr[], int n, int i) {
int largest = i; // 初始化最大元素为根
int left = 2*i + 1; // 左孩子的索引
int right = 2*i + 2; // 右孩子的索引
// 如果左孩子大于根
if (left < n && arr[left] > arr[largest])
largest = left;
// 如果右孩子大于当前的最大元素
if (right < n && arr[right] > arr[largest])
largest = right;
// 如果最大元素不是根
if (largest != i) {
swap(&arr[i], &arr[largest]);
// 递归调整受影响的子树
heapify(arr, n, largest);
}
}
// 堆排序
void heapSort(int arr[], int n) {
// 构建大根堆(重新排序)
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 一个个交换元素
for (int i = n - 1; i >= 0; i--) {
// 移动当前根到末尾
swap(&arr[0], &arr[i]);
// 调整剩余部分的堆
heapify(arr, i, 0);
}
}
// 打印数组
void printArray(int arr[], int n) {
for (int i = 0; i < n; ++i)
printf("%d ", arr[i]);
printf("\\n");
}
// 主函数
int main() {
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr) / sizeof(arr[0]);
heapSort(arr, n);
printf("Sorted array is ");
printArray(arr, n);
}
分析
堆排序的时间复杂度分析:
- 初始堆化时间复杂度:O(n)
- 排序阶段(不包括初始堆化):一次重新堆化需要的时间不超过O(logn),而总共需要n-1次循环,所以排序阶段的时间复杂度为O(nlogn)。
- 总的时间复杂度:Tw(n) = O(n) + O(nlogn) = O(nlogn)。所以,堆排序的总时间复杂度为O(nlogn),这包括了建立初始堆和在调整建新堆时进行的修复筛选过程。
堆排序在最坏情况下的时间复杂度也是O(nlogn),这使得堆排序成为一种在最坏情况下性能依然可预测的排序算法。无论待排序的记录是正序还是逆序排列,堆排序的性能都不会受到太大影响。
堆排序的空间复杂度分析:
- 堆排序只需要一个供交换用的辅助空间,所以其空间复杂度为O(1)。这使得堆排序是一种原地排序算法,也就是说,这种算法不需要额外的存储空间。
堆排序算法的特性:
- 堆排序是一种不稳定的排序算法。也就是说,如果两个元素的键值相同,那么它们在排序后的相对顺序可能会改变。
- 堆排序只能用于顺序结构,不能用于链式结构。因为堆排序需要随机访问,而链式结构不支持高效的随机访问。
- 在记录数较少的情况下,由于初始建堆所需的比较次数较多,所以堆排序并不是一个好的选择。然而,当记录数量较大时,堆排序可以表现出很好的效率。
- 堆排序在最坏情况下的时间复杂度为O(nlogn),这比快速排序在最坏情况下的时间复杂度O(n^2)要好,所以当数据量较大时,堆排序通常是一个更好的选择。
归并排序
归并排序是一种使用分治策略的排序算法。其基本思想是将一个大问题分解为几个小问题,然后单独解决每个小问题,最后再将小问题的解决方案合并为大问题的解决方案。在归并排序中,它将一个待排序的序列分解为n个子序列,每个子序列长度为1,然后凉凉归并,得到n/2个长度为2或1的有序表;继续两两归并……直到合并为一个长度为n的有序表为止。这种方法成为2路归并排序。
下面是一个二路归并排序的举例:
代码实现
#include <stdio.h>
// 合并两个已排序的子序列 arr[l..m] 和 arr[m+1..r] 成为一个排序序列
void merge(int arr[], int l, int m, int r) {
int i, j, k;
int n1 = m - l + 1; // 左侧子序列的长度
int n2 = r - m; // 右侧子序列的长度
// 创建临时数组
int L[n1], R[n2];
// 将数据复制到临时数组 L[] 和 R[] 中
for (i = 0; i < n1; i++)
L[i] = arr[l + i];
for (j = 0; j < n2; j++)
R[j] = arr[m + 1 + j];
// 归并临时数组回 arr[l..r]
i = 0; // 初始化第一个子数组的索引
j = 0; // 初始化第二个子数组的索引
k = l; // 初始化归并后数组的索引
// 通过比较 L 和 R 的元素,将较小值复制回 arr
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制 L[] 中的剩余元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制 R[] 中的剩余元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
// l 是左索引,r 是右索引
void mergeSort(int arr[], int l, int r) {
if (l < r) {
// 寻找中间索引
int m = l + (r - l) / 2;
// 分别排序前半部分和后半部分
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
// 合并已排序的子序列
merge(arr, l, m, r);
}
}
// 打印数组
void printArray(int A[], int size) {
int i;
for (i = 0; i < size; i++)
printf("%d ", A[i]);
printf("\n");
}
// 测试函数
int main() {
int arr[] = {12, 11, 13, 5, 6, 7};
int arr_size = sizeof(arr) / sizeof(arr[0]);
printf("Given array is \n");
printArray(arr, arr_size);
mergeSort(arr, 0, arr_size - 1);
printf("\nSorted array is \n");
printArray(arr, arr_size);
return 0;
}
基数排序
基数排序是一种非比较型证书排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。具体做法是由地位开始,将所有待比数值统一为同样的位数长度,位数较短的树前面补零,然后从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,序列就变成一个有序序列了。
在基数排序中,我们需要一个稳定的排序算法。稳定排序算法是指保证相等的元素在排序后保持原有的相对位置。比如在这里,如果我们有两个元素 321 和 121,它们的第一位相同,那么在排序后,321 应该出现在 121 的前面。
基数排序的步骤可以描述为:
- 找出待排序的数组中最大和最小的元素
- 计算最大元素的位数,这将决定排序的次数
- 按照从低位到高位的顺序执行以下操作:
- 分配:根据每一位上的数字,把元素分配到对应的桶中
- 收集:把每个桶中的元素按照顺序收集起来
- 重复步骤3,直到排序完成
基数排序的效率取决于位数d,每位有k个桶。总的时间复杂度是O(nk),空间复杂度也是O(nk)。当k和d都较小的时候,基数排序的效率非常高。但是对于位数较多的数,或者范围较大的数,基数排序需要很多的空间和时间。
值得注意的是,基数排序一般只适用于整数排序,如果是浮点数或者字符串,需要特殊处理才能使用基数排序。
分析
基数排序的时间复杂度为O(k * (n + m)),其中k表示关键字的个数,n表示元素的个数,m表示关键字的取值范围(桶的个数/进行收集的次数)。
在分配的过程中,需要进行k次分配,每次分配需要遍历n个元素。因此,分配的时间复杂度为O(k * n)。
在收集的过程中,同样需要进行k次收集,每次收集需要遍历m个桶。因此,收集的时间复杂度为O(k * m)。
总的时间复杂度为O(k * (n + m))。
基数排序的空间复杂度为O(n + m),分配过程中需要m个桶,收集回来时需要一个长度为n的数组来存放元素。
基数排序的特点包括:
- 稳定排序:相等的元素在排序后保持了原有的相对位置。
- 可用于链式结构和顺序结构。
- 时间复杂度可以突破基于关键字比较的排序方法的下界O(nlog₂n),达到线性时间复杂度O(n)。
- 需要严格的条件:需要知道各级关键字的主次关系和各级关键字的取值范围。
代码实现
#include <stdio.h>
#define MAX_LEN 1000 // 定义最大长度
#define K 10 // 我们假设数字都是10进制的
// 一个用于获取数组最大值的函数
int getMax(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > max)
max = arr[i];
return max;
}
// 一个用于执行计数排序的函数,根据 significantPlace 来对数组进行排序
void countingSort(int arr[], int n, int significantPlace) {
int output[MAX_LEN]; // 输出的排序数组
int count[K]; // 计数数组
// 初始化计数数组
for (int i = 0; i < K; i++)
count[i] = 0;
// 计算每个桶中的元素数量
for (int i = 0; i < n; i++)
count[(arr[i]/significantPlace)%K]++;
// 修改 count[i],使得 count[i] 包含了在 output 中的实际位置
for (int i = 1; i < K; i++)
count[i] += count[i - 1];
// 构建 output 数组
for (int i = n - 1; i >= 0; i--) {
output[count[(arr[i]/significantPlace)%K] - 1] = arr[i];
count[(arr[i]/significantPlace)%K]--;
}
// 将 output 数组拷贝回 arr[],使得 arr[] 包含了根据当前位数排序后的数字
for (int i = 0; i < n; i++)
arr[i] = output[i];
}
// 基数排序函数
void radixsort(int arr[], int n) {
// 找到最大值,以确定要执行排序的次数
int max = getMax(arr, n);
// 对每一位使用计数排序
// 注意,这里的 exp 是 10 的幂
for (int exp = 1; max/exp > 0; exp *= 10)
countingSort(arr, n, exp);
}
// 打印数组
void print(int arr[], int n) {
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
}
// 测试基数排序
int main() {
int arr[MAX_LEN] = {170, 45, 75, 90, 802, 24, 2, 66};
int n = sizeof(arr)/sizeof(arr[0]);
radixsort(arr, n);
print(arr, n);
return 0;
}
排序算法的分析和应用
一、时间性能: 按照平均时间性能分为三类排序方法:
- 时间复杂度为O(nlogn)的方法有:快速排序、堆排序、归并排序,其中快速排序通常表现最好。
- 时间复杂度为O(n^2)的方法有:直接插入排序、冒泡排序、简单选择排序。其中直接插入排序在对近乎有序的记录进行排序时表现最好。
- 时间复杂度为O(n)的排序方法只有基数排序。
二、空间性能: 指排序过程中所需的辅助空间大小。
- 简单排序方法(直接插入排序、冒泡排序、简单选择排序)和堆排序的空间复杂度为O(1)。
- 快速排序的空间复杂度为O(logn),为栈所需的辅助空间。
- 归并排序所需的辅助空间最多,空间复杂度为O(n)。
- 链式基数排序需要额外设置队列首尾指针,空间复杂度为O(rd)。
三、排序方法的稳定性: 稳定的排序方法是指对于两个关键字相等的记录,它们在排序前后的相对位置保持不变。根据文章的说明,希尔排序、快速排序和堆排序是不稳定的排序方法。
四、时间复杂度的下限: 本文讨论的排序方法中,除了基数排序外,其他方法都是基于比较关键字进行排序的方法。根据判定树的理论,这类排序方法的最快时间复杂度下限为O(nlogn)。而基数排序不基于比较关键字,因此不受这个限制,可以达到线性时间复杂度O(n)。
解释补充
互质,也称为互素,如果两个或多个整数的最大公约数为1,那么它们就被称为互质或互素。例如,15和28就是互质的,因为除了1之外,它们没有其他公约数。