时间复杂度
算法的运行时间
实际运行
假如我们有2种算法A、B,那么怎么衡量这2种算法的优劣性呢?最直接的办法就是跑一遍,记录下运行时间,但是会存在几个问题
- 运行环境的干扰,不同配置的机器跑出来的结果大相径庭,同个算法同个机器不同时间运行的结果也可能存在差异
- 投入测试成本,在大数据计算的场景下,每验证一次算法的周期都以天、周、月,投入产出比极低
那么实际运行测试有很大极限性,那么有从理论出发去衡量算法的执行时间么?
理论运行
假如存在以下2种算法 foo 和 bar
function foo(n: number) {
console.log(n);
}
function bar(n: number) {
for (let i = 0; i < n; i ++) {
console.log(i);
}
}
foo(5)
bar(5)
先假设几个耗时定义(忽略foo、bar函数调用、上下文切换等时间)
console函数调用耗时为10ns- 剩余操作耗时5ns,包括
for调用、赋值操作、if判断等
那么foo的运行时长为 10ns、bar时间复杂度为55ns(5 * 10 + 5)
由此可知 bar 运行时间比 foo 长
推算方式
假如 foo 和 bar 入参随着时间增长而变大的话,2个函数耗时会如何变化呢?
foo函数体里面的逻辑实际上跟入参n没关系,所以无论入参 n 如何变化,均不影响foo的运行时间,故运行时间为10nsbar函数有for循环的存在,那么运行时间跟入参 n 存在线性关系,故运行时间为10 * n + 5
由此可以看到,假设 n 趋向无穷大时,运行时间的关键因素由 n 来决定,其余的常数项可以忽略,而 n 代表 console 函数调用的次数,那么可以定义 foo 和 bar 2个算法的时间复杂度分别为O(1)和 O(n)。
业界一般将O(1)被称为常数时间,O(n)成为线性时间
常见的类型
常数时间
运行时间固定,不因入参大小的变化而变化,表示为 O(1)
线性时间
运行时间随着入参大小的变化成线性级别改变,表示为 O(n),比如单循环遍历数组
指数时间
运行时间随着入参大小的变化成平方级别改变,表示为 O(n^2),比如嵌套循环遍历数组
对数时间
跟指数时间相反,每次计算一次之后的循环次数减半,表示为 O(log n),比如二分法
其它类型请参考 wiki
局限性
如果有另外一个算法 algorithm
function algorithm(n: number) {
for (let i = 0; i < 100; i ++) {
console.log(i);
}
}
function bar(n: number) {
for (let i = 0; i < n; i ++) {
console.log(i);
}
}
bar(5)
algorithm(5)
按照上面的推算方式,algorithm 的时间复杂度也是O(1),但是理论上当 n < 100 时 algorithm 的运行时间比 bar 长,故上述的推算方式存在一定的局限性。
但是笔者觉得还是可以接受的
- 随着时间推移,当n > 100时,时间复杂度为常数时间明显就优于线性时间,存在一个拐点的阈值
- 在拐点的阈值之前,实际的运行时间一般来讲并不会特别离谱,比如1ns和1000ns,理论上相差了1000倍,但是现实时间对人的感觉相差无几
度量方式
可以看到时间复杂度计算跟输入 n 强相关,那么就存在比较极端的情况会影响我们评估算法效率的情况,比如查询数组内元素为m的索引
- 最佳时间复杂度:当输入数组
arr = [m, ...]时,时间复杂度为O(1) - 最差时间复杂度:当输入数组
arr = [..., m]时,时间复杂度为O(n)
考虑到输入的不确定性,业界一般以最差时间复杂度作为评判标准
空间复杂度
程序跑在某个进程内,使用进程管理的内存空间(堆、栈)保存变量、对象、引用等,所以我们评估某个算法的空间复杂度就相当于计算需要的内存空间。
度量方式
度量的方式跟时间复杂度类似,分为最佳空间复杂度和最差空间复杂度,但因为无法预估输入的大小,需要保证在所有输入数据下都有足够的内存空间,故空间复杂度的计算一般采用最差空间复杂度,也可以理解为峰值内存使用率
变量定义
function foo(n: number) {
const a = n; // O(1)
if (n > 10) {
const arr = Array(n).fill(1); // 峰值为 O(n)
}
}
最差空间复杂度在于 n 趋向无穷大时,foo函数的空间复杂度为 O(n)
循环
function loop(n) {
for (let i = 0; i < n; i++) {
console.log(i); // 执行后释放
}
}
loop 函数执行中只保存了变量 i ,console.log 执行完成之后就释放了,不占空间,故空间复杂度为 O(1)
递归
function recur(n: number) {
if (n == 1) return;
return recur(n - 1);
}
递归中每次执行返回 recur,并未回收,只完整执行完递归函数才会释放,故空间复杂度为 O(n)
总结
评估算法的优劣有时间复杂度和空间复杂度2个指标,但是同时优化2个指标比较困难,大部分情况都是以时间复杂度优先,即空间换时间的方式提升算法执行效率