排序3-插入选择排序

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u013904227/article/details/88621056

【Github pages】

排序2-冒泡排序
排序1-几大经典排序算法

本篇文章把插入排序与选择排序合在一起介绍了,插入排序与选择排序的实际时间消耗总体上都会比冒泡排序要更少一点,但是基本上它们的平均时间复杂度是一样的。另外,在对比的时候我全部采用了随机数组的形式测试,没有专门测试过倒序或者其它局部有规律的数组。

本文就不贴完整的代码段了,只贴最核心的部分,不然显得太乱了。这里先贴下本文中有用得到的一些通用函数:

static inline void insert_elem(int *sort_butt, int sel_pos, int insert_pos)
{
    int insert_tmp = sort_butt[sel_pos];
    int i = 0;

    for (i = sel_pos-1; i >= insert_pos; i--)
        sort_butt[i+1] = sort_butt[i];

    sort_butt[insert_pos] = insert_tmp;
}

经典插入排序

插入排序区分了两个区间,一个是未排序区间,一个是已排序区间,每次都从未排序区间抽取一个数插入到已排序区间的合适位置,同时保持已排序区间的有序性,等整个数组遍历完毕之后就达成了有序的要求。

其基本的排序过程如上图所示,红色箭头指向的就是已排序区间的最后一个元素,紫色箭头的起始端就是未排序区间的第一个元素,每次取出未排序区间的第一个元素,然后把它插入到已排序区间的合适位置,重复此步骤知道遍历完整个数组,排序完毕。

看起来很简单嘛,这样的代码写起来也确实是比较简单的,但是其中有不少可以优化的点,先搞下没有任何优化、凭借着我的第一直觉写出来的代码:

    for (i = 1; i < size; i++) {
        for (idx = 0; idx < i; idx++) {
            if (sort_butt[idx] > sort_butt[i])
                insert_elem(sort_butt, i, idx);
        }
    }

其中 insert_elem 就是文章开头时候贴出来的那段代码,目的是为了插入一个数组元素到数组的指定位置,这个插入的操作十分的粗暴,就是很简单的挨个挪挪,实际上单单就插入这个动作本身来讲,这样以及可以了,没必要再去搞一些深度优化。

那么上面的那段代码是否有可以改进的地方呢?答案肯定是有的。可以优化的地方就是那个查找插入点以及插入数值的那个地方。上面的那段代码关于这部分的行为包括两个步骤:

  1. 从已排序区间的开头(也就是数组下标为0的位置)开始查找,直到找到一个比当前取出来的未排序元素值还要大的元素。
  2. 把取出的元素插入到这个大的元素的前一个位置,这个过程涉及到数组的部分区域元素搬移操作。

那么可以优化的点呢,一个是从查找的地方做优化,比如使用二分查找法,一个是从插入的地方做优化,比如从已排序区间的末尾往前查找,如果比当前元素大,就把查找项往后挪一个位置,直到找到第一个小于等于当前元素的数组项,终止插入操作。

上面两个优化的本质都是为了减少整个插入过程的时间,一个是通过加速查找来实现,一个是通过减少冗余的数组项遍历步骤来实现。先说下第二个,具体的代码优化方式如下:

for (i = 1; i < size; i++) {
    insert_tmp = sort_butt[i];
    for (idx = i; idx >= 1 && sort_butt[idx-1] > insert_tmp; idx--)
        sort_butt[idx] = sort_butt[idx-1];

    sort_butt[idx] = insert_tmp;
}

可以明显的看到内层循环的区别,与之前不一样,这里的内层循环是从后往前开始的,在循环判断数值大小的同时就把数据给搬运过去了,判断结束的那一刻,整个插入的过程也结束了,如果把已排序区间的长度记为 N,插入的位置在第 K 项,那么优化过之后的数据访问只包括 N-K+1 项,而未优化的就需要 N 项。所以这个整体的时间会比未优化之前的少很多,基本上少的时间遵循正态分布 0~N 项访问时间的正态分布。

下面再贴下二分查找形式的优化:

static inline int binsearch(int *sort_butt, int sel_pos)
{
    int left_pos = 0, right_pos = sel_pos;
    int search_idx = 0, find_pos = sel_pos;

    while (left_pos <= right_pos) {
        search_idx = left_pos + (right_pos-left_pos)/2;
        if (sort_butt[search_idx] > sort_butt[sel_pos]) {
            find_pos = search_idx;
            right_pos = search_idx-1;
        } else
            left_pos = search_idx+1;
    }

    return find_pos;
}

