【问题标题】:Why does this O(n^2) code execute faster than O(n)? [duplicate]为什么这个 O(n^2) 代码的执行速度比 O(n) 快? [复制]
【发布时间】:2018-11-17 22:43:47
【问题描述】:

我已经编写了两种方法的代码来找出 LeetCode 上字符串中的第一个唯一字符。

问题陈述: 给定一个字符串,找到第一个不重复的 字符并返回它的索引。如果不存在,则返回 -1。

示例测试用例:

s = "leetcode" 返回 0。

s = "loveleetcode",返回 2。

方法 1 (O(n))(如果我错了,请纠正我):

class Solution {
    public int firstUniqChar(String s) {

        HashMap<Character,Integer> charHash = new HashMap<>();

        int res = -1;

        for (int i = 0; i < s.length(); i++) {

            Integer count = charHash.get(s.charAt(i));

            if (count == null){
                charHash.put(s.charAt(i),1);
            }
            else {
                charHash.put(s.charAt(i),count + 1);
            }
        }

        for (int i = 0; i < s.length(); i++) {

            if (charHash.get(s.charAt(i)) == 1) {
                res = i;
                break;
            }
        }

        return res;
    }
}

方法 2 (O(n^2)):

class Solution {
    public int firstUniqChar(String s) {

        char[] a = s.toCharArray();
        int res = -1;

        for(int i=0; i<a.length;i++){
            if(s.indexOf(a[i])==s.lastIndexOf(a[i])) {
                res = i;
                break;
            }
        }
        return res;
    }
}

在方法 2 中,我认为复杂度应该是 O(n^2),因为 indexOf 在这里执行 O(n*1)。

但是当我在 LeetCode 上执行这两种解决方案时,方法 2 的运行时间为 19 毫秒,方法 1 的运行时间为 92 毫秒。我很困惑;为什么会这样?

我认为 LeetCode 会针对最佳、最差和平均情况对小输入值和大输入值进行测试。

更新:

我知道对于某些 n

LeetCode link to the question

【问题讨论】:

  • 您认为第二个解决方案是n^2 的原因是什么? indexOflastIndexOf 都最多迭代字符串一次,因此字符串长度为 n 的总复杂度为 2*n。 n^2 意味着您为每个字符迭代一次字符串。
  • @daniu 这就是他在做什么:for(int i=0; i&lt;a.length;i++){
  • @Nivedita 请注意 s.indexOf(a[i]) 是... i
  • @NathanHughes 对于 input="cc",如果我不使用 indexOf,代码将返回 1,而预期的答案是 -1,因为字符串没有唯一字符。
  • O() 衡量一个算法的可扩展性,而不是它的速度。

标签: java time-complexity big-o


【解决方案1】:

考虑:

  • f1(n) = n2
  • f2(n) = n + 1000

显然 f1 是 O(n2) 而 f2 是 O(n)。对于小的输入(例如,n=5),我们有 f1(n) = 25 但 f2(n) > 1000。

仅仅因为一个函数(或时间复杂度)是 O(n) 而另一个是 O(n2) 并不意味着前者对于所有 n 值都较小,只是存在是一些 n 超出这将是这种情况。

【讨论】:

  • 争辩说 n 太小可能会产生误导。即使使用较大的 n,如果字符串仍然是随机的,(根据my answer)解决方案 2 的性能仍然会更好。在最坏的情况下,n(字符串大小)小至 30000(使用更大的 n 进行 leetcode 测试!)第二种方法已经差了大约 50-100 倍。
【解决方案2】:

对于非常短的字符串,例如单个字符创建HashMap、重新调整大小、在将char 装箱和拆箱到Character 时查找条目的成本可能会超过String.indexOf() 的成本,这可能被认为是热的并且被JVM 内联.

另一个原因可能是访问 RAM 的成本。在查找中涉及额外的HashMapCharacterInteger 对象时,可能需要对RAM 的额外访问。单次访问约为 100 ns,这可以加起来。

看看Bjarne Stroustrup: Why you should avoid Linked Lists。本讲座说明性能与复杂性不同,内存访问可能是算法的杀手。

【讨论】:

  • 那么,这样说是否正确——理论上方法 1 更快,但实际上方法 2 的性能更好?
  • @Nivedita 在某些情况下,是的。理论方法并不是真的更快,它只是具有更好的复杂性,这是一种不同的衡量标准。
  • @Nivedita - 你所说的一般来说是不正确的。
  • 当然,如果你在做一个Cracking the coding interview风格的问题,N总是无限大的;)。一位候选人在没有提示的情况下告诉我理论与现实不完全一致,这给我留下了深刻的印象。
  • @Nivedita 你没抓住重点:O(n) 是基于 随着 n 变大而发生的。方法 1 不仅在理论上更快,方法 1 不会导致您的应用程序挂起更大的 n 值然后方法 2。总是,总是,总是想“这将如何中断?会出什么问题?”。永远,永远,永远不要专注于幸福的道路。
