数据结构与算法:复杂度分析

130 阅读7分钟

前言🌱

对于要学习数据结构与算法的同学来说,我们的出发点在于如何更好的处理问题,以达到合理、高效的目的。

合理就是某种数据结构与算法是适合于某个场景的,又或者他就是为了处理这个场景所进化而来的;高效则是在这些都合理的数据结构与算法中,我们应该有能力评判出那种组合可以更加的高效的解决问题。

通过我个人的学习经验来看,数据结构与算法的讨论往往围绕着如下的几点的复杂度来展开讨论:

  • 排序
  • 插入
  • 删除
  • 查找

一般来讲,排序的复杂度会从两个方面来考虑:时间复杂度空间复杂度;其他的则着重讨论时间复杂度

复杂度分析

实际上无论我们是否刻意的学习数据结构与算法,或者复杂度分析的方法,你的代码在写得越来越多的同时,自然而然也会有一种对性能的追求,比方说经常使用的关系型数据库的索引创建、尽量使用尾递归、更甚者是总是听到有人说代码不要那样写,那样会很慢!

通常来说,我自己会去真真的用代码执行一把,想办法搞明白为什么他会跟快。而这种通过执行代码来分析代码的方式就叫做事后统计法。事后统计法眼见为实,但是实际上他又受到了目标规模以及机器环境的影响,因此相对来说比较主观片面。

因此,数据结构与算法引入了大 O 复杂度表示法,他可以允许你在不执行代码的时候来粗略的评估算法的执行效率。

大 O 时间复杂度表示法

假设 CPU 执行一行代码的时间假设为 unit_time,并在此假设之上进行算法评估。

对于算法评估来讲,往往待评估的代码是与循环有关的,否则也无需评估,只需要数行数即可。

来对 for 循环 进行入门评估:

function foo (n) {
  let sum = 0
  for (let i = 0; i < n; i++) {
    sum += i
  }
}

分析该代码的执行时间其实也是在数行数

  • let sum = 0 占用 1 unit_time
  • for (...) 占用 1 unit_time
  • sum += i 占用 1 unit_time
    • 循环执行 n 次 所以 for(...) * nsum += 1 * n,也就是 2n * unit_time

所以最终执行时间为:T(n) = (2n + 1) * unit_time,可以看到当 n 越来越大时,其执行所消耗的时间也将会越来越大。

T(n):表示代码执行时间 n:表示数据规模大小 f(n):表示每行代码执行的次数总和

那么用 大 O 时间复杂度表示法可以这样写

T(n) = O(f(n))

大 O 时间复杂度实际上不是具体的执行时间,而是表示一种代码执行时间随着数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

当 n 的规模足够大的时候,对于 f(n) 中的低阶、常量、系数几乎不会左右增长的趋势,因此可以将其忽略。

比如:O(2n + 1) 可以忽略常量、系数记作 O(n);O(2n^2 + 2n + 3) 可以忽略低阶、系数、常量记作 O(n^2)。

大 O 时间复杂度分析技巧

既然在使用 大 O 时间复杂度表示法 时,可以忽略低阶、系数、常量。那么在分析代码的时候就可以提前忽略掉此部分,进而直接找到最耗时的操作来进行忽略后的评估。

  1. 直接看循环/递归,忽略常量级的执行时间
  2. 直接看更深的循环/递归,忽略低阶迭代

常见的时间复杂度

多项式量级(复杂度从低到高):

  1. O(1):常量阶__只要跟规模无关,代码再多也是常量级
  2. O(logn):对数阶
  3. O(n):线性阶
  4. O(nlogn):对数阶
  5. O(n^2):平方阶

非多项式量级:

  • O(2^n)
  • O(n!)

非多项式量级的的算法在问题规模逐渐增大的过程中会急剧的增加时间复杂度,也可能会出现无线的情况,因此非多项式量级的算法是低效的。

不同情况的复杂度分析

大 O 时间复杂度分析方法给出的公式代表了随着事件规模的变化,代码执行时间的变化趋势。所以在此基础之上,就可以分别对该趋势的最好最坏平均情况下的时间复杂度进行分析。

假设要从数组 array 中查找某个元素,由于是数组是无序的,所以可以根据下标逐个遍历比对,比对成功后返回该下标。

let array = [6, 3, 2, 10, 32, 5, 0, 12, 1]

查找算法:

function search (target) {
  for (let i = 0; i < array.length; i++) {
    if (array[i] === target) return i
  }
  return -1
}

最好情况时间复杂度

找 6 这个元素。

console.log(search(6)); // 0

很明显,一下就找到了,因为第一个就是 6,像这种没有比它更快的情况就是最好情况时间复杂度。用公式来表示的话就是:

O(1)

最坏情况时间复杂度

找 1 这个元素。

console.log(search(1)); // 8

查找 8 这个元素需要把整个数组都完全遍历一遍,所以时间复杂度为:

O(n)

平均情况时间复杂度

不难发现,有可能要查找的元素它并不存在于 array 当中,直观的考虑的话,存在于不存在发生的概率是 1/2,要么有要么没有,总之还是得把 n 个元素都遍历一遍。

那当元素存在于数组当中时,对于出现在各个位置的情况下需要遍历的次数分别为:

1, 2, 3, 4, ... n

那么现在把它们都加起来,然后求平均值

1 + 2 + 3 + 4 ... + n + n

那么前 1 ~ n 的和处以 n 就可以得出存在于数组中的元素的查找所需平均情况时间复杂度,最后的一个 n 是遍历结束后没有找到的那一次,然而对于不存在的情况而言,要和存在的情况相提并论,存在和不存在发生的概率是 1/2,因此:

存在时:

(1 + 2 + 3 + ... + n) / 2n

不存在时:

n / 2

把它们加起来:

(1 + n) * n + 2n^2 / 4n
->
1 + n + 2n / 4
->
3n + 1 / 4

如果忽略常量系数,那么结果就是:

O(n)

所以平均情况下的时间复杂度为 O(n)

均摊时间复杂度

均摊时间复杂度的使用要求比较高,大部分情况下时间复杂度很低,个别比较高,而且有规律的出现,这时候可以使用均摊时间复杂度分析,比如:

O(1) O(1) O(1) O(1) O(n) ...

首先是可以通过正常的平均时间复杂度的求法来计算一下:

T(n) = (n - 1 ) * 1 + n / n

最后忽略掉系数、常量,结果就是 O(1)。

均摊的意思就是不用这么麻烦的算,直接把第 n 个的时间复杂度摊在前 n - 1 次上,结果就是 O(1)。

结语🍓

最近在极客时间学习数据结构与算法,总地来说每一节都可以用自己的理解趟过去,但又不是那么的透彻。

写文章的目的更多的是为了表达、记录自己的理解,其中必然会有理解不到位的地方,但我总得说出来才知道自己是错误的,才可以改正。