时间复杂度和空间复杂度的计算

192 阅读3分钟

时间复杂度

算法的运行时间

实际运行

假如我们有2种算法A、B,那么怎么衡量这2种算法的优劣性呢?最直接的办法就是跑一遍,记录下运行时间,但是会存在几个问题

  1. 运行环境的干扰,不同配置的机器跑出来的结果大相径庭,同个算法同个机器不同时间运行的结果也可能存在差异
  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函数调用、上下文切换等时间)

  1. console函数调用耗时为10ns
  2. 剩余操作耗时5ns,包括for调用、赋值操作、if判断等

那么foo的运行时长为 10nsbar时间复杂度为55ns(5 * 10 + 5) 由此可知 bar 运行时间比 foo

推算方式

假如 foobar 入参随着时间增长而变大的话,2个函数耗时会如何变化呢?

  1. foo 函数体里面的逻辑实际上跟入参 n 没关系,所以无论入参 n 如何变化,均不影响foo的运行时间,故运行时间为 10ns
  2. bar 函数有for循环的存在,那么运行时间跟入参 n 存在线性关系,故运行时间为 10 * n + 5

由此可以看到,假设 n 趋向无穷大时,运行时间的关键因素由 n 来决定,其余的常数项可以忽略,而 n 代表 console 函数调用的次数,那么可以定义 foobar 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 < 100algorithm 的运行时间比 bar 长,故上述的推算方式存在一定的局限性。 但是笔者觉得还是可以接受的

  1. 随着时间推移,当n > 100时,时间复杂度为常数时间明显就优于线性时间,存在一个拐点的阈值
  2. 在拐点的阈值之前,实际的运行时间一般来讲并不会特别离谱,比如1ns和1000ns,理论上相差了1000倍,但是现实时间对人的感觉相差无几

度量方式

可以看到时间复杂度计算跟输入 n 强相关,那么就存在比较极端的情况会影响我们评估算法效率的情况,比如查询数组内元素为m的索引

  1. 最佳时间复杂度:当输入数组 arr = [m, ...]时,时间复杂度为O(1)
  2. 最差时间复杂度:当输入数组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 函数执行中只保存了变量 iconsole.log 执行完成之后就释放了,不占空间,故空间复杂度为 O(1)

递归

function recur(n: number) {
  if (n == 1) return;
  return recur(n - 1);
}

递归中每次执行返回 recur,并未回收,只完整执行完递归函数才会释放,故空间复杂度为 O(n)

总结

评估算法的优劣有时间复杂度和空间复杂度2个指标,但是同时优化2个指标比较困难,大部分情况都是以时间复杂度优先,即空间换时间的方式提升算法执行效率