【数据结构】散列表介绍 + 手写简单的HashMap

460 阅读6分钟

散列表

什么是散列表

散列表也叫哈希表,是一种通过键值对直接访问数据的机制

每一个key对应一个唯一索引。

散列表的实现原理正是映射的原理,通过设定的一个关键字和一个映射函数,就可以直接获得访问数据的地址,实现O(1)的数据访问效率。

在映射的过程中,事先设定的函数就是一个映射表,也可以称作散列函数或者哈希函数。

偷的一张图片:

image.png

将key进行hash变换,然后直接找到存储数据的数组的索引下标,时间复杂度O(1)。

散列表的特点

  1. 查找效率高。通过键可以直接计算出存储位置,无需遍历整个结构,平均情况下时间复杂度是 O(1)。
  2. 插入和删除的效率也较高(不冲突的话),时间复杂度也是O(1)。
  3. 占用空间大。为了换取高效率,内部需要使用链地址法解决冲突,占用空间更大。
  4. 哈希表是无序的,遍历输出的结果是根据索引的。
  5. 适用于大量数据的快速查找。适合存储大量的数据,快速进行查询。

散列表的实现

散列表的实现,散列表的实现是使用数组。因为数组可以通过下标直接访问,然后通过hash函数计算出key的索引。这样充分利用了数组的快速访问机制。

散列表冲突

我们在使用key计算hash值时,可能会发生冲突。

拿String举例子,String的hashcode()函数返回值仅仅是一个int,而int仅仅是四个字节,而String是无穷大的,所以数据多起来是一定会发生hash冲突的(hash冲突指不同key的hashcode值一样)。

发生hash冲突后,我们通过下标访问的数据就会冲突,比如:

key: hhh, value:ok;
key: ooo, value:ko;

如果hhh和ooo的hash函数值相等,那么它们就会被存到同一个索引下。 这样我们取数据时取的是哪个呢?

这时就叫发生了hash冲突,我们应该怎么办呢?

有以下几种常见的办法:

  1. 链地址法(拉链法): 在冲突的位置用一个链表将冲突的数据串起来,依次访问,直到找到我需要的数据
  2. 开放地址法: 当发生冲突时,从冲突的位置开始继续往后遍历,找到没有存储值的空间,将冲突键值对存储进去。
  3. 再hash法,当哈希发生冲突的时候,不仅修改哈希表索引,还修改哈希函数,使用另外的哈希函数重新计算哈希值。

还有解决办法,我这里只是写了常用的。

散列表在Java中的实现

Java中散列表的实现主要有HashMap和HashTable。

  1. HashMap:
  • HashMap 是基于哈希表的 Map 接口的最常用实现。
  • 它实现了 Map 接口,允许使用 null 键和值。
  1. Hashtable:
  • 不允许使用 null 键和值。
  • 它的效率比 HashMap 低,但是线程是安全的

它们都是使用拉链法来解决hash冲突。

手写一个简单的HashMap

我们实现一个简单的HashMap,拥有基本的get(), put()方法即hash冲突的解决。我们这里仅仅是对散列表的一个认识,没有完成HashMap的扩容,红黑树转换。

下面是完成后的类的结构:

image.png

从上到下依次是:

  • 存储数据的节点数组
  • 数组容量
  • hash算法
  • 构造函数
  • 存放键值对的方法
  • 取值方法
  • 静态节点类

构造函数

FakeHashMap(){
    elements = new Node[8];
}

这里给数组开一个容量为8的空间,如果太大,不便于测试

静态节点类

static class Node<K,V> {
    final K key;
    V value;
    Node<K, V> next;

