数据结构与算法|第八章:排序-上

数据结构与算法|第八章:排序-上

前言

排序应该是最常见,也是最重要的算法了,本系列文章将一些比较经典的算法进行整理,分享给大家,比如冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序;按照时间复杂度进行划分,分为 上、中、下 3 章进行发布。

1.项目环境

2.排序算法时间复杂度对比

排序算法时间复杂度是否基于比较
冒泡、插入、选择 O ( n 2 ) O(n^2) O(n2)
归并、快排 O ( n l o g n ) O(nlogn) O(nlogn)
桶、计数、基数 O ( n ) O(n) O(n)

3.如何分析排序算法

可以从以下几个方面进行分析

3.1 排序算法的执行效率

1.最好情况、最坏情况、平均情况时间复杂度

这三个复杂度我们已经在 第二章:复杂度分析-续 中讨论过了。

2.时间复杂度的系数、常数 、低阶

通常时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。

3.比较次数和交换(或移动)次数

基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

3.2 排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。

3.3 排序算法的稳定性

如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间相对位置不变。

这种情况再实际开发中也会见到,比如班级考试排名,A同学B同学 都考了 100 分,如果按照分数大小来排序,不能保证每一次 A同学B同学 的前后顺序。

怎么解决这个问题呢?一般我们能想到的比较简单的方法就是先按照名称进行排序,再按照分值进行排序,这样就能保证每次排序,A同学 的排名都在 B同学 前面,当然 B同学 比较吃亏(这不是重点),这个例子只是为了说明算法的稳定性。

4.冒泡排序(Bubble Sort)

4.1 原理图解

假设原数组为 [3,1,4,6,2,5],经过一次冒泡排序操作步骤大致如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qxWi0Wd4-1591417402844)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200605214122425.png)]
可以看到经过一次冒泡排序,[1,5,6] 这 3 个元素已经存储在正确的位置上了 ,按照上面的操作,我们再进行两次冒泡操作,就可以得到正确的排序效果了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s32Fd3as-1591417402846)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200605214713712.png)]

4.2 代码实现

我们这里写了两个冒泡算法

  • bubbleSort 是普通的,每次都是循环比较 n ∗ ( n − 1 ) n*(n-1) n(n1)
  • bubbleSortBetter 是优化之后的,如果发现没有数据交换,就表示完全有序,提前退出
  • count 只是为了打印次数,和算法无关可以忽略
public class BubbleSortDemo {
    public static void main(String[] args) {
        int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
        bubbleSort(numbers);
        bubbleSortBetter(numbers);
    }

    private static void bubbleSort(int[] numbers) {
        int count = 0;
        int size = numbers.length;
        for (int j = 0; j < size; j++) {
            for (int i = 0; i < size - 1; i++) {
                if (numbers[i] > numbers[i + 1]) {
                    int tmp = numbers[i + 1];
                    numbers[i + 1] = numbers[i];
                    numbers[i] = tmp;
                }
                count++;
            }
        }
        System.out.printf("循环[%d]次,结果为:%s\n", count, Arrays.toString(numbers));
    }

    private static void bubbleSortBetter(int[] numbers) {
        int count = 0;
        int size = numbers.length;
        for (int j = 0; j < size; j++) {
            boolean flag = false;
            for (int i = 0; i < size - 1; i++) {
                flag = true;
                if (numbers[i] > numbers[i + 1]) {
                    flag = false;// 有数据交换
                    int tmp = numbers[i + 1];
                    numbers[i + 1] = numbers[i];
                    numbers[i] = tmp;
                }
                count++;
            }
            if (flag) {// 表示没有发生交换,完全有序,可以提前退出
                break;
            }
        }
        System.out.printf("循环[%d]次,结果为:%s\n", count, Arrays.toString(numbers));
    }
}

执行结果:

循环[30]次,结果为:[1, 2, 3, 4, 5, 10]
循环[5]次,结果为:[1, 2, 3, 4, 5, 10]

4.3 排序分析

我们从 2. 如何分析排序算法 提到的三种方法进行分析

4.3.1 冒泡排序是原地排序算法吗?

是的,因为冒泡排序每次比较只涉及相邻数据的交换,每次都是申请一个常量级的临时存储空间,所以它的空间复杂度是 O(1)。

