挖掘算法中的数据结构:O(n*logn)排序算法之 归并排序(自顶向下自底向上) 及 算法优化

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了挖掘算法中的数据结构:O(n*logn)排序算法之 归并排序(自顶向下自底向上) 及 算法优化相关的知识,希望对你有一定的参考价值。

在上一篇博文中学习了时间复杂度为 O(n^2)的几个排序算法(选择、插入、冒泡、希尔排序),其中尤为需要注意的是插入排序,在近乎有序的测试用例条件下,此算法的效率会高于O(n*logn)的排序算法,所以它的效率不容小觑。

但是O(n*logn)的排序算法与O(n*logn)之间还是有质变的区别,综合而言性能更优。此篇文章将讲解时间复杂度为O(n*logn)的有关算法,涉及到的知识点有:

  • 归并排序法思想、实现、优化
  • 自底向上的归并排序算法
  • 自顶向下、自底向上两种归并排序算法比较

挖掘算法中的数据结构(一):选择、插入、冒泡、希尔排序 及 O(n^2)排序算法思考


O(n*logn) 和 O(n^2)算法比较

在讨论排序算法的时间复杂度时,O(n*logn)毫无疑问被视为最优解,而O(n^2)总被大家鄙夷不屑。这两者的差距究竟有多大呢,可查看下图进行了解:

也许你在计算机跑程序时感觉时间消耗得都是差不多,并无太大区别,那是因为你使用的测试用例数量太小了。

例如上图中当测试数量为10时,O(n*logn)只是比O(n^2)快3倍,区别并不大,但是随着数量逐渐增大,O(n*logn)的优势会愈加明显!当n=10^5时,其实这个测试数量也不是很大,O(n*logn)O(n^2)快6000倍,相当于使用O(n*logn)算法计算需要1小时,而O(n^2)却需要6000小时,天壤之别已经呈现出来了。

因此,在算法的世界中才会一直有新的被优化的算法产生,从而达到人们追求高效的理想,我们更应该去学习理解。


一. 归并排序

1. 算法思想

首先通过以下动画展示来了解归并排序算法的简单思路,待排序的数组为8,6,2,3,1,5,7,4

(1)整体思想路

  • 首先将数组对半划分,分别对左、右边的数组进行排序。
  • 还要分别继续划分左、右边的数组,将左边的数组继续对半划分…
  • 一直这样划分直到划分的“左边”或“右边”只剩下两个元素,对每一个小部分进行排序。
  • 小部分排序完后,进行向上归并,即与旁边的“小组”进行归并(注意:此时各小组的排序已经完成,需要做的步骤是将其归并!),层层往上,直到由多个小组归并成一个大组,归并完成,排序也完成。

(2)算法复杂度

为何需要将一个数组分层那个多个小组,再进行一层层向上归并?

查看下图是对8个元素进行排序、归并一步步划分的过程。此数组一层层划分下来,总共分成了3级,到第3级时,每个“小组”只剩下1个元素了。8个元素,以2划分,3次下来只剩下1个元素,这个“3”层来源于:log以2为底8 = 3。

所以如果有n个元素,便有 log以2为底n 个层级,虽然分成了不同的部分,但是每一层处理的个数是一样的,如果整个归并过程可以O(n)的时间复杂度来解决的话,那么最后整个过程的时间复杂度就是O(n*logn)

以上就是O(n*logn)时间复杂度的主要来源:通过二分法达到 logn的层级,每一个层级使用 O(n)的时间来处理排序,最后总时间复杂度就是O(n*logn),而这整个过程可以通过递归来完成。

(3)归并过程的思想

接下来需要解决的问题就是两个已经各自排序好的小组是如何归并到一个大组的?

这里可不是采用之前学过的插入算法或其它的,毕竟这样做那之前个小组之间的有序性就无其他任何意义了,主要思路通过以下动画来了解(现在已经有两组搁在排序好的数组,需要将其归并到一个数组 2,3,6,8,1,4,5,7):

  • 首先需要两个临时数组空间来辅助完成归并过程,这也是归并排序的缺点,虽然减少算法复杂度到O(n*logn),但是使用了除O(n)之外的额外空间。(不过在目前计算机中时间效率比空间效率更为重要,可存储的数据规模越来越大,因此设计算法通常优先考虑时间复杂度)
  • 紧接着还需要使用3个索引在数组内进行追踪:
    • k蓝色箭头:归并过程中最终需要跟踪的位置。
    • i、j红色箭头:代表两个排序好的数组当前需要考虑的元素。
  • 首先两个红色箭头分别指向数组中的第一个元素,即待比较元素,而蓝色箭头则是指向最终待归并数组的第一个位置,等待合适元素赋值。比较开始,2比1小,将1赋值到待归并数组的第一个位置
    • 蓝色箭头所指的第一个位置的合适元素已找到,箭头向后移,等待第二个合适元素。
    • 而原本指向1的红色箭头向后移,因为1已经找到合适位置了
    • 而指向2的红色箭头不动,等待下一次比较。
  • 后面依次类推。

