前端算法学习总结篇(js) 2.排序算法

1,638 阅读22分钟

概述

本章主要是总结常见的排序算法,将会介绍他们的排序原理,对应的最好时间复杂度、最差时间复杂度、平均时间复杂度以及空间复杂度等,最重要是排序的源码,将利用一些简单的例题来帮助大家更好的理解排序算法。

阅读本章代码时建议自己mock一个无序数组,然后多加log去看看每次数组的变化,能更好的吸收以下内容。

本人常年在leetcode刷算法题,所以本次文章大部分题目选自leetcode,本文参考:leetcode排序算法解析,已将代码转成js语言方便各位阅读。

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

冒泡排序

冒泡排序可能是大家入门的时候常看到的排序算法。

通常来说,冒泡排序有三种写法:

  • 一边比较一边向后两两交换,将最大值/最小值冒泡到最后一位;
  • 对上一种做法的优化:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生交换表示已经有序,不需要再继续排序;
  • 再对上一种做法进行优化:再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较

第一种写法:

function bubbleSort(arr) {
  const n = arr.length
  // n - 1: 只需要遍历前 n - 1个
  for (let i = 0; i < n - 1; ++i) {
    // n - 1 - i: 由 i 下标出发遍历到 n - 1 下标处
    for (let j = 0; j < n - 1 - i; ++j) {
      // 如果左边的数比右边大,就交换,保证右边的数字最大。 换言之就是将数组从小到大排序
      if (arr[j] > arr[j + 1]) { // 更大的得在后面
        // 平平无奇交换函数
        swap(arr, j, j + 1)
      }
    }
  }
}
// 交换元素
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
  // 当然你也可以使用es6语法 [arr[i], arr[j]] = [arr[j], arr[i]]
}

以上的做法是将数字两两比较,且规定了 “谁大站右边”(由你实际业务决定),经过 n - 1 轮的比较后排序完成,整个过程就像一个个气泡不断上浮,所以被叫做“冒泡排序”

由第一种写法改良而来的第二种写法:

function bubbleSort(arr) {
  // 初始化变量 swapped 为 true,该变量用于 记录当前轮次的比较是否发生过交换 ,初始化为 true 先让程序往下跑
  let swapped = true
  const n = arr.length
  // n - 1: 只需要遍历前 n - 1个
  for (let i = 0; i < n - 1; ++i) {
    // 如果没有发生过交换,说明剩余部分已经是有序的了,可以提前结束遍历  (通常我们称之为 剪枝 ,即再往下跑也没有意义了,已经完成任务了,可以提前结束程序)
    if (!swapped) break
    // 正常进入循环,先设置为 false ,如果发生了交换再设置为 true
    swapped = false 
    // n - 1 - i: 由 i 下标出发遍历到 n - 1 下标处
    for (let j = 0; j < n - 1 - i; ++j) {
      // 如果左边的数大于右边的数,则交换,保证右边的数字最大
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1)
        // 发生了交换 我们把 swapped 设置为 true
        swapped = true
      }
    }
  }
}
// 交换元素
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
  // 当然你也可以使用es6语法 [arr[i], arr[j]] = [arr[j], arr[i]]
}

这种写法基本原理还是 当最外层 for 循环经过一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位

相对于第一种写法的优点在于:如果一轮比较中没有发生过交换,则停止排序,减少无用的遍历

看一个gif来更好的理解吧:

冒泡排序第二种演示

我们可以看到:

  • 第一轮遍历的时候把数字 6 移动到最右
  • 第二轮遍历把数字 5 移动到最右,同时中途把数字 1 和 2 也排了序
  • 第三轮遍历的时候没有发生交换,此时排序已经完成了,结束遍历

在第二种写法的基础上优化出第三种写法:

function bubbleSort(arr) {
  // 写法二变量定义:
  let swapped = true
  const n = arr.length
  // 最后一个没有经过排序的元素的下标
  let indexOfLastUnsortedElement = n - 1
  // 上次交换的位置
  let swappedIndex = -1
  while (swapped) {
    // 正常进入循环,先设置为 false ,如果发生了交换再设置为 true
    swapped = false
    for (let i = 0; i < indexOfLastUnsortedElement; ++i) {
      // 如果左边的数比右边的数大,就叫唤,保证右边的数最大
      if (arr[i] > arr[i + 1]) {
        swap(arr, i, i + 1)
        // 发生了交换, swapped设置为 true, swappedIndex更新到对应下标
        swapped = true
        swappedIndex = i
      }
    }
    // 最后一个没有经过排序的元素的下标就是最后一次发生交换的位置
    indexOfLastUnsortedElement = swappedIndex
  }
}
// 交换元素
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
  // 当然你也可以使用es6语法 [arr[i], arr[j]] = [arr[j], arr[i]]
}

第三种写法看起来稍微复杂点,最外层的 while 循环每一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位

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

当一轮比较从头到尾都没有发生过交换,则表示整个列表已经有序,排序完成

冒泡排序 时间复杂度 & 空间复杂度

冒泡排序经过多次的优化,它的空间复杂度为 O(1), 时间复杂度为O(n^2) 第二、三种写法由于经过优化,最好时间复杂度可以达到O(n)

最好情况: [1, 2, 3, 4, 5, 6, 7] 数组本身就是有序的,只需要遍历一次,由于没有发生过交换,遍历结束

最差情况: [7, 6, 5, 4, 3, 2, 1] 数组本身是逆序的,每次比较都有交换

所以我们可以看到优化后的冒泡排序平均时间复杂度仍然是 O(n^2),这些优化对算法的性能没有质的提升(防抬杠, 抬杠就你对)

总结

冒泡排序并不值得推荐,但却是必须知道的 因为冒泡排序是所有排序算法的祖宗,犹如hello world

例题

  1. leetcode 283. 移动零
  2. 剑指offer 45.把数组排成最小的数

*附送数组元素交换技巧

我们看到冒泡排序是一定需要使用到 交换数组中两个元素的位置 的,那么这里有一个常见的面试题:

怎样在不引入第三个中间变量的情况下,完成数组两个元素的交换

首先我们先不要提 [arr[i], arr[j]] = [arr[j], arr[i]]

我们可以使用一个数学上的技巧

arr[j + 1] = arr[j + 1] + arr[j]
arr[j] = arr[j + 1] - arr[j]
arr[j + 1] = arr[j + 1] - arr[j]

这种写法难记且存在数字越界的风险

最佳的方案是通过位运算符来完成数字交换: (^ 是异或运算符)

arr[i] = arr[i] ^ arr[j]  // a = a ^ b
arr[j] = arr[j] ^ arr[i]  // b = a ^ b
arr[i] = arr[i] ^ arr[j]  // a = a ^ b

这里对位运算符的使用技巧先不做深究,感兴趣可以留言 我考虑出一期位运算符相关算法题特辑。

选择排序

选择排序的思想就是: 双重循环遍历数组,每经过一轮比较,找到最小元素的下标,将其交换至首位

直接上代码:

// 以从小到大排序演示
function selectionSort(arr) {
  let minIndex
  const n = arr.length
  for (let i = 0; i < n - 1; ++i) {
    minIndex = i
    for (let j = i + 1; j < n; ++j) {
      // 记录最小值的下标
      if (arr[minIndex] > arr[j]) {
        minIndex = j
      }
    }
    // 将最小元素交换至首位
    let temp = arr[i]
    arr[i] = arr[minIndex]
    arr[minIndex] = temp
  }
}

选择排序好比打擂台,所有数字挨个打擂,谁的拳头更硬/软就产生新的擂主,交换至首位,打 n - 1 轮,数字就排序完成了

为了方便理解,来看一下动图演示吧

快速排序演示图

看图我们可以发现,每一轮排序都找到了当前那一轮的最小值,并被交换到当前轮的首位,这就是选择排序

二元选择排序

选择排序依然是可以优化的,没想到吧啊哈哈哈

优化的思想: 每轮遍历找到最小值,干嘛不把最大值也找出来

这种优化过后的快速排序 我们称之为 二元选择排序

每轮遍历记录最小值和最大值,可以把数组需要遍历的范围缩小一倍。

上代码:

function selectionSort2(arr) {
  let minIndex, maxIndex
  const n = arr.length
  // i 只要遍历一半啦
  for (let i = 0; i < n / 2; ++i) {
    minIndex = i
    maxIndex = i
    for (let j = i + 1; j < n - 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
    // 将最小元素交换至首位
    let temp = arr[i]
    arr[i] = arr[minIndex]
    arr[minIndex] = temp
    // 如果最大值的下标刚好是 i ,由于 arr[i] 和 arr[minIndex] 已经交换了,所以要更新 maxIndex 的值 这个是重点
    if (maxIndex == i) maxIndex = minIndex
    // 将最大元素交换至末尾
    let lastIndex = n - 1 - i
    temp = arr[lastIndex]
    arr[lastIndex] = arr[maxIndex]
    arr[maxIndex] = temp
  }
}

我们可以看到利用多一个变量 我们最外层的遍历只需要遍历一半就可以了

注意代码里很重要的一句 if (maxIndex == i) maxIndex = minIndex ,这行代码位于 交换最小值最欢最大值的代码的中间

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

二元选择排序的效率

虽然二元选择排序使得排序外层的遍历范围缩小了,但是for循环内做的事情是翻了一倍的。

也就是说二元选择排序无法将排序的效率提升一倍(质的提升)

但实测的话二元选择排序比选择排序速度是要快一点点的,提升的原因在于:

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

选择排序 时间复杂度 & 空间复杂度

选择排序使用两层循环,时间复杂度为 O(n^2),使用有限个变量,空间复杂度为 O(1)

二元选择排序比选择排序稍快,但治标不治本,其优化仍不足以改变其时间复杂度,故时间复杂度为 O(n^2),使用有限个变量,空间复杂度为 O(1)

选择排序与冒泡排序的异同

相同点:

  • 都是两层循环,时间复杂度都是O(n^2)
  • 都只用了有限的变量,空间复杂度O(1)

不同点:

  • 冒泡排序在比较的时候就不断两两交换; 而选择排序多了一个变量用来保存最小/最大值的下标,遍历完成后才交换,减少了交换的次数
  • 冒泡排序是稳定的,选择排序是不稳定的

怎么理解这个稳定和不稳定呢?

很简单,冒泡排序就是当左边数字比右边数字大/小,才会发生交换,相等数字是不会发生交换的,我们就认为他是稳定的。

而选择排序,最大/小值和首位交换的过程中就容易破坏稳定性,比如数组:[2, 2, 1] 在第一次交换的时候 原数组的两个 2 的相对顺序就被改变了,我们认为他是不稳定的。

算法稳定性有什么用?实际业务哪里会用到?

举个例子,比如我们需要对商品做排序,商品「价格」「销量」两个属性,我们要求按照 价格从高到低排序,再按销量从高到低排序

这时,你要保证销量相同的两个商品的顺序仍然保持价格从高到低的顺序,你就必须考虑算法的稳定性。

快速排序不稳定,怎么让他变稳定?

我们是否可以 新开一个数组,将每轮找出的最小值依次推入新数组,这样快速排序是不是就变稳定了?

冒泡排序太稳定了,怎么让他不稳定?

设想一下,我们使用冒泡排序的时候,判断条件是 「左边的数大于右边的数,就交换」,一旦你修改成「左边的数大于等于右边的数,就交换」,那么冒泡排序就变得不稳定了

例题

  1. leetcode 215. 数组中的第K个最大元素
  2. leetcode 912. 排序数组

插入排序

插入排序的思想非常简单,想想你打斗地主的时候,一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序

插入排序有两种写法:

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

交换法插入排序

function insertSort(arr) {
  const n = arr.length
  // 从第二个数字开始,往前插入数字
  for (let i = 1; i < n; ++i) {
    // j 记录当前数字下标
    let j = i
    // j >= 1: j不能回到第一个数字下标 不然 j-1 减哪去了; arr[j] < arr[j - 1]: 当前数字比前一个数字小,则将当前数字与前一个数字交换
    while (j >= 1 && arr[j] < arr[j - 1]) {
      swap(arr, j, j - 1)
      // 更新当前数字下标
      j--
    }
    // 当然上面的 while 觉得不好记可以使用 for ,使用while是为了更好注释出来
    // for (let j = i; j >= 1 && arr[j] < arr[j - 1]; --j) {
    //   swap(arr, j, j - 1)
    // }
  }
}
// 老朋友了
function swap(arr, i, j) {
  arr[i] = arr[i] ^ arr[j]
  arr[j] = arr[i] ^ arr[j]
  arr[i] = arr[i] ^ arr[j]
}

当数字少于两个时,不存在排序问题,所以我们直接从第二个数字开始往前插入数字

整个过程就像是军训排队列,已经有这么一排人了,你站在第一个,发现你比第二个高,你就和他换,诶一直换到下一个人比你高,这不就找到你该呆的位置了吗

移动法插入排序

在交换法插入排序中,每次交换数字时, swap 函数都会进行三次赋值操作。但是实际上呢,新插入的这个数字可能刚换到新的位置上不久在下一次比较的时候又要换位置。

因此有了移动法插入排序:让新插入的数字先进行比较,前面比它大的数字不断向后移动,直到找到适合这个新数字的位置后,新数字只做一次插入操作即可。

上代码!!!

function insertSort(arr) {
  const n = arr.length
  // 从第二个数字开始,往前插入数字
  for (let i = 1; i < n; ++i) {
    let currentNum = arr[i]
    let j = i - 1
    // 1.遇到小于或等于 currentNum 的数字跳出循环 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNum 的数字,跳出循环 这时候 j 是 -1 ,所以 currentNum 会坐到 arr[-1 + 1] = arr[0] 的位置,也就是数组头部位置
    while (j >= 0 && currentNum < arr[j]) {
      // 寻找插入位置的过程中,不断地将比 currentNum 大的数字向后挪  
      arr[j + 1] = arr[j]
      j--
    }
    // currentNum 坐到对应位置
    arr[j + 1] = currentNum
  }
}

来一个直观的gif感受一下8

插入排序演示图

有的好朋友可能已经发现了插入排序的过程不会破坏原数组中相同关键字的相对次序,没错!!所以插入排序是一种稳定排序算法

插入排序 时间复杂度 & 空间复杂度

插入排序过程需要两层循环,时间复杂度为 O(n^2); 只需要常量级的临时变量,空间复杂度为 O(1)。

例题

  1. leetcode 147. 对链表进行插入排序 该题需要链表基础,但是原理和数组插入排序相似,试试看吧
  2. leetcode 912. 排序数组

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

希尔排序

希尔排序在面试或是实际应用中都很少遇到,且排序过程较为复杂,仅需了解即可。

希尔排序算法是首批将时间复杂度降到O(n^2)以下的算法之一

由于希尔算法的优化依赖的基本只有对增量序列的优化,所以其优化是有限的,和上面提到的几个O(n^2)复杂度算法一样,逐渐被快速排序所淘汰

希尔排序的本质是对插入排序的一种优化,其基本思想是:

  • 将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。注意这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值来组成一组。
  • 逐渐缩小间隔进行下一轮排序
  • 最后一轮的时候,间隔为1,其实就相当于插入排序了。但是经过前面的一个「宏观调控」,数组已基本有序了,所以只需要进行少量交换即可完成排序。

上个gif先了解一下吧:

希尔排序演示图

我们可以注意到,第一遍的时候是间隔5,第二遍间隔2,第三遍间隔1。我们称之为增量,那么[5, 2, 1]就称为增量序列。 增量根据增量序列递减,且最后一个增量必须是1。 我们看到间隔2排序之后,数组对于间隔5来说仍然是有序的,也就是说,更小间隔的排序没有把上一步的结果变坏。

那其实影响希尔排序效率很明显是取决于我们的增量序列的定义。 本例采用的是希尔发表此算法论文时选用的序列: 希尔增量序列 N 数组长度 m最大间隔 k中间的间隔

代码如下:(觉得记起来有些难度可以直接跳到优化后的希尔排序)

function shellSort(arr) {
  const n = arr.length
  // 增量序列
  for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
    // 分组 如 gap = [5, 2, 1], 那么这里 groupStartIdx 第一轮就要跑到下标 4,第二轮跑到下标 1...
    for (let groupStartIdx = 0; groupStartIdx < gap; ++groupStartIdx) {
      // 插入排序 + gap 即拿到间隔 gap的下一个数字
      for (let currentIdx = groupStartIdx + gap; currentIdx < n; currentIdx += gap) {
        // currentNumber 站起来 找位置
        let currentNumber = arr[currentIdx]
        let preIdx = currentIdx - gap
        while (preIdx >= groupStartIdx && currentNumber < arr[preIdx]) {
          // 向后挪位置
          arr[preIdx + gap] = arr[preIdx]
          preIdx -= gap
        }
        // currentNumber 找到自己的位置 坐下
        arr[preIdx + gap] = currentNumber
      }
    }
  }
}

