哈希表如何处理冲突?

127
我在我的学位课程中听说,如果新的键条目与其他条目发生冲突,HashTable会将新条目放入“下一个可用”的桶中。
如果在使用冲突键调用时发生冲突,HashTable如何返回正确的值?
我假设KeysString类型,并且hashCode()返回由Java生成的默认值。
如果我实现自己的哈希函数并将其用作查找表的一部分(例如HashMapDictionary),存在哪些处理冲突的策略?
我甚至看到了与质数相关的注释!这些信息在谷歌搜索中并不清楚。
10个回答

108

哈希表处理冲突的方式有两种。

选项1:每个桶都包含一个链接列表,其中存储了散列到该桶的元素。这就是为什么糟糕的哈希函数会使哈希表查找变得非常慢。

选项2:如果哈希表条目已全部满,则哈希表可以增加它拥有的桶数,然后重新分配表中所有元素。哈希函数返回一个整数,哈希表必须将哈希函数的结果与表的大小进行模运算,以确保它能够到达正确的桶。因此,通过增加大小,将重新计算哈希并执行模运算,如果幸运的话,可能会将对象发送到不同的桶中。

Java在其哈希表实现中同时使用选项1和2。


1
在第一种选择的情况下,为什么要使用链表而不是数组甚至是二叉搜索树? - user142019
1
以上的解释是高层次的,我认为它与链表和数组没有太大区别。我认为二叉搜索树会过度复杂化。此外,如果您深入研究诸如ConcurrentHashMap等其他内容,就会发现有许多低级实现细节可以影响性能,而上面的高级解释并未考虑到这些细节。 - ams
5
如果使用了链式结构,在给定一个键值后,我们如何知道要获取哪个项? - ChaoSXDemon
2
@ChaoSXDemon,您可以通过键遍历链中的列表,重复键不是问题,问题在于两个不同的键具有相同的哈希码。 - ams
1
@ams:哪一个更受欢迎?在JAVA中,哈希冲突有没有限制,超过这个限制后第二点会被执行? - Shashank Vivek

93
当您谈到“哈希表会将新条目放置在'下一个可用'存储桶中,如果新的键条目与另一个冲突时”,您正在讨论哈希表的冲突解决方案中的开放 addressing策略。
哈希表有几种解决冲突的策略。
第一种大方法要求键(或指向它们的指针)与相关值一起存储在表中,其中还包括:
  • 分离链接

enter image description here

  • 开放 addressing

enter image description here

  • 聚合哈希
  • 布谷鸟哈希
  • 罗宾汉哈希
  • 2-选择哈希
  • 跳跃式哈希

处理冲突的另一种重要方法是通过动态调整大小,其进一步有几种方式:
  • 通过复制所有条目进行调整大小
  • 增量调整大小
  • 单调键

编辑:以上内容摘自wiki_hash_table,您可以去那里查看以获取更多信息。

3
“[...]需要将键(或指向它们的指针)与相关联的值一起存储在表中。”谢谢,这是阅读有关存储值机制时不总是立即清晰的要点。 - mtone

34

处理冲突的方法有多种,我将解释其中一些。

链表法: 在链表法中,我们使用数组索引来存储值。如果第二个值的哈希码也指向相同的索引,则我们用一个链接列表替换该索引值,并将指向该索引的所有值存储在链接列表中,实际的数组索引指向链接列表的头部。 但是,如果只有一个哈希码指向数组的一个索引,那么该值就直接存储在该索引中。检索值时应用相同的逻辑。这被用于Java HashMap/Hashtable以避免碰撞。

线性探测法: 当我们有比要存储的值更多的索引时,就会使用此技术。线性探测技术的原理是不断增加,直到找到一个空槽位为止。伪代码如下:

index = h(k) 

while( val(index) is occupied) 

index = (index+1) mod n

双重哈希技术:在这种技术中,我们使用两个哈希函数h1(k)和h2(k)。如果h1(k)的位置被占用,则使用第二个哈希函数h2(k)来增加索引。伪代码如下:

index = h1(k)

while( val(index) is occupied)

index = (index + h2(k)) mod n

