大O符号,如何计算或近似计算?

976

大多数计算机科学专业的毕业生肯定知道Big O代表什么。它帮助我们衡量算法的可扩展性。

但是我很好奇,你们是如何计算或近似计算算法复杂度的呢?


5
也许你并不需要提高算法的复杂度,但至少你应该能够计算它来做出决定… - Xavier Nodet
5
我发现这是一个非常清晰的关于大O、大Ω和大θ的解释:http://xoax.net/comp/sci/algorithms/Lesson6.php - Sam Dutton
45
Big-O并不衡量效率,它衡量的是算法随着规模的扩大而扩展的好坏(它也可以适用于其他事物,但我们可能只关心这个)。而且,它只是渐近地衡量,所以如果你运气不好,一个具有“较小”Big-O的算法可能比另一个算法慢(如果Big-O适用于循环),直到你达到非常大的数字。 - ILoveFortran
8
根据大O复杂度选择算法通常是程序设计的重要部分。这绝对不是“过早优化”的情况,无论如何,“过早优化”都是一个被滥用的选择性引用。 - user207421
1
+ILoveFortran 对我而言,“测算算法随着规模的增加而变得更好”,正如你所指出的,实际上与算法的效率有关。如果不是,请在此处解释您对效率的定义。 - Lemuel Uhuru
显示剩余4条评论
24个回答

1547

我会尽力用简单的术语来解释,但请注意这个主题需要我的学生们几个月才能最终掌握。您可以在《Java数据结构和算法》一书的第2章中找到更多信息。


没有机械程序可以用来获得BigOh。

作为一个“食谱”,要从一段代码中获取BigOh,你首先需要意识到你正在创建一个数学公式,以计算在给定某个大小的输入时执行了多少步计算。

目的很简单:从理论角度比较算法,无需执行代码。步骤越少,算法越快。

例如,假设你有这段代码:

int sum(int* data, int N) {
    int result = 0;               // 1

    for (int i = 0; i < N; i++) { // 2
        result += data[i];        // 3
    }

    return result;                // 4
}

这个函数返回数组中所有元素的总和,我们想要创建一个公式来计算该函数的计算复杂度

Number_Of_Steps = f(N)

所以我们有一个名为f(N)的函数,用来计算计算步骤的数量。该函数的输入是要处理的结构的大小。这意味着该函数被调用如下:

Number_Of_Steps = f(data.length)

参数Ndata.length的值。现在我们需要函数f()的实际定义。这是从源代码中完成的,其中每个有趣的行都从1到4进行编号。
有许多计算BigOh的方法。从此处开始,我们将假定每个不依赖于输入数据大小的句子需要一个常数C个计算步骤。
我们将添加函数的单个步骤数量,既不局部变量声明也不返回语句取决于data数组的大小。
这意味着第1行和第4行各需要C个步骤,并且该函数类似于以下内容:
f(N) = C + ??? + C

下一步是定义for语句的值。记住,我们正在计算计算步骤的数量,这意味着for语句的主体会执行N次。这相当于将C加上N次:
f(N) = C + (C + C + ... + C) + C = C + N * C + C

没有机械规则来计算for循环体执行的次数,你需要通过观察代码来计数。为了简化计算,我们忽略了for语句中变量初始化、条件和增量部分。

要得到实际的BigOh,我们需要函数的渐近分析。大致步骤如下:

  1. 去掉所有的常数C。
  2. 从f()中获得标准形式的多项式。
  3. 将多项式的项按增长率排序并进行除法。
  4. 保留当N趋近于无穷大时增长最快的项。

我们的f()有两项:

f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1

去除所有的C常量和冗余部分:

f(N) = 1 + N ^ 1

由于最后一项是当 f() 趋近无穷大时增长最快的(请思考极限),这就是 BigOh 的论点,而 sum() 函数的 BigOh 为:

O(N)

有一些技巧可以解决一些棘手的问题:尽可能使用求和

例如,可以使用求和轻松解决此代码:

for (i = 0; i < 2*n; i += 2) {  // 1
    for (j=n; j > i; j--) {     // 2
        foo();                  // 3
    }
}

