这段简单代码的复杂度是多少?

34

我从一本电子书中复制了这段文本。它说这个算法的复杂度是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 个字符,并且你至少要循环 n 次,那么这将导致 O(n2) 的运行时间。糟糕!

有人能否更清楚地解释一下这个答案?


1
@Kublai Khan:这不是他/她的答案。被质疑的是正在阅读的书中的答案。 - vcsjones
StringBuffer具有一个内部缓冲区,一旦溢出就会加倍。这实际上意味着每次附加内容时不会复制“句子”。只有在缓冲区溢出时才会发生复制。 - Sap
1
如果您关心性能,请使用 StringBuilder。"自JDK 5版本发布以来,该类已被补充了一个等效的类,专为单个线程设计,即 StringBuilder。通常应优先使用 StringBuilder 类,因为它支持所有相同的操作,但速度更快,因为它不执行同步。" - Peter Lawrey
10个回答

26

这似乎是一个误导性的问题,因为我刚刚看过那本书。书中的这部分文字是一个打印错误!以下是上下文:

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

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

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)可以帮助您避免这个问题。”


好的,这个错误已经在下一本书的版本中得到了纠正。 - Kirill Vashilo
2
复杂度是什么?你的回答没有说到这一点。 - sbhatla

20

针对这个高度抽象的代码,回答涉及复杂性的问题有一定难度。根据Java文档中的描述,append函数的复杂度没有明确保证。正如其他人所指出的那样,应当(也可以)将StringBuffer类编写为字符串添加的复杂度不依赖于StringBuffer当前持有的字符串长度。

然而,我怀疑仅仅告诉提问者“你的书是错的!”并没有太多帮助。我们应该看看做了哪些假设,并明确作者想要表达的意思。

您可以做出以下假设:

  1. 创建一个 new StringBuffer 是 O(1)
  2. 获取下一个字符串 wwords 中是 O(1)
  3. 返回 sentence.toString 的复杂度最多为 O(n)。

问题实际上是关于 sentence.append(w) 的顺序,这取决于它在 StringBuffer 内部的实现方式。幼稚的方法是像Shlemiel the Painter一样进行操作。

愚蠢的方法

假设您使用 C 风格的空结束字符串来存储 StringBuffer 的内容。要查找这样一个字符串的末尾,您需要逐个读取每个字符,直到找到空字符 - 然后将新字符串 S 追加到 StringBuffer 字符串中,可以开始从 S 复制字符到 StringBuffer 字符串(以另一个空字符结尾) 。如果按照这种方式编写 append 函数,则其时间复杂度为 O(a+b) ,其中 a 是当前 StringBuffer 中的字符数,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),消除内存重新分配问题。


1
即使进行重新分配,复杂度仍为O(n)。将log(n)乘以n是具有误导性的,因为并非每个重新分配都具有相同的成本。最终的重新分配与所有其他重新分配的成本大致相同。 - fgb
我认为内存性能是 O(log(n) * log(n)),虽然我不确定这是否是一种有用的摊销思路。但它提醒我它可能接近于 O(n),需要更深入地研究。在这种情况下,您大约会复制平均约log(n)个字符,大约重复log(n)次。 - ebyrob

19

被接受的答案是错误的。 StringBuffer 具有摊销时间复杂度为 O(1) 的追加操作,因此 n 次追加的时间复杂度将为 O(n)。

如果它的追加操作不是 O(1),那么 StringBuffer 就没有存在的理由,因为使用普通的 String 连接来编写那个循环也会产生 O(n^2) 的时间复杂度!


1
请注意,javac 实际上会优化字符串拼接以使用 StringBuilder - Mechanical snail
2
你能引用一下这个复杂度的来源吗? - sbhatla
1
哦,StringBuffer 基本上是将所有内容复制到一个单一的字节数组中,在其大小扩展时会“加倍”,因此摊销的时间复杂度为 O(1)。注意了。 - rogerdpack
你错了。由于复杂度将是n^3。因为复杂度将是n+2n+...n^2,这与n^3成比例。 - Nagabhushan Baddi

13

我尝试使用这个程序进行检查

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 Test 200000 - 128 毫秒

java Test 500000 - 370 毫秒

java Test 1000000 - 698 毫秒

版本 1.6.0.21


12

我认为书中的这段文字一定是个打印错误,我认为下面的内容才是正确的,我已经修改好了:

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

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

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

答案:O(n2),其中n是句子中字母的数量。原因是每次将一个字符串附加到sentence后,都会创建sentence的副本并遍历sentence中的所有字母以将它们复制过去。如果每次循环都需要迭代最多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();
}

我是不是正确?


如果您认为原始问题的内容有误,请编辑它。 - LionC
我相信这本书也是想表达这个意思的。 - Jared Burrows

