前端学数据结构与算法(九):常见五种排序算法的实现及其优缺点

时间:2022-07-28
本文章向大家介绍前端学数据结构与算法(九):常见五种排序算法的实现及其优缺点,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

前言

数据结构章节暂时告一段落,从这一章节开始算法之旅。首先从排序开始,排序作为最基础的算法,一点也不简单,写一个快排、堆排、归并排序在大厂面试中并不罕见,或者某些题目就需要使用某些排序的思想来解决,这也就是为什么要学习排序。当然最重要的是学习它的思想,例如快排的partition操作,快排和归并排序的分治思想,以及排序的性能优化,又或者O(n²)的排序也并非一无是处等。本章将手写五种常见排序算法,它们包括冒泡排序、选择排序、插入排序、归并排序、快速排序、(堆排序第七章已介绍),理解它们的优缺点,从而能在合适的场景使用恰当的排序算法。

如何衡量一个排序算法?

排序算法的种类很多,在没对排序有了解时,我曾的天真的以为,直接选出其中一个最快的不就完事了么?但是真实情况会复杂一些,因为一个排序能从很多方便来衡量,并不能简单的拿效率说事。

1. 时间复杂度

这是衡量一个排序算法最直观的感受,我们平时说某一个排序的复杂度也都是平均时间复杂度,但针对排序数据的不同,又会出现最坏、最好时间复杂度的情况,所以我们要搞明白,什么情况是什么复杂度。还有就是排序里经常会用到的操作就是交换位置和赋值,很明显赋值的效率是优于交换位置的,这也需要在复杂度之外考虑。

2. 额外的内存

完成这个排序算法,需要开辟额外多少的辅助空间,也就是说能不能在原数组上直接原地排序,这个也是需要衡量的一个因素,毕竟快和省才是数据结构与算法存在的意义。

3. 稳定性

虽然说排序算法最后都是按照升序或排序排列,但相同的值在排序后,位置的前后关系是否发生了改变这也是衡量的一个标准。例如[3, 1(1号), 2, 1(2号)]排序后都是[1, 1, 2, 3],但1号和和2号是否在排序之后位置发生了改变,这个也很重要。因为在真实场景,我们可能是针对某个对象的某个key进行排序,如果它们key相同,稳定的排序算法就能保持原有的次序不变。

一、冒泡排序(bubbleSort)

相信大家听的最多的就是冒泡排序,它的实现与原理也确实是最好理解的。还是以图解释冒泡排序的原理:

比较两个相邻的元素,比较它们的优先级,将大的元素与小的元素进行交换,经过一轮的比较之后,最大的元素就会出现在数组的末端,然后再下轮的比较范围中排除末端的元素(已经排好序)。这样的将一个个优先级最大元素浮出来的过程,就类似冒泡般。代码如下:

const bubbleSort = arr => {

  for (let i = 0; i < arr.length - 1; i++) { // -1根据内层的终止条件,可以减少一次

    for (let j = 0; j < arr.length - i - 1; j++) {

      // -i缩小范围 -1防止数组越界

      if (arr[j] > arr[j + 1]) { // 相邻比较

        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]] // 交换

      }

    }

  }

}

以上代码确实能实现一个数组的排序,不过通过上述原理示意图不难发现,其实第三步的时候,数组已经排好序,第四步如果能不执行的话那就好了,针对这个情况我们可以加入一个标志位,表示数组是否已经排好序:

const bubbleSort = arr => {

  for (let i = 0; i < arr.length - 1; i++) {

    let flag = true // 标志位

    for (let j = 0; j < arr.length - i - 1; j++) {

      if (arr[j] > arr[j + 1]) {

        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]

        flag = false // 只要有一次相邻交换,说明数组还没排好序

      }

    }

    if (flag) { // 如果遍历了一遍,都没发生相邻交换,说明排序完成

      break

    }

  }

}

冒泡排序的缺点

  1. 时间复杂度高,需要进行O(n²)的两轮比对。
  2. 交换位置的操作太频繁,影响cpu执行效率。

冒泡排序的优点

  1. 是稳定的排序算法,因为值相等时不会进行交换操作。
  2. 原地排序不用开辟额外空间。
  3. 最好情况面对已经排好序的数组,复杂度能降到O(n)

二、选择排序(selectSort)

实现的原理是在未排好序的范围内找到最小的值,然后与该范围头部元素进行交换,从而完成整个数组的排序。原理如下图所示:

首先暂存头部为min,然后再剩下的范围内找到真正最小值的下标替换min,内层循环结束后,进行一次交换即可。代码如下:

selectSort = arr => {

  for (let i = 0; i < arr.length; i++) {

    let min = i // 暂定i为最小

    for (let j = i + 1; j < arr.length; j++) {

      if (arr[min] > arr[j]) { // 找到最小的

        min = j

      }

    }

    [arr[i], arr[min]] = [arr[min], arr[i]] // 内循环结束交换

  }

}

同样都是O(n²)的算法,

但就执行效率来说,选择排序是要优于冒泡排序的,因为冒泡排序比较之后就会进行交换操作,而选择排序则非如此,每次内循环只是找到最小值那项的下标,内循环结束后与数组头部交换,剩下的范围内依然如此进行,所以比较次数虽然不会少,但交换位置的操作次数少了很多,执行效率比冒泡高。还是用图说明下它的原理:

选择排序的缺点

  1. 时间复杂度高。
  2. 不是稳定的排序算法,因为每次找到最小的值后会进行交换位置的操作。
  3. 最坏和最好情况都是O(n²)的复杂度。

选择排序的优点

  1. 是一种原地排序算法,与冒泡排序相比,交换位置的操作改为了赋值操作,执行效率会提高。

三、插入排序(insertSort)

它的实现原理也是将数组分为排好序和未排好序两个范围,每次从未排序好里取出元素,插入到已经排好序的范围内,重复这个过程以完成整个数组的排序。原理如下:

插入排序与之前两个不同,它的内循环是递减的,只要它前面的元素大于当前的元素,就与它交换位置。从第四步不难发现,如果它前面已经是排好序的,那么这次内循环可以直接结束。代码如下:

const insertSort = arr => {

  for (let i = 1; i < arr.length; i++) { // = 1为了接下来的-1

    for (let j = i; j > 0; j--) { // 从i开始递减

      if (arr[j] < arr[j - 1]) { // 如果之前的元素大于当前的

        [arr[j], arr[j - 1]] = [arr[j - 1], arr[j]] // 就交换它们的位置

      } else {

        break // 否则结束本次循环

      }

    }

  }

}

因为排序原理是从后往前找,然后找到它应该在的位置,然后插入到那个地方即可,所以我们完全可以交换位置的操作改为赋值的操作,可以有这样的一个优化,下图所示:

首先暂存需要排序的元素,让暂存的元素与当前的元素进行比较找到可以插入的位置,如果位置不对,通过赋值的方式整体向后移动一位,找到后将暂存的元素赋值到它应该在的位置即可。代码如下:

const insertSort = arr => {

  for (let i = 1; i < arr.length; i++) {

    let temp = arr[i] // 暂存

    let j

    for (j = i; j > 0 && temp < arr[j - 1]; j--) { // 将break写入条件里

      arr[j] = arr[j - 1] // 整体后移

    }

    arr[j] = temp // 将暂存元素插入到对应的位置

  }

}

插入排序的缺点

  1. 时间复杂度高。

插入排序的优点

  1. 有提前终止循环的情况,如果是面对近似有序的数组,效率奇高。
  2. 原地排序不占额外空间,没有交换位置的操作执行效率高。
  3. 是一种稳定的排序算法。
  4. 最好情况能到O(n)(吊打冒泡排序)

四、归并排序(mergeSort)

归并排序使用的是算法的分治思想,也就是将一个大的问题分解为一个小问题,当问题分解到足够小时,解决了这个小问题,大的问题也就迎刃而解。

首先将一个数组从中一分为二,然后分别处理两个小数组的排序问题,再处理两个小数组时,如果它们还能分解,又将它们分为更小的数组。直到分解到是单个元素为止,然后将单元素数组归并为一个有序的小数组,接着将两个有序的小数组归并为更大一些的数组。直到最后原来一分为二的两个数组就全部是有序的,将它们归并以完成最终的排序。宏观原理图解如下:

然后从微观的角度来看下如何归并两个有序数组,如下图所示,当需要归并两个有序数组时,我们需要借助一个原数组的副本,将其拆分为AB。过程是将归并的结果重新赋值原数组C完成。

有三个固定的界限坐标分别为left/mid/right;以及三个游走的坐标k/i/j。从imid是子数组A,从mid + 1right为子数组B,归并的过程只需要分别比对两个子数组的值即可,谁的值小就赋值给原数组k下标的位置,然后游走坐标+1即可。

在归并的过程中会有四种情况发生:

  1. **A[i] > B[j]**

此时只需要将B[j]的值赋值给C[k]j++以及k++继续归并下一个元素。

  1. **A[i] < B[j]**

