时间复杂度和空间复杂度

512 阅读6分钟

时间复杂度和空间复杂度

注:时间复杂度和空间复杂度本身如果要严格意义的数学证明的话,它的公式比较繁复,而且很多时间让人觉得望而却步,不建议大家看这么严谨的数学证明。

时间复杂度

BIG O notation (大O符号表示法)

  1. 大O符号(Big O notation)是用于描述函数渐进行为的数学符号。更确切地说,它是用另一个(通常更简单的)函数来描述一个函数数量级的渐近上界。在数学中,它一般用来刻画被截断的无穷级数尤其是渐近级数的剩余项;在计算机科学中,它在分析算法复杂性的方面非常有用。百度百科-大O符号
  2. 注:O(1)不代表时间复杂度就是1,也可能是2、3、4等,只是所有这些常数复杂度,在 BIG O 表示法中称为 O(1) 复杂度, 其实也可以称之为常数复杂度,O(n) 线性时间复杂度可能运行了n次,也可能运行了2n次,在 BIG O 表示法中一律称为O(n), 所以前面的常数系数是不用考虑的。
  • O(1): Constant Complexity 常数复杂度
  • O(logn): Logarithmic Complexity 对数复杂度
  • O(n): Linear Complexity 线性时间复杂度
  • O(n²): N square Complexity 平方
  • O(n³): N cubic Complexity 立方
  • O(2^n): Exponential Growth 指数
  • O(n!): Facorial 阶乘

怎么去看一段代码的时间复杂度呢,我们通常需要关注的是, 这个函数或者说这段代码根据n的不同情况,它会运行多少次。

常数复杂度代码解析

  // 代码块1 时间复杂度为 O(1)
  let n = 1;

  console.log(n);

  // 代码块2 时间复杂度为 O(?)
  let n = 1000;

  console.log(n);
  console.log(n);
  console.log(n);

代码块1, 不管n等于多少, 都只输出了一次,所以时间复杂度为O(1) 代码块2, 不管n等于多少,都只输出了三次, 所以时间时间复杂度也为O(1)

线性、平方、立方时间复杂度代码解析

  // O(n)
  for (let i = 1; i <= n; i++) {
    console.log(`hey - I am busy looking at: ${ i }`);
  }

  // O(n²)
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
      console.log(`hey - I am busy looking at: ${ i } and ${ j }`);
    }
  }

  // O(n³)
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
      for (let k = 1; k <= n; k++) {
        console.log(`hey - I am busy looking at: ${ i } and ${ j } and ${ k }`);
      }
    }
  }

第一段代码, n 为 1时, 执行一次,n 等于 100时, 输出100次, 符合线性时间复杂度的条件 执行次数为 常数 * n, 表示为 O(n) 第二段代码, n 为 1时, 执行一次,n 等于 3时,输出9次, 符合平方时间复杂度的条件 执行次数为 n², 表示为 O(n²) 第三代代码同理, 我们扩展一下 如果把第二段代码由一个嵌套的结构写成并列的,那么它的时间复杂度是多少?

  for (let i = 1; i <= n; i++) {
    console.log(i);
  }

  for (let i = 1; i <= n; i++) {
    console.log(i);
  }

  // 观察得之,n = 1,输出了2次,n = 2, 输出了4次,n = 3, 输出了6次, 也就是说最深层代码会执行 2 * n次,时间复杂度为 O(2n), 也就是 O(n)

对数时间复杂度代码解析

  // O(logn)
  for (let i = 1; i < n; i = i * 2) {
    console.log(i);
  }

如果n为4, 则只会执行2次,如果n为8,则执行3次,可以看出去永远执行 log2(n)次(也就是2的多少次幂), 所以它的时间复杂度为 O(logn)

指数级的时间复杂度(斐波那契数列)

  // 斐波那契数列比较特殊,它的时间复杂度为 O(k^n) 其中k表示一个常数
  function fib(n) {
    if (n < 2) return n;
    
    return fib(n - 1) + fib(n - 2);
  }

