148.DS--第七章
排序

同前面总结的查找一样是数据结构常用的方法
顾名思义就是将元素排序 让其有序罢了
分为:
- 内部排序:排序期间所有元素都存在内存中
- 外部排序:内存不能全部放下 排序过程需要频繁内外存交换数据
排序主要涉及的操作就是移动和比较 所以后续也会考虑这个次数
还有一个知识点就是稳定性
不是衡量算法优劣 而是一种性质
比如4在5之前 经过某排序后 相对仍然是这样的 就表示该排序算法是稳定的
接下来就一个一个攻克
一.插入排序(直接插入 折半插入 希尔)
我们先看直接插入排序
一句话就是将某个元素插入已经排好的序列里
举个例子
通过上例我们也可以总结一些东西:
- 进行n-1趟插入
- 关于比较和移动次数取决于初始序列的状态
最好情况下比较次数(即本身就是有序的 且是正的)n-1次
最坏情况比较次数(本身有序 且是倒的)n(n-1)/2次
void InsertSort(ElemType A[],int n){
int i,j;
for(i=2;i<=n;i++){
if(A[i]<A[i-1]){
A[0]=A[i];
for(j=i-1;A[0]<A[j];--j)A[j+1]=A[j];
A[j+1]=A[0];
}
}
}
其时间复杂度
空间复杂度O(1)
元素相对顺序没变
稳定的排序算法 适用于顺序存储和链式存储
再看折半插入排序
本质就是折半查找先找到位置然后再拆入移动排序
不过多阐述
void BinsertSort(ElemType A[],int n){
int i,j,low,high,mid;
for(i=2;i<=n;i++){
A[0]=A[i];
low=1;high=i-1;
while(low<=high){
mid=(low+high)/2;
if(A[mid]>A[0]) high=mid-1;
else low=mid+1;
}
for(j=i-1;j>=high+1;--j)
A[j+1]=A[j];
A[high+1]=A[0];
}
}
其时间复杂度 减少了关键字的比较次数
但移动次数未改变 仍为O(n²)
空间复杂度也是O(1)
稳定的排序算法 仅适用于顺序存储
比较重要的希尔排序
核心:每趟排序依靠缩小增量排序
具体是个怎么样的过程
通过一道题来分析

