📖 前言:排序与我们的日常生活息息相关。例如,教师按身高来安排学生的座位,试卷和答题卡按从小号到大号的顺序来整理,各类比赛按成绩的高低来排名,查询火车票时会按照出发的先后来显示,到网上购物会参考销量高低来排序购买等。排序是数据处理和分析中最常用的运算之一,它往往可以提高数据处理的效率;排序也是最基本的算法之一,其他很多算法都是以排序算法为基础,所以研究和掌握排序算法是非常重要的。在信息时代,面对庞大的信息量,想要靠人工进行排序,会耗费大量时间和精力,甚至无法完成。所以,依靠计算机快速、准确地对数据进行排序,是很有必要的。
🎓 作者:HinsCoder
📦 作者的GitHub:代码仓库
📌 往期文章&专栏推荐:
目录📌
🕒 1. 排序的概念及其种类
🕘 1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:待排序的记录序列中相同元素在排序后相对位置不发生变化,则称该排序方法具有稳定性。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求在内外存之间多次交换数据的排序。
🕘 1.2 常见的排序算法
排序OJ(可使用各种排序跑这个OJ) 🔎 排序OJ
🕒 2. 直接插入排序
💡 算法思想:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。就像玩扑克牌时,对其进行从小到大排序。
代码实现如下:
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++) // 因为x元素位置是i的下一个位置,为防止x越界,需要使 i < n - 1
{
int end = i; // 当前要插入位置的索引
int tmp = a[end + 1]; // 需要插入的元素值
// 将 tmp 插入到合适的位置
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end]; // 如果当前元素大于 tmp,则后移
--end;
}
a[end + 1] = tmp; // 插入 tmp 到正确位置
}
}
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高。
- 最坏时间复杂度(逆序):O(N2)
最好时间复杂度(顺序):O(N) - 空间复杂度:O(1)
- 稳定性:稳定
🕒 3. 希尔排序(缩小增量排序)
💡 算法思想:先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。然后将gap逐渐减小重复上述分组和排序的工作。当到达gap=1时,所有元素在统一组内排好序。
算法拆解:预排序+直接插入排序
预排序的本质也是插入排序,是对每一小组中的数据进行插入排序。目的是让整个数组接近有序,使得一开始无序的数组趋向于让大的数往后面走,小的数往前面走;在经过预排序使得整个数组接近有序后,就可以更加快速(中间也减少了数据的挪动次数)的将数组进行排序。
代码实现如下:
void ShellSort(int* a, int n)
{
int gap = n; // 初始增量设置为数组长度n
// 当增量大于1时继续排序
while (gap > 1)
{
// 使用动态增量序列 gap = gap / 3 + 1,这种增量选择时间复杂度约为O(N^1.3)
gap = gap / 3 + 1;
// 对每个子序列进行插入排序,步长为 gap
for (int i = 0; i < n - gap; ++i)
{
int end = i; // 当前要插入位置的索引
int tmp = a[end + gap]; // 要插入的元素
// 使用插入排序的方式将 tmp 插入到合适位置
while (end >= 0 && a[end] > tmp)
{
a[end + gap] = a[end]; // 如果前一个元素比 tmp 大,则向后移动 gap 个位置
end = end - gap; // 继续向前比较
}
a[end + gap] = tmp; // 将 tmp 插入到正确的位置
}
}
}
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此希尔排序的时间复杂度都不固定。
- 时间复杂度O(N1.5)
- 空间复杂度O(1)
- 稳定性:不稳定
🕒 4. 直接选择排序
💡 算法思想:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始(末尾)位置,然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,重复这样的步骤直到全部待排序的数据元素排完。
代码实现如下:这里可以进行一个优化,最小值和最大值同时选,然后将最小值与起始位置交换,将最大值与末尾位置交换。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void SelectSort(int* a, int n)
{
int begin = 0; // 起始位置
int end = n - 1; // 结束位置
// 循环直到整个数组都被排序
while (begin < end)
{
int mini = begin; // 保存最小元素下标
int maxi = begin; // 保存最大元素下标
// 在当前未排序部分查找最小和最大元素的下标
for (int i = begin + 1; i <= end; ++i)
{
if (a[i] < a[mini])
{
mini = i; // 更新最小元素下标
}
if (a[i] > a[maxi])
{
maxi = i; // 更新最大元素下标
}
}
// 将找到的最小元素交换到起始位置
Swap(&a[begin], &a[mini]);
// 如果最大元素的位置在起始位置,更新最大元素下标为 mini
if (maxi == begin)
{
maxi = mini;
}
// 将找到的最大元素交换到末尾位置
Swap(&a[end], &a[maxi]);
// 缩小排序范围
++begin;
--end;
}
}
选择排序的特性总结:
- 选择排序步骤非常好理解,但是效率不是很好(不论数组是否有序都会执行原步骤),实际中很少使用。
- 时间复杂度:O(N2)
- 空间复杂度:O(1)
- 稳定性:不稳定
🕒 5. 堆排序
💡 算法思想:堆排序即利用堆的思想来进行排序,总共分为两个步骤:1. 建堆(升序:建大堆;降序:建小堆) 2. 利用堆删除思想来进行排序:建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
这里以升序为例:
- 首先应该建一个大堆,不能直接使用堆来实现。可以将需要排序的数组看作是一个堆,但需要将数组结构变成堆结构。
- 我们可以从堆从下往上的第二行最右边开始依次向下调整直到调整到堆顶,这样就可以将数组调整成一个堆,且如果建立的是大堆,堆顶元素为最大值。
- 然后按照堆删的思想将堆顶和堆底的数据交换,但不同的是这里不删除最后一个元素。
- 这样最大元素就在最后一个位置,然后从堆顶向下调整到倒数第二个元素,这样次大的元素就在堆顶,重复上述步骤直到只剩堆顶时停止。
// AdjustDown函数:在数组a中,从节点root开始向下调整,使得以root为根的子树满足大顶堆的性质。
void AdjustDown(int* a, int n, int root)
{
assert(a);
int parent = root; // 当前子树的根节点
int child = parent * 2 + 1; // 左孩子节点
// 循环直到没有孩子节点
while (child < n)
{
// 如果右孩子存在且比左孩子大,则选择右孩子作为比较对象
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
// 如果孩子节点比父节点大,则交换父节点和孩子节点的值,并更新父节点和孩子节点继续向下比较
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break; // 如果孩子节点不再比父节点大,则退出循环
}
}
}
void HeapSort(int* a, int n)
{
assert(a);
// 建立大顶堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i); // 对每个非叶子节点进行向下调整,建立大顶堆
}
// 交换堆顶元素和末尾元素,并重新调整堆
for (int i = n - 1; i > 0; i--)
{
Swap(&a[i], &a[0]); // 将当前堆顶(最大值)与数组末尾元素交换
AdjustDown(a, i, 0); // 调整剩余堆为大顶堆,范围缩小为0到i-1
}
}
堆排序的特性总结:
- 堆排序使用堆来选数,效率较高,适用于需要频繁插入和删除的场景。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
🕒 6. 冒泡排序
💡 算法思想:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。一共进行n-1趟这样的交换将可以把所有的元素排好。
代码实现如下:
void BubbleSort(int* a, int n)
{
// 外层循环控制排序轮数,每轮将一个最大的元素放到最后
for (int j = 0; j < n; ++j)
{
int exchange = 0; // 交换标志,用于判断本轮是否有元素交换
// 内层循环遍历当前未排序部分,将相邻的元素逐个比较并交换
for (int i = 1; i < n - j; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]); // 如果前一个元素大于后一个元素,则交换它们
exchange = 1; // 设置交换标志为1,表示本轮有元素交换
}
}
// 如果本轮没有进行任何交换,说明数组已经有序,可以提前结束排序
if (exchange == 0)
{
break;
}
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序,在数据有序时可以提前结束排序。
- 时间复杂度:O(N2)
- 空间复杂度:O(1)
- 稳定性:稳定
🕒 7. 快速排序
💡 算法思想:快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
🕘 7.1 Hoare版本(左右指针法)
具体思路是:
- 选定一个基准值(pivot / key),最好选定最左边或者最右边;
- 确定两个指针
left
和right
分别从左边和右边向中间遍历数组; - 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。这样能保证相遇位置比基准值大;
- 然后右边的指针再走,遇到小于基准值的数就停下来;
- 交换left和right指针对应位置的值;
- 重复以上步骤,直到
left = right
,最后将基准值与left(right)位置的值交换,这便完成一趟排序。
这样基准值左边的所有数都比它小,而它右边的数都比它大,从而它所在的位置就是排序后的正确位置。之后再递归排以基准值为界限的左右两个区间中的数,当区间中没有元素时,排序完成。
// 快速排序hoare版本
int PartSort(int* a, int left, int right)
{
int key = right;// 选定基准值
while (left < right)
{
// 选右边为基准值,则左指针先走,从左往右找到第一个比基准值大的元素
while (left < right && a[left] <= a[key])
{
left++;
}
// 右指针再走,从右往左找到第一个比基准值小的元素
while (left < right && a[right] >= a[key])
{
right--;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
return left;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 7.2 挖坑法
💡 算法思想:挖坑法与上面Hoare版本思想基本一致,都是利用递归(即分治的算法思想)去实现排序。但挖坑法的不同之处在于,当left
和right
这两个下标在遍历过程中找到满足条件的元素时(即元素值大于或小于基准值key),它们会立即将这些元素放置到合适的位置,这个过程可以描述为边查找边替换。相比之下,Hoare版本则是先记录下符合条件的元素下标,在遍历完成后再进行位置的交换。
具体思路是:
- 先将选定的基准值(最左边)直接取出,然后留下一个坑;
- 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位;
- 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位;
- 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。
注意,在操作过程中,我们并不是真正地创建一个新的坑位,而是在逻辑上将其视为坑位。实际上,坑位仍然占据着原来的元素,我们只是在操作时暂时将原元素存储起来,然后用新元素直接覆盖它。在left
和right
最终相遇的位置,就会形成一个坑位,恰好可以放置key。
// 快速排序挖坑法
int PartSort(int* a, int left, int right)
{
int key = a[left]; // 取出基准值
int hole = left; // 保存坑的位置
while (left < right)
{
// 从右向左找第一个小于基准值的元素
while (left < right && a[right] >= key)
{
right--;
}
// 将找到的小于基准值的元素填入左边的坑中
a[hole] = a[right];
hole = right;
// 从左向右找第一个大于基准值的元素
while (left < right && a[left] <= key)
{
left++;
}
// 将找到的大于基准值的元素填入右边的坑中
a[hole] = a[left];
hole = left;
}
// 将基准值填入最后一个坑中,此时左右两部分已经分好
a[hole] = key;
return hole;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 7.3 前后指针法
💡 算法思想:有两个指针:cur
、prev
(cur
的位置在prev
的下一个位置),期间通过cur
去遍历数组,将每个元素与key
值比较大小,若发现有元素小于key
,就让prev++
(即走到下一个位置),再与cur
此时对应的元素进行交换,通过递归的方式重复上述的步骤,即可实现完全排序。
简而言之:
prev
和key
初始位置之间的区间,是在维护一个所有元素都小于key
的区域,目的是使key
左侧的序列中的所有值都小于key
。prev++
到cur
之间的区间,则是在维护一个所有元素都大于key
值的区域,以确保key
右侧的序列中的所有值都大于key
。cur
不断寻找小于key
的元素并与prev++
后的位置交换,是为了将序列后方小于key
的元素移动到key
前方,保证key
左侧子序列的值都小于key
,右侧子序列的值都大于key
。
具体思路是:
- 选定基准值,定义prev和cur指针(
cur = prev + 1
); - cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置;
- 将prev对应值与cur对应值交换;
- 重复上面的步骤,直到cur走出数组范围;
- 最后将基准值与prev对应位置交换;
- 递归排序以基准值为界限的左右区间。
// 快速排序前后指针法
int PartSort(int* a, int left, int right)
{
// 选择基准值为数组的第一个元素(left)
int keyi = left; // 基准值索引
int prev = left; // 前指针初始化为基准值的位置
int cur = left + 1; // 后指针初始化为基准值后一个位置
// 遍历数组,将小于基准值的元素交换到前面
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]); // 如果当前元素小于基准值,交换到前面
}
cur++;
}
Swap(&a[prev], &a[keyi]); // 将基准值交换到最终位置
// 选择基准值为数组的最后一个元素(right)
/*
int keyi = right; // 基准值索引
int prev = left - 1; // 前指针初始化为左边界的前一个位置
int cur = prev + 1; // 后指针初始化为左边界
// 遍历数组,将小于基准值的元素交换到前面
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]); // 如果当前元素小于基准值,交换到前面
}
cur++;
}
Swap(&a[keyi], &a[++prev]); // 将基准值交换到最终位置
*/
return prev;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 7.4 快速排序的优化
快速排序是一种高效的排序算法,但在某些情况下存在缺陷。例如,若基准值选为最小值,则会引发不必要的递归。此外,对于大量有序或近似有序的数据排序时,快速排序效率较为低下,甚至可能导致程序崩溃,因为过多的递归调用可能会造成栈溢出。为了提高效率并避免这些问题,可以采用以下两种优化策略:
- 三数取中法选基准值
- 递归到小的子区间时,可以考虑使用插入排序
🕤 7.4.1 三数取中
💡 算法思想:即在选择基准值时,不是简单地选择数组的第一个元素或者最后一个元素,而是从当前子数组的第一个元素、中间元素和最后一个元素中选择中间大小的元素作为基准值。这种方法的优势在于:
- 降低最坏情况的发生概率: 如果每次选取的基准值都是当前子数组中的中间值,那么快速排序在大多数情况下会有较好的性能表现,因为这样可以避免极端情况下分割不均匀的问题。
- 减少递归深度: 选取较为中间的元素作为基准值,可以更均匀地划分数组,避免出现极端的递归深度,从而减少排序的时间复杂度。
// 三数取中法
int MidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2; // 计算中间元素的索引
// 若要防止整数溢出可以使用以下方式计算mid
// int mid = left + (right - left) / 2;
// 比较三个关键位置的元素,选出中间值的索引作为基准值
if (a[left] < a[right])
{
if (a[mid] < a[left])
{
return left;
}
else if (a[mid] > a[right])
{
return right;
}
else
{
return mid;
}
}
else
{
if (a[mid] > a[left])
{
return left;
}
else if (a[mid] < a[right])
{
return right;
}
else
{
return mid;
}
}
}
// 快速排序前后指针法优化
int PartSort(int* a, int left, int right)
{
int mid = MidIndex(a, left, right);
// 将基准位置调整至最左边
Swap(&a[mid], &a[left]);
// 选择基准值为left
int keyi = left;
int prev = left;
int cur = left + 1;
......
}
🕤 7.4.2 小区间优化排序
💡 算法思想:优化排序的时间复杂度,减少不必要的调用递归次数
具体来说,就是在完成一次快速排序递归后,将数据分为左右两个子序列。如果这两个子序列的数据量都较小,就可以直接采用更适合小数据集的排序方法,如插入排序,而不必再进行快速排序的递归操作。这样不仅提高了算法的效率,也优化了递归层次,特别是在数据量较小的情况下更为明显。
// 快速排序小区间优化
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 keyi = PartSort(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
🕘 7.5 快速排序的非递归实现
💡 算法思想:快速排序的非递归实现依赖于栈来存储待排序的子区间。这种方法不仅避免了递归可能导致的栈溢出问题,而且其核心思想与递归实现是相似的。
具体思路是:
- 将数组左右下标入栈;
- 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限;
- 将区间中的元素按照上述任意一种方法得到基准值的位置;
- 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈;
- 重复上述步骤直到栈为空。
void QuickSortNonR(int* a, int left, int right)
{
// 创建栈
Stack st;
StackInit(&st);
// 原始数组区间入栈
StackPush(&st, right); // 先将右边界入栈
StackPush(&st, left); // 再将左边界入栈
// 将栈中区间排序
while (!StackEmpty(&st))
{
// 弹出栈顶的左右边界
left = StackTop(&st);
StackPop(&st);
right = StackTop(&st);
StackPop(&st);
// 对当前区间进行划分,得到基准值的位置
int mid = PartSort(a, left, right);
// 将基准值两侧的子区间入栈,准备下一轮排序
if (right > mid + 1)
{
StackPush(&st, right); // 右边界入栈
StackPush(&st, mid + 1); // 右侧子区间的左边界入栈
}
if (left < mid - 1)
{
StackPush(&st, mid - 1); // 左侧子区间的右边界入栈
StackPush(&st, left); // 左边界入栈
}
}
// 销毁栈
StackDestroy(&st);
}
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才叫快速排序。
- 时间复杂度:最好:O(N*logN) ; 最坏:O(N2)
- 空间复杂度:O(logN) ~ O(N)
- 稳定性:不稳定
🕒 8. 归并排序
💡 算法思想:归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序,若将两个有序表合并成一个有序表,称为二路归并。
简单来说,快速排序类似于二叉树的前序遍历:首先选择一个基准值(key),然后不断划分区间,对区间内的元素进行排序,直到整个序列有序。这个过程中,一棵二叉树也随之构建完成。
相对地,归并排序则类似于二叉树的后序遍历:它会持续划分区间,直到区间内元素有序,然后利用额外空间对元素进行排序,并将它们合并回原区间,直至整个序列有序。这个过程中,划分区间相当于达到树的最底层,而归并排序则相当于从树的底层开始向上遍历。
🕘 8.1 递归实现
💡 算法思想:首先,编译器并不知道数组中是否存在有序的序列。因此,可以将数组进行划分,一分为二,二分为四…直至每个序列只剩下一个数字。毕竟,单个数字可以被视为已经排序好的。最终,将这些被划分的序列合并起来。
具体思路是:
- 确定递归的结束条件,求出中间数mid;
- 进行分解,根据mid来确定递归的区间大小;
- 递归分解完左边,然后递归分解右边;
- 左右分解完成后,进行合并;
- 申请新数组进行合并,比较两个数组段,记得查漏补缺;
- 合并的时候要对齐下标,每个tmp的下标要找到array中对应的下标。
void _MergeSort(int* a, int left, int right, int* tmp)
{
// 区间中没有元素时不再合并
if (left >= right)
{
return;
}
// 划分数组,每次一分为二
int mid = (left + right) / 2;
_MergeSort(a, left, mid, tmp); // 划分左区间
_MergeSort(a, mid + 1, right, tmp); // 划分右区间
// 合并有序序列
int begin1 = left, end1 = mid; // 有序序列1
int begin2 = mid + 1, end2 = right; // 有序序列2
int i = left; // 辅助数组的起始位置
// 注意结束条件为一个序列为空时就停止
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 n)
{
assert(a);
// 为归并过程分配一个临时数组,用于存储中间结果
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(-1);
}
// 递归调用归并排序
_MergeSort(a, 0, n - 1, tmp);
// 释放临时数组
free(tmp);
tmp = NULL;
}
🕘 8.2 非递归实现
💡 算法思想:非递归实现与递归实现的思想相似。区别在于,非递归是从单个元素的组开始,逐步扩大为2个元素、4个元素的组(二倍数扩大组数),即序列划分过程和递归是相反的。如此继续,直至完成所有元素的归并。
void MergeSortNonR(int* a, int n)
{
assert(a);
// 为归并过程分配一个临时数组,用于存储中间结果
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(-1);
}
// 初始化每组的元素个数为1
int gap = 1;
// 当gap小于数组长度时继续归并
while (gap < n)
{
// 记录tmp数组中的元素下标
int index = 0;
// 遍历数组,将每两个gap长度的子数组进行归并
for (int i = 0; i < n; i += 2 * gap)
{
// 归并 取小的尾插
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
// 当原数组中元素个数不是2^n时,最后两组会出现元素不匹配的情况
// 情况1:第一组越界或第二组全部越界
if (end1 >= n || begin2 >= n)
{
break;
}
// 情况2:第二组部分越界
if (end2 >= n)
{
end2 = n - 1; // 修正一下end2,继续归并
}
// 归并两个子数组到tmp数组中
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
// 如果第一个子数组还有剩余元素,直接复制到tmp中
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
// 如果第二个子数组还有剩余元素,直接复制到tmp中
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
// 将辅助数组中排好序的部分拷贝回原数组中
memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));
}
// 每次循环后将每组元素的个数翻倍
gap *= 2;
}
// 释放临时数组的内存
free(tmp);
tmp = NULL;
}
归并排序的特性总结:
- 归并的缺点在于需要 O(N) 的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(NlogN)
- 空间复杂度:O(N)
- 稳定性:稳定
🕒 9. 计数排序
💡 算法思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用,操作步骤:
- 统计相同元素出现次数
- 根据统计的结果将序列回收到原来的序列中
void CountSort(int* a, int n)
{
assert(a);
// 创建计数数组,数组大小为原数组中最大值-最小值+1
int max = a[0], min = a[0];
int i = 0;
for (i = 0; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int range = max - min + 1;
// 统计次数
int* count = (int*)malloc(sizeof(int) * range);
assert(count);
// 初始化计数数组为0
memset(count, 0, sizeof(int) * range);
// 统计每个值出现的次数
for (i = 0; i < n; i++)
{
count[a[i] - min]++;
}
// 根据计数数组进行排序
int j = 0;
for (i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min; // 将排序后的值放回原数组
}
}
free(count);
count = NULL;
}
注意:计数排序在排负数时,可将负数的类型转化成 unsigned int
。
只适合整数,不适合浮点数、字符串等。 数组中元素有正有负的情况时不适用计数排序。
计数排序的特性总结:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(N+range)
- 空间复杂度:O(range)
- 稳定性:稳定
🕒 10. 排序算法复杂度及稳定性分析
注:在特殊情况下,排序的时间复杂度会发生变化,不能作为判断该排序是否稳定的依据。
排序方法 | 时间复杂度(平均情况) | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
希尔排序 | O(n(1~2)) | O(nlog2n) | O(n2) | O(1) | 不稳定 |
直接选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 |
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | O(log2n) | 不稳定 |
归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 |
计数排序 | O(n+r) | O(n+r) | O(n+r) | O® | 稳定 |
注:
- 希尔排序的时间复杂度和增量的选择有关。
- n 是待排序的元素数量。r 是待排序元素中的最大值加1。
最后我们对各种排序算法进行一个简单的测试:随机测试10000个数据:
void TestOP()
{
// 用当前时间作为随机数种子,确保每次运行时生成不同的随机数
srand(time(0));
const int N = 1000000;
int* a1 = (int*)malloc(sizeof(int)*N);
int* a2 = (int*)malloc(sizeof(int)*N);
int* a3 = (int*)malloc(sizeof(int)*N);
int* a4 = (int*)malloc(sizeof(int)*N);
int* a5 = (int*)malloc(sizeof(int)*N);
int* a6 = (int*)malloc(sizeof(int)*N);
int* a7 = (int*)malloc(sizeof(int)*N);
int* a8 = (int*)malloc(sizeof(int)*N);
for (int i = 0; i < N; ++i)
{
//a1[i] = rand();
a1[i] = rand()+i;
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
BubbleSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
QuickSort(a6, 0, N - 1);
int end6 = clock();
int begin7 = clock();
MergeSort(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
// 输出每个排序算法的执行时间(以毫秒为单位)
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("BubbleSort:%d\n", end5 - begin5);
printf("QuickSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
❗ 转载请注明出处
作者:HinsCoder
博客链接:🔎 作者博客主页