【数据结构与算法】之散列表(Java实现)---第十篇

目录:

一、散列表基本概念

1、基本定义

2、散列表思想

二、散列函数

1、定义

2、散列函数设计的基本要求

3、如何设计散列函数

三、散列冲突

1、开放寻址法

2、链表法

3、如何选择散列冲突解决的方法

四、装载因子

五、散列表应用场景

1、Java中的HashMap

1、Word文档中单词拼写检查功能

2、假设我们有10万条URL访问日志,如何按照访问次数给URL排序?

3、有两个字符串数组,每个数组大约有10万条字符串,如何快速找出两个数组中相同的字符串?

六、总结


一、散列表基本概念

1、基本定义

散列表(Hash  Table,又叫哈希表),是根据关键码值(Key  Value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

2、散列表思想

符号表是一种用于存储键值对(Key -- Value)的数据结构,数组也可以看做是一种特殊的符号表,其中数组的索引即为键,数组元素为相应的值。也就是说,当符号表所有的键都是较小的整数时,我们可以使用数组来实现符号表,将数组的索引作为键,而索引处的数组元素即为对应的值,但是这一表示仅限于所有的键都是比较小的整数时,否则可能会使用一个非常大的数组。而散列表是对以上策略的一种升级,它可以支持任意的键而并没有对他们作过多的限定,对于基于散列表的符号表,若我们要在其中查找一个键,需要以下步骤,当然这也是散列表的思想:

散列表思想:

(1)使用散列函数将给定键转化为一个“数组的索引”,理想情况下,不同的key会被转化为不同的索引,但是在实际情况中,我们会遇到不同的键转化为相同索引的情况,这种情况叫做散列冲突/碰撞,后文中会详细讲解;

(2)得到了索引后,我们就可以像访问数组一样,通过这个索引访问到相应的键值对。

散列表是“时间--空间”权衡的例子。当我们的空间无限大时,我们可以直接使用一个很大的数组来保存键值对,并用key作为数组索引,因为空间不受限,所以我们的键的取值可以无穷大,因此查找任何键都只需要一次普通的数组访问。反过来,若查找操作没有任何时间上的限制,我们就可以直接使用链表来保存所有的键值对,这样把空间的使用降到最低,但查找时只能顺序查找。

然而,在时间的应用中,我们的时间和空间都是有限的。所以,我们必须要在两者之间做出权衡,散列表在时间和空间的使用上找到了一个很大的平衡点。散列表的一个优势在于我们只需要调整散列算法的相应参数而无需对其他部分的代码做任何修改就能在时间和空间上做出策略调整。

从上面可以看出:散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来,可以说,如果没有数组,就没有散列表。


二、散列函数

1、定义

散列函数(又叫哈希函数),顾名思义,它是一个函数。我们可以把它定义为hash(key),其中key表示元素的键值,hasn(key)的键表示经过散列函数计算得到的散列值。

2、散列函数设计的基本要求

(1)散列函数计算得到的散列值是一个非负整数;

(2)如果key1  =  key2,那么hash(key1)  ==  hash(key2);

(3)如果key1  !=  key2,那么hash(key1)  !=  hash(key2)。

解释下上面三点:

第一点:数组下标是从0开始的,所以散列函数生成的散列值也要上非负整数;

第二点:相同的key,经过散列函数得到的散列值也应该是相同的;

第三点:这个要求看起来没什么问题,但是在真实情况下,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。即便是业界非常著名的MD5、SHA、CRC等哈希算法,也无法避免这种散列冲突。而且,因为数组的存储空间有限,所以也会加大散列冲突的概率。

3、如何设计散列函数

散列函数的设计的好坏,决定了散列冲突的概率大小,也直接决定了散列表的性能,那么我们应该如何设计散列函数呢?

(1)散列函数的设计不能太复杂,过于复杂的散列函数,势必会消耗很多计算时间,也会间接的影响到散列表的性能;

(2)散列函数生成的值要尽可能随机且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会再出现某个槽里数据特别多的情况。

实际工作中,我们还需要综合考虑各种因素,比如:关键字的长度、特点、分布以及散列表的大小等。常用的散列函数的设计方法有:直接寻址法、平方取中法、随机数法等等,这些方法了解即可,不是重点。

<1> 直接寻址法:

取k或者k的某个线性函数为Hash值;

特点:由于直接寻址法相当于有多少个关键字就必须有多少个相应的地址去对应,所以不会产生冲突,也正因为这样,所以实际中很少用到这种方法。

<2> 数字分析法:

首先分析待存的一组关键字,比如是一个班级学生的出生年月日,我们发现他们的出生年份大体相同,那么我们肯定不能用他们的年来作为存储地址,这样出现冲突的概率非常大。但是,我们发现月日的具体数字差别很大,如果我们用月日来作为Hash值,则会明显降低冲突几率。因此,数字分析法就是找出关键字的规律,尽可能的用差异数据来构造Hash地址;

特点:需要提前知道所有的关键字,才能分析运用此种方法,所以不太常用。

<3> 平方取中法:

先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。这种方法比较常用。

例:我们把英文字母在字母表中的位置序号作为该英文字母的内部编码。例如K的内部编码为11,E的内部编码为05,Y的内部编码为25,A的内部编码为01, B的内部编码为02。由此组成关键字“KEYA”的内部代码为11052501,同理我们可以得到关键字“KYAB”、“AKEY”、“BKEY”的内部编码。之后对关键字进行平方运算后,取出第7到第9位作为该关键字哈希地址,如下图所示:

关键字 内部编码 内部编码的平方值 H(k)关键字的哈希地址
KEYA 11050201 122157778355001 778
KYAB 11250102 126564795010404 795
AKEY 01110525 001233265775625 265
BKEY 02110525 004454315775625 315

<4> 折叠法:

将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。

<5>随机数法:

选择一个随机函数,取关键字的随机函数值作为Hash地址 ,通常用于关键字长度不同的场合。

特点:通常,关键字长度不相等时,采用此法构建Hash函数 较为合适。

<6>除留取余法:

取关键字被某个不大于Hash表 长m 的数p 除后所得的余数为Hash地址 。

特点:这是最简单也是最常用的Hash函数构造方法。可以直接取模,也可以在平方法、折叠法之后再取模。

值得注意的是,在使用除留取余法 时,对p 的选择很重要,如果p 选的不好会容易产生同义词 。

由经验得知:p 最好选择不大于表长m的一个质数 、或者不包含小于20的质因数的合数。

【说明:上诉6种方法出自于:https://blog.csdn.net/xiaoxik/article/details/74926090


三、散列冲突

【ps:此段落内容摘抄自极客时间的《数据结构与算法之美》专栏】

首先,我们需要知道的是:再好的散列函数也无法避免散列冲突。

如何处理冲突是哈希表不可缺少的一个方面,现在完整的描述下处理冲突:【不适用于链表法】

散列冲突是指由关键字得到的哈希地址的位置上已存有记录,则“处理冲突”就是为该关键字的记录找到另一个“空”的哈希地址。在处理冲突的过程中,可能得到一个地址序列,即在处理哈希地址的冲突时,若得到的另一个哈希表地址仍然发生冲突,则再求下一个地址,若仍然冲突,再求,以此类推,直至不发生冲突为止,则为记录在表中的地址。

解决散列冲突的方法有:开放寻址法(Open  Addressing)和链表法(Chaining),以及再哈希法和公共溢出法。

1、开放寻址法

开放寻址的核心思想是:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。这里我们可以使用线性探测方法(Linear  Probing)

当我们往散列表中插入数据时,如果某个数据经过散列函数之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

下面举例说明:下图中黄色的块表示空闲位置,橙色的块表示已经存储了数据的。从图中可以看出,散列表的大小为10,在元素x插入散列表之前,已经有6个元素插入到散列表中了。x经过Hash算法之后,被散列到下标为7的位置,但是这个位置已经有数据了,所以就产生了冲突。那么就需要顺序的一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置2,于是将其插入到这个位置。

图1

在散列表中查找元素的过程有点儿类似插入的过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素,如果相等,则说明就是我们要找的元素;否则就是顺序往后依次查找,如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。(ps:之所以要往后找,是因为插入数据时可能发生散列冲突,要查找元素插入到了数组中下标为散列值的后面,但是如果遇到空位置,则要查找的元素肯定不存在)

图2

散列表和数组一样,不仅支持插入和查找操作,也支持删除操作。对于线性探测法解决冲突的散列表,删除操作稍微有些特别,我们不能单纯地把要删除的元素位置设置为空,想想这是为什么呢?

上面讲查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,就会被认定为不存在。

解决方法:我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的位置,并不是停下来,而是继续往下探测。

图3

通过上面的讲诉,不难发现其实线性探测法存在很大的问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。在极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到或者删除数据。

对于开放寻址冲突解决方法,除了线性探测法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic  Probing)和双重散列(Double  Hashing)

