文章摘要
LCY AI Pro

本文从基本操作数出发介绍时间复杂度,梳理 OOΩΩΘΘooωω 等渐近符号及常见性质,并结合示例说明复杂度分析方法。

复杂度简介

时间复杂度和空间复杂度是衡量一个算法效率的重要标准。

基本操作数

同一个算法在不同的计算机上运行的速度会有一定的差别,并且实际运行速度难以在理论上进行计算,实际去测量又比较麻烦,所以我们通常考虑的不是算法运行的实际用时,而是算法运行所需要进行的基本操作的数量。

在普通的计算机上,加减乘除、访问变量(基本数据类型的变量,下同)、给变量赋值等都可以看作基本操作。

对基本操作的计数或是估测可以作为评判算法用时的指标。

本章我们主要了解时间复杂度的内容。


时间复杂度

定义

衡量一个算法的快慢,一定要考虑数据规模的大小。所谓数据规模,一般指输入的数字个数、输入中给出的图的点数与边数等等。一般来说,数据规模越大,算法的用时就越长。而在算法竞赛中,我们衡量一个算法的效率时,最重要的不是看它在某个数据规模下的用时,而是看它的用时随数据规模而增长的趋势,即 时间复杂度

引入

考虑用时随数据规模变化的趋势的主要原因有以下几点:

1.现代计算机每秒可以处理数亿乃至更多次基本运算,因此我们处理的数据规模通常很大。如果算法 A 在规模为 nn 的数据上用时为 100n100n 而算法 B 在规模为 nn 的数据上用时为 n2n^2,在数据规模小于 100100 时算法 B 用时更短,但在一秒钟内算法 A 可以处理数百万规模的数据,而算法 B 只能处理数万规模的数据。在允许算法执行时间更久时,时间复杂度对可处理数据规模的影响就会更加明显,远大于同一数据规模下用时的影响。

2.我们采用基本操作数来表示算法的用时,而不同的基本操作实际用时是不同的,例如加减法的用时远小于除法的用时。计算时间复杂度而忽略不同基本操作之间的区别以及一次基本操作与十次基本操作之间的区别,可以消除基本操作间用时不同的影响。

当然,算法的运行用时并非完全由输入规模决定,而是也与输入的内容相关。所以,时间复杂度又分为几种,例如:

  1. 最坏时间复杂度,即每个输入规模下用时最长的输入对应的时间复杂度。在算法竞赛中,由于输入可以在给定的数据范围内任意给定,我们为保证算法能够通过某个数据范围内的任何数据,一般考虑最坏时间复杂度。
  2. 平均(期望)时间复杂度,即每个输入规模下所有可能输入对应用时的平均值的复杂度(随机输入下期望用时的复杂度)。

所谓「用时随数据规模而增长的趋势」是一个模糊的概念,我们需要借助下文所介绍的 渐近符号 来形式化地表示时间复杂度。


渐近符号的定义

渐近符号是函数的阶的规范描述。简单来说,渐近符号忽略了一个函数中增长较慢的部分以及各项的系数(在时间复杂度相关分析中,系数一般被称作「常数」),而保留了可以用来表明该函数增长趋势的重要部分。

一个简单的记忆方法是,含等于(非严格)用大写,不含等于(严格)用小写,相等是Θ\Theta,小于是OO,大于是Ω\Omega。大OO和小oo原本是希腊字母 Omicron,由于字形相同,也可以理解为拉丁字母的大OO和小oo

在英文中,词根「-micro-」和「-mega-」常用于表示 1010 的负六次方(百万分之一)和六次方(百万),也表示「小」和「大」。小和大也是希腊字母 Omicron 和 Omega 常表示的含义。

ΘΘ 符号

对于函数f(n)f(n)g(n)g(n)f(n)=Θ(g(n))f(n)=\Theta(g(n)),当且仅当c1,c2,n0>0\exists c_1,c_2,n_0 >0,使得nn0,0c1g(n)f(n)c2g(n)\forall n\ge n_0,0\le c_1\cdot g(n)\le f(n)\le c_2\cdot g(n)

也就是说,如果函数f(n)=Θ(g(n))f(n)=\Theta(g(n)),那么我们能找到两个正数c1,c2c_1,c_2,使得f(n)f(n)c1g(n)c_1\cdot g(n)c2g(n)c_2\cdot g(n)夹在中间。