    Node(K key, V value, Node<K, V> next) {
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

这个类设为静态的,这样所有的FakeHashMap使用时都不用再加载一遍,它是存在于内存的,使用时更方便。

数组存储的基本单位,也是实现解决hash冲突的关键,结构为链表。

hash函数

int hash(K key){
    return key.hashCode();
}

这里是返回键的hashCode值,所以键对象需要实现hashcode()方法。

put()方法

public void put(K key, V value ){
    // 找出索引
    int index = hash(key) & (capacity-1);
    // 找出索引对应的值
    Node<K, V> element = elements[index];
    // 判断是否为空
    if (element ==null){
        elements[index] = new Node<K,V>(key,value,null);
        return;
    }
    Node<K, V> prev = null;
    // 当其不为空
    while (element!=null){
        // 判断是否与加进来的相同
        if(key.equals(element.key)){
            // 覆盖值
            element.value = value;
            return;
        }
        prev = element;
        element = element.next;
    }
    prev.next = new Node<K,V>(key,value,null);
}

put方法:

  1. 先计算出数组下标,通过hashcode()值 & (capacity-1), 这样得到的下标永远不会超过capacity。
    任何数 & 0111, 得到的范围都是0-7。
  2. 判断索引位置是否有值,无值就往里面添加新节点;有值就通过equals判断是否与我的key相同,相同就覆盖,不相同就继续遍历;如果里面没有相同的,则把新节点放在当前链表末尾。
    你应该看到比较key需要使用equals()方法,所以key也必须重写equals()方法

get()方法

public V get(K key){
    int hash = hash(key);
    int index = hash & (capacity-1);
    Node<K, V> element = elements[index];
    while (element!=null){
        if(element.key.equals(key)){
            return element.value;
        }
        element = element.next;
    }
    return null;
}

get方法就稍微简单了,通过同样的方法获取数组索引,然后取出元素,看key是否相同,相同就取值,不相同就继续遍历;如果没有,返回null。

测试方法

    public static void main(String[] args) {
        FakeHashMap<String, String> map = new FakeHashMap<>();
        map.put("hhh", "hhh");
        map.put("jjj", "jjj");
        map.put("www", "www");
        map.put("aaa", "aaa");
        map.put("bbb", "bbb");
        map.put("ccc", "ccc");
        map.put("ddd", "ddd");
        map.put("mmm", "mmm");
        map.put("eee", "eee");
        System.out.println("测试冲突会不会被覆盖:");
        for (Node<String, String> element : map.elements) {
            while (element!=null){
                System.out.print(element.value + " ");
                element = element.next;
            }
            System.out.println();
        }

        System.out.println("=======================");
        System.out.println("测试冲突的值会不会拿不到:");
        System.out.println(map.get("eee"));
        System.out.println(map.get("jjj"));
        System.out.println("测试相同key会不会覆盖值:");
        System.out.println("原来的eee对应值:"+map.get("eee"));
        map.put("eee", "???");
        System.out.println("现在的eee对应值:"+map.get("eee"));
    }

我这里的key使用的是String,它帮我们重写了hashCode()和equals(),所以我们可以直接使用。

输出

测试冲突会不会被覆盖:
hhh
aaa
jjj bbb
ccc
ddd
mmm eee

www
======================= 测试冲突的值会不会拿不到:
eee
jjj
测试相同key会不会覆盖值:
原来的eee对应值:eee
现在的eee对应值:???

分析:

  • 我们往数组放了九个数,而数组容量是8,所以一定是会冲突的,我们打印每一个索引对应的值;可以看到jjj bbb冲突, mmm 和 eee冲突,但是数据都没丢失,都被打印出来了
  • 然后测试冲突值是否可取,我们通过key获得冲突的值,获取成功
  • 然后测试key相同是否会覆盖,结果覆盖了,证明测试成功。

那证明我们手写的这个HashMap功能是满足hash表的特点的,而且还解决了hash冲突。

总结

  1. 散列表就是一个key对应一个索引下标,一种映射关系,虽然可能会有冲突,所以hash函数选取不容易发生冲突的
  2. 散列表查询速度很快,时间复杂度为O(1),冲突了就是O(1 + 冲突的个数)。
  3. 散列表要正确使用,你选为key的类必须正确编写hashCode()与equals()方法
  4. Java中主要实现了散列表的有HashMap和HashTable,但是HashTable是线程安全的
  5. hash冲突是指不同的key计算出了相同的hash值
  6. hash冲突可以减少,不可能避免
  7. HashMap和HashTable解决冲突使用链地址法
  8. 手写HashMap不难,难的是冲突长度超过8,数组长度超过64链表会变为红黑树(我写不来),红黑树查询时间复杂度降低(O(logn))