数据结构笔记八:排序
排序
排序的基本概念
排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程
排序算法的评价指标
- 时间复杂度,空间复杂度
- 算法的稳定性。若待排序中有两个元素\(R_i\)和\(R_j\),其对应的关键字相同即\(key_i=key_j\),且在排序前\(R_i\)在\(R_j\)的前面,若使用某一排序算法排序后,\(R_i\)仍然在\(R_j\)的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。
排序算法的分类
- 内部排序——数据都在内存中
- 外部排序——数据太多,无法全部放入内存
插入排序
算法思想
每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
//直接插入排序
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;i>=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++) //将各元素插入已排好序的序列中
{
if(A[i]<A[i-1]) //若A[i]关键字小于前驱
{
A[0]=A[i]; //复制为哨兵,A[0]不存发元素
for(j=i-1;A[0]<A[j];--j) //检查所有前面已排好序的元素
A[j+1]=A[j]; //所有大于temp的元素都向后挪位
A[j+1]=A[0]; //复制到插入位置
}
}
}
空间复杂度:\(O(1)\)
最好时间复杂度(全部有序):\(O(n)\)
最坏时间复杂度(全部逆序):\(O(n^2)\)
平均时间复杂度:\(O(n^2)\)
算法稳定性:稳定
优化——折半插入排序
先用折半查找找到应该插入的位置,再移动元素
当low>high
时折半查找停止,应将[low,i-1]
内的元素全部右移,并将A[0]
复制到low
所指位置;
当A[mid]==A[0]
时,为了保证算法的“稳定性”,应继续在mid
所指位置右边寻找插入位置
//折半插入排序
void InsertSort(int A[],int n)
{
int i,j,low,high,mid;
for(i=2;i<n;i++) //将各元素插入已排好序的序列中
{
A[0]=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]; //插入操作
}
}
比起"直接插入排序",比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是\(O(n^2)\)
对链表进行插入排序
移动元素的次数变少了,但是关键字对比的次数依然时\(O(n^2)\)数量级,整体来看时间复杂度依然是\(O(n^2)\)
希尔排序
算法思想
先将待排序表分割成若干形如\(L[i,i+d,i+2d,...i+kd]\)的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量\(d\),重复上述过程,直到\(d=1\)为止
//希尔排序
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]; //插入
}
}
}
}
算法性能分析
空间复制度:\(O(1)\)
时间复杂度:最坏时间复杂度\(O(n^2)\),当n在某个范围内时,可达\(O(n^{1.3})\)
稳定性:不稳定
适用性:仅适用于顺序表,不适用于链表
冒泡排序
算法思想
从后往前(从前往后)两两比较相邻的元素,若为逆序(即A[i-1]>A[i]
),则交换他们,直到序列比较完。称这样过程为“一趟”冒泡排序,总共需进行\(n-1\)趟冒泡
//交换
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; //若本趟遍历后没有发生交换,说明表已经有序
}
}
算法性能分析
空间复制度:\(O(1)\)
时间复杂度:
- 有序:最好时间复杂度\(O(n)\)
- 逆序:最坏时间复杂度\(O(n^2)\)
- 平均:时间复杂度\(O(n^2)\)
稳定性:稳定
适用于链表
快速排序
算法思想
在待排序表\(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)
{
while(low<high&&A[high]>=pivot)
--high;
A[low]=A{high]; //比枢轴小的元素移动到左端
while(low<high&&A[low]<=pivot)
++low;
A[low]=A{high]; //比枢轴小的元素移动到左端
}
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); //划分右子表
}
}
算法效率分析
若每一次选中的“枢轴”将待排序序列划分为均匀的两部分,则递归深度最小,算法效率最高。
快递排序算法优化思路:尽量选择可以把数据中分的枢轴元素
- 选头,中,尾三个位置的元素,取中间值作为枢轴元素
- 随机选一个元素作为枢轴元素
稳定性:不稳定
简单选择排序
算法思路
每一趟在待排序元素中选取关键字最小的元素加入有序子序列
//简单选择排序
void SelectSort(int A[],int n)
{
for(int i=0;i<n-1;i++)
{
int min=i;
for(int j=i+1;j<n;i++) //在A[i...n-1]中选择最小的元素
if(A[j]<A[mid])
min=j;
if(min!=j)
swap(A[i],A[mid]);
}
}
算法性能分析
空间复制度:\(O(1)\)
时间复杂度:\(O(n^2)\)
稳定性:不稳定
适用于链表
堆排序
堆
若n个关键字序列\(L[1...n]\)满足下面某一条性质,则称为堆(Heap)
- 若满足:\(L(i)\ge L(2i)\)且\(L(i)\ge L(2i+1)\) \((1\le i\le n/2)\)——大根堆(大顶堆)(\(根\ge 左、右)\)
- 若满足:\(L(i)\le L(2i)\)且\(L(i)\le L(2i+1)\) \((1\le i\le n/2)\)——**小根堆(小顶堆)(\(根\le 左、右)\)
大根堆(堆排序)
//建立大根堆
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[j]调整到双亲结点上
k=i; //修改k值,以便继续向下筛选
}
}
A[k]=K[0]; //被筛选结点的值放入最终位置
}
基于大根堆进行选择排序
//堆排序
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); //把剩余的待排序元素整体成堆
}
}
堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换),并将待排序元素序列再次调整为大根堆(小元素不断“下坠”)
算法效率分析
结论:一个结点,每“下坠”一层,最多只需对比关键字两次
若树高为h,某结点在第i层,则将这个结点向下调整最多最多只需“下坠”\(h-i\)层,关键字对比次数不超过\(2(h-i)\)
第I层最多有\(2^{i-1}\)个结点,而只有第\(i\)~\((h-1)\)层的结点才有可能需要“下坠”调整
将整棵树调整为大根堆,关键字对比次数不超过\(\sum_{i=h-1}^12^{i-1}2(h-i)\le \sum_{j=1}^{h-1}\frac{j}{2^j}\le4n\)
建堆的过程,关键字对比次数不超过4n,建堆时间复杂度=\(O(n)\)
根节点最多“下坠”\(h-1\)层,每“下坠”一层
每“下坠”一层,最多只需对比关键2次,每一趟排序算法不超过\(O(h)=O(log_2n)\)
共\(n-1\)趟,总的时间复杂度=\(O(nlog_2n)\)
时间复杂度:\(O(n)+O(nlog_2n)\)
空间复制度:\(O(1)\)
稳定性:不稳定
堆的插入删除
在堆中插入新元素
对于小根堆,新元素放到表尾,与父节点对比,若新元素比****父节点更小**,则将二者互换。新元素就这样一路”上升“,直到无法继续上升为止。
在堆中删除元素
被删除元素用堆底元素替代,然后让孩子元素不断”下坠“,直到无法下坠为止。
归并排序
算法思想
归并:把两个或多个已经有序的序列合并成一个
m路归并,每选出一个元素需要对比关键字\(m-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++];
}
while(i<=mid) A[k++]=B[i]++;
while(j<=high) A[k++]=B[j]++;
}
void MergeSort(int 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); //归并
}
}
算法效率分析
二叉树的第h层最多右\(2^{h-1}\)个结点,若树高为h,则应满足\(n\le2^{h-1}\),即\(h-1=\lceil log_2n \rceil\)
结论:n个元素进行2路归并排序,归并趟数\(\lceil log_2n \rceil\)
每趟归并时间复杂度为\(O(n)\),则算法时间复杂度为\(O(nlog_2n)\)
空间复杂度为\(O(n)\),来自辅助数组B
稳定性:稳定
基数排序
算法思想
假设长度为n的线性表中每个结点\(a_j\)的关键字由d元组(\(k_j^{d-1},k_j^{d-2},k_j^{d-3},...,k_j^{1},k_j^{0}\))组成
其中\(0\le k_j^{d-1}\le r-1(0\le j<n,0\le i\le d-1)\),r称为“基数”
基础排序得到递增序列的过程如下:
- 初始化:设置r个空队列,\(Q_0,Q_1,...,Q_{r-1}\)
- 按照各个关键字权重递增的次数(个,十,百),对d个关键字位分别做”分配“和”收集“
- 分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入\(Q_x\)队尾
- 收集:把\(Q_0,Q_1,...,Q_{r-1}\),各个队列中的结点一次出队并链接
算法效率分析
需要r个辅助队列,空间辅助度为\(O(r)\)
一趟分配\(O(n)\),一趟收集\(O(r)\),总共d趟分配和收集,总的时间复杂度为\(O(d(n+r))\)
稳定性:稳定
基数排序的应用
基数排序擅长解决的问题:
- 数据元素的关键字可以方便地拆分为\(d\)组,且\(d\)较小(反例:给5个人的身份证号排序)
- 每组关键字的取值范围不大,即\(r\)较小(反例:给中文人名排序)
- 数据元素个数\(n\)较大(擅长:给十亿人的身份证号排序)
外部排序
外存与内存之间的数据交换
外部排序的原理
时间开销分析
外部排序时间开销=读写外出时间+内部排序所需时间+内部归并所需时间
优化:多路归并
采用多路归并可以归并趟数,从而减少磁盘\(I/0\)(读写次数)
对r个初始归并段,做k路归并,则归并树可以用k叉树表示
若树高为h,则归并趟数\(=h-1=\lceil log_kr \rceil\)
k越大,r越小,归并趟数越少,读写磁盘次数越少
多路归并带来的负面影响:
- k路归并时,需要开辟k个输入缓冲区,内存开销增加
- 每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加
优化:减少初始归并段数量
若能增加初始归并段的长度,则可减少初始归并段数量r
纠正:多路平衡归并
多路平衡归并:
- 最多只能有k个段归并为一个
- 每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到\(\lceil m/k \rceil\)个新的归并片段
败者树
可视为一颗完全二叉树(多了一个头头)。k个叶结点分别时当前参加比较的元素,非叶子结点用来记忆左右子树中的”失败者“,而让胜者往上继续进行比较,一直到根结点。
败者树的使用
多路平衡归并中的应用
有了败者树,选出最小元素,最毒只需对比关键字\(\lceil log_2k\rceil\)
败者树的实现思路
置换-选择排序
设初始待拍文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,AW可容纳\(w\)个记录。置换-选择算法的步骤如下:
- 从FI输入\(w\)个记录到工作区WA
- 从WA中选出其中关键字取最小值的记录,记为\(MINIMAX\)记录
- 将\(MINIMAX\)记录输出到FO中去。
- 若FI不空,则从FI输入下一个记录到WA中
- 从WA中所有关键字比\(MINIMAX\)记录的关键字大的记录中选出最小关键字记录,作为新的\(MINIMAX\)记录
- 重复③到⑤,直至WA中选不出新的\(MINIMAX\)记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中
- 重复②到⑥,直到WA为空,由此得到全部初始归并段。
最佳归并树
归并树的神秘性质
归并过程中的\(磁盘I/O次数=归并树的WPL*2\)
要让磁盘I/O次数最少,就是要使归并树WPL最小——哈夫曼树
对于K叉归并,若初始归并段的数量无法构造严格的k叉归并树,则需要补充几个长度为0的“虚段",再进行k叉哈夫曼树的构造
K叉的最佳归并树一定是一颗严格的k叉树,即树种只包含度为k,度为0的结点。
- 若\((初始归并段数量-1)\%(k-1)=0\),说明刚好可以构成严格k叉树,此时不需要添加虚段
- 若\((初始归并段数量-1)\%(k-1)=u\neq0\),则需要补充\((k-1)-u\)个虚段