3
这本书中有一个错别字。

第一种情况:

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

复杂度 : O(n^2) -> (n个单词) 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 个单词) x O(1)(StringBuffer 连接的分摊复杂度)


2
那取决于StringBuffer的实现方式。假设append()是常数时间,很明显您在时间上有一个O(n)算法,其中n =单词数组的长度。如果append不是常数时间,则需要将O(n)乘以该方法的时间复杂度。如果确实当前的StringBuffer实现是逐个字符复制字符串,则上述算法为Θ(n*m)或O(n*m),其中n为单词数,m为平均单词长度,因此您的书是错误的。我假设您正在寻找严格的界限。
书中答案错误的简单例子: String[] words = ['alphabet']根据书的定义,n=8,因此算法将受到64步的限制。这是事实吗?显然不是严格的。我看到1个赋值和1个复制操作具有n个字符,因此您可以获得大约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实现追加操作,而System.arraycopy又依赖于objArrayKlass.cpp中的O(n)操作(在OpenJDK分发中实现),以执行内存中数组对象成员的实际复制。因此,O(M*n)是正确的。我怀疑其他任何JVM都无法在常数时间内进行复制,因为占用的内存块必须重新对齐。 - Vineet Reynolds
系统.arraycopy在所有JVM上都必须串行吗?我可以想象通过针对超级并发硬件进行优化,使其在给定字符串的大多数大小上几乎保持线性。 - Stefan Kendall
@Vineet:啊,那么,如何实现在推迟内存复制的同时增加缓冲区长度呢?我猜这里的真正答案是StringBuffer的实现完全是解决这个问题的方法,而且随着时间和JVM实现的不同,它可能会有所变化。 - Stefan Kendall
调整/复制数组的问题在于实际实现因架构而异(为简洁起见,此处仅考虑char数组)。在Windows和SPARC上,它似乎是O(n),因为至少有一个循环迭代要复制的字符数。在Linux和Solaris x86平台上,JVM委托给外部C调用(我不知道其特性;我在汇编语言方面遇到了很大的困难)。 - Vineet Reynolds
@Vineet - 可以安全地假设将数据从一个缓冲区复制到另一个缓冲区的时间复杂度为O(n)。除非有人证明可以使用更快的算法。我认为,在某些体系结构中,最多可能包括特殊指令,允许将一些固定数量的字作为单个操作的一部分进行复制。但即使使用这样的指令,复制操作仍然会降至O(n)。 - aroth
@aroth,是的,这就是我的观点。考虑到JVM中实现的所有其他检查,数组复制往往是O(n)的。似乎很难实现常数时间的复制,因为最好的可能性是memcpy/memmove,但它们仍然是O(n),尽管OpenJDK似乎将其用于基本类型而不是对象数组。 - Vineet Reynolds

1
根据书中所述,对于字符串数组中的每个单词,都会创建一个新的 sentence 对象,该句子对象首先复制前一个句子,然后遍历到数组的末尾并附加新单词,因此时间复杂度为 n^2
  1. 第一次 'n' 将前一个句子复制到一个新对象中
  2. 第二次 'n' 遍历该数组并附加它
因此,n*n 将是 n^2

0

在我看来,这似乎是 O(n)(其中 n 是所有单词中字母的总数量)。你基本上正在迭代 words 中的每个字符,并将其附加到 StringBuffer 中。

我唯一能想到这可能是 O(n^2) 的方式是如果 append() 在附加任何新字符之前迭代缓冲区中的所有内容。如果字符数超过当前分配的缓冲区长度(它必须分配一个新缓冲区,然后将当前缓冲区中的所有内容复制到新缓冲区中),则偶尔可能会发生这种情况。但这不会在每次迭代中发生,因此仍然不会产生 O(n^2)。

最多你会有O(m * n),其中m是缓冲区长度增加的次数。由于每次分配更大的缓冲区时,StringBuffer双倍增加其缓冲区大小,因此我们可以确定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存在的主要原因之一。


-1
这是我关于他们如何得出 O(n^2) 的计算:
我们忽略声明 StringBuffer 的 CPU 时间,因为它与最终字符串的大小无关。
在计算 O 复杂度时,我们关心的是最坏情况,当只有一个字母的字符串时会发生。我将在下面的示例之后解释:
假设我们有 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 复制到字符串末尾的 CPU 时间:4
总 CPU 时间 = 1 + 2 + 3 + 4 + 4
如果我们将其推广到 n 个一个字母的单词:

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

我使用等差数列求和公式得到了这个结果。

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

如果我们使用多字母单词,就会更少地找到StringBuffer的结尾,从而减少CPU时间,达到“更好”的情况。


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