归并排序(MergeSort)

和分治思想的第一次相遇

当问题的规模是可以划分的时候,分治的算法往往是很有效的:

不断分割问题的规模,直到子问题的规模足够小便直接求解,之后不断整合子问题的解得到更大规模的解,最后得到完全解。

归并排序就是分治算法的一个简单的例子。

可能有人觉得快速排序也是属于分治算法,但我不这么觉得,因为快速排序是先得到大问题的解的一部分,再靠子问题来完成解,

并没有整合子问题这一步,所以硬要说的话,快速排序应该是“治分”算法

简单图示(是不是有点太简单了)

如何分解?

归并排序把问题划为均匀的两个子问题,即左半区间和右半区间,于是得到递归函数:

#define MID(i) (i >> 1) /// i / 2

/*****************************************
    函数:归并排序
    说明:对区间[low, high)范围的数据排序
*****************************************/
void mergeSort(int* low, int* high)
{
    int range = high - low; ///区间元素个数
    if(range > 1)   ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题
    {
        int* mid = MID(range) + low; ///求出分割点
        ///递归求解子问题
        mergeSort(low, mid);
        mergeSort(mid, high);
        merge(low, mid, high);   ///再合并两个子问题,这个函数待会实现
    }
}

这里是不能应用尾递归优化的,因为节点信息需要保存,以便执行merge(合并)过程。

读者可以思考下对于规模为2的问题为什么不会出现无限递归。

怎么合并?

在合并两个子问题时我们知道子问题对应的区间已经是有序的。所以我们可以通过比较两区间当前的最小值,得到整个区间的最小值,不断选出最小值便可完成合并(类似选择排序)

整个过程的花费是线性的O(n),n为两区间元素个数和。

不过整个过程需要一个辅助数组来存放不断选出的最小值(总不能占别的元素的位置吧),所以为了效率,首先声明足够大的辅助数组:

#define MID(i) (i >> 1) /// i / 2

int* helper;    ///辅助数组

/**********************************************
    函数:归并函数
    说明:合并有序区间[low, mid)和[mid, high)
          left为左区间的遍历指针
          right为右区间的遍历指针
          helper为局部变量覆盖全局声明
          这样做是为了减少代码行数
    时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high, int* left, int* right, int* helper)
{
    while(true)
    {
        if(*left <= *right)  ///相等时下标小的优先,使得算法稳定
        {
            *(helper++) = *(left++);
            if(left >= mid)  ///左区间已经空了
            {
                while(right < high) *(helper++) = *(right++);    ///把右区间剩下的复制过去
                break;  ///跳出循环(外层)
            }
        }
        else
        {
            *(helper++) = *(right++);
            if(right >= high) ///右区间空了
            {
                while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
                break;  ///跳出外层循环
            }
        }
    }
    while(high > low) *(--high) = *(--helper);   ///再复制回来
}

/*****************************************
    函数:归并排序
    说明:对区间[low, high)范围的数据排序
    时间复杂度:O(nlgn)
*****************************************/
void mergeSortRoutine(int* low, int* high)
{
    int range = high - low; ///区间元素个数
    if(range > 1)   ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题
    {
        int* mid = MID(range) + low; ///求出分割点
        ///递归求解子问题
        mergeSortRoutine(low, mid);
        mergeSortRoutine(mid, high);
        merge(low, mid, high, low, mid, helper);   ///再合并两个子问题
    }
}

/****************************************
    函数:归并排序“外壳”
****************************************/
void mergeSort(int* low, int* high)
{
    helper = new int[high - low];   ///辅助数组最多也就存输入的元素数
    if(helper != nullptr)
    {
        mergeSortRoutine(low, high);
        delete[] helper;    ///释放内存
    }
    else return;    ///空间不足,没法启动归并排序
}

时间复杂度

上面的归并排序的时间复杂度是很好分析的,最多有lgn层问题,每层均花费O(n)所以是O(nlgn),并且最坏和最好情况都是差不多的。

优化

关于归并排序的优化还是挺多的,这里先来讲一种很不错的优化:

在原始的归并函数中左右区间的元素都会按大小全部复制到辅助数组中去,之后再一一复制回来。这一过程没错不过却没有考虑那些原本就处于正确位置的元素。

比如当左区间空了的时候,此刻右区间剩下的元素还需要再复制到复制数组中吗?答案是不需要的,因为它们本来就已经在正确位置了:

