插入排序与希尔排序优化思路

本篇针对插入排序和希尔排序来讲优化思路,有部分内容因为前一篇文章已经有交代,这里不重复,如果你还没有看,请先看前一篇。我们来看插入排序的动图演示

插入排序保证前面一块是有序的情况下,新增的一个元素通过向前交换到合适的位置,所以叫做插入排序。

插入排序基本实现

我们先来看插入排序的基本实现

void insert_sort(sort_element_t * beg, sort_element_t * end)
{
    for (sort_element_t* i = beg + 1; i < end; ++i)
    {
        sort_element_t val = *i;
        sort_element_t* j = i;
        for (; j > beg && val < j[-1]; --j)
        {
            *j = j[-1];
        }
        *j = val;
    }
}

插入排序即使不做任何优化,效率也明显高于上篇所说的冒泡排序和选择排序。那么按照惯例,先上双向看看效果

插入排序优化,双向插入

不过双向版本的插入排序可能并不是你想象中那么好写,甚至于有点不太好懂

点击展开

void double_insert_sort(sort_element_t * beg, sort_element_t * end)
{
    if (end - beg > 1)
    {
        for (sort_element_t* i = beg + 1, *j = end - 1; ; )
        {
            sort_element_t val = *i;
            sort_element_t* t = i;
            for (; t > beg && val < t[-1]; --t)
            {
                *t = t[-1];
            }
            *t = val;

            if (++i > j)
            {
                if (*i < *j)
                    ++j;
                else
                    break;
            }

            val = j[-1];
            t = j;
            for (; t < end && *t < val; ++t)
            {
                t[-1] = *t;
            }
            t[-1] = val;

            if (i > --j)
            {
                if (*j < j[-1])
                    --i;
                else
                    break;
            }
        }
    }
}

我们来看看测试结果,在 VS2015 下对 45000 个int排序

int 1 2 3 4 5 6 7 8 9 10 Avg
insert2 0 0 376 165 174 176 177 0 0 80 114
insert 0 0 359 165 174 177 192 0 0 86 115
odd_even 0 0 575 1259 1242 1224 532 0 2 855 568
cocktail 0 0 1252 1474 1537 1611 642 1 2 813 733
select2 0 1068 809 1076 1082 1073 1072 1074 1073 1065 939
bubble 0 0 1192 2190 2220 2193 832 1 4 1376 1000
select 2159 2174 2157 2137 2166 2160 2155 2149 2146 2138 2154

这个优化效果非常不明显,有种吃力不讨好的感觉。那么,我们换个方法,来试试加大步长吧,这个变动让算法有了另一个名字

希尔排序

希尔排序的坑特别深,你需要有心理准备。希尔排序和梳排序类似,都是通过使用较大的步长让元素更快速地到达正确位置附近

不过在步长的选择上,希尔排序与梳排序差别巨大。首先梳排序的步长选择较为简单,每次乘以0.8即可,这个数字就不要管怎么来的了,我也不知道。梳排序的研究资料较少,但希尔排序则不一样,步长选择的研究非常多,也就是说光是步长的选择就是一门学问,这就是一个大坑,而且这个步长选择对性能的影响十分明显。我们先来看最早的希尔排序版本,最早的版本使用步长每次乘以0.5。

void shell_sort_0_5(sort_element_t * beg, sort_element_t * end)
{
    if (end - beg > 1)
    {
        size_t incre = (size_t)(end - beg) / 2;
        for (; incre >= 1; incre /= 2)
        {
            sort_element_t * bound = beg + incre;
            for (sort_element_t * i = bound; i < end; ++i)
            {
                sort_element_t * p = i;
                sort_element_t val = *i;
                for (; p >= bound && val < *(p - incre); p -= incre)
                    *p = *(p - incre);
                *p = val;
            }
        }
    }
}

后来,Pratt 与 Knuth 改进了新的步长序列
1, 4, 13, 40, 121, ...
得到以下代码

void shell_sort(sort_element_t * beg, sort_element_t * end)
{
    if (end - beg > 1)
    {
        size_t incre = 1;
        while (incre < (size_t)(end - beg - 1) / 3)
            incre = incre * 3 + 1; // A003462, Pratt, 1971, Knuth, 1973
        for (; incre >= 1; incre /= 3)
        {
            sort_element_t * bound = beg + incre;
            for (sort_element_t * i = bound; i < end; ++i)
            {
                sort_element_t * p = i;
                sort_element_t val = *i;
                for (; p >= bound && val < *(p - incre); p -= incre)
                    *p = *(p - incre);
                *p = val;
            }
        }
    }
}

