事后统计法

代码跑一遍,然后统计时间,操作简单,但是测试非常依赖环境,且测试结果受数据规模的影响较大。

大 O 复杂度表示法

所有代码的执行事件 T(n) 与每行代码的执行次数 f(n) 成正比。

复杂度分析 - 图1

T 代表代码的执行时间,n 表示数据规模,f 表示每行代码的执行次数总和,O 表示代码的执行时间与执行次数之和成正比。

大 O 时间复杂度实际并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。也叫渐进时间复杂度。

时间复杂度分析

只关注循环执行次数最多的一段代码

大 O 表示法代表变化趋势,通常忽略公式中的常量,低阶,系数,只需要记录最大阶的量级就可以,所以,我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

加法法则

  1. int cal(int n) {
  2. int sum_1 = 0;
  3. int p = 1;
  4. for (; p < 100; ++p) {
  5. sum_1 = sum_1 + p;
  6. }
  7. int sum_2 = 0;
  8. int q = 1;
  9. for (; q < n; ++q) {
  10. sum_2 = sum_2 + q;
  11. }
  12. int sum_3 = 0;
  13. int i = 1;
  14. int j = 1;
  15. for (; i <= n; ++i) {
  16. j = 1;
  17. for (; j <= n; ++j) {
  18. sum_3 = sum_3 + i * j;
  19. }
  20. }
  21. return sum_1 + sum_2 + sum_3;
  22. }

综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就为 O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。那我们将这个规律抽象成公式就是:

复杂度分析 - 图2

乘法法则

复杂度分析 - 图3

常见的时间复杂度示例分析

3723793cc5c810e9d5b06bc95325bf0a.webp
对于罗列的复杂度量级,我们可以粗略地分为两类,多项式量级非多项式量级。其中,非多项式量级只有两个:复杂度分析 - 图5复杂度分析 - 图6。我们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题。

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。接下来主要来看几种常见的多项式时间复杂度。

O(1)

首先要明确,O(1) 只是常量级时间复杂度的一种表示方法,并不代表只执行一行代码。一般情况下,一般情况下只要算法中没有循环语句,递归语句,即使有成千上万行代码,时间复杂度也是 O(1)。

O(logn)、O(nlogn)

  1. i=1;
  2. while (i <= n) {
  3. i = i * 2;
  4. }
  1. 上述代码的时间复杂度为 ![](https://cdn.nlark.com/yuque/__latex/05125548613963d43e5e41594d023cc9.svg#card=math&code=O%28%5Clog_%7B2%7Dn%29&id=A9TBA),根据换底公式 ![](https://cdn.nlark.com/yuque/__latex/67542487495c7c5da84ac66d2f3a622c.svg#card=math&code=%5Clog_%7Ba%7D%20%3D%20%20%5Cfrac%7B%5Clog_%7Bc%7Db%7D%7B%5Clog_%7Bc%7Da%7D&id=GIeFz)我们就可以把所有对数阶的时间复杂度都记为 ![](https://cdn.nlark.com/yuque/__latex/94afa3358dad08ced8fe53fdfa9f332d.svg#card=math&code=O%28%5Clog%7B%7Dn%29&id=WD83O)
  1. for(int j = 0;j < n;j++){
  2. i=1;
  3. while (i <= n) {
  4. i = i * 2;
  5. }
  6. }

如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。

O(m+n)、O(m*n)

这里主要针对代码的复杂度由两个数据的规模决定的情况。

  1. int cal(int m, int n) {
  2. int sum_1 = 0;
  3. int i = 1;
  4. for (; i < m; ++i) {
  5. sum_1 = sum_1 + i;
  6. }
  7. int sum_2 = 0;
  8. int j = 1;
  9. for (; j < n; ++j) {
  10. sum_2 = sum_2 + j;
  11. }
  12. return sum_1 + sum_2;
  13. }
  1. 无法评估 m n 的量级大小,就不能在简单的忽略掉其中一个,所以上述代码的时间复杂度为 ![](https://cdn.nlark.com/yuque/__latex/977c4277c2da10f15b70112888221a9f.svg#card=math&code=O%28m%2Bn%29&id=wk3pt),之前的加法法则不再适用,但是乘法法则依然适用:<br />![](https://cdn.nlark.com/yuque/__latex/e3410f8eeb3ce4eaefd96cb9ade85a1b.svg#card=math&code=T1%28m%29%20%2B%20T2%28n%29%20%3D%20O%28f%28m%29%20%2B%20g%28n%29%29&id=icn3b)

空间复杂度分析

时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。

时间复杂度的几种类型

最好,最坏时间复杂度

  1. // n表示数组array的长度
  2. int find(int[] array, int n, int x) {
  3. int i = 0;
  4. int pos = -1;
  5. for (; i < n; ++i) {
  6. if (array[i] == x) {
  7. pos = i;
  8. break;
  9. }
  10. }
  11. return pos;
  12. }
  1. 如果数组中第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 O(1)。但如果数组中不存在变量 x,那我们就需要把整个数组都遍历一遍,时间复杂度就成了 O(n)。所以,不同的情况下,这段代码的时间复杂度是不一样的。

最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度;最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。

平均情况时间复杂度

依然使用上面的例子,查找 x 共有 n+1 中情况,在数组的 0~n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:

复杂度分析 - 图7

简化公式可得平均时间复杂度就是 O(n)。

这里的计算没有问题,但是整体思路不严谨,因为 x 在或者不在数组内,概率是不一样的,假设在数组内的概率为 1/2 :

复杂度分析 - 图8

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。

只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

均摊时间复杂度

  1. // array表示一个长度为n的数组
  2. // 代码中的array.length就等于n
  3. int[] array = new int[n];
  4. int count = 0;
  5. void insert(int val) {
  6. if (count == array.length) {
  7. int sum = 0;
  8. for (int i = 0; i < array.length; ++i) {
  9. sum = sum + array[i];
  10. }
  11. array[0] = sum;
  12. count = 1;
  13. }
  14. array[count] = val;
  15. ++count;
  16. }

这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,我们用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

分析易得,最好和平均时间复杂度为 O(1) 最坏时间复杂度为 O(n)。

首先大多数情况下,insert 函数的时间复杂度都是 O(1) 只有极特殊情况下才会到达 O(n),其次,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

所以这里就不必再像平均时间复杂度那样找出所有情况以及发生的概率,然后计算加权平均值,而是引入了摊还算法,计算得到的时间复杂度叫做均摊时间复杂度。

还是继续看在数组中插入数据的这个例子。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系时,就可以考虑均摊算法。在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。