for (i = 1; i < size; i++)
    insert_elem(sort_butt, i, binsearch(sort_butt, i));

在数据量巨大的情况下,这种方法会比没有经过任何优化以及第二种优化的方式都要更快,因为二分查找的时间复杂度是 O(logN),当然需要注意的是,这里二分查找的对象是第一个大于给定值的数组项,而不是等于或者任意一个大于的,关键字是:第一个大于。其实我也不确定我那个二分查找的代码是否存在 bug,但是目前使用随机数测试还没有测出来问题,二分查找是后面的问题了,我这里就先凑合用着。

变形插入排序

如果觉得前面的那些还是不够快,这里就有一种变形的插入排序,名曰:希尔排序(shell sort)。希尔排序就是把 gap 这个玩意儿又玩了一遍。犹记得梳排序这种冒泡排序的变体就是把步长给改成 gap /= 1.3 这种类型了,没错,希尔排序也是这样干的。先贴个代码:

void shell_insertion_sort(int *sort_butt, int size, bool ascend)
{
    int i = 0, j = 0, idx = 0, gap_idx = 0;
    int gap[] = {701, 301, 132, 57, 23, 10, 4, 1, -1};

    int insert_tmp = 0;
    int gap_val = gap[gap_idx++];
    while (gap_val != -1) {
        for (i = gap_val; i < size; i++) {
            insert_tmp = sort_butt[i];
            for (idx = i; idx >= gap_val && sort_butt[idx-gap_val] > insert_tmp; idx-=gap_val)
                sort_butt[idx] = sort_butt[idx-gap_val];

            sort_butt[idx] = insert_tmp;
        }

        gap_val = gap[gap_idx++];
    }
}

这段代码的内层循环理解起来是有点麻烦的,先说下它的大致原理,希尔排序就是把原有的排序按照步长重新划分,下面有一张图:

其中 gap == 4 的时候,红色线是一组需要插入排序的子序列,蓝色、浅蓝和绿色也都是这样的一组子序列,分别对它们进行插入排序,下面紫色的是 gap == 1 的情况,那么它就相当于是整个的数组序列了。

为什么带有 gap 就可以加速排序的过程呢?其实原理跟之前冒泡排序里面的优化是原理是差不多的,也是尽快的加速整个数组的有序度,让后面较长的子序列能够以尽可能短的时间去完成排序过程,所以自然也就会加快整个的排序进度,可以看下冒泡排序里面的解释。

说回代码,代码的整个最外层循环是遍历所有的 gap 值,那个数组是怎么来的呢?我没有去深入研究过,不过有人研究过,反正这种是目前比较快的希尔排序 gap 取值方法之一了,还有其它奇形怪状的取值方式,可以自行试下。里面的子循环其实比较难以理解的是为什么 for (i = gap_val; i < size; i++) 这样子写,这个是把上面那幅图同为 gap == 4 的红、蓝、浅蓝、绿色杂糅到一块交叉进行处理的意思。

正常情况下呢,自然而然的想法就是先排红色的,然后再拍蓝色的。。。但是上面的代码它的排序过程如下:

  1. 红色的前两项,也就是 0 和 4 下标对应的两个值,此时 i == 4。
  2. 蓝色的前两项,也就是 1 和 4 下标对应的两个值,此时 i == 5。
  3. 浅蓝的前两项,也就是 2 和 5 下标对应的两个值,此时 i == 6。
  4. 绿色的前两项,也就是 6 和 6 下标对应的两个值,此时 i == 7。
  5. 红色的三项,也就是 0、4、8 下表对应的三个值,此时 i == 8。

对比着这个排序的目的看下最内层的循环与 i 的取值方法就知道为什么代码可以这样子写,这样写也遵循了前面的从后往前遍历已排序序列,同时做插入搬移动作的原则,尽可能的减少冗余的数据项遍历步骤,这种写法我是比较服气的。

选择排序

选择排序主要的操作对象是和插入排序反着来的,它的主要操作对象是未排序区间,每次都从未排序区间选择一个最大/最小的数放到已排序区间的末尾,可以看到它的主要时间消耗就浪费在这个找最大/最小值的操作上面了。

