目录

题目描述

706. 设计哈希映射

题意分析

要求不借助现成的映射容器,自己实现一个键值都是非负整数的映射,支持三个操作:put(key, value) 插入或覆盖、get(key) 查询(不存在返回 -1)、remove(key) 删除。

约束里有两个信号值得注意。第一,键的取值范围是 $[0, 10^6]$,是有限且非负的,这意味着「开一个长度 $10^6 + 1$ 的数组直接下标寻址」在内存上勉强可行,但那是用空间换掉了题目真正想考的东西;题目名字里的「设计哈希映射」摆明了要看的是把大值域压缩到小数组之后如何处理冲突。第二,调用次数最多 $10^4$,实际存活的键远少于值域大小,说明桶数组只需要开到几百上千的量级就足够摊薄冲突。

边界要点集中在三处:put 一个已经存在的键必须覆盖旧值而不是再插一份,否则 get 会读到过期数据;get 不存在的键要返回 -1,而不能返回 0 或抛异常;remove 一个不存在的键必须安静返回,删除的又恰好是链表头节点时,还要记得把桶数组里的指针一起改掉。

解法:数组桶 + 链地址法

核心思路

直接用 key 作为数组下标虽然能做到常数时间,但空间取决于整个键值域。更通用的做法是用 key % BASE 把键映射到较小的桶数组。映射会发生冲突,例如 1 和 770 在 BASE = 769 时都落入 1 号桶,因此桶内还必须保留原始键。

这里用链地址法处理冲突:每个桶是一条单链表,节点保存 keyvaluenextputgetremove 都先定位桶,再沿链比较原始键。新键头插即可;已有键必须更新原节点,不能再插入一个副本。

数据结构的不变量是:每个已存键只出现一次,并且只位于 buckets[key % BASE] 对应的链中。put 的「先查重、后头插」维持唯一性;get 扫完整条链仍未命中即可判定不存在;remove 摘掉匹配节点后即可结束。

固定取素数 769 是基于本题最多约一万次操作的简单折中,能减少常见周期数据带来的聚集。生产级哈希表还会根据装载因子扩容和重新散列,但本题不需要额外实现。

解题步骤

  1. 构造长度为 BASE 的桶数组,每个桶初始为空;题目保证键非负,所以 key % BASE 可直接作为下标。
  2. put:遍历目标链,命中键就更新值并返回;未命中则把新节点插到链头。
  3. get:遍历目标链,命中返回值;走到链尾仍未命中则返回 -1。
  4. remove:在链头前接一个虚拟节点,用前驱指针摘掉目标节点,再把真实桶头更新为 dummy.next

冲突用例最能验证实现:先执行 put(1, 10)put(770, 20),两个键同在 1 号桶但仍能分别取到 10 和 20;删除 770 后,键 1 仍必须保留。

代码实现

class MyHashMap {
    private static final int BASE = 769;
    private Node[] buckets;

    public MyHashMap() {
        buckets = new Node[BASE];
    }

    public void put(int key, int value) {
        int index = hash(key);
        Node cur = buckets[index];
        while (cur != null) {
            if (cur.key == key) {
                cur.value = value;
                return;
            }
            cur = cur.next;
        }
        buckets[index] = new Node(key, value, buckets[index]);
    }

    public int get(int key) {
        Node cur = buckets[hash(key)];
        while (cur != null) {
            if (cur.key == key) {
                return cur.value;
            }
            cur = cur.next;
        }
        return -1;
    }

    public void remove(int key) {
        int index = hash(key);
        Node dummy = new Node(-1, -1, buckets[index]);
        Node pre = dummy;
        Node cur = buckets[index];
        while (cur != null) {
            if (cur.key == key) {
                pre.next = cur.next;
                buckets[index] = dummy.next;
                return;
            }
            pre = cur;
            cur = cur.next;
        }
    }

    private int hash(int key) {
        return key % BASE;
    }

    private static class Node {
        int key;
        int value;
        Node next;

        Node(int key, int value, Node next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
}
type MyHashMap struct {
    buckets []*HashNode
}

type HashNode struct {
    key   int
    value int
    next  *HashNode
}

func Constructor() MyHashMap {
    return MyHashMap{buckets: make([]*HashNode, 769)}
}

func (this *MyHashMap) Put(key int, value int) {
    index := key % len(this.buckets)
    for cur := this.buckets[index]; cur != nil; cur = cur.next {
        if cur.key == key {
            cur.value = value
            return
        }
    }
    this.buckets[index] = &HashNode{key: key, value: value, next: this.buckets[index]}
}

func (this *MyHashMap) Get(key int) int {
    index := key % len(this.buckets)
    for cur := this.buckets[index]; cur != nil; cur = cur.next {
        if cur.key == key {
            return cur.value
        }
    }
    return -1
}

func (this *MyHashMap) Remove(key int) {
    index := key % len(this.buckets)
    dummy := &HashNode{next: this.buckets[index]}
    pre := dummy
    for cur := this.buckets[index]; cur != nil; cur = cur.next {
        if cur.key == key {
            pre.next = cur.next
            this.buckets[index] = dummy.next
            return
        }
        pre = cur
    }
}

复杂度分析

  • 时间复杂度:设当前键数为 n、桶数为 b。分布均匀时每次操作期望为 $O(1 + n / b)$;所有键冲突时最坏为 $O(n)$。
  • 空间复杂度:$O(n + b)$,分别用于链表节点和桶数组。

关键点总结

  • 哈希函数只负责定位桶;节点中的原始 key 才能在发生冲突后确认身份。
  • put 必须先查重再插入,维持「一个键只对应一个节点」的不变量。
  • 头插让新键插入为 $O(1)$;虚拟头节点让删除链首与链中节点共用一套逻辑。
  • 固定桶数足以满足题目约束,但复杂度不是无条件 $O(1)$;极端冲突时仍会退化为链表。
  • 面试追问扩容时,应说明:装载因子过高后创建更大的桶数组,并按新桶数重新计算每个键的位置。

易错点总结

  • put 不查重会产生重复节点:更新后再删除一次,旧值可能重新暴露。
  • 节点只存值、不存键时,同一桶中的冲突键无法区分。
  • 删除链首后若没有同步 buckets[index],桶仍指向已删除节点;虚拟头节点能统一处理。
  • get 未命中必须返回题目规定的 -1,不能用 0,因为 0 本身是合法值。
  • 当前哈希函数依赖「键非负」约束;若键域允许负数,Java 应使用 Math.floorMod(key, BASE),避免负下标。

相似题目

题目 难度 考察点
705. 设计哈希集合 简单 只存键的链地址法
146. LRU 缓存 中等 哈希表配双向链表
208. 实现 Trie (前缀树) 中等 多叉树按字符寻址
380. O(1) 时间插入、删除和获取随机元素 中等 哈希表配变长数组
622. 设计循环队列 中等 定长数组的环形下标
432. 全 O(1) 的数据结构 困难 哈希表配按计数分桶的链表