文章目录
- [1. 前言](#1. 前言)
-
- [1.1 什么是排序?](#1.1 什么是排序?)
- [1.2 排序的稳定性](#1.2 排序的稳定性)
- [1.3 排序的分类和比较](#1.3 排序的分类和比较)
- [2. 常见的排序算法](#2. 常见的排序算法)
- [3. 实现常见的排序算法](#3. 实现常见的排序算法)
-
- [3.1 直接插入排序](#3.1 直接插入排序)
- [3.2 希尔排序](#3.2 希尔排序)
- [3.3 直接选择排序](#3.3 直接选择排序)
- [3.4 堆排序](#3.4 堆排序)
- [3.5 冒泡排序](#3.5 冒泡排序)
- [3.6 快速排序](#3.6 快速排序)
-
- [3.6.1 hoare思想](#3.6.1 hoare思想)
- [3.6.2 挖坑法](#3.6.2 挖坑法)
- [3.6.3 lomuto前后指针法](#3.6.3 lomuto前后指针法)
- [3.6.4 非递归实现快速排序](#3.6.4 非递归实现快速排序)
- [3.6.5 基准值的选取问题](#3.6.5 基准值的选取问题)
- [3.6.6 小区间优化](#3.6.6 小区间优化)
- [3.7 归并排序](#3.7 归并排序)
-
- [3.7.1 递归版本](#3.7.1 递归版本)
- [3.7.2 非递归版本](#3.7.2 非递归版本)
- [3.8 非比较排序:计数排序](#3.8 非比较排序:计数排序)
- 4.测试排序的性能对比
- [5. 排序算法复杂度及稳定性分析](#5. 排序算法复杂度及稳定性分析)
1. 前言
1.1 什么是排序?
所谓排序(sort),就是要整理初始排序表中的元素,使之按关键字递增或递减的有序序列。
1.2 排序的稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
1.3 排序的分类和比较
内排序:在排序过程中,若整个排序表都放在内存中处理,排序时不涉及数据的内,外存交换,则称之为内排序
外排序:若在排序过程中要进行数据的内,外存交换,则称之为外排序
内排序适用于元素个数不是很多的小表 ,外排序则适用于元素个数很多,不能一次将其全部元素放入内存的大表 。内排序是外排序的基础。
2. 常见的排序算法
3. 实现常见的排序算法
3.1 直接插入排序
思路:当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],...,array[i-1] 已经排好序,此时用 array[i] 与 array[i-1],array[i-2],... 进行比较,找到插入位置(找到第一个比array[i]小或相等的数的位置的下一个位置)即将 array[i] 插入,原来位置上的元素顺序后移(默认为升序)
c
void InsertSort(int* arr, int n)
{
for (int i = 0; i < n ; i++)
{
int end = i - 1;//位置为0~i-1的元素已经有序,从位置为i-1的元素开始比较
int tmp = arr[i];//将位置为i的元素进行插入
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];//向后移一位
end--;//下标减一,继续比较
}
else
{
break;//找到第一个比待插入元素tmp小于或相等的元素,跳出循环
}
}
arr[end + 1] = tmp;//在第一个比待插入元素tmp小于或相等的元素的位置的下一个位置插入tmp
}
}
直接插入排序特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度为O(N^2)
- 空间复杂度为O(1)
- 稳定性:稳定
3.2 希尔排序
思路 :希尔排序法又称缩小增量法 。希尔排序法的基本思想是:先选定一个整数(通常是gap = n/3+1,采用Knuth增量序列),把待排序文件所有记录分成各组,所有的距离相等的记录分在同一组内,并对每⼀组内的记录进行排序,然后gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序
先进行预排序,根据gap的大小分为gap组进行排序,排完序更新gap
预排序完后进行直接插入排序(gap==1)
那么要分多少组较为合适?也就是选择步长为多少比较好?这影响着希尔排序整体的时间复杂度和排序效率
常用的增量序列
(1) 希尔建议的序列
希尔建议的增量序列并没有一个固定的公式或明确的通项表达式,它通常是根据经验或实验得出的。一个常见的希尔增量序列示例是:[1, 4, 13, 40, ...] (gap=n/3),但这个序列并不是固定的(也有可能为gap=n/2),可以根据具体的数据集和排序需求进行调整。希尔增量序列的选取对于希尔排序的性能有较大影响。
(2) Hibbard序列
Hibbard增量序列的通项公式为:hi = 2^i - 1,或者递推公式为:h1 = 1,hi = 2 * h(i-1) + 1
Hibbard序列生成的是一个递增的奇数序列 ,如[1, 3, 7, 15, 31, 63, ...]。这个序列的优点在于,它使得在排序的每一轮中,各个子序列的长度互质 ,这有助于减少数据交换的次数,并提高排序的效率。Hibbard序列的另一个特点是,它保证了在排序的最后阶段,增量为1,从而确保整个数组能够被完整地排序。
最坏时间复杂度:O(N^3/2)
猜想的平均时间复杂度:O(N^5/4)
(3) Sedgewick序列
一个常见的Sedgewick增量序列是基于幂次和乘除运算的,如9×4^i - 9×2^i + 1或4^i - 3×2^i + 1 (注意,这些公式可能不是Sedgewick原始研究中使用的确切公式,但它们代表了Sedgewick增量序列的一种可能形式)。这些序列的设计目的是在减少增量的过程中,能够更平滑地过渡到全数组排序,从而减少排序的总时间。
例如:{28,1,5,19,41,109,...},hi = Max((9×4^i - 9×2^i + 1),4^i - 3×2^i + 1)
猜测的最坏时间复杂度:O(N^4/3)
猜测的平均时间复杂度:O(N^7/6)
(4) Knuth 增量序列
h(i) = 1/2 * (3^i - 1)
递推公式:h(1) = 1,h(i) = 3 * h(i-1) + 1
算法实现:
c
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;//相对于gap / 2,gap = gap / 3 + 1可以加快排序的效率
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];//组内元素进行交换(移位)
end -= gap;//减去步长即为同一组的前一个元素的下标,继续进行比较
}
else {
break;
}
}
arr[end + gap] = tmp;//end以及之前的同一组元素已经是有序,在end+gap的位置插入tmp即可
}
}
}
希尔排序的时间复杂度计算 :
外层循环 :外层循环的时间复杂度可以直接给出为: O(log2 n) 或者 O(log3 n) ,即 O(log n)
内层循环 :
因此,希尔排序在最初和最后的排序的次数都为n,即前一阶段排序次数是逐渐上升的状态,当到达某一顶点时,排序次数逐渐下降至n,而该顶点的计算暂时无法给出具体的计算过程。
希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语言版)》--- 严蔚敏书中给出的时间复杂度为:
希尔排序的特性总结:
1.希尔排序是对直接插入排序的优化。
2.当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
3.希尔排序的时间复杂度由步长gap决定,在实际应用中可以通过测试的方法来决定使用哪个增量序列较为合适
4.希尔排序的时间复杂度约为O(n^1.3),时间复杂度为O(1)
5.稳定性:不稳定
3.3 直接选择排序
思路 :遍历数组并用begin和mimi指针先指向第一个位置,让mini指针向后遍历并记录最小数据的位置,遍历完后让begin和mini指向的数据交换(即让最小的数据排在最前面),一直遍历完数组和执行上述操作即可。
c
void SelectSort(int* arr, int n)
{
for (int i = 0; i < n; i++)
{
int begin = i;
int mini = i;
//找最小的
for (int j = i + 1; j < n; j++)
{
if (arr[j] < arr[mini])
{
mini = j;
}
}
//找到了最小的值,i和mini位置数据进行交换
Swap(&arr[begin], &arr[mini]);
}
}
优化:
既然可以向后找最小的数据然后与最前面的数据进行交换,那也可以向后找最大的数据然后与最后面的数据进行交换。
思路:定义两个指针begin(指向数组的开始位置),end(指向数组的末尾位置),再定义两个指针mini和maxi先指向begin指向的位置,然后向后遍历数组记录区间[begin,end]中最小数据和最大数据的位置,遍历完后让mini指向的数据与begin指向的数据进行交换,再让maxi指向的数据与end指向的数据进行交换,最后再让begin向后走一步(加一),end向前走一步(减一)。循环往复,当end<=begin时则退出循环
注意 :当maxi和begin指向同一个位置时会发生错误,因为是先将mini指向的数据与begin指向的数据进行交换,交换完后begin指向的数据是最小的数据,而maxi也指向begin指向的位置,在进行交换的话end指向的数据就会变成最小的数据。所以在进行数据交换前还得判断maxi是不是等于begin,如果是的话就要让maxi指向mini(mini和begin指向的数据交换后,mini指向的数据是最大的数据)
算法实现:
c
void SelectSort(int* arr, 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 (arr[mini] > arr[i])
{
mini = i;
}
if (arr[maxi] < arr[i])
{
maxi = i;
}
}
if (maxi == begin)
{
maxi = mini;
}
Swap(&arr[begin], &arr[mini]);
Swap(&arr[end], &arr[maxi]);
++begin;
--end;
}
}
直接选择排序特性总结:
直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定
3.4 堆排序
思路 :堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。 对堆这个数据结构不熟悉的可以看一下我之前写的博客:数据结构:二叉树(堆)的顺序存储
c
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustDown(int* arr, int parent, int n)
{
//第一种方法:递归
int child = parent * 2 + 1;
if (child + 1 < n && arr[child + 1] > arr[child])
{
child++;
}
if (arr[child] > arr[parent] && child < n)
{
Swap(&arr[child], &arr[parent]);
AdjustDown(arr, child, n);
}
//第二种方法:循环
/*
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
*/
}
void HeapSort(int* arr, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, 0, end);
end--;
}
}
堆排序特性总结:
- 堆排序适用于数据量较大的情况,因为它的时间复杂度相对较低,能够在合理的时间内完成排序任务。
- 对于内存有限的环境也非常适用,由于其空间复杂度低,不需要额外的大量存储空间。
- 时间复杂度:O(N*logN),空间复杂度:O(1)
- 稳定性:不稳定
3.5 冒泡排序
思路:冒泡排序通过重复地遍历要排序的数列,依次比较相邻的两个元素,如果它们的顺序不符合要求就进行交换,就像水中的气泡逐步上升一样,每次把较大(或较小)的元素逐渐 "浮" 到数列的一端,经过多次遍历后实现整个数列的有序排列。
算法实现:
c
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n; i++)
{
int exchange = 0;
for (int j = 0; j < n - i - 1; j++)
{
if (arr[j] > arr[j + 1])
{
exchange = 1;
Swap(&arr[j], &arr[j + 1]);
}
}
if (exchange == 0)
{
break;
}
}
}
冒泡排序特性总结:
1.简单易懂,容易实现。
2.时间复杂度:O(N^2)
3.空间复杂度:O(1)
4.稳定性:稳定
3.6 快速排序
其基本思想为:任取待排序元素序列中的某元素作为基准值(通常选择第一个元素,最后一个元素或者序列中间的元素),按照该排序码(升序或者降序)将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序是一种经典的分治排序算法,不断的将原数组分割成两个部分,再递归和排序两个左右子序列,直到原数组有序
3.6.1 hoare思想
- 有两个指针left和right分别指向数组的第一个和最后一个元素
- 随机选取一个元素作为基准值,这里选择第一个元素left
- left从基准值后面开始从左向右找比基准值要大的元素
- right从后往前找比基准值要小的元素
- 找到后且left<=right,则交换left和right指向的元素
- 循环往复,当left>right时则退出循环。此时left之前的元素都是小于基准值的(left指向的元素大于基准值),而right指向的元素也是小于基准值的,所以基准值要和right指向的元素进行交换(这就实现了比基准值小的元素都在基准值的前面,比基准值大的元素都在基准值的后面),最后再返回基准值的下标即可。
c
//hoare版本
int _QuickSort1(int* arr, int left, int right)
{
int keyi = left;//选取序列的第一个元素作为基准值
++left;//从基准值后面的元素开始查找
while (left <= right)//left和right相遇的值比基准值要大
{
while (left <= right && arr[right] > arr[keyi])
{
right--;
}
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
if (left <= right)
{
//left指向的元素大于基准值,right指向的元素小于基准值,所以要进行交换,交换不要忘了left++,right--
Swap(&arr[left++], &arr[right--]);
}
}
//最后要和right指向的元素进行交换
Swap(&arr[keyi], &arr[right]);
//返回基准值下标
return right;
}
再进行分治操作,递归左右子序列
c
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
//[left,right]-->找基准值mid
int mid = _QuickSort1(arr, left, right);
//左子序列:[left,mid-1]
QuickSort(arr, left, mid - 1);
//右子序列:[mid+1,right]
QuickSort(arr, mid + 1, right);
}
当选取第一个元素作为基准值的hoare思想法的平均时间复杂度为:O(N*logN),当面对有序数组时,时间复杂度最差可能变为O(N^2)。在平均情况下,空间复杂度为O(logN),但是当面对最坏情况下(有序数组),空间复杂度最差为O(N)
3.6.2 挖坑法
思路:创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)
- 选取第一个元素作为坑位,用key存储坑位的元素
- right指针负责从右向左找比坑位要小的元素的下标,找到后将该元素放入坑位hole中,此时right指向元素的位置就是新的坑位
- left指针负责从左向右找比坑位要大的元素的下标,找到后将该元素放入坑位hole中,此时left指向元素的位置就是新的坑位
- 循环往复,当left>=right时则退出循环,最后将key放入到坑位hole中,返回hole即可
c
//挖坑法
int _QuickSort2(int* arr, int left, int right)
{
int hole = left;//设置第一个元素作为坑位
int key = arr[hole];//用key存储坑位的元素
while (left < right)
{
while (left < right && arr[right] > key)
{
right--;
}
//找到比坑位小的元素,将它放入坑中,此时right指向的就是新的坑位
arr[hole] = arr[right];
hole = right;
while (left < right && arr[left] < key)
{
left++;
}
//找到比坑位大的元素,将它放入坑中,此时left指向的就是新的坑位
arr[hole] = arr[left];
hole = left;
}
//最后将key放入坑中,返回坑位hole
arr[hole] = key;
return hole;
}
3.6.3 lomuto前后指针法
- prev指针指向第一个元素的位置,cur指向prev下一个的位置,keyi存储第一个元素的位置
- cur负责从前向右后走,一直找到比keyi指向的元素小的元素位置,找到后prev向后走一步,如果prev不等于cur则交换prev和cur指向的元素(如果prev等于cur就没有交换的必要),cur再++
- 循环往复,一直到cur>right则退出循环
- 此时prev指向的元素以及prev之前的元素都是小于keyi指向的元素的,所以再将prev和keyi指向的元素进行交换,最后返回prev即可
c
//lomuto前后指针法
int _QuickSort3(int* arr, int left, int right)
{
int keyi = left;
int prev = keyi;
int cur = prev + 1;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && ++prev != cur)//避免prev和cur相等时再进行交换操作
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);//交换后prev之前的元素都是小于prev所指向的元素,则prev所指向的元素即为基准值
return prev;//最后返回基准值下标即可
}
3.6.4 非递归实现快速排序
上述采用递归的方法容易导致栈溢出(递归的深度太大),所以这里借助栈这个数据结构对待排序区间的下标进行压栈和出栈的操作来实现快速排序。
不了解栈这个数据结构的可以看一下我之前的博客:数据结构:栈
c
void QuickSortNonR(int* arr, int left, int right)
{
Stack st;
stackInit(&st);
//由于栈是先进后出,所以先将右区间入栈,再将左区间入栈
stackPush(&st, right);
stackPush(&st, left);
while (!stackIsEmpty(&st))
{
int begin = stackTop(&st);
stackPop(&st);
int end = stackTop(&st);
stackPop(&st);
int prev = begin;
int cur = prev + 1;
int keyi = begin;
while (cur <= end)
{
if (arr[cur] < arr[keyi] && ++prev != cur)//避免prev和cur相等时再进行交换操作
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);//交换后prev之前的元素都是小于prev所指向的元素,则prev所指向的元素即为基准值
keyi = prev;//基准值为prev
//根据基准值划分左右区间
//左区间:[begin,keyi-1]
//右区间:[keyi+1,end]
if (keyi + 1 < end)
{
stackPush(&st, end);
stackPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
stackPush(&st, keyi-1);
stackPush(&st, begin);
}
}
satckDestroy(&st);
}
非递归实现快速排序时,平均情况下的时间复杂度为:O(N*logN)。最坏情况发生在每次划分操作都选择到了数组中的最小(或最大)元素作为基准(pivot),导致每次划分后,一个子数组为空,另一个子数组仍然是原数组(除了基准元素)。这样,快速排序就退化成了冒泡排序,时间复杂度为 O(N^2)。
3.6.5 基准值的选取问题
(1) 比较不推荐的选取方法
通常选取第一个元素或者最后一个元素作为基准值,但是当数组是升序或者降序的时候就会导致一个子数组为空,另一个子数组仍然是原数组(除了基准元素)。在这种极端的情况下,快速排序的时间复杂度可能会达到O(N^2),性能严重下降
(2) 随机选取基准值
c
int SelectPivotRandom(int arr[], int low, int high)
{
srand((unsigned)time(NULL));
int pivotPos = rand() % (high - low) + low;
Swap(&arr[pivotPos], &arr[low]);
return arr[low];
}
优点:
1.避免分区不均匀 :随机选取基准值能够显著减少因为固定选取基准值(如数组的第一个、最后一个或中间元素)而可能导致的分区不均匀问题。分区不均匀是快速排序效率下降的主要原因之一,因为它可能导致递归深度增加,甚至在最坏情况下退化为冒泡排序的性能O(N^2) )
2.提高排序效率:通过随机选取基准值,可以更加均匀地分割数据集,使得每次分区后,两个子数组的大小更加接近,从而减少了递归调用的次数和比较的次数,提高了整体排序效率。
缺点:
1.随机性带来的不确定性 :尽管随机选取基准值在大多数情况下能提高排序效率,但其随机性也带来了一定的不确定性。在某些极端情况下,即使随机选取基准值,也可能出现分区不均匀的情况,尽管这种情况相对较少。
2.实现复杂度增加和可能增加额外开销 :相比于固定选取基准值的方法,随机选取基准值需要额外的步骤来生成随机索引并与数组中的某个元素交换,这增加了算法的实现复杂度,随机选取基准值可能带来的额外开销会相对较大,从而影响排序的整体性能。
3.对随机数生成器的依赖:随机选取基准值的方法依赖于随机数生成器的质量。如果随机数生成器不够随机或存在缺陷,那么随机选取基准值的效果可能会受到影响,甚至可能不如固定选取基准值的方法。
(3) 三数取中法
取第一个元素,最后一个元素和中间元素的中位数的下标,将该中位数与数组的首元素或尾元素进行交换即可。
c
int getmidi(int* arr, int left, int right)
{
//int midi = (left + right) / 2;
int midi = left + right >> 1;
if (arr[left] < arr[right])
{
if (arr[left] < arr[midi])//left最小
{
if (arr[midi] > arr[right])
return right;
else
return midi;
}
else
return left;
}
else
{
if (arr[right] < arr[midi])//right最小
{
if (arr[left] < arr[midi])
return left;
else
return midi;
}
else
return right;
}
}
具体使用时,只需要将下面的代码放到每次划分左右子序列代码的开头即可。
c
int key = getmidi(arr, left, right);
Swap(&arr[key], &arr[left]);
这样每次就可以将left指向的元素作为基准值,防止出现有序的情况和便于划分操作
(4) 三路划分法
c
int _QuickSort4(int* arr, int left, int right)
{
int keyi = getmidi(arr, left, right);
Swap(&arr[keyi], &arr[left]);
int key = arr[left];
int prev = left;
int cur = prev + 1;
int r = right;
while (cur <= r)
{
if (arr[cur] > key)
{
Swap(&arr[cur], &arr[r]);
r--;
}
else if (arr[cur] < key)
{
Swap(&arr[cur], &arr[prev]);
cur++;
prev++;
}
else {
cur++;
}
}
return prev;//最后返回基准值下标即可
}
3.6.6 小区间优化
当待排序序列的长度划分到一定大小的时候(N<=20),使用直接插入排序。
原因:对于很小和部分有序的数组,快速排序不如直接插入排序好。当待排序序列的长度划分到一定大小后,继续划分的效率比直接插入排序要差(递归调用会消耗一定的栈空间),此时可以使用直接插入排序而不是快速排序。
c
void InsertSort(int* arr, int left , int right) {
for (int i = left + 1; i <= right; i++) {
int end = i - 1;
int tmp = arr[i];
while (end >= left && arr[end] > tmp) {
arr[end + 1] = arr[end];
end--;
}
arr[end + 1] = tmp;
}
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
if (right - left + 1 <= 20)
{
InsertSort(arr, left, right);
return;
}
//[left,right]-->找基准值mid
int mid = _QuickSort4(arr, left, right);
//左子序列:[left,mid-1]
QuickSort(arr, left, mid - 1);
//右子序列:[mid+1,right]
QuickSort(arr, mid + 1, right);
}
3.7 归并排序
归并排序(MERGE-SORT)是建立在归并操作上的⼀种有效的排序算法,该算法是采用分治法(Divideand Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
3.7.1 递归版本
归并排序(Merge Sort)是一种高效的排序算法,采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序的核心思想是将数组分成两半,分别对它们进行排序,然后将排序好的两半合并在一起。这个过程一直递归进行,直到分割的子数组只包含一个元素(此时认为它是排序好的)。
- 分解过程:找到数组的中间结点,将数组分成两个子数组,再分别对左子数组和右子数组进行递归调用,一直递归到子数组只有一个元素的时候就停止递归调用。
- 合并过程:核心是将两个有序的子数组合并成一个有序的数组:创建一个数组tmp用来存放合并后的有序数组,创建两个指针begin1和end1用来指向一个子数组的首尾位置,再创建两个指针begin2和end2用来指向另一个子数组的首尾位置,再创建一个index指针指向begin1(表示在tmp数组存放第一个元素的位置)。比较begin1和begin2指向的元素,将较小或者较大的元素放入tmp中,再将指向该元素的指针和index向后走一步,一直到其中一个子数组里的元素全部存放在tmp数组中,再将另一个子数组里的剩余所有元素放入tmp数组中即可。
c
void _MergeSort(int* arr, int left, int right,int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
//[left,mid] [mid+1,right]
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
//合并
//[left,mid] [mid+1,right]
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
//要么begin1越界,要么begin2越界
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//[left,mid] [mid+1,right]
//把tmp中的数据拷贝回arr中
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
//归并排序
void MergeSort(int* arr, int n)
{
//创建新数组tmp
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(1);
}
_MergeSort(arr, 0, n - 1, tmp);
//销毁空间
free(tmp);
tmp = NULL;
}
归并排序特性总结(递归):
时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定
3.7.2 非递归版本
思路:首先开辟一个新数组tmp,再设置变量gap来进行分组,分别记录两个子数组的左右区间,将两个子数组进行比较,小的元素插入数组tmp中,直到一个子数组的全部元素插入到tmp数组后再将另一个子数组的剩下所有元素插入到tmp数组中。完成一趟排序后就将排序完的数据拷贝到原数组arr中,再将gap*2,继续完成剩余的排序,直到gap>=n时则完成了全部的排序。
c
void MergerSortNonR(int* a, int n)
{
//创建数组
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("tmp");
exit(-1);
}
//划分组数
int gap = 1;
while (gap < n)
{
int j = 0;
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;
//取两个区间小的值尾插
//一个区间尾插完毕另一个区间直接尾插即可
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//再将剩余数据依次尾插
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
//将数据重新拷贝至原数组
memcpy(a, tmp, sizeof(int) * n);
//更新gap
gap *= 2;
}
//释放
free(tmp);
}
可以看出这个排序结果是错误的,为什么会错误呢?因为随着gap的增大,当gap=8时就会发生越界问题,所以导致排序结果错误,下面就来分析一下
注意 :要注意处理边界问题
一个子数组的左右区间为begin1和end1,另一个子数组的左右区间为begin2和end2,则数组越界有三种情况:
-
- end1,begin2,end2越界
-
- begin2,end2越界
-
- end2越界
改正方式有两种:
第一种:
1.首先不能将子数组全部排完序就将数据整体拷贝,需要排完一组,拷贝一组。
2.当发生越界中的第一、二种时可以直接break。
3.当发生越界中的第三种时可以将边界进行修正。
c
void MergeSortNonR1(int* arr, int n)
{
//创建新数组tmp
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(-1);
}
int gap = 1;//根据gap进行分组
while (gap < n)
{
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;
int j = i;
//end1和begin2越界直接跳出循环
if (end1 >= n || begin2 >= n)
{
break;
}
//end2越界可以修正
if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[j++] = arr[begin1++];
}
else
{
tmp[j++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = arr[begin2++];
}
//归并一组,拷贝一组
memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
第二种:
只需要将越界的区间改为不存在的区间即可
c
void MergeSortNonR2(int* arr, int n)
{
//创建新数组tmp
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
exit(-1);
}
int gap = 1;//根据gap进行分组
while (gap < n)
{
int j = 0;
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;
//将越界的区间修改为不存在的区间
if (end1 >= n)
{
end1 = n - 1;
//修改为不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
//不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[j++] = arr[begin1++];
}
else
{
tmp[j++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = arr[begin2++];
}
}
//整体拷贝
memcpy(arr, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
tmp = NULL;
}
归并排序(非递归)的时间复杂度为:O(N*logN),空间复杂度为:O(N)
3.8 非比较排序:计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用
计数排序适用于数据在一定的范围内较为集中的情景,其时间复杂度较低,但空间需求较大(空间换时间)
思路:先统计相同元素出现次数,再根据统计的结果将序列回收到原来的序列中
c
//计数排序
void CountSort(int* arr, int n)
{
//根据最大值和最小值确定数组大小
int max = arr[0], min = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail!");
exit(1);
}
//初始化count数组中所有的数据为0
memset(count, 0, sizeof(int) * range);
//统计数组中每个数据出现的次数
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
//取count中的数据,往arr中放
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[index++] = i + min;
}
}
}
计数排序的局限性:
1.数据范围限制:如果数据的范围非常大,那么就需要一个同样大的数组来计数,这将导致内存空间的极大浪费,甚至可能超出系统的内存限制。因此,计数排序通常只适用于数据范围相对较小的场景。
2.数据类型限制:计数排序通常只适用于整数排序,对于浮点数、字符串等类型的数据,计数排序则难以直接应用
3.内存使用:计数排序需要额外的内存空间来存储计数数组。在最坏的情况下,如果数据的范围与数据的数量接近,那么计数数组的大小可能会与原始数据数组的大小相当,从而占用大量的内存空间。
计数排序特性总结:
1.计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2.时间复杂度:O(N+range)
3.空间复杂度:O(range)
4.稳定性:稳定
4.测试排序的性能对比
排10万个数据,各个排序算法时间的比较(单位:ms):
5. 排序算法复杂度及稳定性分析