在工程领域中,大量问题是难以达到最优解的,许多问题只是被“差不多”的解决了。问题的难以程度一方面取决于问题本身的性质,另一方面也取决于观测问题的人的只是储备。人的知识越完备,经验越多,分析问题就会越深入,问题就能被解决的更优雅。
——摘录
在前面一篇《DS&A重学日志:[一]浅析数据结构与算法的核心概念》中简单的用个人理解的方式介绍了下复杂度分析和大O复杂度表示法,接下来正式的引入科学的概念及定义。
在引入之前,先想一个问题:如果我们想准确的预估一段代码的运行时间,应该如何操作呢?
- 确定运行平台,包括硬件配置、编程语言、系统环境等,因为这些因素都会影响代码的运行效率。
- 评估各种计算操作所需要的运行时间,例如加法操作需要1ns, 乘法操作需要10ns,打印操作需要5ns等。
- 统计代码滑总所有的计算操作,并将所有的操作和执行时间求和,从而得到运行时间。
但实际上,上述的思路仅适合理论情况下,在实际场景中不合理也不现实,因为算法执行效率本就应该与平台无关,另一方面,我们没法准确的获取每种操作的运行时间。
由于实际测试具有较大的局限性,通常都是通过一些计算来评估算法的效率,也就是统计时间增长趋势,这种估算法的方法被称为:渐进复杂度分析(asymptotic complexity analysis),简称复杂度分析。
复杂度分析的定义
复杂度分析描述的是随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。可以从三个方面来理解:
- 时间和空间资源:分别对应时间复杂度(time complexity)和空间复杂度(space complexity)
- 随着输入数据大小的增加:反映了算法执行效率与输入数据体量之间的关系。
- 时间和空间的增长趋势:表示复杂度分析关注不是运行时间和占用空间的具体值,而是时间或空间增长的“快慢”
针对事后统计法存在的局限性提出的解决方案, 不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方案,这种方案叫做复杂度分析法,也叫大O复杂度表示法。复杂度分析法又分为:时间复杂度分析、空间复杂度分析
大O复杂度表示法
- 分类
- 大O时间复杂度表示法(又称:渐进时间复杂度,简称:时间复杂度)
- 大O空间复杂度表示法(又称:渐进空间复杂度,简称:空间复杂度)
- 公式:
T(n)=O(f(n))- T(n) : 表示代码执行的时间,n表示数据规模的大小
- fn: 表示每行代码执行次数的总和,这是一个公式,所以用fn表示
- O: 表示代码执行时间T(n)与f(n)表达式成正比
如:T(n) = 2n * unit_time 、 T(n) = (2n2+2n+3)*unit_time。
fn: 2n / fn: (2n2+2n+3)
T(n) = O(2n) / T(n) = O(2n2+2n+3)
大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随着数据规模增长的变化趋势,故也称为渐进时间复杂度。
如何进行时间复杂度分析?
专业(数学)方法:求出函数渐近上界
首先统计操作数量,然后判断渐近上界。
1 | def algorithm(n: int): |
如上述代码,给定一个输入大小为n的函数,设算法的操作数量是一个关于数据大小n的函数,记为T(n),则以上函数的操作数量为:T(n) = 3 + 2n,T(n)是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
通常将线性阶的时间复杂度记为O(n),这个数学符号称为大O记号(big-O natation),表示函数T(n)的渐近上界(asymptotic upper bound)。
由此可见时间复杂度分析本质上是计算“操作数量T(n)”的渐近上界。
公式:T(n)=O(f(n))
- T(n) : 表示代码执行的时间,n表示数据规模的大小
- fn: 表示每行代码执行次数的总和,这是一个公式,所以用fn表示
- O: 表示代码执行时间T(n)与f(n)表达式成正比
由此可见:确定f(n)之后,就可以得到时间复杂度O(f(n)).
实用(估算)方法:大O复杂度表示法
方法一:只关注循环执行次数最多的一段代码
- 通常会忽略T(n)的常量、低阶、系统、只需要记录一个最大阶的量级就好,这段代码执行次数的n的量级,就是整段要分析代码的时间复杂度。
方法二:加法法则:总复杂度等于量级最大的那段代码的复杂度
- 抽象成公式则为:如果T1(n)=O(f(n)),T2(n)=O(g(n));那么T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
方法三:乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。
- 抽象为公式则为:T1(n)=O(f(n)),T2(n)=O(g(n));那么T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)).
示例:
1 | def algorithm(n: int): |
完整统计:T(n) = 2n(n+1) + (5n+1) + 2 = $2n^2 + 7n +3$
偷懒统计: $n^2 + n$
上述代码的时间复杂度为:$O(n^2)$
常见时间复杂度类型
- 多项式量级:
- 常量阶O(1)、对数阶O(logn)、线性阶O(n)、线性对数阶O(nlogn)、k次方阶O(n^k)
- 非多项式量级:
- 指数阶O(2n) 、 阶乘阶O(n!)
- 当数据规模n越来越大是,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。
按执行效率从高到低排列常见的有:
$$O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)$$
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
常数阶O(1)
常数阶的操作数量与输入数据大小n无关,即不随着n的变化而变化。
- 常量级时间复杂度,并不是指只执行了一行代码;
- 只要代码的执行时间不随n的增大而增大,这样的代码的时间复杂度就记作O(1),
- 只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,起时间复杂度依然是O(1)
1 | def constant(n: int) -> int: |
线性阶O(n)
线性阶的操作数量相对于输入数据的大小n以线性级别增长,通常出现在单层循环中。
1 | def linear(n: int) -> int: |
需要注意的是:输入数据大小n需根据输入数据的类型来具体确定。比如在第一个示例中,变量 为输入数据大小;在第二个示例中,数组长度 为数据大小。
平方阶O($n^2$)
平方阶的操作数量相对于输入数据大小n以平方级别增长。通常出现在嵌套循环中,外层循环和内存循环的时间复杂度都为$O(n)$,因此总体的时间复杂度为$O(n^2)$
1 | def quadratic(n: int) -> int: |
指数阶 $O(2^n)$
在算法中,指数阶常出现于递归函数中。在生物学中的“细胞分裂”也是指数阶增长的典型代表。
1 | def exponential(n: int) -> int: |
指数阶增长速度非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
对数阶$O(log n)$
与指数阶相反,对数阶放映了“每轮缩减到一半”的情况。设输入数据大小为n, 由于每轮缩减到一半,因此循环次数是$log2n$,即$2^n$的反函数。
对数阶也常出现于递归函数和分治策略(一分为多和化繁为简)的算法中。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
1 | def logarithmic(n: int) -> int: |
线性对数阶$O(nlogn)$
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为$O(nlogn)$ 和 $O(n)$
主流的排序算法的时间复杂度通常为$O(nlogn)$,如:快速排序、归并排序、堆排序等。
1 | def linear_log_recur(n: int) -> int: |
阶乘$O(n!)$
阶乘在算法场景中,通常使用递归实现,在数学场景中,对应“全排列”问题。
1 | def factorial_recur(n: int) -> int: |
- 在n较大时是不可接受的。
如何进行空间复杂度分析?
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。其实和时间复杂度很类似,只需将“运行时间”替换成“占用内存空间”就好。
算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
暂存空间可以进一步划分为三个部分。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分
1 | class Node: |
推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”即可。
而与时间复杂度不同的是,我们通常只关注最差空间复杂度,这是因为内存空间是一项硬性要求,我们必须要确保在所有输入数据下都有足够的内存空间预留。
一般的最差有两层含义:
- 以最差输入数据为准
- 以算法运行中的峰值内存为准
另外需要注意,在递归函数中,需要统计栈帧空间。
1 | def function() -> int: |
函数 loop() 和 recur() 的时间复杂度都为 ,但空间复杂度不同。
- 函数
loop()在循环中调用了 次function(),每轮中的function()都返回并释放了栈帧空间,因此空间复杂度仍为 。 - 递归函数
recur()在运行过程中会同时存在 个未返回的recur(),从而占用 的栈帧空间。
常见空间复杂度类型
$O(1) < O(log n) < O(n) < O(n²) < O(2ⁿ)$
常数阶 < 对数阶 < 线性阶 < 平方阶 < 指数阶
常数阶O(1)
常数阶常见于数量与输入数据大小 无关的常量、变量、对象。
需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 :
1 | def function() -> int: |
线性阶O(n)
线性阶常见于元素数量与n成正比的数组、链表、栈、队列等。
1 | def linear(n: int): |
平方阶O($n^2$)
平方阶常见于矩阵和图,元素数量与n成平方关系:
1 | def quadratic(n: int): |
指数阶 $O(2^n)$
指数阶常见于二叉树。
1 | def build_tree(n: int) -> TreeNode | None: |
对数阶$O(log n)$
对数阶常见于分治算法。例如归并排序(输入长度为n 的数组,每轮递归将数组从中点处划分为两半,形成高度为 $(log n)$的递归树,使用$O(log n)$ 栈帧空间。)、数字转化为字符串。
权衡利弊
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”,反之,则称为“以时间换空间”。
选择那种思路取决于我们更看中哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。