合并排序详解

看到一篇很详细的文章,将原文照抄如下:

—————————————————————————————————————–

—————————————————————————————————————–

文章作者:Yx.Ac
文章来源:勇幸|Thinking (http://www.ahathinking.com)

插入归并

归并排序的时间复杂度为O(nlgn),空间复杂度为O(n);

但是一般来讲,基于从单个记录开始两两归并的排序并不是特别提倡,一种比较常用的改进就是结合插入排序,即先利用插入排序获得较长的有序子序列,然后再两两归并(改进后的归并亦是稳定的,因为插入排序是稳定的)。之所以这样改进是有原因的:尽管插入排序的最坏情况是O(n^2),看起来大于归并的最坏情况O(nlgn),但通常情况下,由于插入排序的常数因子使得它在n比较小的情况下能运行的更快一些,因此,归并时,当子问题足够小时,采用插入排序是比较合适的。

复杂度分析

下面分析下插入归并排序最坏情况下的复杂度:假设整个序列长度为n,当子序列长度为k时,采取插入排序策略,这样一共有n/k个子序列。

子序列完成排序复杂度:最坏情况下,n/k个子序列完成排序的时间复杂度为O(nk)。证明:每个子序列完成插入排序复杂度为O(k^2),一共n/k个子序列,故为O(nk)。

子序列完成合并复杂度:最坏情况下,合并两个子序列的时间复杂度为O(n),一共有n/k个子序列,两两合并,共需要lg(n/k)步的合并,所以这些子序列完成合并的复杂度为O(nlg(n/k))。

所以改进后的插入归并排序的最坏情况的复杂度为O(nk+nlg(n/k)),这里k的最大取值不能超过lgn,显然如果k大于lgn,复杂度就不是与归并一个级别了,也就是说假设一个1000长度的数组,采用插入策略排序子序列时,子序列的最大长度不能超过10。

/* 二路归并
*在函数外申请一个临时数组作为参数传入 
*/ 
void Merge(int arr[], int beg, int mid, int end, int temp_arr[])
{     
    memcpy(temp_arr+beg, arr+beg,sizeof(int)*(end-beg+1)); // 更新临时数组       
    int i = beg;     
    int j = mid + 1;     
    int k = beg;  

    while(i <= mid && j <= end)     
    {        
        if(temp_arr[i] <= temp_arr[j])         
        {             
            arr[k++] = temp_arr[i++];         
        }
        else         
        {             
            arr[k++] = temp_arr[j++];         
        }     
    }    
    while(i <= mid)     
    {         
        arr[k++] = temp_arr[i++];     
    }     
    while(j <= end)     
    {         
        arr[k++] = temp_arr[j++];     
    } 
}   

void MergeSort(int arr[], int beg, int end,int temp_arr[]) 
{     
    if(beg < end)     
    {         
        int mid = (beg + end) / 2;         
        MergeSort(arr, beg, mid, temp_arr);
        MergeSort(arr, mid+1, end,temp_arr);
        Merge(arr, beg, mid, end, temp_arr);     
    } 
}   
/* 改进的归并算法:插入归并 
*  先通过插入排序得到较长的有序串,然后归并 
*  即,当分解的数组长度小于一定值时,不再分解,改用插入排序
*/ 
#define INSERT_BOUND 5   
void InsertSort(int arr[], int beg, int end) 
{     
    for(int i = beg+1; i <= end; ++i)     
    {         
        int temp = arr[i];         
        int j = i - 1;         
        while(j >= beg && arr[j] > temp)
        {             
            arr[j+1] = arr[j--];         
        }         
        arr[j+1] = temp;     
    } 
}

void Insert_MergeSort(int arr[], int beg,int,end, int temp_arr[]) 
{     
    if(end - beg + 1 <= INSERT_BOUND)     
    {         
        InsertSort(arr,beg,end);     
    }
    else
    {
        int mid = (beg + end) / 2;         
        Insert_MergeSort(arr, beg, mid, temp_arr);
        Insert_MergeSort(arr, mid+1, end,temp_arr);
        Merge(arr, beg, mid, end, temp_arr);
    }

}

原地归并

我们说归并排序相对于快排来讲,它需要O(n)的额外空间,这一度成为归并的缺点,不过好在归并排序也可以进行原地排序,只使用O(1)的额外空间。原地归并排序所利用的核心思想便是“反转内存”的变体,即“交换两段相邻内存块”,对于反转内存的相关文章,曾在文章“关于反转字符串(Reverse Words)的思考及三种解法”中对一道面试题做了分析。这一思想用到的地方很多,在《编程珠玑》中被称为“手摇算法”。通过手摇算法的交换内存的思想来进行原地归并又有不少变种,我们举例分析一种比较常见的情况,不同的方法还有基于二分查找的方法来确定交换的内存块,在《计算机编程艺术》中也有不同的思路提供,感兴趣见本文参考资料。

下面举例说明一种原地归并排序的思想。

我们知道,无论是基于单个记录的两两归并,还是利用插入排序先得到较长的子序列然后归并,在算法合并的过程中,我们都是在合并“两个相邻的有序子序列”。

在了解原地归并的思想之前,先回忆一下一般的归并算法,先是将有序子序列分别放入临时数组,然后设置两个指针依次从两个子序列的开始寻找最小元素放入归并数组中;那么原地归并的思想亦是如此,就是归并时要保证指针之前的数字始终是两个子序列中最小的那些元素。文字叙述多了无用,见示例图解,一看就明白。

假设我们现在有两个有序子序列如图a,进行原地合并的图解示例如图b开始

示意图

如图b,首先第一个子序列的值与第二个子序列的第一个值20比较,如果序列一的值小于20,则指针i向后移,直到找到比20大的值,即指针i移动到30;经过b,我们知道指针i之前的值一定是两个子序列中最小的块。

如图c,先用一个临时指针记录j的位置,然后用第二个子序列的值与序列一i所指的值30比较,如果序列二的值小于30,则j后移,直到找到比30大的值,即j移动到55的下标;

如图d,经过图c的过程,我们知道数组块[index, j) 中的值一定是全部都小于指针i所指的值30,即数组块 [index, j) 中的值全部小于数组块 [i, index) 中的值,为了满足原地归并的原则:始终保证指针i之前的元素为两个序列中最小的那些元素,即i之前为已经归并好的元素。我们交换这两块数组的内存块,交换后i移动相应的步数,这个“步数”实际就是该步归并好的数值个数,即数组块[index, j)的个数。从而得到图e如下:

示意图

重复上述的过程,如图f,相当于图b的过程,直到最后,这就是原地归并的一种实现思想,具体代码如下。

/* 原地归并
*  可二路归并亦可与插入排序相结合,如代码
*/
//reverse array
void reverse(int arr[], int size)
{
    int left = 0;
    int right = size -1;
    while(left < right)
    {
        int temp = arr[left];
        arr[left++] = arr[right];
        arr[right--] = temp;
    }
}

// swap [arr,arr+headSize) and [arr+headSize,arr+headSize+endSize)
void SwapMemory(int arr[], int headSize, int endSize)
{
    reverse(arr, headSize);
    reverse(arr + headSize, endSize);
    reverse(arr, headSize + endSize);
}

void Inplace_Merge(int arr[], int beg, int mid, int end)
{
    int i = beg;     // 指示有序串1
    int j = mid + 1; // 指示有序串2
    while(i < j && j <= end)
    {
        while(i < j && arr[i] <= arr[j])
        {
            ++i;
        }
        int index = j;
        while(j <= end && arr[j] <= arr[i])
        {
            ++j;
        }
        SwapMemory(&arr[i], index-i, j-index);//swap [i,index) and [index,j)
        i += (j-index);
    }
}

void Inplace_MergeSort(int arr[], int beg, int end)
{
    if(beg < end)
    {
        int mid = (beg + end) / 2;
        Inplace_MergeSort(arr, beg, mid);
        Inplace_MergeSort(arr, mid+1, end);
        Inplace_Merge(arr, beg, mid, end);
    }

    /* // 结合插入排序
    if(end - beg + 1 <= INSERT_BOUND)
    {
        InsertSort(arr,beg,end);
    }else
    {
        int mid = (beg + end) / 2;
        Inplace_MergeSort(arr, beg, mid);
        Inplace_MergeSort(arr, mid+1, end);
        Inplace_Merge(arr, beg, mid, end);
    }
    */
}

/* 简单测试用例 */
void main()
{
    int arr[] = {3,5,1,7,0,6,9,11,8};
    int temp_arr[] = {3,5,1,7,0,6,9,11,8};

    /* 测试不同的归并算法 */
    //MergeSort(arr,0,8,temp_arr);
    //Insert_MergeSort(arr,0,8,temp_arr);
    //Inplace_MergeSort(arr,0,8);

    for(int i = 0; i < 9; ++i)
    {
        cout<<arr[i]<<" ";
    }
    cout<<endl;

}

对于原地归并,它只是一种归并的手段,具体实现归并排序时,可以在二路归并中使用原地归并,也可以在基于插入排序改进的归并算法中采用,如代码所示。

(全文完)

参考资料:

《算法导论》第二章,思考2-1

基于手摇算法的原地归并
基于二分的原地归并
计算机编程艺术中的原地归并

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值