数据结构之哈希表
初步理解
哈希表:
哈希表是一种数据结构,它基于数组和链表实现;拥有快速插入查询和删除的性能(时间复杂度O(1),接近于常量的时间),解决了数组增删元素慢和链表查询数据慢的问题。
但是哈希表基于数组所以扩展性较差,而且遍历以及排序性能并不怎么样,所以说有得有失。
【此处修正一下:数组的元素操作,如果是基于元素下标的话,会比较快一点,尤其是查找的时候,但是如果删除元素且不留空的话还是需要移动数组后面的元素补空。
如果以元素内容查找元素位置操作的话,则需要进行一步一步的比较,这增加许多步骤。但是哈希表是基于下标的,数组删除元素还需要移动数组元素在哈希表里通过链表完美解决了。这要求链表不能太长,也就是说正常状态下装填因子不能太高。】
哈希表适合用在例如字典存储的场景下,哈希表使用key-value来存取数据,根据key(一般是字符串),通过哈希算法,计算出数组下标,插入其中。取数据的时候也根据这个key的哈希值来找到数组的下标来轻松存取。完善好用的哈希表中,每个数组元素都是一个链表。因为哈希值的计算可能有重复的(key不能有重复,但是key计算的hash可能有重复),在一个数组元素里可能会有多个元素,这些元素通过链表来存储,这样删除某个数据的时候,只需要找到其数组下标在链表中删除。
哈希的推演
现在有个数组,它是编程意义上的,用数组来做数据结构限制很大,首先就是数组内元素固定顺序的并且没有任何数据的说明,在大规模存取的时候,单纯的数组几乎没有什么用。就算业务简单可以使用数组,也会因为数组的增删问题(删除导致大量空间闲置,内存浪费;增加因为数组定长受限,尽管可以copy到一个新数组中实现扩容,但是并不理想)。所以说,单纯的数组很难作为有效的数据结构。
哈希表是基于数组的,key-value存储系统,数组单元是单个的元素,为何能存储key-value呢。这里通过hash算法,将key值想办法映射到数组下标上,这样就可以通过字符串轻松找到数组的下标,非常方便的访问。
简单的key可能是一个序列,00000-99999号的学生,数字就可以作为数组下标,并且天然自然分布,没有任何浪费。但是多数情况下,比如英文单词,就需要特定的算法来映射到数组下标:
略过推导,核心是把字符转化为数字,数字的区间要足够大,以能够容纳所有的数据,并且还要大很多,需要呈现随机的样子。然后取余数,获得相对较小的区间,算法有很多种,这里不一一列举。这样就得到了一个字符到地址的映射。但是问题在于这种算法并不一定能够保证每个字符都映射到不同的下标中,可能有许多数组下标是无法映射到的,或者有些下标被映射了多次,这就存在一个存放的问题了,重复的下标值对应的数据放到哪里?
开放地址法:
创建大于数据量的数组,或者限制数据量小于数组大小。意思是当出现重复的数组下标时,对应的值就放到数组其他空位中去。这要求数组必须要有空位;
那么如何寻找空位呢?有线性查找/二次探测/再哈希法,分别是单向挨个找,单向跳跃找和随即找。
当数组中有一半的数据占用的时候,
新来的元素hash地址已经被占用的几率比较小,就算偶尔占用,挪一个地方就行了。但是当容量占用到80%的时候,新元素很难一次找到空位,甚至多找几次也找不到。当容量超过95%的时候,效率就会变得很低。线性探测会遭遇大的聚集块,导致性能降低。二次探测避免了大数据块的影响,但是因为探测步长不变,会产生二次聚集。在哈希法要稍微好一点,每次探测的步长都是根据key重新计算的。开放地址法在哈希表容量占用在1/2-2/3时性能最好,超过2/3,效率会急剧降低。
链地址法:
通常我们所用的hash表都是这种算法,它同样要解决key映射的下标相同的情况,不同与开放地址法,链地址法使用链表去存储所有key下标相同的元素。
但是这种方法同样会随着链表容量的增加而降低效率,但是这种降低不像是之前开放地址法那样剧烈,它是线性的。
这种方法允许了哈希表的扩容和灵活的插入删除。
使用比较
![](https://images2015.cnblogs.com/blog/1000039/201611/1000039-20161117173102998-2074520210.png)
小结:
![](https://images2015.cnblogs.com/blog/1000039/201611/1000039-20161117173104607-1717108140.png)
![](https://images2015.cnblogs.com/blog/1000039/201611/1000039-20161117173106701-998116438.png)
以上大部分来自《java数据结构和算法(第二版)》第十一章
哈希表的应用:
字典/实时的拼写检查/MD5之类的加密算法/oracle解析sql语句......
哈希表的java版本之HashMap:
HashMap是Map的哈希表实现方式,用于 存取键值对数据。Map接口的另一个实现类是TreeMap,它是通过红黑树来实现键值对存取的,HashMap的另一个兄弟是HashTable,关于HashMap与HashTable的区别,后面再说。
哈希表前面已经说过了,不过对于之前那个开放地址法最近发现个问题:假如key的hash值映射的数组下标相同,那么另一个value势必要探测然后插入到数组的其他地方储存,算法是找到空位就插入,所以完全无规律。那么如何取数据呢?尤其是数组下标映射相同的key?好像是无法实现了,因为冲突的解决办法太简单,没有留下痕迹。
假如用探测步长来决定冲突的数据到底存放在哪里的话,就需要在数组中额外增加探测步长的数据,这样就势必要修改数据结构。因此,链地址法是实际可用的哈希表算法,而
HashMap就是据此实现的。
数组加链表说起来简单,但是实现起来也有些复杂,简单的数组加链表,固然可以存数据,但是取数据的时候,不同的key映射到相同的桶(数组下标)上,
那该如何找到某个key对应的value?HashMap内部给予了一个完美的实现。
HashMap初始化定义了长度为16
(HashMap的长度最好为2的N次幂,因为在hash取数组下标的运算中,会与数组长度-1进行 按位& 运算,如果是非2的n次幂的数,会导致许多不同的key取得相同的下标,导致数组分配不均衡,影响效率.为了避免这个问题,HashMap中还有hash方法,也是用来打乱hash值,使最后的数组下标能够尽量的均匀分布【https://www.zhihu.com/question/20733617 】)
初始化默认的装填因子,也就是load factor 是0.75;这里的负载因子是指HashMap的空间占用情况。例如长度为16的HashMap(是指数组的长度为16,不是其中的数据为16)
那么当空间占用到16*0.75=12之后,HashMap就要扩容了,因为如果负载因子过大,会导致HashMap效率降低,效率降低主要体现在桶里面的链表过长导致查找删除效率降低。
(数组的寻址时间是O(1),也就是说查询极快而且是趋于常量的,二分法时间复杂度是O(log2n),所以影响HashMap的效率主要是桶里面的链表的长度,链表越长,就需要遍历越多的项才能找到指定的元素)
HashMap定义最长的capacity为2的30次幂,超过最大长度还是会取最大长度。
HashMap内部是一个 entry[] ,entry数组,每个entry是一个Entry<K,V>。数组每一个元素都是一个链表,称为桶。
public V put(K key, V value) { //put元素的时候,先判断key是否为null【HashMap允许最多一个key为null,多个key的value为null的情况,HashTable不允许null】 if (key == null) //nullkey都插入到数组的第一个元素里 return putForNullKey(value); //获得hash值,然后计算数组下标 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); //获得这个下标的链表,遍历之,如果发现某一项的hash值与要添加的key的哈希值相同,并且这个项里面的key的值也许要存入的key的值相同(字面值),那么做一个更新操作,把新的value放进去,返回旧的value for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //假如是一个新的项,那么对所有的key-value对数量加1,并且将这个entry添加到这个链表当中。 modCount++; addEntry(hash, key, value, i); return null; }
//如果之前的判断没有走,说明这个下标里面还没有entry实例,还是空的。 void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; //赋值给一个新的entry,如果下面判断数组的size超过了负载因子规定的阈值,就扩容这个HashMap table[bucketIndex] = new Entry<K,V>(hash, key, value, e); if (size++ >= threshold) resize(2 * table.length); }
resize这里需要重新计算hash值,生成新的hash表。
//如果数组已经达到了最大值,那么HashMap里面的键值对最大设置为int类型的最大值,不知道是不是MAXMUM_CAPACITY*0.75 //下面是创建一个新的table,并重新设置。这里transfer是将旧的数组复制到新的数组中,需要重新计算hash值重新排列 void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } //transfer里面的具体算法没怎么看懂,只知道是重新映射 void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { //这里一开始没有看懂,应该是把当前元素的下一个元素定义为第一个元素,这样的话,新来的元素就继续占据表头,下一个元素就是之前上一个元素。 Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } } //第一次循环中,拿到第一个元素放到表头处,并定义第一个元素的下一个元素的地址就是表头。这样第二次循环的时候第二个元素就自然插到表头,第一个元素下移一位。 //这个可能涉及到链表的实现方式,这里还不是很清楚。
public V get(Object key) { if (key == null) return getForNullKey(); int hash = 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.equals(k))) return e.value; } return null; } //get方法先判断null,然后去桶里遍历所有项,只有hash,key都相同时,把值拿出来。如果桶里什么都没有,说明这个key没有数据,返回null。 private V getForNullKey() { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }
除了常用的get/set,还需要注意:
1.通过get返回null时,可能是没有那个key,也有可能这个key的值就是null,所以要判断是否存在某个key,应该用containskey来判断
2.clone方法不会克隆key-value;
3.插入数据的顺序是,每次都插入到链表的表头,也就是最先插入的数据在链表结尾。
HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。
4.HashMap有很多内部类来实现各个部分的功能,
Entry 内部类封装了entry的getKey/getValue/equals等操作,它实现了Map.Entry接口
包括Key,Value.Entry,Hash的迭代器主要都是封装了next方法,以遍历到下一个数据项。
KeySet,Values,EntrySet等,封装了HashMap的key集合,value集合和键值对的集合。也就是说HashMap是一个存储key,value和key-value的数据结构。
5.HashMap如果有多个线程对其结构进行改变【删除/插入】,会发生异常【ConcurrentModificationException】:主要是对modCount的判断实现的。所以说HashMap不是线程安全的。
6.如果需要在多线程环境下使用HashMap,建议在初始化的时候,使用Collections.synchronizedMap(new HashMap())来包装一下,synchronizedMap内部对每个方法都进行了加锁的操作。但是有些博客指出这并不能够完全保证安全,它只能保证在remove/get/put方法是同步的,但是不能保证多线程同时有remove/put/get操作,还能保证安全。
相比较而言HashTable虽然保证了线程安全,但是据说不论是get还是put都会一个锁锁全表,导致频繁锁抢占和阻塞。推荐使用ConcurrentHashMap用与多线程环境,因为后者是对数据分区锁,一次只锁一个桶,不影响其他桶操作。
6.HashTable和HashMap的区别:
1)继承不同
//HashTable publicclassHashtable<K,V> extendsDictionary<K,V> implementsMap<K,V>,Cloneable, java.io.Serializable{ //HashMap publicclassHashMap<K,V> extendsAbstractMap<K,V> implementsMap<K,V>,Cloneable,Serializable { //HashTable是基于旧的Dictionary类扩展的,HashMap是从Map直接过来的的。 //Dictionary类已经过时了,并不推荐直接扩展此类,实际上HashTable基本的实现和HashMap都差不多。
2)内部配置的区别
HashTable和HashMap内部大多数的算法都是一样的,可以说两者本来就是相同的实现方式,但是有细微的不同:
HashTable数组初始化长度11,HashMap为16。
HashTable的哈希算法没有经过hash干扰,所以冲突的几率会比较大;
HashTable数组扩容为:2*原长度-1(HashMap为2*原长度);Entry都是单向链表,实现方式差不多。;
迭代器有所区别,HashTable有两种方式(Iterator 和 Enumeration);
HashTable不允许任何key或value为null,而HashMap则允许一个key为null,不同的key的value为null。
3)线程
一般认为HashTable是线程安全的,在源码中看到大多数的方法之前都声明了synchrnized,只有remove方法对整个表进行了锁定:synchronized(Hashtable.this)
但是HashTable的线程安全仅指避免多个线程同时一个操作(读或者写),但是不能避免一个线程读,一个线程写的冲突安全问题。还需要上层再封装一下。
HashTable和HashMap都支持快速失败(Iterator方式),也就是说在遍历的时候发生结构变化,立刻抛出异常。(这里博客有多种说法,有时间还是要自己亲测一下)
另外,HashTable虽然保证了单个方法的线程同步,但是它的实现方式是在方法上synchronized,这样会锁住整个对象空间,也就是锁整表。
如果是许多线程,会产生巨大的锁竞争,以及大量的阻塞和等待。
同时,利用SynchronnizedMap(new HashMap())包装之后(Collections内部类重写了主要的方法,加上synchronized实现同步),还是会基于全表锁。
如果有必要在多线程环境中,使用Map,建议使用ConcurrentHashMap:基于桶的锁(后面有时间去看看源码)
参考资料: