本文讨论四种常见的排序算法:选择排序、快速排序、归并排序、计数排序。代码用 JS 实现。
目标:考虑一个由数字组成的一维数组,对其中的数字由小到大排列。
一、选择排序 Selection Sort
1.1 递归写法
选择排序递归写法思路:
每次找到数组中余下数字中最小的,放到前面来,然后对余下数字做同样操作,以此类推。
let min = numbers => {
if (numbers.length > 2) {
return min([numbers[0], min(numbers.slice(1))])
} else {
return Math.min.apply(null, numbers)
}
}
let minIndex = numbers => {
return numbers.indexOf(min(numbers))
}
let sort = numbers => {
let index = minIndex(numbers)
let min = numbers[index]
if (numbers.length > 1) {
numbers.splice(index, 1) // 从 numbers 里删掉 min
return [min].concat(sort(numbers))
} else { // 递归出口设置为数组长度等于1,多递归了一次而已
return numbers
}
}
let result = sort([53, 22, 1])
console.log(result)
1.2 循环写法
选择排序循环写法思路:
从左到右遍历数组,每次找到余下数字中最小的,和当前正在查看的数字的索引对比,只要索引不同,就交换两者位置。然后对余下数字做相同操作。
let minIndex = numbers => {
let index = 0
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] < numbers[index]) {
index = i
}
}
return index
}
let swap = (array, i, j) => {
let temp = array[i]
array[i] = array[j]
array[j] = temp
}
let sort = numbers => {
for (let i = 0; i < numbers.length - 1; i++) {
console.log('-----')
console.log(`i:${i}`)
let index = minIndex(numbers.slice(i)) + i
console.log(`index:${index}`)
console.log(`min:${numbers[index]}`)
if (index !== i) { swap(numbers, index, i) }
}
return numbers
}
let result = sort([1, 199, 27, 93, 124, 4903, 4])
console.log(result)
1.3 时间复杂度
O(n²)
推导:(n-1) + (n-2) + (n-3) + 2 + 1 = (n²-n)/2
二、快速排序 Quick Sort
2.1 递归写法
快速排序递归写法思路:
指定一个基准(一般指定中间的数字),比它小的移到前面,比它大的移到后面。对基准两侧的数字继续执行快速排序,以此类推。
特点:基准指在哪,谁的位置就确定了。
let quickSort = arr => {
if (arr.length <= 1) { return arr }
let pivotIndex = Math.floor(arr.length / 2)
let pivot = arr.splice(pivotIndex, 1)[0]
let left = [], right = []
for (let i = 0; i < arr.length; i++) {
if (arr[i] < pivot) {
left.push(arr[i])
} else {
right.push(arr[i])
}
}
return quickSort(left).concat([pivot], quickSort(right))
}
let result = quickSort([4, 99, 2, 0, 88, 67, 35, 28, 19, 9])
console.log(result)
2.2 时间复杂度
O(nlog₂n)
推导:假设 1000 个数,第一次确定基准后,遍历 999 次并移动位置(基准的位置不用动),此时基准前面的数字都比它小,基准后面的数字都比它大。这一次遍历,共执行1000次,只是基准的位置没有动。后面还需要遍历 log₂1000 次,每次都是执行1000次,共计 1000 * log₂1000 次,即 nlog₂n 次
三、归并排序 Merge Sort
归并排序思路:左边一半排好序,右边一半排好序,然后把左右两边合并。
3.1 递归写法
merge 采用递归写法:
两个指针只比每组的第一个。
/**
* @description 两个指针只比每组的第一个
* @param arr1 已经排好序的数组
* @param arr2 已经排好序的数组
*/
let merge = (arr1, arr2) => {
if (arr1.length === 0) { return arr2 }
if (arr2.length === 0) { return arr1 }
if (arr1[0] < arr2[0]) {
return [arr1[0]].concat(merge(arr1.slice(1), arr2))
} else {
return [arr2[0]].concat(merge(arr1, arr2.slice(1)))
}
}
let mergeSort = numbers => {
if (numbers.length === 1) {
return numbers
}
let midIndex = Math.floor(numbers.length / 2)
let halfLeft = numbers.slice(0, midIndex)
let halfRight = numbers.slice(midIndex)
return merge(mergeSort(halfLeft), mergeSort(halfRight))
}
// 测试用例 1
let numbers = [12, 3, 7, 21, 5, 9, 4, 6]
// 测试用例 2
// let numbers = [12, 3, 7, 21, 5, 9, 4, 6,1]
let result = mergeSort(numbers)
console.log(result)
3.2 循环写法
merge 采用循环写法思路:
两组指针从左到右依次移动,哪个小就 push 到新数组。直到其中一个指针到达终点,循环结束。
/**
* @description 两个指针从0开始,按从小到大进行合并
* @param arr1 已经排好序的数组
* @param arr2 已经排好序的数组
*/
let merge = (arr1, arr2) => {
let result = []
for (let i = 0, j = 0; (i < arr1.length) || (j < arr2.length);) {
if (arr1[i] === undefined && arr2[j]) {
result.push(arr2[j])
j++
continue
} else if (arr2[j] === undefined && arr1[i]) {
result.push(arr1[i])
i++
continue
}
if (arr1[i] < arr2[j]) {
result.push(arr1[i])
i++
} else {
result.push(arr2[j])
j++
}
}
return result
}
let mergeSort = numbers => {
if (numbers.length === 1) {
return numbers
}
let midIndex = Math.floor(numbers.length / 2)
let halfLeft = numbers.slice(0, midIndex)
let halfRight = numbers.slice(midIndex)
return merge(mergeSort(halfLeft), mergeSort(halfRight))
}
// 测试用例 1
let numbers = [12, 3, 7, 21, 5, 9, 4, 6]
// 测试用例 2
// let numbers = [12, 3, 7, 21, 5, 9, 4, 6,1]
let result = mergeSort(numbers)
console.log(result)
3.3 时间复杂度
O(nlog₂n)
四、计数排序 Counting Sort
计数排序思路:
用一个哈希表做记录。发现数字 N 就记 N:1,如果再次发现 N 就加 1。记录的同时挑选并记下数字的最大值。
最后把哈希表的 key 全部装进新数组中,假设 N:m,那么 N 要装载 m 次。
let countingSort = arr => {
let hashTable = {}, min = 0, max = 0, result = []
for (let i = 0; i < arr.length; i++) {
if (!(arr[i] in hashTable)) {
hashTable[arr[i]] = 1
} else {
hashTable[arr[i]] += 1
}
min = arr[i] < min ? arr[i] : min
max = arr[i] > max ? arr[i] : max
}
// 遍历哈希表
for (let j = min; j <= max; j++) {
if (j in hashTable) {
for (let i = 0; i < hashTable[j]; i++) { // 解决数字出现多次的情况
result.push(j)
}
}
}
return result
}
// 测试用例 1
// let numbers = [12, 12, 12, 3, 4, 89, 23, 1, 1, 2, 2, 2]
// 测试用例 2
let numbers = [-12, -12, -12, 3, -4, -89, -23, -1, 1, 2, 2, 2]
let result = countingSort(numbers)
console.log(result)
特点:使用了哈希表的数据结构,「用空间换时间」;只遍历一次数组,之前算法都要遍历好几遍。
注意事项:写代码时,到底写的是 key 还是 value 是易错点。
4.1 时间复杂度
O(n+max-min)
相比于以上三种,计数排序时间最快,「用空间换时间」。
五、时间复杂度对比
| 算法 | 时间复杂度 |
|---|---|
| 选择排序 | O(n²) |
| 快速排序 | O(nlog₂n) |
| 归并排序 | O(nlog₂n) |
| 计数排序 | O(n+max-min) |
六、其他排序算法
- 冒泡排序 Bubble Sort
- 插入排序 Insertion Sort
天生就会。扑克牌起牌过程中,每个人给扑克牌排序,就是用的插入排序。 - 希尔排序 Shell Sort
- 基数排序 Radix Sort
按个位、十位、百位...依次排序
七、学习资源与工具
- visualgo.net/zh 通过可视化的方式观看排序过程。