总之,这个归并排序的过程就是两个已排序好的数组来比较头部的元素,取最小值放入最终数组中。此过程也依赖于3个索引值,i、j指向两个有序数组中正在比较大小的元素,k代表比较后的元素应当归并的位置 ,即下一个元素应该放入的位置。

另外,为了跟踪i、j、k的出界情况,还需要在数组中间作标记,左、右边两个有序数组分别标记为l(left)r(right),对于中间位置标记为m(middle)


2. 代码实现

接下来,通过以上思路,由递归思想来完成这个归并过程:

(1)mergeSort函数

目的:主函数中调用此方法即可(暴露给上层调用)

在函数mergeSort递归中还有一个排序的过程,所以再定义一个函数__mergeSort,取名代表它其实是一个私有的函数,被mergeSort所调用,对于用户而言只需调用mergeSort即可。

(2)__mergeSort函数

目的:递归使用归并排序,对arr[l…r]的范围进行排序

  • 首先进行边界判断,若 l 大于或等于 r ,即可停止递归。
  • 计算中间值,对左右分开的两个部分进行归并排序,即分别递归。
  • 左、右两部分分别排序好,就要进行归并操作,将两个部分归并到一个数组中。这里需要调用一个新的函数__merge

(3)__merge函数

目的:将arr[l…mid]和arr[mid+1…r]两部分进行归并

此函数需要进行的逻辑操作在上一点归并过程思想中已详细讲解,来查看具体实现:

  • 创建临时空间,大小为两个带归并数组的总长度,将数组中所有元素赋值到临时空间中。
  • 通过循环,循环次数为待归并数组长度次数,即r-l,在循环中可按照之前分析的逻辑进行代码实现,获取两有序数组头部值中较小值赋值到待归并数组,此处判断有4中情况(根据不同情况进行赋值、移动下标):
    • 如果左半部分元素已经全部处理完毕
    • 如果右半部分元素已经全部处理完毕
    • 左半部分所指元素 < 右半部分所指元素
    • 左半部分所指元素 >= 右半部分所指元素

查看以下代码:

// 将arr[l...mid]和arr[mid+1...r]两部分进行归并
template<typename  T>
void __merge(T arr[], int l, int mid, int r)

    /* VS不支持动态长度数组, 即不能使用 T aux[r-l+1]的方式申请aux的空间
    * 使用VS的同学, 请使用new的方式申请aux空间
    * 使用new申请空间, 不要忘了在__merge函数的最后, delete掉申请的空间:)
    */
    T aux[r-l+1];
    //T *aux = new T[r-l+1];

    for( int i = l ; i <= r; i ++ )
        aux[i-l] = arr[i];

    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid+1;
    for( int k = l ; k <= r; k ++ )

        if( i > mid )  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j-l]; j ++;
        
        else if( j > r )  // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i-l]; i ++;
        
        else if( aux[i-l] < aux[j-l] )   // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i-l]; i ++;
        
        else  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j-l]; j ++;
        
    

    //delete[] aux;


// 递归使用归并排序,对arr[l...r]的范围进行排序
template<typename T>
void __mergeSort(T arr[], int l, int r)

    if( l >= r )
        return;

    int mid = (l+r)/2;
    __mergeSort(arr, l, mid);
    __mergeSort(arr, mid+1, r);
    __merge(arr, l, mid, r);


//主函数中调用此方法即可(暴露给上层调用)
template<typename T>
void mergeSort(T arr[], int n)
    __mergeSort( arr , 0 , n-1 );

3. 比较归并排序与插入排序的时间效率

以下测试将对比归并排序和插入排序,分别在无序、有序的测试用例下的时间:

int main() 
    int n = 50000;

    // 测试1 一般性测试
    cout<<"Test for random array, size = "<<n<<", random range [0, "<<n<<"]"<<endl;
    int* arr1 = SortTestHelper::generateRandomArray(n,0,n);
    int* arr2 = SortTestHelper::copyIntArray(arr1, n);

    SortTestHelper::testSort("Insertion Sort", insertionSort, arr1, n);
    SortTestHelper::testSort("Merge Sort",     mergeSort,     arr2, n);

    delete[] arr1;
    delete[] arr2;

    cout<<endl;

 // 测试2 测试近乎有序的数组

    int swapTimes = 10;
    assert( swapTimes >= 0 );

    cout<<"Test for nearly ordered array, size = "<<n<<", swap time = "<<swapTimes<<endl;
    arr1 = SortTestHelper::generateNearlyOrderedArray(n,swapTimes);
    arr2 = SortTestHelper::copyIntArray(arr1, n);

    SortTestHelper::testSort("Insertion Sort", insertionSort, arr1, n);
    SortTestHelper::testSort("Merge Sort",     mergeSort,     arr2, n);

    delete[] arr1;
    delete[] arr2;

    return 0;