例如,3n2+5n3=Θ(n2)3n^2+5n-3=\Theta(n^2),这里的c1,c2,n0c_1,c_2,n_0可以分别是2,4,1002,4,100nn+nlog5n+mlogm+nm=Θ(nn+mlogm+nm)n\sqrt{n}+n\log^5 n+m\log m+nm=\Theta(n\sqrt{n}+m\log m+nm),这里的c1,c2,n0c_1,c_2,n_0可以分别是1,2,1001,2,100

OO 符号

Θ\Theta符号同时给了我们一个函数的上下界,如果只知道一个函数的渐近上界而不知道其渐近下界,可以使用OO符号。f(n)=O(g(n))f(n)=O(g(n)),当且仅当c,n0\exists c,n_0,使得nn0,0f(n)cg(n)\forall n\ge n_0,0\le f(n)\le c\cdot g(n)。研究时间复杂度时通常会使用OO符号,因为我们关注的通常是程序用时的上界,而不关心其用时的下界。需要注意的是,这里的「上界」和「下界」是对于函数的变化趋势而言的,而不是对算法而言的。算法用时的上界对应的是「最坏时间复杂度」而非大OO记号。所以,使用Θ\Theta记号表示最坏时间复杂度是完全可行的,甚至可以说Θ\ThetaOO更加精确,而使用OO记号的主要原因,一是我们有时只能证明时间复杂度的上界而无法证明其下界(这种情况一般出现在较为复杂的算法以及复杂度分析),二是OO在电脑上输入更方便一些。

ΩΩ 符号

同样的,我们使用Ω\Omega符号来描述一个函数的渐近下界。f(n)=Ω(g(n))f(n)=\Omega(g(n)),当且仅当c,n0\exists c,n_0,使得nn0,0cg(n)f(n)\forall n\ge n_0,0\le c\cdot g(n)\le f(n)

oo 符号

如果说OO符号相当于小于等于号,那么oo符号就相当于小于号。小oo符号大量应用于数学分析中,函数在某点处的泰勒展开式拥有皮亚诺余项,使用小oo符号表示严格小于,从而进行等价无穷小的渐近分析。

f(n)=o(g(n))f(n)=o(g(n)),当且仅当对于任意给定的正数ccn0\exists n_0,使得nn0,0f(n)<cg(n)\forall n\ge n_0,0\le f(n)<c\cdot g(n)

ω\omega 符号

如果说Ω\Omega符号相当于大于等于号,那么ω\omega符号就相当于大于号。

f(n)=ω(g(n))f(n)=\omega(g(n)),当且仅当对于任意给定的正数ccn0\exists n_0,使得nn0,0cg(n)<f(n)\forall n\ge n_0,0\le c\cdot g(n)<f(n)

常见性质

  • f(n)=Θ(g(n))    f(n)=O(g(n))f(n)=Ω(g(n))f(n) = \Theta(g(n)) \iff f(n) = O(g(n)) \land f(n) = \Omega(g(n))
  • f1(n)+f2(n)=O(max(f1(n),f2(n)))f_1(n) + f_2(n) = O(\max(f_1(n), f_2(n)))
  • f1(n)×f2(n)=O(f1(n)×f2(n))f_1(n) \times f_2(n) = O(f_1(n) \times f_2(n))
  • a1,logan=O(log2n)\forall a \neq 1, \log_a{n} = O(\log_2 n)

由换底公式可以得知,任何对数函数无论底数为何,都具有相同的增长率,因此渐近时间复杂度中对数的底数一般省略不写。


简单的时间复杂度计算的例子

for 循环

1
2
3
4
5
6
7
8
9
int n, m;
std::cin >> n >> m;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
for (int k = 0; k < m; ++k) {
std::cout << "hello world\n";
}
}
}
1
2
3
4
5
6
n = int(input())
m = int(input())
for i in range(0, n):
for j in range(0, n):
for k in range(0, m):
print("hello world")
1
2
3
4
5
6
7
8
9
10
int n, m;
n = input.nextInt();
m = input.nextInt();
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
for (int k = 0; k < m; ++k) {
System.out.println("hello world");
}
}
}

