大O,你如何计算/近似它?

时间:2008-08-06 10:18:16

标签: algorithm optimization complexity-theory big-o performance

大多数拥有CS学位的人肯定会知道Big O stands for。 它有助于我们衡量一个算法的有效性,并且如果你在what category the problem you are trying to solve lays in中知道,你可以弄清楚是否仍然可以挤出那么少的额外性能。 1 < / p>

但我很好奇,如何计算或近似算法的复杂性?

1 但正如他们所说,不要过度,premature optimization is the root of all evil,没有正当理由的优化也应该得到这个名称。

24 个答案:

答案 0 :(得分:1443)

答案 1 :(得分:198)

Big 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 ^ 2),因为对于外循环(O(n))的每次传递,我们必须再次遍历整个列表,因此n的乘法使我们得到n平方。

这几乎没有触及表面,但是当您分析更复杂的算法时,涉及校样的复杂数学就会发挥作用。希望这至少让你熟悉基础知识。

答案 2 :(得分:93)

虽然知道如何找出特定问题的Big O时间非常有用,但了解一些常规案例可以帮助您在算法中做出决定。

以下是一些最常见的案例,取自http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions

O(1) - 确定数字是偶数还是奇数;使用常量查找表或哈希表

O(logn) - 使用二进制搜索在有序数组中查找项目

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

O(n 2 ) - 通过一个简单的算法乘以两个n位数字;添加两个n×n矩阵;冒泡排序或插入排序

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

O(c n ) - 使用动态编程找出旅行商问题的(精确)解决方案;使用强力判断两个逻辑语句是否相等

O(n!) - 通过暴力搜索解决旅行商问题

O(n n ) - 经常使用而不是O(n!)来推导渐近复杂度的简单公式

答案 3 :(得分:41)

小提示:big O表示法用于表示渐近复杂性(即,当问题的大小增长到无穷大时),它隐藏一个常数。

这意味着在O(n)中的算法和O中的一个算法(n 2 )之间,最快的并不总是第一个(尽管总是存在n的值,因此尺寸&gt; n的问题,第一种算法是最快的。)

请注意隐藏的常量很大程度上取决于实现!

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

有不同的时间复杂性:

  • 最糟糕的情况(通常最简单的解决,但并不总是非常有意义)
  • 平均情况(通常难以弄清楚......)

  • ...

一个很好的介绍是R. Sedgewick和P. Flajolet的算法分析导论

正如您所说,优化代码时,应始终使用premature optimisation is the root of all evil和(如果可能)分析。它甚至可以帮助您确定算法的复杂性。

答案 4 :(得分:27)

在这里看到答案我认为我们可以得出结论,我们大多数确实通过查看来确定算法的顺序,并使用常识而不是使用例如{ {3}}正如我们在大学时所想到的那样。 有了这个说,我必须补充一点,即使是教授也鼓励我们(稍后)实际上思考而不只是计算它。

另外,我想补充一下递归函数的完成方式:

假设我们有一个像(master method)这样的函数:

(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)


scheme code,回答Peter我在这里描述的方法实际上处理得很好。但请记住,这仍然是近似,而不是完整的数学正确答案。这里描述的方法也是我们在大学教授的方法之一,如果我没记错的是用于比我在本例中使用的因子更高级的算法。
当然,这完全取决于你能够估计函数体的运行时间和递归调用的数量,但对于其他方法也是如此。

答案 5 :(得分:25)

如果您的成本是多项式,只需保留最高阶项,而不是乘数。 E.g:

  

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

对于无限系列来说,这不适用,请注意。一般情况没有单一的配方,但对于一些常见情况,以下不等式适用:

  

O(log N )&lt; O( N )&lt; O( N log N )&lt; O( N 2 )&lt; O( N k )&lt; O(e n )&lt; O(名词!)

答案 6 :(得分:21)

我从信息方面考虑。任何问题都包括学习一定数量的比特。

您的基本工具是决策点及其熵的概念。决策点的熵是它给你的平均信息。例如,如果程序包含具有两个分支的决策点,则其熵是每个分支的概率乘以该分支的反概率的log 2 的总和。这就是你通过执行这个决定所学到的东西。

例如,具有两个分支的if语句,两者都相同,具有1/2 * log(2/1)+ 1/2 * log(2/1)= 1/2的熵* 1 + 1/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比特。你学到的很少!第二个决定并没有好多少。这就是线性搜索如此缓慢的原因。实际上,它需要学习的位数是指数级的。

