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

本文介绍了算法分析中的复杂度概念,包括时间复杂度和空间复杂度,重点讲解了如何使用大O表示法来分析代码的时间复杂度,以及常见的时间复杂度类型。通过对代码示例的解析,阐述了时间复杂度分析技巧,如加法法则和乘法法则。
摘要由CSDN通过智能技术生成

前言

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

摘要:

一段代码所需执行时间和执行时需要的存储空间在算法中有统一的评价标准,被称为「复杂度」,分为「(渐进)时间复杂度」和「(渐进)空间复杂度」,都以大 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 个问题,我还没有在实际中大量使用复杂度分析,没办法体会及提出自己的解答,当使用复杂度分析一段时间后我会再回来更新这个问题的回答。


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

深度学习是机器学习的一个子领域,它基于人工神经网络的研究,特别是利用多层次的神经网络来进行学习和模式识别。深度学习模型能够学习数据的高层次特征,这些特征对于图像和语音识别、自然语言处理、医学图像分析等应用至关重要。以下是深度学习的一些关键概念和组成部分: 1. **神经网络(Neural Networks)**:深度学习的基础是人工神经网络,它是由多个层组成的网络结构,包括输入层、隐藏层和输出层。每个层由多个神经元组成,神经元之间通过权重连接。 2. **前馈神经网络(Feedforward Neural Networks)**:这是最常见的神经网络类型,信息从输入层流向隐藏层,最终到达输出层。 3. **卷积神经网络(Convolutional Neural Networks, CNNs)**:这种网络特别适合处理具有网格结构的数据,如图像。它们使用卷积层来提取图像的特征。 4. **循环神经网络(Recurrent Neural Networks, RNNs)**:这种网络能够处理序列数据,如时间序列或自然语言,因为它们具有记忆功能,能够捕捉数据中的时间依赖性。 5. **长短期记忆网络(Long Short-Term Memory, LSTM)**:LSTM 是一种特殊的 RNN,它能够学习长期依赖关系,非常适合复杂的序列预测任务。 6. **生成对抗网络(Generative Adversarial Networks, GANs)**:由两个网络组成,一个生成器和一个判别器,它们相互竞争,生成器生成数据,判别器评估数据的真实性。 7. **深度学习框架**:如 TensorFlow、Keras、PyTorch 等,这些框架提供了构建、训练和部署深度学习模型的工具和库。 8. **激活函数(Activation Functions)**:如 ReLU、Sigmoid、Tanh 等,它们在神经网络中用于添加非线性,使得网络能够学习复杂的函数。 9. **损失函数(Loss Functions)**:用于评估模型的预测与真实值之间的差异,常见的损失函数包括均方误差(MSE)、交叉熵(Cross-Entropy)等。 10. **优化算法(Optimization Algorithms)**:如梯度下降(Gradient Descent)、随机梯度下降(SGD)、Adam 等,用于更新网络权重,以最小化损失函数。 11. **正则化(Regularization)**:技术如 Dropout、L1/L2 正则化等,用于防止模型过拟合。 12. **迁移学习(Transfer Learning)**:利用在一个任务上训练好的模型来提高另一个相关任务的性能。 深度学习在许多领域都取得了显著的成就,但它也面临着一些挑战,如对大量数据的依赖、模型的解释性差、计算资源消耗大等。研究人员正在不断探索新的方法来解决这些问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值