希尔排序这样子写其实就是: 处理完一组间隔序列后,再回来处理下一组间隔序列,比较符合人类思维 但对于计算机来说,按照顺序从第 gap 个元素开始依次向前插入自己所在的组是在访问一段连续数组。

希尔增量序列优化后的代码如下:

function shellSort(arr) {
  const n = arr.length
  // 增量序列
  for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
    // 从 gap 开始,按照顺序将每个元素依次向前插入自己所在的组
    for (let i = gap; i < n; ++i) {
      // currentNumber 站起来 找位置
      let currentNumber = arr[i]
      // 该组前一个数组的索引
      let preIdx = i - gap
      while (preIdx >= 0 && currentNumber < arr[preIdx]) {
        // 向后挪位置
        arr[preIdx + gap] = arr[preIdx]
        preIdx -= gap
      }
      // currentNumber 找到了自己的位置 坐下
      arr[preIdx + gap] = currentNumber
    }
  }
}

经过优化后这段代码就和插入排序非常相似了,区别在于希尔排序最外层嵌套了一个缩小增量的for循环;并且插入时不再是相邻数字挪动,而是以增量为步长挪动。

Knuth增量序列希尔排序(了解)

经过多次论证,人们发现多余的增量不能使希尔排序变快,反而会多做很多无用功,比如某个数组间隔 8 和间隔 4 的序列都是有序的时候,其实多做了一次无用功。 所以怎样选择增量序列才是希尔排序的核心优化点。

吐槽过冒泡排序的数学家 Knuth 又对希尔排序做了优化:Kunth增量序列,也就是 1, 4, 13, 40, ...,数学界猜想它的平均时间复杂度为 O(n^(3/2))

function shellSortByKnuth(arr) {
  const n = arr.length
  // 找到当前数组要用的 Knuth 序列中的最大值
  let maxKnuthNumber = 1
  while (maxKnuthNumber <= Math.floor(n / 3)) {
    maxKnuthNumber = maxKnuthNumber * 3 + 1
  }
  // 增量按照 Knuth 序列规则依次递减
  for (let gap = maxKnuthNumber; gap > 0; gap = Math.floor((gap - 1) / 3)) {
    // 从 gap 开始,按照顺序将每个元素依次向前插入自己所在的组
    for (let i = gap; i < n; ++i) {
      // currentNumber 站起来 找位置
      let currentNumber = arr[i]
      // 该组前一个数组的索引
      let preIdx = i - gap
      while (preIdx >= 0 && currentNumber < arr[preIdx]) {
        // 向后挪位置
        arr[preIdx + gap] = arr[preIdx]
        preIdx -= gap
      }
      // currentNumber 找到了自己的位置 坐下
      arr[preIdx + gap] = currentNumber
    }
  }
}

希尔排序的稳定性

插入排序是稳定的算法,但是希尔排序是不稳定的,在增量较大的时候,排序过程可能会破坏原有数组的相同元素的相对次序。

希尔排序 时间复杂度 & 空间复杂度

事实上,希尔排序时间复杂度非常难以分析,它的平均复杂度界于 O(n)O(n^2),普遍认为它最好的时间复杂度为 O(n^1.3)

Knuth增量序列希尔排序的时间复杂度尚未有明确证明,数学界仅仅猜想其平均时间复杂度为O(n^1.5)

希尔的空间复杂度为 O(1),只需要常数级的临时变量

例题

  1. leetcode 912. 排序数组
  2. leetcode 506. 相对名次

堆排序

讲堆排序前我们先了解一下什么是

是一种二维数据结构,对抽象思维的要求要高一些,我们整理一下堆的结构,符合以下两个条件之一的完全二叉树就是堆

  • 根节点的值 >= 子节点的值,这样的堆被称之为最大堆,或大顶堆
  • 根节点的值 <= 子节点的值,这样的堆被称之为最小堆,或小顶堆

堆排序的整体思路就是:

  • 用数列构建出一个大顶堆,取出堆顶的数字
  • 调整剩下的数字,构建出新的大顶堆,再次取出顶堆的数字
  • 循环往复,完成整个排序

构建大顶堆 & 调整堆

构建大顶堆的方式有两种:

  • 方案一:从 0 开始,将每个数字依次插入堆中,一边插入,一边调整堆了结构,使其满足大顶堆的要求;
  • 方案二:将整个数列的初始状态视作一棵完全二叉树,自底向上调整树的结构,使其满足大顶堆的要求。

在实际操作中方案二更加常用些,动图演示如下:

初始化堆

交换元素

堆排序代码

写堆排序的代码之前先要知道完全二叉树的几个性质:(1⃣️根节点的下标视为0

  • 对于完全二叉树中的第i个数,它的左子节点下标:left = 2i + 1
  • 对于完全二叉树中的第i个数,它的右子节点下标:right = left + 1
  • 对于有n个元素的完全二叉树(n >= 2),它的最后一个非叶子结点的下标: n/2 - 1
/**
 * 堆排序主函数
 */
function heapSort(arr) {
  // 构建初始大顶堆
  buildMaxHeap(arr)
  for (let i = arr.length - 1; i > 0; --i) {
    // 将最大值交换到数组最后
    swap(arr, 0, i)
    // 调整剩余数组,使其满足大顶堆
    maxHeapify(arr, 0, i)
  }
}
/**
 * 构建初始大顶堆 函数
 */
function buildMaxHeap(arr) {
  // 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 Math.floor(arr.length / 2) - 1 || (arr.length >> 1) - 1`
  for (let i = Math.floor(arr.length / 2) - 1; i >= 0; --i) {
    maxHeapify(arr, i, arr.length)
  }
}
/**
 *  调整大顶堆 函数
 * @param arr 要调整的数组
 * @param i 结点下标
 * @param heapSize 表示剩余未排序的数字的数量,也就是剩余堆的大小
 */
function maxHeapify(arr, i, heapSize) {
  // 定义左子节点下标
  let l = 2 * i + 1 // (i << 1) + 1
  // 定义右子节点下标
  let r = l + 1
  // 记录根节点、左子树节点、右子树结点三者中的最大值下标,默认设置为根结点(当前节点下标)
  let largest = i
  // 与左子树节点比较
  if (l < heapSize && arr[l] > arr[largest]) {
    largest = l
  }
  // 与右子树节点比较
  if (r < heapSize && arr[r] > arr[largest]) {
    largest = r
  }
  if (largest !== i) {
    // 不是根结点 需要交换,把大的换到根结点位置
    swap(arr, i, largest)
    // 再次调整交换数字后的大顶堆, 相当于递归
    maxHeapify(arr, largest, heapSize)
  }
}
/**
 * 交换元素函数
 */
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}

这里可能比较难理解的是 maxHeapify 函数内当最大值节点不是根结点的时候要继续递归的操作,因为我们需要保证调整后的子树仍然是大顶堆,所以子树会执行一个递归的调整过程。

利用位运算来提高效率

堆排序中最后一个非叶子结点的代码 Math.floor(arr.length / 2) - 1 使用位运算可以写成 (arr.length >> 1) - 1

左子结点下标 2 * i + 1 可以写成 (i << 1) + 1

堆排序 时间复杂度 & 空间复杂度

堆排序分为两个阶段:初始化建堆(buildMaxHeap)和重建堆(maxHeapify,直译为大顶堆化)。所以时间复杂度要从这两个方面分析。

根据数学运算可以推导出初始化建堆的时间复杂度为 O(n), 重建堆的时间复杂度为 O(nlogn),所以堆排序总的时间复杂度为 O(nlogn) (选时间更长的)。 推导过程比较复杂,感兴趣的可以去搜一下,这里不提了。

