原文: 算法复杂度(一)
date: 2021-01-12 17:42:14
前言
假如计算机的处理速度无限快, 存储空间无限大且廉价, 那似乎就没有理由来研究算法了.
从目前来看这一点还是不可能的
时间和空间作为一种资源, 而算法作为一种技术来研究如何更有效的利用这些资源.
为求解一个相同的问题而使用不同算法, 其表现出来的效率差异可能非常显著, 甚至可能超过由硬件和软件造成的差异.
所以正确理解算法复杂度分析以及衡量方式至关重要
目的
写本文的目的是为了提高自己对算法复杂度在理论层面上的认知, 着重分享自己的理解与他人分享中比较好的表述. 而且是带着问题的理解与表述.
不会过多示例去做算法复杂度分析, 所以文字占比相较代码更多.
算法复杂度
我们分析算法是为了预测程序算法所需的资源, 相较内存, 网络带宽, 或是其它软硬件资源来说, 通常我们更加关心的是算法所耗费的时间资源和空间资源.
那么如何来度量一个算法程序的时间和空间呢?
就拿时间复杂度来说: 一个算法执行所耗费的时间从理论上讲无法单靠公式精确计算得出, 所以算法的时间复杂度不应该单纯的理解为算法的执行时间
比较简单且合理的理解是算法所耗时间(效率)随数据规模增长的变化趋势, 也称之为渐进时间复杂度(asymptotic time complexity)
那么类似的, 空间复杂度表达的就是算法所耗空间与数据规模之间的增长关系
重点理解"渐进"
BigO Notation
概念&简单推导
我们经常看到的是算法复杂度分析得到一个带O
的公式结果, 例如O(n^2)
, O(logn)
, O(m+n)
...
例如这张图中列举了常见排序算法的复杂度(这里只关注O即可)
括号里面的数学公式我们都能看懂, 那么这个大O
符号和变量n
是什么意思?
其实很简单, 我们只需理解定义 (这里以时间的复杂度为例)
一般来说, 算法耗费的时间与输入的规模成正比, 如果把算法运行的指令数
, 或是最简单的把算法中肉眼可见的每条代码陈述语句
当作所消耗的一个时间单元的话, 我们可以认为算法耗费的时间与运行指令数
/ 代码执行次数
成正比. 即:
- 运行时间 ∝ 输入规模
- 运行时间 ∝ 指令数 / 代码执行的次数
指令数 / 代码执行次数是输入规模n
的某个函数f(n)
, 例如一个单层循环中f(n) = n
, 双层嵌套循环中f(n) = n^2
现在我们只推出了执行时间与指令数/代码执行次数的正比关系, 并不能将T(n)直接与f(n)划上等号, 并且前面在介绍概念也提到了时间复杂度描述的是执行时间与数据规模增长的变化趋势
所以我们用O来表示这一含义, 即: T(n) = O(f(n))
所以最最简单来说大O描述的是算法的运行时间和输入规模之间的关系
其中, 输入规模可以是输入的项数(例如数组元素的个数n), 或是二进制输入的总位数(例如求解两数相加), 再或其它.
大O有非常严谨的数学定义和证明, 但是在算法分析领域或是应对常见的算法分析, 了解这些已足够
去除非主导因子
通常我们计算出的公式可能是多项式, 其中会包含了低阶项, 常量, 常量系数等.
这类项在数据规模不断增长时并不能很明显的左右最终结果, 可以称之为非主导因子, 大多時候应该去掉他们
- 去除常量, 系数常量
- 去除低阶项
例如: O(N² + 2N + 8)
就可以转化为 O(N²)
, 其中低阶项2N
和常量8
就是非主导因子
再例如:
算法A: 时间复杂度为O(n), 其所需执行指令数可能是: 10000n
算法B: 时间复杂度为O(n^2), 其所需执行指令数可能是: 10*n^2
前面反复提到了"渐进", 所以大多时候, 我们不需要耗费很多时间来分析出一个冗长的函数来作为复杂度表达式, 这也与数学中函数的渐近分析有着很多类似
思考
思考这几个问题, 这也是我开始的困惑
1. O 是否一定表示算法最坏情况(上界) ?
从《算法导论》给出的 大O 定义以及数学渐近符号BigO notation定义来讲, 它确实代表渐近上界.
包括很多资料中的描述也是指上界, 因为上界能囊括所有数据输入的情况, 某些情況下比较有用.
但在实际中, 算法的复杂度除了受输入数据规模影响外, 还可能受输入的数据形式所影响, 也就是說算法复杂度分析在有些情况下是用例相关的
最典型的例子就是输入数据的有序程度对插入排序和快速排序算法复杂度的影响
比如我们平常说插入排序是O(n^2)的, 快速排序是O(nlogn)的, 这时我们描述的就是一般情况, 也就是平均情况 而非最坏.
因为对于排序来说, 完全乱序和本来有序的可能性很低, 所以最坏\最好这两种特殊情况并不具有普遍意义, 而平均情况更能代表一般情况, 更有意义.
所以大O在业内描述算法时并不严格指上界
算法中的描述方式勿与数学中的渐近符号描述混淆
例如数学中函数
f(n) = logn
的函数渐近界:O(logn)
和O(n)
都可以說是它的上界, 但在算法领域一般不这么看, 就用O表示算法执行的最低上界PS: 如果你还不了解数学中的函数渐近界, 那就不用管了, 因为混淆不了
2. 为什么不考虑最好的情况 ?
因为对于大多情况的算法来说, 给定特殊的输入, 即在最优情况下都可以达到O(1), 并不具有太大意义
3. 为什么要去除非主导因子 ?
最显著的原因是随着数据规模的增大, 这些非主导因子的增长速度会远小于主导因子.
你可以输入几个函数在 GeoGebra图形计算器 , 用函数图形直观的感受到
我们前面也说过算法复杂度表示的是一个算法执行效率与数据规模增长的变化趋势, 所以:
- 低阶项再大也会被忽略, 因为它对增长趋势的影响相较高阶项来说越来越小;
- 常量再大也会被忽略, 因为它对增长趋势并没有任何影响;
还有另外一个原因, 实际上有些项很难被精确分析得到, 通常在分析算法复杂度时, 也只是把算法程序代码中肉眼可见的陈述语句当作所消耗的一个时间单元.
这是粗略的, 因为实际上这些项可能因为编程语言和编译器的不同, 实际运行时间和步数不等, 对应其机器码的指令数也可能不同, 甚至相同的指令在不同的CPU下执行的操作也可能不同.
Who care? 所以并不值得花大力气去把它们精确出来, 在数据规模趋于很大的渐进分析中, 我们不用关注西瓜旁的芝麻
实际上这些低阶项和常数并不能说是完全没有意义, 有时可能会利用其低阶项或常数更小来优化算法
例如在快速排序中, 对于规模比较小的数组转而使用插入排序, 通常这种优化能获得10~15%的提升
4. 大O表示法下的低阶复杂度算法一定比高阶复杂度算法快吗 ?
例如有这样两个复杂度分析得到的表达式
- O(n) : e.g.
T = 40000n + 1
- O(n²): e.g.
T = 2n² + 1
可以大概算出在数据输入规模 n<200
的范围内, 前者的运行时间T都是大于后者的
所以我们不能认为对于任意输入, O(n)复杂度的算法都要快于O(n²)的.
这个结论还是有实际意义的, 如果更高阶的算法复杂度有着比较小的常量因子, 假如你在能明确预知数据规模不大的情况下, 确实没必要选择或者将其优化至更低阶复杂度的算法, 因为这种情况下时间和空间总是够用的.
再例如在复杂度为O(nlogn)快速排序中, 对于规模比较小的数组转而使用复杂度为O(n²)的插入排序, 通常这种优化能获得10~15%的提升
再结合上面的问题3. 为什么要去除非主导因子 ?
, 我们能看出当数据规模较小, 或是常量, 系数常量, 低阶项这些非主导因子很大的情况下, 它们是不应该被忽略的~
虽然更低阶的复杂度不一定就快, 但依然具有普遍意义和价值, 因为渐进分析中的数据规模理解为数据规模无限增加, 也就是说默认基于数据规模很大这样一个事实, 算法研究的价值也在于数据规模较大情况下的效率提升
渐进的分析方法对于某个算法除很小输入规模以外的所有情况都是很好的选择
空间复杂度
了解了时间复杂度, 类比一下, 算法的空间复杂度描述的就是存储空间与输入规模的增长关系, 全称为渐进空间复杂度
相比之下, 空间复杂度的分析要比时间复杂度简单.
因为通常情况下, 空间复杂度一般都是O(1), O(n), O(n^2)之类的, 对数阶等一些复杂的一般不会出现
直观数据规模
简单做一个不太准确, 但却很有实际用处的例子
- 简单代码模拟几个常见复杂度的算法的运行时间
- 时间假定为1秒
也就是直观的来看一下: 1秒左右的时间不同复杂度的算法大概能够处理多大规模的数据
-
O(n)
public void oN(long n) { long sum = 0; for (long i = 0; i < n; i++) { sum ++; } }
-
O(nlogn)
public void oNLogN(long n) { long sum = 0; for (long i = 0; i < n; i++) { for (long j = 1; j < n; j = j*2) { sum ++; } } }
-
O(n²)
public void oNSquared(long n) { long sum = 0; for (long i = 0; i < n; i++) { for (long j = 0; j < n; j++) { sum ++; } } }
我是在一台CPU 2.6GHz的工作电脑上测试的, 测试结果如下:
时间复杂度 | 数据规模 | 量级(大约) | 运行时间(大约) |
---|---|---|---|
O(n) |
13亿 | 10^8 (亿) | 1s |
O(nlogn) |
3000万 | 10^7 (千万) | 1s |
O(n²) |
3万8 | 10^4 (万) | 1s |
知道其中一个算法复杂度1s能够处理的数据规模量级, 那么其它几个复杂度1s能够处理的数据量级也能大致估算到.
因为测试算法的逻辑很简单, 实际估算中你可以适当把数据规模/量级减少
1——10
倍左右, 以便做出更符合实际情况的估算
这个测试的目的是对什么复杂度的算法, 什么规模的数据, 运行多长时间有一个大概了解. 同时也验证了算法复杂度中数据规模与执行时间的正比关系, 以及它们之间大致的倍数增长关系
实际中运行环境和算法逻辑可能复杂很多, 所以我们只需要了解一个大概量级的差异, 以便我们能够大致估算遇到的算法问题
你只需要知道能够预知数据规模的大概量级前提下, 这个算法是运行几秒, 还是几分钟, 还是卡死?
再或是要你为生产环境设计一个秒级处理百万级别数据规模的算法, 很明显这时你就不要考虑O(n^2), O(n^3)这样的算法了, 而是应该考虑更低阶复杂度的算法
如何分析一个算法的复杂度
基本层面分析
我们在分析一个算法复杂度时, 不需要考虑程序算法最终变为机器码后长什么样子, 具体执行了什么指令, 占用了多少个字节等.
只需要
- 简单的把算法中的每个陈述语句当作所消耗的一个时间单元
- 简单的把开辟的变量或数据结构当作所消耗的一个空间单元
这里就只介绍一种最简单最易理解的一种方法: 频次计数法(Frequency Count Method)
示例
Example 1
累加数组arr中的前n个元素
sum(int[] arr, int n) {
int sum = 0; // 1
for (int i = 0; i < n; ++i) { // n+1
sum = sum + arr[i]; // n
}
return sum; // 1
}
时间复杂度
代码注释中标注了每行代码的执行次数
所以: T(n) = 2n+3
==> 时间复杂度为 O(n)
空间复杂度
算法中所用到的变量和数据结构
变量/数据结构 | 所耗空间单元 |
---|---|
arr | n |
n | 1 |
sum | 1 |
i | 1 |
所以: S(n) = n + 3
==> 空间复杂度为O(n)
这种的分析起来比较简单, 所以下面来看几个复杂度为√ ̄
和log
的
Example 2
{
int p = 0;
for (i = 1; p <= n; i++) {
p = p + i;
}
}
如果不太容易看出, 可以简单的枚举一下
i | p |
---|---|
1 | 0 +1 |
2 | 1 + 2 |
3 | 1 + 2 + 3 |
4 | 1 + 2 + 3 +4 |
... | ... |
k | 1 + 2 + 3 + 4 + ... + k (注意这里是k, 不是n) |
p
到底执行了多少次, 可以根据循环的终止条件p <= n
来得到
所以可以假设最终执行了p (p>n)次
∵ p = 1+2+3+...+k = k(k+1)/2
且 p > n
∴ k(k+1)/2 > n
∴ k^2 + k > 2n
去掉两边的低阶项和常量得到 k^2 > n
∴ k > √n
得到时间复杂度为: O(√n)
同样下面这个简单算法的时间复杂度也为O(√n)
for (i = 0; i * i < n; i ++) {
stmt;
}
Example 3
for (i = 1; i < n; i = i * 2) {
stmt;
}
i |
---|
1 |
1 * 2 = 2^1 |
1 * 2 * 2 = 2^2 |
1 * 2 * 2 * 2 = 2^3 |
... |
1 * 2 * 2 * 2 * ... * 2 = 2^k |
同样, 要求出k的值, 循环终止条件假设i >= n
∴ 2^k >= n
∴ k >= logn
得到算法时间复杂度为 O(logn)
类似的来看这个
for (i = n; i >= 1; i = i/2 ) {
stmt;
}
i |
---|
n |
n / 2 |
n / 2^2 |
n / 2^3 |
... |
n / 2^k |
根据循环终止条件假设 i < 1
∵ n / 2^k < 1
∴ 2^k > n
∴ k >= logn
所以得到时间复杂度为O(logn)
Example 4
来看一种组合情况
p = 0;
for (i = 1; i < n; i = 2*i) {
p ++; // 1
}
for (j = 1; j < p; j = 2*j) {
stmt; // 2
}
变量比较多时注意辨别清楚哪个是输入规模n, 哪个是临时变量
这里的2(loop2)不能直接确定循环次数, 所以需要先看loop1中p的循环次数
- loop 1: p = logn次
- loop 2: logp次
所以, 时间复杂度为O(loglogn)
类似的看一下这个
for (i = 0; i < n; i++) { // 1
for (j = 1; j < n; j = j*2) { // 2
stmt; // 3
}
}
分别分析1,2,3处的执行次数:
- n
- n * logn
- n * logn
所以, T(n) = 2nlogn + n
, 时间复杂度为O(nlogn)
补充
1. 对数复杂度公式中的底
我们看到的复杂度公式中对数函数一般都是以2为底, 例如: ,
是因为大O复杂度公式计算中: 以其它常量为底的log公式(e.g. , a为常数) 经过去非主导因子后都可以转化为 以2为底的log公式. 例如: 可认为等同于 , 即
因为 , 而 是一个常量
其实就是对数的换底公式, 所以: log以任何数为底n的对数 都等于 一个常数 * log以2为底n的对数
对数函数在没有底数时,默认底数为2
e.g.
2. 通过对数公式简化复杂度公式
遇到一些比较复杂的公式例如平方, 对数等, 在求大O复杂度公式时, 可以利用一些对数公式来简化求解
补充一些常用的log公式:
一个面试题
题目: 有一个字符串数组, 将数组中的每一个字符串按照字母序排序; 之后再将整个字符串数组按照字典序排序. 分析整个操作的时间复杂度.
易错答案: O(n*nlogn + nlogn)
= O(n²logn)
错误的原因是没有将字符串的长度考虑进去.
这个算法的时间复杂度应该从两个维度考虑, 并且这两者之间没有关系:
- 字符串数组中最长元素的长度s;
- 数组中字符串元素的个数n;
然后分步来考虑(排序这里假设选择O(nlogn)
的排序算法):
- 对一个字符串元素排序:
O(slogs)
; - 将数组中的每一个字符串按照字母序排序:
O(n) * O(slogs)
=O(n*slogs)
; - 整个字符串数组按照字典序排序:
O(s) * O(nlogn)
=O(s*nlogn)
;
注意3中, 除了比较nlogn次以外, 每次字典序比较还需要耗费O(s)
字符串比较不像数字那样是O(1)的, 需要考虑进去
所以最终的时间复杂度为: O(n*slogs) + O(s*nlogn)
= O(n*slogs + s*nlogn)
意义
要是说我们为什么需要分析算法的复杂度, 或是说分析一个算法复杂度的目的, 我们都应该知道答案.
在了解算法复杂度分析之前, 如果让你来衡量一个算法在时间和空间上的执行效率, 相信也不是一个难题.
比如将算法程序直接在计算机上跑一下, 再用一些监控工具来监控时间和空间占用即可, 并且这样的结果也很直观
但是是否清楚我们为什么要选择这种方式呢? 算法复杂度分析的优势和意义在哪里? 二者应该怎么选择?
首先, 直接运行测算结果或者做基准测试都是完全正确的, 它也有个名字叫"事后统计法"
但是它有着这样的局限性:
- 受环境影响较大. 例如系统运行环境, 处理器, 算法的实现语言等
- 受数据规模影响较大, 数据规模较小可能无法反映出算法的性能, 具体多大的数据规模则需要准备很多测试数据.
例如排序算法, 我们都知道快排的效率要高于插入排序, 但是在小规模数据下, 这个对比结果可能是相反的 - 对测试结果无法有一个直观且感性认知, 不好窥其内因
正因如此, 我们很不方便用实际运行结果向别人表达出某个算法的效率
比如我现在告诉你, 我刚才用Java这样一种编译型语言实现了一个算法, 用10w测试数据在CPU 2.7GHz Core i5, Windows系统下跑了0.75s
, 你会是什么反应? 觉得是快还是慢呢?
所以我们需要复杂度分析这样一个相对简单快捷, 与运行环境无关, 且让我们能在算法设计阶段就对效率有一个直观认知的分析方法
因为算法复杂度分析是渐进分析, 所以在有了这样一个"事前"的理论分析后再根据自己的环境和数据规模再进行实际运行测试, 会得到更加精准的结果
所以二者是相辅相成的