这段简单的代码有多复杂?

Posted

技术标签:

【中文标题】这段简单的代码有多复杂?【英文标题】:What is the complexity of this simple piece of code? 【发布时间】:2011-11-01 15:17:10 【问题描述】:

我正在从我拥有的电子书中粘贴此文本。它说明了复杂性 if O(n2) 并给出了解释,但我看不出如何。

问题:这段代码的运行时间是多少?

public String makeSentence(String[] words) 
    StringBuffer sentence = new StringBuffer();
    for (String w : words) sentence.append(w);
    return sentence.toString();

本书给出的答案:

O(n2),其中 n 是句子中的字母数。原因如下:每次你 将字符串附加到句子,您创建句子的副本并遍历所有字母 复制它们的句子如果您每次在 循环,并且您至少循环了 n 次,这给了您 O(n2) 的运行时间。哎哟!

有人可以更清楚地解释这个答案吗?

【问题讨论】:

@Kublai Khan:这不是他/她的答案。正在阅读的是书中的答案,也就是被质疑的内容。 StringBuffer 有一个内部缓冲区,一旦溢出就会翻倍。这实际上意味着每次添加内容时都不会复制“句子”。只有当缓冲区溢出时才会发生。 如果您关心性能,请使用 StringBuilder "从 JDK 5 版本开始,此类已补充了一个为单线程使用而设计的等效类 StringBuilder。通常应使用 StringBuilder 类优于这个,因为它支持所有相同的操作,但速度更快,因为它不执行同步。" 【参考方案1】:

这似乎是一个误导的问题,因为我刚才碰巧读了那本书。书中这部分文字是错字!这是上下文:

================================================ =====================

问题:这段代码的运行时间是多少?

1 public String makeSentence(String[] words) 
2 StringBuffer sentence = new StringBuffer();
3 for (String w : words) sentence.append(w);
4 return sentence.toString();
5 

答案:O(n2),其中 n 是句子中的字母数。原因如下:每次将字符串附加到句子时,都会创建句子的副本并遍历句子中的所有字母以复制它们。如果您每次在循环中必须迭代最多 n 个字符,并且您循环至少 n 次,那么运行时间为 O(n2)。哎哟! 用StringBuffer(或StringBuilder)可以帮你避免这个问题。

1 public String makeSentence(String[] words) 
2 StringBuffer sentence = new StringBuffer();
3 for (String w : words) sentence.append(w);
4 return sentence.toString();
5 

================================================ =======================

你有没有注意到作者搞砸了?她提到的 O(n2) 解决方案(第一个)与“优化”的解决方案(后者)完全相同。所以,我的结论是作者试图渲染其他东西,例如在附加每个下一个字符串时总是将旧句子复制到新缓冲区,作为 O(n2) 算法的示例. StringBuffer 不应该这么傻,因为作者也提到了“使用 StringBuffer(或 StringBuilder)可以帮助你避免这个问题”。

【讨论】:

对,这个错误已经在下一本书的版本中更正了 复杂度是多少?你的回答没有这么说。【参考方案2】:

当代码以抽象出实现细节的高层次编写时,要回答有关此代码复杂性的问题有点困难。就append 函数的复杂性而言,Java documentation 似乎没有提供任何保证。正如其他人所指出的,StringBuffer 类可以(并且应该)这样编写,以便附加字符串的复杂性不取决于StringBuffer 中保存的字符串的当前长度。

但是,我怀疑问这个问题的人简单地说“你的书错了!”并没有太大帮助。 - 相反,让我们看看做出了哪些假设,并弄清楚作者想说什么。

你可以做出以下假设:

    创建new StringBuffer 是 O(1) 在words 中获取下一个字符串w 是O(1) 返回 sentence.toString 最多为 O(n)。

问题实际上是sentence.append(w) 的顺序是什么,这取决于它在StringBuffer 内部是如何发生的。天真的方法是像Shlemiel the Painter 那样做。

愚蠢的方式

假设您对 StringBuffer 的内容使用 C 风格的以空字符结尾的字符串。找到此类字符串结尾的方法是逐个读取每个字符,直到找到空字符 - 然后附加一个新字符串 S,您可以开始将字符从 S 复制到 StringBuffer 字符串(完成另一个空字符)。如果这样写append,就是O(a + b),其中a是@中当前的字符数987654335@,b是新词的字符数。如果你遍历一个单词数组,并且每次你必须在添加新单词之前读取你刚刚添加的所有字符,那么循环的复杂度是 O(n^2),其中 n 是字符总数在所有单词中(也是最后一句中的字符数)。

