最近做项目会遇到很多关于时间复杂度和空间复杂度的问题,因此我想在这里做一个整理。
首先我们先了解什么是算法,算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。但是对于同一个问题,我们去使用不同的算法,结果或许会一样,但不同的地方就在于你所用算法所耗费的资源和时间,此篇博客就是用于去衡量不同算法的优劣。
是指执行当前算法所消耗的时间,我们通常用时间复杂度来描述。
算法执行时间需通过依据该算法编制的程序在计算机上运行时所消耗的时间来度量。而度量一个程序的执行时间通常有两种方法:
这种方法可行,但不是一个好的方法。该方法有两个缺陷:一是要想对设计的算法的运行性能进行评测,必须先依据算法编制相应的程序并实际运行;二是所得时间的统计量依赖于计算机的硬件、软件等环境因素,有时容易掩盖算法本身的优势。
因事后统计方法更多的依赖于计算机的硬件、软件等环境因素,有时容易掩盖算法本身的优劣。因此人们常常采用事前分析估算的方法。
在编写程序前,依据统计方法对算法进行估算。一个程序在计算机上运行时所消耗的时间取决于下列因素:
(1) 算法采用的策略、方法;(2).编译产生的代码质量;(3) 问题的输入规模;(4)机器执行指令的速度。
一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。并且一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。
在刚才提到的时间频度中,n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。 一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
大O符号表示法
首先我们先看一个例子
以下例子为C语言
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
通过「 大O符号表示法 」,这段代码的时间复杂度为:O(n) ,为什么呢?
在 大O符号表示法中,时间复杂度的公式是: T(n) = O( f(n) ),其中f(n) 表示每行代码执行次数之和,而 O 表示正比例关系,这个公式的全称是:算法的渐进时间复杂度。
我们继续看上面的例子,假设每行代码的执行时间都是一样的,我们用 1颗粒时间 来表示,那么这个例子的第一行耗时是1个颗粒时间,第三行的执行时间是 n个颗粒时间,第四行的执行时间也是 n个颗粒时间(第二行和第五行是符号,暂时忽略),那么总时间就是 1颗粒时间 + n颗粒时间 + n颗粒时间 ,即 (1+2n)个颗粒时间,即: T(n) = (1+2n)*颗粒时间,从这个结果可以看出,这个算法的耗时是随着n的变化而变化,因此,我们可以简化的将这个算法的时间复杂度表示为:T(n) = O(n)
为什么可以这么去简化呢,因为大O符号表示法并不是用于来真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的。
所以上面的例子中,如果n无限大的时候,T(n) = time(1+2n)中的常量1就没有意义了,倍数2也意义不大。因此直接简化为T(n) = O(n) 就可以了。
常见的时间复杂度量级
常数阶O(1)
对数阶O(logN)
线性阶O(n)
线性对数阶O(nlogN)
平方阶O(n2)
立方阶O(n3)
K次方阶O(nk)
指数阶(2n)
上面从上至下依次的时间复杂度越来越大,执行的效率越来越低。
下面选取一些常见的进行讲解
常数阶O(1)
无论代码执行了多少行,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是O(1),如:
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
上述代码在执行的时候,它消耗的时候并不随着某个变量的增长而增长,那么无论这类代码有多长,即使有几万几十万行,都可以用O(1)来表示它的时间复杂度。
线性阶O(n)
这个在最开始的代码示例中就讲解过了,如:
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
这段代码,for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用O(n)来表示它的时间复杂度。
对数阶O(logN)
int i = 1;
while(i<n)
{
i = i * 2;
}
从上面代码可以看到,在while循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。我们试着求解一下,假设循环x次之后,i 就大于 2 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 x = log2^n
也就是说当循环 log2^n 次以后,这个代码就结束了。因此这个代码的时间复杂度为:O(logn)
线性对数阶O(nlogN)
线性对数阶O(nlogN) 其实非常容易理解,将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是 n * O(logN),也就是了O(nlogN)。
就拿上面的代码加一点修改来举例:
for(m=1; m<n; m++)
{
i = 1;
while(i<n)
{
i = i * 2;
}
}
平方阶O(n2)
平方阶O(n²) 就更容易理解了,如果把 O(n) 的代码再嵌套循环一遍,它的时间复杂度就是 O(n²) 了。
举例:
for(x=1; i<=n; x++)
{
for(i=1; i<=n; i++)
{
j = i;
j++;
}
}
这段代码其实就是嵌套了2层n循环,它的时间复杂度就是 O(n*n),即 O(n²)
如果将其中一层循环的n改成m,即:
for(x=1; i<=m; x++)
{
for(i=1; i<=n; i++)
{
j = i;
j++;
}
}
那它的时间复杂度就变成了 O(m*n)
立方阶O(n³)、K次方阶O(n^k)
参考上面的O(n²) 去理解就好了,O(n³)相当于三层n循环,其它的类似。
除此之外,其实还有 平均时间复杂度、均摊时间复杂度、最坏时间复杂度、最好时间复杂度 的分析方法,有点复杂,这里就不展开了。
类似于时间复杂度的讨论,一个算法的空间复杂度(Space Complexity)S(n)定义为该算法所耗费的存储空间,它也是问题规模n的函数。渐近空间复杂度也常常简称为空间复杂度。
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。
空间复杂度比较常用的有:O(1)、O(n)、O(n²),我们下面来看看:
空间复杂度O(1)
如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
举例:
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)
空间复杂度O(n)
int[] m = new int[n]
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)
list.copy():copy操作需要将数组中的元素全部赋值给一个新的list,因此平均和最坏时间复杂度都是O ( n )
list.append(obj):append操作只需要在list尾部添加元素,不需要遍历整个list,因此平均和最坏时间复杂度都是O ( 1 )
list.pop(index):
当index = -1时,pop操作类似append,它只需要考虑list尾部的元素,因此平均和最坏时间复杂度都是O ( 1 )
当index位于[ 0 , l e n ( l ) ] [0, len(l)][0,len(l)]之内时,pop操作需要找到弹出元素的索引,因此平均和最坏时间复杂度都是O ( k )
list.insert(index, obj):插入元素需要遍历list先找到需要插入的位置,因此平均和最坏时间复杂度都是O ( n )
Get Item 、Set Item:获取或更新指定索引位置的元素只需要直接到该位置操作即可,因此平均和最坏时间复杂度都是O ( 1 )
list.remove(obj):移除指定的元素需要遍历整个list,因此平均和最坏时间复杂度都是O ( n )
list.extend(seq):extend操作在尾部扩展list,平均和最坏时间复杂度都是O ( k )
list.sort():sort操作这里使用的是归并排序(merge sort),因此平均和最坏时间复杂度都是O ( n log n )
x in s:最优情况是寻找的元素是list的第一个元素,最坏情况是寻找的元素是list的最后一个元素,因此平均时间复杂度O ( n )
min(s), max(s):时间复杂度为O ( n )
集合(set)是一个无序的不重复元素序列。可以使用大括号 { } 或者 set() 函数创建集合,注意:创建一个空集合必须用 set() 而不是 { },因为 { } 是用来创建一个空字典。
set内置的各种函数:
add():添加元素
clear():清空集合
copy():拷贝一个集合
difference:返回多个集合的差集
difference_update():移除集合中的元素,该元素在指定的集合也存在
discard(obj):删除集合中指定的元素
intersection(set1, set2 … etc):返回两个或更多集合中都包含的元素,即交集,返回一个新集合
intersection_update():返回两个或更多集合中都包含的元素,在原始的集合上移除不重叠的元素
set.isdisjoint(set2):判断两个集合是否包含相同的元素,如果没有返回 True,否则返回 False
set.issubset(set2):判断指定集合是否为该方法参数集合的子集
issuperset():判断该方法的参数集合是否为指定集合的子集
pop():随机移除元素
remove(obj):移除指定元素
symmetric_difference():返回两个集合中不重复的元素集合
symmetric_difference_update():移除当前集合中在另外一个指定集合相同的元素,并将另外一个指定集合中不同的元素插入到当前集合中
union():返回两个集合的并集
update:给集合添加元素
各种操作的时间复杂度:
Operation | Average case | Worst Case | notes |
---|---|---|---|
x in s | O(1) | O(n) | |
Union s/t | O(len(s)+len(t)) | ||
Intersection s&t | O(min(len(s), len(t)) | O(len(s) * len(t)) | replace “min” with “max” if t is not a set |
Multiple intersection s1&s2&…&sn | (n-1)*O(l) where l is max(len(s1),…,len(sn)) | ||
Difference s-t | O(len(s)) | ||
s.difference_update(t) | O(len(t)) | ||
Symmetric Difference s^t | O(len(s)) | O(len(s) * len(t)) | |
s.symmetric_difference_update(t) | O(len(t)) | O(len(t) * len(s)) |
字典(dict)是另一种可变容器模型,且可存储任意类型对象。字典的每个键值(key=>value)对用冒号(:)分割,每个对之间用逗号(,)分割,整个字典包括在花括号(**{})**中
字典的两大特性:
不允许同一个键出现两次
键必须不可变,所以可以用数字,字符串或元组充当,而用列表就不行
dict内置的函数和方法:
len(dict):计算字典元素个数
str(dict):输出字典,一颗打印的字符串表示
dict.clear():删除字典内的所有元素
dict.copy():返回字典的浅复制
dict.fromkeys():创建一个新字典,以序列seq中元素做字典的键,val为字典所有键对应的初始值
dict.get(key, default = None):返回指定键的值,如果值不在字典中返回default值
key in dict:如果键在字典dict里返回true,否则返回false
dict.items():以列表返回可遍历的(键, 值) 元组数组
dict.keys():返回一个迭代器,可以使用 list() 来转换为列表
dict.update(dict2):把字典dict2的键/值对更新到dict里
dict.values():返回一个迭代器,可以使用 list() 来转换为列表
dict.pop(key, default):删除字典给定键 key 所对应的值,返回值为被删除的值。key值必须给出。 否则,返回default值
popitem():随机返回并删除字典中的最后一对键和值
各种操作的时间复杂度:
操作 | 平均时间复杂度 | 最坏时间复杂度 |
---|---|---|
Copy | O(n) | O(n) |
Get Item | O(1) | O(n) |
Set Item | O(1) | O(n) |
Delete Item | O(1) | O(n) |
Iteration | O(n) | O(n) |
deque是collection中表示双端队列的数据结构,它常用的方法有:
append():在队列右端添加元素
appendleft():在队列左端添加元素
clear():清空队列
copy():队列的浅拷贝
count():返回指定元素的出现次数
extend():从队列右端扩展一个列表的元素
extendleft():从队列左端扩展一个列表的元素
index():查找某个元素的索引位置
insert():在指定位置插入元素
pop():获取最右边一个元素,并在队列中删除
popleft():获取最左边一个元素,并在队列中删除
remove():删除指定元素
reverse():队列反转
rotate():把右边元素放到左边
各种方法的时间复杂度:
操作 | 平均时间复杂度 | 最坏时间复杂度 |
---|---|---|
Copy | O(n) | O(n) |
append | O(1) | O(1) |
appendleft | O(1) | O(1) |
pop | O(1) | O(1) |
popleft | O(1) | O(1) |
extend | O(k) | O(k) |
extendleft | O(k) | O(k) |
rotate | O(k) | O(k) |
remove | O(n) | O(n) |
a=[1,2,3,4]需要4个指针和四个数据,增加了存储和消耗cpu
a=np.array([1,2,3,4])只需要存放四个数据,读取和计算更加方便。
def test():
nums = []
for _ in range(900000):
nums.append(np.random.randn())
nums1 = np.array(nums)
print(round(sys.getsizeof(nums) / 1024 / 1024, 2)) # 7.37M
print(round(sys.getsizeof(nums1) / 1024 / 1024, 2)) # 6.87M--准确大小
注:python中float占64bit,对应8byte,所以900000个float的内存大小为:(9000008)/(10241024) ≈ 6.87M
参考链接:https://blog.csdn.net/nature_ph/article/details/121023581
参考链接:https://blog.csdn.net/forlogen/article/details/105073343
参考链接:https://blog.csdn.net/f553762019/article/details/107939161