二次探测:和线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key) + 0,hash(key) + 1,hash(key) + 2 ...而二次探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key) + 0,hash(key) + 1^{2},hash(key) + 2^{2}.......

双重散列:不仅要使用一个散列函数,我们使用一组散列函数hash1(key)、hash2(key)、hash3(key)......我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,以此类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中的空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲位置。这里我们用装载因子(Load  Factor)来表示空位的多少。装载因子的计算公式如下:

散列表的装载因子  =  散列表中已有的元素个数  /  散列表的总长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能就会下降。

开放寻址法解决散列冲突的代码实现:

public class LinearProbingHashMap<K, V> {
	
	private int num;       // 散列表中键值对数目
	private int capacity;  // 散列表的大小
	private K[] keys;      // 散列表中的键
	private V[] values;    // 散列表中的值
	
	public LinearProbingHashMap(int capacity){
		keys = (K[]) new Object[capacity];
		values = (V[]) new Object[capacity];
		this.capacity = capacity;
	}
	
	// 散列函数
	private int hash(K key){
		return (key.hashCode() & 0x7fffffff) % capacity;
	}
	
	// get()方法
	public V get(K key){
		int index = hash(key);
		while(keys[index] != null && !keys.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		// 若给定key在散列表中存在会返回相应value,否则这里返回的是null
		return values[index];
	}
	
	// put()方法
	public void put(K key, V value){
		if(num >= capacity / 2){
			resize(2 * capacity);
		}
		
		int index = hash(key);
		while(keys[index] != null && !keys.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		
		if(keys[index] == null){
			keys[index] = key;
			values[index] = value;
			return;
		}
		
		values[index] = value;
		num++;
	}

	// 删除操作
	public void delete(K key){
	
		if((keys.toString()).contains((CharSequence) key)){
			return;
		}
		
		int index = hash(key); 
		
		while(!key.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		
		keys[index] = null;
		values[index] = null;
		index = (index + 1) % capacity;
		while(keys[index] != null){
			K keyToRedo = keys[index];
			V valueToRedo = values[index];
			keys[index] = null;
			values[index] = null;
			num--;
			put(keyToRedo, valueToRedo);
			index = (index + 1) % capacity;
		}
		num--;
		if(num > 0 && num == capacity / 8){
			resize(capacity / 8);
		}
	}
	
	// 动态扩容
	private void resize(int newCapacity) {
		LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity);
        for (int i = 0; i < capacity; i++) {
            if (keys[i] != null) {
                hashmap.put(keys[i], values[i]);
            }
        }
        keys = hashmap.keys;
        values = hashmap.values;
        capacity = hashmap.capacity;
	}
	
}

2、链表法

链表法是一种更加常用的散列冲突解决办法,相比较开放寻址法,它要简单很多。如图4所示,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应成一条链表,所有散列值相同的元素,我们都放到相同槽位对应的链表中。

图4

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度为O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。实际上,删除和查找的时间复杂度和链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k = n / m,其中n表示散列中数据的个数,m表示散列中“槽”的个数。

链表法解决散列冲突的Java代码实现:

class ChainingHashSet<K, V> {

	private int num;       			// 当前散列表中的键值对总数
	private int capacity;  			// 散列表的大小
	private SeqSearchST<K, V>[] st;   // 链表对象数组
	
	// 构造函数
	public ChainingHashSet(int initialCapacity){
		capacity = initialCapacity;
		st = (SeqSearchST<K, V>[]) new Object[capacity];
		for(int i = 0; i < capacity; i++){
			st[i] = new SeqSearchST<>();
		}
	}
	
	// hash()方法
	private int hash(K key){
		return (key.hashCode() & 0x7fffffff) % capacity;
	}
	
	public V get(K key){
		return st[hash(key)].get(key);
	}
	
	public void put(K key, V value){
		st[hash(key)].put(key, value);
	}
}

// SeqSearchST基于链表的符号表实现
class SeqSearchST<K, V>{
	
	private Node first;
	
	// 结点类
	private class Node{
		K key;
		V value;
		Node next;
		
		// 构造函数
		public Node(K key, V val, Node next){
			this.key = key;
			this.value = val;
			this.next = next;
		}
	}
	
	// get()方法
	public V get(K key) {
		for(Node node = first; node != null; node = node.next){
			if(key.equals(node.key)){
				return node.value;
			}
		}
		return null;
	}

	// put()方法
	public void put(K key, V value) {
		// 先查找表中是否已经存在相应的key
		Node node;
		for(node = first; node != null; node = node.next){
			if(key.equals(key)){
				node.value = value;  // 如果key存在,就把当前value插入node.next中
				return;
			}
		}
		
		// 表中不存在相应的key,直接插入表头
		first = new Node(key, value, first);
	}
}

3、如何选择散列冲突解决的方法

上面提到了四种解决散列冲突的方法,其中开放寻址法和链表法比较常用。比如:Java中LinkedHashMap采用了链表解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突的。现在对这两种解决散列冲突的方法进行优缺点对比以及适用场景进行说明:

(1)开放寻址法

优点:开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效的利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。而链表包含指针,序列化起来就没那么容易。

缺点:用开放寻址法解决散列冲突,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在在开放寻址法中,所有的数据都存储在一个数组中,比起链表来说,冲突的代价更高,所以使用开放寻址法解决散列冲突时,装载因子的上限不能太大,这也导致这种方法比链表更浪费内存空间。

适用场景:当数据量比较小,装载因子比较小的时候,适合采用开放寻址法。这也是Java中ThreadLocalMap使用开放寻址法解决散列冲突的原因。

(2)链表法

优点:首先,链表法对内存的利用率比开放寻执法要高,因为链表结点可以在用的时候再创建,并不需要像开放寻址法那样需要事先申请好;其次,链表法比起开放寻址法,对装载因子的容忍度更高。开放寻址法只能适用于装载因子小于1的情况。接近1的时候,就有可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表来说,只要散列函数的值随机均匀,即便装载因子变成10,也就是链表的长度变长了而已。

缺点:因为链表要存储指针,所以对于比较小的对象存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对CPU缓存是不友好的,这方面对执行效率会有一定的影响。当然如果我们存储的是大对象,也就是说要存储对象的大小远远比一个指针的大小(4个字节或者8个字节),那链表中指针的内存消耗就可以忽略不计了。

实际上,我们可以对链表法稍加改造,就可以实现一个更加高效的散列表。那就是,我们将链表法的链表改造为其他高效的动态数据结构,比如:跳表、红黑树等。这样即便出现散列冲突,极端情况下,所有的数据都散列到一个桶内,那最终退化成的散列表查找时间复杂度也只不过是O(logn)。

适用场景:基于链表的散列冲突处理方法比较适合大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如红黑树替代链表。


四、装载因子

散列表的装载因子  =  散列表中已有的元素个数  /  散列表的总长度

从上面公式可以看出:装载因子标志哈希表的装满成度:

1、装载因子越小,发生的可能性就越小;

2、装载因子越大,代表着表中已填入的元素越多,再填入元素时发生冲突的概率也就越大。那么在查找时,给定值需要比较的关键字的个数就越多。

那么我们怎么解决装载因子过大的问题呢?----动态扩容

装载因子越大,说明散列表中的元素越多,空闲位置少,散列冲突的概率就越大。不仅插入数据的过程需要多次寻址或者拉很长的链,查找过程也会变得很慢。

对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等设计出完美的、极少冲突的散列函数,因为毕竟数据都是已知的,很多冲突情况就很好的避免的;但是对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估要加入的数据的个数以及大小,所以我们无法事先申请一个足够大的散列表。随着数据的慢慢加入,装载因子就会变得很大,当大到一定程度的时候,散列冲突就会变得不可接受。这个时候就需要对散列表进行“动态扩容”了。

针对散列表,当装载因子过大时,我们可以对散列表进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新的散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8,那么经过扩容之后,新散列表的装载因子就变为了以前的一半,0.4。

针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移就变得复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

举个例子:如下图所示,在原来的散列表中,21这个元素原来存储在下标为0的位置,搬移到新的散列表中,存储在下标为7的位置。

图5

插入一个数据,最好情况下,不需要扩容,最好时间复杂度为O(1)。最坏情况下,散列表的装载因子过高,需要扩容,重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度为O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,为O(1)。

实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲的空间会越来越多。如果我们对空间消耗特别敏感,可以设置在装载因子小于某个值之后,启动动态缩容。可以看出来,装载因子太大会导致散列冲突过多;如果太小,又会导致内存浪费严重。

装载因子阈值的设置要权衡时间复杂度和空间复杂度。如果内存空间不紧张,对执行效率要求比较高,就可以降低装载因子的阈值;相反,如果内存空间紧张,对执行效率要求不是很高,可以增加装载因子的值,甚至可以大于1。

现在,我们需要考虑一个问题,如何避免上诉中的低效扩容?

这里举一个极端的例子:如果散列表大小为1GB,需要扩容为原来的2倍,那就需要对1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,听起来就很耗时。如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。这个时候,“一次性”扩容就不合适了。

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请内存空间,但并不将老的数据搬移到新的散列表中。

当有新数据要插入时,我们就将数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新的散列表中。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点点全部都搬移到了新的散列表中了。这样避免了集中一次的数据搬移操作,插入操作就变得很快了。

对于查询操作,为了兼容新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中找。

图6

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是O(1)。


五、散列表应用场景

1、Java中的HashMap

(1)、初始大小:HashMap默认的大小是16,如果事先知道大概数据量有多少,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高HashMap的性能。

(2)、装载因子和动态扩容:最大装载因子默认是0.75,当HashMap中元素个数超过0.75*capacity(capacity表示散列表的容量)的时候,就会启动自动扩容,每次扩容为原来的2倍;

(3)、散列冲突解决方法:HashMap底层采用链表法来解决冲突,即便负载因子和散列函数设计的再合理,也避免不了出现拉链过长的情况,一旦出现拉链过长,就会严重影响HashMap的性能。于是,在JDK1.8中,对HashMap做了进一步的优化,引入了红黑树。当链表长度大于8(默认长度)时,链表就会转化为红黑树。当红黑树的结点个数小于8时,有转化为链表。因为在数据量较小的时候,红黑树需要维护平衡,比起链表并没有太大的优势。

(4)、散列函数:源码如下:

int hash(Object key){
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capacity - 1);  // capacity表示散列表的大小
}

其中,hashCode()方法返回的是Java对象的hash code。比如String类型对象的hashCode()就是下面这样:

public int hashCode() {
    int var1 = this.hash;
    if(var1 == 0 && this.value.length > 0) {
        char[] var2 = this.value;
        for(int var3 = 0; var3 < this.value.length; ++var3) {
            var1 = 31 * var1 + var2[var3];
        }
        this.hash = var1;
     }
    return var1;
}

下面贴出HashMap的部分源码(JDK1.7版本):

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    
    一、静态成员变量
    // 默认的容量为16
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 最大容量为2的30次方
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认的装载因子是0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 默认的是Entry数组
    transient Entry<K,V>[] table;