/**********************************************
    函数:优化版归并函数
    说明:合并有序区间[low, mid)和[mid, high)
          left为左区间的遍历指针
          right为右区间的遍历指针
          helper为局部变量覆盖全局声明
          这样做是为了减少代码行数
    时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high, int* left, int* right, int* helper)
{
    while(true)
    {
        if(*left <= *right)  ///相等时下标小的优先,使得算法稳定
        {
            *(helper++) = *(left++);
            if(left >= mid)  break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置
        }
        else
        {
            *(helper++) = *(right++);
            if(right >= high) ///右区间空了
            {
                while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
                break;  ///跳出外层循环
            }
        }
    }
    while(right > low) *(--right) = *(--helper);   ///再复制回来,不过要跳过右区间剩下的元素
}

这样不仅使代码更加简短,并且在很多时候会使程序加速。

同理可以应用于左区间原本就处于正确位置的元素,最终得到:

/**********************************************
    函数:优化版归并函数
    说明:合并有序区间[low, mid)和[mid, high)
          right为右区间的遍历指针
          helper为局部变量覆盖全局声明
          这样做是为了减少代码行数
    时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high, int* right, int* helper)
{
    ///收缩左边界,不再考虑左区间原本位于正确位置的元素
    while(*low <= *right)
        if(++low >= mid) return;  ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回
    int* left = low;    ///设置左区间遍历指针
    *(helper++) = *(right++);   ///别浪费上面循环失败的比较结果。。。
    if(right >= high) ///右区间空了
        while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
    else while(true)
        {
            if(*left <= *right)  ///相等时下标小的优先,使得算法稳定
            {
                *(helper++) = *(left++);
                if(left >= mid)  break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置
            }
            else
            {
                *(helper++) = *(right++);
                if(right >= high) ///右区间空了
                {
                    while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
                    break;  ///跳出外层循环
                }
            }
        }
    while(right > low) *(--right) = *(--helper);   ///再复制回来,不过要跳过右区间剩下的元素
}

虽然在最坏情况下和原始的归并函数一样,但是大部分情况还是有优化的,特别是当数组原本有序时,每层只需简单遍历O(n/2)个元素,比快速排序更高效。

减少的“倒腾”次数的期望

即下面cnt的平均值:

int cnt = 0;    ///计数器

void merge(int* low, int* mid, int* high, int* right, int* helper)
{
    while(*low <= *right)
    {
        cnt++;  ///左边多一个元素不用参与复制
        if(++low >= mid)
        {
            cnt += high - right;    ///右边都不用参加
            return;
        }
    }
    int* left = low;
    *(helper++) = *(right++);
    if(right >= high)
        while(left < mid) *(helper++) = *(left++);
    else while(true)
        {
            if(*left <= *right)
            {
                *(helper++) = *(left++);
                if(left >= mid)
                {
                    cnt += high - right;    ///右边不用参加复制的元素个数
                    break;
                }
            }
            else
            {
                *(helper++) = *(right++);
                if(right >= high)
                {
                    while(left < mid) *(helper++) = *(left++);
                    break;
                }
            }
        }
    while(right > low) *(--right) = *(--helper);   ///再复制回来,不过要跳过右区间剩下的元素
}

当左右区间元素个数均为 k/2时,左区间不用参与复制的元素个数为 i 的条件为前 i 小的元素都被分在了左边,并且第 i + 1大元素被分在了右边,但第k/2大元素要单独考虑。

右边不用参与复制的元素个数和左区间是一样的,因为是对称的。于是得到下面的级数:

下面是我的测试数据(元素互异且随机排列):

可以看出这项优化平均减少O(n)次多余的操作。

使叶子“变粗”

同样归并排序也可以用插入排序来优化,同样也是因为对于小规模的数据插入排序常数小的缘故。并且由于引进插入排序我们知道叶子宽度一定大于1,因此可以简化归并函数:

#define FACTOR 10    ///叶子宽度
#define MID(i) (i >> 1) /// i / 2

int* helper;    ///辅助数组

/**********************************************
    函数:优化版归并函数
    说明:合并有序区间[low, mid)和[mid, high)
          right为右区间的遍历指针
          helper为局部变量覆盖全局声明
          这样做是为了减少代码行数
    时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high, int* right, int* helper)
{
    ///收缩左边界,不再考虑左区间原本位于正确位置的元素
    while(*low <= *right)
    {
        if(++low >= mid)
            return;  ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回
    }
    int* left = low;    ///设置左区间遍历指针
    *(helper++) = *(right++);   ///别浪费上面循环失败的比较结果。。。
    ///因为叶子大于1,所以之前那两句就不用了。
    while(true)
    {
        if(*left <= *right)  ///相等时下标小的优先,使得算法稳定
        {
            *(helper++) = *(left++);
            if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置
        }
        else
        {
            *(helper++) = *(right++);
            if(right >= high) ///右区间空了
            {
                while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
                break;  ///跳出外层循环
            }
        }
    }
    while(right > low) *(--right) = *(--helper);   ///再复制回来,不过要跳过右区间剩下的元素
}

/*************************************
    函数:优化版插入排序
    说明:对区间[low, high)的数据排序
    时间复杂度:O(n + inverse)
*************************************/
static void improvedInsertionSort(int* low , int* high)
{
    for(int* cur = low; ++cur < high; )     ///实际是从第二个元素开始插入,因为第一个已经有序了
    {
        int tmp = *cur; ///临时保存要插入的值
        int* destPos = cur;     ///记录当前要插入的元素的正确安放位置,这里初始化为本来的位置
        ///把第一次测试单独提出来
        if(*(--destPos) > tmp)
        {
            do
            {
                *(destPos + 1) = *destPos;
            }while(--destPos >= low && *destPos > tmp);     ///测试上一个是否是目标位置
            *(destPos + 1) = tmp;   ///最后一次测试失败使得destIndex比实际小1
        }
    }
}

