确定递归函数的复杂度(大O表示法)

415

我明天要参加计算机科学期中考试,需要帮助确定这些递归函数的复杂度。我知道如何解决简单情况,但我仍在努力学习如何解决更难的情况。以下是一些我无法解决的示例问题。非常感谢任何帮助,并将极大地帮助我的学习,谢谢!

int recursiveFun1(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun1(n-1);
}

int recursiveFun2(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun2(n-5);
}

int recursiveFun3(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun3(n/5);
}

void recursiveFun4(int n, int m, int o)
{
    if (n <= 0)
    {
        printf("%d, %d\n",m, o);
    }
    else
    {
        recursiveFun4(n-1, m+1, o);
        recursiveFun4(n-1, m, o+1);
    }
}

int recursiveFun5(int n)
{
    for (i = 0; i < n; i += 2) {
        // do something
    }

    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun5(n-5);
}

6
如果您不想每次都进行分析,那么有一种称为主方法的黑盒技术。但是需要假设每个递归输入的拆分在每个实例中大小相等。 - Vivek Krishna
2
算法分析的主定理 - RBT
描述5的方法如下:O(f(n)) = T(n/2) ... T((n-5)/2) ... T((n-10)/2)...1,因此树的高度为n/5。这将使得O(f(n))需要T((n/5 * n/2) - (5/2 *n/5)),所以在最坏情况下,递归情况的输入n的上限为O(2^N)。同样,在最好情况和平均情况下也是如此。 - Anthony Moon Beam Toorie
7个回答

579

每个函数的时间复杂度,以大O符号表示:


int recursiveFun1(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun1(n-1);
}

该函数在达到基准情况之前被递归调用了 n 次,因此它的时间复杂度为 O(n),通常称为线性


该函数在达到基准情况之前被递归调用了n次,因此其时间复杂度为O(n),通常被称为线性


int recursiveFun2(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun2(n-5);
}

每次调用该函数时都称为n-5,因此在调用该函数之前我们要从n中减去五,但是n-5也是O(n)。 (实际上称为n/5次的阶数。而,O(n/5) = O(n))。


int recursiveFun3(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun3(n/5);
}

这个函数以5为底取对数,每次在调用函数之前除以5,因此它的时间复杂度是O(log(n))(基于5),通常称为对数级别,而大O表示法和复杂性分析通常使用2作为底数。


void recursiveFun4(int n, int m, int o)
{
    if (n <= 0)
    {
        printf("%d, %d\n",m, o);
    }
    else
    {
        recursiveFun4(n-1, m+1, o);
        recursiveFun4(n-1, m, o+1);
    }
}

这里是O(2^n),或指数级别的,因为每个函数调用都会调用自己两次,除非它已经递归了n次。



int recursiveFun5(int n)
{
    for (i = 0; i < n; i += 2) {
        // do something
    }

    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun5(n-5);
}

这里的for循环会以每次加2的方式进行,因此需要n/2次迭代。递归需要n/5的时间,由于for循环是递归调用的,因此时间复杂度为

(n/5) * (n/2) = n^2/10,

由于渐近行为和最坏情况的考虑或者说是大O所追求的上界,我们只关心最大项,即O(n^2)


祝你期中考试好运 ;)


2
@MJGwater 假设循环的运行时间为m。当递归运行1次时,执行循环需要m的时间。当递归运行2次时,循环也会运行2次,因此需要2m的时间...以此类推。所以应该使用“*”而不是“^”。 - bjc
4
@coder 第五条的解释似乎有些奇怪。如果每次增加2会导致“for”循环迭代n/2次,那么为什么每次减5不会导致n/5次递归调用呢?这仍将导致O(n^2),但看起来更符合直觉的解释。为什么要混合减法和除法,当它们本质上是在做同样的事情时呢? - Jack
1
@Jack 是的,我也在想同样的问题。应该是 n/5 而不是 n-5。最终,整个程序将归结为 O(N^2) - Optider
2
我可能在某个地方会出现一个数学问题,但我的第5题解决方案(虽然仍为n^2)是不同的。 基本情况:T(0) = 1,导致 T(n) = n/2 + T(n-5),展开后得到 T(n) = n/2 + (n/2 + T(n-10)) 进一步扩展为 T(n) = n/2 + (n/2 + (n/2 + T(n-15))) 可以描述为 T(n) = k(n/2) + T(n-5k),因此我们通过以下方式找到T(0) 5k = n,并适当地替换k T(n) = (n/5)(n/2) + T(n - n),这将减少到 T(n) = (n^2/10) + T(0),这将减少到 T(n) = (n^2/10) + 1,即 T(n) = n^2 - Pedro
8
每次被调用时,您需要从计数器中减去5。假设n=100;第二次调用时,计数器变为95,然后变为90,一直到0。如果您统计它被调用的次数,您会注意到它被调用了20次而不是95次,因此正确的调用次数应该是n/5而不是n-5次。 - coder
显示剩余15条评论

