经典 O(n²)比较类排序算法

简介: 经典 O(n²)比较类排序算法

经典 O(n²)比较类排序算法

摘要:排序算法太多了,很多甚至连名字你都没听过,比如猴子排序、睡眠排序等。最常用的:冒泡排序、选择排序、插入排序、归并排序、快速排序、计数排序、基数排序、桶排序。根据时间复杂度,我们分三类来学习,今天要讲的就是 冒泡、插入、选择 排序算法。

排序算法 时间复杂度 是否基于比较
冒泡、插入、选择 O(n²)
快排、归并 O(nlog~n~)
桶、计数、基数 O(n)

十种常见的的排序算法可以分两大类:

  1. 比较类排序:通过比较来决定元素的相对次序,由于其时间复杂度无法突破 O(nlog~n~),因此也叫做非线性时间排序。
  2. 非比较类排序:不是通过比较元素来决定元素的相对次序,可以突破比较排序的时间下限,线性时间运行,也叫做线性时间非比较类排序。

经典算法

学会评估一个排序算法

学习算法,除了知道原理以及代码实现以外,还有更重要的是学会如何评价、分析一个排序算法的 执行效率、内存损耗、稳定性。

执行效率

一般通过如下方面衡量:

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

为何要区分这三种时间复杂度?第一,通过复杂度可以大致判断算法的执行次数。第二,对于要排序的数据有的无序、有的接近有序,有序度不同不同对于执行时间是不一样的,所以我们要区分不同数据场景下算法的性能。

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

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

3.比较次数移动(交换)数据次数基于比较排序的算法执行过程都会涉及两个操作、一个是比较,另一个就是元素交换或者数据移动。所以我们也要把数据交换或者移动次数考虑进来。

内存消耗

算法的内存消耗通过空间复杂度来衡量,不过在这里针对排序算法的内存算好还有一个新概念,原地排序就是特指空间复杂度为 O(1) 的算法,这次所讲的算法都是原地排序算法。

算法的稳定性

如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。** 比如 a 原本在 b 前面,而 a=b ,排序之后 a 仍然在 b 的前面。

比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。

这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用。

算法步骤

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
    持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

/**
 * 冒泡排序: 时间复杂度 O(n²),最坏时间复杂度 O(n²),最好时间复杂度 O(n),平均时间复杂度 O(n²)
 * 空间复杂度 O(1),稳定排序算法
 */
public class BubbleSort implements ComparisonSort {
    @Override
    public int[] sort(int[] sourceArray) {
        // 复制数组,不改变参数内容
        int[] result = Arrays.copyOf(sourceArray, sourceArray.length);
        if (sourceArray.length <= 1) {
            return result;
        }
        int length = result.length;
        for (int i = 0; i < length; i++) {
            // 设定标记,当没有数据需要交换的时候则说明已经有序,提前退出外部循环
            boolean hasChange = false;
            for (int j = 0; j < (length - 1) - i ; j++) {
                if (result[j] > result[j + 1]) {
                    // 数据交换
                    int temp = result[j];
                    result[j] = result[j + 1];
                    result[j + 1] = temp;
                    hasChange = true;
                }
            }
            if (!hasChange) {
                // 没有数据交换,已经有序,提前退出
                break;
            }
        }
        return result;
    }
}

那么问题来了,我们来分析下这个算法的效率如何,教大家学会如何评估一个算法:

1.冒泡是原地排序算法么?

因为冒泡的过程只有相邻数据的交换操作,属于常量级别的临时空间,所以空间复杂度是 O(1),属于原地排序算法。

2.是稳定排序算法?

只有交换才改变两个元素的前后顺序,当相邻数据相等,不做交换,所以相同大小的数据在排序前后都不会改变顺序,属于稳定排序算法。

3.时间复杂度

最好时间复杂度:当数据已经有序,只需要一次冒泡,所以是 O(1)。(ps:都已经是正序了,还要你冒泡何用)

最坏时间复杂度: 数据是倒序的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。(ps:写一个 for 循环反序输出数据不就行了,干嘛要用你冒泡排序呢,我是闲的吗)

插入排序

我们先来看一个问题。一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。

插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。

代码如下所示:

/**
 * 插入排序:时间复杂度 O(n²),平均时间复杂度 O(n²),最好时间复杂度 O(n),
 * 最坏时间复杂度 O(n²),空间时间复杂度 O(1).稳定排序算法。
 */
public class InsertionSort implements ComparisonSort {
    @Override
    public int[] sort(int[] sourceArray) {
        int[] result = Arrays.copyOf(sourceArray, sourceArray.length);
        if (sourceArray.length <= 1) {
            return result;
        }
        // 从下标为 1 开始比较选择合适位置插入,因为下标 0 只有一个元素,默认是有序
        int length = result.length;
        for (int i = 1; i < length; i++) {
            // 待插入数据
            int insertValue = result[i];
            // 从已排序的序列最右边元素开始比较,找到比待插入树更小的数位置
            int j = i - 1;
            for (; j >= 0; j--){
                if (result[j] > insertValue) {
                    // 向后移动数据,腾出待插入位置
                    result[j + 1] = result[j];
                } else {
                    // 找到待插入位置,跳出循环
                    break;
                }
            }
            // 插入数据,因为前面多执行了 j--,
            result[j + 1] = insertValue;
        }
        return result;
    }
}