A[i]赋值给C[k]i++以及k++继续归并下一个元素。

  1. **i > mid**

说明数组A里面所有的元素已经全部归并完成,只需要把数组B里的剩下元素全部赋值给数组C即可。因为都是有序数组,所以数组B里剩下的全部都是大于A数组的元素。

  1. **j > right**

同理说明数组B里全部归并完成,将数组A剩下的值全部赋值给数组C

代码如下:

const mergeSort = arr => {

  const _mergeSort = (arr, l, r) => {

    if (l >= r) { // 递归终止条件

      return

    }

    const mid = l + (r - l) / 2 | 0 // 取中间下标,向下取整

    _mergeSort(arr, l, mid)

    _mergeSort(arr, mid + 1, r)

    _merge(arr, l, mid, r)

  }

  _mergeSort(arr, 0, arr.length - 1)

}



function _merge(arr, l, mid, r) {

  const aux = arr.slice(l, r + 1) // 开辟辅助数组

  let i = l;

  let j = mid + 1;

  for (let k = l; k <= r; k++) {

    if (i > mid) { // 如果数组A越界

      arr[k] = aux[j - l]

      j++

    } else if (j > r) { // 如果数组B越界

      arr[k] = aux[i - l]

      i++

    } else if (aux[j - l] >= aux[i - l]) { // 这里必须要加上=

      arr[k] = aux[i - l] // 当值相等时A数组先归并,这样才能保证算法的稳定性

      i++

    } else {

      arr[k] = aux[j - l]

      j++

    }

  }

}

归并排序的缺点

  1. 不是原地排序,需要开辟额外的O(n)空间。

归并排序的优点

  1. 没有最好最坏时间复杂度,任何情况下都是O(nlogn)
  2. 是一种稳定的排序算法。

五、快速排序(quickSort)

排序算法里的明星,时间复杂度也是名副其实,在所有O(nlogn)的排序里速度最快,如JavaScript封装的sort方法就是采用的快排思想。

快排的实现还是使用的分治的思想,原理就是以其中一个元素作为分区点,将原数组划分为左右两个部分,让左侧数组的值全部比分区点小,右部分数组的值全部比分区点大,这个操作也叫做patition。对已经划分的小数组,继续使用patition的操作,直到划分为单个元素,不能再进行patition操作,整个数组的排序任务完成。还是以图来解释:

还是有两个固定的界限坐标leftright,有两个游走坐标iji表示的是接下来要遍历的点,j表示小区间的末尾,所以j + 1也就是大区间的开头。假设我们选择此时数组的第一项作为分区点,那么接下来我们就要从数组的第二项开始遍历,让剩下的所有项与分区点进行比较。当i指向的点小于分区点时,就让其和j + 1的位置进行交换,j++扩展小区间的范围,否则遍历下一个。当数组全部遍历完时,让leftj下标的项交换位置即完成了区间的划分。代码如下:

const quickSort = arr => {

  const _quickSort = (arr, l, r) => {

    if (l >= r) { // 递归终止条件

      return

    }

    const p = _partition(arr, l, r) // 返回分区点所在下标

    _quickSort(arr, l, p - 1) // 递归进行左半部分

    _quickSort(arr, p + 1, r) // 递归进行右半部分

  }

  _quickSort(arr, 0, arr.length - 1)

}



function _partition(arr, l, r) {

  const v = arr[l] // 选择分区点

  let j = l



  for (let i = l + 1; i <= r; i++) { // 从l + 1,刨去分区点的位置

    if (v > arr[i]) { // 当分区点大于当前节点时

      [arr[i], arr[j + 1]] = [arr[j + 1], arr[i]] 

      // 让大区间的开头与当前节点交换

      j++  // 小区分范围增加

    }

  }

  [arr[j], arr[l]] = [arr[l], arr[j]] // 最后让分区点回到其所在位置

  return j // 返回其下标,进行接下来的分区操作

}

至此完成了一个最简单的快排就实现了,关于快排这个明星算法,实在有太多值得聊,下一章的一整个章节,我们将深入理解这种排序算法的思想及对它的优化。

快速排序的缺点

  1. 不是稳定的排序算法。
  2. 分区点的选择有讲究,选择不当时最坏情况会退化为O(n²)
  3. 需要把待排序的数组一次性读入到内存里。

快速排序的优点

  1. 速度快。
  2. 原地排序,只需要占用O(logn)的栈空间。

最后

至此,最常用几个排序算法介绍完毕。排序算法可以说是算法的基石,下一章单独聊聊快排。

本章github源码