【解决方案3】:

Big O notation 是算法在内存消耗或计算时间方面的扩展方式的理论度量,N - 元素或主要操作的数量,始终为 N-&gt;Infinity

实际上,您的示例中的N 相当小。虽然向哈希表添加元素通常被认为是摊销 O(1),但它也可能导致内存分配(同样,取决于哈希表的设计)。这可能不是 O(1) - 并且还可能导致进程对内核进行系统调用以获取另一个页面。

采用O(n^2) 解决方案 - a 中的字符串会很快发现自己在缓存中,并且可能会不间断地运行。单个内存分配的成本可能会高于嵌套循环对。

在现代 CPU 架构的实践中,从缓存中读取的速度比从主内存中读取的速度快几个数量级,N 在使用理论上的最优算法优于线性数据结构和线性搜索之前将非常大。二叉树对于缓存效率来说尤其不利。

[编辑] 它是 Java:哈希表包含对装箱的 java.lang.Character 对象的引用。每次添加都会导致内存分配

【讨论】:

  • “每次添加都会导致内存分配”——是的,但请注意,Java 虚拟机通常围绕大多数 Java 程序执行大量内存分配这一事实进行了高度优化。 Java 使用极快的分配技术,需要权衡需要更复杂的内存回收,并希望如果分配模式足够简单或程序寿命短,则不需要后者。这里的问题很小且独立,因此 GC 也应该能够快速处理它。缓存位置几乎可以肯定是这里的关键。
  • 装箱到Character 不一定会导致分配,因为 ASCII 范围内的所有值都被缓存。但是HashMap 本身会为每个关联创建一个入口实例。
【解决方案4】:

O(n2) 只是第二种方法的最坏情况时间复杂度。

对于像bbbbbb...bbbbbbbbbaaaaaaaaaaa...aaaaaaaaaaa 这样的字符串,其中有xx a,每次循环迭代大约需要x 步骤来确定索引,因此执行的总步骤大约为2x<sup>2</sup>。对于x 大约 30000,大约需要 1~2 秒,而其他解决方案的性能会好得多。

在线试用时,this benchmark 计算出方法 2 比方法 1 慢大约 50 倍以上的字符串。对于更大的x,差异更大(方法1大约需要0.01秒,方法2需要几秒钟)

但是:

对于每个字符独立选择的字符串,统一来自{a,b,c,...,z} [1],预期时间复杂度应该是 O(n)。

这是真的,假设 Java 使用简单的字符串搜索算法,该算法一个接一个地搜索字符,直到找到匹配项,然后立即返回。搜索的时间复杂度是考虑的字符数。

可以很容易地证明(证明类似于this Math.SE post - Expected value of the number of flips until the first head),特定字符在字母表{a,b,c,...,z} 上的统一独立字符串中的预期位置是O(1)。因此,每个indexOflastIndexOf 调用都在预期的O(1) 时间内运行,而整个算法需要预期的O(n) 时间。

[1]:在original leetcode challenge中,据说是

你可以假设字符串只包含小写字母。

但是,问题中没有提到。

【讨论】:

  • 据我所知,此分析的问题在于您将字母大小限制为 26 个字符。如果我们不这样做,我会假设字母的长度与字符串的长度相比也会影响复杂性。
  • @voo 在 leetcode 示例中是真的。 (尽管很遗憾,问题中没有提到)
  • 出于所有实际目的,字母表可能绝对
【解决方案5】:

Karol 已经为您的特殊情况提供了很好的解释。我想就时间复杂度的大 O 表示法添加一个一般性评论。

一般来说,这个时间复杂度并不能告诉您太多关于实际性能的信息。它只是让您了解特定算法所需的迭代次数。

让我这样说:如果您执行大量快速迭代,这仍然比执行极少的极慢迭代要快。

