C语言数据结构-排序
1. 排序的概念及应用
1.1 排序的概念
- 排序: 所谓排序就是将特定数据按照递增或者递减的方式进行排列
- **稳定性:**若排序后相同关键字的记录相对次序保持不变,则称算法是稳定的;否则称为不稳定的。
- **内排序:**数据元素全部放在内存之中进行排序,插入排序,希尔排序,选择排序,冒泡排序,堆排序都是属于内排序
- **外排序:**数据元素数量太多,内存之中无法存放,在内存之外进行移动,归并排序既可以内排序也可以外排序
1.2 排序的应用
重要性原因: 排序在现实世界中无处不在,是数据处理的基础操作。
筛选功能: 通过排序可以帮助用户快速筛选出符合需求的商品或服务。
排序应用:
- 电商平台商品排序(价格、销量等)
- 外卖平台商家排序
- 游戏排名系统
排序是人们日常生活之中必不可缺的筛选方法
2.常见排序算法的实现
2.1 常见排序算法
- **插入排序:**直接插入排序,希尔排序
- **选择排序:**简单选择排序,堆排序
- **交换排序:**冒泡排序,快速排序
- **归并排序:**归并排序
不常见的排序:
基数排序、鸡尾酒排序等因考察少、应用少而被省略
2.1.1 直接插入排序
所有的排序将以升序作为排序方式进行讲解
插入排序的主要思想是将一段序列看做是有序序列,然后往里面逐渐插入数据,只需要从前往后插入就可以避免他不是有序序列的问题,从第二个数开始,将第二个数作为end,将后一个数作为tmp,一次与end,end-1,end-2作比较,直到找到比tmp小的数据,插入到他的后面即可
直接插入排序的特性总结:
- 时间复杂度O(n2)
- 空间复杂度O(1)
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 稳定性:稳定
//所有的排序将以升序作为排序方式进行讲解
//插入排序
//插入排序的主要思想是将一段序列看做是有序序列,然后往里面逐渐插入数据,只需要从前往后插入就可以避免他不是有序序列的问题
//从第二个数开始,将第二个数作为end,将后一个数作为tmp,一次与end,end-1,end-2作比较,直到找到比tmp小的数据,插入到他的后面即可
void InsertSort(int* a, int n)
{assert(a);//断言for (int i=0; i < n - 1; i++)//此处一定要确定好边界问题,i<n-1,因为一共就n个数排序,i用作end的循环,tmp是end的后一项,所以是i<end-1{int end = i;//找到end下标位置,end从头部开始int tmp = a[end + 1];//tmp保留end后一个数while (end >= 0)//如果插入排序时后插入的数小于前面所有数的话,end最小为-1,所以end下标>=0{if (tmp < a[end])//如果tmp小于end下标所在数{a[end + 1] = a[end];//交换end+1下标所在数和end下标所在数--end;//end往前移动,继续比较tmp数和end下标所在数的大小}else//如果都没有的话,说明此序列已经排序完成{break;//跳出循环}}a[end + 1] = tmp;//将tmp给到现在的end+1的位置}
}
2.1.2 希尔排序
希尔排序是对直接插入排序的优化,首先进行预排序(使数组接近有序),然后进行直接插入排序
- 预排序(把间距为gap的值分为一组,进行插入排序)
- 直接插入排序
经过预排序之后,大的数尽量被放在了后面去,小的数尽量放在了前面,gap越大,前面越大的数越快到后面,后面越小的数,越快到前面,如果gap越小越接近直接插入排序
希尔排序最重要的几点:
- 进行预排序,使得数组接近有序,降低了直接插入排序的复杂度
- 巧妙的设计了多组并排,而不是每组排完再排下一组
- 使用while循环对gap进行控制,逐渐降低gap大小,使得预排序越来越接近有序
- 使用gap=gap/3+1,保证gap最后的值为1,即直接插入排序,避免再次写直接插入排序,提高效率
- 希尔排序不稳定
- 希尔排序的时间复杂度不好计算
//希尔排序是对直接插入排序的优化
//希尔排序首先需要进行预排序,然后进行插入排序,预排序使数组接近有序
//然后对已经预排好的数组进行插入排序
void ShellSort(int* a, int n)
{int gap = n;//设置gap间距为nwhile (gap > 1)//如果gap大于1进入循环{gap = gap / 3 + 1;//保证gap最后是1,如果gap小于3的话gap/3等于0,所以要加1//当gap为1时就是插入排序,避免了代码重复//多组并排for (int i = 0; i < n - gap; i++)//哎?这里怎么是n-gap呢?为什么不是(n-1)/gap呢?//此处是多组并排的思想,(n-1)/gap是想一组排完再排下一组,但是多组并排可以各个组同时进行排序//当end在第一组时,预排序将预排到end+gap位置将这两个数进行预排,当end+1时,将会预排end+1+gap这两个数预排//直到到达n-gap将所有的数组内容预排序完毕{int end = i;//找到end下标位置,end从头部开始int tmp = a[end + gap];//tmp保留end后一个数while (end >= 0)//如果插入排序时后插入的数小于前面所有数的话,end最小为-1,所以end下标>=0{if (tmp < a[end])//如果tmp小于end下标所在数{a[end + gap] = a[end];//交换end+1下标所在数和end下标所在数end-=gap;//end往前移动gap个数,继续比较tmp数和end下标所在数的大小}else//如果都没有的话,说明此序列已经排序完成{break;//跳出循环}}a[end + gap] = tmp;//将tmp给到现在的end+gap的位置}}}
2.1.3 选择排序
选择排序排序顾名思义就是在数组之中选择特定数,放在特定位置进行排序,一般选择排序是选择一个最大或者最小的数放在最后或者最前面的位置,逐渐遍历数组达到排序的目的
但是一次选一个实在是太慢了,我们一次选两个吧,分别确定最大和最小的两个数,同时放在最前和最后的位置,然后移动最前和最后位置即可,但是注意,如果最大的数本身就在最前面,进行交换两次后会导致又换回去了,所以要注意对其进行修正,具体方法见代码
将常用函数独立出来
//交换函数
void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}
//选择排序
//我们将在数组之中找到最大的数和最小的数,并且将最大的数放在最后,最小的数放在最前
//然后又找到次小的数和次大的数,按照以上方式排列
void SelectSort(int* a, int n)
{assert(a);//断言int begin = 0;//找到beginint end = n - 1;//找到endwhile (begin < end)//当begin小于end的时候进入循环{int mini, maxi;//创建两个int类型变量保存最大最小值的下标mini = maxi = begin;//赋初始值为beginfor (int i = begin + 1; i <= end ; i++)//因为mini和maxi都是从begin开始,所以i从begin+1开始即可,遍历整个数组,找到最大最小值{if (a[mini] > a[i])//如果下标为i的值比最小值还要小{mini = i;//那么将i的下标赋予mini}if (a[maxi] < a[i])//如果下标为i的值比最大值还大{maxi = i;//那么将i的下标赋予maxi}}Swap(&a[begin], &a[mini]);//将最小值换到begin的位置//如果maxi在begin的位置上,那么就会导致数值互换两次,又换回去了,maxi和mini是下标,不是数值,别搞混了//不懂就画图if (a[begin] == a[maxi]){maxi = mini;//将mini赋值给mini,对maxi进行修正}Swap(&a[end], &a[maxi]);//将最大值放在end位置++begin;//begin向后移动--end;//end向前移动}
}
2.1.4 堆排序
堆排序的主要思想就是通过建大堆或者小堆的方式,将堆顶和堆底进行交换,将最大值或者最小值放在堆底
需要注意的是排升序要建大堆,排降序建小堆。
//堆排序
//时间复杂度O(N*log^n)
void HeapSort(int* a, int n)
{//排升序建大堆//从最后一个非叶子节点开始for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDwon(a, n, i);//向下调整算法建堆,建大堆}int end = n - 1;//找到堆最后一个元素位置while (end > 0)//当end>0时进入循环{Swap(&a[0], &a[end]);//将堆顶与堆底进行交换AdjustDwon(a, end, 0);//向下调整算法找到第二大的数--end;//将最大的数移除堆外}
}
2.1.5 冒泡排序
冒泡排序就是每一次找到一个最大值或者最小值放在最前或者最后的位置,每一轮找一个数
//冒泡排序
void BubbleSort(int* a, int n)
{int end = n;//确定end的位置while (end > 0)//如果end>0就进入循环,此乃外循环{int IsSort = 1;//判断该数组是否已经有序,默认为有序for (int i = 0; i < end; i++)//此乃内循环,在一轮之中找到该轮最大的数{if (a[i ] > a[i + 1])//如果下标i-1对应的值大于下标i对应的值{Swap(&a[i], &a[i + 1]);//就交换两个数的位置}IsSort = 0;//如果发生了交换说明该数组无序}if (IsSort == 1)//如果该数组为有序,就说明无需排序{break;//跳出循环即可}end--;//end-1}}
2.1.6 快速排序
快速排序的实现一共有三种方法:
- 左右指针法
- 挖坑法
- 前后指针法
学会一种就够了,不介绍太多了,此处介绍最容易理解的左右指针法
左右指针法:
-
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止
-
如果选右边的值做Key,那么一定要让左边的begin先走,如果选左边的值做Key那么一定要让右边的end先走,这样才能保证begin和end相遇的位置是比key大的位置
-
快速排序有一个非常大的缺点,就是如果数组内容有序或者接近有序,那么有可能会导致快速排序的时间复杂度非常的大,最大达到O(N2),因为我们无法保证我们选择的Key是中位数
-
但是我们可以想办法让我们取到的数既不是最小也不是最大的,使用一个方法叫做三数取中法
-
三数取中法:在数组的第一个数,最后一个数,中间的一个数取出来进行比较,选择中间大小的数作为key值,如果中间大小的数是中间位置的数,那么就和最左或者最右的数进行交换,再来排序
-
因为我们使用了三数取中法,所以快排的时间复杂度不在取O(N2),而变成了O(logN);
-
快速排序变成了非常高效率的排序方式
//三数取中法
int GetMidIndex(int *a, int begin, int end)
{int mid = (begin + end) / 2;//找到中间索引值if (a[begin] < a[end])//找到中间大小数的索引值{if (a[begin] > a[mid]){return begin;}if (a[end] < a[mid]){return end;}else{return mid;}}else//a[begin]<=a[end]{if (a[begin] > a[mid]){return begin;}if (a[end] < a[mid]){return end;}else{return mid;}}
}
//快速排序的单趟
int PartSort(int* a, int left, int right)
{int midindex = GetMidIndex( a, left, right);//找到这三个数的中位数Swap(&a[midindex], &a[right]);//虽然我不知道中位数是在哪个地方,但是我直接与最右边进行交换就行了int keyindex = right;//确定以右边作为key值while (left < right)//如果left小于right就进入循环{//left找大while (left < right&&a[left] <= a[keyindex])//如果left小于等于right并且left所对应的值小于key值时{++left;//让left++,直到找到大于key值的left}//right找小while (left < right && a[right] >= a[keyindex])//如果left小于等于right并且right所对应的值大于key值时{--right;//让right--,直到找到小于key值的right}Swap(&a[left], &a[right]);//交换left和right,保证左边都是小于key的值,右边都是大于key的值}Swap(&a[left], &a[keyindex]);//交换left和key的值,此时key的位置确定,数组接近有序return left;//返回left索引值,当前left和right的索引值都相等并且指向中间某位置
}
void QuickSort(int* a, int left, int right)
{assert(a);//断言一下if (left < right)//如果left小于right进入语句{int div = PartSort(a, left, right);//div(分割位置)表示的是基准值最终所在的正确位置,而不是数组的第一个值。QuickSort(a, left, div - 1);//递归左边QuickSort(a, div + 1, right);//递归右边}
}
2.1.7 归并排序
归并排序可以使用两种方法解决,分别是使用递归思想和使用非递归思想
归并排序的单趟思想:合并两段有序数组,合并以后依旧有序:
-
分解:将待排序的数组递归地拆分为两个近似相等的子数组,持续分解直到每个子数组只包含单个元素(此时天然有序)
-
解决:对最小单位的子数组(单元素)进行排序(实际上单元素本身已有序),通过递归实现自底向上的排序过程
-
合并:将两个已排序的子数组合并为一个新的有序数组合并,过程中通过双指针比较的方式选择较小元素,重复合并过程直至所有子数组归并为完整的有序数组
时间复杂度:O(N*logN),空间复杂度:O(N),排序稳定
// 归并排序的单趟排序
void _MergeSort(int* a, int left, int right, int* tmp)
{// 递归终止条件:当子数组只有一个元素或为空时,不再拆分if (left >= right) // left == right 表示只有一个元素,left > right 表示空区间return;// 计算中间位置,将数组分为两部分int mid = (left + right) / 2;// 递归拆分左半部分 [left, mid]_MergeSort(a, left, mid, tmp);// 递归拆分右半部分 [mid+1, right]_MergeSort(a, mid + 1, right, tmp);// ========== 合并两个有序子数组 ==========int begin1 = left, end1 = mid; // 左子数组范围 [begin1, end1]int begin2 = mid + 1, end2 = right; // 右子数组范围 [begin2, end2]int index = left; // 临时数组的起始位置// 双指针合并:比较两个子数组的元素,选择较小的放入临时数组while (begin1 <= end1 && begin2 <= end2){if (a[begin1] <= a[begin2]) // 使用 <= 保持排序稳定性{tmp[index++] = a[begin1++];}else{tmp[index++] = a[begin2++];}}// 处理左子数组剩余元素(如果有)while (begin1 <= end1){tmp[index++] = a[begin1++];}// 处理右子数组剩余元素(如果有)while (begin2 <= end2){tmp[index++] = a[begin2++];}// 将临时数组中已排序的数据拷贝回原数组// 拷贝范围应该是 [left, right],而不是 [left, right)for (int i = left; i <= right; i++) // 修正:i <= right{a[i] = tmp[i];}
}// 归并排序主函数(递归实现)
void MergeSort(int* a, int n)
{// 参数校验assert(a);if (n <= 1) // 添加边界条件检查return;// 申请临时数组空间,用于合并过程中的数据存储int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL) // 添加内存分配检查{perror("malloc failed");return;}// 调用递归排序函数,排序整个数组 [0, n-1]_MergeSort(a, 0, n - 1, tmp);// 释放临时数组空间free(tmp);
}
3. 排序的优缺点分析
- 直接插入排序:平均时间复杂度O(n2),稳定性:稳定,空间复杂度O(1);
- 希尔排序:平均时间复杂度O(n*logn)~O(n2),稳定性:不稳定,空间复杂度:O(1);
- 简单选择排序:平均时间复杂度:O(n2),稳定性:不稳定,空间复杂度:O(1);
- 冒泡排序:平均时间复杂度O(n2),稳定性:稳定,空间复杂度O(1);
- 堆排序:平均时间复杂度O(n*logn),稳定性:不稳定,空间复杂度O(1);
- 快速排序:平均时间复杂度O(n*logn),稳定性:不稳定,空间复杂度O(logn);
- 归并排序:平均时间复杂度O(n*logn),稳定性:稳定,空间复杂度O(n);
3.1. 适用场景总结
-
小规模数据:插入排序、冒泡排序
-
大规模数据:快速排序、堆排序、归并排序
-
内存敏感:堆排序
-
稳定性要求:归并排序、插入排序
-
链表排序:归并排序