排序的基本概念
- 排序:重新排列表中的元素,使表中的元素按照满足关键字递增或递减的过程。
- 算法的稳定性:如果待排序表中有两个元素Ri和Rj他们的关键字相等,在进行排序操作之后,二者的相对位置没有发生改变,则说明这个算法是稳定的,否则是不稳定的。稳定性是算法的一个性质,不能评判算法的优劣。
【对于不稳定的排序算法,只需要举出一组关键字的实例,说明它的不稳定性即可】 - 排序的分类
根据数据元素是否完全在内存中,将排序算法分成两类:内部排序——全部元素都在内存中;外部排序——在排序期间所有元素无法同时存放在内存中,必须在排序的过程中不断根据要求在内、外存之间移动的排序。 - 一般情况下,内部排序算法在排序的过程中需要执行两种操作:比较和移动,但不是所有都是,基数排序就不是。
- 内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度是一般是由比较和移动的次数来确定的。
插入排序
基本思想:每次将一个待排序的记录按其关键字大小插入到前面已经排好的子序列中,直到全部记录插入完成。
⚠️教材的定义体现了在原序列上进行的一种排序;
⚠️按照顺序表的方式存储的话,插入排序实际上就采用就地排序【空间复杂度O(1)】;
⚠️每次都要从在已排好的子序列中找到的位置开始整体将数组元素向后移动。
1、直接插入排序
#include <iostream>
using namespace std;
void DirectSort(int *Array,int length);
int main()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
DirectSort(Array, length);
cout<<"Result:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
//直接插入排序(递增)
void DirectSort(int *Array,int length)
{
if(Array==nullptr||length==0)
return;
/* for(int i=1;i<length;i++)//待排序元素
{
//比较垃圾的逻辑,而且还有错的地方,不过已经改过来了
int temp = Array[i];//一开始没设置这个变量,直接用Array[i]给Array[j]赋值了,这样一定是错的
for(int j=0;j<i;j++)
{
if(Array[i]<Array[j])
{
for(int k=i;k>j;k--)
Array[k]=Array[k-1];
Array[j]=temp;
}
}
}*/
for(int i=2;i<=length;i++)
{//提高效率,到当前要插入的已经是合理顺序的就只需要比较一次,虽然也比较了,但是肯定是少了啊
if(Array[i]<Array[i-1])
{ Array[0]=Array[i];
//设置一个哨兵结点,Array[0]不存放元素,可以将每次待插入元素存入其中
int j;//直接变向比较边移动
for( j=i-1;Array[0]<Array[j];--j)
Array[j+1]=Array[j];//当退出循环的时候直接就找到了要放置的位置
Array[j+1]=Array[0];
}
}
}
Tips:
- 哨兵结点的使用
- 条件的判断,可以提高效率
- 将移动和比较同时进行,而不是先比较找到位置再进行插入移动
直接插入排序的性能分析
性能 | 分析 |
---|---|
空间效率 | 常数个辅助单元,空间复杂度O(1) |
时间效率 | 最好情况:表中元素已经有序,每次插入一个只需要比较一次,时间复杂度O(n); 最坏情况:顺序完全相反,总的比较次数和移动次数都达到最大O(n2 );平均复杂度O(n2) |
稳定性 | 稳定 |
适用性 | 适用顺序存储和链式存储;链式存储就可以用第一个那个思路,从前往后查找指定元素的位置⚠️大多数排序算法都仅适用于顺序存储的线性表 |
2、折半插入排序
折半插入实际上就是把直接插入中的比较和移动拆成了两步:
1、第一步:
利用了折半查找,要明确折半的范围应该是当前比较元素之前的元素是已经排列好的序列,以便据此设置high和low的值
2、第二步:
用high和low表示位置的时候,high+1/low-1,由于折半插入最后一步的特点
#include <iostream>
using namespace std;
void MiddleInsert(int *Array,int length);
int main ()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
MiddleInsert(Array, length);
cout<<"Result:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
void MiddleInsert(int *Array,int length)
{
int i,j,low,high,mid;
for(i=2;i<=length;i++)//注意这里应该有等号,length存储的是实际有效的数组长度,否则最后一个元素就没进入比较序列了
{
Array[0] = Array[i];
low=1;high=i-1;//注意这里的high不是i,是在i之前的已排好的序列中寻找,所以是i-1
//折半查找
while(low<=high)
{
mid=(low+high)/2;
if(Array[0]<Array[mid]) high=mid-1;
else low=mid+1;
}
//元素移动
//从后面开始的,因为都是在待比较元素之前的序列
for(j=i-1;j>high;j--)
{
Array[j+1]=Array[j];
}
Array[high+1]=Array[0];
}
}
折半插入排序性能分析
性能 | 分析 |
---|---|
空间 | O(1) |
时间 | 减少了比较次数,与待排序表的初始状态无关,仅仅取决于表中元素个数n,O(nlog2n);元素移动次数没有改变,它依赖于元素的初始状态;总的时间复杂度O(n2) |
稳定性 | 稳定 |
适用性 | 顺序表,链表no |
希尔排序
1959年,D.L.Shell提出shell排序,又称缩小增量排序。
基本思想:先将待排序表按照希尔增量分割成若干个子表,然后对每个子表进行直接插入排序;按照一定的规律改变希尔增量,然后再次对子表进行直接插入排序,直到希尔增量为1。
⚠️目前尚未求得一个好的增量序列,希尔提出的方法是/2取地板
#include <iostream>
using namespace std;
void ShellSort(int * Array,int length);
int main ()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
ShellSort(Array,length);
cout<<"Result:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
//两种方法:一种是传入shell,一种是直接在shellsort中设置好shell
void ShellSort(int *Array,int length)
{
//第一个循环控制shell变量
for(int shell=length/2;shell>=1;shell=shell/2)
{
//第二个循环把第一组都分出来了,然后再往后就是每次都在不同的组内进行第二个第三个。。。元素的插入(虽然都是一个循环下来的)
for(int i=shell+1;i<=length;i++)
{
//和直接插入一样了就是把1换成了shell增量
if(Array[i-shell]>Array[i])
{
Array[0]=Array[i];
int j;
for( j=i-shell;j>0&&Array[j]>Array[0];j=j-shell)
Array[j+shell]=Array[j];
Array[j+shell]=Array[0];
}
}
}
}
shell sort性能分析
性能 | 分析 |
---|---|
空间 | O(1) |
时间 | 分析较困难,涉及数学上尚未解决的难题。当n在某个范围内的时候,约为O(n1.3);最坏情况O(n2) |
稳定性 | 不稳定 |
适用性 | 仅适用于顺序表 |
交换排序
概念
根据序列中两个元素的关键字的大小比较结果交换两个元素在序列中的位置
1、冒泡排序
基本思想
从前往后或者从后往前两两比较相邻元素的值,如果为逆序(与事先规定顺序:递增/递减相反),交换两个数的位置,从第一对到最后一对均完成交换,称为一次冒泡,它会将整个序列里最小的或者最大的元素换到第一个或者最后一个位置,一般结果将最小的元素交换到待排序序列的第一个位置(关键字最小的元素如气泡般向上漂浮,直至水面,这就是冒泡排序的由来)。下一次冒泡到来之时,已经排好的就不再参与冒泡了。最多n-1趟冒泡排好序。
#include <iostream>
using namespace std;
void BubbleSort(int * Array,int length);
void BubbleSort1(int *Array,int length);
int main ()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
BubbleSort(Array,length);
cout<<"Result:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
BubbleSort1(Array, length);
cout<<"Result1:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
//最朴素的冒泡排序
void BubbleSort(int *Array,int length)
{
for(int i=1;i<length;i++)
{
for(int j=i+1;j<=length;j++)
{
if(Array[j-1]>Array[j])
{
Array[0]=Array[j-1];
Array[j-1]=Array[j];
Array[j]=Array[0];
}
}
}
}
//冒泡排序进阶版
void BubbleSort1(int *Array,int length)
{
//在一次冒泡之后可能对于当前的元素,不需要再与后面的元素比较了
//这样就需要一个判断的标志,来证明当前已不需重排
//如何设置呢?上一次没有交换的就可以证明后面都是排好的了,每一个都满足后面的大于前面的可以直接返回不用再进行冒泡排序了
//所以在每轮比较中都更改flag的值,在进入下一个比较的时候就知道是否已经比较好了
bool flag=false;
for(int i=1;i<=length;i++)
{
for(int j=i+1;j<=length;j++)
{
if(Array[j-1]>Array[j])
{
Array[0]=Array[j-1];
Array[j-1]=Array[j];
Array[j]=Array[0];
flag = true;
}
}
if(flag==false)//本趟遍历之后没有发生交换,说明表已经有序
return ;
}
}
⚠️冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序),也就是有序子序列中的所有元素的关键字一定小于或大于无序子序列中所有元素的关键字,这样每一趟排序都会将一个元素放置在其最终的位置上。
冒泡排序性能分析
性能 | 分析 |
---|---|
时间 | 最好:初始情况为有序,第一趟冒泡flag=false,比较次数n-1,交换次数0,时间复杂度O(1); 最坏:初始情况为逆序,需要n-1趟排序,第i趟需要进行n-i次比较,每次比较都必须移动3次元素,时间复杂度O(n2);平均时间复杂度O(n2) |
空间 | O(1) |
稳定性 | 稳定,其实取决于if中的设置有没有= |
适用性 | 顺序表 |
2、 快速排序
基本思想
基于分治法的思想,在整个序列中找到一个元素作为基准元素pivot,然后通过一趟排序确定pivot的位置,使pivot左边的所有元素都小于pivot,pivot右边的元素都大于pivot,把pivot放置在最终属于它的位置,然后便不再移动它。接下来是递归处理左边序列和右边序列的过程。
#include <iostream>
using namespace std;
void QuickSort(int *Array,int low,int high);
int Partition(int *Array,int low,int high);
int main ()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
QuickSort(Array,1,length);
cout<<"Result1:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
//注意算法跳出的条件
void QuickSort(int *Array,int low,int high)
{
if(low<high)
{
int pivotpos=Partition(Array, low, high);
QuickSort(Array,low, pivotpos-1);//基准值前一半排序
QuickSort(Array, pivotpos+1, high);//基准值后一半排序
}
}
//快速排序算法的性能主要取决于划分操作的好坏,这里假设每次都是以当前表中第一个元素为pivot对表进行划分
//每次从后往前找,当数值比pivot大的时候继续向前寻找,直到找到比pivot小的数或者low=high
//然后从前往后找,当数值比pivot小的时候继续向后寻找,直到找到比pivot大的数或者low=high
int Partition(int *Array,int low,int high)
{
int pivot=Array[low];//设定基准值
while (low<high)
{//先一直向前找
while(low<high&&pivot<=Array[high]) --high;//找到了第一个比low所在位置小的元素
Array[low]=Array[high];//将该元素直接赋值给low,不用担心low的元素被覆盖,一开始使用了pivot进行存储
//因为当前元素比low要小,而当前元素后面的元素都比low要大
//将low替换为当前元素
//然后一直向后找
while(low<high&&pivot>=Array[low]) ++low;//只要找到那个位置左边的元素都比它小右边都比它大即可,现在还不需要有序呢
Array[high]=Array[low];
}
//其实现在把low或者high传入都可以,因为现在low=high
Array[low]=pivot;
return low;
}
int pivot = Array[low];
Array[low] = Array[high];//low已经被存了,放在low的位置一定比当前pivot小
Array[hight] = Array[low];//找到那个大于的,放在之前的high的位置上,
Array[low] = pivot;//然后将pivot设置在最合适的位置即low和high相等
快速排序性能分析(有待仔细演算)
- 空间效率
快速排序是递归的,需要借助一个递归工作栈保存每一层递归调用的必要信息,容量应该与递归调用的最大深度一直。最好情况下是log2(n+1)(取roof);最坏情况下,要进行n-1次递归调用,栈的深度为O(n);平均情况下,栈的深度为O(log2n). - 时间效率
具体使用的划分算法->划分是否对称->快速排序的运行时间
最坏情况发生在两个区域分别包含n-1个和0个元素是,这种最大程度的不对称发生在每一层递归上,即对于初始排序表基本有序或基本逆序是,就得到最坏情况下的时间复杂度O(n2). - 稳定性
不稳定的排序算法
⚠️提高算法效率
- 当递归过程中划分得到的子序列的规模较小时不使用递归而采用直接插入排序
- 选取一个可以将数据中分的pivot(eg:从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的pivot;随机从当前表中选取pivot,这样使得最坏的情况在实际排序中几乎不会发生)
- 在最理想的情况下,也就是partition算法可能做到最平衡的划分中,得到的两个子问题的大小都不可能大于n/2,在这种情况下,快速排序的运行速度大幅度提升,此时时间复杂度为O(nlog2n)
- 快速排序平均情况下的运行时间与最佳情况下运行时间很接近,快速排序是所有内部排序算法中平均性能最优的算法
选择排序
基本思想
每一趟在后面n-i+1个待排序的元素中找到最小的然后放置在第i个位置,就是位置0-length-1下标相当于是已经知道顺序的了,然后在序列里找每组序列的最小元素放在这个已知位置就可以了。
简单选择排序
基本思想
每一趟排序记录当前元素的最终位置,设置一个变量,每当要交换的时候,随时更改变量的值
#include <iostream>
using namespace std;
void EasyChoiceSort(int *Array,int length);
int main ()
{
int Array[11]={0,2,4,1,6,3,7,8,9,5,1};
int length=10;
EasyChoiceSort(Array,length);
cout<<"Result1:";
for(int i=1;i<=10;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
void EasyChoiceSort(int *Array,int length)
{
int location;
for(int i=1;i<=length;i++)
{
location=i;
for(int j=i+1;j<=length;j++)
{
if(Array[j]<Array[location])
location=j;
}
//一开始没加这句,其实无形之中提升了效率,如果位置都相同的话,就不用再交换一次了
if(location!=i)
{
Array[0]=Array[location];
Array[location]=Array[i];
Array[i]=Array[0];
}
}
}
简单排序算法性能分析
性能 | 分析 |
---|---|
空间 | O(1) |
时间 | 简单排序的过程中,元素移动的操作次数很少,不会超过3(n-1)次,最好的情况是移动0次,此时对应的表已经有序;但元素间比较的次数与序列的初始状态无关,始终是n(n-1)/2次,所以时间复杂度为O(n2) |
稳定性 | 不稳定 |
适用性 | 顺序表,但是如果给链表其实也可以 |
堆排序
堆排序特点
在排序过程中,将整个顺序表看成一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的元素。
堆的定义
大根堆
最大元素存放在根结点中,且对任意非根结点,它的值小于或等于其双亲结点
小根堆
最小元素存放在根结点中,且对任意非根结点,它的值大于或等于其双亲结点
相关算法思路
- 构建初始堆
初始建堆的过程就是一个不断筛选的过程。假设该序列长度为n,最后一个元素应当是n/2的子节点,对以n/2为根的子树筛选,若根结点小于左右子结点的最大值,则交换,使该子树称为堆。然后依次向前n/2~1为根的子树进行筛选,看结点值是否大于左右子结点的最大值,若不是交换,但是交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树重新成为堆为止。反复利用上述调整堆的方法建堆,知道根结点为止。 - 堆排序
首先将存放在数组中的n个元素建成初始堆,由于堆本身的特点,堆顶元素就是最大值。堆顶元素输出之后,将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素,反复至堆中只剩下一个元素为止。
用了将堆顶元素和最后一个元素交换,然后每次进行调整的范围都减小1. - 堆的删除和插入
堆的删除只能在堆顶进行,将堆顶元素和最后一个元素交换,然后再将新的堆顶元素进行向下调整AdjustDown()
堆的插入先将新的结点放在最后,然后再对这个结点进行向上调整AdjustUp() - 向上调整堆
不断向上调整和父节点进行比较,结点值大于双亲结点,将双亲结点下调,并继续向上比较
#include <iostream>
using namespace std;
void BuildMaxHeap(int *Array,int length);
void AdjustDown(int *Array,int k,int length);
void AdjustUp(int *Array,int length);
void HeapSort(int *Array,int length);
void DeleteElement(int *Array,int length);
void InsertElement(int *Array,int length,int key);
int main ()
{
int Array[9]={0,53,17,78,9,45,65,87,32};
int length=8;
BuildMaxHeap(Array, length);
cout<<"ResultInitial:";
for(int i=1;i<=8;i++)
cout<<Array[i]<<" ";
cout<<endl;
DeleteElement(Array, length);
cout<<"ResultDelete:";
for(int i=1;i<=7;i++)
cout<<Array[i]<<" ";
cout<<endl;
/* HeapSort(Array, length);
cout<<"Result1:";
for(int i=1;i<=8;i++)
cout<<Array[i]<<" ";
cout<<endl;*/
return 0;
}
//初始建堆
void BuildMaxHeap(int *Array,int length)
{
//遍历整个数组只需要从n/2往前,利用完全二叉树的性质就可以对整个数组进行排列
for(int i=length/2;i>0;i--)
{
AdjustDown(Array,i, length);
}
}
//先从宏观上看不要陷入算法的细节
//函数AdjustDown是为了找到k的最终位置,而在寻找的过程中把中间不恰当的位置都掉换了
void AdjustDown(int *Array,int k,int length)
{
Array[0]=Array[k];
for(int i=2*k;i<=length;i=i*2)//注意这里for循环条件的理解
{
//先对两个子节点的大小进行判断,选择出二者中最合适的
if(Array[i]<Array[i+1]&&i<length)
++i;
//将选择出来的与标准值进行比较
if(Array[i]<Array[0])
break;
else{//将发生交换的进行记录,就知道可能会发生破坏的下一级的堆的位置,不发生交换的当然不会发生破坏,就直接记录这个发生交换的就行
Array[k]=Array[i];
k=i;//当发现不平衡并对当前一层调整完之后,更新为根继续,i和k是相等的,这样利用i乘2之后才能得到下一个的子节点
//因为当前层和其子层的大小通过交换后满足条件,但是有可能导致子层的下一层出现问题
}
}
Array[k]=Array[0];//找到最终k的位置
}
//向上调整堆的算法
void AdjustUp(int *Array,int length)
{
Array[0]=Array[length];//0结点为一个中间值结点
int i=length/2;//从上往下是乘2,从下往上是/2操作
while(i>0&&Array[i]<Array[0])
{
Array[length]=Array[i];
length=i;
i=length/2;
}
Array[length]=Array[0];
}
//明确当前操作的是哪个结点,真正对结果有影响的是哪个结点,不要被其他部分干扰
//堆排序算法
//第一个位置排列的一定是最大的元素,利用最后一个元素与第一个元素交换,然后向下调整
//这样最后一个位置放置的就是整个序列中最大的元素,然后依次递减
void HeapSort(int *Array,int length)
{
BuildMaxHeap(Array, length);
for(int i=length;i>0;i--)
{
Array[0]=Array[i];
Array[i]=Array[1];
Array[1]=Array[0];
//每次交换之后都要把当前的堆进行重新调整
AdjustDown(Array, 1, i-1);//注意最后一个数的范围是i-1,因为i的位置已经放置上合适的值了,得在前面的那些数中进行寻找
}
}
//堆中元素删除算法
void DeleteElement(int *Array,int length)
{
Array[1]=Array[length];
AdjustDown(Array,1, length);
//交换之后实际上是在最后一个位置,那么就得让数组的实际长度发生变化来体现那个数被删除了
//直接length--没用,传参
//length--;
}
//堆中元素插入操作
void InsertElement(int *Array,int length,int key)
{
Array[length+1]=key;//注意不要越界,元素的范围
AdjustUp(Array, length+1);
//length++;
}
堆排序算法性能分析
性能 | 分析 |
---|---|
时间 | 建堆时间为O(n),之后有n-1次向下调整操作,每次调整的时间复杂度为O(h)【树的高度】,在最好、最坏、平均情况下时间复杂度为O(nlog2n) |
空间 | O(1) |
稳定性 | 不稳定 |
适用性 | 利用了完全二叉树的性质,用顺序表存储 |
二路归并排序
基本思想
“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。
假设待排序表含有n个记录,可以看成是含有n个有序子表,每个子表的长度为1,然后两两合并,得到n/2个长度为2或1的有序表;再两两归并,如此重复,直到合并成一个长度为n的有序表为止,这种排序方法称为2-路归并排序。
一般而言对于N个元素进行k-路归并排序时,排序的趟数m满足km=N,从而m=logkN,又考虑到m为整数,所以m=logkN(取top)。
- Merge()
该函数的功能是把前后相邻的两个有序表归并为一个有序表的算法。设两段有序表Array[low…mid],Array[mid+1…high]存放在同一顺序表中的相邻位置上,先将他们复制到数组Help中,每次从Help中对应的Array的两部分中选出一对进行比较,将较小的放在Array数组中,最后当数组Help中有一段的下标超出对应的表长的时候,将另一段中的剩余部分直接复制到A中。 - MergeSort()
一趟归并排序的操作是,调用n/2h(top)次算法Merge将前后相邻且长度为h的两部分进行两两归并,得到前后相邻、长度为2h的有序段,整个归并操作需要进行log2n趟。
递归形式的二路归并算法是基于分治的:
a、分解:将含有n个元素的待排序表分成各含n/2个元素的子表,采用2-路归并算法对两个子表递归地进行排序
b、合并:合并两个已排序的子表得到排序结果
#include <iostream>
using namespace std;
//边界的细节很重要啊!!!!
void Merge(int *Array,int low,int mid,int high);
void MergeSort(int *Array,int low,int high);
int* Help=new int[9];
int main ()
{
int Array[9]={0,53,17,78,9,45,65,87,32};
int length=8;
MergeSort(Array, 1, length);
cout<<"ResultInitial:";
for(int i=1;i<=8;i++)
cout<<Array[i]<<" ";
cout<<endl;
return 0;
}
void Merge(int *Array,int low,int mid,int high)//注意最小从low开始啊!!!
{
for(int i=low;i<=high;i++)//一开始写成了for(int i=0;i<=high;i++)一写for循环直接就写成i=0了,气死了
Help[i]=Array[i];//将原数组复制到Help辅助数组中,然后最终的结果排列仍然用Array数组,但是
int i,j,k;
//设置两个指向两部分的标志,分别比较Help中两部分的值,然后选择将小的放入Array原数组
//因为两部分不一定等长,所以可能最后结果会有一个长一些,直接把剩下的复制到Array原数组即可
for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++)//注意这里的条件控制k=i/low都可,for的第一个条件刚进循环的时候用啊,好像傻了
{
if(Help[i]<=Help[j])
Array[k]=Help[i++];//每次j、i都是自增的
else{
Array[k]=Help[j++];
}
}
//这两个循环只会有一个执行
while(i<=mid)
Array[k++]=Help[i++];
while(j<=high)
Array[k++]=Help[j++];
}
void MergeSort(int *Array,int low,int high)
{
if(low<high)
{
int mid=(low+high)/2;
MergeSort(Array,low, mid);
MergeSort(Array, mid+1, high);//注意把mid已经归到前面的组别了,下一组的low=mid+1,这里的边界都是带=的
Merge(Array, low, mid, high);
}
}
归并算法性能分析
性能 | 分析 |
---|---|
时间 | 每趟归并的时间复杂度为O(n),一共有log2n(top)趟归并,算法的时间复杂度为O(nlog2n) |
空间 | O(n) |
稳定性 | 稳定 |
适用性 | 顺序表 |
基数排序
计数排序
构建一个足够大的Array,Array的最大下标对应着待排序数组中的最大值,然后将在待排序数组中的值对应的去Array找下标,如果是出现一次Array相应位置为1,如果出现多次,Array的相应位置存放的是重复次数。
思想:不是基于比较进行排序的,而是采用多关键字排序思想(即基于关键字各位的大小进行排序的),借助分配和收集两种操作对单逻辑关键字进行排序,分为最高位优先[MSD]和最低位优先[LSD]
根据当前排序的数是几进制数确定不同的桶有多少个,然后按照从个位开始一直到最高位开始进行比较,然后按照每一位的比较结果将其放入桶中(因为桶是有序的所以当前就是递增或着递减的顺序——按照当前位来说),当两个数的位数不同的时候,将位数较小的一个高位补0之后再进行比较。
各种内部排序算法的比较
一般基于三个因素进行对比:
时空复杂度、算法的稳定性、算法的过程特征
算法种类 | 最好情况时间复杂度 | 平均情况时间复杂度 | 最坏情况时间复杂度 | 空间复杂度 | 是否稳定 | 过程特征 |
---|---|---|---|---|---|---|
直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 是 | |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 是 | |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 否 | 简单排序与序列的初始状态无关 |
希尔排序 | O(1) | 否 | 作为插入排序的扩展,对较大规模的排序可以达到很高的效率,但是目前还未得出其精确的渐进时间 | |||
快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | O(log2n) | 否 | 基于分治思想;使用一个小的辅助栈,用于实现递归,平均情况下大小为O(log2n,最坏情况下可能会增长到O(n) |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 否 | |
2-路归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 是 | 同样基于分治思想但是由于其分割子序列与初始序列的排列无关,所以三种情况下的时间复杂度相等;需要较多的辅助空间用于元素复制,大小为O(n),虽然有方法可以克服这点,但是算法本身会变得复杂 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 是 |
从过程特征来看,采用不同的排序算法,在一次循环或几次循环之后的排序结果可能是不同的,题目中会经常出现给出一个待排序的初始序列和已经部分排序的序列,问其采用何种排序算法,比如:冒泡排序和堆排序每次循环后都能产生当前的最大值或最小值,而快速排序一次就确定一个元素的最终位置。
内部排序算法的应用
- 一般需要考虑的条件:
(1)待排序方法需要考虑的因素
(2)元素本身信息量的大小
(3)关键字的结构及其分布情况
(4)稳定性的要求
(5)语言工具的条件,存储结构及辅助空间的大小等 - 实际选择情况
(1)若n较小,可以选择直接插入或者简单选择排序。如果元素本身信息量较大,则使用简单选择排序,因为直接插入会移动大量元素
(2)若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序
(3)若n较大,则可采用时间复杂度为O(nlog2n)
的算法:快速排序、堆排序或归并排序。
使用快速排序是当前时间效率最好的算法;
使用堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏的情况;
如果要求排序稳定且时间复杂度为O(nlog2n)
,则可选用归并排序。
【一般不直接采用归并排序,而是和直接插入相结合,先利用直接插入排序求的较长的有序子文件,然后再进行归并。直接插入是稳定的,所以改进后的算法也是稳定的。】
(4)在基于比较的排序方法中,每次比较两个关键字的大小之后,仅仅出现两种可能的转移,因此可以用一颗二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要O(nlog2n)的时间
(5)若n很大,记录的关键字位数较少且可以分解时,采用基数排序较好
(6)当记录本身信息量较大的时,为避免耗费大量时间移动记录,可用链表作为存储结构
(7)中等规模的元素序列(n<=1000),希尔排序是一种很好的选择
外部排序
外部排序的文件较大,内存一次放不下,需存放在外部介质的文件的排序
为减少平衡归并中外存读写次数所采取的方法,增大归并路数和减少归并段的个数
利用败者树增大归并路数
利用置换-选择排序增大归并段长度来减少归并段的个数
由长度不等的归并段,进行多路平衡归并,需要构造最佳归并树
1. 外部排序的基本概念
在排序的过程中需要进行多次内存和外存中内容的交换,排好序之后的记录放到原有文件中。
2. 外部排序方法
由于外部存储设备的不同还可以分为磁盘文件排序和磁带文件排序。主要不同之处在于初始归并段在内存介质中的分布方式,磁盘是直接存取设备,磁带是顺序存取设备。
文件通常是按块存储在磁盘上的,操作系统也是按块对文件的内容进行读写,因为磁盘读写的机械动作所需的时间远远超过内存运算的时间,所以时间代价主要考虑访问磁盘的次数,即I/O次数
外部排序通常采用归并排序方法,包括两个独立的阶段:
(1)首先根据内存缓冲区的大小,将外存上含n个记录的文件分成若干长度为h的子文件,依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写回外存。
【归并段或顺串:有序子文件】
(2)对这些归并段进行逐趟归并,使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止。
整个过程:首先将文文件按照给定的磁盘块的个数进行划分,划分成的磁盘块两两一组进入内存输入缓冲区进行内部排序,将排序结果输出至输出缓冲区,当输出缓冲区满时,将结果输出至文件中,同时继续将磁盘块中的内容读入内存,如果有一个磁盘块空了,则继续读入下一块,知道两个输入归并段中的内容全部读入内存并归并完成为止。
3. 多路平衡归并与败者树
增加归并路数m可以减少归并趟数S,进而减少访问外存的次数。但是增加归并路数之后,内部归并的时间增加。为了使内部归并不受m的增大影响,引入了败者树。
败者树是对树形选择的一种变形,可以看作一棵完全二叉树,每个叶结点存放各归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中“失败者”的标号,而让胜者继续向上进行比较,一直到根结点。如果两个比较两个数,大的为失败者,小的为胜利者,则根结点指向的数为最小数。
归并路数m的选择不是越大越好,归并路数m增大时,相应地需要增加输入缓冲区的个数。如果可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,使得内外交换数据的次数增大。当m值过大时,虽然归并路数会减小,但读写外存的次数会增加。
4. 置换-选择排序(生成初始归并段)
使用之前的归并方法,最开始的初始归并段都是等长的,现改变初始归并段的生成方法:
从待排文件中按顺序读入工作区大小的元素树木,然后将其中最小的元素取出放入输出文件中,然后继续读入新的元素,找出这一步工作区中最小的元素,再写入输出文件,again and Again,直到最后整个输入文件都走一遍,当前工作区中找不到比当前最小值还小的元素为止。这样算读出一段,然后反复执行操作直到输入文件为空。
5. 最佳归并树
利用Huffman树的思想进行归并处理,在归并树中,让记录数少的初始归并段最先归并,记录数多的初始归并段最晚归并,就可以建立总的I/O次数达到最少的最佳归并树。
如果初始归并段不足构成一棵严格m叉树,需增加长度为0的“虚段”,按照Haffman树的原则,权为0的叶子应离树根最远。
如何确定增添的虚段的数目?