结果显示

现象分析

  • 测试一结论:对于无序的数组,归并排序的时间甩插入排序几条街,更加高效!
  • 测试二结论:对于近乎有序的数组,数组越有序,插入排序的时间性能越趋近于O(n),比归并排序高效(注意前提,近乎有序数组的条件,这里测试的swap time是10,即随机将完全有序的数组交换10组数据达到近乎有序的条件,此部分在上篇博文有讲解,可查看!)。

总结

比较插入排序(InsertionSort)归并排序(MergeSort)两种排序算法的性能效率,整体而言, 归并排序的性能最优, 对于近乎有序的数组的特殊情况, 还是之前介绍的插入排序更胜一筹

归并排序是我们学习的第一个O(nlogn)复杂度的算法,可以在1秒之内轻松处理100万数量级的数据。

注意

不要轻易尝试使用选择排序(SelectionSort)插入排序(InsertionSort)或者冒泡排序(BubbleSort)处理100万级的数据,否则,你就见识了O(n^2)的算法和O(nlogn)算法的本质差异!


4. 代码优化

在实现代码逻辑后,按照我们的套路接下来就要考虑优化问题。

(1)优化一

其实所有的排序算法中都存在一种优化,就是递归到底优化。_merge函数中的第一个判断是当只剩下一个元素时才返回,事实上当方法递归到元素较少时,可使用插入排序来提高性能,由以下两个原因:

  • 当待排序的数组元素较少时,近乎有序的情况概率较大,此时插入排序有优势。
  • 虽然插入排序的时间复杂度是O(n^2)级别,而归并排序是O(n*logn),但是别忽视这两者都依赖于常数系数n,当n较小时,插入排序是稍快于归并排序

所以优化一:函数一开始判断当递归到底只剩下一定值时(可自行修改,不要过大,我这里设定为15)时,剩下的数组采用插入算法进行排序

(2)优化二

思考_merge函数中的逻辑,其中对左右两个部分进行了递归之后,没有考虑两部分的大小问题,一律进行归并过程。事实上有可能出现正好左部分的最后一个值(即最大值)小于右部分的第一个值(即最小值),这样其实整个数组是有序的,无需再进行归并过程,直接跳过即可。

所以优化二:在执行merge操作前,判断两个子数组是否需要merge(可能存在本身就有序的情况)

注意:这种优化实现就是多加了一个if语句判断,这样在处理近乎有序数组的情况下会节省时间,但是判断本身就是一个新的耗时操作,所以此种优化更适用于近乎有序数组排序

(3)代码展示

【__merge函数不作修改,在此不粘贴了】

// 对arr[l...r]范围的数组进行插入排序
template<typename T>
void insertionSort(T arr[], int l, int r)

    for( int i = l+1 ; i <= r ; i ++ ) 

        T e = arr[i];
        int j;
        for (j = i; j > l && arr[j-1] > e; j--)
            arr[j] = arr[j-1];
        arr[j] = e;
    
    return;


// 使用优化的归并排序算法, 对arr[l...r]的范围进行排序
template<typename T>
void __mergeSort2(T arr[], int l, int r)

    // 优化1: 对于小规模数组, 使用插入排序
    if( r - l <= 15 )
        insertionSort(arr, l, r);
        return;
    

    int mid = (l+r)/2;
    __mergeSort2(arr, l, mid);
    __mergeSort2(arr, mid+1, r);

    // 优化2: 对于arr[mid] <= arr[mid+1]的情况,不进行merge
    // 对于近乎有序的数组非常有效,但是对于一般情况,有一定的性能损失
    if( arr[mid] > arr[mid+1] )
        __merge(arr, l, mid, r);


template<typename T>
void mergeSort2(T arr[], int n)
    __mergeSort2( arr , 0 , n-1 );



二. 自底向上的归并排序

1.算法思想

在理解了归并排序的思想后,其实不一定非要按照自顶向下的思路,可以考虑自底向上的过程,查看以下过程:

  • 将此数组按照从坐到右的顺序两两划分成多个小组来进行归并排序的过程(一个组有2个元素)。
  • 在两个元素归并排序完成后,再按照从坐到右的顺序将两个组进行归并到一个组(即1个组有4个元素)。
  • 依次类推。


2. 代码实现

