持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
引言
文中列举了常见的排序算法的实现思路以及具体代码,可以在leetcode题目 912. 排序数组 中运行,方便查看效果。
选择排序
首先需要明确一个问题: 选择排序选择的是什么?
对于升序排序来说,选择排序是在无序的那一部分中选出最大的那个数的索引,将这个数与无序部分的第一个数进行交换,成为有序的一部分,然后不断地重复这一过程,直到数组成员全部有序。
var sortArray = function(nums) {
for(let i=0; i<nums.length; i++) {
for(let j=i; j<nums.length; j++) {
if(nums[i]>nums[j]) {
[nums[i], nums[j]] = [nums[j], nums[i]]
}
}
}
return nums
};
复制代码
插入排序
玩斗地主之类的牌类游戏时就会用到插入排序,每个人首先要一张接一张地拿到自己的牌,并插入到手中已有的牌里,这不就是从无序中选择一个数,插入到有序的数组中吗?
插入排序的插入是指从未排序的序列中选择一个(一般是第一个)插入到前方已经排好的序列中
function insertionSort(arr) {
for(let i=0; i<arr.length; i++) {
// j-1>=0, 保证前面至少有一个元素,如果前面没有元素,没有必要进行比较了
for(let j=i; j-1>=0; j--) {
if(arr[j] < arr[j-1]) {
[arr[j], arr[j-1]] = [arr[j-1], arr[j]]
} else break
}
}
return arr
}
// 优化
function insertionSort(arr) {
for(let i=0; i<arr.length; i++) {
// j-1>=0, 保证前面至少有一个元素,如果前面没有元素,没有必要进行比较了
let j
// 暂存准备插入的元素,能够减少赋值的操作,但总体复杂度不变
let temp = arr[i]
for(j=i; j-1>=0 && temp<arr[j-1]; j--) {
arr[j] = arr[j-1]
}
arr[j] = temp
}
return arr
}
const arr = [6, 4, 2, 3, 1, 5]
console.log(insertionSort(arr))
复制代码
插入排序的重要特性
对于选择排序来说, 它的时间时间复杂度稳定在O(n^2),其内部 循环每次都是从头循环到尾,即使内部循环的的第一个数就是最小的那个数,而对于插入排序来说,其内部排序是可以中途退出的,即内部循环找到了待插入值的位置后就结束了,那么对于一个有序数组来说,插入排序的时间复杂度可以到O(n),但其总体复杂度还是不变的,如果一个数组总体有序的话可以考虑插入排序。
归并排序
自顶向下的归并排序
运用递归的思想,先分成左、右部分,对左右部分递归地进行merge排序,这样左右部分是有序的,然后再将有序的两部分进行合并。
var sortArray = function (arr) {
//[l, r]前闭后闭
const merge = (arr, l, mid, r) => {
const temp = arr.slice(l, r + 1)
let i = l,
j = mid + 1
// 思考为什么要减去l?temp与原数组是存在偏差的
// 截取的数组第一个位置为0,但在原数组中位置是l
// 因此偏差为l,每次要减去l
for (let k = l; k <= r; k++) {
// 左半部分越界
if (i > mid) {
arr[k] = temp[j - l]
j++
// 右半部分越界
} else if (j > r) {
arr[k] = temp[i - l]
i++
} else if (temp[i - l] < temp[j - l]) {
arr[k] = temp[i - l]
i++
} else {
arr[k] = temp[j - l]
j++
}
}
}
const sort = (arr, l, r) => {
if(l >= r) return
let mid = Math.floor((l+r)/2)
sort(arr,l,mid)
sort(arr,mid+1, r)
merge(arr, l, mid, r)
}
sort(arr, 0, arr.length-1)
}
const arr = [1,3,53,9,8,5,4]
mergeSort(arr)
console.log(arr)
复制代码
优化一: 如果左右两部分已经有序,即arr[mid]<=arr[mid+1]
,那么这两段就没有必要进行归并
const sort = (arr, l, r) => {
if(l >= r) return
let mid = Math.floor((l+r)/2)
sort(arr,l,mid)
sort(arr,mid+1, r)
// 两段已经有序,如果arr[mid]<=arr[mid+1],那么这两段就没有必要进行归并
if(arr[mid]>arr[mid+1]) {
merge(arr, l, mid, r)
}
}
复制代码
优化二:还是对sort进行优化,在数据量小的时候插入排序的性能优于归并排序
function insertionSort(arr, l, r) {
for(let i=l; i<=r; i++) {
let j
let temp = arr[i]
for(j=i; j-1>=l && temp<arr[j-1]; j--) {
arr[j] = arr[j-1]
}
arr[j] = temp
}
return arr
}
const mergeSort = (arr) => {
const merge = (arr, l, mid, r) => {
const temp = arr.slice(l, r + 1)
let i = l,
j = mid + 1
//思考为什么要减去l?temp与原数组是存在偏差的
// 截取的数组第一个位置为0,但在原数组中位置是l
// 因此偏差为l,每次要减去l
for (let k = l; k <= r; k++) {
// 左半部分越界
if (i > mid) {
arr[k] = temp[j - l]
j++
// 右半部分越界
} else if (j > r) {
arr[k] = temp[i - l]
i++
} else if (temp[i - l] < temp[j - l]) {
arr[k] = temp[i - l]
i++
} else {
arr[k] = temp[j - l]
j++
}
}
}
const sort = (arr, l, r) => {
// 优化前返回条件
// if(l >= r) return
// 优化后返回条件, 当数据量较小时,插入排序的性能优于归并排序
if(r-l<=15) {
insertionSort(arr, l, r)
// 注意此处,已经排序完毕,立即返回
return
}
let mid = Math.floor((l+r)/2)
sort(arr,l,mid)
sort(arr,mid+1, r)
// 两端已经有序,如果arr[mid]<=arr[mid+1],那么这两段就没有必要进行归并
if(arr[mid]>arr[mid+1]) {
merge(arr, l, mid, r)
}
}
sort(arr, 0, arr.length-1)
}
const arr = [1,3,53,9,8,5,4]
mergeSort(arr)
console.log(arr)
复制代码
自底向上的归并排序
// 自底向上的归并排序
const mergeSort = (arr) => {
const merge = (arr, l, mid, r) => {
const temp = arr.slice(l, r + 1)
let i = l,
j = mid + 1
//思考为什么要减去l?temp与原数组是存在偏差的
// 截取的数组第一个位置为0,但在原数组中位置是l
// 因此偏差为l,每次要减去l
for (let k = l; k <= r; k++) {
// 左半部分越界
if (i > mid) {
arr[k] = temp[j - l]
j++
// 右半部分越界
} else if (j > r) {
arr[k] = temp[i - l]
i++
} else if (temp[i - l] < temp[j - l]) {
arr[k] = temp[i - l]
i++
} else {
arr[k] = temp[j - l]
j++
}
}
}
const sort = (arr) => {
for (let sz = 1; sz < arr.length; sz += sz) {
for (let i = 0; i + sz < arr.length; i += sz + sz) {
// 合并两个区间,这个时候mid=i+sz-1, i+sz<n,说明第二个区间存在
// 但是极端情况下第二个区间可能只有arr[i+sz]这一个数字
// 为防止越界,r=Math.min(i+sz+sz-1, arr.lenth-1)
merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, arr.length - 1))
}
}
}
sort(arr)
}
const arr = [1, 3, 53, 9, 8, 5, 4]
mergeSort(arr)
console.log(arr)
复制代码
快速排序
挑选一个元素作基准(暂时默认选取第一个元素作为基准),将小于基准的元素放在基准之前,大于基准的元素放在基准之后,再分别对小数区与大数区进行排序。
上述操作基于partion函数实现,该函数是快速排序的关键。
第一版
黄色矩形代表的是基准元素
const quickSort = (arr, l, r) => {
// partition将数组分成两部分
// l, r分别代表数组的左右边界
// 默认将arr[l]作为标志位
// [l+1, j] 部分小于arr[l]
// [j+1,i-1]部分大于arr[l] (闭区间)
const partition = (arr, l, r) => {
let j=l
for(i=l+1; i<=r; i++) {
if(arr[i]<arr[l]) {
j++
[arr[i], arr[j]] = [arr[j], arr[i]]
}
}
[arr[l], arr[j]] = [arr[j], arr[l]]
return j
}
if(l>=r) return
let mid = partition(arr, l, r)
quickSort(arr,l,mid-1)
quickSort(arr,mid+1,r)
}
const arr = [1, 3, 53, 9, 8, 5, 4]
quickSort(arr, 0, arr.length-1)
console.log(arr)
复制代码
第一版快速排序的主要问题在于partition的实现,因为我们选取的基准是第一个元素,如果该数组本身就是有序数组的话可能会导致栈溢出。
第二版 增加随机性
增加基准元素选取的随机性后,从动态图中可以看到,黄色矩形并是第一个,而是随机选取的一个元素,然后和第一个元素进行交换。
let p = l + Math.floor(Math.random()*(r-l));
[arr[p], arr[l]] = [arr[l], arr[p]]
复制代码
交换后的实现代码和第一版基本一样。
const quickSort = (arr, l, r) => {
const partition = (arr, l, r) => {
let p = l + Math.floor(Math.random()*(r-l)); //灵魂分号!!! FBI warning!!!
[arr[p], arr[l]] = [arr[l], arr[p]]
let j = l
for(i=l+1; i<=r; i++) {
if(arr[i]<arr[l]) {
j++
[arr[i], arr[j]] = [arr[j], arr[i]]
}
}
[arr[l], arr[j]] = [arr[j], arr[l]]
return j
}
if(l>=r) return // 递归推出条件
let mid = partition(arr, l, r)
quickSort(arr,l,mid-1)
quickSort(arr,mid+1,r)
}
复制代码
冒泡排序
对于升序而言,从结果上来看,无序部分中较大元素依次冒泡出来
- 第i轮开始,
arr[n-i, n]
已排好序 - 第i轮:通过冒泡在
arr[n-i, n]
位置放上合适的元素 - 第i轮结束:
arr[n-i- 1, n]
已排好序
但冒泡排序并不是仅仅简单的将最大(小)的数字移到最后,而是在这过程中不断将相邻的逆序对减少,每冒泡一次,逆序对的数量就会减少,直到减为0,排序完成,也就是说每冒泡一次,数组整体的有序程度是逐渐上升的。
// 冒泡排序
var sortArray = function(nums) {
for(let i=0; i<nums.length; i++) {
for(let j=0; j<nums.length-i; j++) {
if(nums[j]>nums[j+1]) {
[nums[j], nums[j+1]] = [nums[j+1], nums[j]]
}
}
}
return nums
}
复制代码
希尔排序
简单理解:希尔排序就是插入排序pro
其基本思想在于:让数组越来越有序,与冒泡排序不同一次只处理一个逆序对,希尔排序一次同时处理多个逆序对,而不仅仅是相邻的逆序对。
实现思路:对元素间距为n/2的所有数组做插入排序,对元素间距为n/4的所有数组做插入排序 对元素间距为n/8的所有数组做插入排序,..., 直到对元素间距为1的所有数组做插入排序,排序完成。
一句话总结就是:
每一轮按照事先决定的间隔进行插入排序,间隔会依次缩小,最后一次一定要是1。
// 希尔排序
var sortArray = function(nums) {
let space = Math.floor(nums.length/2)
while(space >= 1) {
// 对间隔为space的数组进行插入排序
for(let start = 0; start < space; start++) {
// 进行插入排序的逻辑
for(let i=start+space; i<nums.length; i+=space) {
let temp = nums[i]
let j
for(j=i; j-space>=0 && temp < nums[j-space]; j-=space) {
nums[j] = nums[j-space]
}
nums[j] = temp
}
}
space = Math.floor(space/2)
}
return nums
}
// 改进 四重循环变为三重循环
var sortArray = function(nums) {
let space = Math.floor(nums.length/2)
while(space >= 1) {
// 现在第space个元素就是第一个子序列对应的第二个元素
for(let i=space; i<nums.length; i++) {
let temp = nums[i]
let j
for(j=i; j-space>=0 && temp < nums[j-space]; j-=space) {
nums[j] = nums[j-space]
}
nums[j] = temp
}
space = Math.floor(space/2)
}
return nums
}
复制代码
数组排序时间空间复杂度速查
算法 | 时间复杂度 | 空间复杂度 | ||
---|---|---|---|---|
最佳 | 平均 | 最差 | 最差 | |
快速排序 | O(nlog(n)) | O(nlog(n)) | O(n2) | O(log(n)) |
归并排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) | O(n) |
Timsort | O(n) | O(nlog(n)) | O(nlog(n)) | O(n) |
堆排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) | O(1) |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) |
插入排序 | O(n) | O(n2) | O(n2) | O(1) |
选择排序 | O(n2) | O(n2) | O(n2) | O(1) |
希尔排序 | O(n) | O((nlog(n))2) | O((nlog(n))2) | O(1) |