【问题标题】:Java complexity of two recursive methods两种递归方法的Java复杂度
【发布时间】:2015-05-31 01:44:21
【问题描述】:
public static String rec1 (String s) {

    int n = s.length()/2; 
    return n==0 ? s : rec1(s.substring(n)) + rec1(s.substring(0,n)); 
}


public static String rec2 (String s) {
    return s.length()<=1 ? s : rec2(s.substring(1)) + s.charAt(0); 
}

为什么rec2 的复杂度大于rec1

我对每个都进行了 10.000 次迭代,并使用 System.nanoTime() 测量了执行时间,结果如下:

rec1:字符串长度:200 平均时间:19912ns 递归调用:399 rec1:字符串长度:400 平均时间:42294 ns 递归调用:799 rec1:字符串长度:800 平均时间:77674 ns 递归调用:1599 rec1:字符串长度:1600 平均时间:146305 ns 递归调用:3199 rec2:字符串长度:200 平均时间:26386 ns 递归调用:200 rec2:字符串长度:400 平均时间:100677 ns 递归调用:400 rec2:字符串长度:800 平均时间:394448 ns 递归调用:800 rec2:字符串长度:1600 平均时间:1505853 ns 递归调用:1600

所以在字符串长度为 1600 时,rec1 比 rec2 快 10 倍。我正在寻找一个简短的解释。

【问题讨论】:

  • 是什么让你觉得rec2的复杂度>rec1?在每个方法中放置一个打印语句并尝试运行它们。例如,rec1("1234") 拨打 7 次电话,而 rec2("1234") 拨打 4 次。
  • 检查我上面的编辑,我使用 System.nanoTime 测量了时间,并试图找出 rec2 比 rec1 快的原因。 (甚至禁用了speedstep)
  • 我原来的答案是错误的。我现在已经更新了。

标签: java recursion methods complexity-theory


【解决方案1】:

根据Time complexity of Java's substring()String#substring 现在复制后备数组,O(n) 时间复杂度也是如此。

利用这一事实可以看出rec1 的时间复杂度为O(n log n),而rec2 的时间复杂度为O(n^2)

以初始 String s = "12345678" 开头。为简单起见,我将长度取为 2 的幂。

rec1:

  1. s 分为"1234""5678"
  2. 这些分为"12""34""56""78"
  3. 这些被分成"1""2""3""4""5""6""7""8"

这里有 3 个步骤,因为log(8) = 3。每个char在每一步都被复制,所以复制的字符总数为O(n log n)。当String 以相反的顺序重新组装时,上面的Strings 现在使用串联连接在一起,使用以下步骤:

  1. 字符串加入"21""43""65""87"
  2. 字符串加入"4321""8765"
  3. 字符串被连接成"87654321"

这又是O(n log n)复制的字符!

rec2:

  1. s 分为"1""2345678"
  2. "2345678" 分为"2""345678"
  3. "345678" 分为"3""45678"
  4. "45678" 分为"4""5678"
  5. "5678" 分为"5""678"
  6. "678" 分为"6""78"
  7. "78" 分为"7""8"

这是总共8 + 7 + 6 + 5 + 4 + 3 + 2 = 35 个复制字符。如果你知道代数,这通常是(n * (n+1)) / 2 - 1复制的字符,所以O(n^2)

当这一切以相反的顺序组装时,副本字符数将再次为O(n^2)

【讨论】:

  • 你是如何达到 O(n log n) 的?递归在每个级别中被调用两次。这抵消了 log n,总复杂度与递归次数成线性关系,乘以字符的副本。
  • @RealSkeptic 我有理由相信我是对的,但我要做一些测试来检查......
  • 感谢您的出色回答。我不确定是 O(n) 还是 O(n log n)
  • 你是对的,我已经相应地更正了我的答案。关键是每个字符都被复制了 depth 次,而不管递归步骤的数量。
【解决方案2】:

让我们研究一下性能差异:

String.substring() 
  • substring 在 Java 中非常便宜(直到 Java 7 Update 6),因为它不会复制原始数据,而只会更新同一数组上的偏移量。

字符串覆盖 + 运算符

  • 这里出现了差异,因为覆盖+ 运算符在非文字字符串的情况下使用StringBuilder。如果您深入了解StringBuilder.append() 方法的实现,您最终会找到对System.arraycopy() 的调用。

所以区别在于System.arraycopy() 处理的是rec1 中呈指数减小的数组大小,而rec2 中只处理线性减小的数组大小。

【讨论】:

  • 感谢您的回答。我测量了两种不同长度的方法的执行时间,它们在较长的字符串上表现不同,我正在寻找这种行为的原因。
  • 我不是反对者,但 Jon Skeet stackoverflow.com/questions/4679746/… subString 现在确实复制了原始数据。
  • 我目前正在运行 JDK 1.7.0.04,在这个版本中没有 subString 的复制。所以我的 Java 版本的差异主要来自 System.arraycopy()
【解决方案3】:

(这是关于时间复杂度的修正版本)

虽然递归的次数实际上在 n 中是线性的(因为递归在每一级都被调用两次),但就复制字符而言,两种方法之间存在差异。

每个方法在内部执行两个复制操作 - 一个用于 substring(在 Java 7 中),一个用于 concat(由 + 运算符表示)。

rec2 中,它会一遍又一遍地复制字符串的右侧,直到只剩下一个字符。所以字符串中的最后一个字符被复制depth次,深度是线性的。所以线性步骤乘以线性副本(实际上是一个系列)得到 O(n2)。

rec1 中,每个字符要么复制到左子字符串,要么复制到右子字符串。但是没有字符被复制超过 depth 次 - 直到我们得到单字符子字符串。所以每个字符都被复制了 n 次。递归虽然调用了两次,但并不是对同一个字符调用,所以两次调用引起的日志取消不会影响每个字符的副本数。

重建也是如此。相同的副本反过来发生。

副本数 - n 个字符乘以 log n 的 深度,得到 O(n log n)。执行的步骤数 - O(n),因此步骤数不如副本数重要,总复杂度为 O(n log n)。


此外,还有空间复杂度。 rec1 递归的深度为 O(log n),也就是说,它占用了 O(log n) 的堆栈空间。它这样做了两次,但这并没有改变大O。相比之下,rec2 的深度为 O(n)。

在我的机器上,使用长度为 16384 的字符串运行这两种方法会导致 rec2 的堆栈溢出。 rec1 完成没有问题。当然,这取决于您的 JVM 设置,但您了解情况。

【讨论】:

  • 我需要一些视觉帮助来更好地理解问题和你答案中的细节,所以我打印了递归调用:pastebin.com/WAipHBYE 也许它也可以帮助其他人。
猜你喜欢
  • 2015-09-06
  • 2021-05-07
  • 2021-05-28
  • 1970-01-01
  • 1970-01-01
  • 2013-10-09
  • 1970-01-01
  • 2017-11-20
  • 1970-01-01
相关资源
最近更新 更多