堆排序的空间复杂度为 O(1)O(1),只需要常数级的临时变量。

例题

  1. leetcode 215. 数组中的第K个最大元素
  2. 剑指offer 40.最小的k个数

快速排序

快速排序在时间复杂度O(nlogn)级的几个排序算法中,大多数情况下效率更高,快速排序采用的分治思想也非常实用,所以掌握快速排序是必要的。

快速排序的基本思想:

  • 从数组中取出一个数,称为基数(pivot)
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域。
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成。

先看一下快速排序的演示图:

快速排序演示图

为了方便记忆,我们按照排序的思想按顺序来整理出我们的代码,这块会揉的比较碎来帮助记忆

快速排序递归框架

根据分析好的思路,先大致写出来快速排序的框架

function quickSort(arr, start, end) {
  // 数组分区,并获得中间值的下标
  let middle = partition(arr, start, end)
  // 对左右两边区域进行快速排序
  quickSort(arr, start, middle - 1)  // 左
  quickSort(arr, middle + 1, end)    // 右
}
function partition(arr, start, end) {
  // TODO: 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
}
// 首次调用,开启排序
quickSort(arr, 0, arr.length - 1)

那大致呢就定义好了快排的一个框架,那有递归就一定要考虑递归的出口

快排递归退出的边界条件

当这个区域只剩下一个数字的时候,就不需要排序了,就可以退出递归函数。

但对于快排而言,仍有一个特殊情况,就是 middle 等于 startend 的时候,就会出现某个区域剩余数字为0的情况。

综上所述,即至少有2个数字时,才对区域做快排

更新我们的quickSort:

function quickSort(arr, start, end) {
  // start == end 说明剩余数字为 0, start == end + 1 说明剩余数字为1 , 不存在 start 比 end 大2及以上的情况,所以可以简写为 start >= end return
  // if (start == end || start == end + 1) return
  // 当区域内数字少于 2 个的时候,退出递归  
  if (start >= end) return
  // 数组分区,并获得中间值的下标
  let middle = partition(arr, start, end)
  // 对左右两边区域进行快速排序
  quickSort(arr, start, middle - 1)  // 左
  quickSort(arr, middle + 1, end)    // 右
}

分区算法实现

这是快速排序中最重要的部分,由于分区存在各种边界条件,务必亲自手写加深体会

回顾下分区的目标:将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。

我们首先要做的就是选择一个基数 (pivot),我们可以把它理解成一个,小于这个轴的数字旋转到左边,大于这个轴的数字旋转到右边。 后续还有双轴快排顾名思义就是两个轴把数组分成3个区域来进行旋转。

基数的选择

基数选择没有固定标准,随意选择区间内任意一个数字即可,一般有三种选择方式:

  • 选择第一个元素作为基数
  • 选择最后一个元素作为基数
  • 选择区间内一个随机元素作为基数 (该选择方式的平均时间复杂度最优,后面分析时间复杂度再说明)

我们先以最简单的第一种方式来实现一个快速排序分区

最简单的分区算法

function quickSort(arr, start, end) {
  // 当区域内数字少于 2 个的时候,退出递归  
  if (start >= end) return
  // 数组分区,并获得中间值的下标
  let middle = partition(arr, start, end)
  // 对左右两边区域进行快速排序
  quickSort(arr, start, middle - 1)  // 左
  quickSort(arr, middle + 1, end)    // 右
}
/**
 * 核心:分区函数
 * 目标:将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。
 */
function partition(arr, start, end) {
  // 取第一个数为基数
  let pivot = arr[start]
  // 从第二个数开始分区
  let left = start + 1
  // 右边界
  let right = end
  // left 和 right相遇的时候退出循环
  while (left < right) {
    // 找到第一个大于基数的位置
    while (left < right && arr[left] <= pivot) left++
    // 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数
    if (left != right) {
      swap(arr, left, right)
      right--
    }
  }
  // 如果 left 和 right 相等,单独比较 arr[right] 和 pivot
  if (left == right && arr[right] > pivot) right--
  // 将基数和中间数交换 别忘了这里 start 是基数的下标
  if (right != start) swap(arr, start, right)
  // 返回中间值的下标
  return right
}
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}
// 首次调用,开启排序
quickSort(arr, 0, arr.length - 1)

这段代码我们要注意:

  • 我们需要保证中间值的下标是分区完成后,最后一个比基数小的值,这里我们用的 right 来记录这个值。
  • 在交换 leftright 之前,我们判断了 left != right,这是因为如果剩余的数组都比基数小,则left会一直自增到right才停止,这时候不应该发生交换,因为right已经指向了最后一个比基数小的值。
  • 上面这个拦截又可能会拦截到一种错误情况:如果剩余的数组只有最后一个数比基数大,left仍然自增到right,但我们没有发生交换,所以我们在退出了循环之后,又单独比较了 arr[right]pivot arr[right]pivot的比较实际上非常的巧妙,帮我们处理了三种情况:
  1. 刚才提到的剩余数组中只有最后一个数比基数大
  2. leftright区间内只有一个值,则初始状态下,left == right,所以while(left < right)根本不会进入,所以此时我们单独比较这个值和基数的大小关系
  3. 剩余数组中每个数字都比基数大,此时right会持续减小直到和left一致才退出循环,此时left所在位置的值仍未和基数做比较,所以我们单独比较left 即 right所在位置的值和基数大小的关系

双指针分区算法

除了上述分区算法外,我们还有一种双指针分区算法更为常用,思路如下:

  • left开始,遇到比基数大的数,记录其下标
  • right往前遍历,找到第一个比基数小的数,记录其下标
  • 交换这两个数
  • 重新来过,直到leftright 相等

代码如下:

function quickSort(arr, start, end) {
  // 当区域内数字少于 2 个的时候,退出递归  
  if (start >= end) return
  // 数组分区,并获得中间值的下标
  let middle = partition(arr, start, end)
  // 对左右两边区域进行快速排序
  quickSort(arr, start, middle - 1)  // 左
  quickSort(arr, middle + 1, end)    // 右
}
/**
 * 核心:双指针分区函数
 * 目标:将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。
 */
function partition(arr, start, end) {
  // 取第一个数为基数
  let pivot = arr[start]
  // 从第二个数开始分区
  let left = start + 1
  // 右边界
  let right = end
  // left 和 right相遇的时候退出循环
  while (left < right) {
    // 找到第一个大于基数的位置
    while (left < right && arr[left] <= pivot) left++
    // 找到第一个小于基数的位置
    while (left < right && arr[right] >= pivot) right--
    // 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数
    if (left != right) {
      swap(arr, left, right)
      left++
      right--
    }
  }
  // 如果 left 和 right 相等,单独比较 arr[right] 和 pivot
  if (left == right && arr[right] > pivot) right--
  // 将基数和轴交换
  swap(arr, start, right)
  // 返回中间值的下标
  return right
}
function swap(arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}
// 首次调用,开启排序
quickSort(arr, 0, arr.length - 1)

快速排序算法稳定性

从代码实现可以看出,快排是一种不稳定的排序算法,在分区过程中,相同数字的相对顺序可能被修改

快速排序 时间复杂度 & 空间复杂度

快速排序的平均时间复杂度是 O(nlogn),最坏的时间复杂度是 O(n^2) 空间复杂度与递归的层数有关,每层都会生成一些临时变量,所以空间复杂度是O(logn) ~ O(n),平均空间复杂度是O(logn)

回到前文提到的为什么随机选择一个基数的平均时间复杂度最优? 要理清这个问题我们先要看以下两种情况:

  • 数组为正序: 快速排序正序演示图

  • 数组为逆序: 快速排序逆序演示图

我们可以发现 当我们以第一个元素作为基数时,只要数组已经是正序/逆序的情况,每轮分区后,都有一边区域是空的,就导致了这个基数需要和另一个满数字的区域挨个比较。