/*****************************************
    函数:归并排序
    说明:对区间[low, high)范围的数据排序
    时间复杂度:O(nlgn)
*****************************************/
void mergeSortRoutine(int* low, int* high)
{
    int range = high - low; ///区间元素个数
    if(range > FACTOR)   ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题
    {
        int* mid = MID(range) + low; ///求出分割点
        ///递归求解子问题
        mergeSortRoutine(low, mid);
        mergeSortRoutine(mid, high);
        merge(low, mid, high, mid, helper);   ///再合并两个子问题
    }
    else improvedInsertionSort(low, high);
}

/****************************************
    函数:归并排序“外壳”
****************************************/
void mergeSort(int* low, int* high)
{
    helper = new int[high - low];   ///辅助数组最多也就存输入的元素数
    if(helper != nullptr)
    {
        mergeSortRoutine(low, high);
        delete[] helper;    ///释放内存
    }
    else return;    ///空间不足,没法启动归并排序
}

自底向上的归并排序

上面递归版的归并排序的分解步骤是通过划分父问题才得到子问题,但其实子问题是可以被我们直接找到的,因为一个子问题的标识是一个区间,而区间是由左右端点的数字确定的。

所以我们可以直接计算出我们目前想得到的子问题的区间:

#define MID(i) (i >> 1) /// i / 2
#define NEXT_GAP(i) (i <<= 1)    ///下一个步长

int* helper;    ///辅助数组

/**********************************************
    函数:优化版归并函数
    说明:合并有序区间[low, mid)和[mid, high)
          right为右区间的遍历指针
          helper为局部变量覆盖全局声明
          这样做是为了减少代码行数
    时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high, int* right, int* helper)
{
    ///收缩左边界,不再考虑左区间原本位于正确位置的元素
    while(*low <= *right)  if(++low >= mid)  return;  ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回
    int* left = low;    ///设置左区间遍历指针
    *(helper++) = *(right++);   ///别浪费上面循环失败的比较结果。。。
    if(right >= high) ///右区间空了
        while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
    else while(true)
        {
            if(*left <= *right)  ///相等时下标小的优先,使得算法稳定
            {
                *(helper++) = *(left++);
                if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置
            }
            else
            {
                *(helper++) = *(right++);
                if(right >= high) ///右区间空了
                {
                    while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
                    break;  ///跳出外层循环
                }
            }
        }
    while(right > low) *(--right) = *(--helper);   ///再复制回来,不过要跳过右区间剩下的元素
}

/************************************************
    函数:自底向上版归并排序
    说明:对区间[low, low + range)范围的数据排序
    时间复杂度:O(nlgn)
************************************************/
void mergeSortRoutine(int* low, int* high, int range)
{
    for(int gap = 2; MID(gap) < range; NEXT_GAP(gap))
        for(int* right = low + gap, * mid = low + MID(gap); mid < high; right += gap, mid += gap)
            merge(right - gap, mid, right > high ? high : right, mid, helper);
}

/****************************************
    函数:归并排序“外壳”
****************************************/
void mergeSort(int* low, int* high)
{
    helper = new int[high - low];   ///辅助数组最多也就存输入的元素数
    if(helper != nullptr)
    {
        mergeSortRoutine(low, high, high - low);
        delete[] helper;    ///释放内存
    }
    else return;    ///空间不足,没法启动归并排序
}

不过不知道为什么在我电脑上递归版反而更快,真是奇怪。

后记

如果内容有误或有什么提议请在下面评论,谢谢。

时间: 2024-10-05 04:19:14

归并排序(MergeSort)的相关文章

排序算法THREE:归并排序MergeSort

