前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >前端学数据结构与算法(九):常见五种排序算法的实现及其优缺点

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

原创
作者头像
飞跃疯人院
修改2020-10-26 12:08:05
9230
修改2020-10-26 12:08:05
举报
文章被收录于专栏:数据结构与算法(JavaScript)

前言

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

如何衡量一个排序算法?

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

1. 时间复杂度

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

2. 额外的内存

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

3. 稳定性

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

一、冒泡排序(bubbleSort)

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

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

代码语言:txt
复制
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]] // 交换

      }

    }

  }

}

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

代码语言:txt
复制
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,内层循环结束后,进行一次交换即可。代码如下:

代码语言:txt
复制
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)

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

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

代码语言:txt
复制
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 // 否则结束本次循环

      }

    }

  }

}

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

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

代码语言:txt
复制
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

代码如下:

代码语言:txt
复制
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下标的项交换位置即完成了区间的划分。代码如下:

代码语言:txt
复制
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源码

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 如何衡量一个排序算法?
    • 1. 时间复杂度
      • 2. 额外的内存
        • 3. 稳定性
        • 一、冒泡排序(bubbleSort)
          • 冒泡排序的缺点
            • 冒泡排序的优点
            • 二、选择排序(selectSort)
              • 选择排序的缺点
                • 选择排序的优点
                • 三、插入排序(insertSort)
                  • 插入排序的缺点
                    • 插入排序的优点
                    • 四、归并排序(mergeSort)
                      • 归并排序的缺点
                        • 归并排序的优点
                        • 五、快速排序(quickSort)
                          • 快速排序的缺点
                            • 快速排序的优点
                            • 最后
                            领券
                            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档