线性探测和双重哈希技术是开放地址技术的一部分,只有在可用插槽的数量大于要添加的项数时才能使用。它比链接更节省内存,因为这里没有使用额外的结构,但由于需要进行许多移动直到找到空插槽,所以它很慢。此外,在开放地址技术中,当从插槽中删除一个项目时,我们会放置一个墓碑来指示该项目已经从此处删除,这就是为什么它是空的。

欲了解更多信息,请参见此网站


18

我强烈建议您阅读最近在HackerNews上出现的博客文章: Java中HashMap的工作原理

简而言之,答案是:

如果两个不同的HashMap键对象具有相同的哈希码,会发生什么情况?

它们将存储在同一个桶中,但没有链表的下一个节点。HashMap将使用keys equals()方法来识别正确的键值对。


3
HashMap非常有趣且内容深入!:) - Alex
1
我认为问题涉及哈希表(HashTables),而非哈希映射(HashMap)。 - Prashant Shubham

11
我在学位课程中听说,如果新的键条目与其他条目冲突,HashTable会将新条目放入“下一个可用”的桶中。实际上这并不正确,至少对于Oracle JDK是如此(它是API不同实现之间可能会有所不同的实现细节)。相反,在Java 8或更高版本中,每个桶都包含先前的条目链表和平衡树。
如果在使用冲突键调用时发生冲突,HashTable将使用equals()方法来查找实际匹配的条目以返回正确的值。
如果我实现自己的哈希函数并将其用作查找表的一部分(即HashMap或Dictionary),有哪些处理冲突的策略存在?有各种不同优缺点的冲突处理策略。Wikipedia关于哈希表的条目提供了很好的概述。