更好的方法

另一方面,假设StringBuffer 的内容仍然是一个字符数组,但我们还存储了一个整数size,它告诉我们字符串有多长(字符数)。现在我们不再需要读取StringBuffer 中的每个字符来找到字符串的结尾;我们可以在数组中查找索引size,它是 O(1) 而不是 O(a)。那么append 函数现在只依赖于被附加的字符数,O(b)。在这种情况下,循环的复杂度是 O(n),其中 n 是所有单词中的字符总数。

...我们还没有完成!

最后,实现的另一个方面还没有涉及,那就是教科书中的答案实际提出的方面 - 内存分配。每次您想向StringBuffer 写入更多字符时,不能保证字符数组中有足够的空间来实际放入新单词。如果没有足够的空间,您的计算机需要先分配一些在一段干净的内存中留出更多空间,然后将旧的StringBuffer数组中的所有信息复制过来,然后就可以像以前一样继续了。像这样复制数据需要 O(a) 时间(其中 a 是要复制的字符数)。

在最坏的情况下,您必须在每次添加新单词时分配更多内存。这基本上把我们带回到了循环具有 O(n^2) 复杂度的第一方,这就是这本书似乎所暗示的。如果您假设没有发生任何疯狂的事情(单词不会以指数速率!),那么您可能可以将内存分配的数量减少到更像 O(log(n) ) 通过使分配的内存呈指数增长。如果这是内存分配的数量,并且内存分配通常为 O(a),那么仅归因于循环中内存管理的总复杂度为 O(n log(n))。由于追加工作是O(n)且小于内存管理的复杂度,所以函数的总复杂度是O(n log(n))。

同样,Java 文档在StringBuffer 的容量如何增长方面没有帮助我们,它只是说“如果内部缓冲区溢出,它会自动变大”。根据它的发生方式,您最终可能会得到 O(n^2) 或 O(n log(n)) 的总体结果。

作为留给读者的练习:通过消除内存重新分配问题,找到一种简单的方法来修改函数,使整体复杂度为 O(n)。

【讨论】:

即使重新分配,复杂度也是 O(n)。将log(n) 乘以n 会产生误导,因为并非每次重新分配的成本都相同。最终重新分配的成本与所有其他的总和大致相同。 我认为内存性能为:O(log(n) * log(n)) 虽然我不确定这是否是一种有用的摊销思维方式。它确实提醒我它可能接近O(n) 并且需要更多研究。在这种情况下,您复制了大约 log(n) 个字符的平均值,大约 log(n) 次。【参考方案3】:

接受的答案是错误的。 StringBuffer 已摊销 O(1) 追加,因此 n 追加将是 O(n)。

如果不是 O(1) 追加,StringBuffer 将没有理由存在,因为使用普通的 String 连接编写该循环将是 O(n^2) 为好吧!

【讨论】:

请注意,javac 实际上优化了字符串连接以使用StringBuilders。 你能引用复杂性的来源吗? Ahh StringBuffer 下面基本上将所有内容复制到一个单字节数组中,当它的大小扩大时,它的大小“加倍”,因此摊销 O(1)。明白了。 你错了。复杂度为n^3。因为,复杂度将是 n+2n+...n^2,它与 n^3 成正比。【参考方案4】:

我试着用这个程序检查一下

public class Test 

    private static String[] create(int n) 
        String[] res = new String[n];
        for (int i = 0; i < n; i++) 
            res[i] = "abcdefghijklmnopqrst";
        
        return res;
    
    private static String makeSentence(String[] words) 
        StringBuffer sentence = new StringBuffer();
        for (String w : words) sentence.append(w);
        return sentence.toString();
    


    public static void main(String[] args) 
        String[] ar = create(Integer.parseInt(args[0]));
        long begin = System.currentTimeMillis();
        String res = makeSentence(ar);
        System.out.println(System.currentTimeMillis() - begin);
    

结果正如预期的那样,O(n):

java 测试 200000 - 128 毫秒

