持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第10天,点击查看活动详情
大家好呀,我是蛋蛋。
复杂度分析主要就是时间复杂度和空间复杂度,接下来的文章也主要围绕这两方面来讲。废话不多说,前排马扎瓜子准备好,蛋蛋小课堂正式接客。
时间复杂度
时间复杂度,也就是指算法的运行时间。
对于某一问题的不同解决算法,运行时间越短算法效率越高,相反,运行时间越长,算法效率越低。
那么如何估算时间复杂度呢?
大佬们在拽掉脑阔上最后一根头发的时候发现,当用运行时间去描述一个算法快慢的时候,算法中执行的总步数显得尤为重要。
因为只是估算,我们可以假设每一行代码的运行时间都为一个 Btime,那么算法的总的运行时间 = 运行的总代码行数。
下面我们来看这么一段简单的代码。
def dogeggs_sum (n):
sum = 0
for dogegg in range(n):
sum += dogegg
return sum
在上面假设的情况下,这段求累加和的代码总的运行时间是多少呢?
第 2 行代码需要 1 Btime 的运行时间,第 4 和 第 5行分别运行了 n 次,所以每个需要 n * Btime 的运行时间,所以总的运行时间就是 (1 + 2n) * Btime。
我们一般用 T 函数来表示* 赋值语句的总运行时间*,所以上面总的运行时间就可以表达成 T(n) = (1 + 2n) * Btime,翻译一下就是“数据集大小为 n,总步数为 (1 + 2n) 的算法的执行时间为 T(n)”。
通过公式,我们可以看出 T(n) 和总步数是成正比关系,这个规律的发现其实是很重要的,因为这个告诉了我们一种趋势,数据集规模和运行时间之间有趋势!
所有人准备,我们很熟悉的大 O 闪亮登场了!
大 O 表示法
大 O 表示法,表示的是算法有多快。
这个多快,不是说算法代码真正的运行时间,而是代表着一种趋势,一种随着数据集的规模增大,算法代码运行时间变化的一种趋势。一般记作 O(f(n))。
那么之前的公式就变成 T(n) = O(f(n)),其中 f(n) 是算法代码执行的总步数,也叫操作数。
n 作为数据集大小,它可以取 1,10,100,1000 或者更大更大更大的数,到这你就会发现一件很有意思的事儿,那就是当数据集越来越大时,你会发现代码中的某些部分“失效”了。
还是以之前的代码为例。当 n = 1000 时,1 + 2n = 2001,当 n = 10000 时,1 + 2n = 20001,当这个 n 持续增大时,常数 1 和系数 2 对于最后的结果越来越没存在感,即对趋势的变化影响不大。
所以对于我们这段代码样例,最终的 T(n) = O(n)。
接下来再用两段代码进一步学一下求时间复杂度分析。
时间复杂度分析
代码 1
def dogeggs_sum (n):
sum = 0
for dogegg in range(n):
for i in range(n):
sum += dogegg * i
return sum
这段代码我会从最开始一点点带你来。
第 2 行需要运行 1 次 ,第 4 行需要运行 n 次,第 5 行和第 6 行分别需要运行 n² 次。所以总的运行次数 f(n) = 1 + n + 2n²。
当 n 为 5 的时候,f(n) = 1 + 5 + 2 * 25,当 n 为 10000 的时候,f(n) = 1 + 10000 + 2 * 100000000,当 n 更大呢?
这个时候其实很明显的就可以看出来 n² 起到了决定性的作用,像常数 1,一阶 n 和 系数 2 对最终的结果(即趋势)影响不大,所以我们可以把它们直接忽略掉,所以执行的总步数就可以看成是“主导”结果的那个,也就是 f(n) = n²。
自然代码的运行时间 T(n) = O(f(n)) = O(n²)。
代码 2
def dogeggs_sum (n):
sum1 = 0
for dogegg1 in range(n):
sum1 += dogegg1
sum2 = 0
for dogegg2 in range(n):
for i in range(n):
sum2 += dogegg2 * i
sum3 = 0
for dogegg3 in range(n):
for i in range(n):
for j in range(n):
sum3 += dogegg3 * i * j
return sum1 + sum2 + sum3
上面这段代码是求三部分的和,经过之前的学习应该很容易知道,第一部分的时间复杂度是 O(n),第二部分的时间复杂度是 O(n²),第三部分是 O(n³)。
正常来讲,这段代码的 T(n) = O(n) + O(n²) + O(n³),按照我们取“主导”部分,显然前面两个小弟都应该直接 pass,最终 T(n) = O(n³)。
通过这几个例子,聪明的你肯定会发现,对于时间复杂度分析来说,只要找起“主导”作用的部分代码即可,这个主导就是最高的那个复杂度,也就是执行次数最多的那部分 n 的量级。
剩下的就是多加练习,有意识的多去想多去练,就可以和我一样 帅气 稳啦。
常见时间复杂度
算法学习过程中,我们会遇到各种各样的时间复杂度,但纵使你代码七十二变,几乎也逃不过下面这几种常见的时间复杂度。
| 时间复杂度 | 阶 | f(n) 举例 |
|---|---|---|
| 常数复杂度 | O(1) | 1 |
| 对数复杂度 | O(logn) | logn + 1 |
| 线性复杂度 | O(n) | n + 1 |
| 线性对数复杂度 | O(nlogn) | nlogn + 1 |
| k 次复杂度 | O(n²)、O(n³)、.... | n² + n +1 |
| 指数复杂度 | O(2n) | 2n + 1 |
| 阶乘复杂度 | O(n!) | n! + 1 |
上表的时间复杂度由上往下依次增加,O(n) 和 O(n²) 前面已经讲过了,O(2n) 和 O(n!) 效率低到离谱,以后不幸碰到我再提一下,就不再赘述。
下面主要来看剩下几种最常见的时间复杂度。
O(1)
O(1) 是常数时间复杂度。
在这你要先明白一个概念,不是只执行 1 次的代码的时间复杂度记为 O(1),只要你是常数,像 O(2)、O(3) ... O(100000) 在复杂度的表示上都是 O(1)。
n = 10000
res = n / 2
print(res)
比如像上面这段代码,它运行了 3 次,但是它的时间复杂度记为 O(1),而不是 O(3)。
因为无论 n 等于多少,对于它们每行代码来说还是只是执行了一次,代码的执行时间不随 n 的变化而变化。
O(logn)
O(logn) 是对数时间复杂度。首先我们来看一段代码:
dogegg = 1
while dogegg < n:
dogegg = dogegg * 2
根据之前讲的,我们先找“主导”,在这主导就是第 4 行代码,只要算出它的时间复杂度,总的时间复杂度就知道了。
其实很简单,上面的代码翻译一下就是初始化 dogegg = 1, 乘上多少个 2 以后会 ≥ n。
假设需要 x 个,那么相当于求:
即:
所以上述代码的时间复杂度应该为 O(log2n)。
但是对于对数复杂度来说,不管你是以 2、3 为底,还是以 10 为底,通通记作 O(logn) ,这是为什么呢?
这就要从对数的换底公式说起。
根据换底公式,log2n 可以写成:
对于时间复杂度来说:
所以在对数时间复杂度中我们就忽略了底,直接用 O(logn) 来表示对数时间复杂度。
O(nlogn)
O(nlogn),真的是很常见的时间复杂度,像后面会学到的快速排序、归并排序的时间复杂度都是 O(nlogn)。
如果只是简单看的话是在 logn 外面套了层 for 循环,比如像下面这段代码:
def stupid_cnt(n):
cnt = 0
for i in range(n):
dogegg = 1
while dogegg < n:
dogegg = dogegg * 2
cnt += 1
return cnt
当然像上面这种 stupid 代码一般人不会写,我也只是举个例子给大家看,我是狗蛋,大家千万不要以为我是傻狗。
最好情况、最坏情况和平均情况时间复杂度
除了数据集规模影响算法的运行时间外,“数据的具体情况”也会影响到运行时间。
我们来看这么一段代码:
def find_word(lst, word):
flag = -1
for i in range(len(lst)):
if lst[i] == word:
flag = i
break
return flag
上面这段简单代码是求变量 word 在列表 lst 中出现的位置,我用这段来解释“数据的具体情况”是什么意思。
变量 word 可能出现在列表 lst 的任意位置,假设 a = ['a', 'b', 'c', 'd']:
- 当 word = 'a' 时,正好是列表 lst 的第 1 个,后面的不需要再遍历,那么本情况下的时间复杂度是 O(1)。
- 当 word = 'd' 或者 word = 'e' 时,这两种情况是整个列表全部遍历完,那么这些情况下的时间复杂度是 O(n)。
这就是数据的具体情况不同,代码的时间复杂度不同。
根据不同情况,我们有了最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度这 3 个概念。
最好情况时间复杂度
最好情况就是在最理想的情况下,代码的时间复杂度。 对应上例变量 word 正好是列表 lst 的第 1 个,这个时候对应的时间复杂度 O(1) 就是这段代码的最好情况时间复杂度。
最坏情况时间复杂度
最坏情况就是在最差的情况下,代码的时间复杂度。 对应上例变量 word 正好是列表 lst 的最后一个,或者 word 不存在于列表 lst,这个时候对应的时间复杂度 O(n) 是这段代码的最坏情况时间复杂度。
平均情况时间复杂度
大多数情况下,代码的执行情况都是介于最好情况和最坏情况之间,所以又出现了平均情况时间复杂度。
那怎么计算平均时间复杂度呢?这个说起来有点复杂,需要用到概率论的知识。
在这里我还是用上面的例子来讲,因为只是简单的科普一下,为了方便计算,我假设的会有点随意:
- 从大的方面来看,查找变量 x 在列表 lst 中的位置有两种情况:在或者不在。假设变量 x 在或者不在列表 lst 中的概率都为 1/2。
- 如果 x 在列表 lst 中,那么 x 有 n 种情况,它可能出现在 0 到 n-1 中任意位置,假设出现在每个位置的概率都相同,都为 1/n。
每个出现的概率(即权重)知道了,所以平均时间复杂度为:
是不是看着有点眼熟,这就是我们都学过的加权平均值。
什么,没学过?问题不大。加权平均值就是将各数值乘以相应的权数,然后加总求和得到总体值,再除以总的单位数。
所以最终的平均时间复杂度就为:
臭宝:又是最好,又是最坏,又是平均的,这么多到底用哪个呀?
蛋蛋:这个还用问?当然是选择最坏情况时间复杂度啊!
最好情况,反应最理想的情况,怎么可能天上天天掉馅饼,没啥参考价值。
平均情况,这倒是能反映算法的全面情况,但是一般“全面”,就和什么都没说一样,也没啥参考价值。
最坏情况,干啥事情都要考虑最坏的结果,因为最坏的结果提供了一种保障,触底的保障,保障代码的运行时间这个就是最差的,不会有比它还差的。
所以,不需要纠结各种情况,算时间复杂度就算最坏的那个时间复杂度即可。
空间复杂度
空间复杂度相较于时间复杂度来说,比较简单,需要掌握的内容不多。
空间复杂度和时间复杂度一样,反映的也是一种趋势,只不过这种趋势是代码运行过程中临时变量占用的内存空间。
臭宝:为什么是“临时”咧?
蛋蛋:这就要从代码在计算机中的运行说起啦。
代码在计算机中的运行所占用的存储空间呐,主要分为 3 部分:
- 代码本身所占用的
- 输入数据所占用的
- 临时变量所占用的
前面两个部分是本身就要占这些空间,与代码的性能无关,所以我们在衡量代码的空间复杂度的时候,只关心运行过程中临时占用的内存空间。
空间复杂度记作 S(n),表示形式与时间复杂度一致。
在这同样 n 作为数据集大小,f(n) 指的是规模 n 所占存储空间的函数。
空间复杂度分析
下面我们用一段简单代码来学习下如何分析空间复杂度。
def dogeggs_sum(lst):
sum = 0
for i in range(len(lst)):
sum += lst[i]
return sum
上述代码是求列表 lst 的所有元素之和,根据之前说的,只计算临时变量所占用的内存空间。
形参 lst 所占的空间不计,那么剩下的临时变量只有 sum 和 i,sum 是存储和,是常数阶,i 是存储元素位置,也是常数阶,它俩所分配的空间和规模 n 都没有关系,所以整段代码的空间复杂度 S(n) = O(1)。
常见空间复杂度
常见的空间复杂度有 O(1)、O(n)、O(n²),O(1) 在上节讲了,还有就是像 O(logn) 这种也有,但是等闲不会碰到,在这里就不罗嗦了。
O(n)
def create_lst(n):
lst = []
for i in range(n):
lst.append(i)
return lst
上面这段傻傻的代码有两个临时变量 lst 和 i。
其中 lst 是被创建的一个空列表,这个列表占用的内存随着 for 循环的增加而增加,最大到 n,所以 lst 的空间复杂度为 O(n),i 是存储元素位置的常数阶,与规模 n 无关,所以这段代码最终的空间复杂度为 O(n)。
O(n²)
def create_lst(n):
lst1 = []
for i in range(n):
lst2 = []
for j in range(n):
lst2.append(j)
lst1.append(lst2)
return lst1
还是一样的分析方式,很明显上面这段傻二次方的代码创建了一个二维数组 lst,一维 lst1 占用 n,二维 lst2 占用 n²,所以最终这段代码的空间复杂度为 O(n²)。
到这里,复杂度分析就全部讲完啦,只要臭宝们认真看完这篇文章,相信会对复杂度分析有个基本的认识。复杂度分析本身不难,只要多加练习,平时写代码的时候有意识的多想估算一下自己的代码,感觉就会慢慢来啦。