4.3.2 冒泡排序是稳定排序算法吗?

是的,因为只有前一个元素大于后一个元素的时候,我们才会发生位置交换,如果两个元素值相等,不会发生交换,所以是稳定排序。

4.3.3 时间复杂度

最好时间复杂度: O ( n ) O(n) O(n),因为即使给的数组是完全有序的,我们还是需要遍历比较一遍。

最坏时间复杂度: O ( n 2 ) O(n^2) O(n2),每遍历比较一遍(n 次),只有一个元素到达正确的位置上,那么我们需要做 n 次排序。

平均时间复杂度: O ( n 2 ) O(n^2) O(n2)

在计算 平均时间复杂度 之前需要先理解几个概念

1.有序度

有序元素对:a[i] <= a[j], 如果i < j。

拿我们上面的例子 [3, 1, 4, 10, 2, 5] 来说,有序元素对 有如下 10 个

  • [3,4] [3,10] [3,5]

  • [1,4] [1,10] [1,2] [1,5]

  • [4,10] [4,5]

  • [2,5]

2.满有序度

同理,对应一个倒序排列的数组,比如 [6,5,4,3,2,1] 的数组,有序度为 0 ,对于一个完全有序数组,比如 [1,2,3,4,5,6] 有序度为 5+4+3+2+1 = 15,计算公式为 ( n − 1 ) ∗ n / 2 (n-1)*n/2 (n1)n/2。我们将完全有序数组的有序度称为满有序度。

3.逆序度

有序元素对:a[i] <= a[j], 如果i > j。

方式和 有序度 相反,我们就不多做说明了。

结论:

排序的过程就是一个有序度增加,而逆序度减少的过程,直到有序度等于满有序度就说明排序完成了。

逆序度 = 满序度 - 有序度

分析:

冒泡排序包含两个操作,一个是比较一个是交换,每交换一次,有序度加 1,按我们上面的例子来看,有序度为 10,满序度为15,那么需要交换 15 - 10= 5 次。

对于包含 n 个元素的数组进行冒泡排序

平均交换次数,最坏情况,有序度为 0,需要交换 ( n − 1 ) ∗ n / 2 (n-1)*n/2 (n1)n/2 次,最好情况下,不需要交换,那么中间值我们可以取 ( n − 1 ) ∗ n / 4 (n-1)*n/4 (n1)n/4 来表示一个平均情况

比较次数,因为每次都需要进行比较,所以比较次数的复杂度是 O ( n 2 ) O(n^2) O(n2)

冒泡排序的平均时间复杂度就是交换次数+比较次数,去掉常量,系数,最终平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)

5.插入排序(Insertion Sort)

5.1 原理图解

往一个有序数组中添加一个新元素之后,如何保持数组的顺序性?

假设数组为 [3,5,6,9,13],现在需要插入新元素 4
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MYqgbH7h-1591417402847)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200606101911540.png)]
进行数据迁移,腾出位置 2
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sCUYoToq-1591417402849)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200606102045060.png)]
将元素 4 插入位置 2
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d57i6cP8-1591417402850)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200606102125753.png)]
理解上面的过程之后我们再来看插入排序的原理

首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

如图所示,要排序的数据是 [4,5,6,1,3,2],其中左侧为已排序区间,右侧是未排序区间。
在这里插入图片描述
插入排序的操作也包括两种,一种是比较,一种是移动。

以上图第三步为例,当将元素 1 插入到已排序区域,经过比较,元素1 需要放置数组 0 的位置,那么元素 [4,5,6] 都需要往后移动一位。

这个场景点像小学时期大家第一次上体育课,排队的时候,老师拉一个人出来,一个一个对比较高度,然后插入到中间,后面的同学自然向后移动一位,经过几次这种插入排序,我们站的队就是由高到低的顺序。

5.2 代码实现

public class InsertionSortDemo {
    public static void main(String[] args) {
        int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
        insertionSort(numbers);
    }