增量序列就是d={5,3}我们看具体怎么弄的
d=5的时候 第一趟
d=3 第二趟
这样我们就充分理解了过程是怎样的希尔排序
其实就是增量选好对比的 然后内部还是直接插入排序
再看一道题
后面那个4次4次3次 我们以SNTU3次为例分析
注意注意 不是每个都要去比较 而是从后往前比较 大于的话就直接放后面了 因为它都比序列中最大的大了 就没必要再跟之前的比较了 直接插入排序一定要注意这点
void ShellSort(ElemType A[],int n){
int dk,i,j;
for(dk=n/2;dk>=1;dk=dk/2)
for(i=dk+1;i<=n;++i)
if(A[i]<A[i-dk]){
A[0]=A[i];
for(j=i-dk;j>0&&A[0]<A[j];j-=dk)
A[j+dk]=A[j];
A[j+dk]=A[0];
}
}
时间复杂度主要依赖增量序列 时间复杂度为解出
空间复杂度为O(1)
不稳定的排序算法 仅适用于顺序存储
二.交换排序(冒泡 快速)
先看冒泡排序这个真的是见的太多了 无论是算法题还是什么
void BubbleSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
bool flag=false;
for(int j=n-1;j>i;j--){
if(A[j-1]>A[j]){
swap(A[j-1],A[j]);
flag=true;
}
}
if(flag==false) return;
}
}
空间复杂度O(1)
时间复杂度 最好情况下就是有序的 最坏情况就是逆序的
关于其比较次数和移动次数也可以总结出
稳定的排序算法 适用于顺序存储和链式存储 每趟将一个元素放到最终位置
这道题不难就是了解一下冒泡排序的过程 按着来最终得到序列
这个不是考察重点 重点是快速排序
核心就是利用枢轴(基准)来进行划分排序
我们通过一个例题来了解过程
其实就是i从左到右找大于基准的关键字给空的j
j从右到左找小于基准的关键字给空的i
当ij指到同一地方空的放入基准
第一趟得到基准右边都比它大 左边都比它小
然后相同的方法对左右两边进行快速排序
可以看出每趟把枢轴放到最终位置
还有一点就是也可以看出划分是均分的这样其实就是速度最好的
比如这道题
void QuickSort(ElemType A[], int low, int high) {
if (low < high) { //递归跳出的条件
//Partition()就是划分操作,将表A[low...high]划分为满足上述条件的两个子表
int pivotpos = Partition(A, low, high); //划分
QuickSort(A, low, pivotpos - 1); //依次对两个子表进行递归排序
QuickSort(A, pivotpos + 1, high);
}
}
int Partition(ElemType A[], int low, int high) { //一趟划分
ElemType pivot = A[low]; //将当前表中第一个元素设为枢轴,对表进行划分
while (low < high) { //循环跳出条件
while (low < high && A[high] >= pivot) --high;
A[low] = A[high]; //将比枢轴小的元素移动到左端
while (low < high && A[low] <= pivot) ++low;
A[high] = A[low]; //将比枢轴大的元素移动到右端
}
A[low] = pivot; //枢轴元素存放到最终位置
return low; //返回存放枢轴的最终位置
}
其空间借助了栈实现 所以效率取决于层数
时间复杂度我们之前说过了 最坏情况就是基本有序的时候 最好就是划分均匀
不稳定的排序算法 仅适用于顺序存储 是内部排序算法中平均性能最优的
看一些题目补充知识
这道题能充分理解 枢轴的位置来决定最终位置的关键字个数
首先正常之前讲解的基准为头/尾 每一趟确保一个关键字(基准)放到最终位置
如果基准在中间 第一趟就还是一个 但是此时划分为两个序列 第二趟两个子序列分别划分一次 至少三个确定最终位置两趟
三.选择排序(简单选择 堆)
选择排序的思想就是每趟从后面挑出一个最小的元素加入有序序列里
先看简单选择排序 顾名思义 很简单
void SelectSort(ElemType A[], int n) {
for (int i = 0; i < n - 1; i++) { //共进行 n-1 趟
int min = i; //记录最小元素位置
for (int j = i + 1; j < n; j++) //在 A[i...n-1] 中选择最小元素
if (A[j] < A[min]) min = j; //更新最小元素位置
if (min != i) swap(A[i], A[min]); //swap() 函数共移动元素 3 次
}
}
每趟可以确定一个元素的最终位置
空间复杂度O(1)
比较次数和初始状态无关
时间复杂度无论好坏都是O(n²)
不稳定的排序算法 适用于顺序存储和链式存储 适合关键字少的情况
重点来看堆排序
分为:
- 大顶堆
- 小顶堆
堆是一棵完全二叉树
这就是一个大根堆 堆顶元素最大 然后每个元素>其孩子结点
小顶堆类比相反即可
主要考察的涉及:
- 堆的构建
- 堆的插入操作
- 堆的删除操作
我们一个一个研究探讨
先看堆的构建 我们通过题目来看
初始从空开始构建堆
从空开始插入 每一步出现情况都要调整
插入的时候就是挨个插 就是往堆末端插 然后再调整
这是已经有了堆了 然后调整
关于调整如图就从i=向下取整n/2开始 然后-1往前调整 最终自上而下筛选调整一下有问题的
建立大根堆算法
void BuildMaxHeap(ElemType A[], int len) {
for (int i = len / 2; i > 0; i--) //从最后一个分支结点到根结点
HeapAdjust(A, i, len); //反复调整子树
}
void HeapAdjust(ElemType A[], int k, int len) {
//对以 k 为根的子树进行调整
A[0] = A[k]; //A[0]暂存子树的根结点
for (int i = 2 * k; i <= len; i *= 2) { //沿关键字较大的子结点向下筛选
if (i < len && A[i] < A[i + 1])
i++; //取关键字较大的子结点的下标
if (A[0] >= A[i]) break; //筛选结束
else {
A[k] = A[i]; //将较大结点上移
k = i; //修改 k 值,以便继续向下筛选
}
}
A[k] = A[0]; //被筛选结点的值放入最终位置
}
void HeapSort(ElemType A[], int len) {
BuildMaxHeap(A, len); //初始建堆
for (int i = len; i > 1; i--) { //执行 n-1 趟排序
Swap(A[i], A[1]); //输出堆顶元素(与堆底元素交换)
HeapAdjust(A, 1, i - 1); //把剩余 i-1 个元素重新整理为堆
}
}
关于堆的插入 前面有所涉及 就是先将新元素放到堆末端 然后自下而上检查即可
13本来就比25小了不用比了 上来的18跟堆顶25比一下就行
堆的删除操作也不难:就一点删一次 删的是堆顶元素 然后把堆底元素移到堆顶 然后从上向下调整
第一次删
第二次删
注意删一次调整好了 再删
同样每趟将一个元素(堆顶)放入最终位置
空间复杂度O(1)
时间复杂度
建堆的时间复杂度为O(n)
不稳定的排序算法 仅适用于顺序存储
看一到综合题
这个很明显n的数据量很大 所以用堆排序最合适不过
它要求的还是最小的10个数 那么就采用大根堆就弄10个元素
然后从这些数据量中加入 就是初始构造堆然后插入

