👉向左走还是向右走 -- 编程美学之”排序之美“

150 阅读45分钟

相信大家都有用过array.sort(),但你有没有想过这行简单代码背后藏着怎样的故事?从最基础的冒泡排序到被称为"圣杯"的快速排序,在这背后都发生了什么?跟着我的脚步,这篇文章将带你穿越算法的迷雾森林。

食用指北

不论你是想夯实基础的前端新人,还是准备算法面试的资深工程师,甚至是对编程美学感兴趣的跨界探索者,下面的这九大排序算法都将成为你技术武器库中的瑞士军刀。当你真正理解它们时,会发现这些看似枯燥的算法充满了自然之美和智慧闪光——就像在代码的海洋中,突然读懂了浪花写就的诗。

你能收获的

  • 动态图解演示排序的魔法时刻

  • 真实可跑的JavaScript代码片段

  • 时间复杂度的直观对比

  • 面试中能轻松解出排序相关的算法

Begin

现在,让我们从最熟悉的冒泡排序开始,一起揭开排序算法的神秘面纱。

特别说明:本文的排序算法都按从小到大进行介绍。

一、冒泡排序

概念介绍

顾名思义,就是从"队伍"的一边往另一边冒泡,就像军训时按高矮顺序排队一样。

怎么冒泡?

两层循环实现,外层控制冒泡轮数,内层具体实现。

代码实现

const bubblingSort = function (arr) {
  // 外层循环控制冒泡的次数,有n个元素,就冒泡n-1轮
  // 剩下一个元素,自然就是最小的了,不用冒泡了
  for (let i = 0; i < arr.length - 1; i++) {
    // 内层循环实现每次的冒泡结果
    // 因为已经冒完泡的元素,就不用再和其它元素比较大小了。所以是 "arr.length - 1 - i", 每次只需要从剩下的元素中把最大的元素冒泡到对应的位置就可以了。
    for (let j = 0; j < arr.length - 1 - i; j++) {
      // 比较大小,原地交换
      if (arr[j + 1] < arr[j]) {
        let temp = arr[j + 1]
        arr[j + 1] = arr[j]
        arr[j] = temp
      }
    }
  }
  return arr
}

动图展示

以 [ 8,2,5,9,7 ] 为例

640.gif

步骤拆解

第一轮冒泡:

8和2比较,8比2大,交换位置后,[ 8,2,5,9,7 ] 变成 [ 2,8,5,9,7 ]

8和5比较,8比5大,交换位置后, [2,8,5,9,7 ] 变成 [ 2,5,8,9,7 ]

8和9比较,9比8大,不做交换

9和7比较,9比7大,交换位置后,[ 2,5,8,9,7 ] 变成 [ 2,5,8,7,9 ]

可以看到,第一轮冒泡,比较了4次,把数组中最大的元素"冒泡"到了倒数第一位。

第二轮冒泡:

2和5比较,2比5小,不做交换,还是 [ 2,5,8,7,9 ]

5和8比较,5比8小,不做交换,还是 [ 2,5,8,7,9 ]

8和7比较,8比7大,交换位置后,[ 2,5,8,7,9 ] 变成 [ 2,5,7,8,9 ]

可以看到,第二轮冒泡,在剩下未排序的4个元素中,比较了3次,把数组中第二大的元素"冒泡"到了倒数第二位。

第三轮冒泡:

2和5比较,2比5小,不做交换,还是 [ 2,5,7,8,9 ]

5和7比较,5比7小,不做交换,还是 [ 2,5,7,8,9 ]

可以看到,第三轮冒泡,在剩下未排序的3个元素中,比较了2次,把数组中第二大的元素"冒泡"到了倒数第三位。

第四轮冒泡:

2和5比较,2比5小,不做交换,还是 [ 2,5,7,8,9 ]

可以看到,第三轮冒泡,在剩下未排序的2个元素中,比较了1次,把数组中第二大的元素"冒泡"到了倒数第四位。由于数组总共只有5个元素,所以至此排序结束。

由此,通过步骤拆解,验证了上述代码中注释的内容。

进阶优化

到这里,细心的你肯定看出问题了,到二轮冒泡结束,已经排好序了, 其实已经不需要再排序了,这个时候, 如果还是按照上面的步骤进行操作,其实就是做了无用功了

如果能用及时知道数组已经是排好序的,之后不再处理了,那就好了。

具体怎么做呢?

分析上面的代码,可以发现,在每一轮冒泡过程中,发现有需要排序处理的情况,就会原地互换元素数据。所以,我们可以这么改造:

const bubblingSort = function (arr) {
  for (let i = 0; i < arr.length - 1; i++) {
    // 是否已排序完成标记
    let isSort = true
    for (let j = 0; j < arr.length - 1 - i; j++) {
      if (arr[j + 1] < arr[j]) {
        let temp = arr[j + 1]
        arr[j + 1] = arr[j]
        arr[j] = temp
        // 在冒泡过程中,如果没有数据互换的情况,也就说明全部数据已经是排好序的了,否则isSort为false 
        isSort = false
      }
    }
    // 上一轮冒泡过程已经发现是全部排好序的了, 就中断后面的循环
    if (isSort) {
      break
    }
  }
  return arr
}

| 时间复杂度

双层循环,所以时间复杂度是O(n^2)

二、选择排序

概念介绍

找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。

tips: 冒泡排序是往后排,选择排序是往前排,一个往前,一个往后哈。

怎么选择?

选取未排序序列中最小的值放在未排序序列的首位,循环往复...

代码实现

const selectionSort = (nums) => {
  // 外层循环控制选择的轮数,这里和冒泡排序有点差别,要选择n轮,n是数组的长度
  for (let i = 0; i < nums.length; i++) {
    // 维护一个当前轮次的最小值索引, 这里预先假设第i个元素已经是最小的,后面如果没有更小的元素,就不做处理。如果有更小的,则替换索引即可。最后把最小的元素交换到第i位,就完成了本轮选择排序了。
    let min = i
    // 因为是往前选,所以从下一个元素开始进行比较,一直到最后一个元素
    for (let j = i + 1; j < nums.length; j++) {
      // 更新未排序序列中最小值的索引
      if (nums[j] < nums[min]) {
        min = j
      }
    } 
    // 把最小的元素交换到第i位
    let temp = nums[i]
    nums[i] = nums[min]
    nums[min] = temp
  }
  return nums
}