假设您正在编制索引。假设表已预先排序到很多bin中,并且您使用键中的所有位中的一些位直接索引到表条目。如果有1024个箱,则熵为1/1024 * log(1024)+ 1/1024 * log(1024)+ ...对于所有1024个可能的结果。对于该一个索引操作,这是1024个结果的1/1024 * 10倍,或10个比特的熵。这就是索引搜索速度很快的原因。

现在考虑排序。您有N个项目,并且您有一个列表。对于每个项目,您必须搜索项目在列表中的位置,然后将其添加到列表中。因此,排序大约是基础搜索步数的N倍。

因此,基于具有大致相同可能结果的二元决策的排序都需要O(N log N)步骤。如果它基于索引搜索,则可以使用O(N)排序算法。

我发现几乎所有的算法性能问题都可以这样看待。

答案 7 :(得分:19)

让我们从头开始。

首先,接受这样的原则:对数据进行某些简单操作可以在O(1)时间内完成,也就是说,在与输入大小无关的时间内完成。 C中的这些原始操作由

组成
  1. 算术运算(例如+或%)。
  2. 逻辑操作(例如,&amp;&amp;)。
  3. 比较操作(例如,&lt; =)。
  4. 结构访问操作(例如,像A [i]或指针的数组索引) 低于 - &gt;操作者)。
  5. 简单分配,例如将值复制到变量中。
  6. 调用库函数(例如,scanf,printf)。
  7. 这一原则的理由要求详细研究典型计算机的机器指令(原始步骤)。可以用一些少量的机器指令完成所描述的每个操作;通常只需要一两条指令。 因此,C中的几种语句可以在O(1)时间内执行,也就是说,在与输入无关的某些恒定时间内执行。这些简单包括

    1. 在表达式中不涉及函数调用的赋值语句。
    2. 阅读陈述。
    3. 编写不需要函数调用来评估参数的语句。
    4. 跳转语句中断,继续,转到和返回表达式,其中 表达式不包含函数调用。
    5. 在C中,通过将索引变量初始化为某个值来形成许多for循环 在循环周围每次将该变量递增1。 for循环结束时 指数达到了一定的限度。例如,for-loop

      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。它每次围绕循环和迭代递增1 当我到达n - 1时停止。

      然而,暂时关注for循环的简单形式,其中最终值和初始值之间的差异除以索引变量递增的量告诉我们我们去了多少次围绕循环。这个计数是准确的,除非有办法通过跳转语句退出循环;它是任何情况下迭代次数的上限。

      例如,for循环迭代((n − 1) − 0)/1 = n − 1 times, 因为0是i的初始值,所以n-1是i达到的最高值(即,当i 当n = 1时,循环停止并且i = n-1)不发生迭代,并且添加1 在循环的每次迭代中都是我。

      在最简单的情况下,循环体中花费的时间对于每个循环体都是相同的 迭代,我们可以将身体的大上限乘以数量 循环次数。严格地说,我们必须添加O(1)时间进行初始化 循环索引和O(1)时间的第一次比较循环索引与 限制,因为我们测试的时间比循环更多。但是,除非 可以执行循环零次,初始化循环和测试的时间 限制一次是一个低阶项,可以通过求和规则来删除。


      现在考虑这个例子:

      (1) for (j = 0; j < n; j++)
      (2)   A[i][j] = 0;
      

      我们知道第(1)行需要O(1)次。显然,我们绕过循环n次,如 我们可以通过从线上找到的上限减去下限来确定 (1)再添加1.由于体,线(2),需要O(1)时间,我们可以忽略 增加j的时间和将j与n进行比较的时间,两者都是O(1)。 因此,第(1)和(2)行的运行时间是n和O(1)的乘积,即O(n)

      同样,我们可以限制由线组成的外部循环的运行时间 (2)到(4),这是

      (2) for (i = 0; i < n; i++)
      (3)     for (j = 0; j < n; j++)
      (4)         A[i][j] = 0;
      

      我们已经确定第(3)和(4)行的循环需要O(n)时间。 因此,我们可以忽略增加i的O(1)时间并测试i&lt; n in 每次迭代,得出外部循环的每次迭代都需要O(n)时间。

      外循环的初始化i = 0和条件的(n + 1)st测试 我&lt; n同样需要O(1)时间,可以忽略不计。最后,我们观察到我们走了 在外循环周围n次,每次迭代花费O(n)时间,得到总数 O(n^2)运行时间。


      一个更实际的例子。

      enter image description here

