IT技术博客大学习 共学习 共进步

深入浅出插入类排序算法(直接插入, 折半插入, 希尔排序)

狂Shell - Happy Hacking狂Shell - Happy Hacking 2013-04-07 13:18:50 浏览 7,423 次

1) 直接插入排序:

   通俗的生活例子:比如大家在玩牌的时候, 每次从桌面拿到一张牌后, 然后把它放到手里牌合适的位置(这个合适位置的选择,需要将拿到的牌和手中的牌进行比较).

   假如有5张牌, 牌序为 J, 10, K, Q, A (假设排列在前面的牌先拿到):

   

   如图所示, 开始拿到”J”这张牌, 第一张牌肯定是有序的,所以序列为:

   已排序的系列               原始序列

   J~~~~~~~~~~~~~~~~~~~~~~10~K~Q~AJ~~~~~~~~~~~~~~~~~~~~~~10~K~Q~A

   然后, 拿10这张牌, 发现10 < J小, 所以10要放到J的位置, J向后移动一个位置.,序列为:

   10~J~~~~~~~~~~~~~~~~~~~K~Q~A10~J~~~~~~~~~~~~~~~~~~~K~Q~A

   紧接着拿K这张牌, K > J, 所以不需要移动直接插入到J后面, 序列为:

   10~J~K~~~~~~~~~~~~~~~~~Q~A10~J~K~~~~~~~~~~~~~~~~~Q~A

   再拿Q发现Q < K, 所以K向后移动一个, Q继续和J进行比较发现Q > J , J不需要移动,Q直接插入到原来K的位置即可. 序列为:

   10~J~Q~K~~~~~~~~~~~~~~~A10~J~Q~K~~~~~~~~~~~~~~~A

   最后拿A这张牌, 发现A > K, 所以不需要任何移动直接把A插入到K后面即可. 最后排序序列为:

   10~J~Q~K~A10~J~Q~K~A

   从以上的整理扑克的过程可以发现插入排序是个非常简单的排序算法, 也就是从待排序的牌中拿一张插入到已经排好序的过程, 非常好理解.

   我们来通过代码角度(纯C)来看下,算法的如何演变成程序级的.

void InsertSort(int* _piArr, int len) {
    int i = 1;
    for (; i<len; i++)   {
        int j = i;
        int x = _piArr[i];  /**< 1 */
        while ((j > 0) && (_piArr[j-1] > x)) { /**<  2 */
            _piArr[j] = _piArr[j-1];  /**< 3  */
            j--;
        }
        _piArr[j] = x; /**< 4 */
    }
}
  • 1.获取第一个值,相当于从桌面获取一张未排序的牌

  • 2.把获取的牌和排好序的牌从右往左进行比较,如 上面提到的第二次排序(J > 10), 则进行位置交换

  • 3.把J向后移动(代码这边会在循环外做),并把10插入到J的位置

  • 4.把J真正移动到10原来位置

  •    这个插入程序可以调试通过, 可以正常运行, 我们来分析下它的时间复杂度, 把程序修改如下:

void InsertSort(int* _piArr, int len) {
    int i = 1;
    int compareCount = 0;
    int moveCount = 0;
    for (; i<len; i++)   {
        int j = i;
        int x = _piArr[i];
        compareCount ++;  /**< 一次比较 */
        while ((j > 0) && (_piArr[j-1] > x)) {
            compareCount ++;  /**< 循环比较 */
            moveCount ++; /**< 循环移动 */
            _piArr[j] = _piArr[j-1];
            j--;
        }
        _piArr[j] = x;
    }
    printf("compareCount=%d\n", compareCount);
    printf("moveCount=%d\n", moveCount);
}

   因为排序中涉及到的两个基本操作是关键字的比较记录的移动,因此时间复杂度是取决于这两个因素的,另外从直接插入排序的算法可见, 这两个操作的次数取决于排序的状态正序和逆序,当为正序时, 所需进行的关键字比较和记录的移动次数是最少的, 当为逆序时,所需进行的关键字比较和记录的移动次数最多.

   我们输入正序1,2,3,4,5和逆序5,4,3,2,1分别看下时间复杂度如何.

   正序运行结果:

   

   逆序运行结果:

   

   通过上图可以得出结果:

                       正序                   逆序

   比较次数     4                       14

   移动次数     0                       10

   事实证明如我刚才所说, 但是一般排序数据没有我们这么理想化,所以把最好和最坏情况进行求平均值就可以了.正序比较次数一般情况为n-1n-1, 正序移动次数一般情况为00, 逆序比较次数一般情况为(n+2)(n-1)\over 2(n+2)(n-1)\over 2, 逆序移动次数一般情况为(n+4)(n-1)\over 2(n+4)(n-1)\over 2,所以一般情况下插入排序的平均时间复杂度为O(n^2n^2); 空间复杂度为O(11). (在内存中只使用了一个临时变量)