【讨论】:

    【解决方案6】:

    首先,复杂性分析并不能告诉您太多信息。它曾经告诉你算法将如何——在理论上——随着问题的规模增长到很大的数字(如果你愿意的话,趋向于无穷大)进行比较,并且在某种程度上它仍然如此。 然而,复杂性分析做出的假设在大约 30 到 40 年前只有一半是正确的,而现在则完全不正确(例如,所有操作都相同,所有访问都相同)。我们生活在一个不断变化的因素巨大的世界中,并非所有操作都是相同的,即使是远程操作也不相同。到目前为止,必须非常小心地考虑它,在任何情况下你都不能假设“这是 O(N) 所以它会更快”。这是一个巨大的谬误。

    对于小数,查看“大 O”几乎没有意义,但即使对于大数,请注意 常数因子 可以发挥巨大的主导作用。不,常数因子不为零,而且不可忽略。永远不要假设。
    理论上超级棒的算法,例如,在仅 20 次访问的十亿个元素中找到某些东西,可能比需要 200,000 次访问的“坏”算法慢得多——如果在第一种情况下,20 次访问中的每一次都会导致页面错误使用磁盘查找(每个操作都价值数亿次)。理论和实践在这里并不总是齐头并进的。

    其次,尽管是惯用的并且通常看起来是一个好主意(它是 O(1),是吗?),但在许多情况下使用哈希映射是不好的。并非在所有情况下,但都是这样。比较两个代码 sn-ps 的作用。

    O(N2) 将一个中等大小的字符串转换为字符数组一次(基本上成本为零),然后以线性方式重复访问该数组。这几乎是计算机能够做的最快的事情,即使在 Java 中也是如此。是的,Java 不知道任何诸如内存或缓存之类的东西,但这不能改变这些东西存在的事实。以线性方式在本地访问少量/中等数量的数据很快

    另一个 sn-p 将字符插入到 hashmap 中,为每个字符分配一个数据结构。是的,Java 中的动态分配并没有那么昂贵,但是,分配远不是免费的,而且内存访问变得不连续。
    然后,计算散列函数。这是哈希映射经常忽略的东西。对于单个角色来说,这(希望)是一个便宜的操作,但它远非免费[1]。然后,数据结构以某种方式插入到存储桶中(从技术上讲,这不过是另一种非连贯的内存访问)。现在,很有可能发生冲突,在这种情况下,其他事情必须完成(链接、重新散列等)。
    稍后,再次从 hashmap 中读取值,这再次涉及调用 hash 函数、查找存储桶、可能遍历列表以及在每个节点上进行比较(由于可能发生冲突,这是必要的)。

    因此,每个操作都涉及至少两个间接,加上一些计算。总而言之,与只在一个小数组上迭代几次相比,这痛苦代价高昂。


    [1] 对于单字符键来说,这不是问题,但仍然是一个有趣的事实:人们经常用 O(1) 来谈论哈希映射,这已经不是真的,例如链接,但令人惊讶的是,实际上 散列 键的长度是 O(N)。这很可能很明显。

    【讨论】:

      【解决方案7】:

      我已将函数移植到 C++(17) 中,看看差异是由算法复杂性还是 Java 引起的。

      #include <map>
      #include <string_view>
      int first_unique_char(char s[], int s_len) noexcept {
          std::map<char, int> char_hash;
          int res = -1;
          for (int i = 0; i < s_len; i++) {
              char c = s[i];
              auto r = char_hash.find(c);
              if (r == char_hash.end())
                  char_hash.insert(std::pair<char, int>(c,1));
              else {
                  int new_val = r->second + 1;
                  char_hash.erase(c);
                  char_hash.insert(std::pair<char, int>(c, new_val));
              }
          }
          for (int i = 0; i < s_len; i++)
              if (char_hash.find(s[i])->second == 1) {
                  res = i;
                  break;
              }
          return res;
      }
      int first_unique_char2(char s[], int s_len) noexcept {
          int res = -1;
          std::string_view str = std::string_view(s, s_len);
          for (int i = 0; i < s_len; i++) {
              char c = s[i];
              if (str.find_first_of(c) == str.find_last_of(c)) {
                  res = i;
                  break;
              }
          }
          return res;
      }
      

      结果是:

      leetcode 的第二个速度要快约 30%。

      后来才发现

          if (r == char_hash.end())
              char_hash.insert(std::pair<char, int>(c,1));
          else {
              int new_val = r->second + 1;
              char_hash.erase(c);
              char_hash.insert(std::pair<char, int>(c, new_val));
          }
      

      可以优化为

          char_hash.try_emplace(c, 1);
      

      这也证实了复杂性不仅仅是唯一的事情。有“输入长度”,其他答案已经涵盖了,最后,我注意到了

      实施也有所作为。较长的代码隐藏了优化机会。

      【讨论】:

      • 这与HashMap 的等价物不一样,而是std::unordered_mapstd::map 将是 TreeMap AFAIK。
      • ^, std::map 在这些方面与 umap 相比性能较差。
      猜你喜欢
      • 1970-01-01
      • 2018-10-24
      • 2020-06-25
      • 2019-10-23
      • 2018-12-12
      • 1970-01-01
      • 1970-01-01
      • 2021-12-22
      • 1970-01-01
      相关资源
      最近更新 更多