tips: 实际上,理解了冒泡排序,选择排序就很好理解了

动图展示

还是以 [ 8,2,5,9,7 ] 为例

222.gif

步骤拆解

第一轮选择:

2和8比较,2比8小,更新索引后,最小索引为1

5和2比较,2比5小,不做索引更新

9和2比较,2比9小,不做索引更新

7和2比较,2比7小,不做索引更新

本轮选择的最后,将第i位也就是第一位元素和第二位元素(索引为1)进行数据交换,变成了 [ 2,8,5,7,9 ]

这样,当前序列中最小的元素就被选择到第一位了

可以看到,第一轮选择,比较了4次

第二轮选择:

5和8比较,5比8小,更新索引后,最小索引为2

7和5比较,5比7小,不做索引更新

9和5比较,5比9小,不做索引更新

本轮选择的最后,将第i位也就是第二位元素和第三元素(索引为2)进行数据交换,变成了[ 2,5,8,7,9 ]

这样,当前未排序序列中最小的元素就被选择到第二位了

可以看到,第二轮选择,比较了3次

第三轮选择:

7和8比较,7比8小,更新索引后,最小索引为3

9和7比较,7比9小,不做索引更新

本轮选择的最后,将第i位也就是第三位元素和第四位元素(索引为3)进行数据交换,变成了 [ 2,5,7,8,9 ]

这样,当前未排序序列中最小的元素就被选择到第三位了

可以看到,第三轮选择,比较了2次

第四轮选择:

9和8比较,8比9小,啥也不做

这样,所有的排序就完成了

可以看到,第四轮选择,比较了1次

由此,通过步骤拆解,验证了上述代码中注释的内容。

那么,上面的代码还能不能优化呢?有的,相信仔细的你肯定已经看出来了!

可以这样优化:

const selectionSort = (nums) => {
  for (let i = 0; i < nums.length; i++) {
    // 维护一个当前轮次的最小值索引, 初始值为0, 也就是从第一位开始
    let min = i
    // 从下一个元素开始进行比较,一直到最后一个元素
    for (let j = i + 1; j < nums.length; j++) {
      // 更新未排序序列中最小值的索引
      if (nums[j] < nums[min]) {
        min = j
      }
    } 
    // 把最小的元素交换到第i位
    if(i !== min){
      let temp = nums[i]
      nums[i] = nums[min]
      nums[min] = temp
    }
  }
  return nums
}

如果没有索引更新,就不需要互换数据了!

时间复杂度

双层循环,所以时间复杂度依然是O(n^2),和冒泡排序是一样滴

三、插入排序

概念介绍

找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。

tips: 冒泡排序是往后排,选择排序是往前排,一个往前,一个往后哈。

怎么插入?

其实插入排序和们打扑克牌的时候是一样的。试想这样一个场景,我右手的牌是无序的,我现在要把所有的牌一张一张有序地放到左手中。我们会怎么做呢?

我们会在右手的无序的牌中,抽一张出来,插入到左边,再抽一张出来,插入到左边,再抽一张,插入到左边,每次插入都插入到左边合适的位置,时刻保持左边的牌是有序的,直到右边的牌抽完,则排序完毕。

思路拆解

第一步:

还是以 [ 8,2,5,9,7 ]为例,我们把数组中的数据分成两个区域,已排序区域和未排序区域,初始化的时候所有的数据都处在未排序区域中,已排序区域是空。

第二步

第一轮,从未排序区域中随机拿出一个数字,既然是随机,对应我们的数组,我们就获取第一个,然后插入到已排序区域中。(当然了,第一轮在代码中是可以省略的,所以从下标为1的元素开始即可)

第三步

第二轮,继续从未排序区域中拿出一个数,插入到已排序区域中,这个时候要遍历已排序区域中的数字挨个做比较,然后插入到合适的位置。直到排序结束。

| 图示拆解

111.webp

222.webp

333.webp

444.webp

555.webp

代码实现

const insertSort = function (arr) {
  // 把第一位元素丢到已排序序列中, 从第二位开始插入
  for (let i = 1; i < arr.length; i++) {
    // 先取值出来,后面插入到对应的位置
    let insertedValue = arr[i]
    // 找出要插入的已排序序列的索引范围
    let j = i - 1
    // 比到已排序序列的第一位,也就是 j = 0,就不用再比了。insertedValue小于当前值,则把当前值往右边挪动一位
    while (j >= 0 && insertedValue < arr[j]) {
      // 把当前值往右边挪动一位
      // 实际上,在已排序序列中,有一个或者多个大于当前值的元素,移动之后就会空出一个"位置",需要填补这个"空位"
      arr[j + 1] = arr[j]
      // 动态更新j,记录最新的位置,便于后续插入
      j--
    }
    // 同上,不做无用功
    if (j + 1 !== i) {
      // 插入到空出的那个"位置"上,因为上面j--了,所以需要+1
      arr[j + 1] = insertedValue
    }
  }
  return arr
}

动图展示

还是以 [ 8,2,5,9,7 ] 为例

333.gif

步骤拆解

**第一轮插入

2和8比较,2比8小,更新索引后,最小索引为1

5和2比较,2比5小,不做索引更新

9和2比较,2比9小,不做索引更新

7和2比较,2比7小,不做索引更新

本轮选择的最后,将第i位也就是第一位元素和第二位元素(索引为1)进行数据交换,变成了 [ 2,8,5,9,7 ]

第二轮插入:

5和8比较,5比8小,更新索引后,最小索引为2

9和5比较,5比9小,不做索引更新

7和5比较,5比7小,不做索引更新

本轮选择的最后,将第i位也就是第二位元素和第三位元素(索引为2)进行数据交换,变成了 [ 2,5,8,7,9 ]

第三轮插入:

7和8比较,7比8小,更新索引后,最小索引为3

9和7比较,7比9小,不做索引更新

本轮选择的最后,将第i位也就是第三位元素和第四位元素(索引为3)进行数据交换,变成 [ 2,5,7,8,9 ]