2)折半插入排序

   折半插入排序也是插入排序的一种, 和直接插入排序区别在于插入点不同. 由于插入排序是在一个前面已经排好的序列中进行插入操作, 直接插入是需要进行顺序从右到左进行比较(可以想下刚才我们的插入扑克牌的算法), 假如对 10, J, Q, K, A 要插入9 ,直接插入排序要从A开始依次比较, 一直比较到10. 而假如我们在即将要插入9的时候看到的是Q, 因为Q > 9 这样就知道9应该插入在牌的左边位置. 这种算法就是折半排序算法.

   现在我们举个简单例子, 来更透彻的理解折半插入算法.

   我们还是用扑克牌作为例子, 之前我们排好序的扑克为: 10, J, Q, K, A . 已经在有序状态.下面我们要插入 6, 8, 小王, 大王.

   

   如图所示, 我们将有序的和待排的建立成如上关系, 用两个指针分别指向最小值(low) 和最大值(high). 很明确我们就是要得到中间值 middle = (low+high)/2 并向下取整. 好, 让我们讲待排序的牌插入进来.

   首先插入6. 计算原有序的牌的中间牌是:(0+4)\over 2(0+4)\over 2 = 2 即Q的位置, 因为 6 < Q, 所以6应该插入在Q左边的序列中(注意这里明显比直接插入大大减少了比较次数). 然后把high的指针改为middle(2)-1 = 1, low仍然不变. 如下图所示:

   

   继续计算插入位置 middle = (0+1)/2 = 0.5 向下取整为0 . 0位置的值为10, 6 < 10,所以应该插入在10左边序列中. 继续移动high指针, 发现high = middle(0)-1 = -1 < low. 说明折半查找结束. 6应该插入在10之前就可以了.

   最后先依次向后移动10之后的元素(包括10), 然后将6插入到10的位置.(这里注意,真正实现程序的时候, 数组要申请的合适比如 上面的情况 总共有9个元素, 需要申请9个, 不然就会移动产生溢出了.)

   同理插入8类似, 这里向做左边插入就不再详细说, 我们将6和8插入后, 数组如下:

   

   这里有个要注意下,当折半查找完毕后, 需要把low和high指针都初始化到适合的位置. 如上图所示.

   下面我们说下插入小王的过程, 按照前面过程先计算中间位置middle = (0+6)/2 = 3 中间值为 J , 发现 J < 小王. 然后改变指针low = middle(3)+1 = 4 .如图所示:

   

   继续求中间值, middle = (4+6)/2 = 5, 为K, K < 小王. 所以继续插在K的右边有序序列中, 移动low指针, low = middle(5) + 1 = 6. 如图:

   

   再求中间值(6+6)/2 = 6 , A < 小王 , 所以插入A的右边位置. 移动low指针 low=middle(6)+1 = 7 > high , 所以折半查找结束. 小王插入在A右边位置即可. 大王排序如小王.

   最后排序后的结果为:

   

   从上面的排序过程可以看出, 折半排序大大减少了元素之间的比较次数, 而没有减少元素的移动次数, 所以时间复杂度还是和直接插入排序一样为O(n2), 空间复杂度也一样O(1).

   代码实现如下(调试通过,并测试没有问题):