@Nikita:我不确定Hashtable,而且现在我没有访问源代码的权限,但是我可以百分之百确定,在我调试器中看到的每个版本的HashMap都使用链式存储而不是线性探测。 - Michael Borgwardt
@Michael,我现在正在查看HashMap的源代码public V get(Object key)(与上面相同的版本)。如果您找到确切的版本,其中包含这些链接列表,我会很感兴趣知道。 - Nikita Rybak
@Michael 对不起,是我的错误。我以错误的方式解释了代码。自然而然地,e = e.next 不是 ++index。+1 - Nikita Rybak
啊,当我写我的答案时,你已经解决了这个讨论 :-( - Paŭlo Ebermann
你实际上回答了所有问题,而不是接受一个。 - Eklavyaa
显示剩余2条评论

7
自Java 8以来的更新: Java 8使用自平衡树处理冲突,将最坏情况从O(n)改进为O(log n)用于查找。自平衡树的使用是在Java 8中引入的,作为对链接(直到java 7使用)的改进,链接使用链接列表,并且具有O(n)的最坏情况用于查找(因为它需要遍历列表)。
回答您问题的第二部分,插入是通过将给定元素映射到哈希映射的基础数组中的给定索引来完成的,但是,当发生冲突时,所有元素仍必须被保留(存储在辅助数据结构中,而不仅仅是在基础数组中替换)。这通常是通过使每个数组组件(插槽)成为一个辅助数据结构(称为桶),并将元素添加到驻留在给定数组索引上的桶中来完成的(如果键不存在于桶中,则替换它)。
在查找期间,将键哈希到相应的数组索引,并在给定桶中搜索与(精确)键匹配的元素。因为桶不需要处理冲突(直接比较键),所以解决了冲突问题,但代价是必须在次要数据结构上执行插入和查找。关键点在于,在哈希映射中,既存储键又存储值,因此即使哈希冲突,键也可以在桶中直接进行比较以确定唯一标识。
冲突处理将插入和查找的最坏情况性能从没有冲突处理的O(1)带到了链式的O(n)(使用链接列表作为次要数据结构)和自平衡树的O(log n)。
参考资料:
Java 8为HashMap对象在高冲突情况下带来了以下改进/变化。
  • Java 7中添加的备用String哈希函数已被删除。

  • 包含大量冲突键的桶在达到一定阈值后将在平衡树中而不是链表中存储它们的条目。

上述更改确保在最坏情况下的O(log(n))性能。(https://www.nagarro.com/en/blog/post/24/performance-improvement-for-hashmap-in-java-8)

你能解释一下如何在链表HashMap中进行最坏情况插入,其时间复杂度为O(1),而不是O(N)吗?如果非重复键的冲突率达到100%,那么似乎您最终要遍历HashMap中的每个对象才能找到链表的末尾,对吗?我错过了什么? - mbm29414
在哈希映射实现的特定情况下,你说得确实没错,但并不是因为你需要找到列表的末尾。在一般情况下,链表实现中,指针存储了头和尾,因此可以通过直接将下一个节点附加到尾部来以O(1)的时间复杂度进行插入,但在哈希映射的情况下,插入方法需要确保没有重复项,因此必须搜索列表以检查元素是否已经存在,因此我们最终得到了O(n)的时间复杂度。因此,是链表上强制执行的集合属性导致了O(N)的时间复杂度。我会对我的答案进行更正 :) - Daniel Valland

4

由于Java的HashMap在Sun/Oracle/OpenJDK实现中使用了哪个算法存在一些混淆,以下是相关源代码片段(来自Ubuntu上的OpenJDK 1.6.0_20):

/**
 * Returns the entry associated with the specified key in the
 * HashMap.  Returns null if the HashMap contains no mapping
 * for the key.
 */
final Entry<K,V> getEntry(Object key) {
    int hash = (key == null) ? 0 : hash(key.hashCode());
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

这个方法(引用来自355到371行)在查找表中的条目时被调用,例如从get()containsKey()和其他一些方法。这里的for循环遍历由条目对象形成的链表。
这是条目对象的代码(第691-705行+759行):
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;

    /**
     * Creates new entry.
     */
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

  // (methods left away, they are straight-forward implementations of Map.Entry)

}

紧接着这个方法是addEntry()方法:
/**
 * Adds a new entry with the specified key, value and hash code to
 * the specified bucket.  It is the responsibility of this
 * method to resize the table if appropriate.
 *
 * Subclass overrides this to alter the behavior of put method.
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

这将在桶的前面添加新条目,并附带指向旧的第一个条目的链接(如果没有这样的条目,则为null)。类似地,removeEntryForKey()方法遍历列表,并仅删除一个条目,让列表的其余部分保持不变。
因此,每个桶都有一个链接条目列表,我非常怀疑从_20到_22是否有所改变,因为从1.2开始就是这样。
(此代码为1997-2007 Sun Microsystems版权所有,并在GPL下提供,但最好使用原始文件进行复制,该文件包含在Sun / Oracle的每个JDK中的src.zip中,也包括在OpenJDK中。)

1
我将这个标记为社区维基,因为它并不是一个真正的答案,更多的是对其他答案的讨论。在评论中根本没有足够的空间来引用这样的代码。 - Paŭlo Ebermann

4

它将使用equals方法来检查键是否存在,特别是在同一个桶中有多个元素的情况下。


4

这是一个非常简单的Java哈希表实现。它只实现了put()get()方法,但您可以轻松地添加您喜欢的任何其他方法。它依赖于Java中所有对象都实现的hashCode()方法。您可以轻松创建自己的接口,

interface Hashable {
  int getHash();
}

如果您愿意,可以通过密钥来强制执行它。

public class Hashtable<K, V> {
    private static class Entry<K,V> {
        private final K key;
        private final V val;

        Entry(K key, V val) {
            this.key = key;
            this.val = val;
        }
    }

    private static int BUCKET_COUNT = 13;

    @SuppressWarnings("unchecked")
    private List<Entry>[] buckets = new List[BUCKET_COUNT];

    public Hashtable() {
        for (int i = 0, l = buckets.length; i < l; i++) {
            buckets[i] = new ArrayList<Entry<K,V>>();
        }
    }

    public V get(K key) {
        int b = key.hashCode() % BUCKET_COUNT;
        List<Entry> entries = buckets[b];
        for (Entry e: entries) {
            if (e.key.equals(key)) {
                return e.val;
            }
        }
        return null;
    }

    public void put(K key, V val) {
        int b = key.hashCode() % BUCKET_COUNT;
        List<Entry> entries = buckets[b];
        entries.add(new Entry<K,V>(key, val));
    }
}

2

有各种方法可以解决碰撞问题。其中一些方法是分离链接、开放地址、罗宾汉哈希、布谷鸟哈希等。

Java使用分离链接来解决哈希表中的冲突。下面是一个很好的链接,介绍了它是如何实现的:http://javapapers.com/core-java/java-hashtable/


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接