算法复杂度(Algorithm complexity):在问题的输入规模为 n 的条件下,程序的时间使用情况和空间使用情况。
「算法分析」的目的在于改进算法。正如上文中所提到的那样:算法所追求的就是 所需运行时间更少(时间复杂度更低)、占用内存空间更小(空间复杂度更低)。所以进行「算法分析」,就是从运行时间情况、空间使用情况两方面对算法进行分析。
比较两个算法的优劣通常有两种方法:
大多数情况下,我们会选择第 2 种方式。因为第 1 种方式的工作量实在太大,得不偿失。另外,即便是同一个算法,用不同的语言实现,在不同的计算机上运行,所需要的运行时间都不尽相同。所以我们一般采用预先估算的方法来衡量算法的好坏。
采用预先估算的方式下,编译语言、计算机运行速度都不是我们所考虑的对象。我们只关心随着问题规模 n 扩大时,时间开销、空间开销的增长情况。
这里的 「问题规模 n」 指的是:算法问题输入的数据量大小。对于不同的算法,定义也不相同。
一般来说,问题的输入规模越接近,相应的计算成本也越接近。而随着问题输入规模的扩大,计算成本也呈上升趋势。
接下来,我们将具体讲解「时间复杂度」和「空间复杂度」。
时间复杂度(Time Complexity):在问题的输入规模为 n 的条件下,算法运行所需要花费的时间,可以记作为 T(n)。
我们将 基本操作次数 作为时间复杂度的度量标准。换句话说,时间复杂度跟算法中基本操作次数的数量正相关。
基本操作是一个运行时间不依赖于操作数的操作。
比如两个整数相加的操作,如果两个数的规模不大,运行时间不依赖于整数的位数,则相加操作就可以看做是基本操作。
反之,如果两个数的规模很大,相加操作依赖于两个数的位数,则两个数的相加操作不是一个基本操作,而每一位数的相加操作才是一个基本操作。
下面通过一个具体例子来说明一下如何计算时间复杂度。
def algorithm(n):
fact = 1
for i in range(1, n + 1):
fact *= i
return fact
把上述算法中所有语句的执行次数加起来 1+n+n+1=2×n+2,可以用一个函数 f(n) 来表达语句的执行次数:f(n)=2×n+2。
则时间复杂度的函数可以表示为:T(n)=O(f(n))。它表示的是随着问题规模 n 的增大,算法执行时间的增长趋势跟 f(n) 相同。O 是一种渐进符号,T(n) 称作算法的 渐进时间复杂度(Asymptotic Time Complexity),简称为 时间复杂度。
所谓「算法执行时间的增长趋势」是一个模糊的概念,通常我们要借助像上边公式中 O 这样的「渐进符号」来表示时间复杂度。
渐进符号(Asymptotic Symbol):专门用来刻画函数的增长速度的。简单来说,渐进符号只保留了 最高阶幂,忽略了一个函数中增长较慢的部分,比如 低阶幂、系数、常量。因为当问题规模变的很大时,这几部分并不能左右增长趋势,所以可以忽略掉。
经常用到的渐进符号有三种: Θ 渐进紧确界符号、O 渐进上界符号、Ω 渐进下界符号。接下来我们将依次讲解。
Θ 渐进紧确界符号:对于函数 f(n) 和 g(n),f(n)=Θ(g(n))。存在正常量 c1、c2 和 n0,使得对于所有 n≥n0 时,有 0≤c1⋅g(n)≤f(n)≤c2⋅g(n)。
也就是说,如果函数 f(n)=Θ(g(n)),那么我们能找到两个正数 c1、c2,使得 f(n) 被 c1⋅g(n) 和 c2⋅g(n) 夹在中间。
例如:T(n)=3n2+4n+5=Θ(n2),可以找到 c1=1,c2=12,n0=1,使得对于所有 n≥1,都有 n2≤3n2+4n+5≤12n2。
O 渐进上界符号:对于函数 f(n) 和 g(n),f(n)=O(g(n))。存在常量 c,n0,使得当 n>n0 时,有 0≤f(n)≤c⋅g(n)。
Θ 符号渐进地给出了一个函数的上界和下界,如果我们只知道一个函数的上界,可以使用 O 渐进上界符号。
Ω 渐进下界符号:对于函数 f(n) 和 g(n),f(n)=Ω(g(n))。存在常量 c,n0,使得当 n>n0 时,有 0≤c⋅g(n)≤f(n)。
同样,如果我们只知道函数的下界,可以使用 Ω 渐进下界符号。
渐进符号可以渐进地描述一个函数的上界、下界,同时也可以描述算法执行时间的增长趋势。
在计算时间复杂度的时候,我们经常使用 O 渐进上界符号。因为我们关注的通常是算法用时的上界,而不用关心其用时的下界。
那么具体应该如何计算时间复杂度呢?
求解时间复杂度一般分为以下几个步骤:
同时,在求解时间复杂度还要注意一些原则:
如果 T1(n)=O(f1(n)),T2(n)=O(f2(n)),T(n)=T1(n)+T2(n),则 T(n)=O(f(n))=max(O(f1(n)),O(f2(n)))=O(max(f1(n),f2(n)))。
如果 T1=O(f1(n)),T2=O(f2(n)),T(n)=T1(n)×T2(n),则 T(n)=O(f(n))=O(f1(n))×O(f2(n))=O(f1(n)×f2(n))。
下面通过实例来说明如何计算时间复杂度。
一般情况下,只要算法中不存在循环语句、递归语句,其时间复杂度都为 O(1)。
O(1) 只是常数阶时间复杂度的一种表示方式,并不是指只执行了一行代码。只要代码的执行时间不随着问题规模 n 的增大而增长,这样的算法时间复杂度都记为 O(1)。
def algorithm(n):
a = 1
b = 2
res = a * b + n
return res
上述代码虽然有 4 行代码,但时间复杂度也是 O(1),而不是 O(3)。
一般含有非嵌套循环,且单层循环下的语句执行次数为 n 的算法涉及线性时间复杂度。这类算法随着问题规模 n 的增大,对应计算次数呈线性增长。
def algorithm(n):
sum = 0
for i in range(n):
sum += 1
return sum
上述代码中 sum += 1
的执行次数为 n 次,所以这段代码的时间复杂度为 O(n)。
一般含有双层嵌套,且每层循环下的语句执行次数为 n 的算法涉及平方时间复杂度。这类算法随着问题规模 n 的增大,对应计算次数呈平方关系增长。
def algorithm(n):
res = 0
for i in range(n):
for j in range(n):
res += 1
return res
上述代码中,res += 1
在两重循环中,根据时间复杂度的乘法原理,这段代码的执行次数为 n2 次,所以其时间复杂度为 O(n2)。
阶乘时间复杂度一般出现在与「全排列」、「旅行商问题暴力解法」相关的算法中。这类算法随着问题规模 n 的增大,对应计算次数呈阶乘关系增长。
def permutations(arr, start, end):
if start == end:
print(arr)
return
for i in range(start, end):
arr[i], arr[start] = arr[start], arr[i]
permutations(arr, start + 1, end)
arr[i], arr[start] = arr[start], arr[i]
上述代码中实现「全排列」使用了递归的方法。假设数组 arr 长度为 n,第一层 for
循环执行了 n 次,第二层 for
循环执行了 n−1 次。以此类推,最后一层 for
循环执行了 1 次,将所有层 for
循环的执行次数累乘起来为 n×(n−1)×(n−2)×…×2×1=n! 次。则整个算法的 for
循环中基本语句的执行次数为 n! 次,所以对应时间复杂度为 O(n!)。
对数时间复杂度一般出现在「二分查找」、「分治」这种一分为二的算法中。这类算法随着问题规模 n 的增大,对应的计算次数呈对数关系增长。
def algorithm(n):
cnt = 1
while cnt < n:
cnt *= 2
return cnt
上述代码中 cnt = 1
的时间复杂度为 O(1) 可以忽略不算。while
循环体中 cnt 从 1 开始,每循环一次都乘以 2。当大于等于 n 时循环结束。变量 cnt 的取值是一个等比数列:20,21,22,…,2x,根据 2x=n,可以得出这段循环体的执行次数为 log2n,所以这段代码的时间复杂度为 O(log2n)。
因为 log2n=k×log10n,这里 k≈3.322,是一个常数系数,log2n 与 log10n 之间差别比较小,可以忽略 k。并且 log10n 也可以简写成 logn,所以为了方便书写,通常我们将对数时间复杂度写作是 O(logn)。
线性对数一般出现在排序算法中,例如「快速排序」、「归并排序」、「堆排序」等。这类算法随着问题规模 n 的增大,对应的计算次数呈线性对数关系增长。
def algorithm(n):
cnt = 1
res = 0
while cnt < n:
cnt *= 2
for i in range(n):
res += 1
return res
上述代码中外层循环的时间复杂度为 O(logn),内层循环的时间复杂度为 O(n),且两层循环相互独立,则总体时间复杂度为 O(n×logn)。
根据从小到大排序,常见的时间复杂度主要有:O(1) < O(logn) < O(n) < O(n×logn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)。
时间复杂度是一个关于输入问题规模 n 的函数。但是因为输入问题的内容不同,习惯将「时间复杂度」分为「最佳」、「最坏」、「平均」三种情况。这三种情况的具体含义如下:
我们通过一个例子来分析下最佳、最坏、最差时间复杂度。
def find(nums, val):
pos = -1
for i in range(n):
if nums[i] == val:
pos = i
break
return pos
这段代码要实现的功能是:从一个整数数组 nums 中查找值为 val 的变量出现的位置。如果不考虑 break
语句,根据「2.3 时间复杂度计算」中讲的分析步骤,这个算法的时间复杂度是 O(n),其中 n 代表数组的长度。
但是如果考虑 break
语句,那么就需要考虑输入的内容了。如果数组中第 1 个元素值就是 val,那么剩下 n−1 个数据都不要遍历了,那么时间复杂度就是 O(1),即最佳时间复杂度为 O(1)。如果数组中不存在值为 val 的变量,那么就需要把整个数组遍历一遍,时间复杂度就变成了 O(n),即最差时间复杂度为 O(n)。
这样下来,时间复杂度就不唯一了。怎么办?
我们都知道,最佳时间复杂度和最坏时间复杂度都是极端条件下的时间复杂度,发生的概率其实很小。为了能更好的表示正常情况下的复杂度,所以我们一般采用平均时间复杂度作为时间复杂度的计算方式。
还是刚才的例子,在数组 nums 中查找变量值为 val 的位置,总共有 n+1 种情况:「在数组的的 0∼n−1 个位置上」和「不在数组中」。我们将所有情况下,需要执行的语句次数累加起来,再除以 n+1,就可以得到平均需要执行的语句次数,即:1+2+3+...+n+nn+1=n(n+3)2(n+1)。将公式简化后,得到的平均时间复杂度就是 O(n)。
通常只有同一个算法在输入内容不同,不同时间复杂度有量级的差距时,我们才会通过三种时间复杂度表示法来区分。一般情况下,使用其中一种就可以满足需求了。
空间复杂度(Space Complexity):在问题的输入规模为 n 的条件下,算法所占用的空间大小,可以记作为 S(n)。一般将 算法的辅助空间 作为衡量空间复杂度的标准。
除了执行时间的长短,算法所需储存空间的多少也是衡量性能的一个重要方面。而在「2. 时间复杂度」中提到的渐进符号,也同样适用于空间复杂度的度量。空间复杂度的函数可以表示为 S(n)=O(f(n)),它表示的是随着问题规模 n 的增大,算法所占空间的增长趋势跟 f(n) 相同。
相比于算法的时间复杂度计算来说,算法的空间复杂度更容易计算,主要包括「局部变量(算法范围内定义的变量)所占用的存储空间」和「系统为实现递归(如果算法是递归的话)所使用的堆栈空间」两个部分。
下面通过实例来说明如何计算空间复杂度。
def algorithm(n):
a = 1
b = 2
res = a * b + n
return res
上述代码中使用 a、b、res 这 3 个局部变量,其所占空间大小为常数阶,并不会随着问题规模 n 的在增大而增大,所以该算法的空间复杂度为 O(1)。
def algorithm(n):
if n <= 0:
return 1
return n * algorithm(n - 1)
上述代码采用了递归调用的方式。每次递归调用都占用了 1 个栈帧空间,总共调用了 n 次,所以该算法的空间复杂度为 O(n)。
根据从小到大排序,常见的算法复杂度主要有:O(1) < O(logn) < O(n) < O(n2) < O(2n) 等。
「算法复杂度」 包括 「时间复杂度」 和 「空间复杂度」,用来分析算法执行效率与输入问题规模 n 的增长关系。通常采用 「渐进符号」 的形式来表示「算法复杂度」。
常见的时间复杂度有:O(1)、O(logn)、O(n)、O(n×logn)、O(n2)、O(n3)、O(2n)、O(n!)。
常见的空间复杂度有:O(1)、O(logn)、O(n)、O(n2)。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。