目录
归并排序
算法描述
归并排序是 分治思想 的应用,即将原待排数组 递归或迭代地 分为左右两半,直到数组长度为1,然后对左右数组进行合并(merge),在合并中完成排序。详细过程需结合代码理解,如下动图展示了{4,6,2,1,7,9,5,8,3}的归并排序过程(自顶向下非原地)。合并过程采用 非原地 合并方法,即依次比较两部分已排序数组,将比较结果依次写入 新空间 中。后续会介绍一种称作 原地(in-place) 归并排序的改进,使得空间复杂度达到 常数级 (自底向上时,O(1))。
如下树状图中的橙色线表示递归的轨迹(自顶向下递归归并排序)。
稳定性:稳定。
合并时的此判断中的等号if(left[l_next] <= right[r_next]),保证了出现相等元素时,居左的元素总会被放在左侧,稳定性不受影响。
自顶向下和自底向上
可以通过 自顶向下(top-down) 或 自底向上(bottom-up) 的方式实现归并排序。
自顶向下(top-down):从输入数组出发,不断二分该数组,直到数组长度为1,再执行合并。适合用 递归 实现。
自底向上(bottom-up):从输入数组的单个元素出发,一一合并,二二合并,四四合并直到数组有序。适合用 迭代 实现。
实例
a[]={1,3,5,7,2,4,6,8};
对于这个数组,利用归并的思想排序,就是把它分成两个部分,从中间截开,分成两组数:1,3,5,7和2,4,6,8;我们可以发现两组数都是从小到大排序的,我们可以定义两个变量一个指向前一串数的第一个数字,另一个变量指向第二组数的第一个变量,分别比较这两个数,将小的那个放进一个新数组,然后变量往后移,逐个比较,最终就有了一个新数组,这个新数组就是排序好的数组。
但是如果分成两组数之后,两边的数字并不是有序的该怎么办?这时候说明把数组分开一次不够,就要继续再分,如果还不是有序的?再分,直到把它们分为一个一个的数,然后再用归并的思想把它们重新排回原来的数组,整个数组就变得有序了。
代码
递归实现
void merge_sort(int a[],int left,int right){
if(left<right){
int mid = (left + right) / 2;//从中间截开
merge_sort(a,left, mid);//把左边沿中间截开
merge_sort(a, mid + 1, right);//把右边沿中间截开
merge(a, left, right, mid);//合并
}
}
//接下来这个函数是合并的过程。
void merge(int a[],int left,int right,int mid) {
int s[100];//一个新数组用来存储排序好的数组
int i = left, j = mid + 1;//两个变量分别指向左边和右边两组数的第一个数
int sor = left;
while (i <= mid && j <= right) {
if (a[i] < a[j]) {//归并的过程
s[sor++] = a[i++];
}
else {
s[sor++] = a[j++];
}
}
while (i <= mid) s[sor++] = a[i++];//当一组数已经全部排进去之后,再将另外一组数的全部数字都排进去
while (j <= right) s[sor++] = a[j++];
sor = left;
while (sor <= right) {//把排好序的新数组全部放回原数组里
a[sor] = s[sor];
sor++;
}
}
迭代实现
void mergesort(int num[],int len){
//对数组num归并排序迭代实现,len为数组长度,从小到大排序,O(nlog2^n),稳定
/*核心思想,i表示步长,也就是左右两组各几个元素比较
,从第一轮左右每组1个开始,每轮步长增大一倍
,比较后从小到大存入temp,再对剩余元素进行处理
,最后将排好序的temp返回num数组
*/
//分别为步长、temp下标、左边起始下标、左边终点下标、右边起始下标、右边终止下标
int i,next,left_min,left_max,right_min,right_max;
//新建一个temp数组,长度等于初始数组长度
int *temp = (int*)malloc(len * sizeof(int));
//每轮比较左右两个步长i长度的区间,每轮后i*=2
for(i=1; i<len; i*=2){
//从数组0号开始,下一组的起始位置等于上一组的终止位置,如果下一组左边步长都不够就不比了
for(left_min=0; left_min < len-i; left_min = right_max){
//右边起始位置=左边终止位置=左边起始加步长i
right_min = left_max = left_min + i;
//右边终止位置=右边起始位置加步长i
right_max = right_min + i;
next = 0;//temp的下标
if(right_max > len){//如果右边越界
right_max = len;//右边终止位置最大值只能为len
}
while(left_min < left_max && right_min < right_max){//左右都没到尽头
if(num[left_min] < num[right_min]){//左小右大,左边存入temp
temp[next++] = num[left_min++];
}else{//右小左大,右边存入temp
temp[next++] = num[right_min++];
}
}
/*左边还有一组剩余元素,右边已到终止位置
,说明左边剩余元素最大,将剩余元素移到右边最后
,如果是右边有剩余,则不需要移了已经在最后*/
while(left_min < left_max){
num[--right_min] = num[--left_max];
}
while(next > 0){//把排好序的temp部分返回num
num[--right_min] = temp[--next];
}
}
}
}
进阶·原地归并
前述归并排序,每一次合并都是将两部分待合并数组的比较结果写入一个与arr等大小的临时数组tmpArr中,写入后再将tmpArr中的合并结果写回到arr中。于是tmpArr的空间开销即为该实现的空间复杂度,为 O(n)。实际上,通过一种 原地旋转交换 的方法(俗称手摇算法/内存反转算法/三重反转算法),则只需要 O(1) 的辅助空间(由于递归空间为O(logn),其总的空间复杂度仍为 O(logn))。以下介绍旋转交换的实现方法。
以 456123 为例,欲将 456 和 123 交换位置转换为 123456,只需要执行三次旋转即可:
旋转 456,得到 654
旋转 123,得到 321
旋转 654321 得到 123456。
应用上述「手摇算法」对两个排序序列的「原地归并」过程如下。
- 记左数组第一个数下标为i,记右数组第一个数下标为j。
- 找到左数组中第一个 大于 右数组第一个数字的数,记其下标为i。
- 以index暂存右数组第一个元素的下标index = j。
- 找到右数组中第一个 大于等于 arr[i]的数,记其下标为j。此时必有 [i, index - 1]下标范围序列大于 [index, j - 1] 下标范围序列。
- 通过三次翻转交换 [i, index-1] 和 [index, j - 1] 序列 (指下标范围),即依次翻转[i, index-1],翻转[index, j - 1],翻转[i, j - 1]。
- 重复上述过程直到不满足(i < j && j <= rightEnd)
※ 第4步如果找「大于」而不是「大于等于」,对于数字数组排序,结果正确,但将 破坏稳定性。建议动手画一下。
以{1, 2, 4, 6, 7}与{3, 5, 8, 9} 两个已排序序列的合并为例,观察借助手摇算法实现原地归并的过程。
在{1, 2, 4, 6, 7}中找到第一个大于3的数4,其下标为2,i = 2。index = j = 5。在{3, 5, 8, 9}中找到第一个大于arr[i] = arr[2] = 4的数5,其下标为6,j = 6。
如上操作使得[0, i - 1]必是最小序列,[index, j - 1]必小于arr[i]。因此交换[i, index - 1]和[index, j - 1](采用三次旋转完成交换),使得这部分序列在整个数组中有序。
交换后,继续执行上述过程,直到不满足该条件 :i < j && j <= rightEnd。