可能有人会问,都有序了为什么还会用到你快速排序,我排个寂寞吗? 但是实际业务场景下的确是有这种情况的,比如你现在使用的是一个第三方的数据源,第三方说他们排好序给你渲染,你能相信第三方吗?不能!一旦他们出了啥岔你直接渲染你就背大锅了,所以拿到数据后我们还是要做一次排序。那这种情况就会发生重复排序了,如果我们使用快排就会出现排序速度变慢的问题了。 解决办法也很直接:选择的基数不是剩余数组的最大/最小值就可以了,感兴趣的话可以往下接着看我们的数组打乱算法洗牌算法

*快速排序优化———基数选择之洗牌算法

我们想用第三种选择基数的办法来优化我们的快排平均时间复杂度,洗牌算法在java中已经被封装到了Collections中,苦逼的js啥都没,气抖冷

// 时间复杂度为 O(n) es5 实现
function shuffle(arr){
  var length = arr.length, temp, random
  while(0 != length){
    random = Math.floor(Math.random() * length)
    length--;
    // swap
    temp = arr[length]
    arr[length] = arr[random]
    arr[random] = temp
  }
  return arr
}
// es6实现
function shuffle(arr){
  let n = arr.length, random
  while(0 != n){
    // 无符号右移位运算符向下取整
    random =  (Math.random() * n--) >>> 0; 
    [arr[n], arr[random]] = [arr[random], arr[n]] // ES6的结构赋值实现变量互换
  }
  return arr
}

例题

  1. leetcode 912. 排序数组
  2. leetcode 169. 多数元素

归并排序

归并排序顾名思义就是把有序的列表合并成一个有序的列表

将两个有序的列表合并成一个有序的列表

通过前面的学习我们知道插入排序的过程中,被插入的数组也是有序的,但这个思路只需要一个有序数组就够了,但我们现在有两个有序数组,怎么利用起来这两个有序数组来更好的优化我们的代码呢?

在第二个列表向第一个列表逐个插入的过程中,由于第二个列表已经有序,所以后续插入的元素一定不会在前面插入的元素之前。在逐个插入的过程中,每次插入时,只需要从上次插入的位置开始,继续向后寻找插入位置即可。

思路越来越接近了,但是我们在插入新的数字的时候,原数组需要不断的腾出位置,这个过程必然导致增加一轮遍历,所以我们有一个替代方案:开辟一个长度等同于两个数组长度之和的新数组,并使用两个指针来遍历原有的两个数组,不断将较小的数字添加到新数组中,并移动对应的指针即可。

// 将两个有序的数组合并成一个有序的数组
function merge(arr1, arr2) {
  let result = new Array(arr1.length + arr2.length).fill(0)
  let index1 = 0, index2 = 0
  while (index1 < arr1.length && index2 < arr2.length) {
    if (arr1[index1] <= arr2[index2]) {
      result[index1 + index2] = arr1[index1++]
    } else {
      result[index1 + index2] = arr2[index2++]
    }
  }
  // 将剩余数字补到结果数组之后
  while (index1 < arr1.length) {
    result[index1 + index2] = arr1[index1++]
  }
  while (index2 < arr2.length) {
    result[index1 + index2] = arr2[index2++]
  }
  return result
}

好了 合并函数我们已经搞定了,两个有序数组上哪搞呀? 想了一下我们只能自己拆分,把数组不断的拆成两份,直到只剩下一个数字,这一个数字组成的数组我们就可以认为是有序的。

将数组拆分成有序数组

拆分过程使用了二分的思想,这是一个递归的过程,归并排序使用的递归框架如下:

// 入口函数,触发归并排序递归
function main(arr) {
  if (arr.length == 0) return
  let result = mergeSort(arr, 0, arr.length - 1)
  // 将结果拷贝到 arr 数组中
  for (let i = 0; i < result.length; ++i) {
    arr[i] = result[i]
  }
}
// 归并排序主体
function mergeSort(arr, start, end) {
  // 只剩下一个数字,停止拆分,返回单个数字组成的数组
  if (start == end) return [arr[start]]
  let middle = Math.floor((start + end) / 2) // (start + end) >>> 1
  // 拆分左边区域
  let left = mergeSort(arr, start, middle)
  // 拆分右边区域
  let right = mergeSort(arr, middle + 1, end)
  // 合并左右区域
  return merge(left, right)
}

这个代码的缺点在于在递归过程中开辟了很多临时空间,我们还需要继续优化

归并排序的优化:减少临时空间的开辟

为了减少在递归过程中不断开辟空间的问题,我们在归并排序之前,先开辟一个临时空间,在递归过程中统一使用此空间进行归并即可。

// 入口主函数
function main(arr) {
  if (arr.length == 0) return
  let result = new Array(arr.length).fill(0)
  mergeSort(arr, 0, arr.length - 1, result)
}
// 对 arr 的 [start, end] 区间归并排序
function mergeSort(arr, start, end, result) {
  // 只剩下一个数字,停止拆分
  if (start === end) return
  let middle = (start + end) >>> 1 // Math.floor((start + end) / 2)
  // 拆分左边区域,并将归并排序的结果保存到 result 的 [start, middle] 区间
  mergeSort(arr, start, middle, result)
  // 拆分右边区域,并将归并排序的结果保存到 result 的 [middle + 1, end] 区间
  mergeSort(arr, middle + 1, end, result)
  // 合并左右区域到 result 的 [start, end] 区间
  merge(arr, start, end, result)
}
// 将 result 的 [start, middle] 和 [middle + 1, end] 区间合并
function merge(arr, start, end, result) {
  let middle = (start + end) >>> 1
  // 数组 1 的首尾位置
  let start1 = start, end1 = middle
  // 数组 2 的首尾位置
  let start2 = middle + 1, end2 = end
  // 用来遍历数组的指针
  let index1 = start1, index2 = start2
  // 结果数组的指针
  let resultIndex = start1
  while (index1 <= end1 && index2 <= end2) {
    if (arr[index1] <= arr[index2]) {
      result[resultIndex++] = arr[index1++]
    } else {
      result[resultIndex++] = arr[index2++]
    }
  }
  // 将剩余数字补到结果数组之后
  while (index1 <= end1) {
    result[resultIndex++] = arr[index1++]
  }
  while (index2 <= end2) {
    result[resultIndex++] = arr[index2++]
  }
  // 将result 操作区间的数字拷贝到 arr 数组中,以便下次比较
  for (let i = start; i <= end; ++i) {
    arr[i] = result[i]
  }
}

上面这个版本是比较适合阅读的版本,当然我们也可以适当牺牲一些可读性精简我们的代码,这个版本存在一些不回变动的临时变量,比如 start1 始终等于 start, end2 始终等于 end, end1 始终等于 middle。而且细心的兄弟其实可以发现 resultIndex 的值始终等于 start加上index1index2 移动的距离

那么 resultIndex = start + (index1 - start1) + (index2 - start2)

start1 == start代入可以化简得到resultIndex = index1 + index2 - start2

最终精简版的归并排序如下

最终精简版归并排序(读不懂可以使用上一段的归并排序)

先上个动图演示:

归并排序演示图

代码如下:

// 入口主函数
function main(arr) {
  if (arr.length == 0) return
  let result = new Array(arr.length).fill(0)
  mergeSort(arr, 0, arr.length - 1, result)
}
// 对 arr 的 [start, end] 区间归并排序
function mergeSort(arr, start, end, result) {
  // 只剩下一个数字,停止拆分
  if (start === end) return
  let middle = (start + end) >>> 1 // Math.floor((start + end) / 2)
  // 拆分左边区域,并将归并排序的结果保存到 result 的 [start, middle] 区间
  mergeSort(arr, start, middle, result)
  // 拆分右边区域,并将归并排序的结果保存到 result 的 [middle + 1, end] 区间
  mergeSort(arr, middle + 1, end, result)
  // 合并左右区域到 result 的 [start, end] 区间
  merge(arr, start, end, result)
}
// 将 result 的 [start, middle] 和 [middle + 1, end] 区间合并
function merge(arr, start, end, result) {
  let end1 = (start + end) >>> 1
  let start2 = end1 + 1
  // 用来遍历数组的指针
  let index1 = start, index2 = start2
  while (index1 <= end1 && index2 <= end) {
    if (arr[index1] <= arr[index2]) {
      result[index1 + index2 - start2] = arr[index1++]
    } else {
      result[index1 + index2 - start2] = arr[index2++]
    }
  }
  // 将剩余数字补到结果数组之后
  while (index1 <= end1) {
    result[index1 + index2 - start2] = arr[index1++]
  }
  while (index2 <= end) {
    result[index1 + index2 - start2] = arr[index2++]
  }
  // 将result 操作区间的数字拷贝到 arr 数组中,以便下次比较
  while (start <= end) {
    arr[start] = result[start++]
  }
}

*原地归并排序

上面的归并排序还有优化的空间,优化的点在于上面的归排需要开辟额外的空间,那么原地归并排序就是优化了这一点的

直接上代码:

// 入口主函数
function main(arr) {
  if (arr.length == 0) return
  mergeSort(arr, 0, arr.length - 1)
}
// 对 arr 的 [start, end] 区间归并排序
function mergeSort(arr, start, end) {
  // 只剩下一个数字,停止拆分
  if (start === end) return
  let middle = (start + end) >>> 1 // Math.floor((start + end) / 2)
  // 拆分左边区域,并将归并排序的结果保存到 result 的 [start, middle] 区间
  mergeSort(arr, start, middle)
  // 拆分右边区域,并将归并排序的结果保存到 result 的 [middle + 1, end] 区间
  mergeSort(arr, middle + 1, end)
  // 合并左右区域到 result 的 [start, end] 区间
  merge(arr, start, end)
}
// 将 result 的 [start, middle] 和 [middle + 1, end] 区间合并
function merge(arr, start, end) {
  let end1 = (start + end) >>> 1
  let start2 = end1 + 1
  // 用来遍历数组的指针
  let index1 = start, index2 = start2
  while (index1 <= end1 && index2 <= end) {
    if (arr[index1] <= arr[index2]) {
      index1++
    } else {
      // 右边区域的这个数字比左边区域的数字小,于是它站起来
      let value = arr[index2]
      let index = index2
      // 前面的数字不断往后移
      while (index > index1) {
        arr[index] = arr[index - 1]
        index--
      }
      // 这个数字坐到 index1 所在的位置上
      arr[index] = value
      // 更新所有下标,使其前进一格
      index1++
      index2++
      end1++
    }
  }
}

这段代码在合并arr[start, middle]区间和[middle + 1, end]区间时,将两个区间较小的数字移动到index1的位置,并且将左边区域不断后移,目的是给新插入的数字腾出位置。最后更新两个区间的下标,继续合并更新后的区间。

归并排序 时间复杂度 & 空间复杂度

归并排序的复杂度比较容易分析,拆分数组的过程中,会将数组拆分 logn 次,每层执行的比较次数都约等于 n 次,所以时间复杂度为 O(nlogn)

空间复杂度是 O(n),主要占用空间的就是我们在排序之前创建的长度为nresult数组

归并排序算法稳定性

注意归并排序中最重要的代码

if (arr[index1] <= arr[index2]) {
  result[index1 + index2 - start2] = arr[index++] 
}

这里通过 arr[index1] <= arr[index2] 来合并两个有序数组,保证了原数组中相同元素的相对顺序,如果改写成arr[index1] < arr[index2] ,排序将变得不稳定

例题

  1. 面试题 10.01. 合并排序的数组
  2. 剑指 Offer 51. 数组中的逆序对

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

计数排序

欢迎来到时间复杂度O(n)的排序算法地带,计数排序是在对一定范围内的整数排序时使用的,它的复杂度为 O(n + k) k是整数的范围大小

伪计数排序

举个例子,我们要对一列数组排序,这个数组中每个元素都是[1, 9]区间内的整数,那么我们就可以构建一个长度为 9 的数组用于计数,计数数组的下标分别对应区间内的 9 个整数。然后遍历待排序的数组,把区间内每个整数出现的次数统计到计数数组对应的位置。最后遍历计数数组,将每个元素输出,输出的次数就是对应位置记录的次数。

看一下计数排序的动图演示吧:

计数排序演示图

代码实现如下,以[1, 9]区间数组为例:

function fakeCountingSort(arr) {
  // 建立长度为 9 的数组,下标 0 ~ 8 对应数字 1 ~ 9
  const counting = new Array(9).fill(0)
  // 遍历 arr 中的每个元素
  for (let i of arr) {
    // 将每个整数出现的次数统计到计数数组中对应下标的位置
    counting[i - 1]++
  }
  let index = 0
  // 遍历计数数组,将每个元素输出
  for (let i = 0; i < 9; ++i) {
    // 输出的次数就是对应位置记录的次数
    while (counting[i] != 0) {
      arr[index++] = i + 1
      counting[i]--
    }
  }
}

这样排序之后呢是存在问题的,排序完成后 arr 中记录的元素已经不再是最开始的那个元素了,他们只是值相等,但却不是同一个对象。

在我们的业务需求中,排序的往往是带很多属性的对象,我们不可能排序之后把对象的其他属性丢失掉。

伪计数排序2.0

对于上面的问题,我们想到:在统计元素出现的次数时,把真实的元素保存到列表中,输出时,从列表中取真实的元素

function fakeCountingSort(arr) {
  // 建立长度为 9 的数组,下标 0 ~ 8 对应数字 1 ~ 9
  const counting = new Array(9).fill(0)
  // 记录每个下标中包含的真实元素,值使用队列可以保证排序的稳定性
  const records = new Map()
  // 遍历 arr 中的每个元素
  for (let i of arr) {
    // 将每个整数出现的次数统计到计数数组中对应下标的位置
    counting[i - 1]++
    if (!records.get(i - 1)) {
      records.set(i - 1, [])
    }
    const queue = records.get(i -1)
    queue.push(i)
    records.set(i - 1, queue)
  }
  let index = 0
  // 遍历计数数组,将每个元素输出
  for (let i = 0; i < 9; ++i) {
    // 输出的次数就是对应位置记录的次数
    while (counting[i] != 0) {
      const queue = records.get(i)
      const value = queue.shift()
      records.set(i, queue)
      arr[index++] = value
      counting[i]--
    }
  }
}

通过队列来保存真实的元素,计数完成后,将队列中真实的元素赋到 arr 列表中,这就解决了信息丢失的问题,并且使用队列还可以保证排序算法的稳定性。

但,我们还有更巧妙的方法

真计数排序

上面的计数排序实际上并不是把计数数组的下标直接作为结果输出,而是通过计数的结果,计算出每个元素在排序完成后的位置,然后将元素赋值到对应位置。

function countingSort(arr) {
  // 建立长度为 9 的数组,下标 0 ~ 8 对应数字 1 ~ 9
  const counting = new Array(9).fill(0)
  // 遍历 arr 中的每个元素
  for (let i of arr) {
    // 将每个整数出现的次数统计到计数数组中对应下标的位置
    counting[i - 1]++
  }
  // 记录前面比自己小的数字的总数
  let preCounts = 0
  for (let i = 0; i < counting.length; ++i) {
    // 当前的数字比下一个数字小,累计到preCounts中
    preCounts += counting[i]
    // 将counting计算成当前数字在结果中的起始下标位置。 位置 = 前面比自己小的数字的总数
    counting[i] = preCounts - counting[i]
  }
  const result = []
  for (let i of arr) {
    // counting[i - 1]表示此元素在结果数组中的下标
    const index = counting[i - 1]
    result[index] = i
    // 更新 counting[i - 1],指向此元素的下一个下标
    counting[i - 1]++
  }
  // 将结果赋值回arr 如不需要原数组修改则直接返回result
  for (let i = 0; i < arr.length; ++i) {
    arr[i] = result[i]
  }
}

首先我们把每位元素出现的次数记录到 counting 数组中

然后把 counting[i] 更新为数字 i 在最终排序结果中的起始下标位置。这个位置等于前面比自己小的数字的总数。

接下来从头访问 arr 数组,根据 counting 中计算出的下标位置,将 arr 的每个元素直接放到最终位置上,然后更新 counting 中的下标位置。这一步中的 index 变量也是可以省略的。

最后把 result 数组返回出去或者赋值回 arr 就取决于你的需求啦

最后我们还需要一步来打破这个只能适用于[1, 9]区间的魔咒

最终版计数排序