150
n <= 0 的情况下,T(n) = O(1)。因此,时间复杂度取决于 n >= 0 的情况。
在接下来的部分中,我们将考虑 n >= 0 的情况。
1.
T(n) = a + T(n - 1)

其中a是某个常数。

归纳法证明:

T(n) = n * a + T(0) = n * a + b = O(n)

其中a,b是一些常数。

2.

T(n) = a + T(n - 5)

其中a是某个常数

通过归纳:

T(n) = ceil(n / 5) * a + T(k) = ceil(n / 5) * a + b = O(n)

其中a,b是一些常数,k ≤ 0。

3.

T(n) = a + T(n / 5)

其中a是某个常数

归纳证明:

T(n) = a * log5(n) + T(0) = a * log5(n) + b = O(log n)

其中a,b是一些常数。

4.

T(n) = a + 2 * T(n - 1)

其中a是某个常量

通过归纳法:

T(n) = a + 2a + 4a + ... + 2^(n-1) * a + T(0) * 2^n 
     = a * 2^n - a + b * 2^n
     = (a + b) * 2^n - a
     = O(2 ^ n)

其中a和b是一些常数。

5.

T(n) = n / 2 + T(n - 5)

其中n是一个常数

n = 5q + r重新表述为q和r是整数且r等于0、1、2、3或4

T(5q + r) = (5q + r) / 2 + T(5 * (q - 1) + r)

我们有 q = (n - r) / 5,由于 r < 5,我们可以将其视为常数,因此q = O(n)

归纳法证明:

T(n) = T(5q + r)
     = (5q + r) / 2 + (5 * (q - 1) + r) / 2 + ... + r / 2 +  T(r)
     = 5 / 2 * (q + (q - 1) + ... + 1) +  1 / 2 * (q + 1) * r + T(r)
     = 5 / 4 * (q + 1) * q + 1 / 2 * (q + 1) * r + T(r)
     = 5 / 4 * q^2 + 5 / 4 * q + 1 / 2 * q * r + 1 / 2 * r + T(r)

由于r < 4,我们可以找到一些常数b使得b >= T(r)

T(n) = T(5q + r)
     = 5 / 2 * q^2 + (5 / 4 + 1 / 2 * r) * q + 1 / 2 * r + b
     = 5 / 2 * O(n ^ 2) + (5 / 4 + 1 / 2 * r) * O(n) + 1 / 2 * r + b
     = O(n ^ 2)

2
最近我在一次面试中失利了,问题涉及对递归斐波那契函数的时间和空间复杂度分析,因此导致整个面试都失败了。这篇答案真是太棒了,帮了我很多,我喜欢它,真希望我能给你点赞两次。虽然这是旧文章,但你有没有类似计算空间复杂度的东西?也许一个链接、任何东西? - Dimitar Dimitrov
1
对于第四个问题,即使结果相同,归纳应该是以下的吗?T(n) = a + 2T(n-1) = a + 2a + 4T(n-2) = 3a + 4a + 8T(n-3) = a * (2^n - 1) + 2^n * T(0) = a * (2^n - 1) + b * 2^n = (a + b) * 2^n - a = O(2^n)。 - Snowfish

45

我认为近似递归算法复杂度的最佳方式之一是绘制递归树。当你有了递归树之后:

Complexity = length of tree from root node to leaf node * number of leaf nodes
  1. 第一个函数的长度为n,叶节点数量为1,因此复杂度为n*1 = n
  2. 第二个函数的长度为n/5,叶节点数量仍然是1,因此复杂度为n/5 * 1 = n/5。应该近似为n

  3. 对于第三个函数,由于每次递归调用时都会把n除以5,递归树的长度将为log(n)(以5为底),叶节点数量仍然是1,因此复杂度为log(n)(以5为底) * 1 = log(n)(以5为底)

  4. 对于第四个函数,由于每个节点都有两个子节点,叶节点的数量将等于(2^n),递归树的长度为n,因此复杂度为(2^n) * n。但由于n(2^n)面前微不足道,可以忽略,只能说复杂度是(2^n)

  5. 对于第五个函数,有两个元素引入了复杂度。由函数的递归性质引入的复杂度和由每个函数中for循环引入的复杂度。通过上述计算,由函数的递归性质引入的复杂度将为~ n,由for循环引入的复杂度将为n。总复杂度将为n*n

