赞
踩
目录
(图像由AI生成)
在探索计算机科学的旅途中,排序算法无疑占据了一席重要的地位,它们不仅是理解数据结构与算法设计的基石,更是提高程序性能的关键工具。继C语言数据结构——常见排序算法(一)对插入排序、选择排序及其相关变种进行了深入的讲解之后,本篇文章旨在进一步扩展我们的视野,探讨如交换排序、归并排序和非比较排序等更多高效的排序策略。通过对这些高级排序技术的剖析,我们将揭示它们背后的设计思想、性能特点及适用场景,以加深对排序算法多样性和复杂性的理解。
交换排序是基于交换操作来实现排序的一类算法。它包括了几种具体的排序方法,其中最著名的是冒泡排序和快速排序。这类排序算法的核心思想是通过比较数组中的元素值,并根据比较结果交换它们的位置,以达到排序的目的。
交换排序的基本思想很简单:对于给定的元素集合,重复地比较相邻元素的大小,如果它们的顺序错误就交换它们的位置。这个过程重复进行,直到整个序列变为有序。在这个过程中,较小的元素会逐渐“浮”到序列的前端,而较大的元素会“沉”到序列的后端,这就像水泡从水底上升到水面一样,因此得名冒泡排序。而快速排序则是选定一个基准值,通过一趟排序将未排序的序列分割成两部分,其中一部分的所有数据都比另外一部分的所有数据要小,然后再递归地对这两部分数据分别进行快速排序,以此达到整个序列的有序。
交换排序的特点是实现简单,适用于少量数据的排序,在数据量较大时,其效率不是特别高,但快速排序是一个例外,它是交换排序的一种非常高效的实现,特别适用于大规模数据的排序。尽管快速排序在最坏情况下的时间复杂度为O(n^2),但其平均时间复杂度为O(n log n),并且它还有很好的实际性能。
冒泡排序是一种简单的排序算法,它重复地遍历待排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历数列的工作是重复进行的,直到没有再需要交换,即该数列已经排序完成。
实现原理
冒泡排序的基本思想是通过对待排序序列从前向后(从下标较小的元素开始),依次比较相邻元素的值,若发现逆序则交换,使值较大的元素逐渐从前移向后部,就像水底下的气泡一样逐渐上浮到水面。
- void BubbleSort(int* a, int n)
- {
- assert(a); // 确保数组指针有效
- int end = n - 1; // 设置未排序序列的边界,每次排序后边界减1
- while (end > 0)
- {
- int exchange = 0; // 用于标记本轮遍历是否发生了交换
- for (int i = 0; i < end; i++)
- {
- // 如果当前元素大于后一个元素,则交换它们
- if (a[i] > a[i + 1])
- {
- Swap(&a[i], &a[i + 1]);
- exchange = 1; // 发生了交换,将标记置为1
- }
- }
- // 如果某一轮没有发生交换,说明序列已经完全有序,可以提前结束排序
- if (exchange == 0)
- {
- break;
- }
- end--; // 缩小未排序序列的边界
- }
- }

流程示意图如下(来自1.1 冒泡排序 | 菜鸟教程 (runoob.com))
时间复杂度:
空间复杂度:
性能与特点:
快速排序是一种高效的排序算法,由C. A. R. Hoare在1960年提出。它的基本思想是分而治之,即从数组中挑选一个元素作为基准(pivot),重新排列数组,所有比基准小的元素摆放在基准前面,所有比基准大的元素摆在基准的后面。这一过程称为一次分区(partition)。然后,递归地(recursive)在小于基准值的子数组和大于基准值的子数组上重复这一过程。
快速排序的关键步骤在于分区操作,有多种实现方式,这里介绍三种常见的分区方法:左右指针法、前后指针法和挖坑法。
左右指针法是最基本的分区策略,通过两个指针从数组两端向中间扫描。
- int PartSort1(int* a, int begin, int end)
- {
- int key = a[end]; // 选取最后一个元素作为基准值
- while (begin < end)
- {
- while (begin < end && a[begin] <= key) // 从左向右找第一个大于基准值的元素
- {
- begin++;
- }
- while (begin < end && a[end] >= key) // 从右向左找第一个小于基准值的元素
- {
- end--;
- }
- Swap(&a[begin], &a[end]); // 交换这两个元素
- }
- Swap(&a[begin], &a[keyindex]); // 将基准值交换到正确的位置
- return begin; // 返回基准值的最终位置
- }