如果以输入的数值nnmm的大小作为数据规模,则上面这段代码的时间复杂度为Θ(n2m)\Theta(n^2m)

DFS

在对一张nn个点mm条边的图进行DFS(深度优先搜索,后期会解释)时,由于每个节点和每条边都只会被访问常数次,复杂度为Θ(n+m)\Theta(n+m)


哪些量是常量?

当我们要进行若干次操作时,如何判断这若干次操作是否影响时间复杂度呢?例如:

1
2
3
4
constexpr int N = 100000;
for (int i = 0; i < N; ++i) {
std::cout << "hello world\n";
}
1
2
3
N = 100000
for i in range(0, N):
print("hello world")
1
2
3
4
final int N = 100000;
for (int i = 0; i < N; ++i) {
System.out.println("hello world");
}

如果NN的大小不被看作输入规模,那么这段代码的时间复杂度就是O(1)O(1)。进行时间复杂度计算时,哪些变量被视作输入规模是很重要的,而所有和输入规模无关的量都被视作常量,计算复杂度时可当作11来处理。

需要注意的是,在进行时间复杂度相关的理论性讨论时,「算法能够解决任何规模的问题」是一个基本假设(当然,在实际中,由于时间和存储空间有限,无法解决规模过大的问题)。因此,能在常量时间内解决数据规模有限的问题(例如,对于数据范围内的每个可能输入预先计算出答案)并不能使一个算法的时间复杂度变为O(1)O(1)


主定理 (Master Theorem)

我们可以使用 Master Theorem 来快速求得关于递归算法的复杂度。 Master Theorem 递推关系式如下

T(n)=aT(nb)+f(n)n>bT(n) = a T\left(\frac{n}{b}\right) + f(n) \quad \forall n > b

那么

T(n)={Θ(nlogba)f(n)=O(nlogbaϵ),ϵ>0Θ(f(n))f(n)=Ω(nlogba+ϵ),ϵ0Θ(nlogbalogk+1n)f(n)=Θ(nlogbalogkn),k0T(n) = \begin{cases} \Theta(n \log_b a) & f(n) = O(n^{\log_b a - \epsilon}), \, \epsilon > 0 \\ \Theta(f(n)) & f(n) = \Omega(n^{\log_b a + \epsilon}), \, \epsilon \geq 0 \\ \Theta(n^{\log_b a} \log^{k+1} n) & f(n) = \Theta(n^{\log_b a} \log^k n), \, k \geq 0 \end{cases}

需要注意的是,这里的第二种情况还需要满足满足正则性条件,即af(nb)cf(n)a f\left(\frac{n}{b}\right) \leq c f(n),对于某个常数c<1c<1和足够大的nn

证明思路是是将规模为nn的问题,分解为aa个规模为(nb)(\frac{n}{b})的问题,然后依次合并,直到合并到最高层。每一次合并子问题,都需要花费f(n)f(n)的时间。

下面举几个例子来说明主定理如何使用。

T(n)=2T(n2)+1T(n) = 2T\left(\frac{n}{2}\right) + 1,那么a=2,b=2,log22=1a = 2, b = 2, \log_2 2 = 1,那么ϵ\epsilon可以取值在(0,1](0,1]之间,从而满足第一种情况,所以T(n)=Θ(n)T(n) = \Theta(n)

T(n)=T(n2)+nT(n) = T\left(\frac{n}{2}\right) + n,那么a=1,b=2,log21=0a = 1,b = 2, \log_2 1 = 0,那么ϵ\epsilon可以取值在(0,1](0,1]之间,从而满足第二种情况,所以T(n)=Θ(n)T(n) = \Theta(n)

T(n)=T(n2)+lognT(n) = T\left(\frac{n}{2}\right) + \log n,那么a=1,b=2,log21=0a = 1, b = 2, \log_2 1 = 0,那么kk可以取值为11,从而满足第三种情况,所以T(n)=Θ(logn)T(n) = \Theta(\log n)

T(n)=T(n2)+1T(n) = T\left(\frac{n}{2}\right) + 1,那么a=1,b=2,log21=0a = 1, b = 2, \log_2 1 = 0,那么kk可以取值为00,从而满足第四种情况,所以T(n)=Θ(logn)T(n) = \Theta(\log n)