在此过程中,并不需要递归,而是采用迭代来实现归并排序,代码如下:

  • 首先最外层循环需要对归并的个数进行遍历,size从1开始遍历到n,每次循环增加自身值,即(1->2->4->8)
  • 内存循环就是每一轮在归并过程起始的元素位置,位置从0开始到n - sz,每次循环增加2个size,即第一轮从0~size-1、从size~2size-1这两部分进行归并,第二轮从2size~3size-1、从3size~4size-1这两部分进行归并。注意:这里代码编写需要严谨考虑越界问题。

以下代码中__merge归并过程函数相同,在此不重复粘贴

// 使用自底向上的归并排序算法
template <typename T>
void mergeSortBU(T arr[], int n)
    for( int sz = 1; sz < n ; sz += sz )
        for( int i = 0 ; i < n - sz ; i += sz+sz )
            // 对 arr[i...i+sz-1] 和 arr[i+sz...i+2*sz-1] 进行归并
            __merge(arr, i, i+sz-1, min(i+sz+sz-1,n-1) );

以上代码实现过程中有个特点即未使用到数组的特性,即不是通过索引值来获取元素值。正因如此,可使用O(n*logn)的时间对链表这样的数据结构进行排序,这也是个很常见的算法问题,需仔细琢磨!


3. 代码优化

这里可优化的两处同自顶向下归并排序法相同,以下稍作总结:

  • 对于小数组, 使用插入排序优化。(千万不要轻视插入排序的效率,虽然是O(n^2),但在数量少、有序数组的前提下,效率不容忽视)
  • 在内循环中,由于两个小组内部各自已是有序,对于arr[mid] <= arr[mid+1]的情况,所以代表无需进行归并过程。

优化后代码如下:

【insertionSort函数前面已经贴出,在此不重复粘贴】

template <typename T>
void mergeSortBU(T arr[], int n)
for( int i = 0 ; i < n ; i += 16 )
        insertionSort(arr,i,min(i+15,n-1));

    for( int sz = 16; sz < n ; sz += sz )
        for( int i = 0 ; i < n - sz ; i += sz+sz )
            // 对于arr[mid] <= arr[mid+1]的情况,不进行merge
            if( arr[i+sz-1] > arr[i+sz] )
                __merge(arr, i, i+sz-1, min(i+sz+sz-1,n-1) );

注意

Merge Sort BU 也是一个O(nlogn)复杂度的算法,虽然只使用两重for循环,Merge Sort BU也可以在1秒之内轻松处理100万数量级的数据。不要轻易根据循环层数来判断算法的复杂度,Merge Sort BU就是一个反例。


4. 两种归并排序的时间效率比较

测试数量为100000,比较自顶向下、自底向上两种归并排序在无序和有序数组测试的情况下,两者的测试结果如下:

(测试代码不粘贴,可自行查看源码)

结论

整体而言, 两种算法的效率是差不多的。但是如果进行仔细测试, 自底向上的归并排序会略胜一筹,而且它更适用于链表这样的数据结构进行排序(面试中提出会加分)。

分析

我们对归并排序主要有两个优化。第一,使用插入排序对小数组进行处理。这个优化对两个算法的作用是相同的。

关键在于第二条优化。也就是在真正执行merge操作前,先看一下两个子数组是不是真的需要merge。要注意:在自顶向下的归并排序中。这个优化可以发生在非常高的层次,也就是面对两个很大的数组,也可以通过这步优化,使得我们不需要进一步处理两个大数组!但是在自底向上的归并排序中,我们却不能跨过具有这种性质的大数组,依然要一步一步从小数组向上构造。由于这个原因,自底向上的归并排序的速度被拖慢了,但是自顶向下的归并排序递归调用需要额外的开销!

所以,按理说还是自底向上的归并排序较为高效

还想要知道这两者详细的比较可查看:

http://coding.imooc.com/learn/questiondetail/3208.html




下一篇文章将介绍O(n*logn)排序算法中的另一个鼎鼎大名的算法——快速排序及其衍生算法。

以上所有源码皆在liuyubo老师的github中:
https://github.com/liuyubobobo/Play-with-Algorithms

如有错误,虚心请教~

以上是关于挖掘算法中的数据结构:O(n*logn)排序算法之 归并排序(自顶向下自底向上) 及 算法优化的主要内容,如果未能解决你的问题,请参考以下文章

挖掘算法中的数据结构:堆排序之 二叉堆(Heapify原地堆排序优化)

挖掘算法中的数据结构:O(n^2)排序算法之 选择插入冒泡希尔排序 及 优化

挖掘算法中的数据结构:排序算法总结 和 索引堆及优化(堆结构)

经典排序算法学习笔记之二——快速排序

常用的排序算法和时间复杂度

数据结构与算法之--高级排序:shell排序和快速排序未完待续