哈希表

大多数JAVA开发人员都在使用Maps,尤其是HashMaps。HashMap是一种简单而功能强大的存储和获取数据的方式。但是,有多少开发人员知道HashMap在内部如何工作?几天前,我阅读了java.util.HashMap的大部分源代码(从Java 7到Java 8),以便对这种基本数据结构有深入的了解。在本文中,我将解释java.util.HashMap的实现,介绍JAVA 8实现中的新增功能,并讨论使用HashMaps时的性能,内存和已知问题。

内容[显示]

 

内部存储器

JAVA HashMap类实现接口Map <K,V>。该接口的主要方法是:

  • V put(K键,V值)
  • V get(对象键)
  • V remove(对象键)
  • 布尔containsKey(Object key)

HashMaps使用一个内部类来存储数据:Entry <K,V>。此项是一个简单的键值对,其中包含两个额外的数据:

  • 对另一个Entry的引用,以便HashMap可以存储单个链接列表之类的条目
  • 表示键的哈希值的哈希值。存储该哈希值是为了避免每次HashMap需要哈希时都进行哈希计算。

这是Java 7中Entry实现的一部分:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
}

HashMap将数据存储到条目的多个单链接列表(也称为bucketsbins)中。所有列表都注册在Entry数组(Entry <K,V> []数组)中,并且此内部数组的默认容量为16。

 

 

 

 

 下图显示了带有可空条目数组的HashMap实例的内部存储。每个条目可以链接到另一个条目以形成链接列表。

 

具有相同哈希值的所有键都放在相同的链表(存储桶)中。具有不同哈希值的键可以最终出现在同一存储桶中。

当用户调用put(K键,V值)或get(Object键)时,该函数将计算Entry所在的存储区的索引。然后,该函数遍历列表以查找具有相同键的Entry(使用键的equals()函数)。

对于get(),该函数返回与该条目关联的值(如果该条目存在)。

对于put(K键,V值),如果该条目存在,则该函数将其替换为新值,否则它将在单链接列表的开头创建一个新条目(根据键和参数中的值)。

 

桶的索引(链接列表)由地图分3步生成:

  • 它首先获取密钥哈希码
  • 它会重新整理哈希码,以防止将所有数据放入内部数组的同一索引(存储桶)的键中的哈希函数损坏
  • 它获取经过重整的哈希哈希码,并使用数组的长度(减1)对其进行位掩码此操作可确保索引不能大于数组的大小。您可以将其视为在计算上经过优化的模函数。

这是处理索引的JAVA 7和8源代码:

// the "rehash" function in JAVA 7 that takes the hashcode of the key
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// the "rehash" function in JAVA 8 that directly takes the key
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
// the function that returns the index from the rehashed hash
static int indexFor(int h, int length) {
    return h & (length-1);
}

为了有效地工作,内部数组的大小需要为2的幂,让我们看看为什么。

想象一下,数组大小为17,掩码值将为16(大小为-1)。16的二进制表示形式是0…010000,因此对于任何哈希值H,使用按位公式“ H AND 16”生成的索引将为16或0。这意味着大小为17的数组将仅用于2个存储桶:索引0的一个和索引16的一个,效率不高…

但是,如果您现在采用的是2的幂(如16),则按位索引公式为“ H AND 15”。15的二进制表示形式是0…001111,因此索引公式可以输出0到15的值,并且大小为16的数组已完全使用。例如:

  • 如果H = 952,则其二进制表示形式为0..0111011 1000,关联的索引为0…0 1000  = 8
  • 如果H = 1576,其二进制表示形式是0..01100010 1000,则关联的索引是0…0 1000  = 8
  • 如果H = 12356146,则其二进制表示形式为0..010111100100010100011 0010,关联的索引为0…0 0010 = 2
  • 如果H = 59843,则其二进制表示形式为0..0111010011100 0011,关联的索引为0…0 0011  = 3

 

这就是为什么数组大小是2的幂的原因。此机制对开发人员是透明的:如果他选择大小为37的HashMap,则Map将自动为其内部数组的大小选择37(64)之后的下一个2的幂。

 

自动调整大小

获取索引后,函数(获取,放置或删除)访问/迭代关联的链表,以查看给定键是否存在现有的Entry。如果不进行修改,此机制可能会导致性能问题,因为该函数需要遍历整个列表以查看条目是否存在。想象一下,内部数组的大小是默认值(16),您需要存储2百万个值。在最佳情况下,每个链接列表的大小为125 000个条目(2/16百万)。因此,每个get(),remove()和put()都会导致125 000次迭代/运算。为了避免这种情况,HashMap可以增加其内部数组,以保持很短的链表。

创建HashMap时,可以使用以下构造函数指定初始大小和loadFactor:

public HashMap(int initialCapacity, float loadFactor)

如果不指定参数,则默认initialCapacity为16,默认loadFactor为0.75。initialCapacity表示链表内部数组的大小。

每次使用put(…)在Map中添加新的键/值时,该函数都会检查是否需要增加内部数组的容量。为此,地图存储了2个数据:

  • 地图的大小:它表示HashMap中的条目数。每次添加或删除条目时,都会更新此值。
  • 一个阈值:等于(内部数组的容量)* loadFactor,并在每次调整内部数组的大小后刷新

在添加新的Entry之前,put(…)检查size是否大于阈值,以及是否重新创建大小加倍的新数组。由于新数组的大小已更改,因此索引函数(返回按位运算“ hash(key)AND(sizeOfArray-1)”)将发生变化。因此,数组大小的调整会创建更多的存储桶(即,链表),并将 所有现有条目重新分配到存储桶中(旧的和新创建的)。

调整大小操作的目的是减小链接列表的大小,以使put(),remove()和get()方法的时间成本保持较低。调整大小后,其键具有相同哈希值的所有条目将保留在同一存储桶中。但是,转换之前在同一存储桶中的两个具有不同哈希键的条目可能不在转换之后的同一存储桶中。

 

 

 

该图显示了调整内部数组大小之前和之后的表示。在增加之前,为了获得条目E,地图必须迭代5个元素的列表。调整大小后,相同的get()只是遍历2个元素的链接列表,调整大小后,get()快2倍!

 

注意:HashMap仅增加内部数组的大小,而没有提供减小其大小的方法。

 

线程安全

如果您已经知道HashMaps,那么您知道这不是线程安全的,但是为什么呢?例如,假设您有一个Writer线程仅将新数据放入Map中,而一个Reader线程则从Map中读取数据,为什么它不起作用?

因为在自动调整大小机制期间,如果线程尝试放置或获取对象,则映射可能会使用旧的索引值,而不会找到条目所在的新存储桶。

最坏的情况是两个线程同时放置一个数据,而两个put()调用则同时调整Map的大小。由于两个线程同时修改链表,因此Map可能在其链表之一中以一个内循环结束如果您尝试通过内部循环获取列表中的数据,则get()将永远不会结束。

哈希表的实现是线程安全的实现,从这种情况下阻止。但是,由于所有CRUD方法都是同步的,因此此实现非常慢。例如,如果线程1调用get(key1),线程2调用get(key2),线程3调用get(key3),则一次只能获得一个线程的值,而其中的3个线程可以访问数据与此同时。

自Java 5以来,存在一个更安全的线程安全HashMap实现:ConcurrentHashMap只有存储桶是同步的,因此如果多个线程不暗示访问同一存储桶或调整内部数组的大小,则可以同时获取(),remove()或put()数据。最好在多线程应用程序中使用此实现

 

密钥不变性

为什么字符串和整数是HashMap的键的良好实现?主要是因为它们是一成不变的如果选择创建自己的Key类并且不使其不可变,则可能会丢失HashMap中的数据。

查看以下用例:

  • 您有一个内部值为“ 1”的键
  • 使用此键将对象放入HashMap
  • HashMap从密钥的哈希码生成哈希(因此从“ 1”开始)
  • Map  将此哈希存储 在新创建的Entry中
  • 您将密钥的内部值修改为“ 2”
  • 密钥的哈希值已修改,但HashMap不知道(因为存储了旧的哈希值)
  • 您尝试使用修改后的密钥获取对象
  • 映射会计算密钥的新哈希值(因此从“ 2”开始)以找到条目位于哪个链接列表(存储桶)中
    • 情况1:由于您修改了密钥,因此地图会尝试在错误的存储桶中找到该条目,但找不到它
    •  情况2:幸运的是,修改后的密钥会生成与旧密钥相同的存储桶。然后,映射将遍历链接列表,以查找具有相同键的条目。但是要找到键,映射首先比较哈希值,然后调用equals()比较。由于修改后的键的哈希值与旧哈希值(存储在条目中)的哈希值不同,因此映射将在链接列表中找不到该条目。

这是Java中的具体示例。我在地图中放入了2个键值对,修改了第一个键,然后尝试获取2个值。从地图仅返回第二个值,第一个值在HashMap中“丢失”:

public class MutableKeyTest {
 
    public static void main(String[] args) {
 
        class MyKey {
            Integer i;
 
            public void setI(Integer i) {
                this.i = i;
            }
 
            public MyKey(Integer i) {
                this.i = i;
            }
 
            @Override
            public int hashCode() {
                return i;
            }
 
            @Override
            public boolean equals(Object obj) {
                if (obj instanceof MyKey) {
                    return i.equals(((MyKey) obj).i);
                } else
                    return false;
            }
 
        }
 
        Map<MyKey, String> myMap = new HashMap<>();
        MyKey key1 = new MyKey(1);
        MyKey key2 = new MyKey(2);
 
        myMap.put(key1, "test " + 1);
        myMap.put(key2, "test " + 2);
 
        // modifying key1
        key1.setI(3);
 
        String test1 = myMap.get(key1);
        String test2 = myMap.get(key2);
 
        System.out.println("test1= " + test1 + " test2=" + test2);
 
    }
 
}

输出为:“ test1 = null test2 = test 2”。如预期的那样,Map无法使用已修改的键1检索字符串1。

 

JAVA 8改进

HashMap的内部表示在JAVA 8中发生了很大变化。确实,JAVA 7中的实现需要1k行代码,而JAVA 8中的实现需要2k行代码。我之前所说的大多数内容都是正确的,除了条目的链接列表。在JAVA8中,您仍然有一个数组,但是它现在存储的Node包含与Entries完全相同的信息,因此也是链接列表:

这是JAVA 8中Node实现的一部分:

static class Node<K,V> implements Map.Entry<K,V> {
     final int hash;
     final K key;
     V value;
     Node<K,V> next;

那么,JAVA 7的最大区别是什么?好吧,节点可以扩展到TreeNodes。TreeNode是一个红黑树结构,它存储着更多的信息,因此它可以在O(log(n))中添加,删除或获取元素。

仅供参考,这是TreeNode内部存储的数据的详尽列表

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    final int hash; // inherited from Node<K,V>
    final K key; // inherited from Node<K,V>
    V value; // inherited from Node<K,V>
    Node<K,V> next; // inherited from Node<K,V>
    Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;

红黑树是自平衡二进制搜索树。它们的内部机制确保尽管添加或删除了新的节点,它们的长度始终保持在log(n)中。使用这些树的主要优点是在许多数据位于内部表的相同索引(存储桶)中的情况下,在树中进行搜索将花费O(log(n)),在树中进行搜索则会 花费O(n)与链表。

如您所见,该树比链接列表占用了更多的空间(我们将在下一部分中讨论它)。

通过继承,内部表可以同时包含Node(链接列表TreeNode(红黑)。Oracle决定使用以下规则使用这两种数据结构:
–如果内部表中给定索引(存储桶)的节点超过8个,则链表转换为一棵红黑树
–如果给定索引(存储桶) )内部表中的节点少于6个,树被转换为链表

 

 

 

此图显示了JAVA 8 HashMap的内部数组,其中包含树(位于存储桶0)和链接列表(位于存储桶1,2和3)。值区0是一棵树,因为它有8个以上的节点。

 

内存开销

JAVA 7

HashMap的使用在内存方面要付出一定的代价。在JAVA 7中,HashMap将键值对包装在Entries中。条目具有:

  • 对下一个条目的引用
  • 预先计算的哈希(整数)
  • 钥匙的参考
  • 对值的引用

此外,JAVA 7 HashMap使用Entry的内部数组。假设JAVA 7 HashMap包含N个元素,并且其内部数组具有容量CAPACITY,则额外的内存开销约为:

sizeOf(整数)* N + sizeOf(参考)*(3 * N + C)

哪里:

  • 整数的大小取决于4个字节
  • 引用的大小取决于JVM / OS / Processor,但通常为4个字节。

这意味着开销通常为16 * N + 4 * CAPACITY字节

提醒:自动调整地图大小后,内部数组的容量等于N之后的下一个2的幂。

注意:从JAVA 7开始,H​​ashMap类具有一个惰性的init。这意味着,即使您分配了HashMap,条目的内部数组(花费4 * CAPACITY字节)也不会在内存中分配,直到第一次使用put()方法。

JAVA 8

使用JAVA 8实现时,获取内存使用情况变得有些复杂,因为Node可以包含与Entry相同的数据或相同的数据加上6个引用和一个布尔值(如果它是TreeNode)。

如果所有节点都是节点,则JAVA 8 HashMap的内存消耗与JAVA 7 HashMap相同。

如果所有节点都是TreeNodes,则JAVA 8 HashMap的内存消耗为:

N * sizeOf(整数)+ N * sizeOf(布尔值)+ sizeOf(引用)*(9 * N + CAPACITY)

在大多数标准JVM中,它等于44 * N + 4 *容量字节

 

性能问题

倾斜的HashMap与平衡良好的HashMap

在最佳情况下,get()和put()方法的时间复杂度为O(1)。但是,如果您不注意密钥的哈希函数,则可能会以非常慢的put()和get()调用结束。put()和get的良好性能取决于将数据重新分配到内部数组(存储桶)的不同索引中。如果键的哈希函数设计错误,则将产生倾斜的分区(无论内部数组的容量有多大)。所有使用最大链接条目列表的put()和get()都会变慢,因为它们需要迭代整个列表。在最坏的情况下(如果大多数数据都在相同的存储桶中),最终可能会带来O(n)的时间复杂度。
这是一个视觉示例。第一张图片显示了倾斜的HashMap,第二张图片显示了均衡的图像。

 

 

 

 

在这种偏斜的HashMap的情况下,存储区0上的get()/ put()操作成本很高。获取条目K将花费6次迭代

 

 

 

在平衡良好的HashMap的情况下,获取条目K将花费3次迭代。两个HashMap都存储相同数量的数据,并且具有相同的内部数组大小。唯一的区别是(密钥的)哈希函数,用于在存储桶中分配条目。

这是JAVA中的一个极端示例,其中我创建了一个哈希函数,该函数将所有数据放入同一存储桶中,然后添加200万个元素。

public class Test {
 
