分节目录
数据结构(完结)
数据结构Part1 绪论与线性表
数据结构Part2 栈和队列
数据结构Part3 串
数据结构Part4 树与二叉树
数据结构Part5 图
数据结构Part6 查找
数据结构Part7 排序
第八章 排序
数据结构可视化:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
1.排序的基本概念
1.1 基本概念
排序(Sort),就是重新排列表中改元素,使表少的元素满足按关键字有序的过程。
输入∶n个记录R1, R2… Rn,对应的关字为k1, k2,…, kn。
输出∶输入序列的一个重排R1,R2 …,Rn’,使得有k2’≤k2’≤…≤kn’(也可递减)
排序算法的指标:
时间复杂度,空间复杂度
算法的稳定性:若待排序表中有两个元素Ri和Rj,其对应的关键字相同即keyi= keyj,且在排序前Ri在Rj的前面,若使用某一排序算法排事后。R仍然在R的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。
内部排序:数据都在内存中,关注时/空间复杂度;
外部排序:数据太多,无法全部放入内存,关注时/空间复杂度与读/写磁盘的次数。
2.插入排序
2.1 直接插入排序
2.1.1 算法实现
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
//直接插入排序
void InsertSort(int *A, int n){
int i, j, temp;
for(i=1; i<n; i++){ //将各元素插入已排好序的序列中
if(A[i]<A[i-1]){ //若A[i]关键字小于前驱
temp=A[i]; //用temp暂存A[i]
for(j=i-1; j>=0 && A[j]>temp; --j){ //检查所有前面已排好序的元素
A[j+1]=A[j]; //所有大于temp的元素都向后挪位
}
A[j+1]=temp; //复制到插入位置
}
}
}
//带哨兵的直接插入排序
void InsertSort(int *A, int n){
int i,j;
for( i=2;i<=n; i++){ //依次将A[2]~A[n]插入到前面已排序序列
if(A[i]<A[i-1] ){ //若A[i]关键码小于其前驱,将A[i]插入有序表
A[0]=A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1; A[0]<A[j]; --j){ //从后往前查找待插入位置
A[j+1]=A[j]; //向后挪位
}
A[j+1]=A[O]; //复制到插入位置
}
}
}
2.1.2 性能分析
平均时间复杂度 | O(n) | 备注 |
---|---|---|
最好的时间复杂度 | O(n) | 全部有序 |
最坏的时间复杂度 | O(n2) | 全部逆序 |
稳定性 | 稳定 | 相等不移动 |
2.2 折半插入排序
算法思想:先用折半查找找到应该插入的位置,再移动元素。
当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到 low所指位置。
当A[mid]==A[0]/temp时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置
//折半插入排序
void InsertSort(int *A ,int n){
int i, j, low, high, mid;
for(i=2; i<=n; i++){ //依次将A[2]~A [n]插入前面的已排序序列
A[0]=A[i]; //将A[i]暂存到A[0]
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]; //插入操作
}
}
2.3 希尔排序
2.3.1 算法思想
希尔排序︰先追求表中元素部分有序,再逐渐逼近全局有序;先将待排序表分割成若干形如L[i,i + d,i+ 2*d…, i + k*d]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量重复上述过程,直到d=1为止。
出题方式:给定序列与d,求操作后的状态
//希尔排序
void ShellSort(int *A, int n){
int d, i,j; //A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for(d=n/2; d>=1; d=d/2){ //步长变化
for(i=d+1; i<=n; ++i){
if(A[i]<A[i-d] ){ //需将A[i]插入有序增量子表
A[0]=A[i]; //暂存在A[0]
for(j= i-d; j>0 && A[0]<A[j]; j-=d){
A[j+d]=A[j]; //记录后移,查找插入的位置
}
A[j+d]=A[0]; //插入
}
}
}
}
2.3.2 性能分析
仅适用与顺序表
空间复杂度 | O(1) | 常数个 |
---|---|---|
最好的时间复杂度 | 未知 | 可达O(n1.3) |
最坏的时间复杂度 | O(n2) | d取1 |
稳定性 | 不稳定 | 相等可能移动 |
3.交换排序
基于“交换”的排序︰根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
3.1 冒泡排序
3.1.1 算法思想
从后往前(或从前往后)两两比较相邻元素的值,若为逆序〈即A[i-1]>A[i]),则交换它们,直到序列比较完。称这样过程为“—趟”冒泡排序。没趟排序确定一个元素的位置,若一趟排序中未发生交换,则排序结束。
//交换
Void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
//冒泡排序
void BubbleSort(int *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; //本趟遍历后未发生交换,表示已经有序
}
}
}
3.1.2 性能分析
仅适用与顺序表
空间复杂度 | O(1) | 常数个 |
---|---|---|
最好的时间复杂度 | O(n) | 有序 |
最坏的时间复杂度 | O(n2) | 逆序 |
稳定性 | 稳定 | 相等不移动 |
注意:一次交换有三次操作,一次比较有一次操作。
3.2 快速排序
3.2.1 算法思想
在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
//用第一个元素将待排序序列划分成左右两个部分
int Partition(int *A, int low, int high){
int pivot=A[low]; //第一个元素作为枢轴
while(low<high){ //用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 QuickSort(int *A, int low, int high){
if(low<high){ //递归跳出的条件
int pivotpos=Partition(A,low,high); //划分
QuickSort(A,low , pivotpos-1); //划分左子表
QuickSort(A, pivotpos+1,high); //划分右子表
}
}
3.2.2 性能分析
最好的空间复杂度 | O(log2 n) | 递归层数 |
---|---|---|
最坏的空间复杂度 | O(n) | 本身有序 |
最好的时间复杂度 | O(n*log2 n) | 每次都均匀划分 |
最坏的时间复杂度 | O(n2) | 本身有序(顺/逆) |
平均时间复杂度 | O(n*log2 n) | 最快 |
稳定性 | O(log2 n) | 不稳定 |
快速排序算法优化思路:尽量选择可以把数据中分的枢轴元素。
例如:选头、中、尾三全位置的元素,取中间值作为枢轴元素;或者随机选一个元素作为枢轴元素。
4.选择排序
选择排序︰每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列。
4.1 简单选择排序
4.1.1 算法思想
假设排序表为L[1…n],第i趟从L[i…n]中选择关键字最小的元素与L[i]交换,每一堂排序可以确定一个元素的位置。
//交换
Void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
//简单选择排序
void Selectsort(int *A,int n){
for(inti=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次
}
4.1.2 性能分析
空间复杂度 | O(1) | 常数个 |
---|---|---|
时间复杂度 | O(n2) | 不变 |
稳定性 | 不稳定 |
注意:一次交换有三次操作,一次比较有一次操作。
4.2 堆排序(难点,常考)
4.2.1 堆
若n个关键字序列L[ 1…n]满足下面某一条性质,则称为堆(Heap) :
大根堆(大顶堆)︰L(i) ≥ L(2i) 且 L(i )≥ L(2i+1) (1 ≤ i ≤ n/2 )
小根堆(小顶堆)︰L(i) ≤ L(2i) 且 L(i) ≤ L(2i+1) (1 ≤ i ≤ n/2 )
回忆:完全二叉树的线性存储
将序列在逻辑上看作是一个完全二叉树的线性存储:
大根堆:完全二叉树中,根 >= 左,右;小根堆:完全二叉树中,根 <= 左,右。
4.2.2 建立大根堆
算法思想:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。检查当前结点是否满足根≥左、右,若不满足,将当前结点与更大的一个孩子互换;若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠")
//建立大根堆
void BuildMaxHeap(int A[] ,int len){
for(int i=len/2; i>0; i--) //从后往前调整所有非终端结点
HeadAdjust(A,i,len);
}
//将以k为根的子树调整为大根堆
void HeadAdjust(int A[] ,int k,int len){
A[0]=A[k]; //A[0]暂存子树的根结点
for(int i=2*k; i<=len; i*=2){ //取key较大的子结点向下筛选
if(i<len&&A[i]<A[i+1])
i++; //取key较大的子结点的下标
if(A[0]>=A[i]) break; //筛选结束
else{
A[k]=A[i]; //将A[i]调整到双亲结点上
k=i; //修改k值,以便继续向下筛选
}
}
A[k]=A[0]; //被筛选结点的值放入最终位置
}
4.2.3 基于大根堆排序
堆排序( 基于大根堆)︰每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换),并将待排序元素序列再次调整为大根堆(小元素不断“下坠”),最终形成一个递增序列。
//堆排序的完整逻辑
void HeapSort(int A[] ,int len){
BuildMaxHeap(A,len); //初始建堆
for(int i=len; i>1; i--) //n-1趟的交换和建堆过程
swap(A[i],A[1]); //堆顶元素和堆底元素交换
HeadAdjust(A,1,i-1); //把剩余的待排序元素整理成堆
}
}
4.2.4 性能分析
考虑**BuildMaxHeap()**函数,一个结点,每“下坠”一层,最多只需对比关键字2次,若树高为h,某结点在第i层,则将这个结点向下调整最多只需要“下坠" h-i层,关键字对比次数不超过2(h-i)。
n个结点的完全二叉树树高h=[log2 n] +1;
第i层最多有2i-1个结点,而只有第1~(h-1)层的结点才有可能需要“下坠"调整
将整棵树调整为大根堆,关键字对比次数不超过4n;
∑
i
=
1
h
−
1
2
i
−
1
∗
2
(
h
−
i
)
=
∑
i
=
1
h
−
1
2
i
(
h
−
i
)
=
∑
j
=
1
h
−
1
2
h
−
j
∗
j
≤
2
n
∑
j
=
1
h
−
1
j
2
j
≤
4
n
\sum_{i=1}^{h-1} 2^{i-1}*2(h-i) = \sum_{i=1}^{h-1} 2^{i}(h-i)=\sum_{j=1}^{h-1} 2^{h-j}*j\le2n\sum_{j=1}^{h-1}\frac {j} {2^j}\le4n
i=1∑h−12i−1∗2(h−i)=i=1∑h−12i(h−i)=j=1∑h−12h−j∗j≤2nj=1∑h−12jj≤4n
建堆的过程,关键字对比次数不超过4n,建堆时间复杂度=O(n)
根节点最多“下坠”h-1层,每下坠一层
而每“下坠”一层,最多只需对比关键字2次,因此每一趟排序复杂度不超过O(h)= O(log2 n)
共n-1趟,总的时间复杂度为O(nlog2 n)
空间复杂度 | O(1) | 常数个 |
---|---|---|
时间复杂度 | O(nlog2 n) | 不变 |
稳定性 | 不稳定 |
4.2.5 在堆中插入和删除
插入:新元素放于表尾,然后调用HeadAdjust函数使新元素上升至无法上升为止;
删除:用表尾元素代替被删除元素,然后调用HeadAdjust函数使该素下降至无法下降为止;
注意:调整的过程中对比关键字的次数。
5.归并排序和基数排序
5.1 归并排序
归并:把两个或多个已经有序的序列合并成一个,合并n个序列称为n路归并,每选出一个元素需要对比关键字n-1次
5.1.1 算法思想
将两个有序序列合并为一个有序序列
int *B=(int *)malloc(n*sizeof(int)); //辅助数组B
//A [ low...mid ]和A[ mid+1...high]各自有序,将两个部分归并
void Merge(int A[], int low, int mid, int 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])
A[k]=B[i++]; //将较小值复制到A中
else
A[k]=B[j++];
}//for
while(i<=mid) A[k++]=B[i++];
while(j<=high) A[k++]=B[j++];
}
void MergeSort(in A[], int low,int high){
if(low<high){
int mid=(lowthigh)/2; //从中间划分
MergeSort(A, low, mid); //对左半部分归并排序
MergeSort(A, mid+1, high); //对右半部分归并排序
Merge(A, low, mid, high); //归并
}//if
}
5.1.2 性能分析
二路归并的“归并树”:形态上就是一颗倒立的二叉树
空间复杂度 | O(n) | 复制一份 |
---|---|---|
时间复杂度 | O(nlog2 n) | 不变 |
稳定性 | 稳定 |
5.2 基数排序
5.2.1 算法思想
假设长度为n的线性表中每个节点aj的关键字由d元组(kd-1j, kd-2j, kd-3j, …, k1j, k0j)
其中,0 ≤ kij ≤r-1 (0 ≤ j < n, 0 ≤ i ≤ d-1), r称为基数
基数排序得到递减序列的过程如下
初始化:设置r个空队列,Qr-1,Qr-2,…,Q0
按照各个关键字位 权重递增的次序(个、十、百),对d个关键字位分别做“分配”和“收集”
分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入Q队尾
收集:把Qr-1,Qr-2,…,Q0各个队列中的结点依次出队并链接
常考:手算
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode, *LinkList;
typedef struct{ //链式队列
LinkNode *front, *rear; //队列的对头和队尾指针
}LinkQueue;
5.2.2 性能分析
空间复杂度 | O® | r个辅助队列 |
---|---|---|
时间复杂度 | O(d(n+r)) | 一趟分配O(n) 一趟收集O® 总共d趟分配、收集 |
稳定性 | 稳定 |
基数排序擅长解决的问题:
i.数据元素的关键字可以方便地拆分为d组,且d较小
ii.每组关键字的取值范围不大,即r较小
iii.数据元素个数n较大
6.外部排序
6.1 外存&内存之间的数据交换
操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB,各个磁盘块内存放着各种数据;
磁盘的读/写是以“块”为单位的,数据读入内存后才能被修改,修改完了再写回磁盘。
6.2 外部排序的方法
外部排序:数据元素太多,无法依次全部读入内存进行排序,通常采用归并排序的方法。使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大小的文件进行排序。
步骤:
i.根据内存缓冲区大小,将外存上的文件分成若干个长度为l的子文件,依次读入内存并利用内部排序算法进行排序,将得到的有序子文件重新写会外存中,称这些有序子文件为归并段或顺串;
ii.对这些归并段进行逐趟归并,使归并段逐渐由小到大,直至得到整个有序文件位置。
注意:设内存中设置了两个输入缓冲区input_1, input_2,一个输出缓冲区output,且长度均为n。当归并段长度大于n时,假设归并段A[2n],B[2n]。
首先将A[1, n]与B[1, n]分别读入input_1, input_2,并将归并的结果存入output:
当output满时将output中得内容全部写入到外存中;当input_1或input_2有一个为空时,假设input_1为空,需要将A[n+1, 2n]读入input_1后,再继续进行归并。
优化的方向:减少归并趟数,对r个初始归并段,做k路归并,则归并树可用k叉树表示,若树高为h,则归并趟数=h-1 = 向上取整(logk r),由此可知k叉树第h层最多有kh-1个结点则r≤kh-1,(h-1)最小= 向上取整(logk r)
因此优化的方法有两个:增大k(多路归并),减小r(增加初始归并段的长度)
多路归并带来的负面影响:
i.在k路归并中,需要开辟k个输入缓冲区,内存开销增加;
ii.每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加。
6.3 多路平衡归并与败者树
k路平衡归并:
i.最多只能有k个段归并为一个;
ii.每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到[m/k]个新的归并段;
败者树:可视为—个完全二叉树(多了一个根结点 )。k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
对于k路归并,第一次构造败者树需要对比关键字k-1次,有了败者树,选出最小元素,只需对比关键字「log2 k⌉次(败者树的高度)
实际上败者树只右上方灰色的结点构成,下方绿色节点即为每个归并段当前队段首的元素。
6.4 置换选择排序
设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳w个记录。置换-选择算法的步骤如下:
i.从FI输入w个记录到工作区WA。
ii.从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
iii.将MINIMAX记录输出到FO中去。
iv.若FI不空,则从FI输入下一个记录到WA中。
v.从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX记录。
vi.重复iii~v,直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。
vii.重复ii~vi,直至WA为空。由此得到全部初始归并段。
6.5 最佳归并树
二路归并的情况下:选最小的两个归并段进行归并(哈夫曼树)。
归并树得带全路径长度WPL=读/写磁盘的次数
多路归并二路归并类似,不过要注意的是,对于k叉归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的“虚段”,再进行k叉哈夫曼树的构造。
k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。设度为k的结点有n个,度为0的结点有n0个,归并树总结点数=n 则:
初始归并段数量 + 虚段数量 = n0 (叶子节点的数量)
{
n
=
n
0
+
n
k
,
只有度数为0和k的节点
k
n
=
n
−
1
,
树的度数和为n-1
−
>
n
0
=
(
k
−
1
)
n
k
+
1
−
>
n
k
=
n
0
−
1
k
−
1
\begin{cases} n = n_0 + n_k, & \text {只有度数为0和k的节点} \\ kn=n-1, & \text{树的度数和为n-1} \end{cases}\quad->\quad n_0=(k-1)n_k+1\quad->\quad n_k=\frac{n_0-1}{k-1}
{n=n0+nk,kn=n−1,只有度数为0和k的节点树的度数和为n-1−>n0=(k−1)nk+1−>nk=k−1n0−1
i.若(初始归并段数量-1)%(k-1)= 0,说明刚好可以构成严格k叉树,此时不需要添加虚段
ii.若(初始归并段数量-1) %(k-1)= u ≠ 0,则需要补充(k-1)- u个虚段。