在HashMap中使用String键的坏主意?


69

我知道String类的hashCode()方法不能保证为不同的String-s生成唯一的哈希码。我看到了很多将String键放入HashMap-s的用法(使用默认的String hashCode()方法)。如果put地图使用真正不同的String键替换了先前放置在地图上的HashMap条目,那么很多这种用法可能会导致重大的应用程序问题。

在String.hashCode()对于不同的String-s返回相同值的情况下,您遇到的几率是多少?当键是字符串时,开发人员如何解决此问题?



@Zed,很好的链接。我没有意识到发生碰撞的可能性如此之高。
马库斯·莱昂,

26
您似乎已经错过了HashMaps(或任何语言的任何哈希表)的工作方式。并不是hashCode是数组的唯一索引。它用于计算将对象放置在哪个存储桶中,通常通过获取hashCode()返回的值并对存储桶数进行模量来确定。即使根本没有冲突,映射也将必须调用equals()方法来确定它是否确实是您要搜索的键,或者仅仅是某个其他键具有相同的哈希码。哈希码仅用于查找要搜索的正确存储桶
Fredrik

@Fredrik,是的,我现在知道了。
马库斯·莱昂,

@Zed:请注意,该帖子中的冲突列表适用于所有小写字母。更改大小写将导致一组不同的冲突。
erickson

Answers:


117

开发人员不必为了解决程序的正确性而在HashMap中解决哈希冲突的问题。

这里有一些关键的事情要理解:

  1. 冲突是哈希的固有特征,必须如此。可能值的数量(在您的情况下为字符串,但也适用于其他类型)的数量远远大于整数的范围。

  2. 哈希的每种用法都有一种处理冲突的方法,Java集合(包括HashMap)也不例外。

  3. 哈希不参与相等性测试。确实,相等的对象必须具有相同的哈希码,但事实并非如此:许多值将具有相同的哈希码。因此,请勿尝试使用哈希码比较来替代相等性。收藏没有。他们使用哈希选择一个子集合(在Java Collections世界中称为存储桶),但是他们使用.equals()来实际检查是否相等。

  4. 您不仅不必担心会在集合中导致错误结果的冲突,而且对于大多数应用程序,您*通常*也不必担心性能-Java哈希集合在管理哈希码方面做得很好。

  5. 更好的是,对于您询问的情况(以字符串作为键),您甚至不必担心哈希码本身,因为Java的String类生成了一个很好的哈希码。大多数提供的Java类也是如此。

如果需要,可以提供更多详细信息:

哈希的工作方式(尤其是在像Java的HashMap这样的哈希集合的情况下,这就是您所要求的):

  • HashMap将您提供给它的值存储在子集(称为存储桶)中。这些实际上是作为链接列表实现的。其中的数量有限:iirc,默认情况下为16,并且随着您在地图上放置更多项目而增加。存储桶总应该比值多。举一个例子,使用默认值,如果您向HashMap添加100个条目,将有256个存储桶。

  • 可以在映射中用作键的每个值都必须能够生成一个称为哈希码的整数值。

  • HashMap使用此哈希码选择存储桶。最终,这意味着将整数值modulo作为存储桶的数量,但是在此之前,Java的HashMap具有内部方法(称为hash()),该方法调整哈希码以减少某些已知的聚集源。

  • 查找值时,HashMap选择存储区,然后使用线性搜索链表,以搜索单个元素.equals()

所以:您不必为正确而解决冲突,通常也不必担心它们的性能,如果您使用的是本机Java类(例如String),则不必担心要么生成哈希码值。

如果您必须编写自己的哈希码方法(这意味着您已经编写了一个具有复合值的类,例如名字/姓氏对),则事情会变得稍微复杂一些。在这里很可能会出错,但这不是火箭科学。首先,要知道这一点:为了确保正确性,您必须要做的就是确保相等的对象产生相等的哈希码。因此,如果您为类编写一个hashcode()方法,则还必须编写一个equals()方法,并且必须检查每个方法中的相同值。