首先需要问的是foo()执行顺序。虽然通常为O(1),但您需要向教授询问。 O(1)表示(几乎,大多数情况下)常数C,与大小N无关。

第一句话中的for语句有些棘手。虽然索引结束于2*N,但增量增加了两个。这意味着第一个for只执行N步,并且我们需要将计数除以二。

f(N) = Summation(i from 1 to 2 * N / 2)( ... ) = 
     = Summation(i from 1 to N)( ... )

第二个句子更加棘手,因为它取决于i的值。看一下:索引i取值为:0、2、4、6、8、...、2 * N,第二个for循环被执行:第一个循环N次,第二个循环N-2次,第三个循环N-4次...一直到N/2阶段,在该阶段第二个for循环不再被执行。

公式表示如下:

f(N) = Summation(i from 1 to N)( Summation(j = ???)(  ) )

再次,我们正在计算步骤的数量。根据定义,每个求和公式应始于一,并以大于或等于一的数字结束。

f(N) = Summation(i from 1 to N)( Summation(j = 1 to (N - (i - 1) * 2)( C ) )

(我们假设foo()O(1)并且需要C步。)
我们在这里遇到了一个问题:当i取值N / 2 + 1以上时,内部求和的结果为负数!这是不可能的,也是错误的。我们需要把求和分成两部分,在iN / 2 + 1的时刻进行分割。
f(N) = Summation(i from 1 to N / 2)( Summation(j = 1 to (N - (i - 1) * 2)) * ( C ) ) + Summation(i from 1 to N / 2) * ( C )

自关键时刻i > N / 2以来,内部的for不会被执行,我们假设它的主体具有恒定的C执行复杂度。

现在,可以使用一些身份规则简化求和:

  1. 求和(w从1到N)(C)= N * C
  2. 求和(w从1到N)(A(+/-)B)=求和(w从1到N)(A)(+/-)求和(w从1到N)(B)
  3. 求和(w从1到N)(w * C)= C * 求和(w从1到N)(w)(C是常数,与w无关)
  4. 求和(w从1到N)(w)=(N *(N + 1))/ 2

应用一些代数:

f(N) = Summation(i from 1 to N / 2)( (N - (i - 1) * 2) * ( C ) ) + (N / 2)( C )

f(N) = C * Summation(i from 1 to N / 2)( (N - (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (Summation(i from 1 to N / 2)( N ) - Summation(i from 1 to N / 2)( (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2)( i - 1 )) + (N / 2)( C )

=> Summation(i from 1 to N / 2)( i - 1 ) = Summation(i from 1 to N / 2 - 1)( i )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2 - 1)( i )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N / 2 - 1) * (N / 2 - 1 + 1) / 2) ) + (N / 2)( C )

=> (N / 2 - 1) * (N / 2 - 1 + 1) / 2 = 

   (N / 2 - 1) * (N / 2) / 2 = 

   ((N ^ 2 / 4) - (N / 2)) / 2 = 

   (N ^ 2 / 8) - (N / 4)

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N ^ 2 / 8) - (N / 4) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - ( (N ^ 2 / 4) - (N / 2) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - (N ^ 2 / 4) + (N / 2)) + (N / 2)( C )

f(N) = C * ( N ^ 2 / 4 ) + C * (N / 2) + C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + 2 * C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + C * N

f(N) = C * 1/4 * N ^ 2 + C * N

而大O表示法是:

O(N²)

6
这会是O(N^2)的,因为你需要通过一个循环来读取所有的列,然后再通过另一个循环来读取特定列中的所有行。 - Abhishek Dey Das
1
@arthur:这取决于情况。如果n是元素数量,则为O(n),如果xy是数组的维度,则为O(x*y)。大O表示法是“相对于输入”的,因此它取决于您的输入是什么。 - Mooing Duck
1
很好的答案,但我真的卡住了。求和(i从1到N/2)(N)怎么会变成(N ^ 2 / 2)? - Parsa
2
作为一般规则,sum(i从1到a)(b)等于a * b。这只是另一种说法,即b+b+...(a次)+b = a * b(对于某些整数乘法定义而言)。@ParsaAkbari - Mario Carneiro
1
@Franva 这些是“求和恒等式”的自由变量(谷歌术语)。请在此处查看更好格式化的数学公式:https://courses.cs.washington.edu/courses/cse373/19sp/resources/math/summation/ - vz0
显示剩余3条评论

213

大 O 表示算法的时间复杂度上限。通常它与处理数据集(列表)一起使用,但也可以用于其他领域。

以下是如何在 C 代码中使用几个示例。

假设我们有一个包含 n 个元素的数组

int array[n];

如果我们想访问数组的第一个元素,这将是O(1),因为它不管数组有多大,总是花费相同的常数时间来获取第一个元素。
x = array[0];

如果我们想在列表中查找一个数字:
for(int i = 0; i < n; i++){
    if(array[i] == numToFind){ return i; }
}

这将是O(n),因为我们最多需要查看整个列表才能找到我们的数字。即使我们第一次就找到了我们的数字并运行了一次循环,但Big-O仍然是O(n),因为Big-O描述了算法的上限(omega用于下限,theta用于紧密边界)。

当涉及到嵌套循环时:

for(int i = 0; i < n; i++){
    for(int j = i; j < n; j++){
        array[j] += 2;
    }
}

对于每次外部循环的遍历(O(n)),我们必须再次遍历整个列表,因此n相乘,导致时间复杂度为O(n^2)。

这只是冰山一角,当你分析更复杂的算法时,会涉及到证明的复杂数学。但至少希望这让你熟悉基础。


很好的解释!所以如果有人说他的算法具有O(n^2)的复杂度,这是否意味着他将使用嵌套循环? - Navaneeth K N
2
不是真的,任何导致n平方次的方面都将被视为n的平方。 - asyncwait
1
@NavaneethKN:你不会总是看到嵌套循环,因为函数调用可以自己完成 > O(1) 的工作。例如,在C标准API中,bsearch本质上是O(log n)strlenO(n),而qsortO(n log n)(严格来说它没有保证,快速排序本身的最坏情况复杂度是O(n²),但假设你的libc作者不是个白痴,它的平均情况复杂度是O(n log n),并且使用了一种减少命中O(n²)情况的枢轴选择策略)。如果比较器函数是病态的,bsearchqsort都可能更糟。 - ShadowRanger

110

虽然知道如何为您的特定问题计算大O时间是有用的,但了解一些常见情况可以在帮助您做出算法决策方面起到很大作用。

以下是一些最常见的情况,摘自http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions:

O(1) - 确定一个数字是奇数还是偶数;使用固定大小的查找表或哈希表

O(logn) - 在排序数组中查找项目,使用二分搜索

O(n) - 在未排序列表中查找项目;将两个n位数相加

O(n2) - 通过简单算法乘以两个n位数;将两个n×n矩阵相加;冒泡排序或插入排序

O(n3) - 通过简单算法乘以两个n×n矩阵

O(cn) - 使用动态规划查找巡回推销员问题的(精确)解决方案;使用蛮力法确定两个逻辑语句是否等价

O(n!) - 通过蛮力搜索解决旅行推销员问题

O(nn) - 经常用于推导渐近复杂度的简化公式,而不是O(n!)


为什么不使用 x&1==1 来检查奇偶性呢? - Samie Bencherif
3
这是一种典型的检查方法(实际上,只需测试x&1即可,无需检查== 1;在C中,x&1 == 1被解释为x&(1 == 1)感谢运算符优先级,因此实际上与测试x&1相同)。但我认为你误读了答案;那里有一个分号,不是逗号。它并没有说你需要一个查找表来测试奇偶性,而是说测试奇偶性和检查查找表都是O(1)操作。 - ShadowRanger
1
我不确定最后一句话中关于使用的声明,但是谁这样做就是用一个不等价的类替换另一个类。类O(n!)包含但严格大于O(n^n)。实际上等价的应该是O(n!) = O(n^ne^{-n}sqrt(n))。 - conditionalMethod

43

小提示:大 O 符号用于表示渐近复杂度(即当问题的大小趋向于无限大时),并且它隐藏了一个常数。