它的代码相对来说就比较简单了:
for (i = 0; i < size; i++) {
    min_idx_tmp = i;
    for (idx = i; idx < size; idx++) {
        if (sort_butt[idx] < sort_butt[min_idx_tmp])
            min_idx_tmp = idx;
    }

    if (i != min_idx_tmp) {
        swap_tmp = sort_butt[i];
        sort_butt[i] = sort_butt[min_idx_tmp];
        sort_butt[min_idx_tmp] = swap_tmp;
    }
}

/* 每次选择最大和最小两个值来进行排序 */
int faster_selection_sort(int *sort_butt, int size, bool ascend)
{
    int i = 0, idx = 0;
    int min_idx_tmp = 0, max_idx_tmp = 0;
    int swap_tmp = 0;
    int right_idx = size;

    for (i = 0; i < right_idx; i++) {
        min_idx_tmp = i;
        max_idx_tmp = right_idx-1;
        for (idx = i; idx < right_idx; idx++) {
            if (sort_butt[idx] < sort_butt[min_idx_tmp])
                min_idx_tmp = idx;
            else if (sort_butt[idx] > sort_butt[max_idx_tmp])
                max_idx_tmp = idx;
        }

        if (min_idx_tmp == right_idx-1) {
            swap_tmp = sort_butt[min_idx_tmp];
            sort_butt[min_idx_tmp] = sort_butt[i];
            sort_butt[i] = swap_tmp;

            if (max_idx_tmp != i) {
                swap_tmp = sort_butt[max_idx_tmp];
                sort_butt[max_idx_tmp] = sort_butt[right_idx-1];
                sort_butt[right_idx-1] = swap_tmp;
            }
        } else {
            swap_tmp = sort_butt[max_idx_tmp];
            sort_butt[max_idx_tmp] = sort_butt[right_idx-1];
            sort_butt[right_idx-1] = swap_tmp;

            swap_tmp = sort_butt[min_idx_tmp];
            sort_butt[min_idx_tmp] = sort_butt[i];
            sort_butt[i] = swap_tmp;
        }

        right_idx--;
    }

    return 0;
}

第一段代码是最简单的选择排序代码,后面的那个是对选择排序的优化,区别于每次只选择一个极值,这里每次选择最大与最小两个极值。代码为毛比之前的看起来啰嗦了很多呢?考虑到有下面的几种情况(按照从小到大排序的情况说明这种问题所在):

  1. 最小值在未排序区间的末尾,最大值在未排序区间的开头。
  2. 最小值在未排序区间的末尾,最大值在未排序区间非开头的任意点。
  3. 最小值在未排序区间非末尾的任意点,最大值在未排序区间的开头。
  4. 最大值最小值都不在开头或者末尾的位置。
  5. 最小值在未排序区间的开头,最大值在未排序区间的末尾。

其中第一种是只要求两者交换一次的,第二种要求必须得先交换最小值所在的位置,然后再交换最大值所在的位置,第三种要求必须先交换最大值所在的位置,然后再交换最小值所在的位置,第四种就没有限制了,第五种根本就无需进行任何交换(原本第五种情况我是有写到代码判断里面的,但是看起来这种情况发生的几率很小很小,对整体速度的提升没有太大帮助,于是就把这个选项去掉了)。

至于为什么有上面的限制,直接手画下交换的序列图就可以知道了,如果没有那些限制的话,最后会导致数据丢失、大小错位等问题,所以这部分编码稍微麻烦了一点。

End

希尔排序的排序时间相较于其它的类型是质的飞跃,虽然其时间复杂度还是做不到 O(nlogn),但是已经很快了,这个速度在我的虚拟机上面实测是与梳排序在同一个级别的,一个 10000 长度的随机数组,它们两个跑出来的时间都是 2ms 这个级别的,其它的最小也是 60+ms 的级别,最高到 450+ms,当然这个时间要看相对值,毕竟机器硬件配置不一样跑出来的结果也是不一样的。

列一个简单的时间测试:

算法 数组长度 时间消耗/ms
经典冒泡 10000 453
优化冒泡 10000 297
鸡尾酒 10000 250
梳排序 10000 2
插入排序 10000 109
优化的排序 10000 81
二分查找优化的插入排序 10000 62
希尔排序 10000 2
选择排序 10000 146
优化的选择排序 10000 87

Github 代码


想做的事情就去做吧

没有更多推荐了,返回首页