    private static void insertionSort(int[] numbers) {
        int size = numbers.length;
        for (int i = 1; i < size; i++) {
            int j = i - 1;// 表示已经排序的区域
            int value = numbers[i];// 插入的值
            // value 和已排序区域的每一个元素进行比较,查找插入的位置
            for (; j >= 0; j--) {
                if (numbers[j] > value) { // 如果已排序区域位置[j]的元素大于 value 值
                    numbers[j + 1] = numbers[j];// 往后移动一位
                } else {
                    break;
                }
            }
            numbers[j + 1] = value;// 在腾出的位置插入新元素
        }
        System.out.printf("排序结果为:%s\n", Arrays.toString(numbers));
    }
}

执行结果:

排序结果为:[1, 2, 3, 4, 5, 10]

5.3 排序分析

5.3.1 插入排序是原地排序算法吗?

是的,从实现代码中可以看到排序并没有使用额外的存储空间,所以它的空间复杂度是 O(1)。

5.3.2 插入排序是稳定排序算法吗?

是的,如果两个元素值相等,我们可以将后插入的元素,放在先插入的元素后面,这样就不会发生交换,所以是稳定排序。

5.3.3 时间复杂度

最好时间复杂度: O ( n ) O(n) O(n),因为即使给的数组是完全有序的,我们还是需要遍历比较一遍。

最坏时间复杂度: O ( n 2 ) O(n^2) O(n2),如果数组是倒序排列的,我们每次比较,都需要移动大量的数组元素

平均时间复杂度: O ( n 2 ) O(n^2) O(n2)

在数组中插入一个数据的平均时间复杂度是是 O ( n ) O(n) O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)

6.选择排序(Selection Sort)

6.1 原理图解

选择排序也分为已排序区间和未排序区间,但是不同的是,每次排序会从未排序区间找到最小的元素,将其放到已排序空间的末尾。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C39nZjon-1591417402852)(G:\workspace\csdn\learn-document\data-structure-algorithm\csdn\image-20200606113437068.png)]

6.2 代码实现

public class SelectionSortDemo {
    public static void main(String[] args) {
        int[] numbers = new int[]{3, 1, 4, 10, 2, 5};
        selectionSort(numbers);
    }

    private static void selectionSort(int[] numbers) {
        int size = numbers.length;
        for (int i = 0; i < size - 1; i++) {
            int minValue = numbers[i];
            int k = i;// 未排序区间最小值的下标
            for (int j = i + 1; j < size; j++) {// 找到未排序区间最小的元素
                if (minValue > numbers[j]) {
                    minValue = numbers[j];
                    k = j;
                }
            }
            numbers[k] = numbers[i];// 交换
            numbers[i] = minValue;// 将未排序区间最小元素插入到已排序区域的末尾
        }
        System.out.printf("排序结果为:%s\n", Arrays.toString(numbers));
    }
}

执行结果:

排序结果为:[1, 2, 3, 4, 5, 10]

6.3 排序分析

6.3.1 插入排序是原地排序算法吗?

是的,从实现代码中可以看到排序并没有使用额外的存储空间,所以它的空间复杂度是 O(1)。

6.3.2 插入排序是稳定排序算法吗?

不是,比如 [1,6,10,6,2,3],当元素 2 和元素 6(排第2) 交换的时候,6(原来排第2) 就排到 6(原来第4) 的后面,所以不是稳定排序。

6.3.3 时间复杂度

选择排序的最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度都为 O ( n 2 ) O(n^2) O(n2)

7.小结

排序算法是否原地排序是否稳定时间复杂度
冒泡排序最好: O ( n ) O(n) O(n) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2)
插入排序最好: O ( n ) O(n) O(n) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2)
选择排序最好: O ( n 2 ) O(n^2) O(n2) 最坏: O ( n 2 ) O(n^2) O(n2) 平均: O ( n 2 ) O(n^2) O(n2)

冒泡排序、选择排序在实际开发中使用的不多,从性能和优化角度上,插入排序更好。

选择排序就不需要比较了,从时间复杂度上就已经被 pass 了,我们看看冒泡和插入排序的区别,从交换的代码上来看,冒泡排序交换的次数更多,而且需要开辟一个临时存储空间,当数据量在一个比较合理且正常范围时,两种排序肯定还是优先选择插入排序。

8.参考

  • 极客时间 -《数据结构与算法之美》王争
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值