超全十大经典排序算法及其分析

文章目录

0.算法概述

0.1 算法分类

1、时间复杂度O(n^2)级排序算法:

  • 选择排序
  • 插入排序
  • 冒泡排序

2、时间复杂度O(nlogn)级排序算法:

  • 快速排序
  • 希尔排序
  • 归并排序
  • 堆排序

3、时间复杂度O(n)级排序算法:

  • 桶排序
  • 基数排序
  • 计数排序

4、比较类排序

  • 交换排序
    • 冒泡排序 Bubble Sort
    • 快速排序 Quick Sort
  • 插入排序
    • 简单插入排序 Insertion Sort
    • 希尔排序 Shell Sort
  • 选择排序
    • 简单选择排序 Selection Sort
    • 堆排序 Heap Sort
  • 归并排序
    • 二路归并排序 Merge Sort
    • 多路归并排序 Merge Sort
  • 非比较排序
    • 计数排序 Counting Sort
    • 基数排序 Radix Sort
    • 桶排序 Bucket Sort

0.2 算法复杂度

在这里插入图片描述

0.3 相关概念

时空复杂度:

时间复杂度:时间随着问题(数据)规模的扩大而如何变化的:一般讲时间复杂度都是讲”最坏“的;

  • 不考虑必须要做的操作:循环、赋初值、程序初始化:----->O(1)
  • 不考虑常数项:2n -----> n
  • 不考虑低次项:n^(2+n)-----> n^2

空间复杂度:算法需要用到的额外空间,不包括该问题必须分配的空间;

分类:

  • 非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序;

  • 线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序;

  • 内排序:所有排序操作都在内存中完成;

  • 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;

稳定性:

  • 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。

推荐的标准来源于面试笔试的考察情况:

冒泡排序 ■■□□□
快速排序 ■■■■■
插入排序 ■■■□□
希尔排序 ■□□□□
归并排序 ■■■■■
选择排序 ■■□□□
堆排序 ■■■■□
计数排序 ■■□□□
基数排序 ■□□□□
桶排序 ■□□□□
根据上面的情况,相信你会有所侧重地学习排序算法。

1. 冒泡排序(Bubble Sort)

依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行直到没有相邻元素需要交换,也就是说该元素列已经排序完成。

1.1 算法描述:

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

1.2 图解演示

img

1.3 代码实现

冒泡排序第一种写法:

代码如下:

public static void bubbleSort(int[] arr) {
   
    for (int i = 0; i < arr.length - 1; i++) {
   
        for (int j = 0; j < arr.length - 1 - i; j++) {
   
            if (arr[j] > arr[j + 1]) {
   
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
   
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

1.4 优化过程

  • 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位;

  • 经过优化的写法:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生交换表示已经有序,不再继续排序;

  • 进一步优化的写法:除了使用变量记录当前轮次是否发生交换外,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较;

    最外层的 for 循环每经过一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位,中途也会有一些相邻的数字经过交换变得有序。总共比较次数是 (n-1)+(n-2)+(n-3)+…+1(n−1)+(n−2)+(n−3)+…+1。

冒泡排序第二种写法:

第二种写法是在第一种写法的改良基础下而来,代码如下:

public static void bubbleSort(int[] arr) {
   
    // 初始时 swapped 为 true,否则排序过程无法启动
    boolean swapped = true;
    for (int i = 0; i < arr.length - 1; i++) {
   
        // 如果没有发生过交换,说明剩余部分已经有序,排序完成
        if (!swapped) break;
        // 设置 swapped 为 false,如果发生交换,则将其置为 true
        swapped = false;
        for (int j = 0; j < arr.length - 1 - i; j++) {
   
            if (arr[j] > arr[j + 1]) {
   
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
                // 表示发生了交换
                swapped = true;
            }
        }
    }
}

最外层的 for 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。这种写法相对于第一种写法的优点是:如果一轮比较中没有发生过交换,则立即停止排序,因为此时剩余数字一定已经有序了。

看下动图演示:

img

图中可以看出:

第一轮排序将数字 66 移动到最右边;
第二轮排序将数字 55 移动到最右边,同时中途将 11 和 22 排了序;
第三轮排序时,没有发生交换,表明排序已经完成,不再继续比较。

冒泡排序的第三种写法:

第三种写法比较少见,它是在第二种写法的基础上进一步优化:

经过再一次的优化,代码看起来就稍微有点复杂了。最外层的 while 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。

在下一轮比较时,只需比较到上一轮比较中,最后一次发生交换的位置即可。因为后面的所有元素都没有发生过交换,必然已经有序了。

最好情况:在数组已经有序的情况下,只需遍历一次,由于没有发生交换,排序结束。

img

最差情况:数组顺序为逆序,每次比较都会发生交换。

img

代码如下:

public static void bubbleSort(int[] arr) {
   
    boolean swapped = true;
    // 最后一个没有经过排序的元素的下标
    int indexOfLastUnsortedElement = arr.length - 1;
    // 上次发生交换的位置
    int swappedIndex = -1;
    while (swapped) {
   
        swapped = false;
        for (int i = 0; i < indexOfLastUnsortedElement; i++) {
   
            if (arr[i] > arr[i + 1]) {
   
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, i, i + 1);
                // 表示发生了交换
                swapped = true;
                // 更新交换的位置
                swappedIndex = i;
            }
        }
        // 最后一个没有经过排序的元素的下标就是最后一次发生交换的位置
        indexOfLastUnsortedElement = swappedIndex;
    }
}

1.5 性能分析

时间复杂度、空间复杂度:

  • ​ 第一种写法的比较次数为 (n-1)+(n-2)+(n-3)+…+1,总比较次数为 (n^2)/2,所以时间复杂度为 O(n^2),使用空间只有 i、j 两个变量,所以空间复杂度为 O(1);

  • ​ 第二种写法在数组已经有序的情况下比较次数为 n-1,只需比较一轮即可完成排序,此时时间复杂度为 O(n),最坏的情况和第一种写法一样,平均时间复杂度仍是 O(n^2)使用的空间最多 i、j、swapped、temp 四个变量,最好的情况下只有 i、j、swapped 三个变量,所以空间复杂度为 O(1);

  • ​ 第三种写法时间复杂度和第二种写法一样,平均时间复杂度是 O(n^2)只是实际运行效率比第二种写法好一些;使用的空间最多 swapped、indexOfLastUnsortedElement、swappedIndex、i、temp 五个变量,最好的情况下没有 temp 变量,所以空间复杂度为 O(1)。

稳定性:

​ 冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,是不会再交换的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

2. 选择排序(Selection Sort)

选择排序(Selectionsort)是一种简单直观的排序算法。它的工作原理是:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。

2.1 算法描述:

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  • 初始状态:无序区为N[1…n],有序区为空;
  • 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为N[1…i-1]和N(i…n)。该趟排序从当前无序区中选出关键字最小的记录 N[m],将它与无序区的第1个记录N交换,使N[1…i]和N[i+1…n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
  • n-1趟结束,数组有序化了。

2.2 图解演示

2.3 代码实现

public static void selectionSort(int[] arr) {
   
    int minIndex;
    for (int i = 0; i < arr.length - 1; i++) {
   
        minIndex = i;
        for (int j = i + 1; j < arr.length; j++) {
   
            if (arr[minIndex] > arr[j]) {
   
                // 记录最小值的下标
                minIndex = j;
            }
        }
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}

2.4 优化过程

二元选择排序:

选择排序算法也是可以优化的,既然每轮遍历时找出了最小值,何不把最大值也顺便找出来呢?这就是二元选择排序的思想。

使用二元选择排序,每轮选择时记录最小值和最大值,可以把数组需要遍历的范围缩小一倍。

public static void selectionSort2(int[] arr) {
   
    int minIndex, maxIndex;
    // i 只需要遍历一半
    for (int i = 0; i < arr.length / 2; i++) {
   
        minIndex = i;
        maxIndex = i;
        for (int j = i + 1; j < arr.length - i; j++) {
   
            if (arr[minIndex] > arr[j]) {
   
                // 记录最小值的下标
                minIndex = j;
            }
            if (arr[maxIndex] < arr[j]) {
   
                // 记录最大值的下标
                maxIndex = j;
            }
        }
// 如果 minIndex 和 maxIndex 都相等,那么他们必定都等于 i,且后面的所有数字都与 arr[i] 相等,此时已经排序完成
        if (minIndex == maxIndex) break;
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
// 如果最大值的下标刚好是 i,由于 arr[i] 和 arr[minIndex] 已经交换了,所以这里要更新 maxIndex 的值。
        if (maxIndex == i) maxIndex = minIndex;
        // 将最大元素交换至末尾
        int lastIndex = arr.length - 1 - i;
        temp = arr[lastIndex];
        arr[lastIndex] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

我们使用 minIndex 记录最小值的下标,maxIndex 记录最大值的下标。每次遍历后,将最小值交换到首位,最大值交换到末尾,就完成了排序。

由于每一轮遍历可以排好两个数字,所以最外层的遍历只需遍历一半即可。

二元选择排序中有一句很重要的代码,它位于交换最小值和交换最大值的代码中间:

if (maxIndex == i) maxIndex = minIndex;

这行代码的作用处理了一种特殊情况:如果最大值的下标等于 i,也就是说 arr[i] 就是最大值,由于 arr[i] 是当前遍历轮次的首位,它已经和 arr[minIndex] 交换了,所以最大值的下标需要跟踪到 arr[i] 最新的下标 minIndex。

二元选择排序的效率:
在二元选择排序算法中,数组需要遍历的范围缩小了一倍。那么这样可以使选择排序的效率提升一倍吗?

从代码可以看出,虽然二元选择排序最外层的遍历范围缩小了,但 for 循环内做的事情翻了一倍。也就是说二元选择排序无法将选择排序的效率提升一倍。但实测会发现二元选择排序的速度确实比选择排序的速度快一点点,它的速度提升主要是因为两点:

  • 在选择排序的外层 for 循环中,i 需要加到 arr.length - 1 ,二元选择排序中 i 只需要加到 arr.length / 2
  • 在选择排序的内层 for 循环中,j 需要加到 arr.length ,二元选择排序中 j 只需要加到 arr.length - i

二元选择排序虽然比选择排序要快,但治标不治本,二元选择排序中做的优化无法改变其时间复杂度,二元选择排序的时间复杂度仍然是 O(n^2);只使用有限个变量,空间复杂度 O(1)。

2.5 性能分析

时间复杂度:

​ 择排序的交换操作介于 0 和 (n - 1)次之间。选择排序的比较操作为 n (n - 1) / 2 次之间。选择排序的赋值操作介于 0 和 3 (n - 1) 次之间。比较次数O(n^2)

比较次数与关键字的初始状态无关,总的比较次数N=(n-1)+(n-2)+…+1=n*(n-1)/2。交换次数O(n),最好情况是,已经有序,交换0次;最坏情况交换n-1次,逆序交换n/2次,由于选择排序在进行排序时无论数组是非有序都需要进行相同次数的寻找和比较,所以最好和最差情况下的时间复杂度都是O(n^2)

空间复杂度:

​ 分配变量时不考虑,临时变量分配完在一次for循环后就消失,没有用到额外的空间,所以空间复杂度为O(1)。

稳定性:

​ 选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。举个例子,序列{5,8,5,2,9},我们知道第一遍选择第1个元素5会和2交换,那么原序列中两个5的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。

2.6 拓展

现在让我们思考一下,冒泡排序和选择排序有什么异同?

  • 相同点:
    • 都是两层循环,时间复杂度都为 O(n^2);都只使用有限个变量,空间复杂度 O(1)。
  • 不同点:
    • 冒泡排序在比较过程中就不断交换,而选择排序增加了一个变量保存最小值 / 最大值的下标,遍历完成后才交换,减少了交换次数。
  • 事实上还有一个非常重要的不同点,那就是:
    • 冒泡排序法是稳定的,选择排序法是不稳定的。

那么排序算法的稳定性有什么意义呢?

​ 其实它只在一种情况下有意义:当要排序的内容是一个对象的多个属性,且其原本的顺序存在意义时,如果我们需要在二次排序后保持原有排序的意义,就需要使用到稳定性的算法。排序算法的稳定性与效率、可靠性都无关。

举个例子,如果我们要对一组商品排序,商品存在两个属性:价格和销量。当我们按照价格从高到低排序后,要再按照销量对其排序,这时,如果要保证销量相同的商品仍保持价格从高到低的顺序,就必须使用稳定性算法。

​ 当然,算法的稳定性与具体的实现有关。在修改比较的条件后,稳定性排序算法可能会变成不稳定的。如冒泡算法中,如果将「左边的数大于右边的数,则交换」这个条件修改为「左边的数大于或等于右边的数,则交换」,冒泡算法就变得不稳定了。同样地,不稳定排序算法也可以经过修改,达到稳定的效果。

思考一下,选择排序算法如何实现稳定排序呢?

​ 实现的方式有很多种,这里给出一种最简单的思路:新开一个数组,将每轮找出的最小值依次添加到新数组中,选择排序算法就变成稳定的了。

​ 但如果将寻找最小值的比较条件由arr[minIndex] > arr[j]修改为arr[minIndex] >= arr[j],即使新开一个数组,选择排序算法依旧是不稳定的。所以分析算法的稳定性时,需要结合具体的实现逻辑才能得出结论,我们通常所说的算法稳定性是基于一般实现而言的。

3. 插入排序(Insertion Sort)

插入排序的思想非常简单,生活中有一个很常见的场景:在打扑克牌时,我们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序。

3.1 算法描述

插入排序有两种写法:

  • 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置。

  • 移动法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。

  • 具体算法描述如下:

  • 从第一个元素开始,该元素可以认为已经被排序;

  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;

  • 如果该元素(已排序)大于新元素,将该元素移到下一位置;

  • 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;

  • 将新元素插入到该位置后;

  • 重复步骤2~5。

3.2 图解演示

img

3.3 代码演示:

public static void insertSort(int[] arr) {
   
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
   
        // j 记录当前数字下标
        int j = i;
        // 当前数字比前一个数字小,则将当前数字与前一个数字交换
        while (j >= 1 && arr[j] < arr[j - 1]) {
   
            swap(arr, j, j - 1);
    
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值