【问题标题】:Why can hashCode() return the same value for different objects in Java?为什么 hashCode() 可以为 Java 中的不同对象返回相同的值?
【发布时间】:2011-05-20 14:09:46
【问题描述】:

引用我正在阅读的书Head First Java

关键是哈希码可以相同,但不一定保证对象相等,因为hashCode() 方法中使用的“哈希算法”可能碰巧为多个对象返回相同的值。

为什么hashCode() 方法会为不同的对象返回相同的值?这不会造成问题吗?

【问题讨论】:

  • 因为例如 HashSet 的要点在于每个元素都有唯一的哈希码。如果一对对象可以具有相同的哈希码,这听起来毫无用处。
  • 不,哈希值的重点是将每个对象映射到一个整数。然后您可以将它存储在该值下的数组中(实际上,您首先应用一个 int->int 哈希函数将其映射到数组的范围)。如果 hashCode() 和散列函数都很快,那么当您想从数组中再次检索对象时,您可以快速访问该对象——但除非您事先知道所有对象,否则总是会发生两个对象映射到相同的情况价值。这就是所谓的碰撞,因为碰撞,你不能单独依赖哈希函数,还要使用“equals”方法进行比较。
  • 很高兴我能帮上忙。为了记录,我在下面输入了更详细的解释。
  • 在 hashCode() 中返回一个常量是合法的。不是很有用,但合法。

标签: java data-structures hash hashcode


【解决方案1】:

散列一个对象意味着“找到一个好的、描述性的值(数字),可以被同一个实例一次又一次地复制”。因为来自 Java 的 Object.hashCode() 的哈希码属于 int 类型,所以您只能有 2^32 不同的值。这就是为什么当两个不同的对象产生相同的 hashCode 时,根据散列算法会产生所谓的“冲突”。

通常,这不会产生任何问题,因为hashCode() 大多与equals() 一起使用。例如,HashMap 将在其键上调用hashCode(),以了解键是否可能已包含在 HashMap 中。如果 HashMap 没有找到哈希码,则很明显该键尚未包含在 HashMap 中。但如果是这样,它必须使用equals() 仔细检查所有具有相同哈希码的键。

A.hashCode() == B.hashCode() // does not necessarily mean
A.equals(B)

但是

A.equals(B) // means
A.hashCode() == B.hashCode()

如果equals()hashCode() 实现正确。

有关一般hashCode 合约的更准确描述,请参阅Javadoc