更多关于步长的研究可以参考维基百科,本篇不对步长做讲解。后文的优化将使用这个版本的代码

希尔排序优化,运用带哨兵的插入排序

带哨兵的插入排序是什么东西?其实就是指以下这样的插入排序

void unguarded_insert_sort(sort_element_t * beg, sort_element_t * end)
{
    for (sort_element_t* i = beg + 1; i < end; ++i)
    {
        sort_element_t val = *i;
        sort_element_t* j = i;
        for (; val < j[-1]; --j)
        {
            *j = j[-1];
        }
        *j = val;
    }
}

和前面的插入排序的区别在哪?就是少了j > beg &&,不做边界判断。不做边界判断确实能运行得更快,但不会越界吗?我们来考虑以下这种情况
int arr[8] = {-1, 8, 9, 4, 5, 6, 1, 6};
然后我们排序的时候写unguarded_insert_sort(arr + 1, arr + 8);它就能正确排序而且不会发生越界,因为最前面存在比需要排序的元素都要小的数,而那个数就是哨兵,它充当着让内循环退出的作用,也就是说,在调用这个函数的时候,只要保证beg的左边存在比右边都要小的元素就行了。

有了这个能怎么优化呢?我们把步长1的部分单独拆出来,写成下面这样

void shell_sort_o1(sort_element_t * beg, sort_element_t * end)
{
    if (end - beg > 1)
    {
        size_t incre = 1;
        while (incre < (size_t)(end - beg - 1) / 3)
            incre = incre * 3 + 1; // A003462, Pratt, 1971, Knuth, 1973
        for (; incre > 1; incre /= 3)
        {
            sort_element_t * bound = beg + incre;
            for (sort_element_t * i = bound; i < end; ++i)
            {
                sort_element_t * p = i;
                sort_element_t val = *i;
                for (; p >= bound && val < *(p - incre); p -= incre)
                    *p = *(p - incre);
                *p = val;
            }
        }
        insert_sort(beg, end);
    }
}

这个能理解吧,步长1的时候完全就是插入排序,但真正厉害的在下面这步,要注意到这种写法在变为步长1之前,它的步长是4,我们可以证明整个数组的最小值一定在前4个数中,即前一个步长的范围内。这里不给出具体证明,有兴趣你可以自己试试证明。有了这个结论,前4个元素用插入排序排好了后,后面的就可以使用unguarded_insert_sort

void shell_sort_o2(sort_element_t * beg, sort_element_t * end)
{
    if (end - beg > 10)
    {
        size_t incre = 1;
        while (incre < (size_t)(end - beg - 1) / 3)
            incre = incre * 3 + 1; // A003462, Pratt, 1971, Knuth, 1973
        for (; incre > 1; incre /= 3)
        {
            sort_element_t * bound = beg + incre;
            for (sort_element_t * i = bound; i < end; ++i)
            {
                sort_element_t * p = i;
                sort_element_t val = *i;
                for (; p >= bound && val < *(p - incre); p -= incre)
                    *p = *(p - incre);
                *p = val;
            }
        }
        insert_sort(beg, beg + 4);
        unguarded_insert_sort(beg + 4, end);
    }
    else
    {
        insert_sort(beg, end);
    }
}

这个哨兵技巧在其它的排序里面甚至别的问题里同样也会存在。除了这个还有没有别的优化空间呢?例如,shellsort的最优情况下的时间复杂度还是O(nlogn),那有没有办法使得最优情况时间复杂度下降到O(n)呢?确实有办法,如果在某一轮发现没有数进行交换,那么就立即转成带次数限制的插入排序进行尝试,具体请参阅我的sort项目

测试结果

以下是在 VS2015 上对 4,500,000 个int排序的测试

int 1 2 3 4 5 6 7 8 9 10 Avg
bao_shell 5 6 57 373 368 148 52 35 77 350 147
shell_o2 47 49 68 553 540 187 59 53 106 500 216
shell_sort 47 54 77 563 547 190 67 59 111 509 222
shell_0_5 80 89 114 606 601 236 82 89 143 572 261
comb_sort 166 161 180 492 477 214 171 169 223 478 273

其中bao_shell是我在sort项目中写的希尔排序,使用了更优的步长序列,里面包含更多的优化技巧,本篇就不一一介绍了。

Avatar
抱抱熊

一个喜欢折腾和研究算法的大学生

Related

comments powered by Disqus