1、时间复杂度&空间复杂度
- 关于时间复杂度,我们说的更多的是通过 O(nlogn) 以及 O(n) 等来衡量.日常工作中编写代码的时候,要努力将代码的时间复杂度维持在 O(nlogn) 以下,要知道凡是超过 n 平方的时间复杂度都是难以接受的。
- 空间复杂度比较容易理解,就是对一个算法在运行过程中临时占用存储空间大小的度量。有的算法需要占用的临时工作单元数与解决问题的规模有关,如果规模越大,则占的存储单元越多
1. 时间复杂度
简单理解就是一个算法或是一个程序在运行时,所消耗的时间(或者代码被执行的总次数)。
1.1 常数阶O(1)
不管n等于多少,程序始终只会执行一次,即 T(n) = O(1)
1.2 对数阶O(logn)
i 的值随着 n 成对数增长,读作2为底n的对数,即f(x) = log2n,T(n) = O( log2n),简写为O(logn)
// n = 32 则 i=1,2,4,8,16,32
for (let i = 1; i <= n; i = i * 2) {
console.log("对数阶:" + n);
}
1.3 线性阶O(n)
n的值为多少,程序就运行多少次,类似函数 y = f(x),即 T(n) = O(n)
1.4 线性对数阶O(nlogn)
线性对数阶O(nlogn)其实非常容易理解,将对数阶O(logn)的代码循环n遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)
for (let m = 1; m <= n; m++) {
let i = 1;
while (i < n) {
i = i * 2;
console.log("线性对数阶:" + i);
}
}
1.5 平方阶O(n2)
若 n = 2,则打印4次,若 n = 3,则打印9,即T(n) = O(n2)
以上5种时间复杂度关系为:
从上图可以得出结论,当x轴n的值越来越大时,y轴耗时的时长为:
O(1) < O(logn) < O(n) < O(nlogn) < O(n2)
2. 空间复杂度
空间复杂度表示的是算法的存储空间和数据之间的关系,即一个算法在运行时,所消耗的空间。
空间复杂度相对于时间复杂度要简单很多,我们只需要掌握常见的:
1. 常数阶O(1): const a = ''
2. 线性阶O(n): const arr = []
3. 平方阶O(n2): const arr = [][]
2. 排序
- 比较类排序:通过比较来决定元素间的相对次序,其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
非比较类的排序在实际情况中用的比较少,故本讲主要围绕比较类排序展开讲解。其实根据排序的稳定性,也可以分为稳定排序和不稳定排序,例如快速排序就是不稳定的排序、冒泡排序就是稳定的排序
2.1 冒泡排序
- 比较所有相邻的两个项,如果第一个比第二个大,则交换它们。元素项向上移动至 正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
- 不推荐该算法,它的时间复杂度是 O(n2 )
- 空间复杂度O(1)
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function bubbleSort(array) {
const length = array.length
if (length < 2) return array
for(let i = 0; i < length; i++) {
for(let j = 0; j < length - 1 - i; j++) {
if (array[j] > array[j + 1]) {
// const temp = array[j + 1]
// array[j + 1] = array[j]
// array[j] = temp
// 巧用解构快速换位
[array[j + 1], array[j]] = [array[j], array[j + 1]]
}
}
}
return array
}
bubbleSort(a)
2.2 选择排序
- 找到数据结构中的最小值并 将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
- 不推荐该算法,它的时间复杂度是 O(n2 )
- 空间复杂度是O(1)
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function selectionSort(array) {
const length = array.length
if (length < 2) return array
for(let i = 0; i < length; i++) {
for (let j = i + 1; j < length; j++) {
if (array[j] < array[i]) {
[array[i], array[j]] = [array[j], array[i]]
}
}
}
return array
}
selectionSort(a)
2.3 插入排序
- 每次排一个数组项,以此方式构建最后的排序数组
- 不推荐该算法,它的时间复杂度是 O(n2 )
- 空间复杂度O(1)
- 排序小型数组时,此算法比选择排序和冒泡排序性能要好。
// 思路:假定第一项已经排序了。接着待插入的第二项与第一项进行比较(前面的项比待插入项大则向后移一位)这样头两项就已正确排序,接着待插入的第三项与前面的项进行比较,以此类推。
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function insertionSort(array) {
const length = array.length
if (length < 2) return array
for (let i = 1; i < length; i++) {
let j = i - 1
// temp为待插入值,从第二项开始
const temp = array[i]
while(j>= 0 && array[j] > temp) {
// 如果待插入值小于前一项的值,将前一项后移一位,直到前一项的值比带插入值小
// j的位置空出待插入
array[j + 1] = array[j]
j--
}
// 该后移的都后移完了,空出来一项就属于待插入值,为什么j + 1,因为上面最后又执行了j--
array[j + 1] = temp
}
return array
}
insertionSort(a)
2.4 归并排序
- 将原始数组切分成较小的数组,直到每个小数组只 有一个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
- 它的时间复杂度是O(nlogn)
- 空间复杂度O(n)
// 思路:用二分法将数组递归拆成单项,再按排序结果进行合并
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function mergeSort(array) {
const length = array.length
if (length < 2) return array
const middle = length >> 1 // 二分发切分数组
const left = mergeSort(array.slice(0, middle))
const right = mergeSort(array.slice(middle))
return merge(left, right)
}
function merge(left,right) {
// 按排序结果进行合并
let i = 0, j = 0;
const leftLength = left.length
const rightLength = right.length
let result = []
// 两种特殊情况,左侧都比右侧小,或者左侧都比右侧大
if (left[leftLength - 1] <= right[0]) {
return [...left,...right]
} else if (left[0] >= right[rightLength]) {
return [...right, ...left]
} else {
while(i < leftLength && j < rightLength) {
if (left[i] < right[j]) {
result.push(left[i])
i ++
} else {
result.push(right[j])
j ++
}
}
// 当某一方全部合并后,另一方剩余部分直接合并就可以了
result = result.concat(i < leftLength ? left.slice(i) : right.slice(j))
}
return result
}
mergeSort(a)
2.5 快速排序
- 也使用分而治之的方法,将原始数组 分为较小的数组(但它没有像归并排序那样将它们分割开)
- 它的复杂度是O(nlogn)
- 是最常用的排序算法
/**
* 思路:
* 1.首先,从数组中选择一个值作为中元(pivot),也就是数组中间的那个值
* 2.创建两个指针(引用),左边一个指向数组第一个值,右边一个指向数组最后一个值。移动左指针直到我们找到一个比中元大的值,接着移动右指针直到找到一个比中元小的值,然后 交换它们,重复这个过程,直到左指针超过了右指针。这个过程将使得比中元小的值都排在中元 之前,而比中元大的值都排在中元之后。这一步叫作划分(partition)操作
* 3.接着,算法对划分后的小数组(较中元小的值组成的子数组,以及较中元大的值组成的子数组)重复之前的两个步骤,直至数组已完全排序
* 4.这种方法排序,空间复杂度为o(n)
*/
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function quickSort(array) {
const length = array.length
if (length < 2) return array
let left = 0, right = length - 1;
function quick(array, left, right) {
const index = partition(array, left, right)
// 通过递归细化数组并按左小右大排序
if (left < index - 1) {
quick(array, left, index - 1)
}
if (index < right) {
quick(array, index, right)
}
return array
}
// 将数组划分为左右两侧,并获取分界下标
function partition(array, i, j) {
// 找到中元
const middleIdx = (i + j) >> 1
const middle = array[middleIdx]
// 这里的时间复杂度为O(n)
while(i <= j) {
// 偏移左右指针
while(array[i] < middle) {
i++
}
while(array[j] > middle) {
j--
}
if (i <= j) {
// 左侧小,右侧大
[array[i], array[j]] = [array[j], array[i]]
i++
j--
}
}
// i值将数组分为左右大小两侧
return i
}
quick(array, left, right)
return array
}
quickSort(a)
2.6 sort排序
sort 方法是对数组元素进行排序,默认排序顺序是先将元素转换为字符串,然后再进行排序,先来看一下它的语法: arr.sort([compareFunction])
- 如果 compareFunction(a, b)小于 0,那么 a 会被排列到 b 之前;
- 如果 compareFunction(a, b)等于 0,a 和 b 的相对位置不变;
- 如果 compareFunction(a, b)大于 0,b 会被排列到 a 之前。
1. 底层 sort 源码分析
先大概来梳理一下源码中排序的思路(下面的源码均来自 V8 源码中关于 sort 排序的摘要,地址:V8 源码 sort 排序部分)。 通过研究源码我们先直接看一下结论,如果要排序的元素个数是 n 的时候,那么就会有以下几种情况:
-
当 n<=10 时,采用插入排序;
-
当 n>10 时,采用三路快速排序;
-
10<n <=1000,采用中位数作为哨兵元素;
-
n>1000,每隔 200~215 个元素挑出一个元素,放到一个新数组中,然后对它排序,找到中间位置的数,以此作为中位数。
接下来,我们看一下官方实现的 sort 排序算法的代码基本结构。
function ArraySort(comparefn) {
CHECK_OBJECT_COERCIBLE(this, "Array.prototype.sort");
var array = TO_OBJECT(this);
var length = TO_LENGTH(array.length);
return InnerArraySort(array, length, comparefn);
}
function InnerArraySort(array, length, comparefn) {
// 比较函数未传入
if (!IS_CALLABLE(comparefn)) {
comparefn = function (x, y) {
if (x === y) return 0;
if (% _IsSmi(x) && % _IsSmi(y)) {
return % SmiLexicographicCompare(x, y);
}
x = TO_STRING(x);
y = TO_STRING(y);
if (x == y) return 0;
else return x < y ? -1 : 1;
};
}
function InsertionSort(a, from, to) {
// 插入排序
for (var i = from + 1; i < to; i++) {
var element = a[i];
for (var j = i - 1; j >= from; j--) {
var tmp = a[j];
var order = comparefn(tmp, element);
if (order > 0) {
a[j + 1] = tmp;
} else {
break;
}
}
a[j + 1] = element;
}
}
function GetThirdIndex(a, from, to) { // 元素个数大于1000时寻找哨兵元素
var t_array = new InternalArray();
var increment = 200 + ((to - from) & 15);
var j = 0;
from += 1;
to -= 1;
for (var i = from; i < to; i += increment) {
t_array[j] = [i, a[i]];
j++;
}
t_array.sort(function (a, b) {
return comparefn(a[1], b[1]);
});
var third_index = t_array[t_array.length >> 1][0];
return third_index;
}
function QuickSort(a, from, to) { // 快速排序实现
//哨兵位置
var third_index = 0;
while (true) {
if (to - from <= 10) {
InsertionSort(a, from, to); // 数据量小,使用插入排序,速度较快
return;
}
if (to - from > 1000) {
third_index = GetThirdIndex(a, from, to);
} else {
// 小于1000 直接取中点
third_index = from + ((to - from) >> 1);
}
// 下面开始快排
var v0 = a[from];
var v1 = a[to - 1];
var v2 = a[third_index];
var c01 = comparefn(v0, v1);
if (c01 > 0) {
var tmp = v0;
v0 = v1;
v1 = tmp;
}
var c02 = comparefn(v0, v2);
if (c02 >= 0) {
var tmp = v0;
v0 = v2;
v2 = v1;
v1 = tmp;
} else {
var c12 = comparefn(v1, v2);
if (c12 > 0) {
var tmp = v1;
v1 = v2;
v2 = tmp;
}
}
a[from] = v0;
a[to - 1] = v2;
var pivot = v1;
var low_end = from + 1;
var high_start = to - 1;
a[third_index] = a[low_end];
a[low_end] = pivot;
partition: for (var i = low_end + 1; i < high_start; i++) {
var element = a[i];
var order = comparefn(element, pivot);
if (order < 0) {
a[i] = a[low_end];
a[low_end] = element;
low_end++;
} else if (order > 0) {
do {
high_start--;
if (high_start == i) break partition;
var top_elem = a[high_start];
order = comparefn(top_elem, pivot);
} while (order > 0);
a[i] = a[high_start];
a[high_start] = element;
if (order < 0) {
element = a[i];
a[i] = a[low_end];
a[low_end] = element;
low_end++;
}
}
}
// 快排的核心思路,递归调用快速排序方法
if (to - high_start < low_end - from) {
QuickSort(a, high_start, to);
to = low_end;
} else {
QuickSort(a, from, low_end);
from = high_start;
}
}
}
}
2.总结
如果当 n 足够小的时候,最好的情况下,插入排序的时间复杂度为 O(n) 要优于快速排序的 O(nlogn),因此就解释了这里当 V8 实现 JS 数组排序算法时,数据量较小的时候会采用插入排序的原因了。