排序
排序就是将原本无序的序列重新排列成有序的序列。
- 单独的一个数据
- 多个数据的组合(记录)
- 按记录的主关键字(比如学生信息的学号)来排序
- 按记录的次关键字(比如学生信息的姓名、专业等)来排序
排序的稳定性
如果待排序表中有两个元素Ri、Rj,其对应的关键字keyi=keyj,且在排序前Ri在Rj前面,如果使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。
直接插入排序
直接插入排序:首先以一个元素为有序的序列,然后将后面的元素依次插入到有序的序列中合适的位置直到所有元素都插入有序序列。
void InsertSort(ElemType A[],int n){
int i,j;
for(i=2;i<=n;i++)
if(A[i].key<A[i-1].key){
A[0]=A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1;A[0].key<A[j].key;--j)
A[j+1]=A[j]; //所有比待插入元素值大的都往后移一位,腾出空位
A[j+1]=A[0]; //复制到插入位置
}
}
空间复杂度:在下标为0处存储哨兵,是常数个辅助空间大小,所以空间复杂度为O(1) 时间复杂度:最坏情况下:整个序列都是逆序
分析排序算法稳定性首先要熟悉算法的流程来判断,当结果不明显时可以尝试用具体例子手动模拟过程来得出结论
插入类排序
插入类排序就是在一个有序的序列中,插入一个新的关键字,直到所有的关键字都插入形成一个有序的序列。
插入类排序还包括折半插入排序和希尔排序。
直接插入排序是每次边比较然后边移动元素,直到找到插入的位置。
折半插入排序将比较和移动这两个操作分离出来,也就是先利用折半查找找到插入的位置,然后一次性移动元素,再插入该元素。
void InsertSort(ElemType A[],int n){
int i,j,low,high,mid;
for(i=2;i<=n;i++){ //i记录的是待插入的元素下标,也就是说i-1之前的元素都是有序的
A[0]=A[i]; //保存待插入的值
low=1;high=i-1;
while(low<=high){ //折半查找
mid=(low+high)/2;
if(A[mid].key>A[0].key) 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]; //因为此时high指向的是待插入位置的前一位
}
}
时间复杂度: 折半插入排序相比直接插入排序只是在寻找待插入位置时比较的次数减少(不是一个一个比较),每个待插入元素比较的次数大约在log2n(折半查找判定树),所以n个元素比较操作的时间复杂度为O(nlog2n)。
比较次数和表的初始状态无关,因为比较次数实际是和折半的次数有关系,只要满足low< high,就要不断折半比较,折半一次比较一次。取决于表的长度n但是折半插入排序和直接插入排序在移动关键字的次数方面是一样的(也就是说找到了 具体的插入位置后,还是和直接插入排序一样移动后面的关键字腾出空位)所以折半插入排序的时间复杂度还是O(n2)。
稳定性:和直接插入排序稳定性相同,是稳定的。
希尔排序
希尔排序又称为缩小增量排序 希尔排序的基本思想:希尔排序本质上还是插入排序,只不过是把待排序序列分成几个子序列,再分别对这几个子序列进行直接插入排序。
希尔排序的优势: 希尔排序的每一轮都会使整个序列变得越来越有序,最后一轮当增量为1的时候,整个序列几乎都是有序的,所以进行直接插入排序会提高排序的效率。
void ShellSort (ElemType A[],int n){
int i,j;
for(dk=n/2;dk>=1;dk=dk/2) //初始增量为总长度的一半,之后依次除2且向下取整,
//且最后一次要为1
for(i=dk+1;i<=n;++i)
if(A[i].key<A[i-dk].key){ //A[i].key是待插入的关键字,i-dk之前的都是有序的,如 //果待插入的比有序序列最后一个小, 则需要进行排序(进入if语句块),如果大则不需要(跳出if语句块)
A[0]=A[i]; //待插入关键字暂存在A[0]
for(j=i-dk;j>0&&A[0].key<A[j].key; j-=dk)
//待插入关键字之前以dk为增量的关键字只要比待插入关键字大的都往后移动dk位
A[j+dk]=A[j];
A[j+dk]=A[0]; //找到了待插入的位置,就将待插入关键字插入这个位置
}
}
时间复杂度:... 希尔排序的时间复杂度约为O(n1.3) 在最坏情况下希尔排序的时间复杂度为O(n2) 空间复杂度:希尔排序的空间复杂度为O(1)
稳定性:不稳定,由于不同的增量可能就会把相等的关键字划分到两个直接插入排序中进行 排序, 可能就会造成相对顺序变化。
交换类排序
冒泡排序
假设待排序表长为n,从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序列比较完。我们称它为一趟冒泡,结果将最小的元素交换到待排序列的第一个位置。下一趟冒泡时,前一趟确定的最小元素不再参与比较,待排序列减少一个元素,每趟冒泡的结果把序列中的最小元素放到了序列的最终位置,……,这样最多做n-1趟冒泡就能把所有元素排好序。
void BubbleSort(ElemType A[],int n){
for(i=0;i<n-1;i++){
bool flag=false; //tips:当整个序列都有序的时候,标志位是不发生修改的,从而表示已经排好了
for(j=n-1;j>i;j--) //一趟冒泡过程
if(A[j-1].key>A[j].key){ //如果前面的元素比后面的大,则需要做交换
ElemType temp=A[j-1].key;
A[j-1].key=A[j].key;
A[j].key=temp;
flag=true; //发生了数据交换 修改标志位
}
if(flag==false)return ; //本趟遍历后没有发生交换,说明表已经有序
}
}
空间复杂度:交换时开辟了存储空间来存储中间变量,所以空间复杂度为O(1) 时间复杂度:该算法基本操作是在于交换两个数据。 最坏情况下,初始序列就是逆序的,所以最坏情况下时间复杂度为O(n2)。 最好情况下,初始序列就是顺序的,内层循环if条件始终不成立,所以内层执行n-1次后结束,所以时间复杂度为O(n) 稳定性:当两个关键字相等,if判断条件不成立,所以不会发生数据移动。所以是稳定的。
快速排序
每一趟快排选择序列中任一个元素作为枢轴(pivot)(通常选第一个元素),将序列中比枢轴小的元素都移到枢轴前边,比枢轴大的元素都移到枢轴后边。
int Partition(ElemType A[],int low,int high){ //low是当前待排序的序列起始下标,high是末尾下标
ElemType pivot=A[low]; //第一个元素作为枢轴
while(low<high){
while(low<high&&A[high]>=pivot) --high;//先从末尾往前找到第一个比枢轴小的元素
A[low]=A[high]; //用high的元素替换low的元素
while(low<high&&A[low]<=pivot) ++low; //再从开头往后找到第一个比枢轴大的元素
A[high]=A[low]; //用low的元素替换high的元素
}
A[low]=pivot; //枢轴元素存放到最终位置
return low; //返回存放枢轴的最终位置
}
void QuickSort(ElemType A[],int low,int high){
if(low<high){ //low和high值要合法
int pivotpos=Partition(A,low,high);
QuickSort(A,low,pivotpos-1); //分治递归左半部分
QuickSort(A,pivotpos+1,high); //分治递归右半部分
}
}
时间复杂度:最好情况下时间复杂度为O(nlogn) ,待排序序列越无序,算法效率越高。 最坏情况下时间复杂度为O(n2),待排序序列越有序,算法效率越低。
递归复杂度的表达式:T(n) = aT(n/b) + f(n) n/b是子问题的大小,比如快排每次划分子问题之后的大小,a是子问题大小的数量,f(n)是分解问题和合并问题所需要的时间
空间复杂度:由于快速排序是递归的,需要借助一个递归工作栈来保存每一层递归调用的必要信息,其容量应与递归调用的最大深度一致。 最好情况下为 ⌈log2(n+1)⌉(每次partition都很均匀)递归树的深度O(logn) 最坏情况下,因为要进行n-1次递归调用,所以栈的深度为O(n);
稳定性:快速排序是不稳定的,是因为存在交换关键字。
选择类排序
简单选择排序
void SelectSort(ElemType A[],int n){
for(i=0;i<n-1;i++){ //依次从后面序列中选择当前最小的元素作为第i个元素 最后一个元素不需要排序
min=i; //min存的是当前最小元素所在下标,初值设为第i个
for(j=i+1;j<n;j++) //从第i个元素往后找,一直要找到最后一个元素
if(A[j]<A[min]) min=j; //如果这个值更小,则更新min值为这个更小的元素所在下标
if(min!=i) { //如果第i个元素不是剩下元素最小的,则和最小的进行交换
ElemType temp=A[i];
A[i]=A[min];
A[min]=temp;
}
}
}
空间复杂度:需要额外的存储空间仅为交换元素时借助的中间变量,所以空间复杂度是O(1)
时间复杂度:关键操作在于交换元素操作,整个算法由双重循环组成,外层循环从0到n-2一共n-2+1=n-1次, 对于第i层外层循环,内层循环执行n-1-(i+1)+1=n-i-1次。当i=0,内层循环执行n-1次,当i=n-2,内层循环执行1次,所以是一个等差数列求和,一共为(1+n-1)(n-1)/2=n(n-1)/2 ,所以时间复杂度为O(n2)
稳定性:不稳定 原因就在于交换部分会打破相对顺序
堆排序
堆是一棵完全二叉树,而且满足任何一个非叶结点的值都不大于(或不小于)其左右孩子结点的值。
- 如果是每个结点的值都不小于它的左右孩子结点的值,则称为大顶堆。
- 如果是每个结点的值都不大于它的左右孩子结点的值,则称为小顶堆。
堆排序最重要的操作就是将无序序列调节成一个堆。
①建堆:对初始序列的完全二叉树调整成一个大顶堆 调整方法:二叉树由下至上由右至左(数组的下标由大到小),检查每个结点是否满足大顶堆的要求,如果不满足进行调整。 ②将堆顶结点和最后一个结点交换 也就是将最大值移动到数组的最末尾 有序序列中加入了结点,无序序列减少结点 到这里堆排序的第一趟完成 ③调堆:调整二叉树的结点使得满足大顶堆的要求。调整方法和建堆时一样。 到这里 又调成了一个大顶堆 类似之前的操作,选出根结点为有序序列的第二个数值
void BuildMaxHeap(ElemType A[],int len){
for(int i=len/2;i>0;i--) AdjustDown(A,i,len); //由数组下标高处往低处 从第一个可能需要调整的非叶结点
// 开始检查,直到根结点(注意根结点下标不是0,是从1开始存储)
}
void AdjustDown(ElemType A[],int k,int len){ //A是存储堆的数组,k是需要检查的结点下标,len是堆中结点个数
A[0]=A[k]; //A[0]暂存这个需要检查的结点值
for(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; //i赋值给k也就是从i开始继续往下检查 直到所有结点检查结束
}
}
A[k]=A[0]; //检查到最后k的值就是最后一轮交换过的结点位置下标 将该结点换过去
}
void HeapSort(ElemType A[],int len){
BuildMaxHeap(A,len); //初始建堆
for(i=len;i>1;i--){ //n-1趟的交换和建堆过程
//输出堆顶元素(和堆底元素交换)
ElemType temp=A[i];
A[i]=A[1];
A[1]=temp;
printf(A[i]);
AdjustDown(A,1,i-1); //把剩余的i-1个元素整理成堆
}
}
空间复杂度: 堆排序只需要在交换结点的时候需要额外存储空间来辅佐, 所以空间复杂度为O(1)
时间复杂度: 堆排序的总时间可以分为①建堆部分+②n-1次向下调整堆 ①建堆部分:时间复杂度为O(n) ②调堆部分:一次调堆从上至下最坏情况走得路径是从 根结点到叶子结点,完全二叉树的高度为log2(n+1) , 所以时间复杂度为O(log2n) 那么n-1个顶点时间复杂度为O(nlog2n)
稳定性:右图初始序列为1 2 2 由于2在左孩子位置 第一次交换之后2换到了堆顶 所以2最终排好的位置在最后 也就是1 2 2 两个2的位置发生了变化,所以堆排序不稳定
归并排序
假定待排序表含有n个记录,则可以看成是n个有序的子表,每个子表长度为1,然后两两归并,得到 ⌈n/2⌉个长度为2或1的有序表;再两两归并,……如此重复,直到合并成一个长度为n的有序表为止,这种排序方法称为2-路归并排序。
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]各自有序,将它们合并成一个有序表
for(int k=low;k<=high;k++)B[k]=A[k]; //将A中所有元素复制到B中
for(int i=low,j=mid+1,k=i;i<=mid&&j<=high;k++){ 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++]; //若第二个表未检测完,直接将剩下的部分复制过来
}
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); //归并
}
}
空间复杂度:因为需要将这个待排序序列转存到一个数组,所以需要额外开辟大小为n的存储空间,即空间复杂度为O(n)
稳定性:稳定
基数排序
基数排序(也叫桶排序)是一种很特别的排序方法,它不是基于比较进行排序的,而是采用多关键字排序思想(即基于关键字各位的大小进行排序的),借助“分配”和“收集”两种操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD)排序和最低位优先(LSD)排序。
关键字数量为n,关键字的位数为d,比如748 d=3,r为关键字的基的个数,就是组成关键字的数据的种类,比如十进制数字一共有0至9一共10个数字,即r=10 空间复杂度:需要开辟关键字基的个数个队列,所以空间复杂度为O(r) 时间复杂度:需要进行关键字位数d次"分配"和"收集",一次"分配"需要将n个关键字放进各个队列中,一次"收集"需要将r个桶都收集一遍。所以一次"分配"和一次"收集"时间复杂度为O(n+r)。d次就需要O(d(n+r))的时间复杂度。 稳定性:由于是队列,先进先出的性质,所以在分配的时候是按照先后顺序分配,也就是稳定的,所以收集的时候也是保持稳定的。即基数排序是稳定的排序算法。
外部排序
很多时候,需要对大文件进行排序,因为文件中的记录很多、信息量庞大,无法将整个文件拷贝进内存中进行排序。
解决办法:需要将待排序的记录存储在外存上,排序时再把数据一部分一部分的调入内存进行排序。在排序过程中需要多次进行内存和外存之间的交换,对外存文件中的记录进行排序后的结果仍然被放到原有文件中。这种排序的方法就叫做外部排序。
多路归并算法:通过多路归并,就实现了在较小的内存容量完成大规模的数据排序。
通过置换-选择排序,可以对无序的序列得到初始的归并段。 不同的归并策略会导致归并次数不同,意味着需要进行的I/O操作次数不同。 因此需要找到一种归并次数最少的归并策略来减少I/O操作次数。 最佳归并策略由最佳归并树来解决
如果把各个归并段的长度记做是某个结点的权值,那么最佳归并树就是一棵N路的哈夫曼树。
败者树
败者树可以看成是一棵完全二叉树: 这棵树的叶子结点存储的各个归并段当前参加比较的记录,内部结点存储的则是左右子树当中的失败者。 胜利者则一直继续向上比较直到根结点。
由于每次归并我们都是希望找到各个归并段中最小的关键字,所以胜利者应该是左右子树中关键字较小的。
利用败者树,m路归并每次查找最小关键字,最多需要比较log2m次