第四轮插入:

9和8比较,8比9小,啥也不做

这样,所有的排序就完成了

由此,通过步骤拆解,验证了上述代码中注释的内容。

时间复杂度

双层循环,所以时间复杂度依然是O(n^2),和冒泡排序和选择排序是一样滴

四、希尔排序

概念介绍

希尔排序,又称"缩小增量排序",是一种插入排序的改进版本。目的是解决插入排序在处理大规模数据时性能较差的问题

啥?缩小增量排序是啥?

222.jpg

其实就是将整个数组分为若干个子序列,然后对每个子序列进行插入排序,逐渐减小子序列的长度,从宏观到微观,最终完成整个数组的排序。

核心思想

希尔排序的核心思想是通过大步长的插入排序,先使数组局部有序,然后逐步减小步长,最终达到全局有序。

实现原理

  1. 确定步长: 选择一个初始步长,通常是数组长度的一半。
  2. 分组插入排序: 将数组分为若干个子序列,每个子序列包含每隔步长的元素。对每个子序列进行插入排序。
  3. 减小步长: 逐渐减小步长,重复步骤2,直至步长为1,完成排序。

代码实现

const shellSort = (arr) => {
  const len = arr.length
  // 定义步长
  let gap = Math.floor(len / 2)
  // 一直循环到gap为1
  while (gap > 0) {
    for (let i = gap; i < len; i++) {
      let insertedValue = arr[i]
      let j = i - gap
      while (j >= 0 && insertedValue < arr[j]) {
        arr[j + gap] = arr[j]
        j -= gap
      }
      if (j + gap !== i) {
        arr[j + gap] = val
      }
    }
    // 对半缩小步长
    gap = Math.floor(gap / 2)
  }
  return arr
}

对比插入排序,其实就是多了下面这两步

1. 定义步长

2. 在插入排序最外层嵌套一个while循环,逐步缩小步长范围

tips: 要把插入排序的单步变化改成改gap变化

步骤拆解

这次以 [ 6,5,4,3,2, 1] 为例,步长为3

第一轮gap

gap = Math.floor(arr.length / 2) = 3

从第gap位也就是第四位开始插入,也就是先把3插入到合适的位置上去

3和6比较,3比6小,把6往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 6,5,4,3,2, 1] 变成 [ 3,5,4,6,2, 1]

然后准备插入下一位元素,也就是准备把2插入到合适的位置上去

2和5比较,2比5小,把5往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 3,5,4,6,2, 1] 变成 [ 3,2,4,6,5, 1]

然后准备插入下一位元素,也就是准备把1插入到合适的位置上去

1和4比较,1比4小,把4往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 3,2,4,6,5, 1] 变成 [ 3,2,1,6,5, 4]

第二轮gap:

当前数组: [ 3,2,1,6,5, 4]

gap = Math.floor(gap / 2) = 1

从第gap位也就是第二位开始插入,也就是先把2插入到合适的位置上去

2和3比较,2比3小,把3往右边移动gap位,也就是往右移动1个位置

交换位置后,[ 3,2,1,6,5, 4] 变成 [ 2,3,1,6,5, 4]

然后准备插入下一个位元素,也就是准备把1插入到合适的位置上去

1和2比较,1比2小,把2往右边移动gap位,也就是往右移动1个位置

1和3比较,1比3小,把3往右边移动gap位,也就是往右移动1个位置

交换位置后,[ 2,3,1,6,5, 4] 变成 [ 1,2,3,6,5, 4]

然后准备插入下一位元素,也就是准备把6插入到合适的位置上去

6比前三个元素都大,没有变化,还是 [ 1,2,3,6,5, 4]

然后准备插入下一位元素,也就是准备把5插入到合适的位置上去

最终是交换了5和6的位置,变成 [ 1,2,3,5,6, 4]

然后准备插入下一位元素,也就是准备把4插入到合适的位置上去

最终,6和5各往右移动一位,前面其它几个元素不不动,然后把4插入到3和6中间,

变成 [ 1,2,3,4,5, 6]

至此,循环结束,因为 gap 必须要大于0

可以发现,同样长度的数组,希尔排序花的时间更少

由此,通过步骤拆解,验证了上述代码中注释的内容。

时间复杂度

希尔排序的时间复杂度取决于步长的选择,应根据实际数据情况,选择合适的步长进行排序,通常为O(nlog n)到O(n^2)之间。在实践中,希尔排序的平均时间复杂度约为O(n^1.3)。

tips: 上述示例以数组长度一半作为步长,最外层while的时间复杂度为O(logn)

小结

希尔排序是一种插入排序的改进版本,通过逐步减小步长,先使数组局部有序,然后逐步达到全局有序。它相对于插入排序在处理大规模数据时表现更好。

五、归并排序

概念介绍

归并就是合并,归并算法的核心思想是分治法,就是将一个数组一刀切两半,递归切,直到切成单个元素,那么其实每个单个元素就是有序数组了,然后再按顺序合并就行了。

tips: 火狐浏览器的sort方法就是归并排序

// 辅助方法
// 合并两个有序数组为一个有序数组
const merge = function (left, right) {
  let result = []
  // 分别从两个有序数组中的第一个元素开始取较小的值, 依次push到result中
  let leftIndex = 0
  let rightIndex = 0

  while (leftIndex < left.length && rightIndex < right.length) {
    // 因为传入的是已经排好序的有序数组, 所以每次选取left和right这两个有序数组中的第一个元素中较小的push进result中, 同时把索引往右移动一位就好了。
    // 例如[1,3,5,7]和[2,4,6,8], 1和2中比较小的是1, 先push1, 然后比较2和3, push2, 比较3和4, push3, 比较4和5, push4, 依次类推...
    if (left[leftIndex] < right[rightIndex]) {
      result.push(left[leftIndex])
      leftIndex++
    } else {
      result.push(right[rightIndex])
      rightIndex++
    }
  } 
  // 上面的循环有一种极端的情况是其中一个数组已经push完了, 另外一个数组还有待push的元素, 这种情况, 由于另外一个有序数组B中的所有元素都是大于有序数组A中的最后一个元素的,直接使用result进行拼接即可
  // 下面的concat中会concat至少一个空数组, 对结果是没有影响的
  return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex))
} 

