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) 工作。
如您所见,这里有很多不同的技术可以发挥作用:
- 由内而外地简化循环并制定运行时。
- 使用几何级数求和公式。
- 使用对数的属性。
- 为递归函数编写递归关系并迭代递归以获得已完成工作的表达式。
- 上限和下限总和以获得严格的界限。
我希望我能告诉您,有一种“简单”的方法可以始终计算出递归函数或循环嵌套需要多少工作,但可惜,它更像是一门艺术而不是一门科学。通过练习,您将学会识别许多常见模式。