复杂度分析
数据结构主要是指数据的存储结构,关注的是存储空间的问题;而算法可以视作改变存储结构的方法,而方法则涉及到执行的时间,所以,复杂度分析涉及到就是时间与空间两个问题。
渐近记法
提供一种资源表示形式,主要功能是分析某项功能在应对一定规模参数输入时所需要的资源,在这里包括运行时间和存储空间两部分
上述引自《Python算法》(Magnus Lie Hetland)
在这里我们一般采用O()记法,表示运行的主体。我们来看代码:
def sum1(n):
sum = 0
for i in range(1, n+1):
sum += i
return sum
这是一个普通的求和公式,我们假设每一行代码的执行时间完全一致,那么这个函数的执行时间就是
T
(
n
)
=
O
(
f
(
n
)
)
T(n) = O(f(n))
T(n)=O(f(n))
f
(
n
)
f(n)
f(n)就是完全执行时间,在上面
s
u
m
1
(
n
)
sum1(n)
sum1(n)的例子中
f
(
n
)
=
2
n
+
2
f(n) = 2n + 2
f(n)=2n+2,即
T
(
n
)
=
O
(
2
n
+
2
)
T(n) = O(2n+2)
T(n)=O(2n+2)
而我们对于具体的
f
(
n
)
f(n)
f(n)的表达式并没有太大的兴趣,因为不论表达式如何变化,只要
n
n
n的本体没有什么变化(如变成
n
2
,
l
o
g
(
n
)
n^2,log(n)
n2,log(n)等),那么不论系数和后面的常数如何变化,并不改变运行次数的级数,对最后的运行时间没有太大的影响,我们所获得的时间往往取决于某个特定基本操作被执行的次数,渐近记法主要记述的就是这个特定操作次数的级数,所以,上述
s
u
m
1
(
n
)
sum1(n)
sum1(n)采用渐近记法可以记为
O
(
n
)
O(n)
O(n)
来看另一段代码:
def sum2(n):
sum = 0
i, j = 1, 1
while i <= n:
j = 1
i += 1
while j <= n:
sum += i * j
j += 1
return sum
我们可以轻易的读出,这段代码的运行时间:
T
(
n
)
=
O
(
3
n
2
+
3
n
+
2
)
T(n) = O(3n^2+3n+2)
T(n)=O(3n2+3n+2)
第7、8、9行代码执行了
n
2
n^2
n2次,第3、4、5行代码执行了
n
n
n次,再加上头两行,就能得到上述结果,而在渐近记法中,我们认为,公式中的常量、低阶、系数不影响最后结果,这种方式就如同数学中的
lim
n
→
∞
f
(
n
)
\lim_{n \to \infty} f(n)
n→∞limf(n)一般,可以忽略除了最高阶
n
n
n以外的所有运算、数字。
同时,常见的时间复杂度除了上述的
O
(
n
)
、
O
(
n
2
)
O(n)、O(n^2)
O(n)、O(n2)之外,还有例如
O
(
l
o
g
(
n
)
)
、
O
(
n
l
o
g
(
n
)
)
、
O
(
1
)
O(log(n))、O(nlog(n))、O(1)
O(log(n))、O(nlog(n))、O(1)等较为常见的时间复杂度,还有就是例如
O
(
2
n
)
、
O
(
n
!
)
O(2^n)、O(n!)
O(2n)、O(n!)等不常见的时间复杂度,这在我们学习算法初期、中期,甚至是后期都会很少见到、用到。
接下来我们来看一些常见的例子(
O
(
n
)
和
O
(
n
2
)
O(n)和O(n^2)
O(n)和O(n2)上面有,就不再赘述):
O
(
1
)
O(1)
O(1):
def sum(i, j):
sum = i + j
return sum
#O(1)与行数无关,只要运行次数与输入数无关,哪怕是无数行,都算作O(1)
O ( l o g ( n ) ) O(log(n)) O(log(n)):
def judge(n):
i = 1
while i < n:#好吧,我承认,这段代码有点蠢
i *= 2
return i
上述代码可以用这个完全二叉树来理解,想要逐步向上搜索,这其中一共有
n
n
n(16)个数字,那么搜索次数就是
l
o
g
2
(
n
)
log_2(n)
log2(n)(4).其实,不论这段代码里,不论是乘多少,(2、3、4…)都可以认为是
l
o
g
2
(
n
)
log_2(n)
log2(n),因为,从数学的角度来说:
l
o
g
l
(
2
)
∗
l
o
g
2
(
n
)
=
l
o
g
l
(
n
)
log_l(2) * log_2(n) = log_l(n)
logl(2)∗log2(n)=logl(n)
而我们在前面已经说过了,系数可以作为无关量忽略掉。
O
(
n
l
o
g
(
n
)
)
O(nlog(n))
O(nlog(n)):
def judges(n);
for i in range(n):
j = 1
while j < n:
j *= 2
return '我是个蠢蛋,写这种蠢代码'
#玩笑归玩笑,我们来看一下归并排序
def MergeSort(lst):
#合并左右子序列函数
def merge(arr,left,mid,right):
temp=[] #中间数组
i=left #左段子序列起始
j=mid+1 #右段子序列起始
while i<=mid and j<=right:
if arr[i]<=arr[j]:
temp.append(arr[i])
i+=1
else:
temp.append(arr[j])
j+=1
while i<=mid:
temp.append(arr[i])
i+=1
while j<=right:
temp.append(arr[j])
j+=1
for i in range(left,right+1): # !注意这里,不能直接arr=temp,他俩大小都不一定一样
arr[i]=temp[i-left]
#递归调用归并排序
def mSort(arr,left,right):
if left>=right:
return
mid=(left+right)//2
mSort(arr,left,mid)
mSort(arr,mid+1,right)
merge(arr,left,mid,right)
n=len(lst)
if n<=1:
return lst
mSort(lst,0,n-1)
return lst
x=input("请输入待排序数列:\n")
y=x.split()
arr=[]
for i in y:
arr.append(int(i))
arr=MergeSort(arr)
#print(arr)
print("数列按序排列如下:")
for i in arr:
print(i,end=' ')
简单来说,这种复杂度就是将 l o g ( n ) log(n) log(n)循环执行 n n n次,也没有什么特殊的。
上述归并排序代码来自于CSDN博主@白糖炒栗子~
时间复杂度分析三法则
- 只关注循环执行次数最多的一段代码
一定要与 n n n有关,否则都是常量级 O ( 1 ) O(1) O(1) - 加法法则:总复杂度等于量级最大的那段代码的复杂度
在 s u m 1 ( ) 、 s u m 2 ( ) sum1()、sum2() sum1()、sum2()中介绍得应该说得很清楚了,时间相加,但是关注最大量级 - 乘法法则:循环嵌套的代码复杂度等于嵌套内外代码复杂度乘积
这就体现了 j u d g e s ( ) judges() judges()那段十分愚蠢的代码的作用,可以很好的帮助我们理解什么交嵌套内外复杂度相乘。
两个特例带来的扩展
我们来看两段代码
def sum3(m, n):
i, j = 1, 1
while i <= m:
sum1 += i
i += 1
while j <= n:
sum2 += j
j += 1
sum = sum1 + sum2
return sum
def sum4(m, n):
sum = 0
for i in range(m):
for j in range(n):
sum += i * j
return sum
我们可以看见,这里有两个迭代量 m 、 n m、n m、n而我们就这样去评价的话,完全没办法比较这两个迭代量的量级,所以,对于 s u m 3 sum3 sum3,我们记作 O ( m + n ) O(m+n) O(m+n),对于 s u m 4 sum4 sum4我们记作 O ( m ∗ n ) O(m*n) O(m∗n),这个例子向我们提供了对多元迭代量进行时间复杂度分析提供了思路,对于多元迭代量
- 加法法则由于不知量级而失效,我们选择记录多元迭代量的累加
- 乘法法则依旧可行
时间复杂度分析的几个评价
- 最好、最坏时间复杂度
就是我们在评价时间复杂度时,由于实际位置变化带来的差异,我们看代码:
def search(nums:List, target:int):->ans:int
for i in range(len(nums)):
if nums[i] == target:
ans = i
return ans
很明显,这是一段 O ( n ) O(n) O(n)的查找代码,目的时寻找 t a r g e t target target在数组 n u m s nums nums中的位置,然后我们改动一下:
def search1(nums:List, target:int):->ans:int
for i in range(len(nums)):
if nums[i] == target:
ans = i
break #退出循环
return ans
很明显,在查找到
t
a
r
g
e
t
target
target的位置后,这段代码就退出了循环,那么,它的时间复杂度就和
t
a
r
g
e
t
target
target位置有着直接的关系,如果
n
u
m
s
[
0
]
=
=
t
a
r
g
e
t
nums[0] == target
nums[0]==target
那么这段代码的时间的复杂度就是
O
(
1
)
O(1)
O(1),但是如果
n
=
l
e
n
(
n
u
m
s
)
−
1
n
u
m
s
[
n
]
=
=
t
a
r
g
e
t
或
者
t
a
r
g
e
t
不
在
数
组
中
n = len(nums) - 1\\nums[n] == target\\或者target不在数组中
n=len(nums)−1nums[n]==target或者target不在数组中
那时间复杂度就是
O
(
n
)
O(n)
O(n)了,所谓最好和最坏时间复杂度,就是考量这两种极端情况,但是,我们知道,在实际应用和开发中,极端情况毕竟占少数,大多数都是中间状态,所以,引入下面两种计算方式就很有必要了。
- 平均情况时间复杂度
对于上述情况,我们寻找 t a r g e t target target,假设它在与不在数组中的概率各是 1 2 \frac{1}{2} 21,那么, t a r g e t target target在数组各位置的几率就是 1 2 n \frac{1}{2n} 2n1(数组大小为 n n n),然后我们计算加权平均值:
1 × 1 2 n + 2 × 1 2 n + ⋯ + n × 1 2 n + n × 1 2 = ∑ i = 1 n i × 1 2 n + n 2 = 3 n + 1 4 1\times \frac{1}{2n} +2\times \frac{1}{2n}+ \cdots+n\times \frac{1}{2n}+n\times \frac{1}{2}\\=\displaystyle\sum_{i=1}^{n}i\times \frac{1}{2n}+\frac{n}{2}\\=\frac{3n+1}{4} 1×2n1+2×2n1+⋯+n×2n1+n×21=i=1∑ni×2n1+2n=43n+1
上述其实就是求加权平均值的过程,而为什么要这么计算,我的理解是:在第一个位置,只有一个可能在这里,在第二个位置的时候,因为不在第一个位置,所以累积了一个可能性到第二个位置,依次类推,直到第 n n n个位置,如果不考虑不在数组内的情况,那就累积了 n n n个可能性,也就是一定在这个位置。 - 均摊时间复杂度
按照一般的解释,就是将某一次特别耗时的操作时间均摊到剩下的 n − 1 n-1 n−1次较少的时间中来进行分析,拓展一下,就是将高耗时摊到低耗时进行时间复杂度分析。
由于这种分析法适用度极低,所以就不加以详解,如果有兴趣,可以去看看王争老师的《数据结构与算法之美》,里面有介绍
几个实例
时间复杂度 | 相关名称 | 相关示例及说明 |
---|---|---|
O(1) | 常数级 | 哈希表的查询与修改 |
O(lg(n)) | 对数级 | 二分查找 |
O(n) | 线性级 | 列表遍历 |
O(nlg(n)) | 线性对数级 | 归并排序 |
O(n^2) | 平方级 | n个对象相互比对 |
O(n^3) | 立方级 | Floyd-Warshall算法 |
O(n^k) | 多项式级 | 基于n的k层循环嵌套 |
O(k^n) | 指数级 | 每n项产生一个子集(k>1) |
O(n!) | 阶乘级 | 对n个执行全排列 |
表格引自《Python算法》(Magnus Lies Hetland著)
空间复杂度
还是考虑渐近记法,如
def nums(n):
i = 0
nums = [j for j in range(n)]
nums.append(i)
return nums
在这里,我们申请了两个空间,一个是大小为
1
1
1的
i
i
i,一个是大小为
n
n
n的
n
u
m
s
nums
nums数组,那么按照渐近记法,空间复杂度就是
O
(
n
)
O(n)
O(n),而我们常见的空间复杂度就是
O
(
1
)
、
O
(
n
)
、
O
(
n
2
)
O(1)、O(n)、O(n^2)
O(1)、O(n)、O(n2),其余的时间复杂度基本不会见到。
O
(
n
2
)
O(n^2)
O(n2):
def nums1(n):
nums = [[i for i in range(n)] for i in range(n)]
return nums
结语
复杂度分析讲到这里基本也就没有其它的了,基本就是这样。后续我们还会针对不同的数据结构和一些经典算法写后续文章。数据结构基本按照数组、链表、数、图、栈队的顺序一一来,如果有什么想要了解的算法,也可以私信作者,我会及时按照读者意愿进行更新。最后,本文观点基本来源于
王争老师《数据结构与算法之美》文中的代码除特别标注外,皆来源于次,王争老师采用的是C语言,而我因为对Python稍稍了解一点,就将其改为了 Python
《Python算法》(Magnus Lies Hetland著)文章中的一些观点基本来源于此