复杂度评估
一、概述
在算法的设计当中,我们会去追求两个层面的目标:
1.找到解决问题的方法,程序的最终目的肯定是解决问题,不管是什么,能最终解决问题就算一种方法
2.在资源有限的情况下,我们需要对比各种方法的优劣,而最基础的评判标准版一般是两种:时间效率,也就是算法运行所需要的总时长;空间效率,也就是算法运行所占用的空间大小。
而我们工程师的最终目的都是设计出一种能又快又节省资源的方法去解决问题,而这就涉及到怎么去计算或者说评估一个算法的时间复杂度和空间复杂度了。
一般来说有两种方式来评估:
1.实际的去测试多次。取一个平均值或者中位数,这就是该算法需要的时间和空间,不过这种方式一般耗时耗力而且很难排除硬件的因素,可能需要大量的测试才能尽可能的准确判断算法所需资源。
2.理论估算。通过一些经验方法来计算该算法的理论耗时和占用空间,一般这种就叫复杂度分析,通过估算我们能够分析出算法运行所需的时间和空间增长趋势,最终将算法排序,选出综合最优的算法。克服了硬件的限制,更加方便,尤其是数据量很大的情况下。
二、常见的耗时和占用空间行为
1、循环
包括for和while两种循环,在分析耗时的时候,常常需要对这两种语法多加注意。
(一)for
一般是在知道循环多少次时使用
例如:
def hansome(n: int):
for i in range(n):
print('我是海鸥')
这就是一个最简单的for循环函数,循环几次取决于用户会输入几,操作量与输入的n成正比,事实上这就是一种线性关系 操作次数 = 输入数
(二)while
一般是不清楚有多少次时使用,或者时达成某种条件时使用,当然for能做到的while也能做到,它的自由度要更高一些
例如:
def hansome(n):
i = 0
while i < n:
print('我是海鸥')
和上面的for循环一样,作量与输入的n成正比, 操作次数 = 输入数
(三)嵌套循环
字面意思,就是两个循环嵌套在一起,
例如:
def hansome(n):
for i in range(n):
print('我是海鸥')
for j in range(n):
print('要去整点薯条')
for里面还有个for,外部循环执行一次,内部循环执行n次,也就是说操作次数 = 输入数²,while与for类似,此处就不再演示了,我们可以明显感受到,这里如果数据量大的话,运行速度会远低于上面的单次循环,这也就我们后面要讲的时间复杂度分析。
2、新建储存容器
在新建容器时,一定会在内存中开辟一个新的空间,只要在里面存储数据就会占用额外的内存,一个设备的内存有限,所以在程序运行过程中,也不能开辟太多额外的容器。
一般存储多少内容取决于容器的深度,也就是容器的维度。
三、时间复杂度
用于衡量算法运行时间随着数据量变大时的增长趋势
程序中的每个语句运行都需要时间,我们可以假设普通语句需要1s(假设,实际不会需要这么久)
那么
def hansome(n: int):
print('薯条交出来')
for i in range(n):
print('我是海鸥')
此时需要n + 1s去执行这个程序,可以利用数学的思维来理解 y = x + 1,而当我们n的数字足够大时,1相对于n就可以被忽略了 ,那么也就是x + 1 约等于 x,也就是说只要有一定数据量,可以近似于我们用了n s去执行该程序。
那么问题来了,当程序如下时,时间复杂度该是多少呢
def hansome(n):
print('兄弟')
for i in range(n):
print('我们要飞向何方')
for i in range(n):
print('我是海鸥')
for j in range(n):
print('要去整点薯条')
我们依旧可以利用数学的思维 y = x²+x+1,此时需要n²+n+1 s,我们依旧是以n是一个极大的数字去思考,当n很大时,n²的数字是远远大于n和1的,于是,此时我们可以近似该程序用了n² s去执行。
说到这里,读者应该能意识到,一般我们计算时间复杂度时,会把所需要的时间长度输出为一个数学函数式,再将式子中增长速度远快于其他部分的式子提出来,此时该程序的时间复杂度就是该被提出来的式子。
于是估算就变得简单起来了,我们只需要每次估算最耗时的那部分代码耗时多少,基本就可以知道该程序的时间复杂度为多少,而我们需要掌握的就是哪些是数字大的,哪些是数字小的。
根据数学知识易得:
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
而复杂度一般用O符号表示,也就是
O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(2的n次方) < O(n!)
下面列举几个常见的算法,并列出他的时间复杂度信息。
'''
二分查找--需要处理的数据已经是排序状态,一般结合排序使用
查数据的范围每次会缩减一半,在数学上对应的是y = logx,那么也就是说该算法的时间复杂度为O(logn)
'''
def find(target, nums):
left = 0
right = len(nums) - 1
while left <= right:
mid = left + right // 2
if target <= nums[mid]:
right = mid
else:
left = mid
return mid
'''
冒泡排序--对数据进行排序
此时要分最好情况和最差情况,在最好的情况下,数组已经是排序好的了,利用flag标识符的作用,冒泡算法可以直接结束,不需要再经历循环;但是如果最坏的时候,每个元素都不是顺序的,此时就需要两层循环一直作用,直到完成排序为止,此时的时间复杂度为O(n²)。在实际工作当值,最佳的时间复杂度往往难以实现,而最差时间复杂度却可以体现出该算法的运行效率问题,所以我们取最大时间复杂度来分析,所以冒泡排序的时间复杂度为O(n²)。
'''
def bubble_sort(nums: int):
for i in range(len(nums)):
flag = True
for j in range(len(nums) - i - 1):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
flag = Flase
if flag:
break
四、空间复杂度
用于衡量算法占用内存空间随着数据量变大时的增长趋势
算法在运行过程中,一般会使用的内存空间主要包括:
-
输入空间,包括存储算法的输入数据
-
暂存空间:包括算法在运行过程中的变量、对象、函数上所使用的空间
-
输出空间:包括存储算法的输出数据
其中暂存空间又分为如下几种:
- 暂存数据:包括运行过程中的各种常量、变量、对象等
- 栈帧空间:包括保存调用函数的上下文数据,系统在每次调用函数的时候都会在栈顶建立一个栈帧空间,在函数返回后,空间会被释放掉
- 指令空间:包括编译后的程序指令,在实际的统计中基本可以忽略不记
也就是说:我们通常说的空间复杂度一般只包括:暂存数据、栈帧空间和输出空间。
与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
最差空间复杂度中的“最差”有两层含义。
- 以最差输入数据为准:当 n<10 时,空间复杂度为 O(1) ;但当 n>10 时,初始化的数组 nums 占用 O(n) 空间,因此最差空间复杂度为 O(n) 。
- 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 O(1) 空间;当初始化数组 nums时,程序占用 O(n) 空间,因此最差空间复杂度为 O(n)
def algorithm(n: int):
a = 0 # O(1)
b = [0] * 10000 # O(1)
if n > 10:
nums = [0] * n # O(n)
我们依旧是从线性阶开始分析,有一点python基础的同学知道,一般存储数据的除了常规的int,float等变量,还有像列表,字典这类的容器,这类容器在一维存储数据时会占用部分空间,例如:
def list1(n: int):
nums = [0] * n
hash_map = dict[int, str]()
for i in range(n):
hash_map[i] = str(i)
此时列表nums和字典hash_map都占用O(n)空间
那么聪明的读者肯定意识到了,如果此时数组和字典是一个二维的嵌套呢,存储空间还会是n吗?此时我们可以借用时间复杂度的计算方式,当有嵌套产生时一般是内部的复杂度*外部的复杂度 = 总的复杂度,也就是说,如果是多维的容器,只需要一层一层的分析,再相乘就可以。
与时间复杂度类似,空间复杂度的大小排序也是
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(2的n次方) < O(n!)
五、总结
在最理想的情况下,时间复杂度和空间复杂度都达到最优,那算法自然是好算法,但是实际工作当中想要同时达到最佳是非常困难的事情,一般情况下都是降低一个复杂度,提升另一个复杂度,称为空间换时间或者时间换空间,不过具体需要哪种方式,就需要我们程序员结合项目以及和产品or项目经理沟通再去选择了。