文章目录
排序
1.基本概念
排序(sorting)又称分类,将一组杂乱无章的数据按一定规律排列起来。即将无序序列排成一个有序序列(由小到大或由大到小)的运算。
2.分类
我们可以看到排序的分类非常多:
-
按存储介质可分为:
-
内部排序:数据量不大,数据在内存,无需内外存交换数据
-
外部排序:数据量较大,数据在外存(文件排序)
外部帕西时,要将数据分批调入内存来排序,中间结果还要及时放入外存,显然外部排序要复杂的多。
-
-
按比较器个数可分为:
- 串行排序:单处理机(同一时刻比较一对元素)
- 并行排序:多处理机(同一时刻比较多对元素)
-
按主要操作可分为:
- 比较排序:通过比较来决定元素间的相对次数,由于其时间复杂度不能突破O ( n log n ) ,因此也称为非线性时间比较类排序。
- 基数排序:不比较元素的大小,仅仅根据元素本身的取值确定其有序位置。它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
-
按稳定性可分为:
- 稳定排序:能够使任何数值相等的元素,排序以后相对次序不变
- 非稳定性排序:不是稳定排序的方法。
- 排序的稳定性只对结构型数据排序有意义。例如:
- 排序方法是否稳定,并不能衡量一个排序算法的优劣。
- 按辅助空间可分为:
- 原地排序:辅助空间量为O(1)的排序方法(所占的辅助空间与参与排序的数据量大小无关),通常意义上的排序,都是指的原地排序。
- 非原地排序:辅助空间量超过O(1)的排序方法。
- 按自然性可分为:
- 自然排序:输入数据越有序,排序的速度越快的排序方法
- 非自然排序:不是自然排序的方法。
- 按排序所需工作量
- 简单的排序方法:T(n)=O(n^2^)
- 基数排序:T(n)=O(d.n)
- 先进的排序方法:T(n)=O(nlogn)
2.存储结构
记录序列以顺序表存储:
c
#define MAXSIZE 20 //设记录不超过20个
typedef int KeyType; //设关键字为整形量(int型)
Typedef struct{ //定义每个记录(数据元素)的结构
KeyType key; //关键字
InofType otherinfo; //其他数据项
}RedType;//Record Type
Typedef struct{ //定义顺序表的结构
RedType r[MAXSIZE +1]; //存储顺序表的向量
//r[0]一般作哨兵或缓冲区
int length; //顺序表的长度
}SqList;
一.插入排序
【基本思想】:每步把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
【基本操作】:
- 在有序序列中插入一个元素,保持序列有序,有序长度不断增加。
- 起初,a[0]是长度为1的子序列。然后,逐将a[1]至a[n-1]插入到有序子序列中。
【有序插入方法】:
- 在插入a[i]前,数组a的前半段(a[0]~a[i-1])是有序段,后半段(a[i]~a[n-1])是 停留于输入次序的无序段。
- 插入a[i]使a[0]~a[i-1]有序,也就是要为a[i]找到有序位置 j(0<= j <= i),将a[i]插入在a[j]的位置上。
那么,这个插入位置可以在哪呢?
那么,怎么找到这个插入位置呢?
根据插入的方法,我们将插入排序分为以下三种:
1.1直接插入排序
直接插入排序(Straight Insertion Sort)------采用顺序查找法查找插入位置。
【算法思想】
在顺序查找法中我们使用哨兵来提高查找效率,这里同样可以使用:
【算法实现】
c
void InsertSort(SqList *L){
int i,j;//i表示当前无序部分的第一个元素,j表示寻找插入位置过程中的下标
//依次将R[2]~R[n]插入到前面已排序序列,R[1]为默认排好序的序列,R[0]作为哨兵不存放元素
for(i=2; i<=L->length; i++){
if(L.r[i]key < L.r[i-1].key){//插入前先比较,若当前i比前一位置的大,直接插入有序表中;若小,需将L.r[i]插入有序子表
L.r[0]=L.r[i]; //复制为哨兵
for(j=i-1; L->R[0]<L->R[j]; --j){//从后往前查找待插入位置
L.r[j+1]=L.r[j]; //向后挪位
}
L.r[j]=L.r[0]; //复制到插入位置,即将哨兵上的元素赋值到插入位置
}
}
}
假定初始序列为{ 49 , 38 , 65 , 97 , 76 , 13 , 27 , 49 } ,初始时49可以视为一个已排好序的子序列,按照上述算法进行直接插入排序的过程如下图所示,括号内是已排好序的子序列。
【性能分析】
实现排序的基本操作有两个:
(1)"比较"序列中两个关键字的大小
(2)"移动"记录。
-
时间复杂度
- 最佳情况:T(n) = O(n) (数组有序的情况下)
- 最坏情况:T(n) = O(n^2^) (数组逆序的情况下)
- 平均情况:T(n) = O(n^2^) 耗时差不多是最坏情况的一半
原始数据越接近有序,排序速度越快。所以,要提高查找速度:
- 减少元素的比较次数
- 减少元素的移动次数
-
空间复杂度:O(1)(仅用了一个辅助单元:哨兵)
-
稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
-
适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。
1.2折半插入排序
查找插入位置时采用折半查找法
【算法思想】
- 比较查找:当high<low时,此时的mid即是要插入的位置,查找比较停止,当前下标[0]~high的位置都小于要插入的数,实际上如果有与要插入的数相等的也在这个区间范围里;同时从low开始到有序区最后一个数都大于要插入的数。
- 后移:将low和其后面的元素统一向后移动一位,腾出位置来(这时,有序区就扩充了一个位置)
- 插入到正确的位置:即high+1所对应的位置
当要插入的数=low=high=mid时,考虑到稳定性,为了能让原本在后面的要插入的数排序之后还在后面,所以此时我们应该将要插入的数插入到high值的后面,也就是令left=mid+1.
【算法实现】
c
void BInsertSort(SqList &L){
for(i=2;i<=L.length;++i){//依次插入第2~第n个元素
if(L.r[i]key < L.r[i-1].key){//插入前先比较,若当前i比前一位置的大,直接插入有序表中;若小,需将L.r[i]插入有序子表
L.r[0]=L.r[i]; //复制为哨兵
low=1;high=i-1; //采用折半查找法查找插入位置
while(low<=high){
mid=(low+high)/2;
if(L.r[0].key<L.r[mid].key) high=mid-1;//如果哨兵位置比中间位置的值小,就在左半区查找
else low=mid+1;//否则就在右半区查找
}
}
for(int j=i-1;j >high; j--){
L.r[j+1]=L.r[j];
}
L.r[high+1]=L.r[i];
}
}
【性能分析】
(1)比较次数
折半查找比顺序查找快,所以折半插入排序就平均性能来说比直接插入排序要快;
- 它所需要的关键码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第 i 个对象时,需要经过⌊log_2i⌋+1次关键吗比较。才能确定它的插入位置;
- 当n较大时,总关键码比较次数比直接插入排序的最坏情况要好得多,但比其最好情况要差;
- 在对象的初始排列已经按关键码排好序或接近有序时,直接插入排序比折半插入排序执行的关键码比较次数要少
(2)移动次数
折半插入排序的对象移动次数与直接插入排序相同,依赖于对象的初始排列
- 减少了比较次数,但没有减少移动次数
- 平均性能优于直接插入排序
时间复杂度
- 最佳情况:T(n) = O(n) (数组有序的情况下)
- 最坏情况:T(n) = O(n^2^) (数组逆序的情况下)
- 平均情况:T(n) = O(n^2^) 耗时差不多是最坏情况的一半
虽然折半查找的效率比顺序查找的高,但还是被元素的移动拖了后腿,导致最终的时间复杂度都和直接插入排序一样。
- 空间复杂度:O(1)
- 是一种稳定的排序方法
前面我们提到:直接插入排序在基本有序 ,待排序的记录个数较少的情况下效率比较高。那么,有比折半插入排序还快的吗?怎么才能想办法让排序基本有序,或每比较一次就移动一大步呢?
希尔排序来啦~
1.3希尔排序
现将整个待排序记录序列分割成若干子序列 ,分别进行直接插入排序 ,待整个序列中的记录基本有序时,再对全体记录进行一次直接插入排序。
它与插入排序的不同之处在于,它会优先比较距离较远的元素。
特点:①缩小增量②多遍插入排序
希尔增量 gap=length/2
希尔排序的增量序列的选择与证明是个数学难题,希尔增量序列{n/2,(n/2)/2...1}是比较常用的,也是希尔建议的增量,但其实这个增量序列不是最优的。
- 增量序列必须是递减的,最后一个必须是1
- 增量序列应该是互质的
【算法思想】这里重要的是理解分组思想,每一个组其实就是一个插入排序,相当于进行多次插入排序。
①先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。
②然后将gap逐渐减小重复上述分组和排序的工作。
③当到达gap=1时,所有元素在统一组内排好序。
【算法实现】
c
/*对顺序表L作希尔排序*/
void ShellSort(SqList *L,int dalta[],int t){
//按增量序列dlta[0..t-1]对顺序表L做希尔排序
for(k=0;k<t;++k)
Shelllnsert(L,dlta[k]);//一趟增量为dlta[k]的插入排序
}//ShellSort
void Shelllsert(SqList &L,int dk){
//对顺序表L进行一趟增量为dk的Shell排序,dk为步长因子
for(i=dk+1;i<=L.length;i++)
if(r[i].key < r[i-dk].key){
r[0]=r[i];
for(j=i-dk;j>0 && (r[0].key < r[j].key))
j=j-dk;
r[j+dk]=r[j];
r[j+dk]=r[0];
}
}
【性能分析】
希尔排序算法效率与增量序列的取值有关(简单了解一下)
-
时间复杂度:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当n在某个特定范围时,希尔排序的时间复杂度约为O(n^1.25^)~O(1.6n^1.25^)------经验公式。要好于直接排序的O(n^2^)。
-
空间复杂度:O(1)(仅用了常数个辅助单元)
-
稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。
-
适用性:希尔排序算法仅适用于线性表为顺序存储的情况,不宜在链式存储结构上实现。
二.选择排序
2.1简单选择排序
简单选择排序法(Simple Selection Sort) 就是在待排序的数据中选出最大(小)的元素放在其最终的位置。
【算法思想】
①首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录交换
②再通过n-2次比较,从剩余的n-1个记录中找出关键字次小的记录,将它与第二个记录交换
③重复上诉操作,共进行n-1趟排序后,排序结束。
【算法实现】
c
void SelectSort(SqList *L){
int i,j,min;
for(i=0; i<L->length-1;i++){ //一共进行n-1趟
min = i; //记录最小元素位置
for(j=i+1; j<L->length; j++){
if(L->R[j] < L->R[min]){ //在R[i...n-1]中选择最小的元素
min = j; //更新最小元素位置
}
}
if(min !=i){
swap(L->R[i], L->R[min]); //swap函数移动元素3次
}
}
}
【性能分析】
-
时间复杂度
移动次数:
- 最好的情况是移动0 次,此时对应的表已经有序;
- 最坏的情况:元素移动的操作次数很少,不会超过3(n-1)次
比较次数:元素间比较的次数与序列的初始状态无关,选择排序所需进行的"比较"次数都相同,始终是n ( n − 1 ) / 2 次,因此时间复杂度始终是O(n^2^)。
-
空间复杂度:O(1)(仅用了常数个辅助单元)
-
稳定性:不稳定
2.2堆排序
堆排序(Heap Sort)是对简单选择排序进行的一种改进。
堆的定义
从堆的定义可以看出,堆实质是具有下列性质的完全二叉树 :每个结点的值都大于或等于其左右孩子结点的值,称为大根堆(如下图所示);或者每个结点的值都小于或等于其左右孩子结点的值,称为小根堆。
若在输出堆顶 的最小值(最大值)后,使得剩余n-1个元素的序列重又建成一个堆,则得到n个元素的次小值(次大值)......如此反复,使得能得到一个有序序列,这个过程称之为堆排序。
【算法思想】
那么:如何由一个无序序列建成一个堆?
①调整为堆
- 单结点的二叉树是堆;
- 在完全二叉树中所有以叶子结点(序号 i> n/2)为根的子树是堆。
这样,我们只需依次将以序号为n/2,n/2-1,......,1的结点为根的子树均调整为堆即可。即:对应由n个元素组成的无序序列,"筛选"只需从第n/2个元素开始。
由于堆实质上是一个线性表,那么我们可以顺序存储一个堆
②输出堆顶元素到顺序表
③重复调整,输出
如何在输出堆顶元素后,调整剩余元素为一个新的堆?
以小根堆为例:
①输出堆顶元素之后,以堆中最后一个元素替代之;
②然后将根结点值与左,右子树的根结点值进行比较,并与其中小者 进行交换;
③重复上诉操作,直至叶子结点,将得到新的堆,称这个从堆顶至叶子的调整过程为筛选。
可以看出:
对一个无序序列反复筛选就可以得到一个堆;即从一个无序序列建堆的过程就是一个反复筛选的过程。
下面以一个实例介绍建一个小根堆的过程:
由以上分析知:
若对一个无序序列建成堆,然后输出根;重复该过程就可以由一个无序序列输出有序序列。实质上,堆排序就是利用完全二叉树中父结点与孩子结点之间的内在关系来排序的。
【算法实现】
c
//建立大根堆算法
void BuildMaxHeap(ElemType A[], int len){
for(int i=len/2; i>0; i--){ //从i=[n/2]~1,反复调整堆
HeadAdjust(A, i, len);
}
}
/*函数HeadAdjust将元素k为根的子树进行调整,使之成为一个大根堆*/
void HeadAdjust(ElemType A[], int k, int len){
A[0] = A[k]; //A[0]暂存子树的根节点
for(i=2*k; i<=len; i*=2){ //沿key较大的子结点向下筛选
if(i<len && A[i]<A[i+1]){ //i为key较大的记录的下标
i++; //取key较大的子节点的下标
}
if(A[0] >= A[i]){
break; //筛选结束
}else{
A[k] = A[i]; //将A[i]调整到双亲结点上
k = i; //修改k值,以便继续向下筛选
}
}
A[k] = A[0]; //被筛选结点的值放入最终位置
}
调整的时间与树高有关,为O ( h )。在建含n个元素的堆时,关键字的比较总次数不超过 4n,时间复杂度为 O(n),这说明可以在线性时间内将一个无序数组建成一个堆。
下面是堆排序算法:
c
//堆排序算法如下:
void HeapSort(elem R[]){ //对R[1]到R[n]进行堆排序
int i;
//从第一个非叶子结点来建初始堆,然后将堆顶放在最后一个元素
for(i=n/2; i>=1; i--)
HeapAdjust(R,i,n); //建初始堆
//重新调整堆
for(i=n; i>1; i--){ //进行n-1趟排序
Swap(R[1],R[i]); //根与最后一个元素交换
HeapAdjust(R,1,i-1); //对R[1]到R[i-1]重新建堆
}
}
同时,堆也支持插入操作
对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点向上执行调整操作。大根堆的插入操作示例如下图所示:
【性能分析】
- 时间复杂度:
- 初始化堆所需时间不超过O(n)
- 排序阶段(不含初始堆化)
- 一次重新堆化所需时间不超过O(log n)
- n-1次循环所需时间不超过O(nlog n)
- 平均时间复杂度Tw(n)=O(nlog n)=O(nlog n)
堆排序的时间主要耗费在建初始堆时进行的反复筛选上。建堆时间为O ( n ) ,之后有n − 1次向下调整操作,每次调整的时间复杂度为O ( h ),故在最好、最坏和平均情况下,堆排序的时间复杂度为O(nlog_2 n)。
-
空间复杂度:O(1)(仅用了一个记录大小交换用的辅助存储空间)
-
稳定性:进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法。
-
适用性:堆排序适合关键字较多的情况(如n>1000)。例如,在1亿个数中选出前100个最大值?首先使用一个大小为100的数组,读入前100个数,建立小顶堆,而后依次读入余下的数,若小于堆顶则舍弃,否则用该数取代堆顶并重新调整堆,待数据读取完毕,堆中100个数即为所求。
三.交换排序
两两比较,如果发生逆序则交换,直到所有记录都排好序为止。
3.1冒泡排序
在无序区间,通过相邻数的比较,将最大的数冒泡到无序区间的最后,持续这个过程,直到数组整体有序。------基于简单的交换思想
如下图所示,n趟需要比较n-1趟,第m趟需要比较n-m次:
优点:每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素;
那么,如何提高效率呢?
一旦某一趟比较时不出现记录交换,就说明已经排好序了,就可以结束本算法。所以在算法中我们可以用一个flag来作为本趟冒泡是否发生交换的标志。
【算法思想】
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
【算法实现】
c
void BubbleSort(SqList *L){//冒泡排序算法
int i, j, m;
bool flag = true; //交换时的临时存储,表示本趟冒泡是否发生交换的标志
for(m=0; m< L->length-1 && flag==true; m++){ //总共需m趟
flag = false;
for(j=1; j<=L->length-m; j++){ //一趟冒泡过程
if(L.r[j].key > L.r[j+1].key){ //若为逆序
x=L.r[j];
L.r[j]=L.r[j+1];
L.r[j+1]=x; //交换
flag = true; //发生交换,flag置为1,若本趟没发生交换,flag保持为0
}
}
if(flag == false){
return; //本趟遍历后没有发生交换,说明表已经有序,下一趟循环不会被执行
}
}
}
【性能分析】
-
时间复杂度
- 最好的时间复杂度是O(n)
- 最坏的时间复杂度是O(n^2^)
- 平均时间复杂度是O(n^2^)
-
空间复杂度:O(1)(仅用了常数个辅助单元)
-
稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
3.2快速排序
这里是排序算法的重点了,非常重要!
通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
【特点】
- 从待排序区间任选一个数,作为基准值(pivot);
- Partition: 遍历整个待排序区间,将比基准值小的(可以包含相等的)放到基准值的左边,将比基准值大的(可以包含相等的)放到基准值的右边;
- 采用分治递归思想 ,对左右两个小区间 按照同样的方式处理,直到小区间的长度 == 1(每个小区间的元素只剩一个),代表已经有序, 或者小区间的长度 == 0,代表没有数据。
【算法思想】
①每一趟的子表形成是采用从两头向中间交替式逼近法,直到low=high停止,可以划分为两个子表:一开始把中间元素放到哨兵位置,后面就空了,于是从前面low位置搬一个(大于哨兵的)到high位置,此时low位置空了,再去high搬一个到low位置...
②由于每趟中对各子表的操作都相似,可采用递归算法
【算法实现】
c
void main(){
QSort(L,1,L.length);
}
//对顺序表L快速排序
void QSort(SqList &L,int low,int high){ //初始时,low=1,high=length
//接下来依次划分:
if(low<high){ //长度大于1
pivotloc = Partition(L,low,high); //先找到中心点元素的位置
//将L.r[low...high]一分为二为两个子表,pivotloc为基准值排好序的位置
QSort(L,low,pivotloc-1); //对低子表递归排序
QSort(L,pivptloc=1,high); //对高子表递归排序
}
}
//找中心点的位置
int Partition(SqList &L,int low,int high){
L.r[0]=L.r[low];//先将第一个元素放到哨兵位置
pivotkey=L.r[low].key;//并作为基准值
while(low<high){//当loe和high重合停止循环
while(low<high && L.r[high].key >=pivotkey) --high;
L.r[low]=L.r[high];//low位置空了,找一个满足条件的high放到low位置去
while(low<high && L.r[low].key<=pivotkey) ++low;
L.r[high]=L.r[low];//high位置空了,找一个满足条件的low放到low位置去
}
L.r[low]=L.r[0];//这时将哨兵即中心点元素放到low与high重合的位置
return low;//返回中心点所在位置
}
【性能分析】
-
时间复杂度
最好情况:O(n * log_2(n))
平均情况:O(n * log_2(n))
最坏情况:O(n^2)
- Qsort( )😮(log_2n)
- Partition( )😮(n)
实验结果表明:就平均计算而言,快速排序是我们所讨论的所有内排序方法中最好的一个。
-
空间复杂度:最好 = 平均 = O(log(n));最坏 = O(n)
快速排序不是原地排序。
由于程序中使用了递归,需要递归调用栈的支持,而栈的长度取决于递归调用的深度(即使不用递归,也需要用用户栈)
- 在平均情况下:需要O(log n)的栈空间
- 最坏情况下:栈空间可达O(n)
-
稳定性:在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,它们的相对位置会发生变化,即快速排序是一种不稳定的排序方法。
-
适用性:快速排序不适用于对原本有序或基本有序的记录序列进行排序。
划分元素的选取是影响时间性能的关键
- 输入数据次序越乱,所划分元素值的随机性越好,排序速度越快,快速排序不是自然排序方法。
- 改变划分元素的选取方法,至多只能改变算法平均情况下的时间性能,即最坏情况下,快速排序的时间复杂性总是O(n^2^)
四.归并排序
和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;
即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归 去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。
再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
【算法思想】
如何将两个有序序列合并成一个有序序列呢?
- 前面我们学到了用顺序表的合并
但是,相邻元素的合并如何实现呢?
- 用链表合并(相邻的指针)
一开始给我们的数据元素是一个连续空间,在合并的时候是两个相邻的元素,有两个指针表示我们要合并的两个子序列的当前第一个元素的下标
比较 i 和 j 位置上的关键值,如果 i 位置上的小,将其放进k位置,然后 i 指向下一个位置
【算法实现】
c
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(i=low, j=mid+1, k=i; i<=mid && j<=high; k++){
//从low到mid,j从mid+1到high,k是最终排序数组的下标
if(B[i] <= B[j]){ //比较B左右两段的元素
A[k] = B[i++]; //将较小值赋值给A,B左段下标加1,右段不动
}else{
A[k] = B[j++]; //将较小值赋值给A,B右段下标加1,左段不动
}
}
while(i <= mid){
//若第一个表(左段)未检测完,复制
A[k++] = B[i++];
}
while(j <= high){
//若第二个表(右段)未检测完,复制
A[k++] = B[j++];
}
}
【性能分析】
-
时间复杂度:每趟归并的时间复杂度为O ( n ),共需进行⌈log_2n⌉趟归并,所以算法的时间复杂度为 O(nlog_2n)。
-
空间复杂度:因为需要一个与原始序列同样大小的辅助序列(R1),所以算法的空间复杂度为O ( n ) 。
-
稳定性:由于Merge()操作不会改变相同关键字记录的相对次序,所以2路归并排序算法是一种稳定的排序方法。
五.基数排序
基数排序也是非比较的排序算法,也叫桶排序 或箱排序:设置若干个箱子,将关键字为k的记录放入第k个箱子,然后再按序号将非空的连接。
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。
【算法思想】
基本思想:分配+收集
基数排序的过程分为两种方法:LSD(Least Significant Digit first)[&和MSD(Most Significant Digit first)&]。
LSD 基数排序
LSD 的排序方式由键值的最右边开始,也就是从个位开始比较,逐步移至最左边的位数。具体步骤如下:
①找出最大数:确定需要排序的数组中最大数的位数;
②创建桶:根据位数的不同,分别创建对应的桶,例如个位数就有0-9共10个桶;
③分配:从个位开始,根据位数的值将每个数分配到对应的桶中;
④收集:按照桶的顺序将数字收集起来,收集完后得到的序列是按当前位数排序的;
⑤重复:对于每个更高的位数重复第3步和第4步的过程。
MSD 基数排序
MSD 的排序方式由键值的最左边开始,也就是从最高位开始比较,逐步移至最右边的位数。其步骤与 LSD 类似,但是是从高位开始进行。
【算法实现】
c
public static void radixSort(int[] arr) {
// 假定arr[0] 是最大数
// 1. 通过遍历arr, 找到数组中真正最大值
// 2. 目的是确定要进行多少轮排序
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
// 计算最大数字是几位数
int maxLength = (max + "").length();
// 定义一个二维数组, 就是10个桶
// 1. 该二维数组有10个一维数组 0-9
// 2. 为了防止溢出,每个一维数组(桶),大小定为 arr.length
// 3. 很明确, 基数排序是空间换时间
int[][] bucket = new int[10][arr.length];
// 用于记录在每个桶中,实际存放了多少个数据,这样才能正确的取出
int[] bucketElementCounts = new int[10];
// 根据最大长度的数决定比较的次数
// 1. 大循环的次数就是 最大数有多少位,前面分析过
// 2. n = 1, n *= 10 是为了每轮循环排序时,分别求出各个元素的 个位,十位,百位,千位 ...
// 就是一个小算法
// 3. 这个基础排序,完全可以使用 冒泡分步写代码来完成,比较简单!!
for (int i = 0, n = 1; i < maxLength; i++, n *= 10) {
// 把每一个数字分别计算本轮循环的位数的值,比如第1轮是个位...
for (int j = 0; j < arr.length; j++) {
// 计算
int digitOfElement = arr[j] / n % 10;
// 把当前遍历的数据放入指定的数组中
bucket[digitOfElement][bucketElementCounts[digitOfElement]] = arr[j];
// 记录数量
bucketElementCounts[digitOfElement]++;
}
// 记录取的元素需要放的位置
int index = 0;
// 把各个桶中(10个桶)存放的数字取出来, 放入到arr中
for (int k = 0; k < bucketElementCounts.length; k++) {
// 如果这个桶中,有数据才取,没有数据就不取了
if (bucketElementCounts[k] != 0) {
// 循环取出元素
for (int l = 0; l < bucketElementCounts[k]; l++) {
// 取出元素
arr[index++] = bucket[k][l];
}
// 把这个桶的对应的记录的数据个数置为0,注意,桶本身数据(前面存的数据还在)
bucketElementCounts[k] = 0; //
}
}
}
}
【性能分析】
-
时间复杂度 O(k*(n+m))
k是趟数,m是桶的个数,有多少个桶就要收集多少次,n就是分配的时候需要扔多少个元素
-
空间复杂度 O(n+m)即桶的个数和最后存放的空间
-
稳定性
-
适用性:只能对关键字的取值范围一定的元素进行算法,关键字的取值范围不一定的算不出来
总结
各种排序方法的综合比较
一. 时间性能
- 按平均的时间性能来分,有三种排序方法:
- 时间复杂度为
O(nlog n)
的方法有:- 快速排序,堆排序和归并排序,其中以快速排序为最好;
- 时间复杂度为
O(n^2)
的有:- 直接插入排序,冒泡排序和简单选择排序,其中以直接插入排序为最好,特别是那些对关键字近似有序的记录序列尤为如此;
- 时间复杂度为
O(n)
的排序方法只有:基数排序
- 当待排记录按关键字有序 时,直接插入排序和冒泡排序(线性阶的)能达到O(n)的时间复杂度;而对于快速排序而言,这不是最好的情况,此时的时间性能退化为O(n^2^),因此是应该尽量避免的情况。
- 简单选择排序,堆排序和归并排序的时间性能不随记录序列中关键字的分布而改变。
二. 空间性能
------指的是排序过程中所需的辅助空间大小
- 所有的简单排序方法(包括:直接插入,冒泡和简单选择)和堆排序的空间复杂度为O(1),都是原地排序
- 快速排序为O(log n),为栈所需的辅助空间
- 归并排序所需辅助空间最多,其空间复杂度为O(n)
- 链式基数排序徐附设队列首尾指针,则空间复杂度为O(rd)
三. 排序方法的稳定性能
- 稳定的排序方法指的是,对于两个关键字相等的记录,它们在序列中的相对位置,在排序之前和经过排序之后,没有改变。
- 当对关键字的记录进行LSD方法排序时,必须采用稳定的排序方法
- 对于不稳定的排序方法,只要能举出一个实例说明即可
- 快速排序和堆排序是不稳定的排序方法。
四. 关于"排序方法的时间复杂度的下限"
-
本章讨论的各种排序方法,出基数排序外,其他方法都是基于"比较关键字"进行排序的排序方法,可以证明,这类排序法可能达到的最快的时间复杂度为O(nlog n)
(基数排序不是基于:"比较关键字" 的排序方法,所以它不受这个限制)
-
可以用一颗判定书来描述这类基于"比较关键字"进行排序的排序方法