java 测试 500000 - 370 毫秒

java 测试 1000000 - 698 毫秒

版本 1.6.0.21

【讨论】:

【参考方案5】:

我认为书中的这些文字一定是错字,我认为正确的内容如下,我修复它:

================================================ =====================

问题:这段代码的运行时间是多少?

public String makeSentence(String[] words) 
    String sentence = new String("");
    for (String w : words) sentence+=W;
    return sentence;

答案:O(n2),其中 n 是句子中的字母数。原因如下:每次将字符串附加到句子时,都会创建句子的副本并遍历句子中的所有字母以复制它们。如果您每次在循环中必须迭代最多 n 个字符,并且您循环至少 n 次,那么运行时间为 O(n2)。哎哟!用StringBuffer(或StringBuilder)可以帮你避免这个问题。

public String makeSentence(String[] words) 
    StringBuffer sentence = new StringBuffer();
    for (String w : words) sentence.append(w);
    return sentence.toString();

================================================ =======================

我说的对吗?

【讨论】:

如果您认为原始问题的内容有任何错误,请编辑它 我相信这本书也是这么说的。【参考方案6】:

这本书有一个错字。


第一种情况

public String makeSentence(String[] words) 
    String sentence = new String();
    for (String w : words) sentence += w;
    return sentence;

复杂度:O(n^2) -> (n words) x (n 个字符在每次迭代中复制,用于将当前句子复制到 StringBuffer 中)


第二种情况

public String makeSentence(String[] words) 
    StringBuffer sentence = new StringBuffer();
    for (String w : words) sentence.append(w);
    return sentence.toString();

复杂度:O(n) -> (n words) x O(1)(StringBuffer 连接的平均复杂度)

【讨论】:

【参考方案7】:

这真的取决于StringBuffer 的实现。假设.append() 是常数时间,很明显你有一个O(n) 算法在n = length of the words array 的时间。如果.append 不是恒定时间,则您需要将 O(n) 乘以该方法的时间复杂度。如果StringBuffer 的当前实现确实是逐个字符地复制字符串,那么上面的算法就是

Θ(n*m),或O(n*m),其中n 是字数,m 是平均字长,你的书是错的。我假设您正在寻找一个严格的界限。

书中答案不正确的简单示例: String[] words = ['alphabet'] 根据本书的定义,n=8,所以算法的边界是 64 步。是这样吗?显然不严格。我看到有 n 个字符的 1 个分配和 1 个复制操作,所以你得到了大约 9 个步骤。这种行为是由O(n*m) 的边界预测的,如上所示。

我做了一些挖掘,这显然不是一个简单的角色副本。看起来内存正在被批量复制,这让我们回到O(n),这是您对解决方案的第一次猜测。

/* StringBuffer is just a proxy */
public AbstractStringBuilder append(String str) 

        if (str == null) str = "null";
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;


/* java.lang.String */
void getChars(char dst[], int dstBegin) 
             System.arraycopy(value, offset, dst, dstBegin, count);

你的书要么太旧,要么很糟糕,或者两者兼而有之。我没有足够的决心去挖掘 JDK 版本以找到一个不太理想的 StringBuffer 实现,但也许存在一个。

【讨论】:

Sun/Oracle JVM (Hotspot) 使用System.arraycopy 实现追加操作,而O(n) 操作又依赖于O(n) 操作(在OpenJDK 发行版的objArrayKlass.cpp 中实现)来执行实际的复制内存中的数组对象成员。所以,O(M*n) 是正确的。我怀疑任何其他 JVM 是否会在恒定时间内复制,因为必须重新对齐占用的内存块。 System.arraycopy 是否必须在所有 JVM 上串行?我可以想象以某种方式针对超并发硬件进行了优化,这使得对于给定字符串的大多数大小几乎是线性的。 @Vineet:啊。那么,在延迟内存的实际副本的同时增加缓冲区长度的实现怎么样?我想这里真正的答案是,StringBuffer 的实现完全是这个问题的解决方案,而且随着时间的推移和不同的 JVM 实现的不同,它可能并且很可能会发生变化。 调整大小/复制数组的问题在于,实际实现因一种架构而异(为简洁起见,假设此处仅使用 char 数组)。在 Windows 和 SPARC 上,它似乎是 O(n),因为至少有一个循环遍历被复制的字符数。在 Linux 和 Solaris x86 平台上,JVM 委托给 extern C 调用(我不知道其特征;我在汇编语言方面遇到了很大的困难)。 @Vineet - 可以安全地假设将数据从一个缓冲区复制到另一个缓冲区是 O(n)。至少在有人演示一种可以做得更快的算法之前。我认为充其量一些架构可能包含特殊指令,允许复制一些固定的多个单词作为单个操作的一部分。但即使有这样的指令,复制操作仍然减少到 O(n)。【参考方案8】:

正如书中给出的解释,对于字符串数组中的任何单词,都会创建一个新的句子对象,该句子对象首先复制前一个句子,然后遍历到数组的末尾,然后附加新单词,因此n^2 的复杂性。

    第一个'n'将前一句复制到一个新对象中 第二个 'n' 遍历该数组,然后追加它

因此n*n 将是n^2

【讨论】:

【参考方案9】:

对我来说看起来像 O(n)(n 是所有单词中的字母总数)。您基本上是在遍历words 中的每个字符以将其附加到StringBuffer 中。

我认为这是 O(n^2) 的唯一方法是 append() 在添加任何新字符之前迭代缓冲区中的所有内容。如果字符数超过当前分配的缓冲区长度,它实际上可能偶尔会这样做(它必须分配一个新缓冲区,然后将当前缓冲区中的所有内容复制到新缓冲区中)。但它不会在每次迭代中发生,所以你仍然不会有 O(n^2)。

您最多有 O(m * n),其中m 是缓冲区长度增加的次数。而且因为StringBuffer 每次分配更大的缓冲区时都会double its buffer size,我们可以确定m 大致等于log2(n)(实际上是log2(n) - log2(16),因为默认的初始缓冲区大小是16 而不是1)。

所以真正的答案是本书的示例是 O(n log n),您可以通过预先分配一个容量足以容纳所有字母的 StringBuffer 将其降低到 O(n)。

请注意,在 Java 中使用+= 附加到字符串确实表现出本书解释中描述的低效行为,因为它必须分配一个新字符串并将两个字符串中的所有数据复制到其中。所以如果你这样做,它就是 O(n^2):

String sentence = "";
for (String w : words) 
    sentence += w;

但使用StringBuffer 不应产生与上述示例相同的行为。这就是 StringBuffer 存在的主要原因之一。

【讨论】:

【参考方案10】:

这是我对他们如何得到 O(n^2) 的计算

我们将忽略声明 StringBuffer 的 CPU 时间,因为它不会随最终字符串的大小而变化。

在计算我们关心的最坏情况的 O 复杂度时,这将在有 1 个字母的字符串时发生。我将在这个例子之后解释:

假设我们有 4 个单字母字符串:'A'、'B'、'C'、'D'。

读入A: 查找 StringBuffer 结尾的 CPU 时间:0 追加“A”的 CPU 时间:1

读入 B: 查找 StringBuffer 结尾的 CPU 时间:1 追加“B”的 CPU 时间:1

读入 C: 查找 StringBuffer 结尾的 CPU 时间:2 追加“C”的 CPU 时间:1

读入 D: 查找 StringBuffer 结尾的 CPU 时间:3 追加“D”的 CPU 时间:1

最后将 StringBuffer 复制到 String 的 CPU 时间:4

总 CPU 时间 = 1 + 2 + 3 + 4 + 4

如果我们将其推广到 n 个 1 字母单词:

1 + 2 + 3 + ...... + n + n = 0.5n(n+1) + n

我通过使用算术数列之和的公式来做到这一点。

O(0.5n^2 + 1.5n) = O(n^2)。

如果我们使用多字母词,我们将不得不更频繁地查找 StringBuffer 的结尾,从而降低 CPU 时间和“更好”的情况。

【讨论】:

以上是关于这段简单的代码有多复杂?的主要内容,如果未能解决你的问题,请参考以下文章

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

这段代码的时间复杂度是多少(来自 leetcode)?

这段代码的时间和空间复杂度是多少?

我的 Perl 有多糟糕?获取 IP 地址并返回完全限定域名的脚本 [关闭]

Arduino IDE:“没有命名类型”,为啥我不能写这段代码? [复制]

数据结构与算法复杂度分析