【问题标题】:Complexity in two/three inner loops with same variable具有相同变量的两个/三个内部循环的复杂性
【发布时间】:2021-09-01 01:22:44
【问题描述】:

我发现我在解决看似非常简单的复杂问题时想得太多了。 在处理这类问题时,我正在寻找一些“方法”。 例如(伪代码):

int func(int arr[], int n)
{
    int i'
    int a1, a2;
    if (n<=1)
        return arr[0];
    a1 = func(arr, n-1);
    a2 = 0;
    for(i=2, i<=n; i*=i)
    {
        j = i
        while(j>=i)
        {
            a2+=arr[j];
            j/=2;
        }
    }
    return a1+a2;
}
    
i = 1
while(i<n)
{
    j = 1
    while ( j < i^2)
        j++;
    i=i*2
}

对于第二个示例,我了解外部循环运行 logn 次。结果,内部循环运行:1,...,(n/2)^2,n^2。 那么整体 sigma 应该是多少?

【问题讨论】:

    标签: loops recursion language-agnostic big-o complexity-theory


    【解决方案1】:

    TL;DR:

    • 您的第一段代码及时运行Θ(n log log n)
    • 您的第二段代码及时运行Θ(n2)
    • 我们需要使用各种数学工具来解决这个问题,而这些并不是要分析的简单代码。

    让我们从 第二个 示例开始,而不是第一个,因为事实证明它要简单得多。这是第二个例子:

    i = 1
    while(i<n)
    {
        j = 1
        while (j < i^2)
            j++;
        i=i*2
    }
    

    我在这里假设i^2 表示i2 而不是“i xor 2”,但如果我弄错了,请纠正我。 :-)

    这是一个伟大的地方来介绍这个用于计算大O运行时的格言:

    “当有疑问时,由内而外地工作。”

    也就是说,从最里面的循环开始,计算出它的复杂性,然后用“do X work”形式的东西替换循环。通过重复这个过程,您最终会计算出整个运行时间。

    这里,我们最里面的循环是这个:

    j = 1;
    while (j < i^2)
        j++;
    

    这个循环做了多少工作?好吧,我们数到 i2,每次迭代做 O(1) 个工作,所以这个循环做 Θ(i2) 个工作。请注意,我们用 i 而不是 n 来表示运行时,这是有意的。此处完成的工作量会根据 i 的值在每次迭代中发生变化,我们将尽可能精确。

    用“做 Θ(i2) 工作”替换那个循环给我们这个:

    i = 1
    while(i < n)
    {
        do Θ(i^2) work;
        i = i * 2
    }
    

    我们的下一个任务是看看这个简化的循环完成了多少工作。首先,让我们看看i 在这里取什么值。这将是 1, 2, 4, 8, 16, 32, ...,即 20, 21, 22, 23, 24, 25, ... .更一般地,在外循环的 k 次迭代之后,我们有 i = 2k。因此,循环所做的工作是

    (20)2 + (21)2 + (22)2) + (23)2 + (24)2 + ... + (2kmax)2

    其中 kmax 是循环运行的 k 的最大值。 在这里做一点代数,注意

    (20)2 + (21)2 + (22)2) + (23)2 + (24)2 + ... + (2kmax)2

    = (22)0 + (22)1 + (22 )2 + (22)3 + ...(22)k最大

    = 40 + 41 + ... + 4kmax

    使用几何序列之和的公式,这可以简化为

    (4kmax + 1 - 1) / 3

    = Θ(4kmax)

    所以我们现在需要做的就是计算出 kmax 是多少,我们将完成全部工作。要做到这一点,请注意,一旦我们超过 n,这个循环就会停止运行,这意味着我们将在什么时候停止这个循环

    n = 2kmax

    lg n = kmax

    这意味着我们将有 lg n 次循环(这里,lg n 表示 log2 n)循环。将所有这些放在一起,我们已经完成的工作是

    Θ(4kmax)

    = Θ(4log2 n)

    = Θ((22)log2 n)

    = Θ(22 log2n)

    = Θ(2log2n2)

    = Θ(n2),

    所以我们完成的总工作是 Θ(n2)。请注意,我们并没有通过简单地查看循环并说“这个循环运行了这么多次,这个循环运行了这么多次,所以我们只是将它们相乘”之类的话来达到目标​​。相反,我们必须对序列和序列进行一些数学运算,然后慢慢地将它们分开来得到结果。


    现在,让我们继续第二个例子:

    int func(int arr[], int n)
    {
        int i;
        int a1, a2;
        if (n<=1)
            return arr[0];
        a1 = func(arr, n-1);
        a2 = 0;
        for(i=2, i<=n; i*=i)
        {
            j = i
            while(j>=i)
            {
                a2+=arr[j];
                j/=2;
            }
        }
        return a1+a2;
    }
    

    现在,让我们把递归部分放在一边,只关注循环。最里面的循环是这个:

    j = i;
    while (j >= i)
    {
        a2 += arr[j];
        j /= 2;
    }
    

    这个循环实际上并不难推理。请注意,内部循环的最后一步 (j /= 2;) 将减少j,使其小于i,并且循环将永远不会运行第二次。这意味着循环只做 O(1) 的总工作。因此,我们可以用类似“做 O(1) 的工作”来替换这个循环来得到这个:

    int func(int arr[], int n)
    {
        int i;
        int a1, a2;
        if (n<=1)
            return arr[0];
        a1 = func(arr, n-1);
        a2 = 0;
        for(i=2, i<=n; i*=i)
        {
            do O(1) work;
        }
        return a1+a2;
    }
    

    现在让我们关注这个循环:

    for(i = 2; i <= n; i *= i)
    {
        do O(1) work;
    }
    

    我们在这里做了多少工作? i 的值将是 2, 4, 16, 256, 65536, ... 。我们在这里寻找某种模式,看看我们是否可以计算出执行的循环迭代次数。如果我们检查这些值,我们可以看到它们等于 21、22、24、2 8, 216, etc. 依次可以改写为 220, 221、222、223、224 等。换句话说,我们正在研究双重指数,更具体地说,在 k 次迭代之后,i 的值是 22k.

    一旦我们超过n,这个循环就会停止,所以我们正在寻找 k 的值,循环迭代的次数,其中 22k = n .解决,我们明白了

    22k = n

    2k = lg n

    k = lg lg n

    所以这个循环运行 lg lg n 次迭代,每个迭代都做 O(1) 工作,所以这里完成的总工作是 Θ(log log n)。这是有道理的——如果你反复对一个值求平方,you can only do so O(log log n) times before you overshoot the number n

    然后我们可以用类似“do Θ(log log n)”的方法替换循环来得到这个:

    int func(int arr[], int n)
    {
        if (n <= 1)
            return arr[0];
        
        func(arr, n-1);
        do Θ(log log n) work;
        return /* something */;
    }
    

    进步!我们现在有一个递归函数,它执行 Θ(log log n) 工作,然后调用自身,n 减一。把它写成递归关系是一个很好的下一步尝试。让我们让 T(n) 表示使用参数 n 调用该函数时所做的工作量。然后我们就有了

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

    为什么这是我们得到的重复?好吧,当输入大小为 n 时完成的工作是 log log n,加上评估 n - 1 上的递归调用所需的工作量。(我在这里删除了 Θ 只是为了使数学更容易。)

    我们可以重复这个循环几次,看看我们是否发现了一个模式:

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

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

    = T(n - 3) + log log (n - 2) + log log (n - 1) + log log n

    如果我们想象一直执行到最后,我们会得到类似的东西

    T(n) = log log 2 + log log 3 + log log 4 + ... + log log (n - 1) + log log n

    那么这简化了什么呢?好吧,我们可以通过注意得到一个很好的保守上界

    T(n) = log log 2 + log log 3 + log log 4 + ... + log log (n - 1) + log log n

    ≤ log log n + log log n + log log n + ... + log log n + log log n

    = n log log n

    = O(n log log n)

    它给出了 O(n log log n) 工作的上限。我们可以为下界做类似的事情:

    T(n) = log log n + log log (n-1) + log log (n-2) + ... + log log (3) + log log 2

    ≥ log log n + log log (n-1) + log log (n-2) + ... + log log (n/2)

    ≥ log log (n/2) + log log (n/2) + log log (n/2) + ... + log log (n/2)

    = (n / 2) log log (n / 2)

    = Ω(n log log n)

    所以做的功是Ω(n log log n)。而且由于它同时是 O(n log log n) 和 Ω(n log log n),所以它的结果是 Θ(n log log n) 工作。

    如您所见,这里有很多不同的技术可以发挥作用:

    • 由内而外地简化循环并制定运行时。
    • 使用几何级数求和公式。
    • 使用对数的属性。
    • 为递归函数编写递归关系并迭代递归以获得已完成工作的表达式。
    • 上限和下限总和以获得严格的界限。

    我希望我能告诉您,有一种“简单”的方法可以始终计算出递归函数或循环嵌套需要多少工作,但可惜,它更像是一门艺术而不是一门科学。通过练习,您将学会识别许多常见模式。

    【讨论】:

      猜你喜欢
      • 2021-10-07
      • 2022-08-12
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-10-30
      相关资源
      最近更新 更多