Big-O 表示法(也称为“渐近增长”表示法)是当您忽略原点附近的常数因素和东西时,函数“看起来像”的样子。我们用它来谈论事物如何扩展。
基础知识
对于“足够”大的输入...
-
f(x) ∈ O(upperbound) 表示 f“增长速度不超过”upperbound
-
f(x) ∈ Ɵ(justlikethis) 意思是 f “长得一模一样” justlikethis
-
f(x) ∈ Ω(lowerbound) 表示 f “增长速度不低于” lowerbound
big-O 表示法不关心常数因子:函数9x² 被称为“完全像”10x²。 big-O asymptotic 表示法也不关心 non-asymptotic 东西(“靠近原点的东西”或“当问题规模很小时会发生什么”):函数 @ 987654341@ 据说“长得一模一样”10x² - x + 2。
为什么要忽略等式的较小部分?因为当你考虑越来越大的尺度时,它们与方程的大部分相比变得完全相形见绌;他们的贡献变得渺小和无关紧要。 (参见示例部分。)
换句话说,当你走向无穷大时,这一切都与比率有关。 如果你将实际花费的时间除以O(...),你将得到一个限制大输入限制的常数因子。直观地说,这是有道理的:如果你可以相乘,函数就会“像”一样缩放一个得到另一个。那就是我们说...
actualAlgorithmTime(N) ∈ O(bound(N))
e.g. "time to mergesort N elements
is O(N log(N))"
...这意味着对于“足够大”的问题大小 N(如果我们忽略原点附近的东西),存在一些常数(例如 2.5,完全组成)这样:
actualAlgorithmTime(N) e.g. "mergesort_duration(N) "
────────────────────── < constant ───────────────────── < 2.5
bound(N) N log(N)
常数有多种选择;通常“最佳”选择被称为算法的“常数因子”......但我们经常忽略它,就像我们忽略非最大项一样(请参阅常数因子部分了解它们通常不重要的原因)。你也可以把上面的等式看作一个界限,说“在最坏的情况下,它所花费的时间永远不会比大约N*log(N) 差,在 2.5 的因子内(我们没有的常数因子”不太在乎)”。
一般来说,O(...) 是最有用的,因为我们经常关心最坏情况的行为。如果f(x) 代表“坏”的东西,比如处理器或内存使用情况,那么“f(x) ∈ O(upperbound)”意味着“upperbound 是处理器/内存使用情况的最坏情况”。
应用程序
作为一个纯粹的数学结构,大 O 表示法不限于谈论处理时间和内存。您可以使用它来讨论缩放有意义的任何事物的渐近线,例如:
- 聚会上
N 人之间可能握手的次数(Ɵ(N²),特别是N(N-1)/2,但重要的是它“像”N²)
- 看到一些病毒式营销的人的概率预期数量是时间的函数
- 网站延迟如何随 CPU、GPU 或计算机集群中的处理单元数量而变化
- CPU 芯片上的热量输出如何随晶体管数量、电压等变化。
- 算法需要运行多长时间,作为输入大小的函数
- 算法需要运行多少空间,作为输入大小的函数
示例
对于上面的握手示例,房间中的每个人都与其他人握手。在该示例中,#handshakes ∈ Ɵ(N²)。为什么?
稍微备份一下:握手的次数恰好是 n-choose-2 或 N*(N-1)/2(N 个人中的每个人都与其他 N-1 个人握手,但是这重复了握手,所以除以 2):
但是,对于非常多的人来说,线性项 N 相形见绌,实际上对比率的贡献为 0(在图表中:对角线上的空框占总框的比例随着参与者数量的增加而变小变大)。因此缩放行为是order N²,或者握手的次数“像 N² 一样增长”。
#handshakes(N)
────────────── ≈ 1/2
N²
就好像图表对角线上的空框(N*(N-1)/2 个复选标记)甚至不存在(N2 个复选标记渐近)。
(临时题外话“plain English”:)如果您想证明这一点,您可以对比率执行一些简单的代数,将其拆分为多个项(lim 表示“考虑到的限制” , 没看过的就忽略吧,它只是“and N is really really big”的记号):
N²/2 - N/2 (N²)/2 N/2 1/2
lim ────────── = lim ( ────── - ─── ) = lim ─── = 1/2
N→∞ N² N→∞ N² N² N→∞ 1
┕━━━┙
this is 0 in the limit of N→∞:
graph it, or plug in a really large number for N
tl;dr:对于大值,握手的次数“看起来像”x² 如此之多,如果我们要写下比率 #handshakes/x²,我们不需要的事实 确切地说 x² 握手甚至不会在任意大的时间内显示在十进制中。
例如对于 x=100 万,比率 #handshakes/x²:0.499999...
建立直觉
这让我们可以做出类似...的陈述
“对于足够大的inputsize=N,不管常数因子是多少,如果我加倍输入尺寸...
- ... 我将 O(N)(“线性时间”)算法所花费的时间加倍。”
N → (2N) = 2(N)
- ...我将 O(N²)(“二次时间”)算法所花费的时间平方(四倍)。”(例如,100 倍大的问题需要 100²=10000 倍的时间...可能不可持续)
N² → (2N)² = 4(N²)
- ...我将 O(N³)(“立方时间”)算法所花费的时间加倍(八倍)。” (例如,100 倍大的问题需要 100³=1000000 倍的时间...非常不可持续)
cN³ → c(2N)³ = 8(cN³)
- ...我在 O(log(N))(“对数时间”)算法所花费的时间上加上一个固定的量。” (便宜!)
c log(N) → c log(2N) = (c log(2))+(c log(N)) = (固定数量)+ (c log(N))
- ...我不会更改 O(1)(“恒定时间”)算法所花费的时间。” (最便宜的!)
c*1 → c*1
- ...我“(基本上)加倍”了 O(N log(N)) 算法所花费的时间。” (相当常见)
c 2N log(2N) / c N log(N)(这里我们将 f(2n)/f(n) 相除,但我们可以像上面那样对表达式进行处理并分解出 cNlogN如上)
→ 2 log(2N)/log(N)
→ 2 (log(2) + log(N))/log(N)
→ 2*(1+(log2N)-1) (对于大 N 基本上是 2;最终小于 2.000001)
(或者,对于您的数据来说,log(N) 将始终低于 17,因此它是 O(17 N),它是线性的;但这既不严谨也不合理)
- ... 我荒谬地增加了 O(2N)(“指数时间”)算法所花费的时间。” (你可以加倍(或三倍等)只需将问题增加一个单位即可)
2N → 22N = (4N)......... ...换一种说法...... 2N → 2N+1 = 2N 21 = 2 2N
[对于数学倾向,您可以将鼠标悬停在剧透上以获取较小的旁注]
(归功于https://stackoverflow.com/a/487292/711085)
(从技术上讲,常数因素在一些更深奥的例子中可能很重要,但我已经在上面说了一些事情(例如在 log(N) 中)所以它不)
这些是程序员和应用计算机科学家用作参考点的基本成长顺序。他们总是看到这些。 (因此,虽然您在技术上可以认为“将输入加倍会使 O(√N) 算法慢 1.414 倍”,但最好将其视为“这比对数差,但比线性好”。)
常数因子
通常,我们并不关心具体的常数因子是什么,因为它们不会影响函数的增长方式。例如,两种算法可能都需要O(N) 时间才能完成,但其中一种可能比另一种慢两倍。我们通常不会太在意,除非因素非常大,因为优化是一件棘手的事情(When is optimisation premature?);此外,仅仅选择具有更好大 O 的算法通常会提高性能几个数量级。
一些渐近优越的算法(例如,非比较 O(N log(log(N))) 排序)可能具有如此大的常数因子(例如 100000*N log(log(N))),或者像 O(N log(log(N))) 这样的相对较大的开销,带有隐藏的 + 100*N,即即使在“大数据”上,它们也很少值得使用。
为什么有时 O(N) 是你能做的最好的,即为什么我们需要数据结构
O(N) 算法在某种意义上是“最好”的算法,如果您需要读取所有数据。 读取一堆数据的行为是O(N) 操作。将其加载到内存中通常是O(N)(如果您有硬件支持,则速度更快,或者如果您已经读取数据,则根本没有时间)。但是,如果您触摸甚至查看每条数据(甚至每条其他数据),您的算法将花费O(N) 时间来执行此查看。无论您的实际算法需要多长时间,它至少会是O(N),因为它花费了这段时间查看所有数据。
写作本身也是如此。所有打印出 N 东西的算法都需要 N 时间,因为输出至少有那么长(例如打印出所有排列(重新排列的方法)一组 N 扑克牌是阶乘:O(N!)(这就是为什么在这些情况下,好的程序将确保迭代使用 O(1) 内存,并且不会打印或存储每个中间步骤))。
这激发了数据结构的使用:一个数据结构只需要读取一次数据(通常是O(N)时间),外加一些任意数量的预处理(例如O(N)或O(N log(N))或O(N²)),我们尽量保持小。此后,修改数据结构(插入/删除/等)并对数据进行查询只需很少的时间,例如O(1) 或O(log(N))。然后您继续进行大量查询!一般来说,你愿意提前做的工作越多,你以后要做的工作就越少。
例如,假设您拥有数百万条路段的经纬度坐标,并且想要查找所有街道交叉口。
- 简单的方法:如果您有一个街道交叉口的坐标,并且想要检查附近的街道,您将不得不每次遍历数百万个路段,并检查每个路段是否相邻。
- 如果你只需要做一次,那么
O(N)的幼稚方法只工作一次是没有问题的,但是如果你想做很多次(在这种情况下,N次,每个段一次),我们必须做 O(N²) 工作,或 1000000²=1000000000000 次操作。不好(现代计算机每秒可以执行大约十亿次操作)。
- 如果我们使用称为哈希表(一种即时查找表,也称为哈希图或字典)的简单结构,我们会通过在
O(N) 时间内预处理所有内容来支付少量成本。此后,通过它的键查找某个东西平均只需要恒定的时间(在这种情况下,我们的键是经纬度坐标,四舍五入成一个网格;我们搜索相邻的网格空间,其中只有 9 个,这是一个常数)。
- 我们的任务从不可行的
O(N²) 变成了可管理的O(N),我们所要做的就是支付少量成本来制作哈希表。
-
类比:在这种特殊情况下的类比是一个拼图游戏:我们创建了一个利用数据的某些属性的数据结构。如果我们的路段就像拼图一样,我们通过匹配颜色和图案对它们进行分组。然后我们利用这一点来避免以后做额外的工作(比较相同颜色的拼图,而不是与其他所有拼图)。
故事的寓意:数据结构可以让我们加快操作速度。更重要的是,高级数据结构可以让您以非常聪明的方式组合、延迟甚至忽略操作。不同的问题会有不同的类比,但它们都涉及以利用我们关心的某种结构的方式组织数据,或者我们为了记账而人为地强加给它的结构。我们确实提前工作(基本上是计划和组织),现在重复的任务要容易得多!
实际示例:在编码时可视化增长顺序
从本质上讲,渐近符号与编程完全不同。渐近符号是一种数学框架,用于思考事物如何扩展并可以用于许多不同的领域。也就是说...这就是您将渐近符号应用于编码的方式。
基础知识:每当我们与大小为 A 的集合中的每个元素(例如数组、集合、映射的所有键等)进行交互,或执行循环的 A 迭代时,这就是一个乘法因子大小 A。为什么我说“乘法因子”?--因为循环和函数(几乎按照定义)具有乘法运行时间:迭代次数,循环中完成的工作时间(或函数:次数你调用函数,函数中完成的时间)。 (如果我们不做任何花哨的事情,比如跳过循环或提前退出循环,或者根据参数更改函数中的控制流,这很常见。)这里有一些可视化技术的例子,以及附带的伪代码。
(这里,xs 代表恒定时间工作单元、处理器指令、解释器操作码等)
for(i=0; i<A; i++) // A * ...
some O(1) operation // 1
--> A*1 --> O(A) time
visualization:
|<------ A ------->|
1 2 3 4 5 x x ... x
other languages, multiplying orders of growth:
javascript, O(A) time and space
someListOfSizeA.map((x,i) => [x,i])
python, O(rows*cols) time and space
[[r*c for c in range(cols)] for r in range(rows)]
示例 2:
for every x in listOfSizeA: // A * (...
some O(1) operation // 1
some O(B) operation // B
for every y in listOfSizeC: // C * (...
some O(1) operation // 1))
--> O(A*(1 + B + C))
O(A*(B+C)) (1 is dwarfed)
visualization:
|<------ A ------->|
1 x x x x x x ... x
2 x x x x x x ... x ^
3 x x x x x x ... x |
4 x x x x x x ... x |
5 x x x x x x ... x B <-- A*B
x x x x x x x ... x |
................... |
x x x x x x x ... x v
x x x x x x x ... x ^
x x x x x x x ... x |
x x x x x x x ... x |
x x x x x x x ... x C <-- A*C
x x x x x x x ... x |
................... |
x x x x x x x ... x v
示例 3:
function nSquaredFunction(n) {
total = 0
for i in 1..n: // N *
for j in 1..n: // N *
total += i*k // 1
return total
}
// O(n^2)
function nCubedFunction(a) {
for i in 1..n: // A *
print(nSquaredFunction(a)) // A^2
}
// O(a^3)
如果我们做一些稍微复杂的事情,你仍然可以直观地想象发生了什么:
for x in range(A):
for y in range(1..x):
simpleOperation(x*y)
x x x x x x x x x x |
x x x x x x x x x |
x x x x x x x x |
x x x x x x x |
x x x x x x |
x x x x x |
x x x x |
x x x |
x x |
x___________________|
在这里,您可以绘制的最小可识别轮廓很重要;三角形是二维形状(0.5 A^2),就像正方形是二维形状(A^2);这里的常数因子 2 保留在两者之间的渐近比中,但是,我们像所有因子一样忽略它......(我不在这里讨论这种技术的一些不幸的细微差别;它可能会误导你。)
当然这并不意味着循环和函数不好;相反,它们是现代编程语言的基石,我们喜欢它们。但是,我们可以看到,我们将循环、函数和条件与我们的数据(控制流等)编织在一起的方式模仿了我们程序的时间和空间使用!如果时间和空间使用成为问题,那就是当我们求助于聪明并找到一个我们没有考虑过的简单算法或数据结构时,以某种方式降低增长顺序。然而,这些可视化技术(尽管它们并不总是有效)可以让您天真地猜测最坏情况下的运行时间。
这是我们可以通过视觉识别的另一件事:
<----------------------------- N ----------------------------->
x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x
x x x x x x x x x x x x x x x x
x x x x x x x x
x x x x
x x
x
我们可以重新排列一下,看看它是 O(N):
<----------------------------- N ----------------------------->
x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x
x x x x x x x x x x x x x x x x|x x x x x x x x|x x x x|x x|x
或者也许你对数据进行 log(N) 次传递,总时间为 O(N*log(N)):
<----------------------------- N ----------------------------->
^ x x x x x x x x x x x x x x x x|x x x x x x x x x x x x x x x x
| x x x x x x x x|x x x x x x x x|x x x x x x x x|x x x x x x x x
lgN x x x x|x x x x|x x x x|x x x x|x x x x|x x x x|x x x x|x x x x
| x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x|x x
v x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x|x
无关但值得再次提及:如果我们执行哈希(例如字典/哈希表查找),那是 O(1) 的一个因素。这相当快。
[myDictionary.has(x) for x in listOfSizeA]
\----- O(1) ------/
--> A*1 --> O(A)
如果我们做一些非常复杂的事情,例如使用递归函数或分治算法,您可以使用Master Theorem(通常有效),或者在荒谬的情况下使用 Akra-Bazzi 定理(几乎总是有效)你在维基百科上查找你的算法的运行时间。
但是,程序员不会这样想,因为最终,算法直觉会成为第二天性。您将开始编写一些低效的代码并立即思考“我在做某事效率极低吗?”。如果答案是“是”并且您预见到它实际上很重要,那么您可以退后一步,想出各种技巧来让事情运行得更快(答案几乎总是“使用哈希表”,很少“使用树”,并且很少有更复杂的东西)。
摊销和平均案例复杂度
还有“摊销”和/或“平均情况”的概念(注意它们是不同的)。
平均情况:这只不过是对函数的期望值使用大 O 表示法,而不是函数本身。在通常情况下,您认为所有输入的可能性相同,平均情况只是运行时间的平均值。例如对于快速排序,即使对于一些非常糟糕的输入,最坏的情况是O(N^2),但平均情况是通常的O(N log(N))(真正糟糕的输入数量非常少,以至于我们没有注意到它们一般情况下)。
摊销的最坏情况:某些数据结构的最坏情况复杂度可能很大,但保证如果您执行许多此类操作,您所做的平均工作量会更好比最坏的情况。例如,您可能有一个通常需要常数O(1) 时间的数据结构。但是,偶尔它会“打嗝”并花费O(N) 时间进行一次随机操作,因为它可能需要做一些簿记或垃圾收集或其他事情......但它向您保证,如果它打嗝,它不会打嗝再次进行 N 次以上的操作。最坏情况下的成本仍然是每次操作的O(N),但多次运行的摊销成本是每次操作的O(N)/N = O(1)。由于大型操作很少见,因此可以将大量的临时工作视为与其余工作融为一体的恒定因素。我们说这项工作在足够多的调用中“摊销”了,它会逐渐消失。
摊销分析的类比:
你开车。有时候,你需要花 10 分钟去
加油站,然后花 1 分钟时间给油箱加油。
如果你每次开车去任何地方都这样做(花 10
几分钟开车到加油站,花几秒钟加油
一加仑的一小部分),这将是非常低效的。但是如果你填
每隔几天就上一次油箱,开车到
加油站在足够多的行程中“摊销”,
你可以忽略它,假装你所有的行程都可能延长了 5%。
平均情况与摊销最坏情况之间的比较:
- 平均情况:我们对输入做出一些假设;即如果我们的输入有不同的概率,那么我们的输出/运行时将有不同的概率(我们取平均值)。通常,我们假设我们的输入都是等可能的(均匀概率),但如果现实世界的输入不符合我们的“平均输入”假设,则平均输出/运行时计算可能毫无意义。但是,如果您预期均匀随机输入,那么考虑一下这一点很有用!
- 摊销的最坏情况:如果您使用摊销的最坏情况数据结构,则性能保证在摊销的最坏情况内......最终(即使输入是由无所不知的邪恶恶魔选择的,并且正试图把你搞砸)。通常,我们使用它来分析性能可能非常“不稳定”的算法,这些算法可能会出现意想不到的大问题,但随着时间的推移,它们的性能与其他算法一样好。 (但是,除非您的数据结构对愿意拖延的大量未完成工作有上限,否则邪恶的攻击者可能会迫使您一次性完成最大数量的拖延工作。
不过,如果您是关于攻击者的 reasonably worried,那么除了摊销和平均情况之外,还有许多其他算法攻击向量需要担心。)
平均情况和摊销都是非常有用的工具,可用于思考和设计时考虑到可扩展性。
(如果对此子主题感兴趣,请参阅Difference between average case and amortized analysis。)
多维大O
大多数时候,人们没有意识到有不止一个变量在起作用。例如,在字符串搜索算法中,您的算法可能需要时间O([length of text] + [length of query]),即它在两个变量(如O(N+M))中是线性的。其他更幼稚的算法可能是O([length of text]*[length of query]) 或O(N*M)。忽略多个变量是我在算法分析中看到的最常见的疏忽之一,并且会在设计算法时妨碍您。
整个故事
请记住,big-O 并不是故事的全部。您可以通过使用缓存来显着加速某些算法,使其无需缓存,通过使用 RAM 而不是磁盘来避免瓶颈、使用并行化或提前完成工作——这些技术通常是独立的增长顺序的“big-O”表示法,尽管您经常会在并行算法的 big-O 表示法中看到内核数量。
另外请记住,由于程序的隐藏约束,您可能并不真正关心渐近行为。您可能正在使用有限数量的值,例如:
- 如果您要对 5 个元素进行排序,您不想使用快速的
O(N log(N)) 快速排序;您想使用插入排序,它恰好在小输入上表现良好。这些情况经常出现在分治算法中,您可以将问题分解为越来越小的子问题,例如递归排序、快速傅立叶变换或矩阵乘法。
- 如果某些值由于某些隐藏的事实而得到有效限制(例如,人名的平均限制在 40 个字母左右,而人类年龄的限制在 150 左右)。您还可以对输入施加限制,以有效地使术语保持不变。
在实践中,即使在渐近性能相同或相似的算法中,它们的相对优劣实际上也可能受其他因素驱动,例如:其他性能因素(快速排序和归并排序都是O(N log(N)),但快速排序利用了CPU 缓存);非性能考虑,例如易于实施;图书馆是否可用,图书馆的声誉和维护情况如何。
程序在 500MHz 计算机上的运行速度也会比 2GHz 计算机上的慢。我们并没有真正将其视为资源界限的一部分,因为我们认为缩放是根据机器资源(例如每个时钟周期)而不是实际秒。但是,也有类似的事情会“秘密地”影响性能,例如您是否在仿真下运行,或者编译器是否优化了代码。这些可能会使一些基本操作花费更长的时间(甚至相对于彼此),甚至会渐近地加速或减慢一些操作(甚至相对于彼此)。不同实现和/或环境之间的影响可能很小或很大。你会切换语言或机器来勉强完成那一点额外的工作吗?这取决于其他一百个原因(必要性、技能、同事、程序员的生产力、您时间的金钱价值、熟悉度、变通方法、为什么不组装或 GPU 等...),这可能比性能更重要。
上述问题,例如选择使用哪种编程语言的影响,几乎从未被视为常量因素的一部分(也不应该如此);但是应该注意它们,因为有时(尽管很少)它们可能会影响事物。例如在 cpython 中,本机优先级队列实现是渐近非最优的(O(log(N)) 而不是 O(1) 供您选择插入或 find-min);你使用其他实现吗?可能不会,因为 C 实现可能更快,并且其他地方可能还有其他类似的问题。有权衡;有时它们很重要,有时它们不重要。
(edit:“简单的英语”解释到此结束。)
数学附录
为了完整起见,big-O 表示法的精确定义如下:f(x) ∈ O(g(x)) 表示“f 由 const*g 渐近上界”:忽略 x 的某个有限值以下的所有内容,存在一个常数,使得|f(x)| ≤ const * |g(x)|。 (其他符号如下:就像O 表示≤,Ω 表示≥。还有小写变体:o 表示ω 表示>。)f(x) ∈ Ɵ(g(x)) 表示f(x) ∈ O(g(x)) 和f(x) ∈ Ω(g(x))(g 的上限和下限):存在一些常数,使得 f 始终位于 const1*g(x) 和 const2*g(x) 之间的“带”中。这是你能做出的最强的渐近语句,大致相当于==。 (抱歉,为了清楚起见,我选择将绝对值符号的提及推迟到现在;特别是因为我从未见过在计算机科学环境中出现负值。)
人们会经常使用= O(...),这可能是更正确的“comp-sci”表示法,并且完全可以使用; “f = O(...)”读作“f is order ... / f is xxx-bounded by ...”并被认为是“f 是一些渐近线为 ...”的表达式。我被教导使用更严格的∈ O(...)。 ∈ 表示“是”的一个元素(仍然像以前一样阅读)。在这种特殊情况下,O(N²) 包含像 {2 N², 3 N², 1/2 N², 2 N² + log(N), - N² + N^1.9, ...} 这样的元素并且是无限大的,但它仍然是一个集合。
O 和 Ω 不是对称的(n = O(n²),但 n² 不是 O(n)),但Ɵ 是对称的,并且(因为这些关系都是传递和自反的)Ɵ 因此是对称的并且传递性和自反性,因此将所有函数的集合划分为等价类。等价类是我们认为相同的一组事物。也就是说,给定您能想到的任何函数,您都可以找到该类的规范/唯一“渐近代表”(通常取极限……我认为);就像您可以将所有整数分组为奇数或偶数一样,您可以通过基本上忽略较小的术语将所有带有 Ɵ 的函数分组为 x-ish、log(x)^2-ish 等...更复杂的函数,它们是独立的类)。
= 表示法可能是更常见的一种,甚至被世界著名的计算机科学家在论文中使用。此外,通常情况下,在随意的环境中,人们会说O(...),而他们的意思是Ɵ(...);这在技术上是正确的,因为Ɵ(exactlyThis) 的集合是O(noGreaterThanThis) 的一个子集......而且它更容易输入。 ;-)