本文介绍了常见的 10 种排序算法的原理、基本实现和常见的优化实现,并有(我的认为)足够详细的代码注释。
实在是居家工做,面试笔试必备良药。html
这里只给出基于其原理的通常实现,不少算法都有逻辑更复杂的或代码量更少的精简版,像遍历的改为递归的,两个函数实现的改为一个函数等等,就再也不说起了。前端
够详细了!傻子都能看懂!若是不懂,多看几遍!git
前几天在微博上看到一个视频:用音频演示15种排序算法,能够看一下面试
全部动图均来自《十大经典排序算法总结(JavaScript 描述)》算法
另外一种分类方式是根据是否为“比较排序”。shell
平均时间复杂度 | 最好 | 最坏 | 空间复杂度 | 稳定性 | |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(n logn) | O(n logn) | O(n logn) | O(1) | 不稳定 |
插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(n logn) | O(n log^2 n) | O(n log^2 n) | O(1) | 不稳定 |
快速排序 | O(n logn) | O(n logn) | O(n^2) | O(logn) | 不稳定 |
归并排序 | O(n logn) | O(n logn) | O(n logn) | O(n) | 稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
桶排序 | O(n+k) | O(n+k) | O(n^2) | O(n+k) | 稳定 |
基数排序 | O(n*k) | O(n*k) | O(n*k) | O(n+k) | 稳定 |
已排序元素将放在数组尾部segmentfault
大体流程:api
演示图:数组
function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length -1 - i; j++) {
if (arr[j] > arr[j+1]) swap(arr, j ,j+1)
}
}
return arr
}
// 后面还会屡次用到,就再也不写出来了
function swap(arr, n, m) {
[arr[n], arr[m]] = [arr[m], arr[n]]
}
复制代码
有优化空间,主要从两方面进行优化:app
检查某次内层遍历是否发生交换。
若是没有发生交换,说明已经排序完成,就算外层循环尚未执行完 length-1
次也能够直接 break
。
function bubbleSort1(arr) {
for (let i = 0; i < arr.length - 1; i++) {
// 外层循环初始值为 false,没有发生交换
let has_exchanged = false
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j ,j+1)
has_exchanged = true
}
}
// 内层循环结束判断一下是否发生了交换
if (!has_exchanged) break
}
return arr
}
复制代码
记录内层遍历最后一次发生交换的位置,下一次外层遍历只须要到这个位置就能够了。
那么外层遍历就不能用 for
了,由于每次遍历的结束位置可能会发生改变。
function bubbleSort2(arr) {
// 遍历结束位置的初始值为数组尾,并逐渐向数组头部逼近
let high = arr.length - 1
while (high > 0) {
// 本次内层遍历发生交换的位置的初始值
let position = 0
for (let j = 0; j < high; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1)
// 若是发生了交换,更新 position
position = j
}
}
// 下次遍历只须要到 position 的位置便可
high = position
}
return arr
}
复制代码
双向遍历,每次循环能找到一个最大值和一个最小值。
先后各设置一个索引,向中间的未排序部分逼近。
function bubbleSort3(arr) {
let low = 0, high = arr.length - 1
while (low < high) {
// 正向遍历找最大
for (let i = low; i <= high; i++) if (arr[i] > arr[i + 1]) swap(arr, i, i + 1)
high--
// 反向遍历找最小
for (let j = high; j >= low; j--) if (arr[j] < arr[j - 1]) swap(arr, j, j - 1)
low++
}
return arr
}
复制代码
每次遍历选择最小。
排序后的元素将放在数组前部
大体流程:
并非倒着的冒泡排序。冒泡排序是比较相邻的两个元素
演示图:
function selectionSort(arr) {
for (let i = 0; i < arr.length; i++) {
let min_index = i
// 遍历后面的部分,寻找更小值
for (let j = i + 1; j < arr.length; j++) {
// 若是有,更新min_index
if (arr[j] < arr[min_index]) min_index = j
}
swap(arr, i, min_index)
}
return arr
}
复制代码
使用堆的概念实现的选择排序。
首先,关于堆:
index
个元素为堆的父节点,其左右子节点分别为数组的第 2*index+1
和 2*index+2
个元素已排序元素将放在数组尾部
大体流程:
注意:
演示图:
// 排序
function heapSort(arr) {
var arr_length = arr.length
if (arr_length <= 1) return arr
// 1. 建最大堆
// 遍历一半元素就够了
// 必须从中点开始向左遍历,这样才能保证把最大的元素移动到根节点
for (var middle = Math.floor(arr_length / 2); middle >= 0; middle--) maxHeapify(arr, middle, arr_length)
// 2. 排序,遍历全部元素
for (var j = arr_length; j >= 1; j--) {
// 2.1. 把最大的根元素与最后一个元素交换
swap(arr, 0, j - 1)
// 2.2. 剩余的元素继续建最大堆
maxHeapify(arr, 0, j - 2)
}
return arr
}
// 建最大堆
function maxHeapify(arr, middle_index, length) {
// 1. 假设父节点位置的值最大
var largest_index = middle_index
// 2. 计算左右节点位置
var left_index = 2 * middle_index + 1,
right_index = 2 * middle_index + 2
// 3. 判断父节点是否最大
// 若是没有超出数组长度,而且子节点比父节点大,那么修改最大节点的索引
// 左边更大
if (left_index <= length && arr[left_index] > arr[largest_index]) largest_index = left_index
// 右边更大
if (right_index <= length && arr[right_index] > arr[largest_index]) largest_index = right_index
// 4. 若是 largest_index 发生了更新,那么交换父子位置,递归计算
if (largest_index !== middle_index) {
swap(arr, middle_index, largest_index)
// 由于这时一个较大的元素提到了前面,一个较小的元素移到了后面
// 小元素的新位置以后可能还有比它更大的,须要递归
maxHeapify(arr, largest_index, length)
}
}
复制代码
已排序元素将放在数组前部
大体流程:
第一种理解方式,也就是通常的实现原理:
在上面的第2步中,遍历已排序元素时,若是该未排序元素仍然小于当前比较的已排序元素,就把前一个已排序元素的值赋给后一个位置上的元素,也就是产生了两个相邻的重复元素。
这样一来,在比较到最后,找到合适的位置时,用该未排序元素给两个重复元素中合适的那一个赋值,覆盖掉一个,排序就完成了。
叙述可能不够清楚,看后面的代码就是了。
Talk is hard, show you some codes。
和选择排序好像有一点相似的地方:
第二种理解方式:
在前面的第2步中,至关于把已排序部分末尾添加一个元素,而且执行一次冒泡排序。 由于前面的数组是已排序的,因此冒泡只须要遍历一次就能够给新的元素找到正确的位置。
可是以这种方式实现的代码没法使用二分法进行优化。
那么是否是说明,冒泡排序的优化方法能够用在这里?
并非。由于冒泡排序主要从两方面进行优化:
而这里的冒泡只有一次,而且也不是找极值。
演示图:
// 按照第一种理解方式的实现,即通常的实现
function insertionSort(arr) {
for (let index = 1; index < arr.length; index++) {
// 取出一个未排序元素
let current_ele = arr[index]
// 已排序元素的最后一个的位置
let ordered_index = index - 1
// 前面的元素更大,而且还没遍历完
while (arr[ordered_index] >= current_ele && ordered_index >= 0) {
// 使用前面的值覆盖当前的值
arr[ordered_index + 1] = arr[ordered_index]
// 向前移动一个位置
ordered_index--
}
// 遍历完成,前面的元素都比当前元素小,把未排序元素赋值进去
arr[ordered_index + 1] = current_ele
}
return arr
}
// 按照第二种理解方式的实现
function insertionSort(arr) {
for (let i = 0; i < arr.length; i++) {
// 对前面的已排序数组和新选出来的元素执行一趟冒泡排序
for (let j = i + 1; j >= 0; j--) if (arr[j] < arr[j - 1]) swap(arr, j, j - 1)
}
return arr
}
复制代码
一个意外的弱智发现:while(a&&b){}
和 while(a){ if(b){} }
不等价。。。
使用二分查找。
遍历已排序部分时,再也不是按顺序挨个比较,而是比较中位数。
function binaryInsertionSort(array) {
for (let i = 1; i < array.length; i++) {
// 未排序部分的第1个
let current_ele = array[i]
// 已排序部分的第1个和最后1个
let left = 0, right = i - 1
// 先找位置
while (left <= right) {
// 再也不是从最后一个位置开始向前每一个都比较,而是比较中间的元素
let middle = parseInt((left + right) / 2)
if (current_ele < array[middle]) right = middle - 1
else left = middle + 1
}
// while结束,已经找到了一个大于或等于当前元素的位置 left
// 再修改数组:把 left 到 i 之间的元素向后移动一个位置
for (let j = i - 1; j >= left; j--) array[j + 1] = array[j]
// 插入当前元素
array[left] = current_ele
}
return array
}
复制代码
插入排序使用的二分查找和二分查找函数显然不一样。
由于二者的目的不相同。
二分查找函数须要返回“存在”或“不存在”;而插入排序中的二分查找,关注的不是存在与否,而是“位置应该在哪里”,无论存在不存在,都要返回一个位置。
也叫缩小增量排序,是插入排序的加强版。
不直接对整个数组执行插入排序,而是先分组,对每一个组的元素执行插入排序,使数组大体有序,逐步提升这个“大体”的精确度,也就是减小分组的数量,直到最后只有一组。
指定一个增量 gap
,对数组分组,使得每相距 gap-1
的元素为一组,共分红 gap
组,对每组执行插入排序。逐步缩小 gap
的大小并继续执行插入排序,直到为1,也就是整个数组做为一组,对整个数组执行插入排序。
能够发现,无论增量 gap
初始值设定为多少,最后总会对整个数组进行一次插入排序,也就是说 gap
对排序结果是没有影响的,只是影响了算法效率。
至于 gap
如何取值最好,尚未研究过。期待你们留言交流。(只是随便一说,我看这个单纯就是为了面试。。)
大体流程:
gap
的值演示图:
function shellSort(arr) {
// 外层循环逐步缩小增量 gap 的值
for (let gap = 5; gap > 0; gap = Math.floor(gap / 2)) {
// 中层和内层是插入排序
// 普通插入排序从第1个元素开始,这里分组了,要看每一组的第1个元素
// 共分红了 gap 组,第一组的第1个元素索引为 gap
// 第一组元素索引为 0, 0+gap, 0+2*gap,...,第二组元素索引为 1, 1+gap, 2+2*gap,...
for (let i = gap; i < arr.length; i++) {
let current_ele = arr[i]
// 普通插入排序时,j 每次减小1,即与前面的每一个元素比较
// 这里 j 每次减小 gap,只会与当前元素相隔 n*(gap-1) 的元素比较,也就是只会与同组的元素比较
let ordered_index = i - gap
while (ordered_index >= 0 && arr[ordered_index] > current_ele) {
arr[ordered_index + gap] = arr[ordered_index]
ordered_index -= gap
}
arr[ordered_index + gap] = current_ele
}
}
return arr
}
复制代码
大体流程:
pivot
,好比第一个元素
固然能够选其余元素,可是最后会递归至只剩一个元素,因此仍是选第一个元素比较靠谱
pivot
更小的元素建立一个数组,更大的建立一个数组,相等的也建立一个数组普通快速排序没有考虑与
pivot
相等的状况,只建了更小和更大的两个数组。
像上面考虑与pivot
相等的状况时,又叫作三路快排。
演示图:
function quickSort(arr) {
// 只剩1个元素,不能再分割了
if (arr.length <= 1) return arr
// 取第1个元素为基准值
let base = arr[0]
// 分割为左小右大两个数组,以及包含元素自己的中间数组
let left = [], middle = [base], right = []
for (let index = 1; index < arr.length; index++) {
// 若是有与自己同样大的元素,放入 middle 数组,解决重复元素的问题
if (arr[index] === base) middle.push(arr[index])
else if (arr[index] < base) left.push(arr[index])
else right.push(arr[index])
}
// 递归并链接
return quickSort(left).concat(middle, quickSort(right))
}
复制代码
是采用分治法(Divide and Conquer)的一个很是典型的应用。
简单说就是缩小问题规模,快速排序也是分治法
大体流程:
递归地把数组分割成先后两个子数组,直到数组中只有1个元素
直接分两半,不用排序
同时,递归地从两个数组中挨个取元素,比较大小并合并
演示图:
// 分割
function mergeSort2(arr) {
// 若是只剩一个元素,分割结束
if (arr.length < 2) return arr
// 不然继续分红两部分
let middle_index = Math.floor(arr.length / 2),
left = arr.slice(0, middle_index),
right = arr.slice(middle_index)
return merge2(mergeSort2(left), mergeSort2(right))
}
// 合并
function merge2(left, right) {
let result = []
// 当左右两个数组都尚未取完的时候,比较大小而后合并
while (left.length && right.length) {
if (left[0] < right[0]) result.push(left.shift())
else result.push(right.shift())
}
// 其中一个数组空了,另外一个还剩下一些元素
// 由于是已经排序过的,因此直接concat就行了
// 注意 concat 不改变原数组
if (left.length) result = result.concat(left)
if (right.length) result = result.concat(right)
return result
}
复制代码
只能用于由肯定范围的整数所构成的数组。
统计每一个元素出现的次数,新建一个数组 arr
,新数组的索引为原数组元素的值,每一个位置上的值为原数组元素出现的次数。
大体流程:
演示图:
function countingSort(array) {
let count_arr = [], result_arr = []
// 统计出现次数
for (let i = 0; i < array.length; i++) {
count_arr[array[i]] = count_arr[array[i]] ? count_arr[array[i]] + 1 : 1
}
// 遍历统计数组,放入结果数组
for (let i = 0; i < count_arr.length; i++) {
while (count_arr[i] > 0) {
result_arr.push(i)
count_arr[i]--
}
}
return result_arr
}
复制代码
根据原数组的最小和最大值的范围,划分出几个区间,每一个区间用数组来表示,也就是这里所说的桶。
根据元素大小分别放入对应的桶当中,每一个桶中使用任意算法进行排序,最后再把几个桶合并起来。
区间的数量通常是手动指定的。
基本流程:
range
range
,商的整数部分即对应的桶的索引,放入该桶push()
,好比使用插入排序concat
起来便可其余排序方法固然也能够。不过插入排序实现时更接近“给已排序数组新增一个元素并使之有序”这种目的。
演示图:
function bucketSort(array, num) {
let buckets = [],
min = Math.min(...array),
max = Math.max(...array)
// 初始化 num 个桶
for (let i = 0; i < num; i++) buckets[i] = []
// (最大值-最小值)/桶数,获得每一个桶最小最大值的差,即区间
// 好比 range 为10, 0号桶区间为0-10,1号桶10-20,...
let range = (max - min + 1) / num
for (let i = 0; i < array.length; i++) {
// (元素-最小值)/区间,取整数部分,就是应该放入的桶的索引
let bucket_index = Math.floor((array[i] - min) / range),
bucket = buckets[bucket_index]
// 空桶直接放入
if (bucket.length) {
bucket.push(array[i])
}
// 非空,插入排序
else {
let i = bucket.length - 1
while (i >= 0 && bucket[i] > array[i]) {
bucket[i + 1] = bucket[i]
i--
}
bucket[i + 1] = array[i]
}
}
// 合并全部桶
let result = []
buckets.forEach((bucket) => {
result = result.concat(bucket)
})
return result
}
复制代码
一个题外话,关于 Array
的 fill()
方法。
在初始化数组的时候,想着是否是能够用 let arr = new Array(4).fill([])
,一行代码就能够给数组添加初始元素,这样就不用先建立数组,而后再 for
循环添加元素了。
可是问题是,fill()
添加的引用类型元素——这里就是空数组 []
——它们指向的是同一个引用。若是修改了其中一个数组,其余的数组也都跟着变了。
仍是老老实实 for
循环吧。
要求元素必须是0或正整数。
经过比较每一个元素对应位置上数字的大小进行排序:个位与个位,十位与十位 ...
根据比较顺序不一样,分为两类:
两种方法的共同点是:
插播一曲 LSD: Lucy in the Sky with Diamonds
基本流程:
先看一下演示图比较好
max_len
max_len
做为遍历次数,从个位开始;内层循环遍历数组演示图:
function radixSortLSD(arr) {
// 找出最大元素
let max_num = Math.max(...arr),
// 获取其位数
max_len = getLengthOfNum(max_num)
console.log(`最大元素是 ${max_num},长度 ${max_len}`)
// 外层遍历位数,内层遍历数组
// 外层循环以最大元素的位数做为遍历次数
for (let digit = 1; digit <= max_len; digit++) {
// 初始化0-9 10个数组,这里暂且叫作桶
let buckets = []
for (let i = 0; i < 10; i++) buckets[i] = []
// 遍历数组
for (let i = 0; i < arr.length; i++) {
// 取出一个元素
let ele = arr[i]
// 获取当前元素该位上的值
let value_of_this_digit = getSpecifiedValue(ele, digit)
// 根据该值,决定当前元素要放到哪一个桶里
buckets[value_of_this_digit].push(ele)
console.log(buckets)
}
// 每次内层遍历结束,把全部桶里的元素依次取出来,覆盖原数组
let result = []
buckets.toString().split(',').forEach((val) => {
if (val) result.push(parseInt(val))
})
// 获得了一个排过序的新数组,继续下一轮外层循环,比较下一位
arr = result
console.log(arr)
}
}
function getLengthOfNum(num) { return (num += '').length }
// 获取一个数字指定位数上的值,超长时返回0
// 个位的位数是1,十位的位数是2 ...
function getSpecifiedValue(num, position) { return (num += '').split('').reverse().join('')[position - 1] || 0 }
复制代码
这个没图,不过更简单,也不须要图。
现实生活中比较数字大小的时候通常也是这么作的,先比较最高位,而后再看更小位。
基本流程:
举两个栗子。
没有重复元素的状况:
// 原始数组
[110, 24, 27, 56, 9]
// 原数组至关于
[110, 024, 027, 056, 009]
// 第一次入桶,比较最高位百位
[[024, 027, 056, 009], [110]]
// 当桶中有多个元素时,递归。这里就是递归第一个桶
// 第二次入桶,比较十位
[[[009], [024, 027], [056]], [110]]
// 第二个桶中还有元素,继续递归
// 第三次入桶,比较个位
[[[009], [[024], [027]], [056]], [110]]
// 结果就是
[009, 024, 027, 056, 110]
复制代码
也就是说,对于没有重复元素的状况,递归的最终结果是每一个桶中只有一个元素。
有重复元素的状况:
[110, 024, 024, 056, 009]
// 第一次入桶,比较百位
[[009, 024, 024, 056], [110]]
// 第二次入桶,比较十位
[[[009], [024, 024], [056]], [110]]
// 第三次入桶,比较个位
[[[009], [[024, 024]], [056]], [110]]
复制代码
能够发现,对于有重复元素的状况,最终重复的元素都会在同一个桶中,不会产生每一个桶中只有一个元素的结果。
这时只要判断是否已经比较完个位了便可。也就是说,无论有没有重复元素,最大元素有几位,就最多须要比较多少次。
总之,能够想象成一个树结构,从原数组开始一直向下分出子数组,最后子数组中只有一个元素,或只有重复的元素。
function radixSortMSD(arr) {
// 最大元素
let max_num = Math.max(...arr),
// 获取其位数做为初始值,最小值为1,也就是个位
digit = getLengthOfNum(max_num)
return msd(arr, digit)
}
function msd(arr, digit) {
// 建10个桶
let buckets = []
for (let i = 0; i < 10; i++) buckets[i] = []
// 遍历数组,入桶。这里跟 LSD 同样
for (let i = 0; i < arr.length; i++) {
let ele = arr[i]
let value_of_this_digit = getSpecifiedValue(ele, digit)
buckets[value_of_this_digit].push(ele)
}
// 结果数组
let result = []
// 遍历每一个桶
for (let i = 0; i < buckets.length; i++) {
// 只剩一个元素,直接加入结果数组
if (buckets[i].length === 1) result = result.concat(buckets[i])
// 还有多个元素,可是已经比较到个位了
// 说明是重复元素的状况,也直接加入结果数组
else if (buckets[i].length && digit === 1) result = result.concat(buckets[i])
// 还有多个元素,而且尚未比较结束,递归比较下一位
else if (buckets[i].length && digit !== 1) result = result.concat(msd(buckets[i], digit - 1))
// 空桶就不做处理了
}
return result
}
复制代码
十大经典排序算法总结(JavaScript描述) - 掘金
前端 排序算法总结 - segmentfault
JS快速排序&三路快排
图解排序算法(二)之希尔排序
计数排序,桶排序与基数排序 - segmentfault
时间复杂度 - 维基
比较排序 - 维基
个人其余文章:
《深刻 JavaScript 经常使用的8种继承方案》
《免费为网站添加 SSL 证书》
《详解 new/bind/apply/call 的模拟实现》