映射型数据结构
{Back to Index}

Table of Contents

1 HashMap

1.1 JDK HashMap 实现方案

JDK 的哈希表是使用 单向链表和红黑树 来实现的。

默认使用单向链表将同一个 中的元素连接起来。

在添加元素时,可能会由单向链表 转为红黑树 来存储元素, 转换的条件是当桶的数量 大于等于 64 且单向链表的节点数量 大于等于 8

当红黑树节点数量减少到一定程度(\(\le 6\)),又会转为单向链表形式。

1.2 \(hashcode\) 和 \(equals\)

使用自定义对象作为 key ,需要 同时 重写 \(hashCode\) 和 \(equals\) 方法。

\(equals\) 方法作用是判断 2 个 key 是否为 同一个 , \(hashCode\) 方法是为 key 选择桶的位置, 必须 保证 \(equals\) 为 true 的 2 个 key 的哈希值是 一样 的,反过来,哈希值相等的 key ,不一定相同。

如果不重写 \(hashCode\) 而 只重写 \(equals\) 会导致 2 个 \(equals\) 为 true 的 key 同时存在于哈希表中。

1.3 插入操作(put)

HashMap 并没有强制要求键值需要具备可比较性 , 对于 \(put(key, value)\) 操作,并假设桶内存放的是已是红黑树节点,则确定新插入节点最终存放位置的逻辑大致为:

  • 先根据 key 的哈希值大小进行二分查找
  • 如哈希值相同则通过 \(equals\) 方法检查是否为相同的 key
  • 如果不是相同的 key ,则检查 key 是否具备可比较行(是否实现 Comparable 接口),并根据 \(compareTo\) 方法的返回值继续二分搜索。
    这里 不考虑 \(compareTo\) 返回值为 0 的情况,因为 \(a.compareTo(b) == 0\) 并不意味 \(a.equals(b) == true\) 。
  • 如仍无法比较大小,则 必须 对左右子树进行 遍历 以确定是否已存在该节点
  • 如子树中不存在该节点,则以内存地址大小 (\(System.identityHash()\)) 为比较条件来确定最终需要插入节点的位置

2 LinkedHashMap

在 HashMap 的基础上 维护元素的添加顺序 ,使得遍历结果遵从添加顺序。

linkedhashmap.png

Figure 1: 通过链表结构维护添加顺序

2.1 添加节点时维护顺序

添加节点时维护添加顺序比较直观,只需要更新尾节点的指针即可。

2.2 删除节点时维护顺序

2.2.1 可以直接删除的情况

如果节点 可以直接删除 ,比如红色节点,则更新前后节点相应的指针即可,如:

linkedhashmap_delete1.png

Figure 2: 被删节点为红色节点,直接删除,并更新链表指针

2.2.2 删除的是前驱/后继节点的情况

如果删除的是 度为 2 的节点,因为此时真正被删除的是前驱或者后继节点,因此需要特殊处理,即 更换节点与前驱或后继节点在链表中的位置

linkedhashmap_delete2.png

Figure 3: 需要删除的节点 并非真正删除的节点 的情况

3 跳表

通常,有序链表的搜索,添加,删除操作的平均时间复杂度为 \(O(n)\) ,跳表可以将这些操作的复杂度优化到 \(O(logn)\) 。

由于跳表的实现与维护相对简单,因此 跳表设计的初衷是为了取代平衡树

跳表通常用于存放键值对,作用和 TreeMap 类似。

跳表的最底层可以看成是 原始数据 ,而其他层可以看作 索引

Redis 的 SortedSet 底层是使用跳表实现的。

3.1 搜索操作

  • 顶层 链表的首元素开始,从左往右搜索,直至找到一个大于或等于目标的元素,或者到达当前层链表的尾部
  • 如果该元素等于目标元素,则表明该元素已被找到
  • 如果该元素大于目标元素或已到达链表的尾部,则退回到当前层的前一个元素,然后转入下一层进行搜索

skiplist.png

Figure 4: 搜索 key==19 示意图

搜索操作代码示例:

public V get(@NonNull K key) {
    Node<K, V> node = head;
    for (int i = level - 1; i >= 0; i--) { // 从顶层到底层,遍历头节点的 nexts 指针数组
        int cmp = -1;
        while (node.nexts[i] != null &&
               (cmp = compare(node.nexts[i].key, key)) < 0) {
            node = node.nexts[i];
        }
        // 到这里说明 node.nexts[i].key >= key
        if (cmp == 0) return node.nexts[i].value;
    }
    return null;
}