【讨论】:

    【解决方案2】:

    只有超过 40 亿个可能的哈希码(int 的范围),但您可以选择创建的对象数量要多得多。因此,某些对象必须共享相同的哈希码,pigeonhole principle

    例如,包含从 A 到 Z 的 10 个字母的可能字符串的数量是 26**10,即 141167095653376。不可能为所有这些字符串分配唯一的哈希码。这也不重要——哈希码不需要是唯一的。对于真实数据,它只需要没有太多的冲突。

    【讨论】:

      【解决方案3】:

      哈希表的想法是您希望能够以有效的方式实现称为字典的数据结构。字典是一种键/值存储,即您希望能够将某些对象存储在某个键下,然后能够使用相同的键再次检索它们。

      访问值的最有效方法之一是将它们存储在数组中。例如,我们可以实现一个字典,它使用整数作为键,使用字符串作为值,如下所示:

      String[] dictionary = new String[DICT_SIZE];
      dictionary[15] = "Hello";
      dictionary[121] = "world";
      
      System.out.println(dictionary[15]); // prints "Hello"
      

      不幸的是,这种方法根本不是很通用:数组的索引必须是整数值,但理想情况下,我们希望能够为我们的键使用任意种类的对象,而不仅仅是整数。

      现在,解决这个问题的方法是将任意对象映射到整数值,然后我们可以将其用作数组的键。在 Java 中,这就是 hashCode() 所做的。所以现在,我们可以尝试实现一个 String->String 字典:

      String[] dictionary = new String[DICT_SIZE];
      // "a" -> "Hello"
      dictionary["a".hashCode()] = "Hello";
      
      // "b" -> "world"
      dictionary["b".hashCode()] = "world";
      
      System.out.println(dictionary["b".hashCode()]); // prints world
      

      但是,嘿,如果我们想将某个对象用作键,但其hashCode 方法返回的值大于或等于DICT_SIZE,该怎么办?然后我们会得到一个 ArrayIndexOutOfBoundsException ,这是不可取的。所以,让我们把它做得尽可能大,对吧?

      public static final int DICT_SIZE = Integer.MAX_VALUE // Ooops!
      

      但这意味着我们必须为我们的数组分配大量内存,即使我们只打算存储几个项目。所以这不是最好的解决方案,事实上我们可以做得更好。假设我们有一个函数h,它对于任何给定的DICT_SIZE 将任意整数映射到[0, DICT_SIZE[ 范围内。然后,我们可以将h 应用于键对象的hashCode() 方法返回的任何内容,并确保我们停留在底层数组的边界内。

      public static int h(int value, int DICT_SIZE) {
          // returns an integer >= 0 and < DICT_SIZE for every value.
      }
      

      该函数称为散列函数。现在我们可以调整我们的字典实现来避免 ArrayIndexOutOfBoundsException:

      // "a" -> "Hello"
      dictionary[h("a".hashCode(), DICT_SIZE)] = "Hello"
      
      // "b" -> "world"
      dictionary[h("b".hashCode(), DICT_SIZE)] = "world"
      

      但这引入了另一个问题:如果h 将两个不同的键索引映射到同一个值怎么办?例如:

      int keyA = h("a".hashCode(), DICT_SIZE);
      int keyB = h("b".hashCode(), DICT_SIZE);
      

      keyAkeyB 可能会产生相同的值,在这种情况下,我们会不小心覆盖数组中的值:

      // "a" -> "Hello"
      dictionary[keyA] = "Hello";
      
      // "b" -> "world"
      dictionary[keyB] = "world"; // DAMN! This overwrites "Hello"!!
      
      System.out.println(dictionary[keyA]); // prints "world"
      

      好吧,你可能会说,那么我们只需要确保我们以永远不会发生这种情况的方式实现h。不幸的是,这通常是不可能的。考虑以下代码:

      for (int i = 0; i <= DICT_SIZE; i++) {
          dictionary[h(i, DICT_SIZE)] = "dummy";
      }
      

      这个循环在字典中存储DICT_SIZE + 1 值(实际上总是相同的值,即字符串“dummy”)。嗯,但是数组只能存储DICT_SIZE 不同的条目!这意味着,当我们使用h 时,我们将覆盖(至少)一个条目。或者换句话说,h 会将两个不同的键映射到相同的值!这些“碰撞”是无法避免的:如果 n 只鸽子试图进入 n-1 个鸽子洞,其中至少有两个必须进入同一个洞。

      但是我们可以做的是扩展我们的实现,以便数组可以在同一个索引下存储多个值。这可以通过使用列表轻松完成。所以不要使用:

      String[] dictionary = new String[DICT_SIZE];
      

      我们写:

      List<String>[] dictionary = new List<String>[DICT_SIZE];
      

      (旁注:请注意,Java 不允许创建泛型类型的数组,因此上述行无法编译——但您明白了)。

      这将改变对字典的访问,如下所示:

      // "a" -> "Hello"
      dictionary[h("a".hashCode(), DICT_SIZE)].add("Hello");
      
      // "b" -> "world"
      dictionary[h("b".hashCode(), DICT_SIZE)].add("world");
      

      如果我们的哈希函数 h 为我们所有的键返回不同的值,这将导致每个列表只有一个元素,并且检索元素非常简单:

      System.out.println(dictionary[h("a".hashCode(), DICT_SIZE)].get(0)); // "Hello"
      

      但我们已经知道,通常h 有时会将不同的键映射到同一个整数。在这些情况下,列表将包含多个值。对于检索,我们必须遍历整个列表才能找到“正确”的值,但是我们如何识别它呢?

      好吧,我们总是可以将完整的 (key,value) 对存储在列表中,而不是单独存储值。然后查找将分两步执行:

      1. 应用哈希函数从数组中检索正确的列表。
      2. 遍历存储在检索列表中的所有对:如果找到具有所需键的对,则返回该对中的值。

      现在添加和检索已经变得如此复杂,以致于为这些操作单独对待我们自己的方法并不少见:

      List<Pair<String,String>>[] dictionary = List<Pair<String,String>>[DICT_SIZE];
      
      public void put(String key, String value) {
          int hashCode = key.hashCode();
          int arrayIndex = h(hashCode, DICT_SIZE);
      
          List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
          if (listAtIndex == null) {
              listAtIndex = new LinkedList<Pair<Integer,String>>();
              dictionary[arrayIndex] = listAtIndex;
          }
      
          for (Pair<String,String> previouslyAdded : listAtIndex) {
              if (previouslyAdded.getKey().equals(key)) {
                  // the key is already used in the dictionary,
                  // so let's simply overwrite the associated value
                  previouslyAdded.setValue(value);
                  return;
              }
          }
      
          listAtIndex.add(new Pair<String,String>(key, value));
      }
      
      public String get(String key) {
          int hashCode = key.hashCode();
          int arrayIndex = h(hashCode, DICT_SIZE);
      
          List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
          if (listAtIndex != null) {
              for (Pair<String,String> previouslyAdded : listAtIndex) {
                  if (previouslyAdded.getKey().equals(key)) {
                      return previouslyAdded.getValue(); // entry found!
                  }
              }
          }
      
          // entry not found
          return null;
      }
      

      因此,为了使这种方法起作用,我们实际上需要两个比较操作:在数组中查找列表的 hashCode 方法(如果 hashCode()h 都很快,这会很快)和 @987654354 @ 遍历列表时需要的方法。

      这是散列的一般概念,你会从java.util.Map. 中认出putget 方法当然,上面的实现过于简单化了,但它应该能说明一切的要点。

      当然,这种方法不仅限于字符串,它适用于所有类型的对象,因为方法hashCode()equals 是顶级类 java.lang.Object 的成员,所有其他类都继承自那个。

      如您所见,如果两个不同的对象在其hashCode() 方法中返回相同的值并不重要:上述方法将始终有效!但仍然希望它们返回不同的值以降低h 产生的哈希冲突的机会。我们已经看到,这些通常不能 100% 避免,但是我们得到的冲突越少,我们的哈希表就越高效。在最坏的情况下,所有键都映射到同一个数组索引:在这种情况下,所有对都存储在一个列表中,然后找到一个值将成为一个成本与哈希表大小成线性关系的操作。

      【讨论】:

      • @Lukas Eder:您的回答不仅更加简洁(而且仍然正确且易于理解),而且您的信用也比我的 tl;dr 回答多 ;-)
      • 难以置信! :) 标记为已接受!再次非常感谢您。
      • @AndroidNoob:谢谢你的勾选,但我想现在有点不公平:Lukas Eder,你之前接受过他的回答,写了一个绝对正确的答案,他只是有点比我快。我认为他应该拿回他的功劳……
      • 这显然是你的帖子! :)
      • 随着时间的推移,您可能会赶上其他答案的学分。我也给了你一票;)
      【解决方案4】:

      hashCode() 值可用于通过将哈希码用作存储对象的哈希表存储桶的地址来快速查找对象。

      如果多个对象从 hashCode() 返回相同的值,则意味着它们将存储在同一个存储桶中。如果许多对象存储在同一个存储桶中,则意味着平均而言,查找给定对象需要更多的比较操作。

      改为使用 equals() 比较两个对象,看看它们在语义上是否相等。

      【讨论】:

        【解决方案5】:

        据我了解,hashcode 方法的工作是创建用于散列元素的存储桶,以便检索更快。如果每个对象都返回相同的值,那么进行任何散列是没有用的。

        【讨论】:

          【解决方案6】:

          我不得不认为这是一个非常低效的哈希算法,因为 2 个对象具有相同的哈希码。

          【讨论】:

          • 如果使用可以容忍重复哈希码的数据结构,即使效率低下,通常会导致一组 10,000 个项目中的 100 个项目的哈希码与具有与集合中的一个其他项目匹配的哈希码,而不是一个很少导致甚至一个重复的哈希码。实现前一个指标的快速算法往往比实现第二个指标的慢速算法更有效。
          • 您的回答如何使我的回答无效?它仍然是低效的,只是更实用。
          • 如果使用一种算法,散列集中的平均项目与其他 0.1 个项目共享一个桶,但稍微更昂贵的算法可以消除所有冲突,后一种算法只会更有效如果它的额外成本不到额外比较成本的十分之一。如果散列算法花费大量时间,则完全没有冲突可能表明更快的算法可能更有效。
          • 这两个语句中有这么多 ifs...是的,您是对的,但您将竭尽全力指出一个非常简单的点,即保证没有冲突所花费的时间可能 i> 不值得。你可以这么说,而不是发明假设的算法,这些算法需要足够的时间让它变得不值得。好悲伤。
          • 我将您的原始答案解释为一个笼统的陈述,即高效的哈希算法必须将每个不同的对象映射到不同的哈希码;任何不这样做的算法都是低效的。这种说法是错误的。高效的哈希码预计会有偶尔的哈希冲突;在许多情况下,消除所有碰撞是不可能的,即使不是不可能,除了最简单的类型之外,它很少值得。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2016-04-10
          • 2014-02-13
          • 1970-01-01
          • 2022-11-29
          • 1970-01-01
          相关资源
          最近更新 更多