排序算法的思考和总结(一)-冒泡和选择排序

各种排序算法性能的优劣我们总要关注的有三点:一是时间复杂度;二是空间复杂度;三是排序算法的稳定性。

《排序算法的思考和总结(一)-冒泡和选择排序》
各种排序算法的可视化理解可以看这里:排序算法可视化

关于排序的稳定性,记不住的可以看这里:各种排序的稳定性

各种排序算法的学习可以看这里:白话经典算法系列

冒泡排序,插入排序,选择排序是三种基本排序,说它们是基本排序我想是因为它们的平均时间复杂度都是O(n2),然后它们都很简单易懂。

冒泡排序

有段时间,我真的分不清冒泡排序和选择排序,它们真的很像。后来我发现这和我冒泡排序的写法有关。
一开始我写冒泡排序核心部分是这么写的,思路是:每一轮都从当前位置开始往后找,每找到一个比头部位置小的数就换到头部。这样每一轮交换的结果都是把最小的数交换到了头部,最后完成排序。

for (int i = 0; i < n; i++){
    for (int j = i; j < n; j++){
        if (a[i] > a[j]){
            swap(a[i], a[j]);
        }
    }
}

这种写法的冒泡排序和选择排序的很像,选择排序每轮也是找最小的数,不过选择排序会记录下每轮中最小的数,最后将最小的元素换到头部,这样每轮只发生一次元素交换。而冒泡排序这里,每当发现比当前头部小的元素时就会交换,这样每轮可能会交换多次。

后来我发现教材上的冒泡排序都是另外一种写法,感觉真的像在冒泡。它的思路是每次比较的都是相邻的两个数,把小的数换到前面去。它写起来像这个样子:

for (int i = 1; i < n; i++){
    for (int j = n - 1; j >= i; j--){
        if (a[j - 1] > a[j])
            swap(a[j - 1], a[j]);
    }
}

对比我发现,我之所以记住的是前面一种冒泡写法,是因为前一种写法的思路比较顺畅。前一种写法两轮循环的思路都是从前往后,外循环是从0~n-1,内循环是从i~n-1,比较舒服。而教材上的冒泡写法,外循环是从1~n-1,内循环却又是从后往前n-1~i,而且外循环从下标1开始而不是从0开始,看着总是不舒服。不管怎样,还是教材上的写法比较“冒泡”,也不容易和选择排序混淆啦。
思考冒泡排序的过程,可以发现就是元素的比较和交换。在最好的情况下可能一次都不交换,最坏的情况下可能每次都交换。就算不是最好的情况(数组已经拍好了序),我们考虑数组基本有序的情况,如果还是按照上述算法冒泡排序,很多操作其实是浪费的。比如对{2,1,3,4,5};进行排序,实际上第一轮交换2和1的位置之后就已经排好了,但程序还是会继续运行到结束,这是不必要的。
基于这个考虑,我们可以对冒泡排序进行优化,设置有个标志位exchange,用来标志每轮排序中是否发生了元素的交换,如果没有发生元素交换,那么说明排序已经完成,就没有必要完成接下来的操作了。

template <typename T>
void bubbleSort3(T* source, int length)
{
    if (source == NULL || length < 2) return;
    bool exchange = false;              
    for (int i = 1; i < length; i++)
    {
        exchange = false;                       //默认没有发生元素的交换
        for (int j = length - 1; j >= i; j--)
        {
            if (source[j - 1] > source[j])
            {
                exchange = true;                //标记发生了元素交换,排序还未完成
                swap(source[j - 1], source[j]);
            }
        }
        if (exchange == false) return;          //排序已经完成,结束排序
    }
}

在此基础上的冒泡排序算法其实还可以再改进,上面算法第二层循环是从后往前,将大的数往后移。其实,第二层循环可以先从后往前算一遍,再从前往后算一遍, 目的是为了使序列尽快变得有序。这称为双向冒泡排序算法。

一个排序的例子如下所示:
排序前:45 19 77 81 13 28 18 1977 11
往右排序:19 45 77 13 28 18 19 7711 [81]
向左排序:[11] 19 45 77 13 28 1819 77 [81]
往右排序:[11] 19 45 13 28 18 19 [77 77 81]
向左排序:[11 13] 19 45 18 28 19 [77 77 81]
往右排序:[11 13] 19 18 28 19 [45 77 77 81]
向左排序:[11 13 18] 19 19 28 [45 77 77 81]
往右排序:[11 13 18] 19 19 [28 45 77 77 81]
向左排序:[11 13 18 19 19] [28 45 77 77 81]
如上所示,括号中表示左右两边已排序完成的部份,当left >= right时,则排序完成。

双向冒泡排序对随机序列排序性能稍高于普通冒泡排序,但是因为是双向冒泡,每次循环都双向检查,极端环境下会出现额外的比较,导致算法性能的退化,比如“4、5、7、1、2、3”这个序列就会出现退化。
还有一种局部冒泡排序,又兴趣的可以自己看一下:局部冒泡排序

选择排序

选择排序的想法很简单粗暴,第一次找出最小的一个排在第一个,然后在剩下的数中找最小的排在第二个……依次进行下去,直到排完为止。

template <typename T>
void selectSort(T* a, int n)
{
    if (a == NULL || n < 2) return;
    for (int i = 0; i < n-1; i++)
    {
        int minIndex = i;
        for (int j = i+1; j < n; j++)
        {
            if (a[j] < a[minIndex]) minIndex = j;
        }
        swap(a[i], a[minIndex]);
    }
}

这很好理解,没什么好说的。值得注意的是,选择排序花费的时间是固定的,时间复杂度O(n2),它适用于元素规模大,但是排序码却比较小的序列。因为这种序列的排序,移动操作所花费的时间往往比比较操作的时间大的多,而其他算法移动操作的次数都要比选择排序来的多。
选择排序是一种不稳定排序序列。
选择排序的缺点比较明显,就是每一轮都要进行很多次比较,当然这些比较很多都是重复的。在此基础上做改进,于是就有了锦标赛排序(树形选择排序)。
关于树形选择排序,可以从一条面试题说起:给出一个长度为N的数组,找出最小的两个元素,最少需要多少次比较?
显然选出第一个最小的数要n-1次比较,然后我们也不希望再选出第二个数还要n-2次比较,这里就用到了树形选择排序。关于树形选择排序算法的有兴趣学习的可以自己搜索,反正是一种空间换时间的方法,时间复杂度O(nlogn),在我看来这个排序算法复杂不实用,知道就好,当然算法的思想还是挺值得了解一下。
为了克服树形排序需要较多辅助空间保存比较结果和与标记为无穷大的值进行多余比较的缺点。有了堆排序,它使比较次数达到了树形排序的水平,同时又不会增加额外的存储空间,只需一个记录大小的空间用于记录间的交换。堆排序

点赞