3.2 添加操作

3.2.1 确定节点层数

每个节点的层数是 随机确定 的,通过数学统计已经证明了此种做法依然可以保证预期的时间复杂度。 😲

Redis 中计算层数的思路也是类似的:

private int randomLevel() {
    int level = 1;
    while (Math.random() < P && level < MAX_LEVEL) level++;
    return level;
}

跳表中元素的平均层数是 \(\frac{1}{1-p}\) ,因此当 \(p = \frac{1}{2}\) 时,平均层数是 2 ; 当 \(p = \frac{1}{4}\) 时,平均层数是 1.33 ,层数即为 nexts 数组长度。

考虑到二叉树每个节点要保存父子节点 3 个指针,因此跳表相比二叉树要 节省内存

3.2.2 查找添加位置

添加操作的本质是 查找 需要被添加的位置,并更新 前驱 节点(即 \(6, 9, 12\))的指针和指向 后继 节点(即 \(19, 21\))的指针。

添加操作的 难点 在于如何确定前驱节点对应层数的 next 指针,即图中待插入节点前面彩色的部分。 彩色指针的特点为它们指向的节点必定 \(\ge\) 待添加节点或指向 null

skiplist_add.png

Figure 5: 添加一个节点(彩色部分是 需要更新的指针)

public V put(@NonNull K key, V value) {
    Node<K, V> node = head;
    Node[] prevs = new Node[level]; // 记录每一层对应的前驱节点
    for (int i = level - 1; i >= 0; i--) { // 从顶层到底层,遍历头节点的 nexts 指针数组
        int cmp = -1;
        while (node.nexts[i] != null &&
               (cmp = compare(node.nexts[i].key, key)) < 0) {
            node = node.nexts[i];
        }
        if (cmp == 0) {  // already exists, just update value
            Node<K, V> old = node.nexts[i];
            old.value = value;
            return old.value;
        }
        prevs[i] = node;
    }
    // 到这里说明需要添加一个全新的节点
    int newNodeLevel = randomLevel();
    Node<K, V> newNode = new Node<>(key, value, newNodeLevel);
    for (int i = 0; i < newNodeLevel; i++) {
        if (i >= level) { // 新节点层数高于跳表有效层数
            head.nexts[i] = newNode; // 直接将首节点对应层数中的 next 指针指向新节点即可
            continue;
        }
        newNode.nexts[i] = prevs[i].nexts[i];
        prevs[i].nexts[i] = newNode;
    }
    level = Math.max(level, newNodeLevel);
    size++;
    return null;
}

3.3 删除操作

skiplist_delete.png

Figure 6: 删除节点时需要将前驱节点的指针更新为后继节点

public V remove(@NonNull K key) {
    Node<K, V> node = head;
    Node[] prevs = new Node[level]; // 记录每一层对应的前驱节点
    boolean exist = false;
    for (int i = level - 1; i >= 0; i--) { // 从顶层到底层,遍历头节点的 nexts 指针数组
        int cmp = -1;
        while (node.nexts[i] != null &&
               (cmp = compare(node.nexts[i].key, key)) < 0) {
            node = node.nexts[i];
        }
        // 就算找到要删除的节点,遍历也要继续,因为要找到所有的前驱节点
        // 这里要做的只是记录下被删节点是否存在
        if (cmp == 0) exist = true;
        prevs[i] = node;
    }
    if (!exist) return null;
    Node<K, V> nodeToRemove = node.nexts[0]; // node.nexts[0] 必然是被删节点

    for (int i = 0; i < nodeToRemove.nexts.length; i++) {
        prevs[i].nexts[i] = nodeToRemove.nexts[i];
    }
    size++;
    // 更新跳表有效层数
    for (int i = level; i >= 0; i--) {
        if (head.nexts[i - 1] != null) {
            level = i;
            break;
        }
    }
    return nodeToRemove.value;
}

Author: Hao Ruan (ruanhao1116@gmail.com)

Created: 2021-02-27 Sat 18:03

Updated: 2021-08-17 Tue 11:23

Emacs 27.1 (Org mode 9.3)