怎么分析一个递归函数的时间复杂度呢? 关键点在于我们需要了解他的递归总共执行了这个语句多少次,如果是简单的循环很好理解,n次就执行了n次,递归的话因为层层嵌套,变得异常复杂,这时我们往往需要借助画一个树形结构来理解递归,我们称之为递归状态的递归树或者状态树。 我们知道 斐波那契数列为: 0, 1, 1, 2, 3, 5, 8, 13, 21..., 面试时通常有人会写出以上最最简单的递归写法,这段语句的话,它的时间复杂度我们来分析下。我们假设 n 初始值为6, 我们要计算第六位数字,这个时候执行fib(6)会引出fib(5)和fib(4), 同理可得, fib(5)计算的话, 需要计算 fib(4)和fib(3), 可以看出一个节点执行都会引出两个分支, 用状态树来表示的话: 我们可以看出, 每个节点都会往下展开为2个节点(不管此节点有没有计算过), 所以求第六个数的时候,就变成了2^6这样一个繁复的时间复杂度。

时间复杂度曲线

我们可以看到,当n比较小(运算数量级小)的时候,也就是 n 小于4时,不同的时间复杂度其实差不多,当n增大的时候,会发现指数级(当然还有阶乘)涨的是非常快的, 也就是说如果你在写一段程序中,把指数级(2^n) 降到平方级(n²)的话,那么从这个曲线来看,当n较大时,你得到的收益是非常高的。

  // 求 1到n的循环累加
  let n = 100;
  let sum = 0;

  // 代码段1 暴力求解法  时间复杂度 O(n)
  for(let i = 0; i < n; i++) {
    sum += i;
  };

  // 代码段2 利用等差数列求和公式求和 因为无论n为多少 都只执行一次(常数次) 所以时间复杂度为 O(1) 
  sum = n * (n + 1) / 2;

从上图得之, 当n无限大, 带来的收益也是非常大的, 所以不同的代码实现,执行性能上会有区别哦。

递归的四种情形

  1. 二分查找: 一般发生在一个数列本身有序,而且你要在这个有序数列中找到你要的目标数,所以每次都一分为2,只查一边,时间复杂度是 n(logn)
  2. 二叉树遍历: 时间复杂度为 O(n),
  3. 排好序的二维矩阵中进行二分查找,根据主定理可以得出,最后的时间复杂度为 O(n)的,一维的矩阵中查找,时间复杂度为 O(logn)
  4. 归并排序(merge sort): 所有排序中最优的办法,时间复杂度为 O(nlogin)

思考题(都可回答根据主定理, 这里不做描述)

  1. 二叉树遍历: 前序、中序、后序:时间复杂度是多少?

O(n) 不管怎么遍历, 每个节点都只走一次, 有n个节点, 则遍历n次, 线性于这个二叉树的节点总数。

  1. 图的遍历: 时间复杂度是多少?

O(n) 同理, 图的每个节点访问一次且只访问一次,所以图的遍历时间复杂度也是线性于图的节点总数。

  1. 搜索算法: DFS、BFS时间复杂度是多少?

O(n) 这里的n指的是搜索空间里面的节点总数。

  1. 二分查找: 时间复杂度是多少?

O(logn)

空间复杂度

空间复杂度与时间复杂度的情况类似,但是更加的简单,我们就直接用最简单的方法来分析即可, 如果你的代码包含了一维数组,传入n个元素, 那么空间复杂度就是 O(n), 如果开了二维数组, 长度都为n,那么空间复杂度基本上就为n², 如果有递归,那么递归最大的深度就是空间复杂度的最大债,如果递归和数组同时存在,则两者之间的最大值就是你的空间复杂度。

斐波那契数列数列递归求解法的空间复杂度分析

在上文递归状态图中我们看到,时间复杂度为O(2^n), 而这段代码没有用到数组,所以它的空间复杂度为 O(n).