这是最直观的分区方式。选择数组最右侧的元素作为基准,然后使用两个指针begin和end分别从数组的左右两端开始,向中间移动。
前后指针法(或双指针法)优化了元素的交换次数,特别适用于存在大量相等元素的数组。
- int PartSort2(int* a, int begin, int end)
- {
- int key = a[end];
- int cur = begin;
- int prev = cur - 1;
- while (cur < end)
- {
- if (a[cur] < key && ++prev != cur)
- {
- Swap(&a[cur], &a[prev]);
- }
- cur++;
- }
- Swap(&a[++prev], &a[end]);
- return prev;
- }

前后指针法也称为双指针法。它选取最右侧元素作为基准,然后使用一个cur指针遍历数组,prev指针标记小于基准值的区域边界。
挖坑法通过“挖坑填坑”的方式进行元素的交换,这样可以减少数据的移动次数。
- int PartSort3(int* a, int begin, int end)
- {
- int key = a[end]; // 初始化坑的位置为基准值的位置
- while (begin < end)
- {
- while (begin < end && a[begin] <= key)
- {
- begin++;
- }
- a[end] = a[begin]; // 将大于基准值的元素移动到坑的位置,新的坑挖在begin的位置
- while (begin < end && a[end] >= key)
- {
- end--;
- }
- a[begin] = a[end]; // 将小于基准值的元素移动到坑的位置,新的坑挖在end的位置
- }
- a[begin] = key; // 基准值填入最后的坑
- return begin;
- }

挖坑法是一种直观的实现快速排序的方法。首先,选取最右侧元素作为基准,并认为这个位置是一个“坑”。
在快速排序中,选择合适的基准值(pivot)是优化性能的关键之一。三数取中法是一种改进的基准值选择策略,旨在减少最坏情况发生的概率,提高算法在各种数据分布情况下的性能表现。
三数取中法指的是从数组的首元素、尾元素和中间元素中选取中位数作为基准值。相比随机选择或固定选择基准值,三数取中法能更好地适应数组的分布特点,避免了在特定数据集上的极端表现。
- int GetMidIndex(int* a, int begin, int end)
- {
- int mid = begin + (end - begin) / 2; // 防止直接相加导致的溢出
-
- // 对begin、mid、end所指的三个元素进行排序
- int arr[] = {a[begin], a[mid], a[end]};
- InsertSort(arr, 3); // 使用插入排序,因为数组很小
-
- // 返回中位数对应的原始索引
- if (arr[1] == a[begin]) return begin;
- else if (arr[1] == a[mid]) return mid;
- else return end;
- }
这段代码首先计算中间位置的索引,然后构造一个包含首元素、中间元素和尾元素的小数组,并对这个小数组进行排序,最后返回中间元素在原数组中的索引。这样选取的基准值接近于中位数,能有效避免在已经或几乎有序的数组上进行快速排序时的性能退化。
优化策略说明
- // 三数取中法,用于优化基准值的选择
- int GetMidIndex(int* a, int begin, int end)
- {
- int mid = begin + (end - begin) / 2;
- int arr[] = {a[begin], a[mid], a[end]};
- InsertSort(arr, 3); // 对三个数进行排序
- if (arr[1] == a[begin]) return begin;
- else if (arr[1] == a[mid]) return mid;
- else return end;
- }
-
- // 左右指针法
- int PartSort1(int* a, int begin, int end)
- {
- int MidIndex = GetMidIndex(a, begin, end);
- Swap(&a[MidIndex], &a[end]); // 将基准值交换到数组末尾
- int key = a[end];
- while (begin < end)
- {
- while (begin < end && a[begin] <= key) begin++;
- while (begin < end && a[end] >= key) end--;
- Swap(&a[begin], &a[end]);
- }
- Swap(&a[begin], &a[end]);
- return begin; // 返回分区的界限
- }
-
- // 前后指针法
- int PartSort2(int* a, int begin, int end)
- {
- int MidIndex = GetMidIndex(a, begin, end);
- Swap(&a[MidIndex], &a[end]); // 将基准值交换到数组末尾
- int key = a[end];
- int cur = begin;
- int prev = cur - 1;
- while (cur < end)
- {
- if (a[cur] < key && ++prev != cur) Swap(&a[cur], &a[prev]);
- cur++;
- }
- Swap(&a[++prev], &a[end]);
- return prev;
- }
-
- // 挖坑法
- int PartSort3(int* a, int begin, int end)
- {
- int MidIndex = GetMidIndex(a, begin, end);
- Swap(&a[MidIndex], &a[end]); // 将基准值交换到数组末尾
- int key = a[end];
- while (begin < end)
- {
- while (begin < end && a[begin] <= key) begin++;
- a[end] = a[begin];
- while (begin < end && a[end] >= key) end--;
- a[begin] = a[end];
- }
- a[begin] = key;
- return begin;
- }
-
- // 快速排序主函数
- void QuickSort(int* a, int left, int right)
- {
- assert(a);
- if (left >= right) return;
-
- if(right - left + 1 < 10)
- {
- // 对小数组使用插入排序
- InsertSort(a + left, right - left + 1);
- }
- else
- {
- // 递归快速排序
- int div = PartSort3(a, left, right); // 使用挖坑法分区
- QuickSort(a, left, div - 1);
- QuickSort(a, div + 1, right);
- }
- }

