数据结构和算法是很重要的基础知识,我在这方面也比较薄弱,最近也在重新学习;之前学习一直是断断续续的,现在在这分享给大家,希望能一起探讨和学习。
今天我分享的是一些基本的排序算法:冒泡排序、选择排序、插入排序、归并排序、快速排序。这里给了对应算法的维基百科链接,感兴趣可以自主查看一下。
编写测试案例
在进入正式的算法学习之前,我们先写好测试案例,这里我们可以使用 jest 框架来做测试,我们今天用到的相关知识很简单,所以这里完全不用担心。主要就是用到了以下API:
test:用于运行测试,有三个参数:测试名称,包含测试期望的函数,超时时间。点击查看文档excpet:参数为代码返回的值,用作断言。点击查看相关文档toEqual:判断是否与给定值相等。点击查看相关文档
不想麻烦的也可以直接跳过。
测试代码:
const sortedArr = [1, 2, 3, 4, 5, 6, 7]
const reverseArr = [7, 6, 5, 4, 3, 2, 1]
const notSortedArr = [4, 3, 1, 7, 6, 2, 5]
const equalArr = [1, 1, 1, 1, 1, 1, 1]
const negativeArr = [-3, -7, -6, -4, -5, -2, -1]
const sortedNegativeArr = [-7, -6, -5, -4, -3, -2, -1]
const mixedArr = [1, -3, 0, -2, 2, -1, 3]
const sortedMixedArr = [-3, -2, -1, 0, 1, 2, 3]
function testSort(sort) {
expect(sort([])).toEqual([])
expect(sort([1])).toEqual([1])
expect(sort([1, 2])).toEqual([1, 2])
expect(sort([2, 1])).toEqual([1, 2])
expect(sort(sortedArr)).toEqual(sortedArr)
expect(sort(reverseArr)).toEqual(sortedArr)
expect(sort(notSortedArr)).toEqual(sortedArr)
expect(sort(equalArr)).toEqual(equalArr)
expect(sort(negativeArr)).toEqual(sortedNegativeArr)
expect(sort(sortedNegativeArr)).toEqual(sortedNegativeArr)
expect(sort(mixedArr)).toEqual(sortedMixedArr)
expect(sort(sortedMixedArr)).toEqual(sortedMixedArr)
}
这是我这边写的测试案例,可以直接使用。因为我在写这些练习代码的时候用的是测试驱动开发的模式。所以就先列举了测试案例。接下来步入正题:
冒泡排序
冒泡排序很简单,就如同它的名字一样,它是和气泡一样一层一层往上冒;也就是将相邻元素进行比较,如果靠前的元素比靠后的元素大,则进行交换(这里我们是实现升序,降序原理也是一样)。比如有一组数据:5、2、4、3、1。我们每次排序如下:
原始状态: 5、2、4、3、1
第 1 次冒泡: 2、4、3、1、5
第 2 次冒泡: 2、3、1、4、5
第 3 次冒泡: 2、1、3、4、5
第 4 次冒泡: 1、2、3、4、5
从上面具体冒泡结果可以看到,我们每次把最大的元素(未排好序的)冒泡到了正确的位置,最后就是有序的了。对应的代码如下:
function bubbleSort(originalArr) {
// 拷贝数组,用于返回,不改变原数组
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
for (let i = 0; i < len; i++) {
for (let j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// ES6的解构赋值
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
// const temp = arr[j]
// arr[j] = arr[j + 1]
// arr[j + 1] = temp
}
}
}
return arr
}
⚠️注意:本文中的排序算法都是返回新数组,方便测试。 这里交换的代码使用的是ES6的 解构赋值 ,不了解的可以查看链接,我们也可以直接用一个暂时变量来辅助实现(上面的注释代码)。也可以将其封装为一个工具函数:
function swap(arr, i, j) {
const temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
后面的代码我还是会直接使用解构赋值来实现,大家也可以替换为 swap 函数。 但是我们发现:有时候并不需要冒泡 n(元素个数) 次,我们可以提前终止排序操作。这里我们可以使用一个标记 swapped (是否有交换)来记录是否排序完成。优化的代码如下:
function bubbleSort(originalArr) {
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
for (let i = 0; i < len; i++) {
let swapped = false
for (let j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
swap(arr, j, j + 1)
swapped = true
}
}
if (!swapped) break
}
return arr
}
function swap(arr, i, j) {
const temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
通过判断是否有交换操作:有则继续,没有则跳出循环。
测试:我们可以新建__test__文件夹,然后新建测试文件:bubbleSort.test.js,具体代码如下:
const bubbleSort = require('../bubbleSort')
const testSort = require('../testSort')
// describe('BubbleSort', () => {
// it('should sort array', () => {
// testSort(bubbleSort)
// })
// })
test('BubbleSort', () => {
testSort(bubbleSort)
})
⚠️注意:我这里的代码都是在node环境下运行的,若是运行有问题也没关系,我的代码全部上传到github,可以直接clone下来运行,github地址:github.com/webpig/algo…
选择排序
选择排序的思想是每次从未排序的一组数据中取最小(或最大)的元素,与未排序的第一个元素进行交换。操作如下:
原始数据: 5、2、4、3、1
第 1 次选择: 1、2、4、3、5
第 2 次选择: 1、2、4、3、5
第 3 次选择: 1、2、3、4、5
......
选择排序相对比较简单,我们可以写出如下代码:
function selectionSort(originalArr) {
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
for (let i = 0; i < len; i++) {
let minIndex = i
for (let j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j
}
}
if (minIndex > 0) {
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]
}
}
return arr
}
我们这里只需要每次记录最小值的下标,全部比较完成之后进行交换操作。
插入排序
我们都玩过扑克牌,我们在理牌的时候,是在已有的牌的基础上进行插入的,而已有牌是有顺序的;这就是插入排序的原理。排序过程如下:
原始数据: 已排序:5,未排序:2、4、3、1
第 1 次插入:已排序:2、5,未排序:4、3、1
第 2 次插入:已排序:2、4、5,未排序:3、1
第 3 次插入:已排序:2、3、4、5,未排序:1
第 4 次插入:已排序:1、2、3、4、5
我们根据以上示例可以直接写出如下代码:
function insertionSort(originalArr) {
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
// 需排序的元素个数,第一位默认已排序
for (let i = 1; i < len; i++) {
// j为需插入的元素找到正确位置
for (let j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
[arr[j], arr[j - 1]] = [arr[j - 1], arr[j]]
}
}
return arr
}
这里通过前后元素比较,进行交换操作,最终将元素插入。这种写法简单,但是会进行多次交换操作,不是很好。我们应该在没有找到最终位置时只是简单的把大的元素往后挪动(减少数组访问次数),代码如下:
function insertionSort(originalArr) {
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
for (let i = 1; i < len; i++) {
const value = arr[i]
let j = i - 1
for (; j >= 0; j--) {
if (arr[j] > value) {
arr[j + 1] = arr[j]
} else {
break
}
}
arr[j + 1] = value
}
return arr
}
i 用于记录当前需要插入数据下标,用 value 保存该值或者直接使用 arr[i] ,然后 value 与已排序数据进行比较,若 value 较小则移动较大元素,否则停止比较,将 value 插入到该位置。
归并排序
归并排序用的是分治思想,把大问题拆分为小问题,小问题解决了大问题就解决了。这里是一个先拆分后合并的操作:
原始数据:[7, 4, 8, 9, 3, 2, 6, 1]
拆分为:[7, 4, 8, 9]、[3, 2, 6, 1]
拆分为:[7, 4]、[8, 9]、[3, 2]、[6, 1]
拆分为:[7]、[4]、[8]、[9]、[3]、[2]、[6]、[1]
合并为:[4, 7]、[8, 9]、[2,3]、[1,6]
合并为:[4, 7, 8, 9]、[1, 2, 3, 6]
合并为:[1, 2, 3, 4, 6, 7, 8, 9]
我们可以用递归来编写代码:
function mergeSort(arr) {
const len = arr.length
if (len <= 1) return arr
const midIndex = Math.floor(len / 2)
const leftArr = arr.slice(0, midIndex)
const rightArr = arr.slice(midIndex, len)
return merge(mergeSort(leftArr), mergeSort(rightArr))
}
function merge(leftArr, rightArr) {
let sortedArr = []
while (leftArr.length && rightArr.length) {
if (leftArr[0] <= rightArr[0]) {
sortedArr.push(leftArr.shift())
} else {
sortedArr.push(rightArr.shift())
}
}
// if (leftArr.length) {
// sortedArr = sortedArr.concat(leftArr)
// }
// if (rightArr.length) {
// sortedArr = sortedArr.concat(rightArr)
// }
return sortedArr.concat(leftArr, rightArr)
}
递归终止条件为 => 数组长度小于等于1。数组长度大于1时则将数组拆分为二,最后需要对拆分的数组做合并操作。这里的合并操作如代码所示,就是将两个数组合并:一一判断两个数组的第一个元素,将较小元素存入已排序数组,最后再判断是否还有未添加元素,若有直接添加在后面。
快速排序
快速排序也是使用的分治思想,它也是找到一个分区点,将小于该分区点的值的元素放在一起,将大于该分区点的值的元素放在一起。即:arr[0 ~ p-1](小于arr[p]),arr[p],arr[p+1 ~ r](大于arr[p]),操作如下:
原始数据:[5, 2, 3, 1, 4]
取4为分区点:[2, 3, 1]、4、[5]
[2, 3, 1]取1为分区点:1、[2、3]
[2, 3]取3为分区点:[2]、3
最后为:[1, 2, 3, 4, 5]
代码如下:
function quickSort(originalArr) {
const arr = [...originalArr]
const len = arr.length
if (len <= 1) return arr
const mid = arr.pop()
const lowArr = []
const highArr = []
for (let i = 0; i < arr.length; i++) {
if (arr[i] <= mid) {
lowArr.push(arr[i])
} else {
highArr.push(arr[i])
}
}
return quickSort(lowArr).concat(mid, quickSort(highArr))
}
这个版本的空间复杂度比较高,我们使用了两个额外的数组来存储数据,我们需要优化该算法。
原地快速排序
什么是原地排序?就是不需要额外空间。我们可以先写出递归代码:
function quickSortInPlace(
originalArr,
startIndex = 0,
endIndex = originalArr.length - 1,
// 可以不用管该参数
recursiveCall = false
) {
// 可以不用管
const arr = recursiveCall ? originalArr : [...originalArr]
if (startIndex < endIndex) {
const pivot = partition(arr, startIndex, endIndex)
// 可以不用管
const RECURSIVE_CALL = true
quickSortInPlace(arr, startIndex, pivot - 1, RECURSIVE_CALL)
quickSortInPlace(arr, pivot + 1, endIndex, RECURSIVE_CALL)
}
return arr
}
主要思想就是找到分区点,将小于分区点值的数据放在左边,将大于分区点值的数据放在右边,递归,直到区间为1,那么数组就是有序的了。这里重点在于如何找到分区点。
我们可以使用 i 来记录已处理区间,j 来记录未处理区间,取最后元素为分区点,计作 pivot ,每次从未处理区间取出头部元素和 pivot 对比,如果小于 pivot ,则将其放入已处理区间,i 向后移,继续处理。最后将 pivot 与 i 位置元素交换,这样 pivot 就是分区点了。具体代码如下:
function partition(arr, startIndex, endIndex) {
let pivot = arr[endIndex]
let i = startIndex
for (let j = startIndex; j < endIndex; j++) {
if (arr[j] < pivot) {
[arr[i], arr[j]] = [arr[j], arr[i]]
i++
}
}
[arr[i], arr[endIndex]] = [arr[endIndex], arr[i]]
return i
}
到此,这五个基本排序我们都学习了。最后总结一下:
| 名称 | 时间复杂度 | 是否原地排序 | 是否稳定 |
|---|---|---|---|
| 冒泡排序 | O(n^2) | 是 | 是 |
| 选择排序 | O(n^2) | 是 | 否 |
| 插入排序 | O(n^2) | 是 | 是 |
| 归并排序 | O(nlogn) | 否 | 是 |
| 快速排序 | O(nlogn) | 是 | 否 |
这里的时间复杂度都是平均时间复杂度,是否稳定指的是:如果数组中有相同的元素,排序后相同的数据还是保持原来的顺序。比如:3、2、3、5、1,选择排序第一次就会把第一个3最后,它就在第三个3后面了;所以它不是稳定的。快速排序也是一样,第一次分区后第一个3就到最后面了。
这里讲的都是比较基础的东西,希望大家能给出建议。后续会继续深入,比如复杂度的分析,排序进一步优化等等。
代码github地址:github.com/webpig/algo…
欢迎大家一起学习👏👏👏