    // table中实际的Entry数量
    transient int size;

    // size达到此门槛后,必须扩容table,值为:capacity * loadfactory = 16 * 0.75 = 12,也就意味着,存够12个数据时,table就要扩容一次
    int threshold;

    // 装载因子,值从一开始构造HashMap时就确定了,默认是0.75
    final float loadFactor;

    transient int modCount;

    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

    private static class Holder { ...//省略}

    ......
    ......

    二、构造函数
    // 初始容量initialCapacity为16,装填因子loadFactor为0.75
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        init(); // init可以忽略,方法默认为空{},当你需要集成HashMap实现自己的类型时可以重写此方法做一些事

    }

其中Map.Entry<K, V>的源码,数组table实际存储的类型:

 // 三、Map.Entry<K, V>,数组table实际存储的类型
     static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

        // Entry的构造函数
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;    // 链表的下一个Entry
            key = k;
            hash = h;
        }
        
        // hashCode()方法
        public final int hashCode() {
            return (key==null   ? 0 : key.hashCode()) ^
                   (value==null ? 0 : value.hashCode());
        }

存数据:put(key, value):

// 存数据 
public V put(K key, V value) {
        if (key == null)
            // HashMap允许key为null:在table中找到null key,然后设置Value,同时其hash为0;
            return putForNullKey(value);
        
        // a、计算key的hashCode
        int hash = hash(key);
        
        // b、根据hashCode计算index
        int i = indexFor(hash, table.length);
       
         // c、遍历index位置的Entry链表
         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))) {
               
               // hashCode和equals都相等则表明:本次put是覆盖操作,下面return了被覆盖的老value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;

        // d、添加Entry,并解决冲突
        // 如果需要增加table长度(size>threshold)就乘2增加,并重新计算每个元素在新table中的位置和转移
        addEntry(hash, key, value, i);
        return null;   // 增加成功最后返回null

    }