可以编写一个不好但正确的hashcode()方法,这意味着它可以满足“相等的对象必须产生相等的哈希码”约束,但是由于发生很多冲突,其性能仍然很差。

规范的退化最坏情况将是编写一种在所有情况下仅返回恒定值(例如3)的方法。这意味着每个值都将散列到同一存储桶中。

它仍然可以工作,但是性能会下降到链表的性能。

显然,您不会编写如此糟糕的hashcode()方法。如果您使用的是一个不错的IDE,它可以为您生成一个。由于StackOverflow喜欢代码,因此以下是上述firstname / lastname类的代码。


public class SimpleName {
    private String firstName;
    private String lastName;
    public SimpleName(String firstName, String lastName) {
        super();
        this.firstName = firstName;
        this.lastName = lastName;
    }
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result
                + ((firstName == null) ? 0 : firstName.hashCode());
        result = prime * result
                + ((lastName == null) ? 0 : lastName.hashCode());
        return result;
    }
    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        SimpleName other = (SimpleName) obj;
        if (firstName == null) {
            if (other.firstName != null)
                return false;
        } else if (!firstName.equals(other.firstName))
            return false;
        if (lastName == null) {
            if (other.lastName != null)
                return false;
        } else if (!lastName.equals(other.lastName))
            return false;
        return true;
    }
}


您的示例是否假设映射中的每个对象正确实现了equals()方法,该方法使HashMap可以区分这些对象?
马库斯·莱昂,

注意-这可能被解释为“编写自己的String.hashCode()版本”,这可能不是一个好主意。我知道这不是您的意思,但我建议您删除部分答案以避免混淆。
finnw

@Marcus-正确吗?是的,必须有一个“正确”的equals方法-从Object继承的(这是一种身份比较)方法,或者自己构建一个。存在不正确的.equals方法是一件坏事。
CPerkins,2009年

1
@finnw-谢谢,但是请记住,我称它为简陋的情况。实际上,您无法编写String.hashCode的版本,因为String是最终版本。尽管如此,我还是不喜欢提出不好的建议。您有任何澄清建议吗?
CPerkins,2009年

1
值得注意的是,如果知道一个要检查是否相等的两个对象的哈希码,则检查哈希码是否相等并且仅在对象本身匹配时才进行相等性测试通常是有用的。Java哈希集合这样做是为了避免对具有不同哈希值但最终位于同一存储桶中的项进行相等性测试的时间。
supercat 2014年

4

我强烈怀疑该HashMap.put方法不能仅通过查看来确定密钥是否相同String.hashCode

绝对有可能发生哈希冲突,因此,如果确实存在两个s从返回的值相同的情况,则可以期望该String.equals方法也将被调用以确保Strings真正相等StringhashCode

因此,String仅当且仅当by所返回的值相等且该方法返回时,才将新键判断为与String已经存在于该键中的键相同。HashMaphashCodeequalstrue

另外要补充的是,这种思想对于之外的其他类也适用String,因为Object该类本身已经具有hashCodeequals方法。

编辑

因此,要回答这个问题,不,使用aString作为a的键不是一个坏主意HashMap


当然也使用了equals,但是只要您在同一个存储桶中有多个对象,您的O(1)就更像O(n)...
Zed在2009年

@Zed:这就是为什么对于依赖于散列来确定它们物理存储值的集合的大小合适的大小很重要的原因。我相信Java中的Hash *实现的存储负载因子为0.75。
coobird

1
@Marcus:它的Javadoc指定平等使用:java.sun.com/javase/6/docs/api/java/util/...
Zed的

4

这不是问题,而只是哈希表的工作方式。对于所有不同的字符串,都不可能有不同的哈希码,因为与整数相比,不同的字符串要多得多。

正如其他人所写的那样,哈希冲突是通过equals()方法解决的。这可能导致的唯一问题是哈希表的退化,从而导致性能下降。这就是Java的HashMap具有负载因子(存储桶与插入的元素之间的比率)的原因,如果超出该负载因子,则将导致存储桶数量翻倍的表重新哈希。