// 归并排序函数
// 最终会拆成只有一个元素的"有序数组", 然后通过merge方法合并成一个新的有序数组。
const mergeSort = function (arr) {
  let len = arr.length
  // 递归处理,需要考虑边界情况
  // 分而治之的思想, 最终需要分到不能再分, 也就是一个元素为止。
  if (len <= 1) return arr
  const middleIndex = Math.floor(len / 2)
  const leftArr = arr.slice(0, middleIndex)
  const rightArr = arr.slice(middleIndex)
  // 稍微有点抽象, 使用一个特例结合此表达式进行辅助理解, 假设刚好有4个元素的情况, 那么就是
  // merge(merge(mergeSort(left-left), mergeSort(left-right)), merge(mergeSort(right-left), mergeSort(right-right)))
  return merge(mergeSort(leftArr), mergeSort(rightArr))
}

动图展示

这次以 [ 6,4,3,7,5, 1, 2] 为例

111.gif

步骤拆解

这次以 [ 6,5,4,3,2, 1] 为例,步长为3

第一轮gap

gap = Math.floor(arr.length / 2) = 3

从第gap位也就是第四位开始插入,也就是先把3插入到合适的位置上去

3和6比较,3比6小,把6往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 6,5,4,3,2, 1] 变成 [ 3,5,4,6,2, 1]

然后准备插入下一位元素,也就是准备把2插入到合适的位置上去

2和5比较,2比5小,把5往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 3,5,4,6,2, 1] 变成 [ 3,2,4,6,5, 1]

然后准备插入下一位元素,也就是准备把1插入到合适的位置上去

1和4比较,1比4小,把4往右边移动gap位,也就是往右移动3个位置

交换位置后,[ 3,2,4,6,5, 1] 变成 [ 3,2,1,6,5, 4]

第二轮gap:

当前数组: [ 3,2,1,6,5, 4]

gap = Math.floor(gap / 2) = 1

从第gap位也就是第二位开始插入,也就是先把2插入到合适的位置上去

2和3比较,2比3小,把3往右边移动gap位,也就是往右移动1个位置

交换位置后,[ 3,2,1,6,5, 4] 变成 [ 2,3,1,6,5, 4]

然后准备插入下一个位元素,也就是准备把1插入到合适的位置上去

1和2比较,1比2小,把2往右边移动gap位,也就是往右移动1个位置

1和3比较,1比3小,把3往右边移动gap位,也就是往右移动1个位置

交换位置后,[ 2,3,1,6,5, 4] 变成 [ 1,2,3,6,5, 4]

然后准备插入下一位元素,也就是准备把6插入到合适的位置上去

6比前三个元素都大,没有变化,还是 [ 1,2,3,6,5, 4]

然后准备插入下一位元素,也就是准备把5插入到合适的位置上去

最终是交换了5和6的位置,变成 [ 1,2,3,5,6, 4]

然后准备插入下一位元素,也就是准备把4插入到合适的位置上去

最终,6和5各往右移动一位,前面其它几个元素不不动,然后把4插入到3和6中间,

变成 [ 1,2,3,4,5, 6]

至此,循环结束,因为 gap 必须要大于0

可以发现,同样长度的数组,希尔排序花的时间更少

由此,通过步骤拆解,验证了上述代码中注释的内容。

时间复杂度

我们可以发现 merge 方法中只有一个 while 循环,于是得出每次合并的时间复杂度为 O(n) ,而分解数组每次对半切割,属于对数时间 O(logn) ,也就是说,总的时间复杂度为 O(nlogn)

小结

归并排序是分治思想在排序算法设计中的集中体现, 深入理解了归并排序, 也就掌握了分而治之的精髓, 关键是:什么时候分, 又什么时候合; 怎么分, 又怎么合。

六、快速排序

概念介绍

快速排序的核心思想也是分治法,分而治之。它的实现方式是每次从序列中选出一个基准值,其他数依次和基准值做比较,比基准值大的放右边,比基准值小的放左边,然后再对左边和右边的两组数分别选出一个基准值,进行同样的比较移动,重复步骤,直到最后只剩下基准值,整个数组就成了有序的序列。

关键步骤

在数据集之中,选择一个元素作为"基准"(standardValue)。

所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。

对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。

代码实现

简易版本

新建数组递归版本。无需交换,每个分区都是新数组,数量庞大:

这个版本利用了JS数组可变且随意拼接的特性,让每个分区都是一个新数组,从而无需交换数组项。 这个方式非常简单易懂,但理论上来讲不是完全意义上的快排,效率较差。

const quickSort = function (arr) {
  let len = arr.length
  // 处理递归临界情况,按单个元素聚合数组, 与下面的return相呼应 -- return quickSort(left).concat([standardValue]).concat(quickSort(right))
  if (len <= 1) return arr
  const standardIndex = Math.floor(len / 2)
  const standardValue = arr.splice(standardIndex, 1)[0]
  const left = [],
    right = []
  //  这里arr.length比上面的len少1, 因为上面删掉了中间值
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] > standardValue) {
      right.push(arr[i])
    } else {
      left.push(arr[i])
    }
  }
  // 这里需要递归到只有一个元素也就是基准值为止
  return quickSort(left).concat([standardValue]).concat(quickSort(right))
}
进阶版本

双指针版本

// 交换元素
function swap(arr, a, b) {
  const temp = arr[a]
  arr[a] = arr[b]
  arr[b] = temp
}

// partition的作用:将基准数放在合适的位置上, 并返回其索引
function partition(arr, begin, end) {
  // 简单起见, 就以第一个元素arr[begin]作为基准数, 并创建两个指针, 分别置于数组的头和尾
  let left = begin,
    right = end
  // 双指针查找, left -- 左指针, right -- 右指针
  // 这里left < right是要确保没有重复查找, 也就是当leftright重合的时候停止循环
  while (left < right) {
    // 从右到左进行查找, 找到小于基准数的元素索引
    while (left < right && arr[right] >= arr[begin]) right--
    // 从左往右进行查找, 找到大于基准数的元素索引
    while (left < right && arr[left] <= arr[begin]) left++
    // 这里总共有两种情况:
    // 只移动一个指针, 两个指针都有移动
    // 1、只移动一个指针, 也就是基准值右边的元素都比基准值小的这种情况, 例如 [6,5,4,3,2,1], 外层第一轮循环后right不变, left移动到最右边和right重合, swap(arr, left, right) 即 swap(arr, 5, 5) 后还是 [6,5,4,3,2,1], 最终 swap(arr, begin, left) 即 swap(arr, 0, 5) 后变为 [1,5,4,3,2,6]. 可以看到, 基准值左边的元素都是小于基准值的, 然后对左边的数组重复之前的排序操作
    // 2、两个指针都有移动, 例如 [2,1,3,4,5,6], 外层第一轮循环后 left=1, right=1, swap(arr, left, right) 即 swap(arr, 1, 1) 后还是 [2,1,3,4,5,6], 整个循环结束, 最后swap(arr, begin, left) 即 swap(arr, 0, 1) 后为 [1,2,3,4,5,6], 可以看到, 基准值2左边的元素都比基准值小, 基准值右边的元素都比基准值大
    // 又例如 [3,2,1,4,5,6], 外层第一轮循环后 left=2, right=2, swap(arr, left, right) 即 swap(arr, 2, 2) 后还是 [3,2,1,4,5,6], 整个循环结束, 最后swap(arr, begin, left) 即 swap(arr, 0, 2) 后为 [1,2,3,4,5,6], 可以看到, 基准值3左边的元素都比基准值小, 基准值右边的元素都比基准值大。这里发现一个问题, 这里已经排好序了, 后面还会继续分区。后面我们继续讨论这个问题。
    // 之所以不存在两个指针都不动的情况, 是因为begin的初始值和left是重合的, 也就是说, 除非前面的right--一次都不会发生, 否则一定会至少left++一次。前面的right--一次都不会发生的情况:已经是排好序的了, 例如[1,2,3,4,5,6], right-- 直到right0, 此时left=right=begin=0, 符合begin >= end的条件, 排序结束
    // 交换两个元素, 使小于基准数的元素在基准数左边, 大于基准数的元素在基准数右边
    swap(arr, left, right)
  }
  // 循环结束后, 将基准数begin置换到数组中间的位置
  swap(arr, begin, left)
  // 其实swap(arr, begin, right)也是可以的,因为最终leftright在这里是一样的
  // swap(arr, begin, right)
  // 返回基准数所在的位置
  return left
  // return right
}

// 快速排序
const quickSort = function (arr, begin = 0, end = arr.length - 1) {
  // 同上,处理临界情况。也就是begin和end重合的时候
  if (begin >= end) return arr
  // 排序好中间的元素, 并返回其索引
  const standardIndex = partition(arr, begin, end)
  // 递归左子数组
  quickSort(arr, begin, standardIndex - 1)
  // 递归右子数组
  quickSort(arr, standardIndex + 1, end)
  return arr
}

// 测试
const arr = [6, 1, 2, 5, 4, 3, 9, 7, 10, 8] // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// 就以第一个元素为基准数呗
console.log(quickSort(arr))

tips: 关键在于实现partition方法

| 极端情况

如果给定的数组完全倒叙,那么会出现,基准数被交换到数组的最右侧,左子数组长度为 n-1,右子数组长度为0,分治策略失效,快速排序退化成“冒泡”排序的近似形式。也就是上面代码中的 [6,5,4,3,2,1] 这个情况

如何优化呢?

222.jpg

**可以通过扩大基准数的选取范围来解决:优化“分区划分”中的基准数的选取策略,例如选取3个候选元素,并将这三个候选元素的中位数作为基准数!**
// 交换元素
function swap(arr, a, b) {
  const temp = arr[a]
  arr[a] = arr[b]
  arr[b] = temp
}

// 取出中间的值
// 对应六种模型:
// LMR、LRM、MLR、MRL、RLM、RML
function medianThree(arr, left, right, mid) {
  let _left = arr[left],
    _mid = arr[mid],
    _right = arr[right]
  if (_mid >= _left && _mid <= _right) return mid
  if (_left >= _mid && _left <= _right) return left
  return right
}

function partition(arr, begin, end) {
  const mid = medianThree(arr, begin, right, Math.floor((begin + right) / 2))
  // 将基准数交换到最前前面
  swap(arr, begin, mid)
  例如 [6,5,4,3,2,1] 就是 medianThree(0, 5, 2)返回值为2, 交换后变为  [4,5,6,3,2,1]

  let left = begin,
    right = end
  // 交换后依然以 arr[begin]为基准数
  while (left < right) {
    while (left < right && arr[right] >= arr[begin]) right--
    while (left < right && arr[left] <= arr[begin]) left++
    swap(arr, left, right)
  }
  swap(arr, begin, left)
  return left
}

function quickSort(arr, begin = 0, end = arr.length - 1) {
  if (begin >= end) return arr
  const standardIndex = partition(arr, begin, end)
  qucikSort(arr, begin, standardIndex - 1)
  quickSort(arr, standardIndex + 1, end)
  return arr
}

动图展示

这次以 [ 3,5,8,1,2,9,4,7,6] 为例

111.gif

小结

快速排序的时间复杂度和归并排序一样,O(nlogn),但这是建立在每次切分都能把数组一刀切两半差不多大的前提下,如果出现极端情况,比如排一个有序的序列,如[ 9,8,7,6,5,4,3,2,1 ],选取基准值 9 ,那么需要切分 n - 1 次才能完成整个快速排序的过程,这种情况下,时间复杂度就退化成了 O(n2),当然极端情况出现的概率也是比较低的。

所以说,快速排序的时间复杂度是 O(nlogn),极端情况下会退化成 O(n2),为了避免极端情况的发生,选取基准值最好随机选取。

七、堆排序

概念介绍

堆排序顾名思义,是利用堆这种数据结构来进行排序的算法。

如果你了解堆这种数据结构,你应该知道堆是一种优先队列,两种实现,最大堆和最小堆,由于我们这里排序按升序排,所以就直接以最大堆来说吧。

啥是优先队列?啥是最大堆?最小堆?

222.jpg