下面对上面的a、b、d步骤进行展开说明:

a、为了防止低质量的hash函数,HashMap自己会再重新计算一遍key的hashCode

final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            // 字符串会被特殊处理,返回32bit的整数(就是int)
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
        
        // 将key的hashCode与h按位异或,最后赋值给h
        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

b、计算此hashCode该被存放入table的那个index

static int indexFor(int h, int length) {
           // 与table的length - 1按位与,就能保证返回结果在0-length-1内
           return h & (length-1);
    }

c、解决冲突,链表法

void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];  // index的Entry拿出来
        // put添加新元素是直接new Entry放在链头,如果有老的(有冲突)则将next设置为老的,如果没有正好设置next为null
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
}

取数据get(Object key):

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        // 3 如果该index处Entry的key与此k相等,就返回value,否则继续查看该Entry的next
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
}


final Entry<K,V> getEntry(Object key) {
        // 1 根据k使用hash(k)重新计算出hashCode
        int hash = (key == null) ? 0 : hash(key);
        // 2 根据indexFor计算出该k的index
        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;
}

补充延申:

 这里从HashMap延申到Object对象【Object类API解读】:

public class Object {

    // ...
    //hashCode()方法
    public native int hashCode();

    // equals()方法
    public boolean equals(Object obj) {
        return (this == obj);
    }
    