流程示意图如下(来自1.6 快速排序 | 菜鸟教程 (runoob.com))
1.3.3复杂度分析
时间复杂度
最好情况:当每次分区都能将数组分成长度相等的两部分时,排序的深度为log n,每一层的总工作量为n,因此最好情况下的时间复杂度为O(n log n)。
最坏情况:当每次分区操作都将数组分成长度为1和n-1的两部分时,排序的深度为n,这导致时间复杂度退化为O(n^2)。这种情况通常发生在数组已经是有序或几乎有序的情况下。
平均情况:对于随机分布的数据,快速排序的平均时间复杂度为O(n log n)。通过合理的基准值选择,可以确保算法在大多数情况下都接近于最好情况的性能。
三数取中法对时间复杂度的影响
三数取中法通过从数组的首部、中部、尾部取出三个元素,并选择它们的中位数作为基准值,这种方法旨在减少最坏情况发生的概率,从而改善快速排序的平均性能。
减少最坏情况发生的概率:三数取中法使得基准值的选择更加靠近数组的中位数,减少了在极端数据分布情况下的性能退化,避免了算法时间复杂度频繁达到O(n^2)的情况。
提升平均性能:虽然三数取中法增加了少量的前期计算(需要对三个元素进行比较和可能的排序),但它通过更合理的基准值选择,使得分区过程更加均衡,减少了递归深度,提高了排序的平均效率。
对最好和最坏情况的影响:三数取中法不改变快速排序在最好情况下的时间复杂度(O(n log n)),但它有效地减少了最坏情况的发生频率,使得算法的平均时间复杂度接近最好情况。
归并排序是一种高效的排序算法,采用分治法的思想来实现。其基本原理是将一个数组分成两半,分别对它们进行排序,然后将两个有序数组合并成一个整体有序数组。
归并排序的核心思想可以概括为三个步骤:
归并过程需要额外的空间来暂存数据,这是归并排序空间复杂度高于O(1)的主要原因。
- void _MergeSort(int* a, int left, int right, int* tmp)
- {
- if (left < right)
- {
- int mid = left + (right - left) / 2; // 避免溢出
- _MergeSort(a, left, mid, tmp); // 排序左半部分
- _MergeSort(a, mid + 1, right, tmp); // 排序右半部分
- int i = left;
- int j = mid + 1;
- int k = left;
-
- // 合并两个有序区间
- while (i <= mid && j <= right) {
- if (a[i] <= a[j]) {
- tmp[k++] = a[i++];
- }
- else {
- tmp[k++] = a[j++];
- }
- }
-
- // 处理剩余的元素
- while (i <= mid) {
- tmp[k++] = a[i++];
- }
- while (j <= right) {
- tmp[k++] = a[j++];
- }
-
- // 将合并后的数组复制回原数组
- for (i = left; i <= right; i++) {
- a[i] = tmp[i];
- }
- }
- }
-
- void MergeSort(int* a, int n) {
- assert(a);
- int* tmp = (int*)malloc(sizeof(int) * n);
- if (tmp == NULL) {
- printf("malloc fail\n");
- exit(-1);
- }
- _MergeSort(a, 0, n - 1, tmp);
- free(tmp);
- }

