散列表

文章目录

    • 什么是散列表
    • 散列表的读写操作
      • 写操作(put)
      • 读操作(get)
      • 扩容(resize)
      • 缩容(reduce)
    • 散列表代码实现
    • 散列表和链表一起使用的优势


什么是散列表

散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value) 的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。

散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。 可以说,如果没有数组,就没有散列表。
散列表_第1张图片
问:不过,散列表是如何根据Key值来快速查找到它所匹配的Value值呢?

答:散列表其本质也是一个数组,数组只能根据下标来访问而散列表是用Key值来访问,所以我们需要一个“中转站”,通过某种方式,把Key和数组下标进行转换。这个中转站就叫作哈希函数。
散列表_第2张图片
在Java及大多数面向对象的语言中,每一个对象都有属于自己的hashcode,这个
hashcode是区分不同对象的重要标识。无论对象自身的类型是什么,它们hashcode都是一个整型变量。所以哈希函数就是通过哈希码(hashcode)转换成为数组下标,按照数组长度进行取模运算。

index = HashCode (Key) % Array.length

通过哈希函数,我们可以把字符串或其他类型的Key,转化成数组的下标index。

如给出一个长度为8的数组:

则当key=001121时,

index = HashCode ("001121") % Array.length = 1420036703 % 8 = 7

而当key=this时,

index = HashCode ("this") % Array.length = 3559070 % 8 = 6

散列表的读写操作

写操作(put)

写操作就是在散列表中插入新的键值对(在JDK中叫作Entry)。例如调用hashMap.put(“002931”, “王五”),意思就是插入一组Key为002931、Value为王五的键值对。

第一步: 通过哈希函数,把Key转化成数组下标5。

第二步: 如果数组下标5对应的位置没有元素,就把这个Entry填充到数组下标5的位置。
散列表_第3张图片
不过,如果出现了哈希冲突的情况(例如002936这个Key对应的数组下标是2;002947这个Key对应的数组下标也是2.),就需要对冲突进行解决。
散列表_第4张图片
解决哈希冲突的方法主要有两种,一种是开放寻址法,一种是链表法

开放寻址法: 当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就”,寻找下一个空档位置。重新探测一个空闲位置,将其插入。探测方法主要有线性探测、二次探测、双重散列三种。在Java中,ThreadLocal所使用的就是开放寻址法。当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

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

二次探测:跟线性探测很像,线性探测每次探测的步长是 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)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

链表法: 这种方法被应用在了Java的集合类HashMap当中。HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
散列表_第5张图片

读操作(get)

读操作就是通过给定的Key,在散列表中查找对应的Value。调用hashMap.get(“002936”),意思是查找Key为002936的Entry在散列表中所对应的值。

第一步: 通过哈希函数,把Key转化成数组下标2。

第二步: 找到数组下标2所对应的元素,如果这个元素的Key是002936,那么就找到了;如果这个Key不是002936也没关系,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与Key相匹配的节点。
散列表_第6张图片

扩容(resize)

当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。这时,散列表就需要扩展它的长度,也就是进行扩容。

对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个。

1:Capacity,即HashMap的散列表容量
2:LoadFactor,即HashMap的负载因子,默认值为0.75f

衡量HashMap需要进行扩容的条件如下。

HashMap.Size >= Capacity×LoadFactor

散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

扩容不是简单地把散列表的长度扩大,需要分为下面两个步骤。

第一步: 扩容,创建一个新的Entry空数组,长度是原数组的2倍。

第二步: 重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。经过扩容,原本拥挤的散列表重新变得稀疏,原有的Entry也重新得到了尽可能均匀的分配。
散列表_第7张图片
例如,在未扩容的散列表中Value=21的位置为0,散列表扩容之后Value=21的位置会通过公式:key%散列表的大小变成位置7。

缩容(reduce)

对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果对空间消耗非常敏感,可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了。

散列表代码实现

package 散列表;

import java.util.Map.Entry;

/**
 * 散列表实现
 * @author 15447
 *
 */