    // ...
}

你可以看Object类的源码,hashCode()方法上说明了:equals的对象必须有相同的哈希码。

equals()方法上说明了:覆盖此方法时,通常有必要重写hashCode()方法,以维护equals的对象必须有相同的哈希码。

总结:覆盖equals()方法通常有必要也覆盖hashCode()方法,因为必须要保证equals对象的hasnCode相等。

为什么覆盖equals()方法通常有必要也覆盖hashCode()方法,因为必须要保证equals对象的hasnCode相等呢?

从Object的源码可以看出来,equals()方法默认是用==号判断的,hashCode()方法是native方法,即直接依赖C计算出来的。所以继承了Object类的子类如果只覆盖equals方法而未覆盖hashCode()方法,则会导致错误计算Index,覆盖操作失效。

假设equals()方法覆盖为name相等的对象即相等(小明等于小明),但是没有覆盖hashCode()方法。put(key)操作,由于新的key和HashMap中原有的老key是两个不同的对象,尽管他们equals,不过继承自Object的hashCode()方法给出了两个不同的hashCode值,再根据hashCode计算index时,就会计算出两个不同的index。这直接导致一次覆盖操作变成了新增操作。

因此:覆盖equals()方法通常有必要也覆盖hashCode()方法,因为必须要保证equals对象的hasnCode相等。