我们完全可以把堆(以下全都默认为最大堆)看成一棵完全二叉树,但是位于堆顶的元素总是整棵树的最大值,每个子节点的值都比父节点小,由于堆要时刻保持这样的规则特性,所以一旦堆里面的数据发生变化,我们必须对堆重新进行一次构建。

既然堆顶元素永远都是整棵树中的最大值,那么我们将数据构建成堆后,只需要从堆顶取元素不就好了吗!第一次取的元素,是否取的就是最大值?取完后把堆重新构建一下,然后再取堆顶的元素,是否取的就是第二大的值?反复的取,取出来的数据也就是有序的数据!

222.gif

实现思路

构建最大堆:将无序数组转化为最大堆。

交换堆顶元素与末尾元素:将最大元素(堆顶)移到数组末尾,并调整堆结构。

重复调整堆:对剩余的未排序部分重复上述过程,直到整个数组有序。

tips: 堆排序通常使用最大堆来实现升序排序。

如何构建最大堆?

从最后一个非叶子节点开始,向上调整每个节点,确保子树满足最大堆性质。 这样经过一次遍历,整个数组就构建成一个最大堆。

代码实现

// 数据交换
function swap(arr, i, j) {
  var temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}

// 父节点的值与子节点的值进行比较,将父节点的值替换成最大的值,如果已是则不替换

/**
 * 从 index 开始检查并保持最大堆性质
 * @arr
 * @index 检查的起始下标
 * @heapSize 堆大小
 **/
function maxHeapify(arr, index, heapSize) {
  // 根节点和左右子节点在数组中的索引, 这是公式, 暂时先记着
  var iMax = index,
    iLeft = 2 * index + 1,
    iRight = 2 * (index + 1)

  if (iLeft < heapSize && arr[index] < arr[iLeft]) {
    iMax = iLeft
  }
  if (iRight < heapSize && arr[iMax] < arr[iRight]) {
    iMax = iRight
  }
  if (iMax != index) {
    swap(arr, iMax, index)
  }

  return arr
}

// 构建最大堆, heapSize -- 堆大小
function buildMaxHeap(arr, heapSize) {
  let iParent = Math.floor((heapSize - 1) / 2) // 最后一个节点的父元素所在的数组索引, 这是公式, 暂时先记着

  // 关键代码,确保构建最大堆。从后往前遍历, 是为了保持数组数据结构的稳定性
  // 将每一个节点和其左右子节点做比较, 并更新为三个节点中的最大值
  for (let i = iParent; i >= 0; i--) {
    maxHeapify(arr, i, heapSize)
  }

  return arr
}

// 堆排序
function heapSort(arr) {
  // 初始化构建最大堆, 通过这个步骤, 将数组最大的值, 交换到了数组第一位
  buildMaxHeap(arr, arr.length)
  for (let i = arr.length - 1; i > 0; i--) {
    // 把数组最大的值交换到数组倒数第一位也就是最后一位, 数组第二大的值交换到数组倒数第二位, ...
    swap(arr, 0, i)
    // 在未排序序列中构建最大堆
    buildMaxHeap(arr, i)
  }
  return arr
}

动图展示

这次以 [ 5,2,7,3,6,1,4] 为例

111.gif

步骤拆解

以 [ 8,2,5,9,7,3] 为例

首先,将数组构建成堆。

111.webp

接下来,利用最大堆的特性,我们取出堆顶的数据9(最大的数),取法是将数组的第一位和最后一位调换,然后将数组的待排序范围 -1。

222.webp

现在的待排序数据是[ 3,8,5,2,7 ],我们继续将待排序数据构建成堆。

333.webp

取出堆顶数据,这次就是第一位和倒数第二位交换了,因为待排序的边界已经减 1 。

444.webp

继续构建堆

555.webp

依次循环,最终完成排序

相关公式

parent(i) = floor((i-1)/2); // 其中i为最后一个父节点/非叶子节点 ileft(i)=2i+1; iright(i)=2(i+1);

优点

堆排序(Heap Sort)是一种基于堆(Heap)数据结构的高效排序算法。堆排序利用堆的特性将数组有序化,具有时间复杂度稳定、空间复杂度低的优点。

缺点

堆排序是不稳定的排序算法。相同值的元素在排序后可能会改变相对位置。比如 [9,8,8,7], 在排序后中间的两个8可能会互换位置。

时间复杂度

构建堆:O(n)

调整堆:O(n log n)

总体时间复杂度:O(n log n)

堆排序的时间复杂度在所有情况下(最佳、平均、最坏)都是 O(n log n)。

小结

堆排序利用堆这种数据结构,通过构建堆、交换堆顶元素与末尾元素、调整堆的方式,实现对数组的高效排序。其稳定的时间复杂度和原地排序的特点使其在许多场景下表现出色。虽然堆排序是不稳定的,但在需要 O(n log n) 时间复杂度且不关心稳定性的情况下,是一个不错的选择。

tips:

最大堆用于升序排序,最小堆用于降序排序

对于非常大的数据集,考虑分块处理,或者使用更高效的排序算法(如快速排序、归并排序)结合堆排序。

堆不是本文的重点, 有兴趣的朋友们可以深入了解下相关的概念,或者验证下相关公式

八、计数排序

概念介绍

计数排序是一种非基于比较的排序算法,之前的冒泡、选择、插入、希尔、归并、快排、堆排序都是基于比较进行排序的算法。由于不涉及元素之间的比较,计数排序在某些情况下具有较快的速度。

与其他比较排序算法不同,计数排序通过统计每个元素出现的次数,然后根据统计信息将元素直接放置到输出数组的正确位置。啥?

222.jpg

计数排序具有线性时间复杂度的特点,适用于一定范围内的整数排序。

实现原理

找到范围: 遍历整个数组,找到数组中的最大值和最小值。

统计频次: 创建一个计数数组,记录每个元素出现的频次。

生成排序数组: 根据频次信息,生成排序后的数组。

tips:计数排序的核心在于通过频次统计,直接确定每个元素在排序后数组中的位置。

代码实现