    public static void main(String[] args) {
 
        class MyKey {
            Integer i;
            public MyKey(Integer i){
                this.i =i;
            }
 
            @Override
            public int hashCode() {
                return 1;
            }
 
            @Override
            public boolean equals(Object obj) {
            
            }
 
        }
        Date begin = new Date();
        Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
        for (int i=0;i<2_000_000;i++){
            myMap.put( new MyKey(i), "test "+i);
        }
 
        Date end = new Date();
        System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
    }
}

在我的核心i5-2500k @ 3.6Ghz上,使用Java 8u40需要超过45分钟(我在45分钟后停止了该过程)。

现在,如果我运行相同的代码,但是这次我使用以下哈希函数

    @Override
    public int hashCode() {
        int key = 2097152-1;
        return key+2097152*i;
}

需要46秒,这更好!此哈希函数的分区比上一个更好,因此put()调用更快。

如果我使用以下哈希函数运行相同的代码,则可以提供更好的哈希重新分区

@Override
public int hashCode() {
return i;
}

现在需要2秒钟

我希望您认识到哈希函数的重要性。如果在JAVA 7上运行相同的测试,则在第一种情况和第二种情况下结果会更糟(因为put的时间复杂度在JAVA 7中为O(n)与在Java 8中为O(log(n)))

使用HashMap时,您需要为您的密钥找到一个哈希函数,以将密钥散布到尽可能多的存储桶中为此,您需要避免哈希冲突字符串对象是一个很好的键,因为它具有良好的哈希功能。整数也很好,因为它们的哈希码是它们自己的值。

 

调整开销

如果您需要存储大量数据,则应创建初始容量接近预期容量的HashMap。

如果您不这样做,则Map将采用默认大小16,factorLoad为0.75。11个第一个put()将非常快,但是第12个(16 * 0.75)将重新创建一个新的内部数组(及其关联的链表/树),其新容量为32。第13个到第23个将很快,但是第24个(32 * 0.75)将重新创建(再次)代价高昂的新表示形式,该表示形式将内部数组的大小加倍。内部调整大小操作将出现在put()的第48、96、192等处。在低音量下,内部阵列的完全恢复速度很快,但在高音量下,可能需要几秒钟到几分钟。通过最初设置您的预期大小,可以避免这些 昂贵的操作

但是有一个缺点:如果您将数组大小设置得很高,例如2 ^ 28,而在数组中仅使用2 ^ 26个存储桶,则会浪费大量内存(在这种情况下,大约2 ^ 30个字节)。

 

结论

对于简单的用例,您不需要知道HashMaps的工作方式,因为您不会看到O(1)和O(n)或O(log(n))操作之间的区别。但是,最好了解最常用的数据结构之一的底层机制。此外,对于Java开发人员来说,这是一个典型的面试问题。

大量使用时,了解其工作原理并了解键的哈希函数的重要性就变得很重要。

我希望本文能帮助您对HashMap实现有一个深入的了解。

from : http://coding-geek.com/how-does-a-hashmap-work-in-java/

哈希表

https://en.wikipedia.org/wiki/Hash_table#Separate_chaining_with_list_heads

哈希表
类型 无序关联数组
发明的 1953年
时间复杂度大O符号
算法   平均 最坏的情况下
空间   O(n[1] O(n
搜索   O(1) O(n
插入   O(1) O(n
删除   O(1) O(n
 
一本小的电话簿作为哈希表

计算中,哈希表散列映射)是一种数据结构,它实现一个关联数组 抽象数据类型,即可以映射的结构的键哈希表使用哈希函数索引(也称为哈希码)计算到存储桶插槽的数组中,从中可以找到所需的值。在查找期间,将对密钥进行哈希处理,并且所得到的哈希值将指示相应值的存储位置。

理想情况下,哈希函数会将每个键分配给唯一的存储桶,但是大多数哈希表设计采用了不完善的哈希函数,这可能会导致哈希冲突,其中哈希函数会为多个键生成相同的索引。通常以某种方式容纳这种碰撞。

在尺寸合理的哈希表中,每次查找的平均成本(指令)与表中存储的元素数无关。许多哈希表设计还允许键值对的任意插入和删除,每次操作的固定平均成本为(摊销[2])。[3] [4]

在许多情况下,散列表实际上比搜索树或任何其他查找结构更有效因此,它们被广泛用于许多计算机软件中,尤其是用于关联数组数据库索引缓存

 

散列[编辑]

哈希的思想是将条目(键/值对)分布在一系列存储桶中给定一个密钥,该算法将计算一个索引索引建议在何处可以找到该条目:

通常,这是通过两个步骤完成的:

在此方法中,哈希与数组大小无关,然后使用取模运算符将其减少为索引(介于0之间的数字)。 array_size − 1%

在数组大小为2的的情况下,余数运算将减少为masking,这将提高速度,但可能会增加哈希函数差的问题。[5]

选择哈希函数[编辑]

基本要求是该函数应提供哈希值均匀分布不均匀的分布会增加冲突的数量以及解决冲突的成本。有时有时很难通过设计来确保均匀性,但是可以使用统计检验(例如针对离散均匀分布Pearson卡方检验)进行经验评估[6] [7]

仅对于应用程序中出现的表大小,分布才需要统一。特别是,如果使用动态调整大小并将表大小精确加倍和减半,则仅当大小为2的时,哈希函数才需要统一在这里,索引可以计算为哈希函数的某些位范围。另一方面,某些哈希算法更喜欢将大小设为素数[8]模数运算可能会提供一些额外的混合;这对于较差的哈希函数特别有用。

对于开放式寻址方案,哈希函数还应避免聚类,即两个或多个键到连续插槽的映射。即使负载系数很低且冲突很少发生,这种聚类也可能导致查找成本飞涨。流行的乘法哈希[3]被认为具有特别差的聚类行为。[8]

人们认为,通过减少或通过位掩码[需要引用]密码散列函数可为任何表大小提供良好的散列函数如果存在恶意用户尝试通过提交旨在在服务器的哈希表中生成大量冲突的请求破坏网络服务的风险,它们也可能是适当的。但是,也可以通过使用更便宜的方法(例如对数据应用秘密或使用通用哈希函数来避免破坏的风险加密哈希函数的缺点是它们通常计算速度较慢,这意味着在不需要任何大小,则最好使用非加密哈希函数。[需要引用]

完善的哈希函数[编辑]

如果提前知道所有键,则可以使用完美哈希函数来创建没有冲突的完美哈希表。如果使用最小完美散列,则还可以使用散列表中的每个位置。

完美的哈希可以在所有情况下进行恒定的时间查找。这与大多数链接和开放式寻址方法相反,后者的查找时间平均较短,但可能非常大,O(n),例如,当所有键都哈希为几个值时。

关键统计数据[编辑]

哈希表的关键统计数据是负载因子,定义为

 

 

哪里

  • n是哈希表中占用的条目数。
  • k是存储桶数。

随着负载因子的增加,哈希表变慢,甚至可能无法工作(取决于所使用的方法)。哈希表的预期恒定时间属性假定负载因子保持在某个界限以下。对于固定数量的存储桶,查找时间随着条目数量的增加而增加,因此无法实现所需的恒定时间。在某些实现中,解决方案是在达到负载因子限制时自动增大(通常为两倍)表的大小,从而强制重新散列所有条目。举一个实际的例子,Java 10中HashMap的默认加载因子是0.75,这“在时间和空间成本之间提供了很好的折衷”。[9]

其次是负载因子,可以检查每个存储桶的条目数方差。例如,两个表都具有1,000个条目和1,000个存储桶;一个在每个存储桶中只有一个条目,另一个在同一存储桶中具有所有条目。显然,在第二个哈希中不起作用。

低负载因数不是特别有益。当负载因子接近0时,哈希表中未使用区域的比例增加,但是搜索成本不一定会降低。这导致浪费的内存。

碰撞分辨率[编辑]

哈希碰撞散列一大组可能的密钥的随机子集时,实际上是不可避免的。例如,如果将2450个密钥散列到一百万个存储桶中,即使具有完全均匀的随机分布,则根据生日问题,至少有两个密钥散列到同一插槽的机率大约为95%。

因此,几乎所有哈希表实现都有某种冲突解决策略来处理此类事件。下面介绍一些常见的策略。所有这些方法都要求将键(或指向它们的指针)及其关联值存储在表中。

单独链接[编辑]

 
哈希冲突通过单独的链接解决。

在称为单独链接的方法中,每个存储桶都是独立的,并且具有某种具有相同索引的条目列表哈希表操作的时间是找到存储桶的时间(常数)加上列表操作的时间。

在大多数实现中,如果哈希函数运行正常,则存储桶将只有很少的条目。因此,对于这些情况在时间和空间上有效的结构是优选的。不需要或不需要对每个存储桶有足够数量的条目有效的结构。如果这些情况经常发生,则哈希函数需要固定。[10]

有一些实现[11]在时间和空间上都具有出色的性能,每个存储桶中的平均元素数在5到100之间。

与链接列表分开链接[编辑]

具有链接列表的链式哈希表很受欢迎,因为它们仅需要具有简单算法的基本数据结构,并且可以使用不适合其他方法的简单哈希函数。[需要引用]

表操作的成本是为所需的密钥扫描所选存储桶的条目的成本。如果键的分布足够均匀,则查找平均成本仅取决于每个存储桶中键的平均数量,也就是说,它与负载因子大致成比例。

因此,即使表条目的数量n比插槽的数量高得多,链式哈希表仍然有效例如,具有1000个插槽和10,000个存储键(负载系数10)的链式哈希表比10,000插槽表(负载系数1)慢五到十倍。但仍比普通顺序列表快1000倍。

对于单独链接,最坏的情况是将所有条目都插入到同一存储桶中,在这种情况下,哈希表无效,代价是搜索存储桶数据结构的开销。如果后者是线性列表,则查找过程可能必须扫描其所有条目,因此最坏情况下的开销与表中条目的数量n成正比

通常使用条目添加到存储桶的顺序来顺序搜索存储桶链。如果负载系数很大,并且某些密钥比其他密钥更有可能出现,那么使用前移启发式方法重新排列链条可能会很有效。仅当负载因子很大(大约10或更大),或者散列分布很可能不均匀,或者即使必须保证良好的性能时,才需要考虑使用更复杂的数据结构(例如平衡的搜索树)。在最坏的情况下。但是,在那些情况下,使用更大的表和/或更好的哈希函数可能更有效。[需要引用]

链式哈希表还继承了链表的缺点。当存储小键和值时,next每个条目记录中指针的空间开销可能很大。另一个缺点是遍历链接列表的缓存性能较差,从而使处理器缓存无效。

与列表头单元格分开链接[编辑]

 
通过与存储桶数组中的头记录单独链接来进行哈希冲突。

一些链接实现将每个链的第一条记录存储在插槽阵列本身中。[4] 在大多数情况下,指针遍历的数量减少一。目的是提高哈希表访问的缓存效率。

缺点是空桶与一个入口的桶占用相同的空间。为了节省空间,此类哈希表通常具有与存储的条目一样多的插槽,这意味着许多插槽具有两个或多个条目。[需要引用]

与其他结构分开链接[编辑]

除了列表以外,还可以使用支持所需操作的任何其他数据结构。例如,通过使用自平衡二进制搜索树,可以将常见哈希表操作(插入,删除,查找)的最坏情况理论时间降低到O(log n而不是O(n)。但是,这给实现带来了额外的复杂性,并且对于较小的哈希表可能会导致更差的性能,在哈希表中,插入和平衡树所花费的时间大于对列表的所有元素执行线性搜索所需的时间。[3] [12]Java版本8中HashMap是在表中使用自平衡二进制搜索树的哈希表的真实示例[13]

称为数组哈希表的变体使用动态数组来存储所有哈希到同一插槽的条目。[14] [15] [16]每个新插入的条目都会附加到分配给插槽的动态数组的末尾。动态数组以精确匹配的方式调整大小,这意味着它仅按需要增加字节数。人们发现了诸如以块大小或页数增加阵列的替代技术可以改善插入性能,但会占用空间。此变体可以更有效地利用CPU缓存转换后备缓冲区(TLB),因为插槽条目存储在顺序的内存位置中。它还省去next了链接列表所需指针,从而节省了空间。尽管经常调整数组大小,但发现由操作系统引起的空间开销(例如内存碎片)很小。[需要引用]

对这种方法的详细描述是所谓的动态完美哈希[17]其中包含k个条目的存储桶被组织为具有2个时隙的完美哈希表虽然它使用更多的内存(在最坏的情况下,n条目有n个2个插槽,在最坏的情况下,n × k个插槽),但此变体保证了恒定的最坏情况下的查找时间和较低的插入摊销时间。还可以为每个存储桶使用融合树,以高概率实现所有操作的恒定时间。

 

打开地址[编辑]

 
哈希冲突通过使用线性探测(间隔= 1)的开放式寻址解决。请注意,“ Ted Baker”具有唯一的哈希值,但是与先前与“ John Smith”发生冲突的“ Sandra Dee”发生了冲突。

在另一种称为开放式寻址的策略中,所有条目记录都存储在存储桶数组本身中。当必须插入新条目时,将检查存储桶,从哈希到的插槽开始,并按某些探测顺序进行操作,直到找到未占用的插槽。搜索条目时,将按相同顺序扫描存储桶,直到找到目标记录或找到未使用的阵列插槽,这表明表中没有此类键。[19]名称“开放地址”是指以下事实:项目的位置(“地址”)不是由其哈希值确定的。(此方法也称为 封闭式哈希;请勿将其与“开放式哈希”或“封闭式寻址”混淆

众所周知的探针序列包括:

  • 线性探测,其中探头之间的间隔是固定的(通常为1)。由于良好的CPU缓存利用率和高性能,该算法在哈希表实现中的现代计算机体系结构中得到了最广泛的使用。[20]
  • 二次探测,其中,通过将二次多项式的连续输出与原始哈希计算给出的起始值相加来增加探测之间的间隔
  • 双重哈希,其中探针之间的间隔由第二个哈希函数计算

所有这些开放式寻址方案的缺点在于,存储条目的数量不能超过存储桶阵列中的插槽数量。实际上,即使具有良好的哈希函数,当负载因子超过0.7左右时,它们的性能也会大大降低。对于许多应用程序,这些限制要求使用动态调整大小及其伴随的成本。[需要引用]

开放式寻址方案还对哈希函数提出了更严格的要求:除了将密钥更均匀地分布在存储桶上之外,该函数还必须最小化按探测顺序连续的哈希值的聚类。使用单独的链接,唯一需要考虑的是太多的对象映射到相同的哈希值。它们是相邻还是附近完全无关紧要。[需要引用]

仅当条目较小(小于指针大小的四倍)并且负载因数不太小时,开放寻址才会节省内存。如果负载系数接近于零(也就是说,存储桶的数量远远大于存储的条目),则即使每个条目只有两个字,开放式寻址也是浪费的

 

 
该图比较了通过链接和线性探测在大型哈希表(远远超过缓存的大小)中查找元素所需的平均CPU缓存未命中数。线性探测由于具有更好的参考位置而表现更好,尽管随着表的填充,其性能会急剧下降。

开放式寻址避免了分配每个新条目记录的时间开销,并且即使在没有内存分配器的情况下也可以实现。它还避免了访问每个存储桶的第一个条目(通常是唯一一个)所需的额外间接访问。它还具有更好的参考位置,尤其是在线性探测中。在较小的记录大小下,这些因素会比链接产生更好的性能,尤其是对于查找。具有开放地址的哈希表也更易于序列化,因为它们不使用指针。[需要引用]

另一方面,对于大型元素,常规开放式寻址是一个糟糕的选择,因为这些元素会填满整个CPU缓存行(抵消了缓存的优势),并且在大的空表插槽上浪费了大量空间。如果开放式寻址表仅存储对元素的引用(外部存储),则即使使用大型记录,它也会使用与链接相当的空间,但会失去其速度优势。[需要引用]

一般而言,开放式寻址更适合具有较小记录的哈希表,这些记录可以存储在表中(内部存储)并适合高速缓存行。它们特别适合一个单词或更少的元素如果期望该表具有较高的负载系数,记录较大或数据大小可变,则链式哈希表通常会表现良好或更好。[需要引用]

合并哈希[编辑]

链接和开放寻址的混合形式,合并的哈希将表本身内的节点链链接在一起。[19] 像开放式寻址一样,与链接相比,它可以实现空间使用和缓存优势(有所减少)。像链接一样,它不表现出聚类效果。实际上,桌子可以被有效地填充到高密度。与链接不同,它的元素不能超过表槽。

杜鹃哈希[编辑]

另一种替代的开放式寻址解决方案是布谷鸟哈希,这可以确保在最坏情况下的查找和删除时间不变,以及插入时的摊销时间不变(遇到最坏情况的可能性很小)。它使用两个或多个哈希函数,这意味着任何键/值对都可以位于两个或多个位置。对于查找,使用第一个哈希函数。如果找不到键/值,则使用第二个哈希函数,依此类推。如果在插入期间发生冲突,则使用第二个哈希函数将键重新哈希,以将其映射到另一个存储桶。如果使用了所有哈希函数,并且仍然存在冲突,那么与之碰撞的密钥将被删除,以为新密钥腾出空间,而旧密钥将与其他哈希函数之一一起重新哈希,从而将其映射到另一个哈希函数。桶。如果该位置也导致碰撞,然后重复该过程,直到没有冲突或遍历所有存储桶为止,此时将调整表的大小。通过将多个哈希函数与每个存储桶中的多个单元格相结合,可以实现很高的空间利用率。[需要引用]

跳房子哈希[编辑]

另一替代开放寻址方案是跳房子散列[21] 其结合了接近的杜鹃散列线性探测,但似乎在一般以避免其局限性。特别是即使负载系数超过0.9时,它也能很好地工作。该算法非常适合于实现可调整大小的并发哈希表

跳房子哈希算法的工作原理是在始终找到给定条目的原始哈希桶附近定义桶的邻域。因此,搜索仅限于该邻域中的条目数,在最坏的情况下该数是对数的,平均而言是恒定的,并且邻域的正确对齐通常需要一个高速缓存未命中。插入条目时,首先尝试将其添加到附近的存储桶中。但是,如果该邻域中的所有存储桶都被占用,则该算法将依次遍历存储桶,直到找到一个空插槽(未占用的存储桶)为止(如线性探测一样)。此时,由于空存储桶位于附近,因此物品会按一系列的啤酒花反复地被移位。(这类似于杜鹃哈希,但不同的是,在这种情况下,空插槽被移至邻域,而不是移出项目,以期最终找到一个空插槽。)每一跳使开放的插槽更接近原始邻域,而不会无效沿途所有水桶的邻域属性。最后,已将开放的插槽移到附近,可以将要插入的条目添加到其中。[需要引用]

罗宾汉散列[编辑]

两次哈希冲突解决方案的一种变体是Robin Hood哈希。[22] [23]这个想法是,如果一个新钥匙的探测次数大于当前位置的钥匙探测次数,则它可能会取代已插入的钥匙。这样做的最终效果是减少了表中最坏情况的搜索时间。这类似于有序哈希表[24],不同的是,更改键的标准不取决于键之间的直接关系。由于最坏的情况和探针数量的变化都大大减少了,所以一个有趣的变化是从预期的成功探针值开始探测表,然后从该位置向两个方向扩展。[25] 外部Robin Hood哈希是此算法的扩展,其中表存储在外部文件中,并且每个表位置对应于具有B记录的固定大小的页面或存储桶[26]

2选择散列[编辑]

2选择哈希对哈希表采用两个不同的哈希函数1x)和2x)。这两个哈希函数都用于计算两个表位置。将对象插入表中后,会将其放置在包含较少对象的表位置(如果存储桶大小相等,则默认为1x)表位置)。2选择散列采用两种选择的幂的原理。[27]

动态调整大小[编辑]

当进行插入时,哈希表中的条目数超过了负载因子和当前容量的乘积,则需要重新哈希表[9]哈希处理包括增加基础数据结构的大小[9],并将现有项目映射到新的存储桶位置。在一些实施方式中,如果初始容量大于条目的最大数量除以负载因子,则将不会发生任何哈希操作。[9]

为了限制由于空存储桶而浪费的内存比例,某些实现还可以在删除项目时缩小表的大小(随后进行重新哈希处理)。从时空权衡的角度来看,此操作类似于动态数组中的释放。

通过复制所有条目来调整大小[编辑]

一种常见的方法是在负载系数超过某个阈值max时自动触发完全调整大小然后分配一个新的更大的表,每个条目从旧表中删除,并插入到新表中。从旧表中删除所有条目后,旧表将返回到空闲存储池。同样,当负载系数降至第二阈值min以下时,所有条目都将移至新的较小表中。

对于频繁收缩和增长的哈希表,可以完全跳过向下调整的大小。在这种情况下,表的大小与一次在哈希表中的最大条目数成正比,而不是与当前数目成正比。缺点是内存使用率会更高,因此缓存行为可能会更糟。为了获得最佳控制,可以提供“缩小以适应”操作,该操作仅应要求执行。

如果表大小在每次扩展时以固定百分比增加或减少,则这些调整大小的总成本(在所有插入和删除操作中摊销)仍然是常数,与条目数n执行的操作m无关

例如,考虑以最小可能的大小创建的表,并且每次负载比超过某个阈值时都会将其加倍。如果将m个元素插入到该表中,则该表的所有动态调整大小中发生的额外重新插入的总数最多为m  -1。换句话说,动态调整大小会使每个插入或删除操作的成本大约增加一倍。

一次性重新哈希的替代方法[编辑]

某些哈希表实现(尤其是在实时系统中)无法一次全部完成扩展哈希表的代价,因为它可能会中断时间紧迫的操作。如果无法避免动态调整大小,则一种解决方案是逐步执行调整大小。

基于磁盘的哈希表几乎总是使用某种方式来代替一次性重哈希,因为在磁盘上重建整个表的成本太高。

增量调整大小[编辑]

一次放大表的一种替代方法是逐渐执行重新哈希处理:

  • 在调整大小期间,分配新的哈希表,但保持旧表不变。
  • 在每个查找或删除操作中,检查两个表。
  • 仅在新表中执行插入操作。
  • 在每次插入时,还将r元素从旧表移动到新表。
  • 从旧表中删除所有元素后,请对其进行分配。

为了确保在需要扩展新表之前已完全复制了旧表,在调整大小期间,必须将表的大小至少增加(r +1)/ r

单调键[编辑]

如果知道密钥将以单调递增(或递减)的顺序存储,则可以实现一致性哈希的变体

给出了一些初始密钥ķ 1,随后密钥ķ 分割密钥域ķ 1,∞)到集{[ ķ 1ķ),[ ķ,∞) }。通常,重复此过程会得到更精细的分区{[ 10),[ 0,k 1),...,[ n-1,k n),[ n,∞) },用于单调递增的键0,...,n)的某些序列,其中n细化次数必要的变通相同的过程也适用于单调减小密钥。通过为该分区的每个子间隔分配不同的哈希函数或哈希表(或两者),并在每次调整哈希表的大小时优化分区,此方法可确保即使发布了任何键,哈希也不会改变。哈希表已增长。

由于通常通过加倍来增加条目的总数,因此只需要检查O(log(N))个子间隔,用于重定向的二进制搜索时间将是O(log(log(N))))。

 

线性哈希[编辑]

线性哈希[28]是允许增量哈希表扩展的哈希表算法。它使用单个哈希表实现,但具有两个可能的查找功能。

散列哈希表的散列[编辑]

减少表调整大小成本的另一种方法是选择散列函数,以使在调整表大小时大多数值的散列不改变。此类哈希函数在基于磁盘的分布式哈希表中很普遍,在这种情况下,重新哈希的费用过高。设计哈希以使在调整表大小时大多数值不会改变的问题被称为分布式哈希表问题。四种最受欢迎​​的方法是集合点哈希一致哈希内容可寻址网络算法和Kademlia距离。

表演

 

速度分析
在最简单的模型中,哈希函数是完全未指定的,并且该表不会调整大小。具有理想的散列函数,大小表{\ displaystyle k}ķ 开放式寻址没有冲突,可以保持 {\ displaystyle k}ķ 元素具有一次比较以成功查找,而一个表的大小 {\ displaystyle k}ķ 与链接和 {\ displaystyle n}ñ 键具有最小值 {\ displaystyle max(0,nk)}{\ displaystyle max(0,nk)} 碰撞和 {\ displaystyle \ Theta(1 + {\ frac {n} {k}})}{\ displaystyle \ Theta(1 + {\ frac {n} {k}})}比较查找。使用最差的哈希函数,每次插入都会导致冲突,并且哈希表会退化为线性搜索,{\ displaystyle \ Theta(n)}\ Theta(n) 每次插入的摊销比较,最多 {\ displaystyle n}ñ 比较以成功查找。

向该模型添加重新哈希处理非常简单。与动态数组一样,将几何尺寸调整为{\ displaystyle b}b 暗示只有 {\ displaystyle {\ frac {n} {b ^ {i}}}}{\ displaystyle {\ frac {n} {b ^ {i}}}} 插入钥匙 {\ displaystyle i}一世 或多次,以使插入总数受上述限制 {\ displaystyle {\ frac {bn} {b-1}}}{\ displaystyle {\ frac {bn} {b-1}}},这是 {\ displaystyle \ Theta(n)}\ Theta(n)。通过使用重新哈希来维护{\ displaystyle n <k}n <k,同时使用链接和开放式寻址的表可以具有无限的元素,并且可以在一次比较中成功执行查找,以最佳选择哈希函数。

在更现实的模型中,哈希函数是哈希函数概率分布上的随机变量,并且性能是根据哈希函数的选择平均计算得出的。当这种分布是均匀的时,该假设称为“简单均匀散列”,可以证明带链散列需要{\ displaystyle \ Theta(1 + {\ frac {n} {k}})}{\ displaystyle \ Theta(1 + {\ frac {n} {k}})} 平均比较以查找不成功,并且使用开放式寻址进行哈希处理 {\ displaystyle \ Theta \ left({\ frac {1} {1-n / k}} \ right)}{\ displaystyle \ Theta \ left({\ frac {1} {1-n / k}} \ right)}。[29]如果我们维持'{\ displaystyle {\ frac {n} {k}} <c}{\ displaystyle {\ frac {n} {k}} <c} 使用表大小调整,其中 {\ displaystyle c}C 是小于1的固定常数。

有两个因素会显着影响哈希表上操作的延迟:[30]

缓存丢失。随着负载因子的增加,由于平均缓存丢失的增加,哈希表的搜索和插入性能可能会大大降低。
调整大小的成本。当哈希表变得庞大时,调整大小成为一项极其耗时的任务。
在对延迟敏感的程序中,平均情况和最坏情况下的操作时间消耗必须小,稳定,甚至可预测。K哈希表[31]设计用于低延迟应用程序的一般情况,旨在在不断增长的大型表上实现成本稳定的操作。

内存利用率
有时,表的内存需求需要最小化。减少链接方法中的内存使用量的一种方法是消除某些链接指针或将其替换为某种形式的缩写指针。速度分析
在最简单的模型中,哈希函数是完全未指定的,并且该表不会调整大小。具有理想的散列函数,大小表{\ displaystyle k}ķ 开放式寻址没有冲突,可以保持 {\ displaystyle k}ķ 元素具有一次比较以成功查找,而一个表的大小 {\ displaystyle k}ķ 与链接和 {\ displaystyle n}ñ 键具有最小值 {\ displaystyle max(0,nk)}{\ displaystyle max(0,nk)} 碰撞和 {\ displaystyle \ Theta(1 + {\ frac {n} {k}})}{\ displaystyle \ Theta(1 + {\ frac {n} {k}})}比较查找。使用最差的哈希函数,每次插入都会导致冲突,并且哈希表会退化为线性搜索,{\ displaystyle \ Theta(n)}\ Theta(n) 每次插入的摊销比较,最多 {\ displaystyle n}ñ 比较以成功查找。

向该模型添加重新哈希处理非常简单。与动态数组一样,将几何尺寸调整为{\ displaystyle b}b 暗示只有 {\ displaystyle {\ frac {n} {b ^ {i}}}}{\ displaystyle {\ frac {n} {b ^ {i}}}} 插入钥匙 {\ displaystyle i}一世 或多次,以使插入总数受上述限制 {\ displaystyle {\ frac {bn} {b-1}}}{\ displaystyle {\ frac {bn} {b-1}}},这是 {\ displaystyle \ Theta(n)}\ Theta(n)。通过使用重新哈希来维护{\ displaystyle n <k}n <k,同时使用链接和开放式寻址的表可以具有无限的元素,并且可以在一次比较中成功执行查找,以最佳选择哈希函数。

在更现实的模型中,哈希函数是哈希函数概率分布上的随机变量,并且性能是根据哈希函数的选择平均计算得出的。当这种分布是均匀的时,该假设称为“简单均匀散列”,可以证明带链散列需要{\ displaystyle \ Theta(1 + {\ frac {n} {k}})}{\ displaystyle \ Theta(1 + {\ frac {n} {k}})} 平均比较以查找不成功,并且使用开放式寻址进行哈希处理 {\ displaystyle \ Theta \ left({\ frac {1} {1-n / k}} \ right)}{\ displaystyle \ Theta \ left({\ frac {1} {1-n / k}} \ right)}。[29]如果我们维持'{\ displaystyle {\ frac {n} {k}} <c}{\ displaystyle {\ frac {n} {k}} <c} 使用表大小调整,其中 {\ displaystyle c}C 是小于1的固定常数。

有两个因素会显着影响哈希表上操作的延迟:[30]

缓存丢失。随着负载因子的增加,由于平均缓存丢失的增加,哈希表的搜索和插入性能可能会大大降低。
调整大小的成本。当哈希表变得庞大时,调整大小成为一项极其耗时的任务。
在对延迟敏感的程序中,平均情况和最坏情况下的操作时间消耗必须小,稳定,甚至可预测。K哈希表[31]设计用于低延迟应用程序的一般情况,旨在在不断增长的大型表上实现成本稳定的操作。

内存利用率
有时,表的内存需求需要最小化。减少链接方法中的内存使用量的一种方法是消除某些链接指针或将其替换为某种形式的缩写指针。

通过高德纳引入的另一种技术[引证需要],被称为quotienting。对于本讨论假设键,或者该键的可逆散列版本,是整数米从{0,1,2,...,M-1}和桶的数目是Ñ。 m被N除以产生商q和余数r。余数r用于选择存储桶;在存储桶中,只需要存储商q。这样可以为每个元素节省2(N)个日志位,这在某些应用中可能非常重要。

商链链哈希表或简单的布谷鸟哈希表都可以很容易地实现。为了将该技术与普通的开放地址哈希表一起使用,John G. Cleary引入了一种方法[32],其中每个存储桶中都包含两位(原始位和更改位),以使原始存储区索引(r)重建。

在上述方案中,log 2(M / N)+ 2位用于存储每个密钥。有趣的是,理论上的最小存储量为log 2(M / N)+ 1.4427位,其中1.4427 = log 2(e)。

功能
优势
哈希表相对于其他表数据结构的主要优点是速度。当条目数量很大时,此优势更加明显。当可以预先预测最大条目数时,哈希表特别有效,因此可以以最佳大小分配一次存储桶数组,而从不调整大小。
如果一组键值对是固定的,并且提前知道(因此不允许插入和删除),则可以通过谨慎选择哈希函数,存储区表大小和内部数据结构来降低平均查找成本。特别地,人们可能能够设计出一种无冲突甚至完美的哈希函数。在这种情况下,密钥不必存储在表中。
缺点
尽管对哈希表的操作平均需要花费恒定的时间,但良好的哈希函数的成本可能明显高于顺序列表或搜索树的查找算法的内部循环。因此,当条目数非常小时,哈希表无效。(但是,在某些情况下,可以通过将哈希值与密钥一起保存来减少计算哈希函数的高成本。)
对于某些字符串处理应用程序(例如拼写检查),哈希表的效率可能不如trys,有限自动机或Judy数组。另外,如果没有太多可能要存储的键(也就是说,如果每个键可以用足够小的位数表示),则可以使用该键直接作为数组的索引,而不使用哈希表价值。请注意,在这种情况下不会发生冲突。
可以高效地枚举存储在哈希表中的条目(以每个条目不变的成本),但是只能以某种伪随机顺序进行枚举。因此,没有有效的方法来找到其键距给定键最近的条目。以特定顺序列出所有n个条目通常需要一个单独的排序步骤,其成本与每个条目的log(n)成正比。相比之下,有序搜索树的查找和插入成本与log(n)成正比,但允许以大约相同的成本找到最接近的关键字,并以每项恒定的成本对所有项进行有序的枚举。但是,可以使LinkingHashMap创建具有非随机序列的哈希表。[33]
如果未存储键(因为哈希函数是无冲突的),则可能没有简便的方法来枚举任何给定时刻表中存在的键。
尽管每次操作的平均成本是恒定的并且很小,但是单个操作的成本可能会很高。特别是,如果哈希表使用动态调整大小,则插入或删除操作可能会偶尔花费与条目数量成比例的时间。在实时或交互式应用程序中,这可能是一个严重的缺点。
通常,哈希表的引用局部性很差-也就是说,要访问的数据似乎在内存中随机分布。因为哈希表会导致访问模式跳来跳去,所以这会触发微处理器高速缓存未命中,从而导致长时间的延迟。如果表相对较小并且键很紧凑,则紧凑的数据结构(例如使用线性搜索搜索的数组)可能会更快。最佳性能点因系统而异。
当发生许多冲突时,哈希表的效率变得非常低下。尽管极不可能偶然出现非常不均匀的哈希分布,但具有哈希功能知识的恶意攻击者可能能够将信息提供给哈希,从而通过引起过多的冲突而产生最坏情况的行为,从而导致性能很差,例如,一个拒绝服务攻击。[34] [35] [36]在关键应用中,可以使用具有更好的最坏情况保证的数据结构。但是,通用哈希(一种防止攻击者预测哪些输入会导致最坏情况的行为的随机算法)可能更可取。[37]Linux路由表高速缓存中的哈希表使用的哈希函数已在Linux 2.4.2版中进行了更改,作为针对此类攻击的对策。[38]

通过高德纳引入的另一种技术[引证需要],被称为quotienting。对于本讨论假设键,或者该键的可逆散列版本,是整数米从{0,1,2,...,M-1}和桶的数目是Ñ。 m被N除以产生商q和余数r。余数r用于选择存储桶;在存储桶中,只需要存储商q。这样可以为每个元素节省2(N)个日志位,这在某些应用中可能非常重要。

商链链哈希表或简单的布谷鸟哈希表都可以很容易地实现。为了将该技术与普通的开放地址哈希表一起使用,John G. Cleary引入了一种方法[32],其中每个存储桶中都包含两位(原始位和更改位),以使原始存储区索引(r)重建。

在上述方案中,log 2(M / N)+ 2位用于存储每个密钥。有趣的是,理论上的最小存储量为log 2(M / N)+ 1.4427位,其中1.4427 = log 2(e)。

功能
优势
哈希表相对于其他表数据结构的主要优点是速度。当条目数量很大时,此优势更加明显。当可以预先预测最大条目数时,哈希表特别有效,因此可以以最佳大小分配一次存储桶数组,而从不调整大小。
如果一组键值对是固定的,并且提前知道(因此不允许插入和删除),则可以通过谨慎选择哈希函数,存储区表大小和内部数据结构来降低平均查找成本。特别地,人们可能能够设计出一种无冲突甚至完美的哈希函数。在这种情况下,密钥不必存储在表中。
缺点
尽管对哈希表的操作平均需要花费恒定的时间,但良好的哈希函数的成本可能明显高于顺序列表或搜索树的查找算法的内部循环。因此,当条目数非常小时,哈希表无效。(但是,在某些情况下,可以通过将哈希值与密钥一起保存来减少计算哈希函数的高成本。)
对于某些字符串处理应用程序(例如拼写检查),哈希表的效率可能不如trys,有限自动机或Judy数组。另外,如果没有太多可能要存储的键(也就是说,如果每个键可以用足够小的位数表示),则可以使用该键直接作为数组的索引,而不使用哈希表价值。请注意,在这种情况下不会发生冲突。
可以高效地枚举存储在哈希表中的条目(以每个条目不变的成本),但是只能以某种伪随机顺序进行枚举。因此,没有有效的方法来找到其键距给定键最近的条目。以特定顺序列出所有n个条目通常需要一个单独的排序步骤,其成本与每个条目的log(n)成正比。相比之下,有序搜索树的查找和插入成本与log(n)成正比,但允许以大约相同的成本找到最接近的关键字,并以每项恒定的成本对所有项进行有序的枚举。但是,可以使LinkingHashMap创建具有非随机序列的哈希表。[33]
如果未存储键(因为哈希函数是无冲突的),则可能没有简便的方法来枚举任何给定时刻表中存在的键。
尽管每次操作的平均成本是恒定的并且很小,但是单个操作的成本可能会很高。特别是,如果哈希表使用动态调整大小,则插入或删除操作可能会偶尔花费与条目数量成比例的时间。在实时或交互式应用程序中,这可能是一个严重的缺点。
通常,哈希表的引用局部性很差-也就是说,要访问的数据似乎在内存中随机分布。因为哈希表会导致访问模式跳来跳去,所以这会触发微处理器高速缓存未命中,从而导致长时间的延迟。如果表相对较小并且键很紧凑,则紧凑的数据结构(例如使用线性搜索搜索的数组)可能会更快。最佳性能点因系统而异。
当发生许多冲突时,哈希表的效率变得非常低下。尽管极不可能偶然出现非常不均匀的哈希分布,但具有哈希功能知识的恶意攻击者可能能够将信息提供给哈希,从而通过引起过多的冲突而产生最坏情况的行为,从而导致性能很差,例如,一个拒绝服务攻击。[34] [35] [36]在关键应用中,可以使用具有更好的最坏情况保证的数据结构。但是,通用哈希(一种防止攻击者预测哪些输入会导致最坏情况的行为的随机算法)可能更可取。[37]Linux路由表高速缓存中的哈希表使用的哈希函数已在Linux 2.4.2版中进行了更改,作为针对此类攻击的对策。[38]

HashTable和HashMap键值如何存储在内存中?

基本思想HashMap是:

  1. AHashMap实际上是包含键和值的特殊对象的数组。
  2. 该阵列具有一定数量的存储桶(插槽),例如16。
  3. 哈希算法由hashCode()每个对象具有方法提供。因此,当你正在编写一个新的Class,你应该照顾的正确hashCode()equals()方法实现。Object该类的)默认值将内存指针作为一个数字。但这对于我们要使用的大多数类都不是一件好事。例如,String该类使用一种算法,该算法根据字符串中的所有字符进行散列-像这样想:(hashCode = 1.char + 2.char + 3.char...简化)。因此,即使它们在内存中的不同位置,两个相等的字符串也具有相同的hashCode()
  4. 如果结果为hashCode()“ 132”,那么如果我们有那么大的数组,那么该结果就是存储对象的存储桶数。但是我们没有,我们只有16个桶长。因此,我们使用显而易见的计算'hashcode % array.length = bucket''132 mod 16 = 4'将键值对存储在存储区编号4中。
    • 如果没有其他一对,那还可以。
    • 如果有一个键等于我们拥有的键,我们将删除旧的键。
    • 如果还有另一个键-值对(冲突),我们将新的“键值对”链接在旧的“键值对”链接列表中。
  5. 如果后备数组变得太满,那么我们将不得不创建太多的链表,我们将创建一个长度加倍的新数组,重新哈希所有元素并将其添加到新数组中,然后处置旧数组。这很可能是上最昂贵的操作HashMap,因此Maps如果您以前知道它,则想告诉您将使用多少个存储桶。
  6. 如果有人尝试获取值,他会提供一个密钥,我们对其进行哈希处理,对其进行修改,然后遍历潜在的链表以进行精确匹配。

图片由维基百科提供: 图形

在这种情况下,

  • 有一个带有256个存储桶的数组(编号为0-255)
  • 有五个人。将它们的哈希码放入后mod 256,指向数组中的四个不同插槽。
  • 您会看到Sandra Dee没有空闲时间,因此她已被追随John Smith。

现在,如果您尝试查找Sandra Dee的电话号码,则可以对她的名字进行哈希处理,将其修改为256,然后查看存储桶152。在那里,您会找到John Smith。那不是桑德拉,再往前看……啊哈,桑德拉被约翰束缚住了。

 
https://en.wikipedia.org/wiki/Hash_list

 

 

 
 
posted @ 2021-01-05 16:15  CharyGao  阅读(123)  评论(0)    收藏  举报