确定给定代码的复杂性

时间:2011-10-25 06:51:13

标签: c algorithm recursion big-o recurrence

鉴于代码的一小部分,你将如何确定一般的复杂性。我发现自己对Big O问题非常困惑。例如,一个非常简单的问题:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

TA用类似的东西解释了这一点。像这样是n选择2 =(n(n-1))/ 2 = n ^ 2 + 0.5,然后移除常数使其变为n ^ 2。我可以把int测试值和尝试但是这个组合的东西怎么样?

如果是if语句怎么办?如何确定复杂性?

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

那么递归呢......

int fib(int a, int b, int n) {
    if (n == 3) {
        return a + b;
    } else {
        return fib(b, a+b, n-1);
    }
}

6 个答案:

答案 0 :(得分:69)

一般,无法确定给定函数的复杂性

警告!文字墙传入!

1。有very simple种算法,没有人知道它们是否会停止。

如果给定某个输入,有no algorithm可以决定给定程序是否停止。计算计算复杂度是一个更难的问题,因为我们不仅需要证明算法停止,而且还需要证明它有多快

//The Collatz conjecture states that the sequence generated by the following
// algorithm always reaches 1, for any initial positive integer. It has been
// an open problem for 70+ years now.
function col(n){
    if (n == 1){
        return 0;
    }else if (n % 2 == 0){ //even
        return 1 + col(n/2);
    }else{ //odd
        return 1 + col(3*n + 1);
    }
}

2。 Some algorithms有奇怪的和不合时宜的复杂性

由于这些家伙,一般的“复杂性确定方案”很容易变得太复杂

//The Ackermann function. One of the first examples of a non-primitive-recursive algorithm.
function ack(m, n){
    if(m == 0){
        return n + 1;
    }else if( n == 0 ){
        return ack(m-1, 1);
    }else{
        return ack(m-1, ack(m, n-1));
    }
}

function f(n){ return ack(n, n); }

//f(1) = 3
//f(2) = 7
//f(3) = 61
//f(4) takes longer then your wildest dreams to terminate.

3。 Some functions非常简单,但会混淆很多种静态分析尝试

//Mc'Carthy's 91 function. Try guessing what it does without
// running it or reading the Wikipedia page ;)
function f91(n){
    if(n > 100){
        return n - 10;
    }else{
        return f91(f91(n + 11));
    }
}

那就是说,我们仍然需要一种方法来找到东西的复杂性,对吧? For循环是一种简单而常见的模式。举个例子:

for(i=0; i<N; i++){
   for(j=0; j<i; j++){
       print something
   }
}

由于每个print something都是O(1),算法的时间复杂度将取决于我们运行该行的次数。好吧,正如你的TA提到的那样,我们通过查看这种情况下的组合来做到这一点。内循环将运行(N +(N-1)+ ... + 1)次,总计为(N + 1)* N / 2.

由于我们忽略常数,我们得到O(N 2 )。

现在,对于更棘手的案例,我们可以获得更多数学。尝试创建一个函数,其值表示算法运行所需的时间,给定输入的大小为N. 通常我们可以直接从算法本身构造这个函数的递归版本,因此计算复杂性成为在该函数上设置边界的问题。我们将此函数称为重复

例如:

function fib_like(n){
    if(n <= 1){
        return 17;
    }else{
        return 42 + fib_like(n-1) + fib_like(n-2);
    }
 }

很容易看出,以N为单位的运行时间将由

给出
T(N) = 1 if (N <= 1)
T(N) = T(N-1) + T(N-2) otherwise

嗯,T(N)只是古老的斐波那契函数。我们可以使用归纳来对它进行一些限制。

例如,让我们通过归纳证明所有N的T(N)<= 2 ^ n(即T(N)是O(2 ^ n)) < / p>

  • 基本情况:n = 0或n = 1
    T(0) = 1 <= 1 = 2^0
    T(1) = 1 <= 2 = 2^1
  • 归纳案例(n> 1):
    T(N) = T(n-1) + T(n-2)
    aplying the inductive hypothesis in T(n-1) and T(n-2)...
    T(N) <= 2^(n-1) + 2^(n-2)
    so..
    T(N) <= 2^(n-1) + 2^(n-1)
         <= 2^n

(我们可以尝试做类似的事情以证明下限)

在大多数情况下,对函数的最终运行时间进行一次很好的猜测可以让您通过感应证明轻松解决重现问题。当然,这需要您能够先猜测 - 只有很多练习可以帮助你。

最后请注意,我想指出 Master theorem,这是我现在可以想到的更常见的复发问题的唯一规则。使用它当你必须处理一个棘手的分而治之的算法时。


此外,在你的“if case”例子中,我会通过欺骗和分割成两个独立的循环来解决这个问题。 t里面有一个if。

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

具有相同的运行时间
for (int i = 0; i < n; i += 2) {
    for (int j = i; j < n; j++) { ... }
}

for (int i = 1; i < n; i+=2) {
    for (int j = 0; j < i; j++) { ... }
}

这两部分中的每一部分都可以很容易地看出为O(N ^ 2),总数也是O(N ^ 2)。

请注意,我使用了一个很好的技巧来摆脱这里的“if”。 这样做没有一般规则,如Collat​​z算法示例

所示

答案 1 :(得分:14)

一般来说,决定算法的复杂性在理论上是不可能的。

然而,一个很酷且以代码为中心的方法实际上只是直接考虑程序。举个例子:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

现在我们要分析它的复杂性,所以让我们添加一个简单的计数器来计算内线的执行次数:

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
        counter++;
    }
}

因为System.out.println行并不重要,所以我们将其删除:

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        counter++;
    }
}

现在我们只剩下计数器了,我们显然可以简化内循环:

int counter = 0;
for (int i = 0; i < n; i++) {
    counter += n;
}

...因为我们知道增量完全按 n 次运行。现在我们看到计数器按 n 递增 n 次,所以我们将其简化为:

int counter = 0;
counter += n * n;

我们出现了(正确的)O(n 2 )复杂性:)它在代码中有:)

让我们看一下递归Fibonacci计算器的工作原理:

int fib(int n) {
  if (n < 2) return 1;
  return fib(n - 1) + fib(n - 2);
}

更改例程,使其返回在其中花费的迭代次数,而不是实际的Fibonacci数:

int fib_count(int n) {
  if (n < 2) return 1;
  return fib_count(n - 1) + fib_count(n - 2);
}

它仍然是斐波那契! :)所以我们现在知道递归Fibonacci计算器的复杂度为O(F(n)),其中F是Fibonacci数本身。

好的,让我们看看更有趣的东西,比如简单(和低效)的mergesort:

void mergesort(Array a, int from, int to) {
  if (from >= to - 1) return;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  mergesort(a, from, m);
  mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
  }
  for (i = from; i < to; i++)
    a[i] = b[i - from];
}

因为我们对实际结果不感兴趣但是对复杂性不感兴趣,所以我们改变了例程,以便它实际返回所执行工作单元的数量:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
    count++;
  }
  for (i = from; i < to; i++) {
    count++;
    a[i] = b[i - from];
  }
  return count;
}

然后我们删除那些实际上不影响计数的行并简化:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  count += to - from;
  /* Copy the array */
  count += to - from;
  return count;
}

仍在简化:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  count += (to - from) * 2;
  return count;
}

我们现在可以省去阵列:

int mergesort(int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(from, m);
  count += mergesort(m,    to);
  count += (to - from) * 2;
  return count;
}

我们现在可以看到,实际上from和to的绝对值不再重要,而只是它们的距离,所以我们将其修改为:

int mergesort(int d) {
  if (d <= 1) return 1;
  int count = 0;
  count += mergesort(d / 2);
  count += mergesort(d / 2);
  count += d * 2;
  return count;
}

然后我们来:

int mergesort(int d) {
  if (d <= 1) return 1;
  return 2 * mergesort(d / 2) + d * 2;
}

在第一次调用时,显然 d 是要排序的数组的大小,因此您可以重现M(x)(这在第二行很明显: )

M(x) = 2(M(x/2) + x)

这需要解决才能获得封闭的表格解决方案。通过猜测解M(x)= x log x,你可以做到最简单,并验证右侧:

2 (x/2 log x/2 + x)
        = x log x/2 + 2x
        = x (log x - log 2 + 2)
        = x (log x - C)

并验证它是否与左侧渐近相同:

x log x - Cx
------------ = 1 - [Cx / (x log x)] = 1 - [C / log x] --> 1 - 0 = 1.
x log x

答案 2 :(得分:2)

尽管这是一种过度概括,但我喜欢在列表方面考虑Big-O,其中列表的长度为N项。

因此,如果你有一个迭代遍历列表中所有内容的for循环,它就是O(N)。在你的代码中,你有一行(单独隔离)是0(N)。

for (int i = 0; i < n; i++) {

如果你有一个for循环嵌套在另一个for循环中,并且你对列表中的每个项目执行一个操作,要求你查看列表中的每个项目,那么你正在为N中的每个项目执行N次操作项目,因此O(N ^ 2)。在上面的示例中,您实际上在for循环中嵌套了另一个for循环。因此,您可以将其视为每个for循环为0(N),然后因为它们是嵌套的,将它们相乘以获得总值0(N ^ 2)。

相反,如果你只是对一个项目进行快速操作,那么就是O(1)。没有'长度列表n'可以过去,只需要一次操作。在上面的例子中,将这个放在上下文中,操作:

if (i % 2 ==0)

是0(1)。重要的不是'if',而是检查单个项目是否等于另一个项目这一事实是对单个项目的快速操作。和以前一样,if语句嵌套在你的外部for循环中。但是,因为它是0(1),那么你将所有东西乘以'1',因此在你对整个函数的运行时间的最终计算中没有“明显的”影响。

对于日志和处理更复杂的情况(比如这个计算到j或i的业务,而不仅仅是n),我会指出一个更优雅的解释here

答案 3 :(得分:2)

我喜欢为Big-O表示法使用两个东西:标准Big-O,这是最坏的情况,和平均Big-O,这通常是最终发生的事情。它还帮助我记住Big-O表示法试图将运行时间近似为N(输入数量)的函数。

  

TA用类似的东西解释了这一点。像这样是n选择2 =(n(n-1))/ 2 = n ^ 2 + 0.5,然后移除常数使其变为n ^ 2。我可以输入int测试值并尝试但是这个组合的内容是如何产生的?

正如我所说,正常的大O是最糟糕的情况。你可以尝试计算每一行执行的次数,但是只看第一个例子并说n长度上有两个循环,另一个嵌入另一个循环就更简单了,所以它是n * ñ。如果它们是一个接一个,则为n + n,等于2n。由于它是近似值,你只需说n或线性。

  

如果是if语句怎么办?如何确定复杂性?

对于我来说,平均案例和最佳案例对于组织我的想法有很大帮助。在最坏的情况下,你忽略if和say n ^ 2。在一般情况下,对于你的例子,你有一个循环超过n,另一个循环超过n的一部分时间发生。这给你n * n / x / 2(x是你在嵌入循环中循环的n的任何分数。这给你n ^ 2 /(2x),所以你得到的n ^ 2就是一样的。是因为它是近似值。

我知道这不是你问题的完整答案,但希望它能够解释代码中的近似复杂性。

正如我上面的答案中所说,显然无法为所有代码片段确定这一点;我只想添加使用平均大小写Big-O的想法。

答案 4 :(得分:0)

对于第一个片段,它只是n ^ 2,因为你执行n次操作n次。如果j初始化为i,或者升级到i,那么您发布的解释会更合适,但事实并非如此。

对于第二个片段,您可以很容易地看到第一个片段将被执行的一半时间,而第二个片段将在另一半时间内执行。根据那里的内容(希望它取决于n),你可以将等式重写为递归方程式。

递归方程(包括第三个片段)可以写成:第三个将显示为

T(n) = T(n-1) + 1

我们很容易看到的是O(n)。

答案 5 :(得分:-1)

Big-O只是一个近似值,它没有说算法执行需要多长时间,它只是说明了当输入大小增加时需要多长时间。

因此,如果输入是大小N并且算法评估常数复杂度的表达式:O(1)N次,则算法的复杂度是线性的:O(N)。如果表达式具有线性复杂度,则算法具有二次复杂度:O(N * N)。

某些表达式具有指数复杂性:O(N ^ N)或对数复杂度:O(log N)。对于具有循环和递归的算法,将每个循环级别和/或递归的复杂度相乘。就复杂性而言,循环和递归是等价的。算法在算法的不同阶段具有不同的复杂性,选择最高的复杂度而忽略其余的。最后,所有恒定的复杂性被认为是等价的:O(5)与O(1)相同,O(5 * N)与O(N)相同。