这意味着,对于 O(n) 和 O(n2) 的算法而言,最快的不总是第一个(虽然总存在一个 n 值,使得在问题大小 >n 时,第一个算法最快)。

请注意,隐藏常数非常取决于实现!

此外,在某些情况下,运行时间不是输入大小 n 的确定性函数。以使用快速排序进行排序为例:对于 n 个元素的数组进行排序所需的时间不是常数,而是取决于数组的起始配置。

有不同的时间复杂度:

  • 最坏情况(通常最容易找出,但不总是非常有意义)
  • 平均情况(通常更难找出...)

  • ...

算法分析导论(作者:R. Sedgewick 和 P. Flajolet)是一本很好的介绍书。

正如你所说,过早优化是万恶之源,而且(如果可能的话)在优化代码时应该始终使用性能分析。它甚至可以帮助你确定算法的复杂度。


4
在数学中,O(.)表示上限,而theta(.)表示上下界都有限制。在计算机科学中,这个定义是否真的与数学不同,还是只是一种常见的符号误用?根据数学定义,sqrt(n)既是O(n),也是O(n^2),因此并非总是存在某个n值,使得一个O(n)函数始终更小。 - Douglas Zare

30
看到这里的答案,我认为我们大多数人在估算算法的顺序时确实是通过观察并运用常识而不是像我们在大学里学习的那样使用例如master method这样的计算方法来估算。
话虽如此,我必须补充说即使是教授(后来)也鼓励我们实际上去思考而不仅仅是计算它。
此外,我想添加一下递归函数的处理方式:
假设我们有一个像(scheme code)这样的函数:
(define (fac n)
    (if (= n 0)
        1
            (* n (fac (- n 1)))))

这个函数是一个递归函数,用于计算给定数字的阶乘。

第一步是尝试确定该函数体的性能特征,仅考虑函数体内部,此时函数体中没有任何特殊操作,只有一个乘法(或返回值1)。

因此,函数体的性能为:O(1)(常数)。

接下来,尝试确定递归调用的次数。在这种情况下,我们有n-1个递归调用。

因此,递归调用的性能为:O(n-1)(顺序为n,因为我们丢弃了不重要的部分)。

然后将它们放在一起,就可以得到整个递归函数的性能:

1 * (n-1) = O(n)


Peter, 为了回答你提出的问题,我在这里描述的方法可以很好地处理这个问题。但请记住,这仍然是一个近似而不是完全正确的数学答案。这里描述的方法也是我们在大学学习的方法之一,如果我没记错的话,它被用于比我在这个例子中使用的阶乘更高级的算法。
当然,这完全取决于您能够估计函数体的运行时间和递归调用的次数的程度,但对于其他方法同样如此。


Sven,我不确定你用于评估递归函数复杂度的方法能否适用于更复杂的函数,比如在二叉树中进行自顶向下的搜索/求和/某些操作。当然,你可以通过分析简单的例子得出答案。但是对于递归函数,我想你必须做一些数学计算吧? - Peteter
3
+1 表示对递归的支持... 这个也很棒:“...甚至教授都鼓励我们思考...” :) - TT_ stands with Russia
是的,这很好。我倾向于这样想,O(..)内部的术语越高,你/机器所做的工作就越多。将其与某些东西联系起来思考可能是一种近似,但这些边界也是如此。它们只告诉您当输入数量增加时要执行的工作量如何增加。 - Abhinav Gauniyal

27

如果你的成本是一个多项式,只需保留最高阶项而不带其乘数。例如:

  

O((n/2 + 1)*(n/2)) = O(n2/4 + n/2) = O(n2/4) = O(n2)

请注意,这对于无限级数不起作用。对于一般情况没有单一的方法,但对于一些常见情况,以下的不等式适用:

  

O(log N) < O(N) < O(N log N) < O(N2) < O(Nk) < O(en) < O(n!)


23

从信息角度思考问题。任何问题都包含了学习一定数量的信息。

你的基本工具是决策点及其熵的概念。决策点的熵是它将为你提供的平均信息量。例如,如果一个程序包含一个有两个分支的决策点,则它的熵是每个分支的概率乘以该分支倒数概率的对数的总和。这就是通过执行该决策所学到的信息量。

