《算法》系列 知识整理(C++描述)
算法学习历程
- 排序算法
- 查找算法
- 图
- 字符串问题
- 智能算法
学习目录
- 排序算法
- 初级排序算法
- 归并排序
- 快速排序
- 优先队列
- 排序算法的应用
本文主要内容
本文主要讲述归并排序算法,在介绍归并排序的思想基础上,分析自顶向下的归并排序及自底向上的归并排序两种方法,并比较二者的区别。
归并排序
算法宏观描述:
在对一个数组进行排序时,我们可以先将其分成两部分,对这两部分分别进行排序,再将两个有序的数组归并在一起,最终得到一个有序序列。
在描述中我们看到,归并排序中非常重要的两步是将数组分成两部分和将两个有序数组进行归并,这样的思想便是著名的分治思想。
(本文将着重点放在归并排序算法的分析上,对分治思想不做过多讲解,后期会对各种重要思想进行系统性地整理,这项工作已提上日程。)
最常见的归并方法为:将两个有序数组归并到第三个数组中,得到最终的有序数组。
而这也为我们带来一个困扰:当对大数组进行排序时,每次归并都创建一个新的数组势必会带来较大的空间开销。
我们希望实现一种“原地归并”,即不使用额外的空间对数组进行归并排序。但实际上,目前已有的实现这样想法的方法都较为复杂,因此本文在介绍中使用较为朴素的一种方法进行归并,如下:
创建一个与原数组(记为数组a)等长的新数组(记为数组aux),将数组a复制到数组aux中,再将aux[low…mid]、aux[mid+1…high]两部分归并至a[low,high]中。
(注:我们假设aux[low…mid]、aux[mid+1…high]已有序)
C++描述为:
//MAX不小于数组a的长度
int aux[MAX];
//默认从小到大排序
void merge_sort(int *a,int low,int mid,int high)
{
//a[]={4,5,6,1,2,3}
int i=low,j=mid+1;
for(int k=low;k<=high;k++)
{
//将a复制到aux
aux[k]=a[k];
}
for(int k=low;k<=high;k++)
{
if(i>mid)
a[k]=aux[j++];//当前半部分已归并完,直接将后半部分复制到a中
else if(j>high)
a[k]=aux[i++];//当后半部分已归并完,直接将前半部分复制到a中
else if(aux[j]<aux[i])
a[k]=aux[j++];//若后半部分的索引元素小于前半部分索引元素
else a[k]=aux[i++];//若前半部分的索引元素小于后半部分索引元素
}
//得到结果1,2,3,4,5,6
}
在代码中,我们看到,aux数组被设置为全局变量,目的是在整个递归过程中均可以使用,避免重复创建新数组,浪费空间。
在上述原地归并的基础上,我们延申出两种归并算法:
应用上述的分治思想,实现自顶向下的归并排序和自底向上归并排序。
自顶向下的归并排序
当我们想对子数组a[low…hi]进行排序,那么我们先将其分为a[low…mid]和a[mid+1,high]两部分,通过递归对两部分分别进行归并排序,最后两个数组再进行归并,得到有序序列。
我们一起来看一下自顶向下的归并排序的调用轨迹:
以数组15个元素的数组a为例。
- sort(a,0,15)
- sort(a,0,7)
- sort(a,0,3)
- sort(a,0,1)
- merge(1,0,0,1)
- sort(a,2,3)
- merge(a,0,1,3)
- merge(a,0,1,3)
- sort(a,0,1)
- sort(a,4,7)
- sort(a,4,5)
- merge(a,4,4,5)
- sort(a,6,7)
- merge(a,6,6,7)
- merge(a,4,5,7)
- sort(a,4,5)
- merge(a,0,3,7)
- sort(a,0,3)
- sort(a,8,15)
- sort(a,8,11)
- sort(a,8,9)
- merge(a,8,8,9)
- sort(a,10,11)
- merge(a,10,10,11)
- merge(a,8,9,11)
- sort(a,8,9)
- sort(a,12,15)
- sort(a,12,13)
- merge(a,12,12,13)
- sort(a,14,15)
- merge(a,14,14,15)
- merge(a,12,13,15)
- sort(a,12,13)
- merge(a,8,11,15)
- sort(a,8,11)
- merge(a,0,7,15)
- sort(a,0,7)
- 结束
在轨迹中黑色加粗的部分,我们可以看到整个数组中归并的顺序。
现在来分析一下自顶向下的归并排序需要进行的比较次数:
结论:对于长度为N的任意数组,自顶向下的归并排序需要1/2NlgN~NlgN次比较
令C(N)表示长度为N的数组排序时所需比较的次数。
则有C(0)=C(1)=0.
当N>0时,
C(N)=C(
N
2
\frac{N}{2}
2N)+C(
N
2
\frac{N}{2}
2N)+N
其中
右侧第一项表示对数组的前半部分进行排序时需要的比较次数;
右侧第二项表示对数组的后半部分进行排序时需要的比较次数;
右侧第三项表示左右两部分数组进行归并时,需要的排序次数。
当数组的长度正好为2的幂时(不妨设N=
2
n
2^n
2n),利用数学归纳,我们可以得到一个解:
C(N)=2C(
N
2
\frac{N}{2}
2N)+N
C
(
N
)
N
\frac{C(N)}{N}
NC(N)=
C
(
N
/
2
)
N
/
2
\frac{C(N/2)}{N/2}
N/2C(N/2)+1
而
C
(
N
/
2
)
N
/
2
\frac{C(N/2)}{N/2}
N/2C(N/2)=
C
(
N
/
2
²
)
N
/
2
²
\frac{C(N/2²)}{N/2²}
N/2²C(N/2²)+1
同理,
C
(
N
/
2
²
)
N
/
2
²
\frac{C(N/2²)}{N/2²}
N/2²C(N/2²)又可拆……
最后我们可以得到
C
(
N
)
N
\frac{C(N)}{N}
NC(N)=
C
(
N
/
2
n
)
2
0
\frac{C(N/2^n)}{2^0}
20C(N/2n)+n=n
所以C(N)=nN,又因为N=
2
n
2^n
2n,所以n=
log
2
N
\log_2^N
log2N
最终得到C(N)=N
log
2
N
\log_2^N
log2N
这是在归并时比较N次的情况下求得的结果
当情况最好时,只需要比较
N
2
\frac{N}{2}
2N次(其中一个序列的元素均大于另一个序列的元素时)
此时求得结果为:
1
2
\frac{1}{2}
21N
log
2
N
\log_2^N
log2N
好,我们现在有了归并排序的比较次数最多为N
log
2
N
\log_2^N
log2N 这个结论。
接下来分析,最多需要访问数组多少次:
对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6NlgN次
现在开始分析:
我们再来回顾一下归并排序的代码,不用向上翻啦,我再贴一下:
//MAX不小于数组a的长度
int aux[MAX];
//默认从小到大排序
void merge_sort(int *a,int low,int mid,int high)
{
//a[]={4,5,6,1,2,3}
int i=low,j=mid+1;
for(int k=low;k<=high;k++)
{
//将a复制到aux
aux[k]=a[k];
}
for(int k=low;k<=high;k++)
{
if(i>mid)
a[k]=aux[j++];//当前半部分已归并完,直接将后半部分复制到a中
else if(j>high)
a[k]=aux[i++];//当后半部分已归并完,直接将前半部分复制到a中
else if(aux[j]<aux[i])
a[k]=aux[j++];//若后半部分的索引元素小于前半部分索引元素
else a[k]=aux[i++];//若前半部分的索引元素小于后半部分索引元素
}
//得到结果1,2,3,4,5,6
}
来一起看
我们刚刚讲到的比较次数最多为N
log
2
N
\log_2^N
log2N这个结论,看比较这个步骤发生在哪?
是不是发生在
else if(aux[j]<aux[i])
这个部分,而且,对于每次调用merge函数,复制、比较、赋值这三个步骤的次数均相同?
也就是下面三条语句:
aux[k]=a[k]; //赋值
aux[j]<aux[i] //比较
a[k]=aux[j++]//(或a[k]=aux[i++])//赋值
这三条语句在一次merge中,执行的次数相同。
而我们上面分析到,比较这一操作发生了N
log
2
N
\log_2^N
log2N次,所以复制、比较、赋值三种操作均发生了N
log
2
N
\log_2^N
log2N次。
若访问数组a和访问数组aux的次数均算在内,那么上面的三条语句分别均访问数组两次(访问数组a一次,访问数组aux一次)。
因此归并排序中,访问数组的次数最多为6N
log
2
N
\log_2^N
log2N次。
自底向上的归并排序
上面我们讲到自顶向下的归并排序,主要通过:若要使整个数组有序,先使两个子数组有序 的思想。
现在我们介绍另一种归并排序思想:先归并微型数组,再进行成对归并,直至将整个数组归并完成。
即两两归并、四四归并、八八归并……
我们先看一下自底向上归并的轨迹:
(黑色代表本次调用merge时改变的子数组)
(sz代表子数组的大小,以M E R G E S O R T E X A M P L E 为例)
sz=1
merge(a,0,0,1) E M R G E S O R T E X A M P L E
merge(a,2,2,3) E M G R E S O R T E X A M P L E
merge(a,4,4,5) E M G R E S O R T E X A M P L E
merge(a,6,6,7) E M G R E S O R T E X A M P L E
merge(a,8,8,9) E M G R E S O R E T X A M P L E
merge(a,10,10,11) E M G R E S O R T E X A M P L E
merge(a,12,12,13) E M G R E S O R T E X A M P L E
merge(a,12,12,13) E M G R E S O R T E X A M P E L
sz=2
merge(a,0,1,3) E G M R E S O R E T X A M P L E
merge(a,4,5,7) E G M R E O S R E T X A M P L E
merge(a,8,9,11) E G M R E O S R A E T X M P L E
merge(a,12,13,15) E G M R E O S R A E T X E L M P
sz=4
merge(a,0,3,7) E E G M O R R S A E T X E L M P
merge(a,8,11,15) E E G M O R R S A E E L M P T X
sz=8
merge(a,0,7,15) A E E E E G L M M O P R R S T X
同样,我们也来分析这种方法的比较次数、访问数组的次数。
不难发现,自底向上与自顶向下的归并排序的差异点仅在于归并的顺序不同,其比较次数、访问数组字数都是相同的,因此:
对于长度为N的任意数组,自底向上的归并排序需要1/2NlgN~NlgN次比较;
对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6NlgN次;
证明步骤同上,在此不再赘述。
有关归并排序的内容到这里就结束了,初次整理,避免不了有错误,欢迎指正。:)
如果喜欢,还可以观看本系列其他文章鸭~