确定给定代码的复杂性

Posted

技术标签:

【中文标题】确定给定代码的复杂性【英文标题】:Determining the complexities given codes 【发布时间】:2011-12-14 16:45:11 【问题描述】:

给定一段代码,您将如何确定总体上的复杂性。我发现自己对大 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);
    

【问题讨论】:

【参考方案1】:

一般来说,没有办法确定给定函数的复杂度

警告!文字墙传入!

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

现在,对于更棘手的情况,我们可以获得更多的数学知识。给定输入的大小 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) 只是古老的斐波那契函数。我们可以使用归纳法对其设置一些界限。

例如,让我们通过归纳证明 T(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 theorem,这是我现在能想到的解决更困难的重现问题的唯一规则,它是常用的。必须处理一个棘手的分治算法。


另外,在您的“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)。

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

【讨论】:

不错的答案。并且我同意。但是如果偏离主题并试图通过提供数据并对其进行统计分析来找出函数的复杂性呢?显然它不适用于所有类型的函数,有时它非常不切实际 - 但如果你能证明参数的跨度,它可以给你一个令人满意的结果,不是吗? @stephan:程序基准通常过于不精确,无法产生“良好”的复杂性界限(在理论上),但它们对于深入了解难题(例如平均情况)仍然非常宝贵严重依赖输入的分析或问题) @missingno 嗯,传统的基准测试程序(分析器)不会按照我的想法去做。我更想建立一个具有明确定义跨度的参数化激励装置。然后可以通过简单的数学来近似这些数据,从而得到复杂度函数。从这个函数中得到 Big-O 是微不足道的。 问题在于,对于小 Ns,您可以进行基准测试,有太多东西会扰乱渐近性,这意味着您只能得到一个非常粗略的估计,这可能不会比您事先已经知道的好多少- 尝试在图中区分 O(n) 和 O(n log n) ;)。此外,对于真正困难的问题,很难创建全面的基准测试,因为很多事情都会影响运行时间(当人们开始在他们的论文上使用 physics terminology 时,你知道事情何时失控:P) Collat​​z 的学生试图证明他的猜想:i-programmer.info/news/112-theory/… - 32 页长,但是他在 p11 上犯了一个错误。【参考方案2】:

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

但是,一种很酷且以代码为中心的方法是直接从程序的角度进行思考。举个例子:

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

【讨论】:

【参考方案3】:

尽管这是一种过度概括,但我喜欢从列表的角度来考虑 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 语句嵌套在外部 for 循环中。但是,因为它是 0(1),所以您将所有内容都乘以“1”,因此对整个函数的运行时间的最终计算没有“明显”影响。

对于日志,以及处理更复杂的情况(例如计数 j 或 i,而不仅仅是 n),我会为您指出一个更优雅的解释 here。

【讨论】:

【参考方案4】:

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

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

正如我所说,正常的 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 一样。这个是因为它是一个近似值。

我知道这不是您问题的完整答案,但希望它对近似代码的复杂性有所帮助。

正如我在上面的答案中所说,显然不可能为所有的 sn-ps 代码确定这一点;我只是想在讨论中添加使用平均情况 Big-O 的想法。

【讨论】:

【参考方案5】:

对于第一个 sn-p,它只是 n^2,因为您执行 n 次操作 n 次。如果j 被初始化为i,或者上升到i,那么您发布的解释会更合适,但目前情况并非如此。

对于第二个sn-p,你可以很容易地看到第一个将执行一半的时间,第二个将执行另一半的时间。根据里面的内容(希望它依赖于n),您可以将方程重写为递归方程。

递归方程(包括第三个 sn-p)可以这样写:第三个将显示为

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

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

【讨论】:

【参考方案6】:

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

【讨论】:

以上是关于确定给定代码的复杂性的主要内容,如果未能解决你的问题,请参考以下文章

如何为给定的代码编写递归关系

算法基础 -- 简介时间复杂度与空间复杂度

确定代码段的时间复杂度

如何确定这段代码的时间复杂度?

从 .NET 确定 C# 和 C++ 的 SLOC 和复杂性

算法 入门