数据结构与算法(第一季):哈希表(Hash Table)

一、哈希表(Hash Table)

1、概念

  • 哈希表也叫做散列表。
  • 哈希表的原理:
image
  • 利用哈希函数生成key对应的index,时间复杂度O(1)
  • 根据index(索引)操作定位数组元素,时间复杂度O(1)
  • 哈希表的空间换时间的典型应用。

2、哈希冲突

image
  • 哈希冲突也叫做哈希碰撞
    • 2个不同的key,经过哈希函数计算出相同的结果。
    • key1 != key2hash(key1) = hash(key2)
  • 解决哈希冲突的常见方法
    • 按照一定规则(线性探测平方探测)向其他地址探测,直到遇到空桶。(开放定址法
    • 设计多个哈希函数。(再哈希法
    • 通过链表将同一index的元素串起来。(链地址法
image

3、JDK1.8的哈希冲突解决方案

image
  • 默认使用单向链表将元素串起来。
  • 单向链表的节点数量大于8时,将转为红黑树来存储元素。在调整容量时,如果树节点数量小于6,又会转为单向链表
  • 为什么不使用双向链表,而是单向链表
    • 每次查找都要从单向链表头节点开始遍历,首先确认链表中是否已有该元素,若没有则插入。
    • 单向链表双向链表少一个指针,可以节省内存空间。

4、哈希函数

  • 哈希表中哈希函数的实现步骤:
    • 生成key哈希值(必须是整数)。
    • 再让key哈希值跟数组的大小进行相关运算,生成一个索引值。
image
  • 为了提高效率,可以使用&与运算取代%运算(前提将数组的长度设计为2^n)。
image
  • 因为数组长度为为2^n,转换为二进制是10000100之类的值,table.length - 1的结果转换成二进制即为01111011之类的值。
  • hash_code(key)01111做与运算,比如10100&00111,结果为00101,值肯定小于00111,且最小为00000,最大为00111
  • 结论就是无论hash_code(key)有多大,当它table.length - 1,它的值都是在0table.length - 1之间。
  • 良好的哈希函数是让哈希值更加均匀的分布,减少哈希冲突次数,提升哈希表的性能

5、如何生成hash_code(key)

  • key的常见种类可能是有整数,浮点数,字符串,自定义对象。
  • 不同种类的key,哈希值的生成方式不一样,但目标是一致的:
    • 尽量让每个key的哈希值是唯一的。
    • 尽量让key的所有信息参与运算。
5.1、整数的哈希值
  • 整数的哈希值则直接为它的值,比如10的哈希值就是10
5.2、浮点数的哈希值
  • 浮点数的哈希值是将存储的二进制格式转为整数值。
  • 比如10.6在内存中是01010010101,那么将01010010101转为整数即为1093245338
5.2、LongDouble的哈希值
  • java中的hash值必须是整数,即是32位。
  • LongDouble都是64位,可以将value高32位和低32位混合计算出32位的值,然后再用value的值与这个32位值做亦或运算(相同为0,不同为1)。
  • 亦或运算之后再将64位的结果强制转换为32位。
image
image
  • 最终获取的值是橙色部分。
  • 这样即充分利用所有信息计算出了哈希值。
5.3、字符串的哈希值
  • 比如字符串jack,由j、a、c、k四个字符组成(字符的本质就是一个整数,因为可以转换成ascII码)
  • 因此jack的哈希值可以表示为j*n^3 + a*n^2 + c*n^1 + k*n^0
5.4、自定义对象的哈希值
  • 默认情况下,自定义对象的哈希值是跟内存地址有关系的。
  • 所以两个对象即使各个属性值都相同,但他们的哈希值是不同的,如果用一个字典保存这两个对象,字典内会有两个对象。
  • 如何能让字典中只保存相同对象中的一个呢?这就需要我们重写hashCode函数。
image
  • 重写的hashCode函数,我们将对象所有属性都计算进来,这样即充分利用所有信息计算出了哈希值。
  • 哈希冲突的时候,我们会调用对象的equals函数进行对象间的比较。如果两个对象相同,则覆盖,如果不同,则加入到value链表中。所以我们也需要重写equals函数。
  • 哈希值相同的两个对象,不一定相等,只能说两个对象通过hashCode算出的索引值相同。
5.5、自定义对象存储举例
  • 假设我们创建三个对象存入map中,并且p1test的哈希值相同:
image
  • 当存入p1test时,map中的结构如下:
image
  • 当存入p2时,会调用equals函数比较p1p2

,因为重写了equals函数,所以会返回true,在map中,当确认两个对象相等时,则会执行替换:

image

二、哈希表的接口设计(HashMap)

  • 用Map实现一个哈希表(HashMap)
  • 通过一个数组保存key(索引)
  • key对应的value(数据)则直接保存红黑树根节点
public class HashMap<K, V> implements Map<K, V> {
    private Node<K, V>[] table;
    protected static class Node<K, V> //声明一个节点类
    private static final int DEFAULT_CAPACITY = 1 << 4; //数组默认长度,16

    public HashMap() {
        table = new Node[DEFAULT_CAPACITY]; //建议长度为2^n
    }
}
复制代码

三、哈希表的实现(HashMap)

1、声明节点

protected static class Node<K, V> {
    int hash;
    K key;
    V value;
    boolean color = RED;
    Node<K, V> left;
    Node<K, V> right;
    Node<K, V> parent;
    public Node(K key, V value, Node<K, V> parent) {
        this.key = key;
        int hash = key == null ? 0 : key.hashCode();
        this.hash = hash ^ (hash >>> 16);
        this.value = value;
        this.parent = parent;
    }

    public boolean hasTwoChildren() {
        return left != null && right != null;
    }

    public boolean isLeftChild() {
        return parent != null && this == parent.left;
    }

    public boolean isRightChild() {
        return parent != null && this == parent.right;
    }

    public Node<K, V> sibling() {
        if (isLeftChild()) { return parent.right; }
        if (isRightChild()) { return parent.left; }
        return null;
    }

    @Override
    public String toString() {
        return "Node_" + key + "_" + value;
    }
}
复制代码

2、clean实现

  • 遍历数组,清空数组的每一个节点。
@Override
public void clear() {
    if (size == 0) return;
    size = 0;
    for (int i = 0; i < table.length; i++) {
        table[i] = null;
    }
}
复制代码

3、put实现

  • 在进行插入操作时,通过比较keykey.parent哈希值大小,来决定插入位置。
  • 如果哈希值相同,则比较equals
  • 如果equals相同,则比较类名
  • 如果类名相同(同一种类型),则查看是否实现comparable,如果实现,则直接通过该函数比较。
  • 如果相同类型,不具有可比较性,或其中一个数据为空,则比较内存地址
  • 直接比较内存地址会导致结果不稳定,应该先扫码树中是否有该值,如果没有,再比较内存地址
@Override
public V put(K key, V value) {
    resize();

    int index = index(key); // 获取key对应的索引
    Node<K, V> root = table[index]; // 取出index位置的红黑树根节点
    if (root == null) { // 如果根节点为空
        root = createNode(key, value, null);
        table[index] = root;
        size++;
        fixAfterPut(root);
        return null;
    }

    //  如果根节点不为空,添加新的节点到红黑树上面
    Node<K, V> parent = root;
    Node<K, V> node = root;
    int cmp = 0;
    K k1 = key;
    int h1 = hash(k1);
    Node<K, V> result = null;
    boolean searched = false; // 是否已经搜索过这个key
    do {
        parent = node;
        K k2 = node.key;
        int h2 = node.hash;
        // 根据哈希值来进行比较,大的放右边,小的放左边。
        if (h1 > h2) {
            cmp = 1;
        } else if (h1 < h2) {
            cmp = -1;
        } else if (Objects.equals(k1, k2)) {
            cmp = 0;
        } else if (k1 != null && k2 != null 
            && k1 instanceof Comparable
            && k1.getClass() == k2.getClass()
            && (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
        } else if (searched) { // 已经扫描了
            cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
        } else { // searched == false; 还没有扫描,然后再根据内存地址大小决定左右
        if ((node.left != null && (result = node(node.left, k1)) != null)
        || (node.right != null && (result = node(node.right, k1)) != null)) {
            // 已经存在这个key
            node = result;
            cmp = 0;
        } else { // 不存在这个key
            searched = true;
            cmp = System.identityHashCode(k1) - System.identityHashCode(k2); //根据内存地址计算出的hashcode
        }
    }

    if (cmp > 0) { // 大于
        node = node.right;
    } else if (cmp < 0) { //小于
        node = node.left;
    } else { // 相等
        V oldValue = node.value;
        node.key = key;
        node.value = value;
        node.hash = h1;
        return oldValue;
    }
} while (node != null);

// 看看插入到父节点的哪个位置
Node<K, V> newNode = createNode(key, value, parent);
if (cmp > 0) {
    parent.right = newNode;
} else {
    parent.left = newNode;
}
size++;

// 新添加节点之后的处理
fixAfterPut(newNode);
return null;
}

/**
* 根据key生成对应的索引(在桶数组中的位置)
*/
private int index(K key) {
    return hash(key) & (table.length - 1); //先求哈希值,再做位运算
}

private int hash(K key) {
    if (key == null) return 0;
    int hash = key.hashCode();
    return hash ^ (hash >>> 16); //高16位与低16位做一次运算
}
复制代码

3、get实现

  • 首先确定key,然后再寻找索引下的
@Override
public V get(K key) {
    Node<K, V> node = node(key);
    return node != null ? node.value : null;
}

private Node<K, V> node(K key) {
    //根据key生成对应的索引
    //根据索引在数组中找到根节点。
    Node<K, V> root = table[index(key)];
    return root == null ? null : node(root, key); 
}

private Node<K, V> node(Node<K, V> node, K k1) {
    int h1 = hash(k1);
    // 存储查找结果
    Node<K, V> result = null;
    int cmp = 0;
    while (node != null) {
        K k2 = node.key;
        int h2 = node.hash;
        // 先比较哈希值
        if (h1 > h2) {
            node = node.right;
        } else if (h1 < h2) {
            node = node.left;
        } else if (Objects.equals(k1, k2)) { //可比较性
            return node;
        } else if (k1 != null && k2 != null 
                && k1 instanceof Comparable
                && k1.getClass() == k2.getClass()
                && (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
                node = cmp > 0 ? node.right : node.left;
        } 
        // 走到这里,表示哈希值相等,不具备可比较性,也不 equals
        else if (node.right != null && (result = node(node.right, k1)) != null) { //先找右子树
            return result;
        } else { // 再去左边扫码
            node = node.left;
        }
    }
    return null;
}
复制代码

4、remove实现

@Override
public V remove(K key) {
    return remove(node(key));
}

protected V remove(Node<K, V> node) {
    if (node == null) return null;

    Node<K, V> willNode = node;

    size--;

    V oldValue = node.value;

    if (node.hasTwoChildren()) { // 度为2的节点
        // 找到后继节点
        Node<K, V> s = successor(node);
        // 用后继节点的值覆盖度为2的节点的值
        node.key = s.key;
        node.value = s.value;
        node.hash = s.hash;
        // 删除后继节点
        node = s;
    }

    // 删除node节点(node的度必然是1或者0)
    Node<K, V> replacement = node.left != null ? node.left : node.right;
    int index = index(node);

    if (replacement != null) { // node是度为1的节点
        // 更改parent
        replacement.parent = node.parent;
        // 更改parent的left、right的指向
        if (node.parent == null) { // node是度为1的节点并且是根节点
            table[index] = replacement;
        } else if (node == node.parent.left) {
            node.parent.left = replacement;
        } else { // node == node.parent.right
            node.parent.right = replacement;
        }
        // 删除节点之后的处理
        fixAfterRemove(replacement);
    } else if (node.parent == null) { // node是叶子节点并且是根节点
        table[index] = null;
    } else { // node是叶子节点,但不是根节点
        if (node == node.parent.left) {
            node.parent.left = null;
        } else { // node == node.parent.right
            node.parent.right = null;
        }
        // 删除节点之后的处理
        fixAfterRemove(node);
    }

    // 交给子类去处理
    afterRemove(willNode, node);
    return oldValue;
}
复制代码

5、containsValue实现

  • 检测哈希表中是否存在某值,只有遍历每一个树,使用层序遍历实现。
@Override
public boolean containsValue(V value) {
    if (size == 0) return false;
    Queue<Node<K, V>> queue = new LinkedList<>();
    for (int i = 0; i < table.length; i++) {
        if (table[i] == null) continue;

        queue.offer(table[I]);
        while (!queue.isEmpty()) {
            Node<K, V> node = queue.poll();
            if (Objects.equals(value, node.value)) return true;

            if (node.left != null) {
                queue.offer(node.left);
            }
            if (node.right != null) {
                queue.offer(node.right);
            }
        }
    }
    return false;
}
复制代码

6、扩容

  • 当哈希表的table数组中添加元素过多后,哈希冲突概率增大,效率变低。所以要根据装填因子判断是否需要扩容。
  • 装填因子:节点总数量 / 哈希表桶数组长度,也叫做负载因子
  • 在JDK1.8的HashMap中,如果装填因子超过0.75,就扩容为原来的2倍。
  • 如果装填因子超过0.75,就将数组长度扩大为原来的两倍,并将原来的数据迁移进新数组。
  • 扩容之后,原来数据算出的节点值就有可能不一样了(保持不变index = index + 旧容量),因为节点的计算需要涉及到table.length
private void resize() {
    // 装填因子 <= 0.75
    if (size / table.length <= DEFAULT_LOAD_FACTOR) return;

    Node<K, V>[] oldTable = table;//保留旧的数组
    table = new Node[oldTable.length << 1];//将数组长度变为原来的2倍

    //层序遍历
    Queue<Node<K, V>> queue = new LinkedList<>();
    for (int i = 0; i < oldTable.length; i++) {
        if (oldTable[i] == null) continue;

        queue.offer(oldTable[I]);
        while (!queue.isEmpty()) {
            Node<K, V> node = queue.poll();
            if (node.left != null) {
                queue.offer(node.left);
            }
            if (node.right != null) {
                queue.offer(node.right);
            }

            // 挪动代码得放到最后面
            moveNode(node);
        }
    }
}

private void moveNode(Node<K, V> newNode) {
    // 重置节点属性
    newNode.parent = null;
    newNode.left = null;
    newNode.right = null;
    newNode.color = RED;

    int index = index(newNode);
    // 取出index位置的红黑树根节点
    Node<K, V> root = table[index];
    if (root == null) {
        root = newNode;
        table[index] = root;
        fixAfterPut(root);
        return;
    }

    // 添加新的节点到红黑树上面
    Node<K, V> parent = root;
    Node<K, V> node = root;
    int cmp = 0;
    K k1 = newNode.key;
    int h1 = newNode.hash;
    do {
        parent = node;
        K k2 = node.key;
        int h2 = node.hash;
        if (h1 > h2) {
            cmp = 1;
        } else if (h1 < h2) {
            cmp = -1; // 不用再比较equals,因为不会存在重复数据。
        } else if (k1 != null && k2 != null 
                && k1 instanceof Comparable
                && k1.getClass() == k2.getClass()
                && (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
        } else { // 搜索也不需要,原因同上。
            cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
        }

        if (cmp > 0) {
            node = node.right;
        } else if (cmp < 0) {
            node = node.left;
        }
    } while (node != null);

    // 看看插入到父节点的哪个位置
    newNode.parent = parent;
    if (cmp > 0) {
        parent.right = newNode;
    } else {
        parent.left = newNode;
    }

    // 新添加节点之后的处理
    fixAfterPut(newNode);
}
复制代码

7、equals的优化

  • 我们在添加元素时,要谨防因equals的函数实现有问题,导致a.equals(b)b.equals(a)的结果不一致。
  • 所以在实现equals函数时,要遵循以下条件:
image

四、TreeMap VS HashMap

  • TreeMap增删改查都是O(logn)级别,而哈希表是O(1)级别。
  • 当元素具备可比较性且要求升序遍历时,使用TreeMap。当对遍历没有要求时,选择HashMap

五、LinkedHashMap

  • 在HashMap的基础上维护元素的添加顺序,使得遍历的结果是遵循添加顺序的。
  • 假设添加顺序是37,21,31,41,97,95,52,42,83
image

六、LinkedHashMap的接口实现

  • 新增firstlast两个属性。
public class LinkedHashMap<K, V> extends HashMap<K, V> {
    private LinkedNode<K, V> first;
    private LinkedNode<K, V> last;
}
复制代码
  • 给Node增加前驱和后继两个指针。
private static class LinkedNode<K, V> extends Node<K, V> {
    LinkedNode<K, V> prev;
    LinkedNode<K, V> next;
    public LinkedNode(K key, V value, Node<K, V> parent) {
        super(key, value, parent);
    }
}
复制代码
  • 添加一个构造节点的函数。
@Override
protected Node<K, V> createNode(K key, V value, Node<K, V> parent) {
    LinkedNode node = new LinkedNode(key, value, parent);
    if (first == null) { //没有头节点
        first = last = node;
    } else { //有头节点
        last.next = node;
        node.prev = last;
        last = node;
    }
    return node;
}
复制代码
  • clear函数需要清空first和last指针。
@Override
public void clear() {
    super.clear();
    first = null;
    last = null;
}
复制代码
  • 遍历函数
@Override
public void traversal(Visitor<K, V> visitor) {
    if (visitor == null) return;
    LinkedNode<K, V> node = first;
    while (node != null) {
        if (visitor.visit(node.key, node.value)) return;
        node = node.next;
    }
}
复制代码
  • 删除函数,不仅仅需要删除数据,还需要修改指针指向。
  • 删除度为2的节点时,需要注意更换node前驱/后继节点的链接位置。
  • 例如,删除31
image
@Override
protected void afterRemove(Node<K, V> willNode, Node<K, V> removedNode) {
    LinkedNode<K, V> node1 = (LinkedNode<K, V>) willNode;
    LinkedNode<K, V> node2 = (LinkedNode<K, V>) removedNode;

    if (node1 != node2) {
        // 交换linkedWillNode和linkedRemovedNode在链表中的位置
        // 交换prev
        LinkedNode<K, V> tmp = node1.prev;
        node1.prev = node2.prev;
        node2.prev = tmp;
        if (node1.prev == null) {
            first = node1;
        } else {
            node1.prev.next = node1;
        }
        if (node2.prev == null) {
            first = node2;
        } else {
            node2.prev.next = node2;
        }

        // 交换next
        tmp = node1.next;
        node1.next = node2.next;
        node2.next = tmp;
        if (node1.next == null) {
            last = node1;
        } else {
            node1.next.prev = node1;
        }
        if (node2.next == null) {
            last = node2;
        } else {
            node2.next.prev = node2;
        }
    }

    LinkedNode<K, V> prev = node2.prev;
    LinkedNode<K, V> next = node2.next;
    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
    }

    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
    }
}
复制代码
  • 核心就是交换。
image
  • 遍历节点
@Override
public boolean containsValue(V value) {
    LinkedNode<K, V> node = first;
    while (node != null) {
        if (Objects.equals(value, node.value)) return true;
        node = node.next;
    }
    return false;
}
复制代码
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容