条件就是<堆顶 加入堆 最后这个含10个元素的大根堆就是要求的
时间复杂度就是构建堆的时间O(n) 空间复杂度O(1)
四.归并排序
两个或多个有序序列合并成为一个更长的有序序列
比如这章重点介绍的就是两两归并 因此叫做2路归并排序
搞懂归并排序 核心就是搞懂怎么归并的
通过函数Merge()实现:
- 1.将两个有序子表合并成一个有序表
- 2.将所有元素复制到辅助数组B中
- 3.设置两个指针分别指向B中两段起始位置 依次比较
- 4.较小者放入数组A中 当一段所有元素复制完停止比较 将另一端剩余的直接复制到A中
Merge()函数实现
ElemType *B = (ElemType *)malloc((n+1)*sizeof(ElemType)); //辅助数组 B
void Merge(ElemType A[], int low, int mid, int high) {
//表 A 的两段 A[low...mid] 和 A[mid+1...high] 各自有序,将它们合并成一个有序表
int i, j, k;
for (k = low; k <= high; k++)
B[k] = A[k]; //将 A 中所有元素复制到 B 中
for (i = low, j = mid + 1, k = i; i <= mid && j <= high; k++) {
if (B[i] <= B[j]) //比较 B 的两个段中的元素
A[k] = B[i++]; //将较小值复制到 A 中
else
A[k] = B[j++];
}
while (i <= mid) A[k++] = B[i++]; //若第一个表未检测完,复制
while (j <= high) A[k++] = B[j++]; //若第二个表未检测完,复制
}
注意 归并排序比较次数的数量级和初始状态无关
看题理解过程
按照Merge()合并的方式
然后继续合并
共14次
注意这是归并排序 需要按照Merge()函数的方式进行推算归并
继续合并
掌握后并不难
看伪代码实现
// 2 路归并排序的递归实现如下:
void MergeSort(ElemType A[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2; //从中间划分两个子序列
MergeSort(A, low, mid); //对左侧子序列进行递归排序
MergeSort(A, mid + 1, high); //对右侧子序列进行递归排序
Merge(A, low, mid, high); //归并
}
}
借助了辅助数组 所以空间复杂度为O(n)
时间复杂度无论最坏最好平均情况都是看归并递归趟数
基于分治策略 稳定的排序算法 使用于顺序存储和链式存储
五.基数排序
不依赖元素间的比较
基于关键字各位的大小进行排序
分成两种:
- MSD 最高位优先法:按关键字权重递减依次逐层划分为更小的子序列 最后再依次连接成一个有序序列
- LSD 最低位优先法:按关键字权重递增依次逐层划分为更小的子序列 最后再依次连接成一个有序序列
通常采用链式基数排序 其实就是三步走:
- 找基数r
- r个队列链
- 分配收集(MSD/LSD)
同样我们通过一道题目来理解过程
我们以LSD最低位优先法来做过程
第一趟 以个位开始
得到序列 110 120 911 122 114 007 119
第二趟 以十位
得到序列 007 110 911 114 119 120 122
第三趟 也是最后 以百位
过程很简单就注意区分以LSD还是MSD法划分
然后就是下一趟以上一趟划分的序列为初始进行划分
基数r就是队列长
接住了辅助空间 所以空间复杂度为O(r)
时间效率和序列初始状态无关 无论好坏平均时间复杂度取决于趟数和元素个数n还有r个队列
稳定的排序算法 适用于顺序存储和链式存储
没完 看一道综合题
第一题纯粹就是分析代码 在第一题分析的过程中其实就可以搞懂这是个什么排序
代入来看看过程 先是i=0
然后i=1
i=2
初见端倪了 其实一般推三个就出来规律了
发现count加的其实就是比它小的时候 比如这个count[2]=4就是 a[2]比四个大
count就是计数排序
因为前面并未过多涉及计数排序所以这道题参考答案 随后综合题的练习再会总结