_MergeSort
函数详解
分解:计算中间位置mid = left + (right - left) / 2
,这样做可以避免大数相加可能导致的整数溢出问题。然后将数组从中间分割成两个部分,分别对这两部分递归地执行归并排序。
递归排序:递归调用_MergeSort(a, left, mid, tmp)
对左半数组进行排序,_MergeSort(a, mid + 1, right, tmp)
对右半数组进行排序。递归的基准情况是子数组只包含一个元素(此时子数组自然是有序的)。
合并:
i
和j
,分别指向左半部分和右半部分的起始位置。tmp
中,并移动相应的指针。tmp
后,如果还有剩余的元素,直接将剩余元素复制到tmp
中,这确保了没有遗漏任何元素。复制回原数组:将临时数组tmp
中的有序元素复制回原数组a[left...right]
。这一步是必要的,因为归并过程在tmp
中进行,原数组a
的相应部分在这个过程中还是无序的。
MergeSort
函数详解
MergeSort
函数是归并排序的对外接口,主要负责初始化所需的额外空间(即临时数组tmp
)并调用_MergeSort
函数。int* tmp = (int*)malloc(sizeof(int) * n);
通过动态内存分配,为临时数组tmp
分配足够的空间。这一空间将用于在归并过程中暂存元素。_MergeSort(a, 0, n - 1, tmp);
从数组的最左侧到最右侧对整个数组进行排序。free(tmp);
在排序完成后,释放tmp
所占用的内存空间。这一步骤是必要的,以避免内存泄露。流程示意图如下(来自1.5 归并排序 | 菜鸟教程 (runoob.com))
时间复杂度
最好、最坏和平均情况:归并排序在最好、最坏和平均情况下的时间复杂度都是O(n log n)。这是因为归并排序总是均匀地将数组分成两半,并且对这两半进行递归排序,然后再将它们合并。无论数组的初始状态如何,分解和合并操作所需的时间总是相同的。
空间复杂度
总体空间复杂度:归并排序需要一个与原数组相同大小的临时数组来存放在合并过程中排序后的数据,因此其空间复杂度为O(n)。
归并排序的空间复杂度主要来源于两个方面:
非比较排序是一种不通过比较操作来决定元素间顺序的排序算法。与基于比较的排序算法(如快速排序、归并排序等)不同,非比较排序通常能在更快的时间内完成排序任务,尤其适用于特定类型的数据集。其中,计数排序是一种非常高效的非比较排序方法。
计数排序是一种稳定的排序算法,适用于一定范围内的整数排序。它的基本思想是对每一个输入的元素a[i]
,确定小于a[i]
的元素个数,然后直接将a[i]
放到输出数组的相应位置上。
- void CountSort(int* a, int n)
- {
- assert(a);
- int min = a[0], max = a[0];
- // 找到数组的最大值和最小值
- for (int i = 0; i < n; i++)
- {
- if (a[i] < min) min = a[i];
- if (a[i] > max) max = a[i];
- }
-
- int range = max - min + 1; // 计算范围
- int* count = (int*)calloc(range, sizeof(int)); // 创建计数数组
- if (count == NULL)
- {
- printf("calloc fail\n");
- exit(-1);
- }
-
- // 对每个元素计数
- for (int i = 0; i < n; i++)
- {
- count[a[i] - min]++;
- }
-
- // 根据计数数组重建原数组
- for (int i = 0, index = 0; i < range; i++)
- {
- while (count[i]--)
- {
- a[index++] = i + min;
- }
- }
-
- free(count); // 释放计数数组
- }