答案 8 :(得分:13)

如果您想通过经验而不是通过分析代码来估计代码的顺序,那么您可以坚持使用一系列不断增加的n值和时间代码。以对数刻度绘制您的计时。如果代码为O(x ^ n),则值应落在斜率n的行上。

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

答案 9 :(得分:9)

基本上90%的时间只是分析循环。你有单,双,三嵌套循环吗?你有O(n),O(n ^ 2),O(n ^ 3)运行时间。

非常罕见(除非您正在编写一个具有广泛基础库的平台(例如,.NET BCL或C ++的STL),否则您将遇到比查看循环更困难的任何事情(对于语句, ,转到等...)

答案 10 :(得分:7)

我认为通常不太有用,但为了完整起见,还有一个Big Omega Ω,它定义了算法复杂性的下限,还有一个Big Theta Θ,它定义了一个上层和下限。

答案 11 :(得分:7)

Big O表示法很有用,因为它易于使用并隐藏不必要的复杂性和细节(对于某些不必要的定义)。计算分而治之算法复杂性的一种好方法是树方法。假设您有一个带有中间过程的快速排序版本,因此您每次都将阵列拆分为完美平衡的子阵列。

现在构建一个对应于您使用的所有数组的树。在根处有原始数组,根有两个子数组,即子数组。重复此操作,直到底部有单个元素数组。

由于我们可以在O(n)时间内找到中值并在O(n)时间内将数组分成两部分,因此在每个节点完成的工作是O(k),其中k是数组的大小。树的每个级别(最多)包含整个数组,因此每个级别的工作是O(n)(子数组的大小加起来为n,因为我们每个级别有O(k),我们可以添加它) 。每次我们将输入减半时,树中只有log(n)级别。

因此我们可以通过O(n * log(n))来上限工作量。

然而,Big O隐藏了一些我们有时不能忽视的细节。考虑用

计算Fibonacci序列
a=0;
b=1;
for (i = 0; i <n; i++) {
    tmp = b;
    b = a + b;
    a = tmp;
}

并且假设a和b是Java中的BigIntegers或者可以处理任意大数的东西。大多数人会说这是一个没有退缩的O(n)算法。原因是你在for循环中有n次迭代,而O(1)在循环中工作。

但Fibonacci数很大,第n个Fibonacci数在n中是指数的,所以只存储它将占用n个字节的数量级。使用大整数执行加法将需要O(n)量的工作。因此,此过程中完成的工作总量为

1 + 2 + 3 + ... + n = n(n-1)/ 2 = O(n ^ 2)

所以这个算法在四维时间运行!

答案 12 :(得分:7)

熟悉我使用的算法/数据结构和/或迭代嵌套的快速浏览分析。困难在于你可能多次调用库函数 - 你经常不确定你是否有时不必要地调用函数或者它们正在使用什么实现。也许库函数应该具有复杂性/效率度量,无论是Big O还是其他指标,可以在文档中使用,甚至可以IntelliSense

答案 13 :(得分:7)

将算法分解为您知道大O符号的片段,并通过大O运算符进行组合。这是我所知道的唯一方式。

有关详情,请查看主题的Wikipedia page

答案 14 :(得分:6)

对于第一种情况,内循环执行n-i次,因此执行总数是i0n-1的总和(因为低于,低于或等于n-i。您最终得到n*(n + 1) / 2,所以O(n²/2) = O(n²)

对于第二个循环,i介于外部循环中的0n之间;然后当j严格大于n时执行内循环,这是不可能的。

答案 15 :(得分:6)

关于“你如何计算”Big O,这是Computational complexity theory的一部分。对于某些(许多)特殊情况,您可以使用一些简单的启发式方法(例如,为嵌套循环乘以循环计数),尤其是。当你想要的只是任何上限估计时,你不介意它是否过于悲观 - 我想这可能是你的问题所在。

如果您真的想回答任何算法的问题,您可以做的最好的是应用理论。除了简单的“最坏情况”分析外,我发现Amortized analysis在实践中非常有用。

答案 16 :(得分:5)

除了使用主方法(或其专业化之一)之外,我还通过实验测试算法。这不能证明任何特定的复杂性类别都可以实现,但它可以保证数学分析是合适的。为了帮助解决这个问题,我将代码覆盖工具与我的实验结合使用,以确保我能够运用所有案例。

一个非常简单的例子说你想对.NET框架列表排序的速度进行健全性检查。您可以编写如下内容,然后在Excel中分析结果以确保它们不超过n * log(n)曲线。

在这个例子中,我测量了比较的数量,但是检查每个样本大小所需的实际时间也是谨慎的。但是,您必须更加小心,只是测量算法而不包括测试基础架构中的工件。

int nCmp = 0;
System.Random rnd = new System.Random();

// measure the time required to sort a list of n integers
void DoTest(int n)
{
   List<int> lst = new List<int>(n);
   for( int i=0; i<n; i++ )
      lst[i] = rnd.Next(0,1000);

   // as we sort, keep track of the number of comparisons performed!
   nCmp = 0;
   lst.Sort( delegate( int a, int b ) { nCmp++; return (a<b)?-1:((a>b)?1:0)); }

   System.Console.Writeline( "{0},{1}", n, nCmp );
}


// Perform measurement for a variety of sample sizes.
// It would be prudent to check multiple random samples of each size, but this is OK for a quick sanity check
for( int n = 0; n<1000; n++ )
   DoTest(n);

答案 17 :(得分:4)

不要忘记也允许空间复杂性,如果内存资源有限,也可能引起关注。因此,例如,您可能听到有人想要一个恒定空间算法,这基本上是一种说法,算法占用的空间量不依赖于代码中的任何因素。

有时复杂性可能来自于调用的次数,执行循环的频率,分配内存的频率等等,这是回答这个问题的另一部分。

最后,大O可用于最坏情况,最佳情况和摊销案例,通常情况下,这是用于描述算法可能有多糟糕的最坏情况。

答案 18 :(得分:4)

很棒的问题!

免责声明:此答案包含虚假陈述,请参阅以下评论。

如果你正在使用Big O,你就会谈论更糟糕的情况(更多关于后来的意思)。此外,平均情况有首都theta,最佳情况有大欧米茄。

查看此网站,了解Big O的正式定义:https://xlinux.nist.gov/dads/HTML/bigOnotation.html

  

f(n)= O(g(n))表示存在正常数c和k,因此对于所有n≥k,0≤f(n)≤cg(n)。对于函数f,c和k的值必须是固定的,并且不能取决于n。

好的,现在我们的意思是&#34;最好的情况&#34;和#&#34;最坏情况&#34;复杂?

这可能是通过实例最清楚地说明的。例如,如果我们使用线性搜索来查找排序数组中的数字,那么最坏情况就是当我们决定搜索数组的最后一个元素时采取与数组中的项目一样多的步骤。 最佳情况将是我们搜索第一个元素,因为我们会在第一次检查后完成。

所有这些形容词 - case复杂性的要点在于我们正在寻找一种方法来绘制假设程序在特定变量大小方面运行完成的时间量。但是对于许多算法,您可以争辩说特定大小的输入没有一个时间。请注意,这与函数的基本要求相矛盾,任何输入都应该只有一个输出。因此,我们提出多个函数来描述算法的复杂性。现在,即使搜索大小为n的数组可能会花费不同的时间,具体取决于您在数组中查找的内容并且与n成比例,我们可以使用最佳情况,平均值创建算法的信息性描述-case和最坏情况的类。

对不起,这篇文章很糟糕,缺乏太多技术信息。但希望它能让时间复杂性课程更容易思考。一旦你对这些问题感到满意,解决整个程序并寻找依赖于数组大小的for循环和基于数据结构的推理之类的东西变成一个简单的问题是什么样的输入会导致琐碎的情况以及会产生什么样的输入在最坏的情况下。

答案 19 :(得分:4)

经常被忽视的是算法的预期行为。 它不会改变算法的Big-O ,但它确实与语句“过早优化......”相关。

您的算法的预期行为是 - 非常愚蠢 - 您可以期望算法处理您最有可能看到的数据的速度。

例如,如果您在列表中搜索某个值,则为O(n),但如果您知道您看到的大多数列表都预先显示了您的值,则算法的典型行为会更快。

要真正确定它,您需要能够描述“输入空间”的概率分布(如果您需要对列表进行排序,该列表已经多久排序一次?它的频率是多少?反转?它经常被排序多少?)你知道这一点并不总是可行的,但有时你会这样做。

答案 20 :(得分:2)

我不知道如何以编程方式解决这个问题,但人们首先要做的是我们在完成的操作次数中对某些模式的算法进行抽样,比如4n ^ 2 + 2n + 1我们有2条规则: / p>

  1. 如果我们有一个术语总和,则保留增长率最大的术语,省略其他术语。
  2. 如果我们有多个因素的乘积,则省略常数因子。
  3. 如果我们简化f(x),其中f(x)是完成的操作数的公式,(上面解释的4n ^ 2 + 2n + 1),我们得到big-O值[O(n ^ 2) ) 在这种情况下]。但这必须考虑到程序中的拉格朗日插值,这可能很难实现。如果真正的大O值是O(2 ^ n),并且我们可能有类似O(x ^ n)的东西,那么该算法可能无法编程。但如果有人证明我错了,请给我代码。 。 。

答案 21 :(得分:2)

对于代码A,外部循环将执行n+1次,“1”时间表示检查我是否仍满足要求的过程。内循环运行n次,n-2次......因此,0+2+..+(n-2)+n= (0+n)(n+1)/2= O(n²)

对于代码B,虽然内部循环不会介入并执行foo(),但内部循环将执行n次,取决于外部循环执行时间,即O(n)

答案 22 :(得分:0)

我想从一些不同的方面来解释Big-O。

Big-O只是用来比较程序的复杂性,这意味着当输入增加时,它们的增长速度是多少,而不是执行该操作所花费的确切时间。

在big-O公式中的恕我直言,最好不要使用更复杂的方程式(您可以坚持使用下图中的方程式。)但是,您仍然可以使用其他更精确的公式(例如3 ^ n,n ^ 3 ,...),但有时可能会误导更多!因此最好使其尽可能简单。

enter image description here

我想再次强调,这里我们不想为我们的算法获得确切的公式。我们只想显示输入增长时它如何增长,并在这种意义上与其他算法进行比较。否则,您最好使用基准测试等其他方法。

答案 23 :(得分:0)

首先,接受的答案是试图解释漂亮的花哨的东西,
但我认为,故意使 Big-Oh 复杂化并不是解决方案,
程序员(或至少像我这样的人)搜索的内容。

大哦(简而言之)

function f(text) {
  var n = text.length;
  for (var i = 0; i < n; i++) {
    f(string.slice(0, n-1))
  }
  // ... other JS logic here, which we can ignore ...
}

上面的大哦是 f(n) = O(n!) 其中 n 代表输入集中的 number 项, 和 f 表示每项完成 operation


Big-Oh 表示法是算法复杂度的渐近上限。
在编程中:假设的最坏情况时间,
或假设逻辑的最大重复次数,适用于输入的大小。

计算

记住(从上面的意思);我们只需要受N(输入大小)影响的最坏情况时间和/或最大重复次数
然后再看一下(接受的答案)示例:

for (i = 0; i < 2*n; i += 2) {  // line 123
    for (j=n; j > i; j--) {     // line 124
        foo();                  // line 125
    }
}
  1. 从这个搜索模式开始:

    • 找到 N 导致重复行为的第一行,
    • 或者导致执行的逻辑增加,
    • 无论是否恒定,请忽略该行之前的任何内容。
  2. 似乎第 123 行就是我们要搜索的 ;-)

    • 乍一看,线条似乎具有 2*n 最大循环。
    • 但再看一遍,我们看到了 i += 2(那一半被跳过了)。
    • 所以,max repeat 就是 n,把它写下来,比如 f(n) = O( n ,但不要关闭括号。
  3. 重复搜索直到方法结束,找到与我们的搜索模式匹配的下一行,这里是第 124 行

    • 这很棘手,因为奇怪的条件和反向循环。
    • 但记住我们只需要考虑最大重复次数(或最坏情况下花费的时间)。
    • 就像说“反向循环 jj=n 开头,对吗?是的,n 似乎是最大可能的重复次数”一样简单,所以,将 n 添加到上一个写下的末尾,但类似于“( n ”(而不是 + n,因为它在前一个循环内)并且仅当我们在前一个循环之外找到某些内容时才关闭括号。

搜索完成!为什么?因为第 125 行(或之后的任何其他行)与我们的搜索模式不匹配。
我们现在可以关闭任何括号(在我们的记录中左开),结果如下:

f(n) = O( n( n ) )

尝试进一步缩短“n( n )”部分,例如:

  • n( n ) = n * n
  • = n2
  • 最后,只需使用 Big Oh 表示法将其包裹起来,例如 O(n2) 或 O(n^2),无需格式化。