为什么此O(n ^ 2)代码执行得比O(n)快?

时间:2018-11-17 22:43:47

标签: java time-complexity big-o

我已经编写了两种方法的代码,以找出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在最佳,最差和平均情况下都对大小输入值进行了测试。

更新

我知道O(n ^ 2算法)对于某些n

LeetCode link to the question

7 个答案:

答案 0 :(得分:90)

考虑:

  • f 1 (n)= n 2
  • f 2 (n)= n + 1000

显然,f 1 是O(n 2 ),而f 2 是O(n)。对于少量输入(例如n = 5),我们有f 1 (n)= 25,但f 2 (n)> 1000。

仅仅因为一个函数(或时间复杂度)是O(n),另一个函数是O(n 2 )并不意味着前者对于所有n值都较小,而是是n以外的情况。

答案 1 :(得分:40)

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

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

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

答案 2 :(得分:17)

Big O notation是一种算法的理论度量,其中算法以N(元素或主要运算的数量,并且始终为{{1})在内存消耗或计算时间上进行扩展}。

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

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

在现代CPU体系结构中,从高速缓存中读取的数据比从主存储器中读取的数据要快几个数量级,在使用理论上最佳的算法胜过线性数据结构和线性搜索之前,a将会很大。二进制树对于提高缓存效率特别不利。

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

答案 3 :(得分:11)

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

对于诸如bbbbbb...bbbbbbbbbaaaaaaaaaaa...aaaaaaaaaaa的字符串,其中有x个b和x a,每个循环迭代大约需要x个步骤来确定索引,因此执行的总步骤数大约是2x2。对于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中,据说

  

您可以假定该字符串仅包含小写字母。

但是,问题中没有提到。

答案 4 :(得分:3)

Karol已经为您的特殊情况提供了很好的解释。我想就时间复杂度添加一个关于大O标记的一般性说明。

通常,这段时间的复杂性不会告诉您太多有关实际性能的信息。它只是让您了解特定算法所需的迭代次数。

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

答案 5 :(得分:3)

首先,复杂性分析并不能告诉您很多。它用来告诉您从理论上讲,随着问题规模的扩大(如果可能的话,趋向无穷),算法将如何比较,并且在某种程度上仍然如此。 br /> 但是,复杂性分析做出的假设在30到40年前只是半正确的,而如今却没有任何真实的假设(例如,所有操作都相同,所有访问权限都相同)。我们生活在一个不断变化的因素中,并非所有操作都是相同的,甚至不是遥不可及的。就此而言,必须非常谨慎地考虑它,在任何情况下都不能假设“这是O(N),所以它将更快”。这是一个巨大的谬论。

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

第二,尽管习惯用法并且通常看起来是个好主意(它是O(1),是吗?),但在许多情况下使用哈希映射是不好的。并非在所有情况下都如此,但这是这种情况。比较这两个代码段的功能。

O(N 2 )一次将一个较小的字符串转换为字符数组(基本上成本为零),然后以线性方式重复访问该数组。即使使用Java,这几乎也是计算机能够完成的最快的操作。是的,Java不了解任何事物,例如内存或高速缓存,但这不能改变这些事物存在的事实。以线性方式本地访问少量/中等数量的数据很快

另一个代码段将字符插入哈希表,为每个字符分配一个数据结构。是的,Java中的动态分配并不是 昂贵的,但是,分配远没有空闲空间,并且内存访问变得不连续。
然后,计算哈希函数。这是哈希映射经常被忽略的东西。对于单个字符,这(希望)是一种便宜的操作,但是离free [1] 尚远。然后,将数据结构以某种方式插入到存储桶中(从技术上讲,这只是另一种非一致性内存访问)。现在,很可能发生碰撞,在这种情况下,必须完成其他操作(链接,重新哈希,等等)。
后来,再次从哈希图中读取值,这又涉及到调用哈希​​函数,查找存储桶,可能遍历列表以及在每个节点上进行比较(由于可能发生冲突,因此有必要)。

因此,每个操作至少涉及 两个间接操作,以及一些计算。总而言之,与仅在一个小的数组上迭代几次相比,这是“非常痛苦的”。


[1] 在这里,单字符键不是问题,但仍然是一个有趣的事实:人们经常以O(1)的形式谈论散列映射,这对于例如链接,但令我们惊讶的是,实际上 hashing 密钥相对于密钥的长度为O(N)。这可能很明显。

答案 6 :(得分:0)

我已将函数移植到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);

这也证实了复杂性不仅是唯一的原因。有一个“输入长度”,其他答案已经解决了,最后,我注意到了

  

实施也有所不同。更长的代码隐藏了优化机会。