注:这是一种粗略快速地计算复杂度的方法(非官方!)。欢迎提出反馈意见。谢谢。


非常好的回答!我有一个关于第四个函数的问题。如果它有三个递归调用,答案会是(3^n)吗?还是你仍然只说(2^n)? - Ben Forsrup
@Shubham:我觉得#4不对。如果叶子节点的数量是2^n,那么树的高度必须是n,而不是log n。只有当n表示树中节点的总数时,高度才会是log n。但它并不是这样。 - Julian A.
@BenForsrup:这将是3^n,因为每个节点都将有三个子节点。最好的方法是使用虚拟值自己绘制递归树以确保这一点。 - Shubham
#2 应该是 n-5 而不是 n/5。 - Fintasys
一个不适用的例子:制作最小堆需要O(n)时间,但它有O(n/2)个叶子和O(log(n))的高度。 - Elliott
在第三个中,我们不会将复杂度缩写为log(n)吗? - Jason210

27

我们可以在数学上证明它,这是我在上面的答案中缺失的内容。

这可以极大地帮助您了解如何计算任何方法。 我建议从上到下阅读它,以充分理解如何进行计算:

  1. T(n) = T(n-1) + 1表示该方法完成所需的时间等于将 n-1 带入相同的方法得到的时间,即T(n-1),加上完成一般操作所需的时间(除了 T(n-1))。现在,我们需要找到 T(n-1),方法如下:T(n-1) = T(n-1-1) + 1。看起来我们可以构建一个函数以获得某种重复,以便我们完全理解。我们将 T(n-1)=... 的右侧替换为方法 T(n) = ... 中的 T(n-1),得到:T(n) = T(n-1-1) + 1 + 1,即T(n) = T(n-2) + 2,或者说我们需要找到缺失的 kT(n) = T(n-k) + k。下一步是取出n-k,并声明n-k=1,因为递归结束时当n<=0时恰好需要 O(1) 的时间。从这个简单的方程中,我们现在知道k=n-1。我们将 k 放入最终的方法中:T(n) = T(n-k) + k,这将给我们:T(n) = 1 + n - 1,即nO(n)
  2. 与第 1 个问题相同。您可以自行测试,看到您得到了 O(n)
  3. T(n) = T(n/5) + 1与第 1 个问题类似,该方法完成的时间等于使用 n/5 替换相同方法时所需的时间,因此它被限制为 T(n/5)。像在第 1 个问题中一样,让我们找到 T(n/5)T(n/5) = T(n/5/5) + 1,即T(n/5) = T(n/5^2) + 1。将 T(n/5) 放入 T(n) 中进行最终计算:T(n) = T(n/5^k) + k。同样,由于递归结束时需要恰好 O(1) 的时间,而n/5^k=1,因此n=5^k,这正是询问什么次幂的 5 可以给我们 n 的答案,即log5n=k(以 5 为底的对数)。让我们将我们的发现放入 T(n) = T(n/5^k) + k,如下:T(n) = 1 + logn,即O(logn)
  4. T(n) = 2T(n-1) + 1与第 4 个问题基本相同,但这次我们递归调用该方法 2 次,因此需要将它乘以 2。让我们找到 T(n-1)=2T(n-1-1)+1,即T(n-1)=2T(n-2)+1。像在第 1 个问题中一样,让我们将发现放入其中:T(n) = 2(2T(n-2)) + 1 + 1,即T(n) = 2^2T(n-2) + 2,这给出了T(n)=2^kT(n-k)+k我现在建议您阅读其他答案,这将使您更好地了解情况。 祝您好运,赢得那些大O。


13

关键在于将调用树形象化。完成后,复杂性为:

nodes of the call tree * complexity of other code in the function

后一个术语的计算方式与普通迭代函数相同。

相反,完全二叉树的总节点数可以通过以下方式计算:

                  C^L - 1
                  -------  , when C>1
               /   C - 1
              /
 # of nodes =
              \    
               \ 
                  L        , when C=1 (this is special case of a single branch tree)

其中,C代表每个节点的子节点数,L代表树的层数(包括根节点)。

很容易想象这棵树。从第一个调用(根节点)开始,画出与函数中递归调用数相同的子节点数量。同时,将传递给子调用的参数写为“节点值”也很有用。

因此,以下是上述示例的结果:


int recursiveFun1(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun1(n-1);
}
首先考虑调用树:
n     level 1
n-1   level 2
n-2   level 3
n-3   level 4
... ~ n levels -> L = n
在这里,每个节点的子节点数量为C = 1,层数为L = n + 1。函数的其余部分的复杂度为O(1)。因此总复杂度为L * O(1)=(n + 1)* O(1)= O(n)
int recursiveFun2(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun2(n-5);
}

