具有相同变量的 two/three 内部循环的复杂性

Complexity in two/three inner loops with same variable

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

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。 那么总体西格玛应该是多少?

TL;DR:

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

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

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

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

这是一个很棒的地方,可以介绍这个锻炼大 O 的格言运行次:

"When in doubt, work inside out."

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

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

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

这个循环做了多少工作?嗯,我们计算到 i2,每次迭代做 O(1) 工作,所以这个循环做 Θ(i2) 工作.请注意,我们用 i 而不是 n 来表示 运行time,这是有意为之的。此处完成的工作量根据 i 的值从一个迭代到另一个迭代发生变化,我们将尽可能精确。

用“do Θ(i2) work”替换那个循环给我们这个:

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 是循环 运行s 的 k 的最大值。 在这里做一点代数,注意

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

= (22)0 + (22)1 + (22)2 + (22)3 + ...(22)kmax

= 40 + 41 + ... + 4kmax

使用几何数列求和的公式,可以简化为

(4kmax + 1 - 1) / 3

= Θ(4kmax)

所以我们现在需要做的就是计算出 kmax 是多少,然后我们就会完成全部工作。为此,请注意,一旦我们超过 n,此循环就会停止 运行ning,这意味着我们将在

时停止此循环

n = 2kmax

lg n = kmax

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

Θ(4kmax)

= Θ(4log2 n)

= Θ((22)log2 n)

= Θ(22 log2n)

= Θ(2log2n2)

= Θ(n2),

所以我们完成的总功是Θ(n2)。请注意,我们并没有通过简单地查看循环并说类似“这个循环 运行s 这么多次,这个循环 运行s 这么多次,所以我们只是将它们相乘”相反,我们必须对序列和系列进行一些数学运算,然后慢慢地将事物分开才能得出结果。


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

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, 28, 216, 等等。这又可以重写为 220, 221, 22 2, 223, 224,等等。换句话说,我们正在看一些双指数的东西,更具体地说,在 k 次迭代之后,i 的值是 22k.

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

22k = n

2k = lg n

k = lg lg n

所以这个循环 运行s 进行 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) 工作。

如您所见,这里有许多不同的技巧:

  • 由内而外工作以简化循环并计算 运行时间。
  • 使用几何级数求和的公式。
  • 使用对数的性质。
  • 为递归函数编写递归关系并迭代递归以获得完成工作的表达式。
  • 上界和下界求和以获得紧界。

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