Java数据结构与算法——散列表(HashTable)

一、概述

1、定义

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系 f,使得每个关键字 key 对应一个存储位置 f(key)。查找时,根据这个确定的对应关系找到给定值 key 的映射 f(key),若查找集合中存在这个记录,则必定在 f(key)的位置上。

对应关系 f 称为散列函数,又称为哈希(Hash)函数。采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表(Hash table)。关键字对应的记录存储位置称为散列地址

2、散列表查找步骤

散列过程其实就两步:

(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。

(2)在查找时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。

所以说,散列技术既是一种存储方法,也是一种查找方法。散列技术的记录之间不存在逻辑关系,它只与关键字有关,所以散列主要是面向查找的存储结构

3、两个关键问题

(1)散列函数应该如何设计?

(2)冲突如何解决?

冲突——当两个关键字 key1 不等于 key2 时,却有f(key1)= f(key2),这种现象称为冲突。并把 key1 和 key2 称为这个散列函数的同义词。

二、散列函数的构造方法

设计散列函数有两个基本原则——(1)计算简单;(2)散列地址分布均匀。在实际工作中,还需考虑关键字的长度、特点、分布以及散列表的大小等。

1、直接定址法

取关键字的某个线性函数值作为散列地址,即 f(key)= a * key + b。

这样的散列函数简单、均匀,也不会产生冲突。但限制也很多,要事先知道关键字的分布情况,适合查找表较小且连续的情况,所以现实并不常用。

2、数字分析法

如果关键字的位数较多,比如手机号,前三位是接入号,对应不同的运营商,中间四位是 HLR 识别号,表示用户号的归属地,后四位才是用户号。若现在要存储一家公司的员工登记表,可以用手机号作为关键字,那么极有可能前 7 位都是相同的,我们就可以先择后四位作为散列地址。如果容易存在冲突问题,可以对抽取出来的数字再进行反转、右环位移等操作。抽取——使用关键字的一部分来计算散列存储位置。

数字分析法适合处理关键字位数较大,且关键字的若干位分布比较均匀。

3、平方取中法

假设关键字是 1234,它的平方是 1522756,抽取中间三位 227,用做散列地址;假设关键字是4321,它的平方是 18671041,抽取中间三位,可以是 671,也可以是 710,用做散列地址。

平方取中法适合于不知道关键字的分布,且位数不是很大的情况。

4、折叠法

将关键字从左到右分割成位数相等的几部分(最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。比如,关键字 9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,叠加求和 987 + 654 + 321 + 0 = 1962,抽取后三位得到散列地址 962;如果这样不能保证分布均匀,也可以从一端向另一端来回折叠后对齐相加,即将 987 和 321 反转,再叠加 789 + 654 + 123 + 0 = 1566,得到散列地址 566。

折叠法不需要知道关键字的分布,适合关键字位数较多的情况。

5、除留余数法——最常用的构造散列函数法

对于表长为 m 的散列表,散列函数为:f(key)= key mod p(p 小于等于 m),mod 是取模(求余数)的意思。可以直接对关键字取模,也可以在折叠、平方取中后取模。关键在于选择合适的 p,以避免产生同义词在这里插入图片描述
以上表为例,对有 12 个记录的关键字构造散列表时,选择 p = 12,用 f(key)= key mod 12 对每个关键字进行计算,可得到上表所示的哈希表。比如 29 mod 12 = 5,所以它存储在下标为 5 的位置(黄色位置)。

但是,因为 12 = 2 * 6 = 3 * 4,如果关键字中存在 18 = 3 * 6、30 = 5 * 6 等数字时,他们的余数都为 6,就会和 78 对应的下标位置冲突(红色位置)。

再比如,一种极端情况,所有的关键字都是 12 的倍数,那它们的余数就都为 0 了,如下表所示:
在这里插入图片描述
此时,如果选择 p = 11,则只有 12 和 144 有冲突,相对好一点。

对 p 的选择:若散列表表长为 m,通常选择 p 为小于或等于表长(最好接近 m )的最小质数或不包含小于 20 的质因子的合数。

6、随机数法

取关键字的随机函数值作为它的散列地址,即 f(key)= random(key)。当关键字长度不等时,采用这个方法比较合适。

如果关键字是字符串,可以用 ASCII 码或者 Unicode 码等转化为数字再进行处理。

三、处理散列冲突的方法——冲突是不能避免的

1、开放定址法(线性探测法)

(1)原理

开放定址法——一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。公式:
f i ( k e y ) = ( f ( k e y ) + d i ) m o d m f_i(key) =( f(key) + d_i) \quad mod \quad m
其中, d i ( i = 0 , 1 , 2 , . . . ) d_i(i = 0,1,2,...) 为上一次计算的余数。

比如,关键字集合为 {12,67,56,16,25,37,22,29,15,47,48,34},表长 12,所用散列函数为 f(key)= key mod 12。散列过程如下:

(1)计算前五个数 {12,67,56,16,25} 时,没有冲突,直接存入,如下表:
在这里插入图片描述
(2)再计算 key = 37,f(37) = 1,与 25 所在的位置冲突。应用上面处理冲突的公式 f(37) = (f(37)+ 1)mod 12 = 2。于是,将 37 存入下标为 2 的位置。如下表:
在这里插入图片描述
(3)继续计算,22、29、15、47都没有冲突,正常存入,如下表:
在这里插入图片描述
(4)再计算 key = 48 ,f(48)= 0,与 12 所在的位置冲突,应用处理冲突的公式得 f(48) = (f(48)+ 1)mod 12 = 1,与 25 所在的位置冲突。继续,f(48) =( f(48)+ 1)mod 12 = 2,还是冲突……一直到 f(48) = (f(48)+ 6)mod 12 = 6 时,出现空位,方可存入。如下表:
在这里插入图片描述
(5)同理,最后将 34 存入表中,最终结果如下:
在这里插入图片描述
这种开放定址法也叫线性探测法

由这个例子可以看出,在解决冲突的时候,会碰到 48 和 37 这种不是同义词却要争夺一个地址的情况,这种情况称为堆积。堆积的出现需要我们不断的处理冲突,降低存入和查找的效率。

散列表的装填因子———装填因子 a = 填入表中的记录个数 / 散列表长度。a 标志着散列表的装满的程度。当填入表中的记录越多,a 就越大,再填入记录时产生冲突的可能性就越大。线性探测是一步一步往后探测,当装填因子比较大时,就会频繁的出现堆积。(所以,散列表的平均查找长度取决于装填因子,而不是查找集合中记录的个数)

(2)代码实现

查找——get(K key):

根据给定的 key 计算散列地址,从该散列地址开始顺序查找,如果找到则命中,否则检查散列表中的下一个位置(将索引值加1),直到找到该键或者遇到一个空元素为止。

插入——put(K key, V value):

根据给定的 key 计算散列地址,如果该散列地址是一个空元素,那么就将该键值对保存在那里;如果不是,就顺序查找一个空元素来保存它。

动态调整数组大小——resize(int newCapacity):

在实际应用中,当装填因子 a 接近 1 时,查找操作的时间复杂度会接近 O(n)。为了保证散列表的性能,当键值对总数很小时,应当动态调整数组的大小,使得散列表的使用率在 1/8 到 1/2 之间。

删除——delete():

删除时,若只将要删除的键所在的位置设为 null,则会使该位置之后的元素无法被查找。因此,还需要把删除元素位置之后的键值对重新插入。

完整代码如下:

public class LinearProbingHashMap<K, V> {
	private int num;// 散列表中键值对的数目
	private int capacity;// 散列表的容量
	private K[] keys;// 散列表中的键
	private V[] values;// 散列表中的值
	
	public LinearProbingHashMap(int capacity){
		this.capacity = capacity;
		keys = (K[])new Object[capacity];
		values = (V[])new Object[capacity];
	}
	
	/*
	 * 散列函数:将hash值和0x7FFFFFFF做一次按位与操作:
	 * 		为了保证得到的index的第一位为0,保证index是一个正数。
	 */
	private int hash(K key){
		return (key.hashCode() & 0x7fffffff) % capacity;
	}
	
	// 查找
	public V get(K key){
		int index = hash(key);
		while(keys[index] != null && !key.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		// 若给定的key在散列表中,返回对应的value;否则,返回null
		return values[index];
	}
	
	// 插入
	public void put(K key, V value){
		if(num >= capacity / 2){
			resize(capacity * 2);
		}
		int index = hash(key);
		while(keys[index] != null && !key.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		if(keys[index] == null){
			keys[index] = key;
			values[index] = value;
			return;
		}
		values[index] = value;
		num++;
	}
	
	// 动态调整数组的大小
	private void resize(int newCapacity){
		LinearProbingHashMap 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;
	}
	
	// 删除
	public void delete(K key){
		int index = hash(key);
		while(keys[index] != null && !key.equals(keys[index])){
			index = (index + 1) % capacity;
		}
		// 要删除的key不存在则直接返回
		if(keys[index] == null){
			return;
		}
		// 删除
		keys[index] = null;
		values[index] = null;
		
		// 删除元素位置之后的键值对重新插入
		index = (index + 1) % capacity;
		while(keys[index] != null){
			// 暂存当前键值对
			K tempKey = keys[index];
			V tempValue = values[index];
			// 删除当前键值对
			keys[index] = null;
			values[index] = null;
			num--;
			put(tempKey, tempValue);// 重新插入
			index = (index + 1) % capacity;
		}
		num--;
		// 若键值对数目太少,就减小数组的大小
		if(num > 0 && num <= capacity / 8){
			resize(capacity / 8);
		}
	}
	
	// 打印
	public void display(){
		for (int i = 0; i < capacity; i++) {
			if(keys[i] != null){
				System.out.println("[" + keys[i] + ", " + values[i] + "]");
			}
		}
	}
	
	public static void main(String[] args) {
		LinearProbingHashMap<Integer, String> hashMap = 
				new LinearProbingHashMap<Integer, String>(10);
		hashMap.put(24, "kobe");
		hashMap.put(30, "curry");
		hashMap.put(3, "pual");
		hashMap.put(23, "jordan");
		hashMap.display();
		System.out.println(hashMap.get(24));
		hashMap.delete(3);
		hashMap.display();
	}
}

测试结果如下:

[30, curry]
[3, pual]
[24, kobe]
[23, jordan]
==============
kobe
[30, curry]
[23, jordan]
[24, kobe]

2、链地址法

将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。

比如,对于集合 {12,67,56,16,25,37,22,29,15,47,48,34},进行除数为 12 的除留余数法,可得到下图所示的结构:
在这里插入图片描述
此时,无论出现多少个冲突,只需要在当前位置给单链表增加节点。该方法可以确保不会出现找不到地址的情况,但是,也带来了查找时需要遍历单链表的性能损耗。完整代码如下:

// 链表类
class SeqList<K, V>{
	// 结点类
	private class Node{
		K key;
		V value;
		Node next;
		
		public Node(K key, V value, Node next) {
			this.key = key;
			this.value = value;
			this.next = next;
		}
	}
	
	private Node first;
	
	// 查找
	public V get(K key){
		for(Node node = first; node != null; node = node.next){
			if(key.equals(node.key)){
				return node.value;
			}
		}
		return null;
	}
		
	// 插入
	public void put(K key, V value){
		// 键值对直接插入链表头部
		first = new Node(key, value, first);
	}
	
	// 删除
	public void delete(K key){
		Node node = first;
		while(node != null && !node.next.key.equals(key)){
			node = node.next;
		}
		if(node == null){
			System.out.println("元素不存在!");
			return;
		}
		node.next = node.next.next;
	}
	
	// 打印
	public void display(){
		Node node = first;
		while(node != null){
			if(node.next != null){
				System.out.print("[" + node.key + ", " + node.value + "], ");
			}else{
				System.out.print("[" + node.key + ", " + node.value + "]");
			}
			node = node.next;
		}
	}
}

// 链地址法
public class ChainHashMap<K, V> {
	private int num;// 散列表中的键值对数
	private int capacity;// 散列表的容量
	private ArrayList<SeqList<K, V>> sl;// 链表对象数组(利用动态数组)
	
	// 创建指定大小的链表对象数组,并给数组的每个位置创建一个空链表
	public ChainHashMap(int capacity){
		this.capacity = capacity;
		sl = new ArrayList<SeqList<K, V>> (capacity);
		for (int i = 0; i < capacity; i++) {
			sl.add(new SeqList<>());
		}
	}
	
	// 散列函数
	private int hash(K key){
		return (key.hashCode() & 0x7fffffff) % capacity;
	}
	
	// 查找
	public V get(K key){
		return sl.get(hash(key)).get(key);
	}
	
	// 插入
	public void put(K key, V value){
		sl.get(hash(key)).put(key, value);
		num++;
	}
	
	// 删除
	public void delete(K key){
		sl.get(hash(key)).delete(key);
	}
	
	public void display(){
		for (int i = 0; i < sl.size(); i++) {
			System.out.print("sl[" + i + "]: ");
			sl.get(i).display();
			System.out.println();
		}
	}
	
	public static void main(String[] args) {
		ChainHashMap<Integer, String> hashMap = 
				new ChainHashMap<Integer, String>(10);
		hashMap.put(3, "pual");
		hashMap.put(24, "kobe");
		hashMap.put(30, "curry");
		hashMap.put(23, "jordan");
		hashMap.put(32, "magic");
		hashMap.put(34, "O'neal");
		//System.out.println(hashMap.num);
		hashMap.display();
		System.out.println("=============");
		hashMap.delete(3);
		hashMap.display();
		//System.out.println(hashMap.get(3));
	}
}

测试结果:

sl[0]: [30, curry]
sl[1]: 
sl[2]: [32, magic]
sl[3]: [23, jordan], [3, pual]
sl[4]: [34, O'neal], [24, kobe]
sl[5]: 
sl[6]: 
sl[7]: 
sl[8]: 
sl[9]: 
=============
sl[0]: [30, curry]
sl[1]: 
sl[2]: [32, magic]
sl[3]: [23, jordan]
sl[4]: [34, O'neal], [24, kobe]
sl[5]: 
sl[6]: 
sl[7]: 
sl[8]: 
sl[9]: 

3、两种处理冲突方法的比较

(1)开放寻址法

优点: 开放寻址法的散列表中的数据都存储在数组中,可以有效的利用CPU缓存加快查询速度。

缺点: 用开放寻址法解决散列冲突,删除数据的时候比较麻烦,删除数据后还需要处理删除数据位置之后的数据;另外,在开放寻址法中,数据都存储在一个数组中,相比于链表,冲突的代价更高,且比链表更浪费内存空间。

适用场景: 当数据量比较小时,适合采用开放寻址法。如,Java 中 ThreadLocalMap 就是用开放寻址法解决散列冲突。

(2)链地址法

优点: 链地址法对内存的利用率比开放寻址法高,因为链表结点用的时候才创建,而开放寻址法要事先创建好数组。开放寻址法只能适用于装填因子小于 1 的情况,装填因子接近 1 时,就会有大量的散列冲突,导致大量的探测、再散列等,使得散列表性能下降;而链地址法则没有局限,无论数据再怎么增加,也只是加长链表的长度而已。

缺点: 链表要存储指针,所以当数据量较小时,要比开放寻址法更消耗内存,当数据量大时,链地址法消耗的内存会少一点。另外,在查找时,链地址法需要遍历单链表,查找效率不如开放寻址法。

适用场景: 当数据量比较大时,适合采用链地址法。链地址法更加灵活,支持更多的优化策略,比如用红黑树替代链表,Java 中 LinkedHashMap 就是用链地址法解决散列冲突。

发布了56 篇原创文章 · 获赞 0 · 访问量 955

猜你喜欢

转载自blog.csdn.net/weixin_45594025/article/details/104358434
今日推荐