这通常效果很好,但前提是散列函数良好,即,对于特定输入集,其产生的碰撞次数不会超过统计上的预期数目。String.hashCode()在这方面是很好的,但这并非总是如此。据称,在Java 1.2之前,它仅包含第n个字符。这样做速度更快,但是会导致共享第n个字符的所有String发生可预见的冲突-如果您运气不佳,无法进行此类常规输入,或者如果有人想对您的应用程序进行DOS攻击,那就非常糟糕。


4

我把你引到这里的答案。虽然使用字符串不是一个主意(@CPerkins完美解释了为什么),但将值存储在带有整数键的哈希图中更好,因为它通常更快(尽管并不明显)并且机会较低(实际上没有机会)的碰撞。

看到这个图表使用216553个键在各种情况下的碰撞,(从这个被盗,重新格式化为我们的讨论)

Hash           Lowercase      Random UUID  Numbers 
=============  =============  ===========  ==============
Murmur            145 ns      259 ns          92 ns
                    6 collis    5 collis       0 collis
FNV-1a            152 ns      504 ns          86 ns
                    4 collis    4 collis       0 collis
FNV-1             184 ns      730 ns          92 ns
                    1 collis    5 collis       0 collis*
DBJ2a             158 ns      443 ns          91 ns
                    5 collis    6 collis       0 collis***
DJB2              156 ns      437 ns          93 ns
                    7 collis    6 collis       0 collis***
SDBM              148 ns      484 ns          90 ns
                    4 collis    6 collis       0 collis**
CRC32             250 ns      946 ns         130 ns
                    2 collis    0 collis       0 collis

Avg Time per key    0.8ps       2.5ps         0.44ps
Collisions (%)      0.002%      0.002%         0%

当然,整数的数量限制为2 ^ 32,因为对字符串的数量没有限制(并且对可以存储在中的键的数量也没有理论上的限制HashMap)。如果使用long(或什至float),则冲突是不可避免的,因此没有比字符串更好的了。但是,即使发生哈希冲突,put()get()将始终放置/获取正确的键值对(请参见下面的编辑)。

最后,它实际上并不重要,因此请使用更方便的方法。但是,如果方便没有影响,并且您不打算拥有超过2 ^ 32个条目,那么建议您将其ints用作键。


编辑

尽管以上绝对正确,但String出于性能原因,切勿使用“ StringKey” .hashCode()来代替原始密钥来生成密钥-2个不同的字符串可能具有相同的hashCode,从而导致put()方法被覆盖。Java的实现HashMap足够聪明,可以自动使用相同的哈希码处理字符串(实际上是任何类型的键),因此让Java为您处理这些事情是明智的。


请注意,“冲突”不可避免性与类型的键空间大小无关。实际上,“ hasCode”再次被处理成非常小的整数。这是因为哈希图在内部是“链接列表”的“数组列表”,并且哈希码被处理为用作数组索引。请参见本文的nTableMask部分,以获取另一个(PHP)实现的一个很好的示例:nikic.github.io/2012/03/28/…整数的性能好处是由于“ hashCode”和“ equals”函数的成本差异。
user1122069 '16

2

您正在谈论哈希冲突。哈希冲突是一个问题,而与hashCode的类型无关。所有使用hashCode的类(例如HashMap)都可以很好地处理哈希冲突。例如,HashMap可以在每个存储桶中存储多个对象。

除非您自己调用hashCode,否则不必担心。哈希冲突虽然很少见,但不会破坏任何东西。


但是put方法的Javadocs说“如果映射先前包含此键的映射,则替换旧值。” 这似乎与HashMap“可以在每个存储桶中存储多个对象”的建议相矛盾。没有?
马库斯·利昂

2
@Marcus,不,哈希冲突并不总是意味着键冲突。仅当字符串完全等效时,才会替换该值。如果字符串具有相同的哈希码,则它将仍然有效。存储桶只是一个链表,可以存储具有相同散列的许多不同的字符串。
finnw
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.