海绵宝宝带你学习——冒泡排序和选择排序

1,229 阅读4分钟

冒泡排序和选择排序总结

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

  • 欢迎评论

    欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章

上节回顾

  1. 归并排序和快速排序代码解读
  2. 堆排序和插入排序代码解读
  3. 复杂度的分析和计算方法

冒泡排序

  • 基本思想

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

  • 动图展示

bubbleSort.gif

我们从上图中可以看出,就是遍历整个数组,然后将相邻两个元素的不断比较,将最大的元素冒泡到最后,进行多次完成最终排序

  • 代码实现

    /**
     * 冒泡思想
     * 步骤1:每次都和相邻的元素比较大小,并交换位置
     * 步骤2:直到将最大的元素冒泡到数组的末尾,结束一轮冒泡
     * 步骤3:不需要再对数组最后一个元素进行冒泡,再次进行一轮冒泡
     * 步骤4:循环步骤1,2
     * @param {*} arr
     * @returns
     */
    const bubbleSort = (arr) => {
      const len = arr.length;
      for (let i = 0; i < len - 1; i++) {
        for (let j = 0; j < len - 1 - i; j++) {
          if (arr[j] > arr[j + 1]) {
            [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
          }
        }
      }
      return arr;
    };
    
  • 代码解读

    首先记录数组arr的长度,然后我们开始写第一层循环,第一层循环主要的作用是每一轮冒泡结束后,成功将最大的元素冒泡到数组的最后,所以每当i加上1的时候,我们第二层循环由于每次比较都是需要比较当前元素和下一个元素的值的大小并进行交换操作,所以长度就是len - 1 - i

    我们来理解一下为什么要这么操作,首先我们假设数组的长度为3,外层循环我们暂时称为i层循环,内层循环我们暂时称为j层循环,那么在进行第一次i层循环的时候,j层循环的条件就是j<3-1-0 => j<2,所以我们要看的就是极限条件(边界情况),当第一次i层循环的时候,其实j层循环需要遍历的就是整个数组长度,但是不需要取到数组的最后一个元素,因为我们一直比较的是当前元素和下一个元素的大小,所以只用遍历到len-2的那个元素就可以,用代码的形式来表示就是j<len-1,当然也可以写成j<=len-2,这下大家应该能够理解了,那么刚刚我们说的是数组从下标为0的时候的情况,那么进行第二次i层循环的时候,说明数组的最后一个元素已经是最大的了,所以在第二次j层循环的时候,只需要遍历到j<len-1-1就可,当然,这个1是随着外层的i而变化的,这样的话就能够保证在数组中已排好序的元素不需要再进行比较,再看最后进行第三次i层循环的时候,前面两个元素已经有序,由于我们的数组长度是3,所以在进行第三次循环的时候,第一个元素一定是最小的那一个,所以可以不进行遍历,这就是i层循环为什么取不到i<=len-1的原因

    当然,循环最里面元素交换的操作使用的是es6的语法,感兴趣的同学可以去学习下,也可以采用申请临时变量的方式进行操作

  • 代码分析

    • 空间复杂度分析

      只涉及到常量级的临时空间,而且只涉及到元素的交换操作,所以空间复杂度是O(1)

    • 是否是稳定的排序算法

      只有交换才会改变两个元素的先后顺序,所以,我们可以在有相邻元素相等的情况下不进行交换,以此来保证排序算法的稳定,相同的数据在排序前后先后顺序没有发生改变,所以是稳定的排序算法

    • 时间复杂度分析

      最好的情况下,元素都是有序的,我们只需要进行一次冒泡操作,就可以结束了,所以最好时间复杂度是O(n)

      最坏的情况下,元素都是倒序的,我们需要进行n次的冒泡操作,最坏的时间复杂度是O(n2)

      平均时间复杂度的话,由于对于包含n个数据的数组,他们有n!中排序方式(参考概率论),不同的排列方式,冒泡排序的执行时间肯定是不一样的

      bubbleSort1.png

      我们来看上面这个图,初始有序的是(4,5),(4,6),(5,6),一共3组,没有其他排列组合了,我们想象一下,如果是一个完全有序的,一共是多少组呢?1,2,3,4,5,6,我们很容易想到梯形的面积公式,所以一共是6*5/2=15组,那么当我们修改两个元素的位置,1,2,3,4,6,5,它的初始有序组合一共是14组,逆序的就一组

      逆序有多少组,代表了什么呢?代表整个冒泡排序,需要进行数据交换的次数,我们得出以下结论

      交换次数=n(n1)/2初始有序对数交换次数=n*(n-1)/2 - 初始有序对数

      如果有序的一组都没有,代表我们需要进行n*(n-1)/2次交换,如果初始有序的是n*(n-1)/2组,那么就不需要交换,那么我们取一个中间值n*(n-1)/4来表示需要交换的元素平均的情况

      因为平均情况下需要进行n*(n-1)/4次交换操作,而我们比较操作的次数肯定是要多于交换操作,复杂度的上限是O(n2),所以最终平均的时间复杂度就是O(n2)

    选择排序

    • 基本思想

      有点类似于插入排序,也分已排序区间和未排序区间,但是选择排序会每次从未排序空间中找到最小的元素,然后将它放到已排序区间的末尾,直到最后未排序区间的长度为0,完成最终的排序

    • 动图展示

      selectionSort.gif

      我们能够看到,已排序区间是黄色,通过不断遍历未排序空间,找到最小的元素放入已排序区间的末尾

    • 代码实现

      /**
       * 选择排序思想
       * 步骤一:每次都找到数组中最小的元素,然后放到元素首位
       * 步骤二:首位的元素不需要动,接着对接下来的 n-1 个元素进行步骤一的操作
       * @param {*} arr
       */
      const selectionSort = arr => {
        const len = arr.length;
        // 这里最外层只需要遍历到 (len - 2) 位置就可以了
        // 子循环会比较倒数第二个元素和最后一个元素的大小并交换位置,最后一个元素自然是最大的
        for (let i = 0; i < len - 1; i++) {
          let minIndex = i;
          for (let j = i + 1; j < len; j++) {
            // 找到最小元素的索引
            if (arr[j] < arr[minIndex]) {
              minIndex = j;
            }
          }
          [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
        }
        return arr;
      };
      
    • 代码解读

      这里就不仔细解读了,主要的思想就是通过记录每次循环的最小元素的index,考虑边界情况,如果是没有找到比当前元素小的,那么可以不进行交换,所以上面的代码还是有改进的地方

      外层循环主要目的是为了记录当前循环到第几个元素了,这个元素之前称作已排序区间,这个元素后面的都是未排序区间,所以我们内层循环的操作就是在内存循环中找到最小元素的索引,最后在外层循环中交换位置

    • 代码分析

      • 空间复杂度分析

        只涉及到常量级的临时空间,所以空间复杂度是O(1)

      • 是否是稳定的排序算法

        不是,我们想象下有两个相等的元素都在未排序空间中,那么肯定是会将后面的那个元素和当前元素互换,导致排序后相对位置发生了改变

      • 时间复杂度分析

        不管是顺序还是逆序的数组,时间复杂度都是O(n2),你们可以自己试着分析看看