function countingSort(arr) {
  // 判空以及处理边界
  if (arr == null || arr.length <= 1) return
  // 找到最大值、最小值
  let max = Math.max(...arr) // es6写法,es5 请用 Math.max.apply(null, arr)
  let min = Math.min(...arr) // es6写法,es5 请用 Math.min.apply(null, arr)
  // 确定计数范围
  const range = max - min + 1
  // 建立长度为 range 的数组, 下标 0 ~ range - 1 对应数字 1 ~ range
  const counting = new Array(range).fill(0)
  // 遍历 arr 中的每个元素
  for (let i of arr) {
    // 将每个整数出现的次数统计到计数数组中对应下标的位置
    counting[i - 1]++
  }
  // 记录前面比自己小的数字的总数
  let preCounts = 0
  for (let i = 0; i < counting.length; ++i) {
    // 当前的数字比下一个数字小,累计到preCounts中
    preCounts += counting[i]
    // 将counting计算成当前数字在结果中的起始下标位置。 位置 = 前面比自己小的数字的总数
    counting[i] = preCounts - counting[i]
  }
  const result = []
  for (let i of arr) {
    // counting[i - 1]表示此元素在结果数组中的下标
    const index = counting[i - 1]
    result[index] = i
    // 更新 counting[i - 1],指向此元素的下一个下标
    counting[i - 1]++
  }
  // 将结果赋值回arr 如不需要原数组修改则直接返回result
  for (let i = 0; i < arr.length; ++i) {
    arr[i] = result[i]
  }
}

计数排序 时间复杂度 & 空间复杂度 & 稳定性

从代码实现中我们可以看到每次遍历都是进行 n 次或者 k 次,所以计数排序的时间复杂度为 O(n + k), k表示数据的范围大小

用到的空间主要是长度为 k 的计数数组和长度为 n 的结果数组,所以空间复杂度也是 O(n + k)

上文提到真计数排序是一种稳定排序,伪计数排序不是稳定排序

计数排序缺陷

如果给你一个要排序的数组: [1, Number.MAX_SAFE_INTEGER],虽然只有两个元素,但是范围却太大了,仅仅声明数组就会占用超大的内存。

所以要记住,计数排序只适用于数据范围不大的场景

例题

  1. leetcode 912. 排序数组
  2. leetcode 1122. 数组的相对排序

基数排序

先举个🌰,我们需要对997,996,855,666 四个数字做基数排序,我们需要:

  • 先看第一位基数:6比8小,8比9小,所以666是最小的数字,855是第二小的数字,暂时无法确定两个9开头的数字哪个更小
  • 再比较9开头的两个数字,看他们第二位基数,9 和 9 相等,还是无法确定其大小关系
  • 再比较99开头的两个数字,看他们第三位技术,6比7小,所以996小于997