1 /** 2 *归并排序思路:分治法思想 O(nlogn) 3 * 把数组一分为二,二分为四 4 * 四和为二,二和为一 5 * 6 */ 7 8 /** 9 * 归并排序主方法 10 *@params 待排序的数组 11 *@params 初始位置 12 *@params 最终位置 13 */ 14 15 public class MergeSort 16 { 17 public static void mergeSort(int[] resouceArr, int begin , int

归并排序 MergeSort

递归的归并排序 // MergeSorttest.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <VECTOR> #include <IOSTREAM> using namespace std; void Merge(vector<int> & v,int begin,int mid,int end){ in

算法Sedgewick第四版-第1章基础-2.1Elementary Sortss-006归并排序(Mergesort)

一. 1.特点 (1)merge-sort : to sort an array, divide it into two halves, sort the two halves (recursively), and then merge the results. As you will see, one of mergesort’s most attractive properties is that it guarantees to sort any array of N items in t

算法(第四版)学习笔记(三)——归并排序

归并排序  MERGE-SORT 时间复杂度: 空间复杂度: 一.原地归并排序 步骤:将两个已有序数组组合到一个数组中并排好序. 1 #include<stdio.h> 2 #include<malloc.h> 3 int *c; 4 void merge(int *a, int *b,int m,int n); 5 int main() 6 { 7 int n,m,sum,i; 8 scanf("%d",&n); 9 int *a=(int *)mal

常见排序集合(冒泡排序,选择排序,直接插入排序,二分插入排序,快速排序,希尔排序,归并排序)

一下是一些常见的排序算法: 交换元素(后面算法都有用到): // 交换元素 private static void swap(int[] a, int i, int j) { int temp; temp = a[i]; a[i] = a[j]; a[j] = temp; } 冒泡排序(有优化): // 冒泡排序(优化①,②,③,④) private static void bubbleSort(int[] a) { boolean flag = false;// ①表示整个序列是无序的 for

经典排序之归并排序

归并排序,其的基本思路就是将数组分成二组A,B,如果这二组组内的数据都是有序的,那么就可以很方便的将这二组数据进行排序.如何让这二组组内数据有序了? 可以将A,B组各自再分成二组.依次类推,当分出来的小组只有一个数据时,可以认为这个小组组内已经达到了有序,然后再合并相邻的二个小组就可以了.这样通过先递归的分解数列,再合并数列就完成了归并排序. #include <stdio.h> #include <stdlib.h> /* 归并排序 mergeSort 说明:mergeSort需

排序算法(Java语言)——归并排序

归并排序mergesort中基本的操作是合并两个已排序的表.因为这两个表已排序,所以若将输出放到第三个表中,则该算法可以通过对输入数据一趟排序完成.基本的合并算法是取两个输入数组A和B,一个输出数组C,以及3个计数器Actr.Bctr.Cctr,他们初始置于对应数组的开始端.A[Actr]和B[Bctr]中的较小者被拷贝到C的下一个位置,相关的计数器向前推进一步.当两个输入表有一个用完的时候,则将另一个表中剩余部分拷贝到C中. 合并另个已排序的表的时间显然是线性的,因为最多进行N-1次比较,其中

归并排序模板

归并:将两个或两个以上的有序表组合成一个新的有序表. 一般情况不用这种方式排序,只有在将多个有序序列整合成一个有序序列是才会用到归并排序,才能想归并效率体现的最高. 算法描叙: 1.设初始序列含有n个记录,则可看成n个有序的子序列,每个子序列长度为1. 2.两两合并,得到 n/2 个长度为2或1的有序子序列. 3.再两两合并,--如此重复,直至得到一个长度为n的有序序列为止. 个人见解:也就是先将一个无序的序列对半拆分,将拆分后的序列继续拆分,直到拆分成一个元素为一个序列为止,然后在将两个这样的

【自考】排序算法-插入、交换、选择、归并排序

碎碎念: 记得当初第一年的时候.接触算法.有那么两个视频.跳舞的.讲的是冒泡排序跟选择排序.当时看了好多遍终于懂了.这次多了一些算法.学起来也还好吧.咱是有基础的人.找到了以前的视频.有的就发了.没找到的就没法.其实算法并不难.绕绕就明白了.先别看代码- - 思维导图 插入排序 从头到尾巴.从第二个开始.向左进行插入.这里说的插入是指作比较.直到比较出比自己小的就插入到他的前面. 例子 1 7 4 8 6 5 插入排序 [1]7 4 8 6 5 [1 7] 4 8 6 5 [1 4 7]  8

java实现归并排序算法

第一, 分解: 把待排序的 n 个元素的序列分解成两个子序列, 每个子序列包括 n/2 个元素. 第二, 治理: 对每个子序列分别调用归并排序MergeSort, 进行递归操作 第三, 合并: 合并两个排好序的子序列,生成排序结果. 来自CODEGO.NET的代码: public static void mergeSort(int[] a, int[] tmp, int left, int right) {     if (left < right) {       int mid = left