1、Word文档中单词拼写检查功能

解题思路:常用的英文单词大概有20万个左右,假设单词的平均长度为10个字母,即平均一个单词占用10个字节的内存空间,那么20万英文单词大约占2MB的存储空间,就算放大10倍也就是20MB。对于现在的计算机来说,这个大小完全可以放在内存里面,所有我们可以用散列表来存储整个英文单词的词典

当用户输入某个英文单词的时候,我们拿用户输入的单词去散列表中查找。如果找到,则说明拼写正确;如果没有找到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以很轻松实现快速判断是否存在拼写错误。

2、假设我们有10万条URL访问日志,如何按照访问次数给URL排序?

解题思路:遍历这10w条日志记录,以URL为Key,URL的访问次数为Value,存入散列表中,同时记录下访问次数最大的值K,时间复杂度为O(n)。

如果k不是很大,直接使用桶排序,时间复杂度为O(n);如果k比较大,就使用快速排序,时间复杂度为O(nlogn)。

3、有两个字符串数组,每个数组大约有10万条字符串,如何快速找出两个数组中相同的字符串?

以第一个字符串数组构建散列表,key为字符串,value为出现的次数。再遍历第二个字符串数组,以字符串为key在散列表中查找,如果value大于零,说明存在相同字符串,时间复杂度为O(n)。


