- 1、前言
- 2、常见排序算法
- 3、排序算法实现
- 3.1 直接插入排序
- 3.2 希尔排序
- 3.3 选择排序
- 3.4 堆排序
- 3.5 冒泡排序
- 3.6 快速排序
- 3.6.1 单趟排序
- hoare法
- 挖坑法
- 双指针法
- 3.6.2 非递归实现
- 3.6.3 常见问题
- 基准值的选取
- 小区间优化
- 3.7 归并排序
- 3.7.1 递归实现
- 3.7.2 非递归实现
- 3.8 计数排序
- 4、算法稳定性及复杂度分析
1、前言
使一串数据按照特定规则重新排列的过程,以便于后续的搜索、插入、删除等操作更高效地进行。
稳定性
稳定:当未排序时a在b前面且a=b,排序后a仍然在b前面
不稳定:当未排序时a在b前面且a=b,排序后a可能会出现在b后面
排序分为 内部排序 和 外部排序。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序
2、常见排序算法
3、排序算法实现
3.1 直接插入排序
遍历数组,每一位和比当前下标小的所有数比较大小,找到第一位比自己小(或者相等)的数后直接插入该数的后面,比自己大的数进行移位操作。(假设要求升序排序)
算法实现:简单的遍历加移位,具体看代码
//时间复杂度O(N^2)
//最坏情况:逆序
//最好情况:顺序或接近有序 O(N)
void InsertSort(int* a, int n)
{
int length = n;
for (int i = 0; i < length; i++)
{
int end = i - 1;//从i-1开始遍历(0~i-1已经有序)
int tmp = a[end + 1];//保存插入的数
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];//移位
end--;//下标后移
}
else break;
}
a[end + 1] = tmp;//插入第一个比自己小的数后面
}
}
直接插入排序特性总结
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度;O(N^2)
- 空间复杂度:O(1),它使一种稳定的排序算法
- 稳定性:稳定
3.2 希尔排序
- 希尔排序法(Shell Sort)又称缩小增量法。它将待排序的数组分割成若干个较小的子序列,对子序列分别进行插入排序,最终合并这些有序的子序列完成排序过程。
- 如何分,分成多少组(如何选择合适的步长序列),分割间距对性能有什么影响?
选择合适的步长序列对希尔排序的性能有着显著的影响。这是因为步长序列决定了希尔排序在不同阶段的跳跃间隔,直接影响到排序过程中元素之间的比较和交换次数,从而影响整体的时间复杂度和排序效率。
常见的步长序列选择方法
1)希尔建议的步长序列
原始的希尔排序建议步长序列为 n/2, n/4, …, 1。这种序列保证了每次迭代的步长逐渐减半,直到最后一次步长为1。这种方法简单直接,通常能够提供不错的性能。
2)Hibbard步长序列
更好的增量序列选择是增量序列中的任何2个元素都是互素的
- Hibbard序列的步长为 2k -1,其中k为正整数。这种步长序列的选择更加复杂,步长的增长速度比较快,能够快速减少序列的逆序对数目,从而提高排序效率。但这种序列可能会导致较多的移动和比较操作。
- 使用 Hibbard 增量的希尔排序,其最坏情形的运行时间为 Θ(n3/2);
- 其平均情形的运行时间被认为是 O(n5/4)(基于模拟结果)。
3)Sedgewick步长序列
Sedgewick序列是经验性的步长序列,通过一系列的增量(比如 1, 5, 19, 41, 109, …)来选择步长。这种序列在实际应用中表现良好,能够有效地减少排序时间。
实现思路
1,5,19,41,109,… 这个序列中的项交替地取自以下2个序列
1,19,109,505,2161,…,9∗(4k−2k)+1 (k=0,1,2,3,…)
5,41,209,929,3905,…,2k+2(2k+2−3)+1 (k=0,1,2,3,…)
使用 Hibbard 增量的希尔排序平均运行时间猜测为O(n7/6),最坏情形为O(n4/3)。
希尔排序的时间复杂度和增量h的选取(gap)有关。
算法实现:带步长序列的插入排序,不断插排直到gap=1
void ShellSort(int* a, int n)
{
assert(a);
int gap = n;
while (gap > 1)
{
//gap /= 2;
gap = gap / 3 + 1;
//相比于简单地每次除以2,gap = gap / 3 + 1 的选择可以更快地将数组中的大元素向前移动,减少逆序对的数量,从而加快排序的速度。
for(int i=0;i<n-gap;i++)
{
int end=i;
int x=a[end+gap];
//移位得到正确插入位置end+gap
while(end>=0)
{
if(a[end]>a[end+gap])
{
a[end+gap]=a[end];//相当于移位操作
end-=gap;
}
else break;//0~end已经排序好,直接break即可
}
//插入
a[end+gap]=x;
}
}
希尔排序特性总结
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
- 希尔排序的时间复杂度由gap决定,实际应用中可以通过测试得出较为合适的步长序列。
- 时间复杂度O(N1.25)~ O(1.6*N1.25)
- 空间复杂度O(1)
- 稳定性:不稳定。
3.3 选择排序
每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,重复这样的步骤直到全部待排序数据元素排完。
算法实现:
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;//和快排很类似哈
while (begin < end)
{
int mini = begin, maxi = begin;//找最小值和最大值的下标
for (size_t i = begin; i <= end; i++)
{
if (a[i] < a[mini]) mini = i;
if (a[i] > a[maxi]) maxi = i;
}
Swap(&a[mini], &a[begin]);
Swap(&a[maxi], &a[end]);
if(maxi==begin) Swap(&a[maxi], &a[end]);//重叠问题,再交换一次就行
++begin;
--end;
}
}
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好(不论数组是否有序都会执行原步骤)。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.4 堆排序
利用堆积树(堆)这种数据结构所设计的一种排序算法,是选择排序的一种。通过堆来进行选择数据,需要注意的是排升序需建大堆,排降序建小堆。
算法实现:建堆+向下调整
void AdjustDown(int* a, int size, int parent)
{
int child = parent * 2 + 1;//左右孩子和parent比较大小决定是否down操作
//第一种方式:递归
if (a[child + 1] < a[child]&&child+1<size) child++;
if (a[child] < a[parent]&&child<size)
{
Swap(&a[parent], &a[child]);
AdjustDown(a,size, child);
}
//第二种方式:循环
/*while (child < size)
{
if (child + 1 < size && 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)
{
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) AdjustDown(a, n, i);
//堆排序(向下调整->O(N*logN)) (向上调整->O(N))
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
堆排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.5 冒泡排序
冒泡排序(Bubble Sort)是一种简单直观的排序算法,它重复地走访要排序的元素列,依次比较相邻的两个元素,如果顺序不对就交换它们。这个过程一直持续到没有再需要交换,也就是说该数组已经排序完成。
算法实现:
//时间复杂度O(N^2)
//空间复杂度:O(1)
void BubbleSort(int* a, int n)
{
bool f = false;
for (int i = 0; i < n - 1; i++)
{
f = false;
for (int j = 0; j < n - i - 1; j++)
if (a[j + 1] < a[j])
{
Swap(&a[j + 1], &a[j]);
f = true;
}
if (!f) break;//如果已经有序则无需继续遍历
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
3.6 快速排序
快速排序(Quick Sort)是一种经典的分治(Divide and Conquer)排序算法,其核心思想是通过将原始数组分割成较小的子数组来解决问题,然后递归地排序这些子数组。
- 快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。
实现步骤
1)选择基准值:从数组中选择一个基准值。通常情况下选择第一个元素、最后一个元素或者数组中间的元素作为基准值。
2)分割数组:重新排序数组,所有比基准值小的元素摆放在基准值前面,所有比基准值大的元素摆放在基准值后面。在这个分割结束之后,该基准值就处于数组的中间位置。这个称为分割(partition)操作。
3)递归排序子数组:递归地调用上述分割操作,对左右两个子数组分别进行排序。
4)合并结果:将左子数组、基准值和右子数组合并成最终的排序数组。
3.6.1 单趟排序
hoare法
取第一个元素作为基准(key)
int PartSort(int* a, int left, int right)
{
int keyi = left;
//找出最适合key插入的位置
while (left < right)
{
//right先走,找小
while (a[right] >=a[keyi]&&left<right) right--;
//left再走,找大
while (a[left] <= a[keyi]&&left<right) left++;
Swap(&a[left], &a[right]);//交换,使得所有比a[keyi]小的元素在左区间,比a[keyi]大的元素在右区间
}
Swap(&a[keyi], &a[left]);//将序列头部作为基准的数换到最合适的位置
return left;//返回left位置,方便获取a[left]
}
得到目标下标后可以进行分治操作
void QuickSort(int* a, int left, int right)
{
if (left >= right)//递归结束条件
{
return;
}
int keyi = PartSort1(a, left, right);//找出key的下标,然后进行左右分组递归
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
以首元素为基准的hoare法的快速排序的时间复杂度是:O(N*log(N)),但是当面对有序数组的时候,快排的时间复杂度就会变成O(N^2)。
挖坑法
是hoare法的变形.
基本思路
(1)将待排序序列头部的元素存放到一个临时变量中作为key值,将key值原先的位置作为坑位
(2)从右往左寻找比key小的值,找到后将其填入坑中,并且其原来的位置成为新的坑
(3)从左往右寻找比key大的值,找到后将其填入坑中,并且其原来的位置成为新的坑
(4)重复步骤2~3直到待排序序列扫描完毕
(5)最后将key值填入坑中,返回key值下标
int PartSort(int* a, int left, int right) //单趟排序挖坑法
{
int tmp = a[left];
int hole = left;
while (left < right)
{
while (left < right && a[right] >= tmp)
{
right--;
}
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= tmp)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = tmp;
return left;
}
双指针法
1)prev指针指向待排序序列开头,cur指针指向prev的下一个位置,将序列头部元素作为key值
2)cur向后走寻找比key小的值,如果找到则prev++,并交换元素
3)重复步骤2直到cur走到结尾,将key值与prev位置的值交换并返回key值此时的下标
int PartSort3(int* a, int left, int right)
{
if(left>=right) return;
int mid = GetmidIndex(a, left, right);
swap(&a[mid], &a[left]);
int cur = left + 1;
int prev = left;
int key = a[left];
while (cur <= right)
{
if (a[cur] < key && ++prev != cur)//++prev != cur是为了防止prev与cur相等的时候还进行互换操作
swap(&a[prev], &a[cur]);
cur++;
}
swap(&a[prev], &a[left]);
return prev;//此时在prev左边都是比a[prev]要小的数,右边都是比a[prev]要大的数
}
3.6.2 非递归实现
上述方法采用递归实现(深度太深容易栈溢出),这里提供一种用栈通过对待排序区间下标的压栈和出栈来实现快排的方法。
void QuickSortNonR(int* a, int left, int right) //非递归实现快排
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
//单趟排序,得出两个区间
int cur = begin + 1;
int prev = begin;
int key = a[begin];
int keyi = begin;
while (cur <= end)
{
if (a[cur] < key && ++prev != cur)//++prev != cur是为了防止prev与cur相等的时候还进行互换操作
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[begin]);
keyi = prev;
//先进右后进左
//QuickSort(a, prev + 1, end); 递归版本
if (keyi + 1 < end)//非递归
{
STPush(&st, end);
STPush(&st, prev + 1);
}
//QuickSort(a, begin, prev - 1); 递归版本
if (keyi - 1 > begin)//非递归
{
STPush(&st, prev-1);
STPush(&st, begin);
}
}
}
用栈实现快排的非递归。
3.6.3 常见问题
基准值的选取
虽然无论基准值选择数组当中的哪个元素都能完成排序工作,但是有些选择显然更优。下面会给出几类选法的分析。
1)不好的选取作法
通常的,这类选法是指将数组中第一个元素作为基准。如果输入是随机的,那么这是可以接受,并不会影响算法的性能。但是如果输入数据是预排序(升序)或是反序的,就会产生一个劣质的分割。
以升序为例
- 如果数组已经是升序排列(预排序),而选择第一个元素作为基准值,快速排序的分割过程将导致基准值的左右两侧子数组极度不平衡。即基准值的右侧子数组为空,左侧子数组包含除基准值外的所有元素。这种极端情况下,快速排序的时间复杂度可能达到O(n^2),因为它需要进行大量的比较和交换操作。
- 影响:导致快速排序退化为最坏情况,性能下降到与插入排序相近的时间复杂度。
以降序为例
- 类似于预排序情况,选择第一个元素作为基准值会导致快速排序的不平衡分割。基准值右侧的子数组会变得非常大,而左侧的子数组会很小。
- 影响:同样可能导致时间复杂度达到O(n^2),性能严重下降。
为了避免快速排序在预排序或反序情况下的性能问题,下面给出一些较优的选取方法
2)随机选择基准值
优点:每次在数组中随机选择一个元素作为基准值。这种方法可以有效地减少出现最坏情况的概率,因为随机选择可以平均分配可能的数据分布情况,降低快速排序的平均时间复杂度。
缺点:与固定选择基准值的方法相比,随机选择基准值需要额外的步骤来生成随机数并选取相应的元素作为基准值。这在一定程度上增加了算法的实现复杂度和运行时间。虽然在大多数情况,这种增加的开销可以接受。
3)三数中值分割法
在选择基准值时,可以考虑数组的第一个元素、中间元素(N/2)和最后一个元素,并选择它们的中位数作为基准值。这种方法在一定程度上减少了最坏情况发生的概率(预排序和反序的坏情况),但需要更多的比较操作。(用大白话来说:取三个数的中位数的下标,交换数组首元素(或尾元素)和该中位数)
使用这种方法可以减少快速排序约5%的运行时间
int GetMidIndex(int* a, int left, int right)//三数取中
{
//第一种写法(个人认为不好写,容易出错)
/*
int mid = (left + right) / 2;
if (a[left] > a[mid])
{
if (a[mid] > a[right])
return mid;
else if (a[left] > a[right])
return right;
else
return left;
}
else
{
if (a[mid] < a[right])
return mid;
else if (a[left] < a[right])
return right;
else
return left;
}
*/
//第二种写法:直接列出三种情况即可(比较容易理解且好看)
int mid = left + right >> 1;//取中间的数
//比较得出中位数
if (a[mid] <= a[left] && a[mid] >= a[right] || a[mid] >= a[left] && a[mid] <= a[right]) return mid;
else if (a[left] <= a[mid] && a[left] >= a[right] || a[left] >= a[mid] && a[left] <= a[right]) return left;
else return right;
}
具体的使用,我们只需要在单趟排序代码的开头加上这么一段就行:
int keyi = GetMidi(a,left,right);
Swap(&a[keyi], &a[left]);//交换left和mid的值,避免出现最小或最大(防止出现有序),避免了不必要的最坏情况
通过三数取中法,我们确保选择了一个较为中间的值作为基准,而将其置于序列的开头有助于后续的分割操作。
小区间优化
对于很小的数组(N<=20),快速排序的性能还不如插入排序好。
快速排序是一个递归算法,它每次递归调用都会消耗一定的栈空间(尽管现代编译器会优化尾递归)。对于非常小的数组,递归调用的开销可能会占据排序总时间的一个显著比例。(使用插入排序可以减少90%做优的递归)
void QuickSort(int* a, int left, int right) //小区间优化版快排:直接插入替代
{
if (left >= right)
return;
if (right - left + 1 < 20)
{
InsertSort(a + left, right - left + 1);
return;
}
int key = PartSort2(a, left, right);
QuickSort(a, left, key - 1);
QuickSort(a, key + 1, right);
}
3.7 归并排序
核心思想:分治
- 将原始的数组分解成较小的数组,直到每个小数组只有一个元素。
- 通过逐步合并这些小数组,最终得到一个有序的数组。
3.7.1 递归实现
算法实现:
- 分解过程
归并排序使用递归来实现分解过程。假设我们有一个数组 a,要对其进行归并排序,过程如下:找到数组的中间点,将数组分成两半。 对左半部分和右半部分分别进行归并排序(递归调用)。 当每个子数组只包含一个元素时,递归停止。- 合并过程
合并过程是归并排序的关键步骤,它将两个有序的子数组合并成一个更大的有序数组:创建一个临时数组 tmp,用来存放合并后的结果。 使用两个指针(或索引),分别指向要合并的两个子数组的开头。比较两个指针指向的元素,将较小(或较大)的元素放入 tmp 数组中。 移动指针,直到其中一个子数组的所有元素都被放入 tmp 中。将剩余的子数组的所有元素依次复制到 temp 中。
//子函数
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//只有一个数返回
if (begin == end) return;
int mid = (begin + end) / 2;
//分区间———— [begin,mid-1] [mid,end]
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid+1, end, tmp);
//到这里分区间操作全部完成
//从底部一步步归并,直到完成所有数据的排序
//归并过程
int i = begin;
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
//双指针算法描述
//从两个区间的begin开始,两个指针不断比较,把小的数据插入到tmp,完成排序、
//注意此时两个区间都有序
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++];
//把tmp中排序好的区间传入a中
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
//我们不能每次递归都开辟一次空间,所以需要将主要的代码都放在子函数中,对子函数进行递归
//开辟空间
int* tmp = (int*)malloc(sizeof(int) *(n));
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
//销毁空间
free(tmp);
tmp = NULL;
}
3.7.2 非递归实现
和递归实现的区别是:要处理边界问题
基本思路:
1)设定一个初始值为1的gap
2)通过gap来分割子序列。每次分割出两个相邻的子序列进行归并,归并好一组就将其覆盖到原序列中
3)重复步骤2直到第一轮归并结束
4)gap乘2,重复步骤2~3,直到gap超过原序列的大小
边界问题请见代码注释
//非递归实现归并排序
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * (n));
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = begin1 + gap - 1;
int begin2 = begin1 + gap, end2 = begin2 + gap - 1;
//非递归容易越界(左区间或右区间超过n)
//因此需要分类讨论
//在所有越界情况中:只有end2越界才需要归并,其余情况的越界无需归并(已经排序好)
if (end1 >= n || begin2 >= n) break;
//如果end2越界,修改end2至数组边界n-1
if (end2 >= n) end2 = n - 1;
int j = i;
//归并
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++];
//把tmp中排序好的区间传入a中
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
}
上述的排序均为比较排序,下面给出几种非比较排序
3.8 计数排序
计数排序是一个不基于比较的排序算法,又被称为鸽巢原理,是对哈希直接定址法的变形应用。
其优势在于对一定范围内较集中的数据排序时,其时间复杂度低于任何一个基于比较的算法。但是空间需求较大。(空间换时间)
//计数排序(适合小范围)
//时间复杂度:O(N+Range)
//空间复杂度:O(Range)
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 1; 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);
if (count == NULL)
{
perror("malloc fail");
return;
}
//初始化为0,用count来记某个数出现的次数
//Q:为什么不能直接sizeof count?
//count 是一个指针:count 是一个 int* 类型的指针,
// sizeof(count) 返回的是指针的大小,通常是 4 或 8 字节(取决于平台和编译器)。
memset(count, 0, sizeof(int)*range);
//统计次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;//映射对应下标位置
}
//排序
int j = 0;
//0~range遍历,自动排序好了,只需要存入a数组即可
for (int i = 0; i < range; i++)
{
while (count[i]--)//出现相同的,依次放入数组
{
a[j++] = i + min;//i+min为原来数的大小
}
}
}
局限性
- 范围限制:计数排序依赖于数据的范围。如果数据范围很大,则需要大量的内存来存储计数数组,导致空间浪费
- 只适合整形:字符串(转化为整形可以排),浮点,结构体(实际应用多)都很少采用,因此实际应用较少。
4、算法稳定性及复杂度分析
稳定性:相同的值相对顺序是否改变。