排序(冒泡,插入,选择)
八大经典排序:
冒泡排序 插入排序 选择排序 归并排序 快速排序 计数排序 基数排序 桶排序
冒泡 插入 选择 O(n²) 基于比较
快排 归并 O(nlogn) 基于比较
计数 基数 桶 O(n) 不基于比较
如何评价一个排序算法的优劣
排序算法的 执行效率 指时间复杂度,比较次数,移动次数
排序算法的 内存消耗 指空间复杂度
排序算法的 稳定性 指 排序后序列中 相等元素 的 原有先后顺序不变
原地排序(Sorted in place),特指 空间复杂度 为O(1)的排序算法
排序后相等元素顺序不变,称为 稳定的排序算法,相反 称为 不稳定的排序算法
稳定的排序算法 的优势
真实的软件开发中,进行排序的不是单纯的整数,而是一组对象,依赖某个key进行排序.
假设某个 订单系统中,存在 下单时间 订单金额 两个属性,
我们希望按 金额从小到大 进行排序,金额相同的订单按 下单时间从早到晚 排序,
则 先按 下单时间(注意是先时间) 进行排序,之后再用 稳定排序算法 按订单金额排序
稳定排序算法可以保证 金额相同的两个对象 在排序之后 前后顺序不变
冒泡排序(Bubble Sort)
冒泡排序 进行多轮,
每一轮 从头到尾对相邻两个数进行比较,看是否满足大小关系要求
如果 不满足 则交换两个数的位置,
每一轮 的最终结果 会使得一个数 ‘冒泡’,位于正确的位置,进行n轮
// 冒泡排序
const bubbleSort = (arr) => {
if (arr.length <= 1) return
for (let i = 0; i < arr.length; i++) {
let hasChange = false
// 每次冒泡最末尾的数已经位于正确的位置,后续不需要参与比较所以-i
for (let j = 0; j < arr.length - i - 1; j++) {
// 从小到大进行排序
if (arr[j] > arr[j + 1]) {
const temp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = temp
hasChange = true
}
}
// 如果某次冒泡没进行任何交换 说明所有元素已经到位
if (!hasChange) break
}
console.log(arr)
}
const test = [4, 5, 6, 3, 2, 1]
bubbleSort(test)
冒泡排序 空间复杂度为 O(1),是 原地排序算法,
相邻元素大小相等时不会进行交换 是 稳定的排序算法,
时间复杂度 为 O(n²)
最好情况 全为顺序 只要一轮冒泡(只需检查一轮),时间复杂度 O(n)
最好情况 全为逆序 需要n轮冒泡(需检查n轮),时间复杂度 O(n²)
有序度 和 逆序度:
有序度是数组中具有有序关系的元素对的个数
如:2,4,3,1,5,6,有序度为 11(一个个从前往后找,如24,23,25,26都是有序对,再找4*,3*,1*)
对于1,2,3,4,5,6 这种叫 满有序度,
还可以得出 逆序度 = 满有序度 - 有序度
n位数组满有序度 为 n*(n-1)/2
(如上例123456,1往后可找5对,2找4对,最后得5+4+3+2+1满序度)
冒泡排序中每一个逆序度都代表一次交换
最坏情况下 有序度为0,逆序度为n*(n-1)/2,最好情况下 相反,平均交换n*(n-1)/4次
所以复杂度为 O(n²)
插入排序(Insertion Sort)
插入排序 将数据分为两个区间,已排序区间 和 未排序区间,
初始 已排序区间 就是第一个元素,
每轮 取 未排序区间 的元素,在 已排序区间 中找合适的位置插入,
直到 未排序区间为空
// 插入排序
const insertionSort = (arr) => {
if (arr.length <= 1) return
// (0,i) 代表有序区间 i代表无序区间这轮即将要插入有序区间的数
for (let i = 1; i < arr.length; i++) {
const temp = arr[i]
let j = i - 1
// j 属于 [0,i-1],从后向前比较,比temp大的就后移
for (j; j >= 0; j--) {
if (arr[j] > temp) {
arr[j + 1] = arr[j]
} else {
break
}
}
// 直到 找到第一个<j或=j的数下标 或循环完j=-1,
// temp放这个数后面(j+1)
arr[j + 1] = temp
}
console.log(arr)
}
const testSort = [4, 1, 6, 3, 2, 1]
insertionSort(testSort)
插入排序 空间复杂度为 O(1),是 原地排序算法,
发现值相同的元素时插入 是 稳定的排序算法,
时间复杂度 为 O(n²) (n轮,每轮移动n个数)
最好情况时间复杂度为O(n)
选择排序
也区分 已排序区间 和 未排序区间,
每轮 找到 未排序区间 最小的数,与 已排序区间的末尾 的数交换,
// 选择排序
const selectionSort = (arr) => {
if (arr.length <= 1) return
// 需要注意这里的边界, 因为需要在内层进行 i+1后的循环,所以外层需要 数组长度-1
for (let i = 0; i < arr.length - 1; i++) {
let minIndex = i
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j // 找到整个数组的最小值
}
}
const temp = arr[i]
arr[i] = arr[minIndex]
arr[minIndex] = temp
}
console.log(arr)
}
const testSelect = [4, 8, 6, 3, 2, 1, 0, 12]
selectionSort(testSelect)
插入排序 空间复杂度为 O(1),是 原地排序算法,
是 不!稳定的排序算法,
任何情况时间复杂度 为 O(n²)
选择排序好理解,但是相比上面两个,略显逊色
总结
冒泡、插入、选择排序都有一个共同点,将待排序数列分为已排序和未排序两部分。
在未排序的部分中查找一个最值,放到已排序数列的恰当位置。
具体到代码层面,外层循环的变量用于分割已排序和未排序数,
内层循环的变量用于在未排序数中查找。
从思路上看,这三种算法其实是一样的,所以时间复杂度也相同
冒泡排序 不管怎么优化 元素交换次数都是 元素数据的逆序度
插入排序 不管怎么优化 元素移动次数都是 元素数据的逆序度
但是 插入排序 更受欢迎,因为:
// 冒泡排序的交换操作
if (arr[j] > arr[j + 1]) {
// ---每次交换三次赋值---
const temp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = temp
hasChange = true
}
// 插入排序的移动操作
if (arr[j] > temp) {
// ---每次移动一次赋值---
arr[j + 1] = arr[j]
} else {
break
}
所以 冒泡排序 虽然和插入排序时间复杂度一样,但 赋值操作 占用了额外三倍的时间.
排序排序 还具有优化后的 希尔排序
额外:当数据结构为链表
考虑只能改变节点位置,
冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;
插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,
但排序完毕后可能需要倒置链表;
选择排序比较次数一致,交换操作同样比较麻烦。
综上,时间复杂度和空间复杂度并无明显变化,
若追求极致性能,
冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。