这里的调用树如下所示:

n
n-5
n-10
n-15
... ~ n/5 levels -> L = n/5
再次,C = 1,但 L = n/5。函数的其余部分复杂度为 O(1)。因此,总复杂度为 L * O(1) = (n/5) * O(1) = O(n)


int recursiveFun3(int n)
{
    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun3(n/5);
}

调用树是:

n
n/5
n/5^2
n/5^3
... ~ log5(n) levels -> L = log5(n)
因此,C = 1,L = log(n)。函数的其余部分的复杂度为O(1)。因此总复杂度为L * O(1) = log5(n) * O(1) = O(log(n))
void recursiveFun4(int n, int m, int o)
{
    if (n <= 0)
    {
        printf("%d, %d\n",m, o);
    }
    else
    {
        recursiveFun4(n-1, m+1, o);
        recursiveFun4(n-1, m, o+1);
    }
}

在这里,调用树更加复杂:

               n                   level 1
      n-1             n-1          level 2
  n-2     n-2     n-2     n-2      ...
n-3 n-3 n-3 n-3 n-3 n-3 n-3 n-3    ...     
              ...                ~ n levels -> L = n

节点每个有C = 2个子元素,而L = n。函数剩余部分的复杂度为O(1)。

由于C>1,我们这次使用调用树中节点数的完整公式。因此总复杂度为(C^L-1)/(C-1) * O(1) = (2^n - 1) * O(1) = O(2^n)


int recursiveFun5(int n)
{
    for (i = 0; i < n; i += 2) {
        // do something
    }

    if (n <= 0)
        return 1;
    else
        return 1 + recursiveFun5(n-5);
}

再次提醒,调用树如下:

n
n-5
n-10
n-15
... ~ n/5 levels -> L = n/5
这里 C = 1,L = n/5。函数其余部分的复杂度为 O(n)。因此总复杂度为 L * O(1) = (n/5) * O(n) = O(n^2)

我认为 n-5 并不等同于 n/5i += 2 也不等同于 n/2。如果 n 很大,例如 100,n-595,90,85..i += 22,4,6,...n/5100,20,4n/250,25,12,5。差别如此之大!?! - Kok How Teh
1
@KokHowTeh 如果你在谈论 recursiveFun2,你可能会混淆这里涉及的实体:n-5参数n/2 是要执行的 调用次数。由于 recursiveFun2 调用了 recursiveFun2(n-5),不管 n 有多大,调用次数都将是 n/5。尝试像这样思考:如果在每次调用中你跳过5个单位,你将总共覆盖多少个单位? - higlu
不对,你说的是 L=n/5,而在你的解释中,L是调用树的层数,这明显不是 n/5。它怎么会是 n/5 而不是 n-5 呢?而且,在 recursiveFun2 中也没有 n/2recursiveFun5 也一样。n-m 不等于 n/m - Kok How Teh
2
@KokHowTeh,我会再试一次。显然这里没有人试图说n-m IS n/m。相反,我是在说一个以n-m为参数递归调用的函数会导致n/m个函数调用。因此,由于这个原因,recursiveFun2树的层数恰好为L=n/5。这里的事实是,树分叉成了每个节点只有一个子节点,这与L无关。我不知道这是否让你感到困惑。无论如何,只需计算:假设n=20,则总共有f(20),f(15),f(10),f(5) -> 20/5个调用。 - higlu
这里分享的公式真正的来源在哪里?谢谢。 - Kok How Teh

2

我看到对于接受的答案(recursivefn5),有些人对解释有困惑。所以我会尽我所知尝试澄清。

  1. for循环运行n/2次,因为在每次迭代中,我们都是将i(计数器)增加2倍。因此,假设n=10,则for循环将运行10/2=5次,即当i为0、2、4、6和8时分别运行。

  2. 同样地,递归调用每次都会减少5倍,即它运行n/5次。再次假设n=10,则递归调用运行2次,即n为10和5时,然后它达到基本情况并终止。

  3. 计算总运行时间,for循环在每次调用递归函数时运行n/2次。由于递归fxn运行n/5次(见2),for循环运行(n/2)*(n/5)=(n^2)/10次,这转化为O(n^2)的整体大O运行时间-忽略常数(1/10)...


0

目前你的回答不够清晰。请编辑并添加更多细节,以帮助其他人理解它如何回答所提出的问题。你可以在帮助中心找到有关如何撰写好答案的更多信息。 - Community

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