六、总结

这一篇文章讲解了散列表的概念、包括散列表的由来、散列函数以及散列冲突的解决办法。

1、散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性;

2、散列表的两个核心问题是散列函数散列冲突的解决;

3、散列冲突有两种常用的解决办法:开放寻址法链表法;

关于散列冲突的解决方法的选择,上文中对比了开放寻址法和链表法的优缺点和适用场景。大部分情况下,链表法更加普适。而且我们还可以通过链表法中的链表改造成其他数据结构,比如红黑树等,来避免散列表的时间复杂度退化为O(n),抵御散列碰撞的攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻执法。

4、散列函数设计的好坏决定了散列冲突的概率,也就是决定了散列表的性能;

要尽可能让散列后的值随机分布,这样尽可能的减少散列冲突,即便冲突之后,分配到每个槽里的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,否则会太耗时间,也会影响散列表的性能。

5、如何根据装载因子进行动态扩容。

6、散列表的应用场景。


参考及推荐:

1、java中哈希表及其应用详解

2、Java数据结构与算法解析——散列表

3、散列表的基本原理与实现

学习不是单打独斗,如果你也是做Java开发,可以加我微信,一起分享学习经验!

本人微信号:pengcheng941206

没有更多推荐了,返回首页

私密
私密原因:
请选择设置私密原因
  • 广告
  • 抄袭
  • 版权
  • 政治
  • 色情
  • 无意义
  • 其他
其他原因:
120
出错啦
系统繁忙,请稍后再试