// 计数排序
function countSort(arr) {
  // 实现思路:
  // 先统计最大最小值, 确定计数区间
  // 再新建数组做计数标记, 计算元素的频次, 这是关键
  // 最后根据标记频次进行数组原地排序
  
  const n = arr.length

  // 找到数组中的最大值和最小值, 以便于确定下一步的新建数组的长度
  let min = arr[0],
    max = arr[0]
  for (let i = 1; i < n; i++) {
    if (arr[i] < min) {
      min = arr[i]
    }
    if (arr[i] > max) {
      max = arr[i]
    }
  } 
  
  // 新建数组做计数标记,计算元素的频次
  // 要先预留足够多的位置,所以需要新建一个长度为 max-min+1 的数组, 初始值设置为0
  // 之所以是max-min+1,是因为待排序数组中可能有正整数也可能有负整数
  
  let countArray = Array(max - min + 1).fill(0)
  for (let i = 0; i < n; i++) {
    countArray[arr[i] - min]++
  } 
  
  // 根据频次进行原地排序, 排好一位 index++
  // 从第一位开始进行填空排序
  let index = 0
  for (let i = 0; i < countArray.length; i++) {
    while (countArray[i] > 0) {
      arr[index] = i + min // 对应上面的 countArray[arr[i] - min]++
      index++
      // arr[index++] = i + min;
      countArray[i]--
    }
  }
  return arr
}

| 动图展示

这次以 [ 5,3,4,7,2,4,3,4,7] 为例

111.gif

步骤拆解

拿一组计数排序啃不掉的数据 [ 500,6123,1700,10,9999 ] 来举例

优点

有一个需求就是当对成绩进行排名次的时候,如何在原来排前面的人,排序后还是处于相同成绩的人的前面。 解题的思路是对 countArr 计数数组进行一个变形,和名次挂钩,我们知道 countArr 存放的是分数的出现次数,那么其实我们可以算出每个分数的最大名次,就是将 countArr 中的每个元素顺序求和。如下:

111.png

变形之后是什么意思呢?

222.jpg

我们把原数组[ 3,5,8,2,5,4 ]中的数据依次拿来去 countArr 去找,你会发现 3 这个数在 countArr[3] 中的值是 2 ,代表着排名第二名,(因为第一名是最小的 2,对吧?),5 这个数在 countArr[5] 中的值是 5 ,为什么是 5 呢?我们来数数,排序后的数组应该是[ 2,3,4,5,5,8 ],5 的排名是第五名,那 4 的排名是第几名呢?对应 countArr[4] 的值是 3 ,第三名,5 的排名是第五名是因为 5 这个数有两个,自然占据了第 4 名和第 5 名。

所以我们取排名的时候应该特别注意,原数组中的数据要从右往左取,从 countArr 取出排名后要把 countArr 中的排名减 1 ,以便于再次取重复数据的时候排名往前一位。

tips: 计数排序特别适用于整数范围较小且元素分布较为均匀的情况。

缺点

如果我要排的数据范围比较大呢?比如[ 1,9999 ],我排两个数你要创建一个 长度10000 的数组来计数?

因此计数排序仅适用于取值范围相差不大的数组排序使用,此时排序的速度是非常可观的。

时间复杂度

计数排序的时间复杂度为O(n + k),其中n是数组的长度,k是数组中元素的范围(最大值减最小值加1)。

小结

计数排序是一种非比较性的排序算法,通过统计元素的频次,直接确定元素在排序后数组中的位置。

尽管其适用范围有一定限制,但在特定场景中,计数排序能够以线性时间复杂度实现排序,具有较好的性能表现

所以,在选择排序算法时,需要根据具体数据特征和需求综合考虑,以达到最佳的排序效果

九、桶排序

概念介绍

桶排序可以看成是计数排序的升级版,它将要排的数据分到多个有序的桶里,每个桶里的数据再单独排序,再把每个桶的数据依次取出,即可完成排序。

那么问题来了:

222.jpg

桶这个东西怎么表示?

怎么确定桶的数量?

桶内排序用什么方法排?

实现原理

确定桶的数量: 遍历数组找到最大值和最小值,计算出桶的数量。

分配到桶中: 将元素根据映射规则分配到相应的桶中。

对每个桶排序: 对每个非空桶中的元素进行排序。

合并桶: 将所有桶中的元素合并成一个有序序列。

tips: 桶排序的核心在于对数据进行分桶,确保每个桶内的数据有序,然后通过合并桶得到最终的有序序列。

代码实现

function bucketSort(arr, bucketSize) {
  // 实现思路:
  // 先求最大最小值, 确定范围
  // 分桶和新建数组表示桶
  // 按照关联关系将数组中元素依次入桶
  // 桶内排序
  // 依次收集桶内所有元素并返回
  if (arr.length === 0) {
    return arr
  }

  let minValue = arr[0]
  let maxValue = arr[0]
  // 求出最大值和最小值
  maxValue = Math.max(...arr)
  minValue = Math.min(...arr)

  // 桶的容量
  // 设置桶的默认容量为容纳5个元素,或者也可以使用数组长度的一半
  // 也可以设置DEFAULT_BUCKET_SIZE=1, 一个桶装一个元素, 这样也不用进行桶内排序了, 时间最快
  
  let DEFAULT_BUCKET_SIZE = 5
  bucketSize = bucketSize || DEFAULT_BUCKET_SIZE

  // 有了最大值和最小值, 和桶的容量, 就可以求出桶的数量了
  let bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1
  // let bucketCount = Math.ceil((maxValue - minValue) / bucketSize)

  // 有几个桶就新建几个数组, 用数组来表示桶
  var buckets = new Array(bucketCount)
  for (let i = 0; i < buckets.length; i++) {
    buckets[i] = []
  }

  // 或者这样也可以
  // let buckets = []
  // for (let i = 0; i < bucketCount; i++) {
  //   buckets[i] = []
  // }

  //利用映射函数将数据分配到各个桶中, 这一步确保了每个"桶"中的数据都比下一个"桶"中的数据要小
  // 接下来再将每个桶的数据排好序, 然后依次进行合并就可以得到最终排好序的数组了
  for (let i = 0; i < arr.length; i++) {
    // 与上面的"求出桶的数量"相呼应
    // 注意:最后一个桶的索引 -- Math.floor((maxValue - minValue) / bucketSize) --  Math.ceil((maxValue - minValue) / bucketSize) + 1
    let bucketIndex = Math.floor(arr[i] - min / bucketSize)
    buckets[bucketIndex].push(arr[i])
  }

  let result = []

  // 桶一排序, result依次追加桶一的数据, 桶二排序, result依次追加桶二的数据, ...
  for (i = 0; i < buckets.length; i++) {
    // 对每个桶进行排序,这里使用了插入排序,比较稳定,如果对稳定性没啥要求,这里也可以直接用js的数组sort方法,很直接
    const sortFn = (a, b) => a - b
    buckets[i].sort(sortFn)
    // Array.prototype.sort.call(buckets[i], sortFn)
    // [].call(buckets[i], sortFn)
    // insertionSort(buckets[i])
    for (var j = 0; j < buckets[i].length; j++) {
      result.push(buckets[i][j])
    }
  }

  return result
}