计数排序的思想:对每个待排元素 统计小于其的元素个数 从而确定它在有序序列中的位置
若有n个元素小于它 那么它就排在第n+1位
六.外部排序
先看一下前面内部排序的总结 图省事直接拿过来了
前面介绍的都是内部排序 一次性将数据存在内存里
当遇到大规模文件时且涉及内外存数据交换时 就需要依赖外部排序
外部排序的时间代价主要取决于访问磁盘I/O的次数
方式如下:
- 1.生成初始归并段(置换-选择排序)
- 2.多路归并
增大归并路数可有效减少归并趟数 从而显著降低总的磁盘I/O次数
我们搞懂三个东西即可:
- 置换-选择排序
- 多路平衡归并和败者树
- 最佳归并树
1.置换-选择排序(生成初始段)
减少初始段个数可以减少归并趟数
那就利用置换-选择算法将初始段生成的长点 从而减少初始段个数
我们以一个例题来看这个算法的流程
- 三个区 FO输出区 WA工作区 FI输入区
- 从工作区选出关键字最小的记录 作为MINIMAX记录
- 将MINIMAX记录写入输出区 并将输入区下一个记录放到工作区
- 在工作区中的 不小于之前放入输出区的MINIMAX记录 选出最小者成为新的MINIMAX记录
- 重复步骤 直到输入区和工作区均空 生成初始归并段
比如对文件{17 21 05 44 10 12 56 32 29}生成初始段
当56放入输出区 此时工作区不再有比56大或等于的数 这个时候就重新找MINIMAX记录
注意到56加入输出区已经是一段了 后面是新的一段
2.多路平衡归并和败者树
增大归并路数可减少归并趟数
引入败者树是为了使内部归并时间复杂度不受K增大影响
关于败者树我们需要了解一下:
- 可视为一棵完全二叉树
- 内部归并比较次数和归并路数无关
- 归并路数并非越大越好
- 败者代表的是关键字较大者 胜者继续向上比较
- 败者段号存于父节点LS[]中 最终胜者段存于LS[0]中
用一个例子来看
我们看这棵败者树怎么变的
看道题
3.最佳归并树
前面置换-选择算法生成初始段后
这些初始归并段长度不等 最佳归并树就是来是这些归并段得到一定归并顺序从而减少I/O次数
利用的思想核心:优先归并长度较小的段
其实使用的还是哈夫曼编码思想构造树
有时我们需要引入长度为0的虚段来辅助构造最佳归并树
比如有5个结点 构造3路平衡归并树
什么时候添加虚段同样有规则:

关于这些关系:

足够做题了
看题理解考察内容
需要的虚段 按照之前的规则即可
按照之前的公式
这道题涉及最佳归并树的构建和虚段 比较经典
虚段长度为0
再看一道综合题

这道题就是用知识置换-选择排序(生成初始段)
来吧第一题

不难按照之前的推法即可 注意:选出最小记录加入输入FO区 随后选出工作区不小于其的最小关键字加入 当工作区不再存在输出区此时为一个段了 然后再从新开始以此类推
第二题用例子一想即可

最大为n 就是升序元素 然后都干第一个段里了

当降序WA大于FI区的时候 无论FI怎么往WA区里加 FO第一段的情况就是一直重新找最小值
所以为m
数据结构的总结到此为止 408的基础总结知识也到此结束
整个过程是较漫长的 但极其有成长的 到此还没有结束 因为从来没有真正彻底掌握这些计算机知识 所以后面会有更加深入的总结 以考题来总结复习这些知识点 届时会再来借用这些总结 学习永远不会停下 5.23完结杀青!!!
参考:王道考研
参考:AI
参考:408铺子https://www.csgraduates.com/

































































浙公网安备 33010602011771号