例如,一个有两个等可能分支的if语句的熵为 1/2 * log(2/1) + 1/2 * log(2/1) = 1. 因此,它的熵为1比特。

假设你正在搜索一个 N 个项目的表格,比如 N=1024。这是一个包含 10 位信息量的问题,因为 log(1024) = 10 比特。所以,如果你可以使用具有等可能结果的 IF 语句进行搜索,那么需要 10 个决策。

这就是二分查找得到的结果。

假设你在进行线性搜索。你查看第一个元素并询问它是否是你想要的。这个决策的概率是1/1024是正确的,而1023/1024不正确。该决策的熵是 1/1024*log(1024/1) + 1023/1024 * log(1024/1023) = 1/1024 * 10 + 1023/1024 * 约等于0 = 约等于0.01比特。你学到的很少!第二个决策也不会好到哪里去。这就是为什么线性搜索如此缓慢,实际上它是与需要学习的比特数呈指数关系。

假设你正在进行索引。假设表格已经预先分成许多箱子,并且你使用关键字中的一些或全部位来直接索引到表格条目。如果有1024个箱子,则所有1024种可能结果的熵为1/1024 * log(1024) + 1/1024 * log(1024) + ...。对于那个索引操作,这是 1/1024 * 10 倍的 1024 种结果,或者说有10比特的熵。这就是为什么索引搜索很快的原因。
现在考虑排序。你有 N 个项目和一个列表。对于每个项目,你必须搜索它在列表中的位置,然后将其添加到列表中。因此,排序需要大约N倍底层搜索的步骤数。
因此,在基于二进制决策的排序中,几乎等可能的结果需要大约 O(N log N) 步。如果它是基于索引搜索的,则可以实现O(N) 排序算法。
我发现几乎所有算法性能问题都可以用这种方式来解决。

哇,你有关于这个的任何有用参考资料吗?我觉得这些东西对我设计/重构/调试程序很有帮助。 - Jesvin Jose
4
值得一提的是,我写了一本书,涵盖了那个话题和其他话题。尽管这本书已经很久没有再印刷了,但还可以以合理的价格购买到副本。我曾试图让GoogleBooks抓取它,但目前还有点难弄清楚谁拥有版权。 - Mike Dunlavey

22

让我们从头开始。

首先,接受这个原则:某些简单的数据操作可以在O(1)的时间内完成,即在时间上与输入的大小无关。在C语言中,这些原始操作包括:

  1. 算术运算(例如+或%)。
  2. 逻辑运算(例如,&&)。
  3. 比较运算(例如,<=)。
  4. 结构体访问操作(例如,类似A[i]的数组索引,或指针后跟->运算符)。
  5. 简单赋值,如将一个值复制到一个变量中。
  6. 库函数调用(例如scanf、printf)。

这个原则的正当性需要对计算机的机器指令(原始步骤)进行详细研究。每个描述的操作都可以用一些小的机器指令来完成;通常只需要一两个指令。

因此,C语言中的几种语句可以在O(1)的时间内执行,即在某个常数数量的时间内独立于输入。这些简单的语句包括:

  1. 赋值语句,在它们的表达式中不涉及函数调用。
  2. 读语句。
  3. 写语句,在评估参数时不需要函数调用。
  4. 跳转语句break、continue、goto和return expression,其中expression不包含函数调用。

在C语言中,许多for循环是通过将索引变量初始化为某个值,并每次循环将该变量增加1来形成的。当索引达到某个限制时,for循环结束。例如,下面是一个for循环:

for (i = 0; i < n-1; i++) 
{
    small = i;
    for (j = i+1; j < n; j++)
        if (A[j] < A[small])
            small = j;
    temp = A[small];
    A[small] = A[i];
    A[i] = temp;
}

使用索引变量 i。每次循环它会将 i 增加 1,当 i 达到 n-1 时,迭代就停止了。