动图展示

111.gif

时间复杂度

桶排序的时间复杂度为O(n + k),其中n是数组的长度,k是桶的数量。在桶的数量接近数组长度时(最理想的情况是桶的容量为1),桶排序的效率较高。

tips:

桶排序是一种占用额外空间的排序算法,其空间复杂度为O(n + k),其中n是数组的长度,k是桶的数量。

桶排序的时间复杂度和空间复杂度都受到桶的数量影响,因此合理选择桶的数量对算法性能至关重要。

适用场景

桶排序是一种分布式排序算法,具有简单、直观且高效的特点。它将待排序数据分到有限数量的桶中,每个桶再分别排序,最后将各个桶中的数据合并成有序序列。

桶排序的关键在于将数据映射到合适的桶中,并保证每个桶内的数据是有序的。它适用于数据分布较为均匀的情况,特别适用于大数据量,无法一次载入内存的情况

深入实践

在额外空间充足的情况下,尽量增大桶的数量,极限情况下每个桶只有一个数据时,或者是每只桶只装一个值时,完全避开了桶内排序的操作,桶排序的最好时间复杂度就能够达到 O(n)。

比如高考总分 750 分,全国几百万人,我们只需要创建 751 个桶,循环一遍挨个扔进去,排序速度是毫秒级

但是如果数据经过桶的划分之后,桶与桶的数据分布极不均匀,有些数据非常多,有些数据非常少,比如[ 8,2,9,10,1,23,53,22,12,9000 ]这十个数据,我们分成十个桶装,结果发现第一个桶装了 9 个数据,这是非常影响效率的情况,会使时间复杂度下降到 O(nlogn),解决办法是我们每次桶内排序时判断一下数据量,如果桶里的数据量过大,那么应该在桶里面回调自身再进行一次桶排序。

小结

桶排序是一种适用于特定场景的排序算法,通过将数据分布到有限数量的桶中,保证每个桶内的数据有序,最终实现全局有序。尽管其在一些特殊情况下性能较好,但由于需要额外的空间存储桶,因此在某些内存敏感的场景中可能不太适用。在选择排序算法时,需要根据具体数据特征和需求综合考虑,以达到最佳的排序效果。

tips:桶排序是计数排序的升级版,也是非比较排序,但桶内还是要使用排序

排序之美-- 人人都懂的九大排序

简单排序包括冒泡排序、插入排序、简单选择排序。

从平均时间来看,快速排序是效率最高的,但快速排序在最坏情况下的时间性能不如堆排序和归并排序

上面的算法基本是使用线性存储结构,像插入排序这种算法用链表实现更好,省去了移动元素的时间。

tips:

原地快排(不产生新的数组)的空间占用是递归造成的栈空间的使用,最好情况下是递归log2n次,所以空间复杂度为O(log2n),最坏情况下是递归n-1次,所以空间复杂度是O(n)。

非原地快排(产生新的数组)每次递归都要声明一个总数为n的额外空间,所以空间复杂度变为原地排序的n倍,即最好情况下O(nlog2n),最差情况下O(n^2) 在实际应用中,快速排序通常效率最高,但不稳定。插入排序和选择排序在小数据量时是不错的选择。

浏览器中的排序算法

谷歌JS V8引擎sort的实现:

Chrome的V8引擎在实现sort方法时,采用了多种排序算法的混合策略。对于小数组(长度小于等于10),V8使用插入排序,而对于较大的数组则使用快速排序。这种选择是基于性能考虑:插入排序在小数组上更高效,而快速排序在大规模数据上更有优势‌1。此外,V8还会根据具体情况选择使用堆排序以应对最坏情况下的性能问题‌

算法实战

LeetCode上的九大排序算法难度等级如下‌:

冒泡排序‌:简单题。冒泡排序的时间复杂度为O(n^2),适用于小规模数据排序‌。

选择排序‌:简单题。选择排序的时间复杂度也为O(n^2),适用于小规模数据排序‌。

插入排序‌:简单题。插入排序的时间复杂度为O(n^2),适用于小规模数据排序‌。

希尔排序‌:中等题。希尔排序通过插入排序的方式对部分数组进行排序,时间复杂度取决于间隔序列的选择,通常比 O(n log n) 的算法快‌。

归并排序‌:中等题。归并排序的时间复杂度为O(n log n),适用于大规模数据排序‌。

快速排序‌:中等题。快速排序的平均时间复杂度为O(n log n),但最坏情况下为O(n^2),适用于大规模数据排序‌。

堆排序‌:中等题。堆排序的时间复杂度为O(n log n),适用于大规模数据排序‌。

桶排序‌:中等题。桶排序的时间复杂度取决于输入数据的分布,最好情况下为O(n+k),最坏情况下为O(n^2),适用于数据分布均匀的情况‌。

计数排序‌:简单题。计数排序的时间复杂度为O(n+k),适用于小规模数据或数据范围较小的场景‌。

写在最后

感谢你能看到这里,继续加油哦💪!如果小伙伴们还有其他的应用场景,欢迎在评论区留言哈😄。码字不易, 如若有错误,也望指出哈❤️。

如果本文对你有一点点帮助,点个赞支持一下呗,你的每一个【赞】都是我创作的最大动力 😘。

如果您觉得文章不错,记得 点赞关注加收藏 哦 💕

111.webp