依然继续分析该算法的性能。

1.是否是原地排序算法

从实现过程就知道,插入排序不需要额外的存储空间,所以空间复杂度是 O(1),属于原地排序。

2.是否是稳定排序算法

对于值相等的元素,我们选择将数据插入到前面元素的后面,这样就保证原有的前后顺序不变,属于稳定排序算法。

3.时间复杂度

如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据

如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n²)。

还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n²)。

选择排序

选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

算法步骤

  1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
  2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  3. 重复第二步,直到所有元素均排序完毕。


代码如下:

public class SelectionSort implements ComparisonSort {
    @Override
    public int[] sort(int[] sourceArray) {
        int length = sourceArray.length;
        int[] result = Arrays.copyOf(sourceArray, length);
        if (length <= 0) {
            return result;
        }
        // 一共需要 length - 1 轮比较
        for (int i = 0; i < length - 1; i++) {
            // 每轮需要比较的次数 length - i,找出最小元素下标
            int minIndex = i;
            for (int j = i + 1; j < length; j++) {
                if (result[j] < result[minIndex]) {
                    // 查出每次最小元素下标
                    minIndex = j;
                }
            }
            // 将当前 i 位置的数据与最小值交换数据
            if (i != minIndex) {
                int temp = result[i];
                result[i] = result[minIndex];
                result[minIndex] = temp;
            }
        }
        return result;
    }
}

首先,选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n²)。

那选择排序是稳定的排序算法吗?

答案是否定的,选择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性

比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

总结

这三种时间复杂度为 O(n²) 的排序算法中,冒泡排序、选择排序,可能就纯粹停留在理论的层面了,学习的目的也只是为了开拓思维,实际开发中应用并不多,但是插入排序还是挺有用的。后面讲排序优化的时候,我会讲到,有些编程语言中的排序函数的实现原理会用到插入排序算法。(希尔排序就是插入排序的一种优化)

今天讲的这三种排序算法,实现代码都非常简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序的时候,这个时间复杂度还是稍微有点高,所以我们更倾向于用下一节要讲的时间复杂度为 O(nlogn) 的排序算法。

算法执行效率

课后思考

最后给大家一个问题,答案可在后台发送 「插入」获取答案,也可以加群跟我们一起讨论。

问题是:插入排序和冒泡排序时间复杂度相同,都是 O(n²),实际开发中更倾向于插入排序而不是冒泡排序


相关文章
|
6月前
|
机器学习/深度学习 算法
【优选算法】—— 滑动窗口类问题
【优选算法】—— 滑动窗口类问题
104 0
|
6月前
|
机器学习/深度学习 算法
【数学建模竞赛】评价类赛题常用算法解析
【数学建模竞赛】评价类赛题常用算法解析
117 0
|
6月前
|
搜索推荐 算法
11.经典 O(n²)比较类排序算法
11.经典 O(n²)比较类排序算法
70 0
|
算法 C++ 索引
函数模板和类模板 知识点总结 C++程序设计与算法笔记总结(七) 北京大学 郭炜(下)
函数模板和类模板 知识点总结 C++程序设计与算法笔记总结(七) 北京大学 郭炜(下)
47 0
|
29天前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
32 4
|
3月前
|
搜索推荐 算法 Java
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
该博客文章通过UML类图和Java源码示例,展示了如何使用适配器模式将QuickSort类和BinarySearch类的排序和查找功能适配到DataOperation接口中,实现算法的解耦和复用。
39 1
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
|
3月前
|
数据采集 算法 数据可视化
基于K-Means聚类算法对球员数据的聚类分析,可以自主寻找最优聚类数进行聚类
本文介绍了一个基于K-Means聚类算法的NBA球员数据分析项目,该项目通过采集和分析球员的得分、篮板、助攻等统计数据,使用轮廓系数法和拐点法确定最优聚类数,将球员分为不同群组,并提供了一个可视化界面以便直观比较不同群组的球员表现。
基于K-Means聚类算法对球员数据的聚类分析,可以自主寻找最优聚类数进行聚类
|
4月前
创建KNN类
【7月更文挑战第22天】创建KNN类。
33 8
|
4月前
|
算法 数据库
|
5月前
|
数据采集 算法 安全
CVPR 2024:给NeRF开透视眼!稀疏视角下用X光进行三维重建,9类算法工具包全开源
【6月更文挑战第28天】CVPR 2024亮点:SAX-NeRF框架开源!融合X光与NeRF,提升3D重建效果。X3D数据集验证,Lineformer+MLG策略揭示物体内部结构,增强几何理解。虽有计算成本及泛化挑战,但为计算机视觉和医学影像开辟新路径。[论文链接](https://arxiv.org/abs/2311.10959)**
168 5