排序算法
复杂度
老是听别人说O(N),O(logN),O(N²),我们也装着样子喊着,那到底什么是时间复杂度呢?什么是空间复杂度呢?怎么算出来的呢?为什么这个算法是O(N),另一个又是O(logN),为什么需要复杂度呢?
为什么需要复杂度
- 测试结果非常依赖测试环境
测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用Intel Core i9处理器和Intel Core i3处理器来运行,不用说,i9处理器要比i3处理器执行的速度快很多。还有,比如原本在这台机器上a代码执行的速度比b代码要快,等我们换到另一台机器上时,可能会有截然相反的结果。
- 测试结果受数据规模的影响很大
后面我们会讲排序算法,我们先拿它举个例子。对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!
所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。
那怎么算出这些复杂度呢
const fn = (n)=>{
let sum = 0;
let i = 0
for(;i<n;i++){
sum = sum + i
}
return sum
}
在考虑时间复杂度时,我们站在CPU的角度进行观察,CPU在运算每行代码时,无非是进行 读数据 - 处理数据 - 写数据 三个环节,我们初定这三个环节需要花费1个单位时间 unit-Time
以上诉代码为例
第2,3,6行代码执行了1次,而三四行代码需要执行n次,这里的n指的就是传入函数的参数数据量
那么整个函数跑下来需要花费 n+3 * unit-Time的时间,而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了
所以使用大O表示法的时候值记录最大量级O(n)
那空间复杂度呢
一个道理,时间复杂度是以时间为维度,那空间复杂度就是以函数里创建了多少空间为维度去进行计算。
常见排序算法的时间复杂度和空间复杂度
术语解释:
- 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面,反之为不稳定
- In-place:是否在源数据上进行排序
- k:桶的个数
冒泡排序
这个大家都知道的,直接上代码
const bubbleSort = (arr)=>{
let len = arr.length;
for (let i = 0; i < len; i++) {
for (let j = 0; j < len - 1 - i; j++) {
if(arr[j]>arr[j+1]){
[arr[j],arr[j+1]] = [arr[j+1],arr[j]]
}
}
}
}
可以明显看出来,是做了循环的遍历嵌套,而时间复杂度是O(n²),空间复杂度是创建的i和j,是常量级别的,所以空间复杂度是O(1)
选择排序
选择排序的思路是:寻找后续列表中最小(最大)的与列表最前面的未被排序过的进行交换
const selectSort = (arr)=>{
let len = arr.length
for (let i = 0; i < len; i++) {
//从头按顺序选择幸运观众(凑合选着先)
let minIdx = i
//遍历i以后的列表,找一个小于或者大于幸运观众的值,选他为幸运观众(选幸运观众)
for (let j = i+1; j < len; j++) {
if(arr[j]<arr[minIdx]){
minIdx = j
}
}
//交换位置,给幸运观众排座位
[arr[i],arr[minIdx]] = [arr[minIdx],arr[i]]
}
}
时间复杂度就是 O(n²),空间复杂度也还是常量级别的O(1)
插入排序
插入排序的思路是:选择一个key值,去跟key左侧(已排序的区域进行比较,已排序的区域的值大于(小于)key的右移),直到已排序的值小于(大于)key,则就当前位置复制key,做插入操作
普通版
const insertSort = (arr)=>{
let len = arr.length
for (let i = 1; i < len; i++) {
//获取一个基准值
let key = arr[i]
//复制基准值的位置
let j = i - 1
//寻找小于基准值的值
while (j>=0 && arr[j] > key) {
//不符合条件的都一边去,靠边站
arr[j+1] = arr[j]
j--
}
//在对应位置将基准值插入
arr[j+1] = key
}
}
时间复杂度就是 O(n²),空间复杂度也还是常量级别的O(1)
二分查找
由于左侧已排列好的是一个单调的区域,所以可以考虑用二分查找去查找插入的位置,减少复杂度
const binaryInsertionSort = (arr)=>{
let len = arr.length
for(let i = 0;i<len;i++){
//获取基准值
let key = arr[i]
//寻找基准值位置,二分查找
let l = 0,r = i - 1
while(l<=r){
let mid = l + ((r-l)>>1)
if(arr[mid]>key){
//如果中值大于基准值,说明基准值应该插在中值的左侧
r = mid - 1
}else{
//如果中值小于基准值,说明基准值应该插在中值的右侧
l = mid + 1
}
}
for (let j = i - 1; j >= l; j--) {
arr[j + 1] = arr[j];
}
arr[l] = key;
}
}
希尔排序(做预处理的插入排序)
希尔排序的思路是:作为插入排序的优化,又称减少增量排序
使用插入排序,通过比较相距一定间隔(增量)的元素,每次比较所用的距离随着算法的进行而缩小,直到比较到相邻元素为止。
const shellSort = (arr)=>{
let len = arr.length
//控制比较间隔
for(let gap = len >> 1; gap>0 ; gap = gap >> 1 ){
for (let r = gap; r < len; r++) {
//同插入排序的步骤:
//选择基准值
//复制基准值位置
let j = r
let key = arr[r]
//比较
if(arr[j] < arr[j - gap]){
while (j - gap >= 0 && key < arr[j - gap]) {
arr[j] = arr[j - gap]
j -= gap;
}
//插入
arr[j] = key
}
}
}
}
T(n)= 2T(n/2)+c
= 4T(n/4)+2c
……
= 2^kT(n/(2^k)) + kc
时间复杂度是:O(nlogN),空间复杂度是常量级的O(1),不稳定
归并排序
归并的思路是使用了分治法,将整体拆成局部,通过局部的排列去扩散到全局排序
const mergeSort = (arr)=>{
if(arr.length<2) return arr
//将传入的数组二分开,一直到只剩一个
const mid = arr.length >> 1
let left = arr.slice(0,mid)
let right = arr.slice(mid)
const merge = (left,right) =>{
const tempArr = []
while (left.length && right.length) {
//可以想象成两个队伍,从矮到高排列成一条队伍
if(left[0]<right[0]){
tempArr.push(left.shift())
}else{
tempArr.push(right.shift())
}
}
return [...tempArr,...left,...right]
}
//两个部分拆到最小
left = mergeSort(left)
right = mergeSort(right)
return merge(left,right)
}
在运算中,mergeSort用公式法计算时间复杂度
T(n) = 2T(n/2) + c
= 4T(n/4) +2c
……
= 2^kT(n/(2^k)) + kc
= 2^kT(1) + nc
……
=2^k = n
k = log2为底的N次幂
//大O表示法,除开常量
=log2为底的N次幂 * T(1)
所以时间复杂度是O(logN)
在运算中,merge的时间复杂度是N
所以归并的时间复杂度是O(nlogn),空间复杂度是tempArr的长度,也就是arr的长度,故为O(n),并且归并算法并不是在源数据上进行处理,所以是Out-place的
快速排序
快排的思路就是:选择一个基准值,比基准值小的放基准值的左边,比基准值大的放基准值的右边,然后再递归基准值两侧的数组
普通版
const quickSort = (arr,left,right)=>{
if(arr.length<2) return arr
let len = arr.length
let lessArr = []
let moreArr = []
//随机选
let pivot = Math.floor(Math.random()*len)
let key = arr[pivot]
for (let i = 0; i < len; i++) {
if(arr[i]<key && i !== pivot){
lessArr.push(arr[i])
}
if(arr[i]>key && i !== pivot){
moreArr.push(arr[i])
}
}
return [...quickSort(lessArr),arr[pivot],...quickSort(moreArr)]
}
这个方法虽然也可以进行快排,并且也符合快排的思路,但是并不是原地排序,还浪费了不少空间复杂度
三路快排
思路:
在源数据中划分三个区域:小于基准值,大于基准值,未遍历到的区域
从一开始选择最左为基准值,遍历数组
三个规则:
- 等于基准值的,i++,不做处理,继续遍历
- 小于基准值的,lt记录的是小于基准值区域末位idx,arr[lt]与当前遍历值交换,i++,lt++(扩大小于基准值的区域)
- 大于基准值的,rt记录的是大于基准值区域的头部idx,arr[rt]与当前遍历值交换,rt- -(缩小基准值的区域)
const threePathQuickSort = (arr,left,right)=>{
if(left >= right){
return false;
}
const areaObj = pieceArea(arr,left,right)
threePathQuickSort(arr,left,areaObj.left)
threePathQuickSort(arr,areaObj.right,right)
}
const pieceArea = (arr,left,right)=>{
let pivot = arr[left]
//左区间(小于基准值区域 默认为空)
let lt = left
//右区间(大于基准值区域 默认为空)
let rt = right + 1
for (let i = left + 1; i < rt;) {
if(arr[i] === pivot){
i++
}else if(arr[i] > pivot){
//大于基准值,将当前值归到rt区间里
[arr[rt - 1],arr[i]] = [arr[i],arr[rt - 1]]
rt--
}else{
//小于基准值,将当前值归到lt区间中
[arr[lt + 1],arr[i]] = [arr[i],arr[lt + 1]]
lt++
i++
}
}
//将基准值跟左区间交换
[arr[left],arr[lt]] = [arr[lt],arr[left]]
lt--
console.log(`三路快排后的数组: ${arr}`);
return {left:lt,right:rt}
}