综上所述 997 是福报 (不是

好了,言归正传,基数排序存在两种实现方式:

上面这个例子使用的是 最高位优先法 简称 MSD (Most significant digital),思路是从最高位开始,依次对基数进行排序。

与之对应的是 最低位优先法 简称 LSD (Least significant digital),思路是从最低位开始,依次对基数进行排序。注意使用 LSD 必须保证对基数进行排序的过程是稳定的。

通常来讲,LSDMSD更加常用也更加符合计算机的操作习惯,每次遍历都可以将所有数字统一处理。而MSD在上述🌰里,在第二步比较两个以9开头的数字时,其他基数开头的数字不得不放到一边,会产生很多临时变量。

我们先来看一下基数排序的演示图:

基数排序演示图

基数排序可以分成以下三个步骤:

  • 找出数组中最大的数字的位数 maxDigitLength
  • 获取数组中每个数字的基数
  • 遍历 maxDigitLength 轮数组,每轮按照基数对其进行排序

正整数基数排序

我们会利用计数排序稳定且小范围有优势的特点来做基数的排序,下面是LSD方式的基数排序代码,但只能是正整数排序,对包含负数数组的排序直接往下跳:

function radixSort(arr) {
  if (arr == null) return
  // 找到数组中的最大值
  let max = 0  // Math.max(...arr)
  for (let i of arr) {
    if (i > max) max = i
  }
  // 计算这个最大值的位数
  let maxDigitLength = 0
  while (max != 0) {
    maxDigitLength++
    max = Math.floor(max / 10)
  }
  // 使用计数排序算法对基数进行排序, 因为基数区间就是 [0, 9],所以这里可以直接创建一个长度为10的计数数组
  let counting = new Array(10).fill(0)
  let result = new Array(arr.length).fill(0)
  // 获取基数
  let dev = 1
  for (let i = 0; i < maxDigitLength; ++i) {
    for (let value of arr) {
      const radix = Math.floor(value / dev) % 10 // 基数
      counting[radix]++ // 对基数进行统计
    }
    for (let j = 1; j < counting.length; ++j) {
      counting[j] += counting[j - 1]
    }
    // 使用倒序遍历的方式完成计数排序
    for (let j = arr.length - 1; j >= 0; --j) {
      const radix = Math.floor(arr[j] / dev) % 10
      result[--counting[radix]] = arr[j]
    }
    // 计数排序完成后,将结果拷贝回 arr 数组 
    for (let i = 0; i < arr.length; ++i) {
      arr[i] = result[i]
    }
    // 将计数数组重置为0
    counting.fill(0)
    // 进位
    dev *= 10 
  }
}

对包含负数的数组进行基数排序

如果数组中包含负数怎么进行基数排序?

思路一: 数组每个元素都加上一个合适的正整数,使其全部变成非负整数,等到排序完成再减去这个加上去的数就行了

pass!!!加法运算可能导致数字越界,所以必须单独处理数字越界的情况。

思路二: 在对基数进行计数排序的时候,初始化一个长度为 19 的计数数组,用来存储 [-9, 9] 这个区间内所有的整数。在把每一位基数计算出来后,加上9,就能对应上 counting 数组的下标。也就是说, counting 数组的下标 [0, 18] 对应基数 [-9, 9]

代码如下:

function radixSort(arr) {
  if (arr == null) return
  // 找到数组中的最长数字,注意这里要取绝对值来找位数才准确
  let max = 0 
  for (let i of arr) {
    if (Math.abs(i) > max) max = Math.abs(i)
  }
  // 计算这个最长数字的位数
  let maxDigitLength = 0
  while (max != 0) {
    maxDigitLength++
    max = Math.floor(max / 10)
  }
  // 使用计数排序算法对基数进行排序,下标 [0, 18] 对应基数 [-9, 9]
  let counting = new Array(19).fill(0)
  let result = new Array(arr.length).fill(0)
  // 获取基数
  let dev = 1
  for (let i = 0; i < maxDigitLength; ++i) {
    for (let value of arr) {
      // 基数下标调整
      const radix = Math.floor(value / dev) % 10 + 9
      counting[radix]++ // 对基数进行统计
    }
    for (let j = 1; j < counting.length; ++j) {
      counting[j] += counting[j - 1]
    }
    // 使用倒序遍历的方式完成计数排序
    for (let j = arr.length - 1; j >= 0; --j) {
      // 下标调整
      const radix = Math.floor(arr[j] / dev) % 10 + 9
      result[--counting[radix]] = arr[j]
    }
    // 计数排序完成后,将结果拷贝回 arr 数组 
    for (let i = 0; i < arr.length; ++i) {
      arr[i] = result[i]
    }
    // 将计数数组重置为0
    counting.fill(0)
    // 进位
    dev *= 10 
  }
}

代码中主要做了两处修改:

  • 当数组中存在负数时,我们需要计算数组中绝对值最大的数,也就是最长的数
  • 在获取基数的步骤,将计算出的基数加上9,使其与 counting 数组下标一一对应

LSD VS MSD

介绍完了 LSD基数排序我们来介绍一下 MSD的实现

function radixSort(arr) {
  if (arr == null) return
  // 找到数组中的最长数字,注意这里要取绝对值来找位数才准确
  let max = 0 
  for (let i of arr) {
    if (Math.abs(i) > max) max = Math.abs(i)
  }
  // 计算这个最长数字的位数
  let maxDigitLength = 0
  while (max != 0) {
    maxDigitLength++
    max = Math.floor(max / 10)
  }
  generate(arr, 0, arr.length - 1, maxDigitLength)
}
function generate(arr, start, end, position) {
  if (start == end || position == 0) return
  // 使用计数排序算法对基数进行排序,下标 [0, 18] 对应基数 [-9, 9]
  let counting = new Array(19).fill(0)
  let result = new Array(end - start + 1).fill(0)
  let dev = Math.pow(10, position - 1)
  for (let i = start; i <= end; ++i) {
    // MSD 从最高位开始
    const radix = Math.floor(arr[i] / dev) % 10 + 9
    counting[radix]++
  }
  for (let j = 1; j < counting.length; ++j) {
    counting[j] += counting[j - 1]
  }
  // 拷贝 counting, 用于待会的递归
  const countingCopy = JSON.parse(JSON.stringify(counting))
  for (let i = end; i >= start; --i) {
    const radix = Math.floor(arr[i] / dev) % 10 + 9
    result[--counting[radix]] = arr[i]
  }
  // 计数排序完成后,将结果拷贝回 arr 数组 
  for (let i = start; i <= end; ++i){
    arr[i] = result[i - start]
  }
  // 对 [satrt, end] 区间内的每一位基数进行递归排序
  for (let i = 0; i < counting.length; ++i) {
    generate(arr, i == 0 ? start : start + countingCopy[i - 1], start + countingCopy[i] - 1, position - 1)
  }
}

基数排序 时间复杂度 & 空间复杂度

无论 LSD 还是 MSD,基数排序时都需要经历 maxDigitLength 轮遍历,每轮遍历的时间复杂度为 O(n + k),其中 k 表示每个基数可能的取值范围大小。如果是对非负整数排序,则 k = 10,如果是对包含负数的数组排序,则 k = 19

所以基数排序的时间复杂度为 O(d(n + k))(d 表示最长数字的位数,k 表示每个基数可能的取值范围大小)。

使用的空间和计数排序是一样的,空间复杂度为 O(n + k)(k 表示每个基数可能的取值范围大小)。

例题

  1. leetcode 164. 最大间距
  2. leetcode 561. 数组拆分 I

桶排序

桶排序的思想:

  • 将区间划分为 n 个相同大小的子区间,每个子区间称为一个桶
  • 遍历数组,将每个数字装入桶中
  • 对每个桶内的数字单独排序,这里需要采用其他排序算法,如插入、归并、快排等
  • 最后按照顺序将所有桶内的数字合并起来

桶排序在实际应用比较少,因为他的使用需要依赖其他排序算法,且需要所有输入数据都服从均匀分布,才能使桶排序效率高一些。

在最差的情况下,所有数据都在一个桶里,反而会徒增一轮遍历。

综上,使用桶排序我们需要考虑两个因素:

  • 设置多少个桶比较合适,桶的数量过少,会导致单个桶内的数字过多,桶排序的时间复杂度就会在很大程度上受桶内排序算法的影响。桶的数量过多,占用的内存就会较大,并且会出现较多的空桶,影响遍历桶的效率。
  • 桶采用哪种数据结构,如果将桶的数据结构设置为数组,那么每个桶的长度必须设置为待排序数组的长度,因为我们需要做好最坏的计算,即所有的数字都被装入了同一个桶中,所以这种方案的空间复杂度会很高。

以数组作为桶

首先,找到最大值和最小值

function bucketSort(arr) {
  // 判空以及边界处理
  if (arr == null || arr.length <= 1) return
  // 找到最大值,最小值
  let max = Math.max(...arr), min = Math.min(...arr) // es5采用 Math.max.apply(null, arr)
  // 确定范围
  const range = max - min
  // ...
}

装桶

// 设置桶的数量,这里设置100个桶,可根据实际情况去设置
const bucketAmount = 100
// 桶和桶之间的间距
const gap = range / (bucketAmount - 1)
// 用二维数组来装桶,第一个纬度是桶的编号,第二个纬度是桶中的数字。
const buckets = Array.from(Array(bucketAmount), () => new Array())
// 用二维数组来装桶,第一个纬度是桶的编号,第二个纬度是桶中的数字。
const buckets = Array.from(Array(bucketAmount), () => new Array())
// 装桶
for (let value of arr) {
  // 找到 value 属于哪个桶
  let index = Math.floor((value - min) / gap)
  // 装桶后,更新bucketLength[index]
  buckets[index].push(value)
}

对桶内元素进行单独排序,这一步需要借助其他排序算法

// 对每个桶内的数字进行单独排序
let index = 0
for (let i = 0; i < bucketAmount; ++i) {
  if (buckets[i] == 0) continue
  // 取出桶内的数组
  let arrInBucket = JSON.parse(JSON.stringify(buckets[i]))
  // 这里需要结合其他排序算法,例如 插入排序
  insertSort(arrInBucket)
  // 排序完成后赋值到arr上
  for (let j = index; j < index + arrInBucket.length; ++j) {
    arr[j] = arrInBucket[j - index]
  }
  index += arrInBucket.length;
}

以数组作为桶的桶排序代码

function bucketSort(arr) {
  // 判空以及边界处理
  if (arr == null || arr.length <= 1) return
  // 找到最大值,最小值
  let max = Math.max(...arr), min = Math.min(...arr) // es5采用 Math.max.apply(null, arr)
  // 确定范围
  const range = max - min
  // 设置桶的数量,这里设置100个桶,可根据实际情况去设置
  const bucketAmount = 100
  // 桶和桶之间的间距
  const gap = range / (bucketAmount - 1)
  // 用二维数组来装桶,第一个纬度是桶的编号,第二个纬度是桶中的数字。
  const buckets = Array.from(Array(bucketAmount), () => new Array())
  // 装桶
  for (let value of arr) {
    // 找到 value 属于哪个桶
    let index = Math.floor((value - min) / gap)
    // 装桶后,更新bucketLength[index]
    buckets[index].push(value)
  }
  // 对每个桶内的数字进行单独排序
  let index = 0
  for (let i = 0; i < bucketAmount; ++i) {
    if (buckets[i] == 0) continue
    // 取出桶内的数组
    let arrInBucket = JSON.parse(JSON.stringify(buckets[i]))
    // 这里需要结合其他排序算法,例如 插入排序
    insertSort(arrInBucket)
    // 排序完成后赋值到arr上
    for (let j = index; j < index + arrInBucket.length; ++j) {
      arr[j] = arrInBucket[j - index]
    }
    index += arrInBucket.length;
  }
}
// 插入排序
function insertSort(arr) {
  const n = arr.length
  // 从第二个数字开始,往前插入数字
  for (let i = 1; i < n; ++i) {
    let currentNum = arr[i]
    let j = i - 1
    // 1.遇到小于或等于 currentNum 的数字跳出循环 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNum 的数字,跳出循环 这时候 j 是 -1 ,所以 currentNum 会坐到 arr[-1 + 1] = arr[0] 的位置,也就是数组头部位置
    while (j >= 0 && currentNum < arr[j]) {
      // 寻找插入位置的过程中,不断地将比 currentNum 大的数字向后挪  
      arr[j + 1] = arr[j]
      j--
    }
    // currentNum 坐到对应位置
    arr[j + 1] = currentNum
  }
}

由于js数组的特性,我们不需要提前定义好数组的长度,即数组随时可随需要扩容,如使用java等强类型语言,建议使用链表装桶,桶内排序采用数组

桶排序 时间复杂度 & 空间复杂度

由于桶排序的过程与具体使用的排序算法有关,假设数据服从均匀分布,桶的数量合适,不论采用的排序算法是O(n^2)级还是O(nlogn)级,其时间复杂度都约等于O(n),否则其时间复杂度将达到O(n^2 / k) || O(nlog(n/k)) k 为桶的数量

桶排序的空间复杂度也和具体使用的排序算法有关,O(1) 或者 O(n)

综上所述,桶排序不一定比算法O(nlogn)的排序算法快,其影响效率的变量是比较多的。

例题

  1. leetcode 908. 最小差值 I
  2. leetcode 164. 最大间距

JS源码 Array.sort() 实现原理

V8 引擎 sort 函数只给出了两种排序分别是: 插入排序快速排序,数组长度小于等于 10 的用插入排序,比10大的数组则使用快速排序 地址:v8引擎sort源码

Mozilla/Firefox : 归并排序(jsarray.c 源码)

Webkit :底层实现用了 C++ 库中的 qsort() 方法