public class HashTable<K, V> {

	/**
	 * 散列表默认长度
	 */
	private static final int DEFAULT_INITAL_CAPACITY = 8;
	
	/**
	 * 负载因子
	 */
	private static final float LOAD_FACTOR = 0.75f;
	
	/**
	 * 实际元素数量
	 */
	private int size = 0;
	
	/**
	 * 散列表索引数量
	 */
	private int use = 0;
	
	/**
	 * 初始化散列表
	 */
	private Entry<K, V>[] table;

	/**
	 * 无参构造初始化HashTable
	 */
	public HashTable() {
		table = (Entry<K, V>[])new Entry[DEFAULT_INITAL_CAPACITY];
	}
	
	/**
	 * 键值对类
	 */
	static class Entry<K, V> {
		K key; 
		
		V value;
		
		Entry<K, V> next;
		
		public Entry(K key, V value, Entry<K, V> next) {
			this.key = key;
			this.value = value;
			this.next = next;
		}
	}
	
	/**
     * 新增
     *
     * @param key
     * @param value
     */
    public void put(K key, V value) {
        int index = hash(key);
        // 位置未被引用,创建哨兵节点
        if (table[index] == null) {
            table[index] = new Entry<>(null, null, null);
        }

        Entry<K, V> tmp = table[index];
        // 新增节点
        if (tmp.next == null) {
            tmp.next = new Entry<>(key, value, null);
            size++;
            use++;
            // 动态扩容
            if (use >= table.length * LOAD_FACTOR) {
                resize();
            }
        }
        // 解决散列冲突,使用链表法
        else {
            do {
                tmp = tmp.next;
                // key相同,覆盖旧的数据
                if (tmp.key == key) {
                    tmp.value = value;
                    return;
                }
            } while (tmp.next != null);

            Entry<K, V> temp = table[index].next;
            table[index].next = new Entry<>(key, value, temp);
            size++;
        }
    }

    /**
     * 散列函数
     * 

* 参考hashmap散列函数 * * @param key * @return */ private int hash(Object key) { int h; return (key == null) ? 0 : ((h = key.hashCode()) ^ (h >>> 16)) % table.length; } /** * 扩容 */ private void resize() { Entry<K, V>[] oldTable = table; table = (Entry<K, V>[]) new Entry[table.length * 2]; use = 0; for (int i = 0; i < oldTable.length; i++) { if (oldTable[i] == null || oldTable[i].next == null) { continue; } Entry<K, V> e = oldTable[i]; while (e.next != null) { e = e.next; int index = hash(e.key); if (table[index] == null) { use++; // 创建哨兵节点 table[index] = new Entry<>(null, null, null); } table[index].next = new Entry<>(e.key, e.value, table[index].next); } } } /** * 删除 * * @param key */ public void remove(K key) { int index = hash(key); Entry e = table[index]; if (e == null || e.next == null) { return; } Entry pre; Entry<K, V> headNode = table[index]; do { pre = e; e = e.next; if (key == e.key) { pre.next = e.next; size--; if (headNode.next == null) use--; return; } } while (e.next != null); } /** * 获取 * * @param key * @return */ public V get(K key) { int index = hash(key); Entry<K, V> e = table[index]; if (e == null || e.next == null) { return null; } while (e.next != null) { e = e.next; if (key == e.key) { return e.value; } } return null; } }

散列表和链表一起使用的优势

一个缓存(cache)系统主要包含下面这些操作
1:往缓存中添加一个数据
2:从缓存中删除一个数据
3:在缓存中查找一个数据
这三个操作都要涉及“查找”操作,如果单纯地采用链表的话,时间复杂度只能是 O(n)。如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到 O(1)。具体的结构就是下面这个样子:
散列表_第8张图片
查找一个数据: 散列表中查找数据的时间复杂度接近 O(1)

删除一个数据: 借助散列表,我们可以在 O(1) 时间复杂度里找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。

添加一个数据: 添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。

因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。

你可能感兴趣的:(数据结构与算法,数据结构,java,散列表)