前言
数据结构章节暂时告一段落,从这一章节开始算法之旅。首先从排序开始,排序作为最基础的算法,一点也不简单,写一个快排、堆排、归并排序在大厂面试中并不罕见,或者某些题目就需要使用某些排序的思想来解决,这也就是为什么要学习排序。当然最重要的是学习它的思想,例如快排的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
}
}
}
冒泡排序的缺点
- 时间复杂度高,需要进行
O(n²)
的两轮比对。 - 交换位置的操作太频繁,影响
cpu
执行效率。
冒泡排序的优点
- 是稳定的排序算法,因为值相等时不会进行交换操作。
- 原地排序不用开辟额外空间。
- 最好情况面对已经排好序的数组,复杂度能降到
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²)
的算法,
但就执行效率来说,选择排序是要优于冒泡排序的,因为冒泡排序比较之后就会进行交换操作,而选择排序则非如此,每次内循环只是找到最小值那项的下标,内循环结束后与数组头部交换,剩下的范围内依然如此进行,所以比较次数虽然不会少,但交换位置的操作次数少了很多,执行效率比冒泡高。还是用图说明下它的原理:
选择排序的缺点
- 时间复杂度高。
- 不是稳定的排序算法,因为每次找到最小的值后会进行交换位置的操作。
- 最坏和最好情况都是
O(n²)
的复杂度。
选择排序的优点
- 是一种原地排序算法,与冒泡排序相比,交换位置的操作改为了赋值操作,执行效率会提高。
三、插入排序(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 // 将暂存元素插入到对应的位置
}
}
插入排序的缺点
- 时间复杂度高。
插入排序的优点
- 有提前终止循环的情况,如果是面对近似有序的数组,效率奇高。
- 原地排序不占额外空间,没有交换位置的操作执行效率高。
- 是一种稳定的排序算法。
- 最好情况能到
O(n)
,(吊打冒泡排序)
。
四、归并排序(mergeSort)
归并排序使用的是算法的分治思想,也就是将一个大的问题分解为一个小问题,当问题分解到足够小时,解决了这个小问题,大的问题也就迎刃而解。
首先将一个数组从中一分为二,然后分别处理两个小数组的排序问题,再处理两个小数组时,如果它们还能分解,又将它们分为更小的数组。直到分解到是单个元素为止,然后将单元素数组归并为一个有序的小数组,接着将两个有序的小数组归并为更大一些的数组。直到最后原来一分为二的两个数组就全部是有序的,将它们归并以完成最终的排序。宏观原理图解如下:
然后从微观的角度来看下如何归并两个有序数组,如下图所示,当需要归并两个有序数组时,我们需要借助一个原数组的副本,将其拆分为A
和B
。过程是将归并的结果重新赋值原数组C
完成。
有三个固定的界限坐标分别为left/mid/right
;以及三个游走的坐标k/i/j
。从i
到mid
是子数组A
,从mid + 1
到right
为子数组B
,归并的过程只需要分别比对两个子数组的值即可,谁的值小就赋值给原数组k
下标的位置,然后游走坐标+1
即可。
在归并的过程中会有四种情况发生:
A[i]
>B[j]
此时只需要将B[j]
的值赋值给C[k]
,j++
以及k++
继续归并下一个元素。
A[i]
<B[j]
将A[i]
赋值给C[k]
,i++
以及k++
继续归并下一个元素。
i
>mid
说明数组A
里面所有的元素已经全部归并完成,只需要把数组B
里的剩下元素全部赋值给数组C
即可。因为都是有序数组,所以数组B
里剩下的全部都是大于A
数组的元素。
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++
}
}
}
归并排序的缺点
- 不是原地排序,需要开辟额外的
O(n)
空间。
归并排序的优点
- 没有最好最坏时间复杂度,任何情况下都是
O(nlogn)
; - 是一种稳定的排序算法。
五、快速排序(quickSort)
排序算法里的明星,时间复杂度也是名副其实,在所有O(nlogn)
的排序里速度最快,如JavaScript
封装的sort
方法就是采用的快排思想。
快排的实现还是使用的分治的思想,原理就是以其中一个元素作为分区点,将原数组划分为左右两个部分,让左侧数组的值全部比分区点小,右部分数组的值全部比分区点大,这个操作也叫做patition
。对已经划分的小数组,继续使用patition
的操作,直到划分为单个元素,不能再进行patition
操作,整个数组的排序任务完成。还是以图来解释:
还是有两个固定的界限坐标left
和right
,有两个游走坐标i
和j
,i
表示的是接下来要遍历的点,j
表示小区间的末尾,所以j + 1
也就是大区间的开头。假设我们选择此时数组的第一项作为分区点,那么接下来我们就要从数组的第二项开始遍历,让剩下的所有项与分区点进行比较。当i
指向的点小于分区点时,就让其和j + 1
的位置进行交换,j++
扩展小区间的范围,否则遍历下一个。当数组全部遍历完时,让left
和j
下标的项交换位置即完成了区间的划分。代码如下:
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]) { // 当分区点大于当前节点时
swap(arr, j + 1, i)
// 让大区间的开头与当前节点交换
j++ // 小区分范围增加
}
}
swap(arr, j, l) // 最后让分区点回到其所在位置
return j // 返回其下标,进行接下来的分区操作
}
function swap (arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]]
}
至此完成了一个最简单的快排就实现了,关于快排这个明星算法,实在有太多值得聊,下一章的一整个章节,我们将深入理解这种排序算法的思想及对它的优化。
快速排序的缺点
- 不是稳定的排序算法。
- 分区点的选择有讲究,选择不当时最坏情况会退化为
O(n²)
。 - 需要把待排序的数组一次性读入到内存里。
快速排序的优点
- 速度快。
- 原地排序,只需要占用
O(logn)
的栈空间。
最后
至此,最常用几个排序算法介绍完毕。排序算法可以说是算法的基石,下一章单独聊聊快排。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。