分析
时间复杂度:计数排序的时间复杂度为O(n+k),其中n是列表长度,k是整数范围。这是因为算法首先遍历一次原数组来计数,然后再遍历一次计数数组来重建原数组。
空间复杂度:由于需要额外创建一个计数数组,其空间大小依赖于输入数据的范围(max - min + 1
),因此空间复杂度为O(k)。
适用场景:计数排序最适合范围不是很大的情况。如果max
和min
之间的范围远大于元素个数n,计数排序可能就不那么高效了,因为会产生大量未使用的空间。
流程示意图如下(来自1.8 计数排序 | 菜鸟教程 (runoob.com))
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表示字符串(如姓名或日期)和特定格式的浮点数,所以基数排序不仅可以用于整数排序,也可以用于其他类型数据的排序。
实现原理
基数排序通过以下步骤进行:
digit
。这是因为最大数的位数决定了排序进行的轮数。- void RadixSort(int* a, int n)
- {
- assert(a);
- int max = a[0];
- // 找出最大数
- for (int i = 0; i < n; i++)
- {
- if (a[i] > max)
- {
- max = a[i];
- }
- }
- // 计算最大数的位数
- int digit = 0;
- while (max)
- {
- max /= 10;
- digit++;
- }
-
- int* count = (int*)malloc(sizeof(int) * 10); // 计数数组
- int* bucket = (int*)malloc(sizeof(int) * n); // 桶数组
- int radix = 1;
- for (int i = 0; i < digit; i++) // 按照位数进行的轮数
- {
- memset(count, 0, sizeof(int) * 10); // 初始化计数数组
- // 计数
- for (int j = 0; j < n; j++)
- {
- count[(a[j] / radix) % 10]++;
- }
- // 累加
- for (int j = 1; j < 10; j++)
- {
- count[j] += count[j - 1];
- }
- // 分配
- for (int j = n - 1; j >= 0; j--)
- {
- int k = (a[j] / radix) % 10;
- bucket[count[k] - 1] = a[j];
- count[k]--;
- }
- // 收集
- memcpy(a, bucket, sizeof(int) * n);
- radix *= 10;
- }
- free(count);
- free(bucket);
- }

复杂度分析
性能和特点
流程示意图如下(来自1.10 基数排序 | 菜鸟教程 (runoob.com))
排序算法的稳定性是指在排序过程中,具有相等键值的元素之间的相对顺序在排序前后是否保持不变。如果排序算法能够保持相等元素之间的相对次序不变,那么这种排序算法被称为稳定的;反之,如果排序过程可能改变相等元素之间的相对次序,则该算法被认为是不稳定的。
稳定性在多关键字排序中尤为重要。例如,在对一组记录按照多个字段进行排序时,稳定的排序算法可以保证,当按照第二个字段排序时,第一个字段排序的结果仍然得以保留。这意味着稳定的排序算法可以通过顺序对每个关键字应用排序操作来实现多关键字排序,而不会打乱之前关键字的排序结果。
稳定排序算法示例
不稳定排序算法示例
(来自1.0 十大经典排序算法 | 菜鸟教程 (runoob.com))
通过对常见排序算法的深入探讨,我们得以领略它们各自的独特魅力和应用场景。无论是插入排序的简洁、选择排序的直观、还是归并排序和快速排序的高效,每种算法都在特定条件下展现出了其价值。理解这些排序算法的原理和性质,不仅能够帮助我们更好地处理数据排序问题,还能深化我们对算法设计和优化的认识。在实际应用中,选择合适的排序算法可以显著提升程序的性能。希望本系列文章能为你的学习之旅提供有益的参考和启发。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。