算法分析:原子操作与时间复杂度
算法分析:原子操作与时间复杂度
根据教材 《数据结构与算法 Python 实现》 整理,代码和笔记可前往我的 Github 库下载 isKage/dsa-notes 。【建议 star !】
研究算法运行的时间非常重要,我们往往关注:
- 算法运行时间与输入大小的关系
- 往往考虑最差的情况
1 实验研究
如果算法是准确的,且已经可以正常运行。可以采用最简单的方法检验时间,即直接计算算法执行的时间差:
1 | from time import time |
显然这种方法简单,但缺点明显【不推荐使用】:
- 比较不同算法时,要控制硬件等条件相同
- 算法的输入可能并不够,例如对于巨量数据输入,每次都测试显然是不明智的
- 这个方法必须要求算法能过成功运行
2 原子操作
2.1 伪代码
所以为了解决上面的问题,我们往往分析伪代码,例如:
1 | Algorithm method (arg [, arg, ...]) |
不关注代码能否执行,只用于评价算法的好坏。
2.2 原子操作
原子操作是算法进行的一些基本运算:
- 在伪代码中可被识别出来(无须算法成立即可进行评价)
- 很大程度上独立于编程语言而存在(即适用于各种环境)
- 执行时间为常数(基本单元,与输入大小
n
无关)
按照原子操作的观点,我们可以根据操作总时间与输入大小的关系,评价一个算法的好坏。即寻找某种函数关系:
其中 $t$ 为算法执行的时间,$n$ 为算法输入的大小。下面是常见的函数 $f$ 形式。
3 常见函数
3.1 常见 7 个函数
算法分析里常见的 7 个函数为:
- 常数函数
这表明,无论算法输入的大小 n
如何变化,算法所有原子操作的执行时间总和大约为一个常数。这是算法分析里最好的情况,表示这个算法非常的迅速。
- 对数函数
注意,这里的对数底数并不重要。
- 线性函数
注意,添加常数系数不影响,即 $C \times n$ 等价于 $n$ 。
- $n\log n$ 函数
注意,$\log n$ 是一个比 $n$ 更小的函数,可以理解为
- 二次函数
常见的输入大小为 n
却要执行 $n^2$ 次的算法为两层 for
循环(嵌套循环)。
- 三次函数和其他多项式
与二次函数类似,这一类问题的操作数满足一个多项式函数。
- 指数函数
其中 $b$ 为某一个常数,指数增长非常迅速。
3.2 比较增长率
一般而言,我们希望
- 数据结构的操作运行时间与常数函数或对数函数成正比
- 算法以线性函数或 $n \log n$ 函数运行
- 运行时间与二次、三次函数相关的难以应对大规模输入数据的情形
- 指数函数则完全不可行
使用双对数刻度画出这 7 个函数的图像,即可直观地体悟它们的增长速度。
双对数刻度:指的是对坐标轴刻度进行放缩,按照 $10^1, 10^2, 10^3, \cdots$ 的数值等距标注刻度。
4 渐进分析
实际分析中,我们不用求出 $f(n)$ 的确切表达。只需知道算法操作时间与哪个形式的函数成正比即可。
4.1 大 O 符号
定义:令 $f(n),\ g(n): \N \to \R$ 的函数,如果对任意常数 $c > 0$ ,都存在整数 $n_0 \geq 1$ 使得
成立,则称 $f(n)$ 是 $O(g(n))$ 。
例如:对于 $f(n) = 3n+5$ 我们寻找 $n_0$ 使得 $f(n) = 3n+5 \leq c\cdot g(n) = c \cdot n$ 不难发现,当 $n \geq [\frac{5}{c-3}] + 1 := n_0$ 时 $f(n) \leq c\cdot g(n)$ 。所以可知 $f(n)$ 是 $O(n)$ 。
4.2 大 O 的性质
大 O 符号能让我们忽略那些常量因子和低阶项。常见的有
- 多项式保留最高阶即可
- $n$ 比 $\log n$ 增长的更快
- $1 \sim n$ 的 $k$ 次方求和
- 指数项含有常数
因为 $b^{n+c} = b^c \cdot b^{n} := C\cdot b^n$
但需要注意的是,不可以更换底数 $b$。
5 算法分析实例
5.1 寻找最大数
输入一个列表,输出列表中的最大值。列表的长度可以理解为输入大小 n
。
1 | def find_max(data): |
一层循环,所以大约执行 n 次。其他操作均为常数次,故这个算法时间复杂度为 O(n)
5.2 计算前缀平均值
输入一个列表,计算前 i
个数字的平均值。列表的长度可以理解为输入大小 n
。
5.2.1 二次算法
整个列表循环一层,然后在循环中再次循环加和
1 | def prefix_average1(data): |
第二层循环执行 i + 1 次,而 i 在第一层循环里从 0 到 n - 1 ,故整个算法操作次数大约为 $\sum_{i=0}^{n-1}\ (i+1)$ 省略低阶项,大致为 O(n^2)
5.2.2 线性算法
1 | def prefix_average2(data): |
如此只有一层循环,大致为 O(n)
5.3 三集不相交
输入三个序列 A, B, C
,输出是否存在交集。如果存在交集,则返回 True
否则返回 False
5.3.1 三层循环
1 | def disjoint1(A, B, C): |
显然,使用了三层循环。复杂度大致为 O(n^3)
。(准确而言应该为三个集合大小的乘积,但本质一样)
5.3.2 循环中加入判断
1 | def disjoint2(A, B, C): |
仍然为 O(n^3)
例如上面的例子,当 A, B, C
的相同元素在最后时,不得不完全遍历。
5.4 元素唯一性
给定一个长为 n
的序列,判断是否元素互不相同,是则为 True
5.4.1 简单的迭代
1 | def unique1(data): |
与 5.2.1 节 计算前缀平均值的二次算法类似,复杂度为 O(n^2)
5.4.2 先排序
1 | def unique2(data): |
循环执行了 n 次,Python 内置的 sorted 方法执行了 log n 次,故最后的复杂度为 O(n * log n)