数据结构与算法之美笔记——复杂度分析(上)

前言

关于算法的笔记我调整了一下书写的方式,接下来的笔记我都会以总结开篇,通过自己对总结的发问倒推详情,最后以解答老师的思考题结束。

摘要:

一段代码所需执行时间和执行时需要的存储空间在算法中有统一的评价标准,被称为「复杂度」,分为「(渐进)时间复杂度」和「(渐进)空间复杂度」,都以大 O 表示法表示。

对于上述摘要,初看的人会对几个名词疑惑不解。时间复杂度和空间复杂度是什么,大 O 表示法如何表示复杂度,接下来对这几个疑问进行讲解。

时间复杂度

时间复杂度通俗说就是一段代码执行的时间消耗,但计算一段代码的执行时间最直观的方法是执行代码后输出执行时间,对这种方法有个称呼,叫做「事后统计法」。这种方法直观也方便,但存在一定的问题,这样统计的代码执行时间与具体的外部环境关系较大,不同的硬件环境等都会对执行时间的统计结果造成影响,而时间复杂度对代码的执行时间的统计是脱离外部环境影响的,更加客观。

如何分析代码时间复杂度

首先我们以一段代码为例:

int sum(int n) {
	int i = 0;
	int sum = 0;
	for(; i < n; i++) {
		sum += i;
	}

	return sum;
}

以上的代码都可以总结为「取数->运算->存数」,虽然硬件环境会导致每行代码的执行时间有差异,但我们假设每行代码的执行时间都相同,并且为 unit_time。现在来分析一下这段代码的总执行时间 T,第 2、3 行代码执行 1 次,执行时间都为 unit_time,第 4、5 行代码都循环执行了 n 遍,所以执行时间都为 n × u n i t _ t i m e n\times unit\_time n×unit_time,所以这段代码的总执行时间满足下列公式。

T = 2 × u n i t _ t i m e + 2 × n × u n i t _ t i m e T=2\times unit\_time+2\times n\times unit\_time T=2×unit_time+2×n×unit_time

再以同样的方式分析下一段代码

int sum(int n) {
   int i = 0;
   int j = 0;
   int sum = 0;
   for(; i < n; i++) {
   	j = 0;
   	for(; j < n; j++) {
   		sum = sum + i * j;
   	}
   }
   return sum;
}

第 2, 3, 4 行代码执行时间都是 unit_time,第 5, 6 行代码执行都为 n × u n i t _ t i m e n\times unit\_time n×unit_time,第 7, 8 行代码循环了 n × n n\times n n×n 遍,所以执行时间都为 n 2 × u n i t _ t i m e n^2\times unit\_time n2×unit_time,这段代码的总执行时间可以表示为下列公式。

T = 3 × u n i t _ t i m e + 2 × n × u n i t _ t i m e + 2 × n 2 × u n i t _ t i m e T = 3\times unit\_time + 2\times n\times unit\_time + 2\times n^2\times unit\_time T=3×unit_time+2×n×unit_time+2×n2×unit_time

可以从分析代码的时间复杂度看出,总执行时间是随着 n (数据规模)的增长而变化的,所以才会称为「渐进时间复杂度」。

大O表示法

时间复杂度需要用大 O 表示法表示,大 O 表示法的公式如下

T ( n ) = O ( f ( n ) ) T(n) = O(f(n)) T(n)=O(f(n))
$ T(n) $ 是执行时间
f ( n ) f(n) f(n) 是计算执行时间的公式
O O O 表示两者之间成正比关系

可以将之前两段分析的时间复杂度表示为

T 1 ( n ) = O ( 2 + 2 n ) T_1(n) = O(2 + 2n) T1(n)=O(2+2n)
T 2 ( n ) = O ( 3 + 2 n + 2 n 2 ) T_2(n) = O(3 + 2n + 2n^2) T2(n)=O(3+2n+2n2)

当数据规模(也就是 n )足够大时,常量、系数和低阶项几乎不会影响总执行时间的变化,可以省略,上列两个公式就可以表示为如下:

T 1 ( n ) = O ( n ) T_1(n) = O(n) T1(n)=O(n)
T 2 ( n ) = O ( n 2 ) T_2(n) = O(n^2) T2(n)=O(n2)

这就是用大O表示法表示的时间复杂度。

时间复杂度分析技巧

了解了时间复杂度的分析,加上几个小技巧可以更快捷方便地分析出一段代码的时间复杂度,接下来我们介绍一下这几个时间复杂分析的技巧。

关注时间复杂度最大的一段代码,时间复杂度的加法法则

标题显示虽然是两个技巧,其实是同一个意思。因为大 O 表示法会省去系数、常量和低阶项,所以最大时间复杂度的一段代码就是整段代码的时间复杂度,时间复杂度的加法法则则是对这一阐述的数学表达,加法法则可表达为:

T 1 ( n ) = O ( f ( n ) ) T_1(n) = O(f(n)) T1(n)=O(f(n))
T 2 ( n ) = O ( g ( n ) ) T_2(n) = O(g(n)) T2(n)=O(g(n))
T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) T(n) = T_1(n) + T_2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n))) T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))

用下列代码举例:

int sum(int n) {
	int i = 0;
	int j = 0;
	int sum = 0;
	for(; i < 100; i++) {
		sum += i;
	}

	for(; j < n; j++) {
		sum += j;
	}
	return sum;
}

除了两个 for 循环外其他行代码执行次数都为 1 次,而两个 for 循环的时间复杂度分别为 O ( 1 ) O(1) O(1) O ( n ) O(n) O(n),根据加法法则,最终整段代码的时间复杂度为 O ( n ) O(n) O(n)。注意,所有常量级的时间复杂度都表示为 O ( 1 ) O(1) O(1),只要明确执行次数,不论 10000,100000 都是 O ( 1 ) O(1) O(1)

乘法法则:嵌套循环的时间复杂度为内外循环的时间复杂度乘积

先上数学表达式

T 1 ( n ) = O ( f ( n ) ) T_1(n) = O(f(n)) T1(n)=O(f(n))
T 2 ( n ) = O ( g ( n ) ) T_2(n) = O(g(n)) T2(n)=O(g(n))
T ( n ) = T 1 ( n ) × T 2 ( n ) = O ( f ( n ) ) × O ( g ( n ) ) = O ( f ( n ) × g ( n ) ) T(n) = T_1(n) \times T_2(n) = O(f(n)) \times O(g(n)) = O(f(n) \times g(n)) T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))

代码实例如下:

int sum(int n) {
	int i = 0;
	int j = 0;
	int sum = 0;
	for(; i < n; i++) {
		for(j = 0; j < n; j++) {
			sum = sum + i * j;
		}
	}
	return sum;
}

这段代码有一段是嵌套循环,内外两个循环的时间复杂度都为 O ( n ) O(n) O(n),所以整段嵌套循环的时间复杂度就为 O ( n ) × O ( n ) = O ( n 2 ) O(n) \times O(n) = O(n^2) O(n)×O(n)=O(n2)

常见的时间复杂度

时间复杂度中有一些是较为常见的,接下来看一下这几种常见的时间复杂度。

O(1)

O ( 1 ) O(1) O(1) 表示可确定执行次数的时间复杂度,如:

int sum(n) {
	int i = 0;
	int sum = 0;
	for(; i < 100; i++) {
		sum += i;
	}
	
	return sum;
}

第 4、5 行代码可确定的执行100次,所以时间复杂度为 O ( 1 ) O(1) O(1),即使是 10000 次也是可确定的循环次数,也是 O ( 1 ) O(1) O(1)

O(logn)、O(nlogn)

我们先尝试分析如下代码:

int sum(int n) {
	int sum = 0;
	for(int i = 0; i < n; i = i * 2;) {
		sum += i;
	}

	return sum;
}

假设循环中执行了 k 次使 i 小于等于 n,也即跳出循环,那 k 的最小情况可表示为如下式子

2 k = n 2^k = n 2k=n
所以 k = log ⁡ 2 n k = \log_2n k=log2n

同理如果第3行代码的 i = i × 2 i = i \times 2 i=i×2 修改为 i = i × 3 i = i \times 3 i=i×3,那时间复杂度就为 O ( log ⁡ 3 n ) O(\log_3n) O(log3n),但对数间可以相互转化。

log ⁡ 3 n = log ⁡ 3 2 × log ⁡ 2 n \log_3n = \log_3{2} \times \log_2{n} log3n=log32×log2n
所以 O ( log ⁡ 3 n ) = O ( log ⁡ 3 2 × log ⁡ 2 n ) O(\log_3n) = O(\log_3{2} \times \log_2n) O(log3n)=O(log32×log2n)
省略常量可以写为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

所以这样的时间复杂度都写为 O ( log ⁡ n ) O(\log{n}) O(logn)。那 O ( n log ⁡ n ) O(n\log{n}) O(nlogn) 便是上一段代码中的循环外再嵌套执行 n 次的循环。

O(m+n),O(m*n)

当一段代码的执行时间会被多个数据规模的变化影响时,可以使用这样的大 O 表示法。例如:

int sum(int m, int n) {
	int sum = 0;
	for(int i = 0; i < m; i++) {
		sum += i;
	}

	for(int i = 0; i < n; i++) {
		sum += i;
	}
	return sum;
}

这段代码中的两段 for 循环时间复杂度分别为 O ( m ) O(m) O(m) O ( n ) O(n) O(n),要分析总的时间复杂度需要使用加法,此时之前提到的加法法则不能套用了,因为影响两个 for 循环的是两个不同的数据规模,这段代码的总时间复杂度可以表示为 T = O ( m ) + O ( n ) = O ( m + n ) T = O(m) + O(n) = O(m + n) T=O(m)+O(n)=O(m+n)

O ( m × n ) O(m \times n) O(m×n) 就是表示嵌套的两个 for 循环,分别执行 m 次和 n 次,如下代码:

int sum(int m, int n) {
	int i = 0;
	int j = 0;
	int sum = 0;
	for(; i < m; i++) {
		for(j = 0; j < n; j++) {
			sum = sum + m * n;
		}
	}
	return sum;
}

常见的几种时间复杂度从低阶到高阶有 O ( 1 ) , O ( log ⁡ n ) , O ( n ) , O ( n log ⁡ n ) , O ( n 2 ) O(1),O(\log n),O(n),O(n\log n),O(n^2) O(1)O(logn)O(n)O(nlogn)O(n2)

空间复杂度

空间复杂度与时间复杂度相似,空间复杂度描述的是数据规模的增长引起算法的存储空间的变化,分析如下代码:

int[] getIndex(int n) {
	int i = 0;
	int[] a = new int[n];
	for(; i < n; i++) {
		a[i] = i;
	}
	return a;
}

第 2, 3 行代码都申请了存储空间,但第 2 行代码的存储空间大小与 n 无关,属于常量,第 3 行数组 a 新申请了大小为 n 的存储空间,其他代码没有占用更多空间,所以这段代码的空间复杂度为 O ( n ) O(n) O(n)

解答提问

王争老师在专栏最后提出了两个问题。

  1. 项目中会进行性能测试,再进行时间复杂度和空间复杂度的分析是否多此一举?
  2. 每段代码都进行时间复杂度和空间复杂度分析是否很浪费时间?

对于第 1 个问题,在时间复杂度介绍开始时已经讲过,性能测试也会被硬件等外部环境影响,而复杂度的分析可以相对客观从代码方面得到执行时间和存储空间占用的趋势情况。至于第 2 个问题,我还没有在实际中大量使用复杂度分析,没办法体会及提出自己的解答,当使用复杂度分析一段时间后我会再回来更新这个问题的回答。


文章中如有问题欢迎留言指正
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值