确定代码复杂度

34

给出一小段代码,你如何确定其一般性的复杂度。我发现自己在大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。我可以尝试使用测试值,但是这个组合的概念从哪里来的呢?
如果有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个回答

69

一般来说,无法确定给定函数的复杂度。

警告!大段文本即将到来!

1. 有非常简单的算法,甚至没有人知道它们是否停止。

如果给定特定输入,则没有算法可以判断给定程序是否停止。计算计算复杂度是更困难的问题,因为我们不仅需要证明算法会停止,还需要证明它的速度

//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. 一些算法 的复杂度很奇怪并且不同寻常

一个普通的"复杂度确定方案"由于这些算法而变得过于复杂

//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. 一些函数非常简单,但会使许多静态分析尝试混淆

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

  • 基本情况: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定理,这是我现在能想到的用于更复杂的递归问题的唯一规则。当您必须处理棘手的分治算法时,请使用它。


此外,在你的“if case”示例中,我会通过欺骗并将其拆分为两个不需要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”。没有通用的规则来做到这一点,如Collatz算法示例所示


不错的回答。我也同意。但是如果离题并尝试通过提供数据并进行统计分析来找到函数的复杂性呢?显然,这对于所有类型的函数都不起作用,有时非常不切实际 - 但是如果您可以证明参数范围,那么它可能会给您一个令人满意的结果,不是吗? - stefan
@stephan:程序基准测试通常过于不精确,无法产生“好看”的复杂度界限(在理论意义上),但它们仍然对解决一些困难问题(如平均情况分析或严重依赖于输入的问题)提供了宝贵的见解。 - hugomg
@missingno 嗯,传统的基准程序(分析器)不会做我想做的事情。 我更多地考虑设置一个具有明确定义跨度的参数激发装置。 然后可以通过简单的数学来近似这些数据以得到我们的复杂度函数。 从这个函数获得大O是微不足道的。 - stefan
问题在于对于你可以进行基准测试的小N,有太多的东西会影响渐近意义,这意味着你只能得到一个非常粗略的估计,可能比你之前已经知道的还要差 - 试着在图表中区分O(n)和O(n log n) ;)。此外,对于真正困难的问题,很难创建全面的基准测试,因为许多因素都会影响运行时间(当人们开始在论文中使用物理术语时,你就知道事情已经失控了:P)。 - hugomg
Collatz猜想的学生试图证明他的猜想:http://www.i-programmer.info/news/112-theory/2525-collatz-conjecture-proved.html - 共32页,但是他在第11页上犯了一个错误。 - Alvin K.

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次,所以我们可以简化成:
int counter = 0;
counter += n * n;

我们已经得出了(正确的)O(n2)时间复杂度 :) 它在代码中呈现出来了 :)

现在让我们看一下递归斐波那契计算器的工作原理:

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

修改该程序使其返回在程序内部进行的迭代次数而不是实际的斐波那契数:

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

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

好的,我们来看一些更有趣的东西,比如简单的(且低效的)归并排序:

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
尽管这是一个过于概括的说法,但我喜欢用列表来解释大O符号,其中列表的长度为N项。
因此,如果您有一个for循环,它遍历列表中的所有内容,则其复杂度为O(N)。在您的代码中,您有一行代码(仅就该行而言)的复杂度为O(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),我会指向一个更优雅的解释这里


2
我喜欢使用两种Big-O符号:标准的Big-O,即最坏情况下的运行时间;平均的Big-O,即通常发生的情况。同时,我也需要记住,Big-O符号是试图近似运行时间作为输入数量N的函数。
TA用组合之类的东西解释了这个问题。比如,n个选择2个=n选2=(n(n-1))/2=n^2+0.5,然后去掉常数,得到n^2。我可以尝试插入测试值,但这个组合具体怎么算的?
正如我所说,正常的Big-O情况是最坏的情况。你可以尝试计算每行被执行的次数,但更简单的方法是看第一个例子,看看有两个循环重复n次,一个嵌套在另一个中,因此是n*n。如果它们是一个接一个的,那就是n+n,等于2n。由于这是一个近似值,你只需说n或线性即可。
如果有if语句该怎么办?如何确定复杂度?
对我来说,拥有平均情况和最佳情况对于整理我的思路非常有帮助。在最坏的情况下,你忽略if语句并说n^2。在平均情况下,对于你的示例,你有一个循环n次,另一个循环n的一部分,发生了一半的时间。这给你n*n/x/2(x是嵌套循环中循环的n的任何一部分的分数),这样你就得到了n^2/(2x),所以你仍然得到了n^2。这是因为它是一个近似值。
我知道这不是对你问题的完整回答,但希望它能在代码复杂度的近似中提供一些启示。正如在我的回答之前所述,显然不能确定所有代码片段的复杂度;我只想添加使用平均情况Big-O符号的想法。

0

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

对于第二个片段,你可以轻易地看出其中一半时间会执行第一个函数,另一半时间会执行第二个函数。根据里面的内容(希望它依赖于n),你可以将等式重写为递归式。

递归方程(包括第三个片段)可以写成这样:第三个方程将出现为

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

我们可以很容易地看出这是O(n)。


-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)相同。


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