算法的复杂度

266 阅读8分钟

衡量算法的好坏有很多标准, 其中最重要的两大标准是算法的时间复杂度和空间复杂度.

如实现一个功能:

大黄的代码运行一次要花100ms 占用内存5MB;

小灰的代码运行一次要花100s 占用内存500MB;

于是: 小灰,收拾东西"走人", 明天不用来了.

小灰的代码虽然也实现了功能, 但是存在两个严重的问题:

1. 运行时间长 2. 占用空间大

时间复杂度

基本执行次数

使用四个场景说明:

1. 一个长度为 10cm 的面包, 小灰每3min吃掉1cm, 那么吃掉整个面包需要多久?

答案为 3*10 即30min

如果面包的长度为n 则为 3 * n 即 3n min,使用函数记做 T(n)=3n;

2. 一个长度为 16cm 的面包, 小灰每 5min 吃掉面包剩余长度的一半, 即第1min 吃掉8cm, 第2min吃掉 4cm, 第3min吃掉 2cm.... 那么小灰把面包吃的只剩1cm, 需要多久呢?

使用数学表达式的方式就是, 数字16 不断除以2, 除几次后的结果等于1 使用对数表示, 即以2为底数16的对数 log216.

因此把面包吃的只剩下1cm 需要5 * log216 即20min

如果面包的长度为n cm, 则为 5 * log2n 即 5log2n, 记做 T(n)=5log2n;

3. 一个长度为 10cm 的面包和一个鸡腿, 小灰每2min 吃掉一个鸡腿, 那么小灰吃掉整个鸡腿需要多久?

答案为 2min, 因为这里只要求吃掉鸡腿, 和10cm的面包没有关系.

如果面包的长度为 ncm 呢? 自然为无论面包多长, 吃掉整个鸡腿的时间都是2min, 记做T(n)=2;

4. 一个长度为 10cm 的面包, 小灰吃掉第一个 1cm 需要 1min 时间, 吃掉第2个 1cm 需要 2min时间, 吃掉第3个1cm, 需要 3min时间...... 每吃1cm, 所花的时间都比上一个 1cm 多用 1min,那么吃掉整个面包需要多久?

答案为 从1累加到10的总和, 也就是55min.

如果面包的长度为 n cm呢?

根据高斯算法, 此时吃掉整个面包的时间需要 1 + 2 + 3 + 4 + ··· + (n - 1) + n = (n - 1) * n/2 min, 也就是 0.5n2 + 0.5n min, 记做 T(n)=0.5n2 + 0.5n;

则T(n)为程序的基本操作执行次数的函数(也可以认为是程序的相对时间函数),n为输入规模.

渐进时间复杂度

例如算法A的执行次数为 T(n)=100n, 算法B的执行次数为 T(n)=5n2, 这两个到底谁的运行时间更长一些呢? 这就要看n的取值了。

因此为了解决时间分析的问题就有个 渐进时间复杂度的概念, 其官方定义如下:

若存在函数f(n), 使得当n趋近于无穷大时, T(n)/f(n)的极限值为不等于零的常数, 则称f(n)是T(n)的通数量级函数, 记做T(n)=O(f(n)), O为算法的渐进时间复杂度, 简称为时间复杂度.

直白的讲,时间复杂度就是把程序的相对执行时间函数T(n)简化为一个数量级, 这个数量级可以是 n、n2、n3等.

如何推导出时间复杂度呢? 有以下几个原则:

如果运行时间是常数量级, 则用常数1表示

只保留时间函数中的最高阶项

如果最高阶项存在, 则省去最高阶项前边的系数

则之前的四个场景为:

场景1: T(n)=3n

最高阶为3n, 省去系数3, 则转换的时间复杂度为

T(n)=n

场景2: T(n)=5log2n

最高阶项为5log2n, 省去系数5, 则转换的时间复杂度为

T(n)=log2n

场景3: T(n)=2

只有常数量级, 则转换的时间复杂度为

T(n)=1

场景4:T(n)=0.5n2 + 0.5n

最高阶为0.5n2, 省去系数0.5 则转换时间复杂度为

T(n)=n2

这四种时间复杂度的算法究竟谁的执行时间更长,谁更节省时间, 当n的取值足够大的时候, 不难得出下边的结论:

1 < log2n < n < n2

时间复杂度的巨大差异

算法A的执行次数为 T(n)=100n, 时间复杂度为n

算法B的执行次数为 T(n)=5n2, 时间复杂度为n2

算法A运行在老旧的电脑上, 算法B运行在超级计算机上, 超级计算机的运行速度是老旧计算机的100倍, 那么随着输入规模n的增长, 两种算法谁的运行速度更快呢?

可以看到随着输入越来越大, 算法A的优势开始显现出来, 算法B则运行的越来越慢, 这就是不同的时间复杂度带来的差距.

空间复杂度

在运行一段程序时, 我们不仅要执行各种运算指令, 同时也会根据需要, 储存各种临时的中间数据, 以便后续的指令可以更加方便的执行,

例如: 给出如下所示的 n 个整数, 其中有两个整数是重复的, 要求找出两个重复的整数:

3, 1, 2, 4, 9, 7, 2

最朴素的方法就是双重循环, 具体如下:

遍历整个数列, 每遍历到一个新的整数就开始回顾之前遍历的所有整数, 看看这些整数有没有与之数值相同的:

1:遍历 3 前边没有数字, 所以无须回顾比较,

2:遍历整数 1,回顾之前的数字3, 没有发现重复数字,

3:遍历整数2, 回顾之前的数字3, 1 发现没有重复的数字,

.....

后续的步骤类似,一直遍历到最后一个整数2, 发现和之前的整数2 重复。

双重循环虽然能得出来结果,但是显然不是一个好的算法, 它的时间复杂度为n2

这里就可以利用中间数据以提高效率,

当遍历整个数列的时候,每遍历一个整数就把该整数存储起来, 就像放在字典中一样, 当遍历下一个整数的时候, 不必再往前回溯, 而是直接去字典中查找, 看看有没有对于的整数即可,

假设已经遍历了前边7个整数, 那么字典中存储的信息如下:

字典左侧key代表整数的值, 右侧value代表该整数出现的次数, 当遍历到最后一个整数2时, 从字典中可以轻松找到2曾经出现过

由于字典本身的时间复杂度为 1 , 所以整个算法的时间复杂度为n

这就是以空间换时间的做法, 但是, 内存空间是有限的, 在时间复杂度相同的情况下, 算法占用的内存空间自然越小越好, 如果描述一个算法占用内存空间的大小, 这就是 空间复杂度

和时间复杂度类似, 空间复杂度是对一个算法在运行过程中临时占用内存空间的大小的度量.

空间复杂度的计算

1. 常量空间

当算法的储存空间大小固定, 和出入规模没有直接关系的时候, 空间复杂度记做 1, 例如下边这段程序:

  function func1(n) {
      var a = 3
  }

2. 线性空间

当算法分配的空间是一个线性的集合(如数组), 并且集合大小和输入规模n成正比的时候, 空间复杂度记做 n, 例如下边这段程序:

  function func1(n) {
      var array = new Array(n);
  }

3. 二维空间

当算法分配的空间是一个二维数组集合,并且集合的长度和宽度都与输入规模n成正比, 空间复杂度记做 n2, 例如下边这段程序:

  function func1(n) {
      var array = new Array(n).fill(new Array(n));
      console.log(array);
  }

4. 递归空间

递归是一个比较特殊的场景, 虽然递归代码中没有显示的声明变量, 但是计算机在执行程序的时候, 会专门分配出一块内存, 用来储存"方法调用栈",

"方法调用栈" 包括进栈出栈两个行为

当进入一个新方法时, 执行入栈操作, 把调用方法的上下文压如栈中,

当方法返回时, 执行出栈操作, 把调用方法的上下文从栈中弹出,

下边这段程序是一个标准的递归程序:

  function func(n) {
      if (n <= 1) {
          return;
      }
      func(n - 1);
  }

假如传入的参数为5, 那么func参数n=5的调用信息先入栈,

接下来递归调用相同的方法, fun(参数n=4)的调用信息入栈

依次类推, 递归的越深, 入栈的元素就越多

当n=1时, 达到递归结束的条件, 执行return, 方法出栈

最终"方法调用栈"全部元素会一一出栈, 由此可以看出递归的深度是n, 则空间复杂度为n

摘要总结自: 漫画算法 小灰的算法之旅