void BinaryInsertSort(int* _piArr, int len) {
    int i = 1, j = 0;
    int low = 0;  /**< 1 */
    int high = 0;
    int middle = 0;
    for (; i<len; i++)   {
         /**< 减少比较次数和移动次数 */
        if (_piArr[i] >= _piArr[i-1]) {
            continue;
        }
        low = 0;
        high = i - 1;
        j = i;
        int x = _piArr[i];
        while (low <= high) {
            middle = (low+high)/2;
            if (x < _piArr[middle]) { /**< 2 */
                high = middle - 1;  /**< 3 */
            } else if (x > _piArr[middle]) {
                low = middle + 1;   /**< 4 */
            /**< 别忘了相等情况 */
            } else if (x == _piArr[middle]){
                low = middle + 1;
            } else {
                break;
            }
        }
        while (j > low) { /**< 5 */
            _piArr[j] = _piArr[j-1];
            j--;
        }
        _piArr[low] = x;
    }
}
  • 1.一般情况下整个数组都是随机的,都处在无序状态,所以low和high指针都指向第一个元素0位置

  • 2.在中间位置左边序列

  • 3.改变high指针high= middle - 1

  • 4.改变low指针low = middle + 1

  • 5.移动元素

  • 2)希尔排序

    之前的直接插入排序和折半插入排序的平均时间复杂度为O(n2), 而现在说的这个希尔排序在时间复杂度上是有所突破的.平均时间复杂度为O(nlog_2nnlog_2n).  那它是怎么减少平均时间复杂度的呢.

       大家都知道直接插入排序,在数据量非常小情况下或基本有序的情况下, 但是这都是两个极端情况, 大部分情况还是不能满足于此, 所以希尔排序在这两个个地方做了改进, 它将待排序的序列按某种规则分成几个序列, 分别再对这个几个子序列进行直接插入排序. 故希尔排序又叫 缩小增量排序. 缩小和增量体现在算法哪呢? 让我们通过例子来理解算法本质.

       假设我们以序列10, 9, 8, 7, 6,  5, 4, 3, 2, 1这个10个逆序序列进行讨论.

       首先我们要做的是 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1. (这个序列是逐渐缩小的.)

       增量的选择非常有讲究, 好的增量的特征是:

  • 最后一个增量必须为1

  • 尽量避免序列中的值互为倍数的情况

  •    一般情况下初次取序列的一半为增量,以后每次减半, 知道增量为1.

       这里我们开始选择5,3,1作为增量.  下面我们开始进行希尔排序(缩小增量排序).

       初试序列: 10~~9~~8~~7 ~~6~~5~~4~~3~~2~~1 10~~9~~8~~7 ~~6~~5~~4~~3~~2~~1

       1)以增量55进行分割, 得到以下子序列:

       子序列1: ~10~~~~~~~~~~~~~~~~5~10~~~~~~~~~~~~~~~~5

       子序列2: ~~~~~9~~~~~~~~~~~~~~~~~4~~~~~9~~~~~~~~~~~~~~~~~4

       子序列3: ~~~~~~~~~8~~~~~~~~~~~~~~~~~3~~~~~~~~~8~~~~~~~~~~~~~~~~~3

       子序列4: ~~~~~~~~~~~~7~~~~~~~~~~~~~~~~~2~~~~~~~~~~~~7~~~~~~~~~~~~~~~~~2

       子序列5: ~~~~~~~~~~~~~~~~6~~~~~~~~~~~~~~~~~1~~~~~~~~~~~~~~~~6~~~~~~~~~~~~~~~~~1

       再对这些子序列进行直接插入排序!

       子序列1: ~5~~~~~~~~~~~~~~~~10~5~~~~~~~~~~~~~~~~10

       子序列2: ~~~~~4~~~~~~~~~~~~~~~~~9~~~~~4~~~~~~~~~~~~~~~~~9

       子序列3: ~~~~~~~~~3~~~~~~~~~~~~~~~~~8~~~~~~~~~3~~~~~~~~~~~~~~~~~8

       子序列4: ~~~~~~~~~~~~2~~~~~~~~~~~~~~~~~7~~~~~~~~~~~~2~~~~~~~~~~~~~~~~~7

       子序列5: ~~~~~~~~~~~~~~~~1~~~~~~~~~~~~~~~~~6~~~~~~~~~~~~~~~~1~~~~~~~~~~~~~~~~~6

       第一次希尔排序结束得到:

       5~~4~~3~~2 ~~1~~10~~9~~8~~7~~6 5~~4~~3~~2 ~~1~~10~~9~~8~~7~~6

       2)再以增量3进行分割,得到以下子序列.

       子序列1: ~5~~~~2~~~~9~~~~~6~5~~~~2~~~~9~~~~~6

       子序列2: ~~~4~~~~~1~~~~8~~~4~~~~~1~~~~8

       子序列3: ~~~~~3~~~~~~10~~~~7~~~~~3~~~~~~10~~~~7

       再对这些子序列进行直接插入排序, 得到:

       子序列1: ~2~~~~5~~~~6~~~~~9~2~~~~5~~~~6~~~~~9

       子序列2: ~~~1~~~~~4~~~~8~~~1~~~~~4~~~~8

       子序列3: ~~~~~3~~~~~~7~~~~10~~~~~3~~~~~~7~~~~10

       第二次希尔排序结果为:

       2~~1~~3~~5~~4~~7~~6~~8~~10~~92~~1~~3~~5~~4~~7~~6~~8~~10~~9

       最后再进行增量为1的分割, 其实就是所有数据进行直接插入排序. 得到:

       1~~2~~3~~4~~5~~6~~7~~8~~9~~101~~2~~3~~4~~5~~6~~7~~8~~9~~10

       让我们根据上述算法步骤再来看看希尔排序的优势:它每次都把整个数列分成有序的n份,然后再插入每一趟排序将使得数列部分有序,从而使得以后的插入排序很快找到插入位置,希尔排序在所有插入排序算法中时间复杂度和空间复杂度是较好的一种.希尔排序利用不管开始的序列多么庞大, 关键字多么无序, 只要将这个很大的序列划分为多个子序列(每个子序列的规模非常小), 对其再进行直接插入排序,效率是可观的.随着缩小增量(5, 3, 1), 子序列也越来越大, 但是子序列已经接近基本有序, 所以再进行直接插入算法 ,则排序效率依然很高.

       希尔算法的实现(简单用n/over 2n/over 2取缩小增量序列):

    void ShellInsert(int*a, int inc, int len) {
        for (int i=inc; i<len; i+=inc) {   /**< 根据增量序列inc进行分割 */
            int j=i;
            x=a[i];
            while (j>0 && a[j-inc]>x) {
                a[j]=a[j-inc];
                j-=inc;
            }
            a[j]=x;
        }
    }
    void ShellSort(int* a, int len) {
         int inc = len/2;
         for (; inc>=1; inc/=2) {
             ShellInsert(a, inc, len);
         }
    }

       另外从整个算法角度看, 希尔排序算法依赖一种称之为“排序增量”的数列,不同的增量将导致不同的效率, 所以很有必要对增量序列的选择进行单独进行研究, 对时间复杂度的研究其实也和增量序列的选择有关, 所以这里先到这, 以后单独对希尔排序的增量序列进行研究写篇博文.

    原创文章,转载请注明出处: http://www.crazyshell.org/blog/?p=903

    建议继续学习

    1. 如何使用1M的内存排序100万个8位数 (阅读 12,222)
    2. 快速排序(Quicksort)的Javascript实现 (阅读 11,542)
    3. 腾讯-1亿个数据取前1万大的整数-题解答 (阅读 9,943)
    4. 深入浅出交换类排序算法(冒泡排序,快速排序) (阅读 6,982)
    5. Java程序员必知的8大排序算法 (阅读 5,560)
    6. Mysql中的排序优化 (阅读 5,520)
    7. Vim(gVim)对排序的妙用 (阅读 5,280)
    8. 快速排序详细分析 (阅读 4,920)
    9. 深入浅出选择类排序算法(简单选择排序,堆排序) (阅读 4,540)
    10. Learning to rank在淘宝的应用 (阅读 4,421)