然而,暂时只关注 for 循环的简单形式,其中“最终值与初始值之间的差除以索引变量增加的数量告诉我们我们要循环多少次”。这个计数是精确的,除非有跳转语句退出循环;在任何情况下,它都是迭代次数的上界。

例如,for 循环迭代 ((n-1)-0)/1=n-1 次,因为 i 的初始值为 0,n-1 是 i 取得的最高值(即当 i 达到 n-1 时,循环停止且不存在 i=n-1 的迭代),并且在循环的每次迭代中向 i 添加了 1。

在最简单的情况下,即在每次迭代中循环体花费的时间相同的情况下,“大 O 记号”表示法中的上界可以通过“循环次数”乘以循环体的大 O 上限得出。严格地说,我们必须为初始化循环索引和第一次比较循环索引与极限添加 O(1) 时间,因为我们测试的次数比我们循环的次数多一次。但是,除非循环可能执行零次,否则初始化循环和测试极限一次的时间是可以通过求和规则省略的低阶项。


现在考虑这个例子:

(1) for (j = 0; j < n; j++)
(2)   A[i][j] = 0;
我们知道第一行的时间复杂度为O(1)。很明显,我们要循环n次,可以通过在第一行找到的下限和上限之间相减并加1来确定。由于循环体第二行的时间复杂度为O(1),我们可以忽略增加j的时间和将j与n进行比较的时间,这两者也都是O(1)。因此,第一行第二行的运行时间是n乘以O(1)的结果,即O(n)
类似地,我们可以限定由第二行第四行组成的外部循环的运行时间。
(2) for (i = 0; i < n; i++)
(3)     for (j = 0; j < n; j++)
(4)         A[i][j] = 0;
我们已经确定线路(3)和(4)的循环需要O(n)时间。因此,我们可以忽略每次迭代中增加i和测试i < n所需的O(1)时间,从而得出外部循环的每次迭代需要O(n)时间。
外部循环的初始化i = 0和第(n + 1)次测试条件i < n同样需要O(1)时间且可以忽略。最后,我们观察到我们围绕外部循环运行n次,每次迭代需要O(n)时间,总共需要O(n^2)运行时间。
一个更实际的例子。

enter image description here


如果goto语句包含函数调用怎么办?就像 step3:if(M.step == 3){ M = step3(done,M); } step4:if(M.step == 4){ M = step4(M); } 如果(M.step == 5){ M = step5(M); 转到step3; } 如果(M.step == 6){ M = step6(M); 转到step4; } 返回cut_matrix(A,M)。 那么复杂度如何计算呢?是加法还是乘法?考虑到step4是n^3,step5是n^2。 - Taha Tariq

16

如果您想通过经验估计您的代码顺序而不是通过分析代码,您可以插入一系列递增的n值并计时您的代码。将时间绘制在对数坐标上。如果代码是O(x ^ n),那么值应该落在斜率为n的直线上。

这比仅仅研究代码有几个优点。首先,您可以看到运行时间接近其渐近顺序的范围。此外,您可能会发现,某些代码,例如由于库调用所花费的时间,您原本认为是O(x)顺序,实际上是O(x ^ 2)顺序。


仅更新此答案:https://en.wikipedia.org/wiki/Analysis_of_algorithms,此链接具有您所需的公式。许多算法遵循幂规则,如果您的算法也是如此,并且在一台机器上有2个时间点和2个运行时间,则我们可以在对数-对数图上计算斜率。这是a=log(t2/t1)/log(n2/n1),这为我提供了O(N^a)中算法的指数。这可以与使用代码进行手动计算进行比较。 - Christopher John
1
你好,非常好的回答。我想知道你是否了解任何库或方法(例如我使用Python/R),可以将这种经验法则推广到不断增加的数据集上,即拟合各种复杂度函数,并找出哪个是相关的。谢谢。 - agenis

12
基本上,90% 的时间是在分析循环。你有单层、双层、三层嵌套的循环吗?那么,你的运行时间就是 O(n)、O(n^2)、O(n^3)。
除非你正在编写一个具有广泛基础库的平台(例如 .NET BCL 或 C++ 的 STL),否则很少会遇到比查看循环更难的事情。

1
取决于循环。 - kelalaka

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接