手写一个 mini HashMap,彻底搞懂哈希表原理

HashMap 的核心就三件事:哈希函数把任意 key 映射成数组下标、冲突处理保证不同 key 不互相覆盖、动态扩容在元素增多时维持 O(1) 的查询效率。Java 的 HashMap 用的是链地址法 + 红黑树 + 0.75 负载因子扩容,本文用 JS 从零实现一个 mini 版本,把这三件事逐个拆开看。

哈希表到底在干嘛

说白了,哈希表就是一个"数组 + 哈希函数"的组合。你想存一组键值对,但数组只能用数字下标访问。哈希函数干的事情,就是把任意类型的 key 转成一个数组下标。

比如 key 是 "name",哈希函数算出 hash("name") = 3,那你就把值塞到 array[3]。取的时候同理,算一遍哈希,直接定位到那个位置,O(1) 搞定。

javascript 复制代码
// 最朴素的哈希表雏形
class TinyMap {
  constructor() {
    this.buckets = new Array(8); // 底层数组,初始容量 8
  }

  // 哈希函数:把 key 转成合法下标
  hash(key) {
    let h = 0;
    const str = String(key); // 统一转字符串
    for (let i = 0; i < str.length; i++) {
      h = (h * 31 + str.charCodeAt(i)) | 0; // 经典多项式哈希
    }
    return Math.abs(h) % this.buckets.length; // 取模映射到数组范围
  }

  set(key, value) {
    const index = this.hash(key); // 算出下标
    this.buckets[index] = value;  // 直接存,先不管冲突
  }

  get(key) {
    const index = this.hash(key);
    return this.buckets[index];   // 直接取
  }
}

const m = new TinyMap();
m.set("name", "张三");  // hash("name") -> 某个下标
m.set("age", 25);
console.log(m.get("name")); // "张三"

看着挺好使对吧?但有个致命问题------两个不同的 key 算出了同一个下标,后存的会把前存的覆盖掉。这就是哈希冲突。

哈希冲突:绕不开的核心问题

两个 key 哈希到同一个位置,太正常了。数组就 8 个槽,你存 100 个键值对,必撞。学术界管这叫鸽巢原理,工程上你得想办法解决。

主流方案有两种:链地址法开放寻址法。Java 的 HashMap 用的是链地址法,Python 的 dict 用的是开放寻址法。我把两种都实现一下,你对比着看。

链地址法:每个槽挂一条链表

思路很直白------冲突了?没关系,同一个位置挂一串,用链表串起来。查找的时候遍历链表,逐个比 key。

javascript 复制代码
// 链地址法实现
class ChainMap {
  constructor(size = 8) {
    this.buckets = new Array(size);
    for (let i = 0; i < size; i++) {
      this.buckets[i] = []; // 每个槽是一个数组(当链表用)
    }
    this.size = 0; // 记录元素个数,扩容要用
  }

  hash(key) {
    let h = 0;
    const str = String(key);
    for (let i = 0; i < str.length; i++) {
      h = (h * 31 + str.charCodeAt(i)) | 0;
    }
    return Math.abs(h) % this.buckets.length;
  }

  set(key, value) {
    const index = this.hash(key);
    const bucket = this.buckets[index];

    // 先找有没有相同 key,有就覆盖
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        bucket[i][1] = value; // 更新旧值
        return;
      }
    }

    // 没有就追加到链表尾部
    bucket.push([key, value]); // [key, value] 存一对
    this.size++;

    // 负载因子超过 0.75 就扩容
    if (this.size / this.buckets.length > 0.75) {
      this.resize();
    }
  }

  get(key) {
    const index = this.hash(key);
    const bucket = this.buckets[index];
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        return bucket[i][1]; // 找到了,返回值
      }
    }
    return undefined; // 没找到
  }

  resize() {
    const oldBuckets = this.buckets;
    this.buckets = new Array(oldBuckets.length * 2); // 容量翻倍
    for (let i = 0; i < this.buckets.length; i++) {
      this.buckets[i] = []; // 初始化新数组
    }
    this.size = 0;

    // 把所有旧数据重新哈希到新数组
    for (const bucket of oldBuckets) {
      for (const [key, value] of bucket) {
        this.set(key, value); // 重新插入,hash 会自动落到新位置
      }
    }
  }
}

Java HashMap 在 JDK 8 之后还有个优化:链表长度超过 8 且数组长度超过 64 时,链表转红黑树,查询从 O(n) 降到 O(log n)。我们用 JS 写 mini 版本就不搞这么复杂了,但思路你得知道。

开放寻址法:冲突了就往下找

另一种思路------数组里不放链表。冲突了?沿着某个规则继续往后找空位,找到就塞进去。查找也一样,算出下标后发现不是你要的 key,就按同样的规则继续找。

javascript 复制代码
// 开放寻址法(线性探测)
class OpenMap {
  constructor(size = 8) {
    this.buckets = new Array(size);
    this.size = 0;
  }

  hash(key) {
    let h = 0;
    const str = String(key);
    for (let i = 0; i < str.length; i++) {
      h = (h * 31 + str.charCodeAt(i)) | 0;
    }
    return Math.abs(h) % this.buckets.length;
  }

  set(key, value) {
    let index = this.hash(key);
    while (this.buckets[index] !== undefined) {
      // 槽被占了,看看是不是同一个 key
      if (this.buckets[index][0] === key) {
        this.buckets[index][1] = value; // 覆盖旧值
        return;
      }
      index = (index + 1) % this.buckets.length; // 线性探测:往下挪一格
    }
    this.buckets[index] = [key, value]; // 找到空位,塞进去
    this.size++;
    if (this.size / this.buckets.length > 0.75) {
      this.resize();
    }
  }

  get(key) {
    let index = this.hash(key);
    while (this.buckets[index] !== undefined) {
      if (this.buckets[index][0] === key) {
        return this.buckets[index][1]; // 找到了
      }
      index = (index + 1) % this.buckets.length; // 继续探测
    }
    return undefined; // 碰到空位还没找到,说明不存在
  }

  resize() {
    const old = this.buckets;
    this.buckets = new Array(old.length * 2);
    this.size = 0;
    for (const entry of old) {
      if (entry !== undefined) {
        this.set(entry[0], entry[1]); // 重新哈希
      }
    }
  }
}

两种方案各有优劣,放一张表你直观感受下:

对比维度 链地址法 开放寻址法(线性探测)
冲突处理 每个槽挂链表 冲突后按规则探测下一个空位
内存占用 需要额外存链表节点指针 全在数组里,没有额外开销
删除操作 直接删链表节点,简单 需要特殊标记(tombstone),麻烦
缓存友好 链表节点分散,缓存命中率低 数据连续,缓存命中率高
典型代表 Java HashMap Python dict
最坏情况 所有 key 哈希到同一位置,退化成链表 聚集效应导致探测链越来越长

实际项目里选哪种,得看你的场景。数据量大、频繁删除,链地址法稳;内存敏感、缓存敏感,开放寻址法更合适。

扩容:什么时候扩,怎么扩

前面代码里你可能注意到了 resize() 方法。这个扩容机制是 HashMap 性能的关键。

核心概念叫负载因子 (load factor),就是 元素个数 / 数组容量。Java HashMap 默认负载因子阈值是 0.75------意思是数组用到四分之三的时候,就该扩了。扩容就是容量翻倍,然后把所有数据重新哈希到新数组。

为什么是 0.75?这是个经验值。太低浪费内存,太高冲突概率飙升。0.75 是时间和空间的折中点。

javascript 复制代码
// 扩容过程的单独拆解,看得更清楚
function resizeDemo() {
  const oldCapacity = 8;
  const newCapacity = oldCapacity * 2; // 16,翻倍
  const oldBuckets = [[], [], [], [], [["a", 1]], [["b", 2]], [], []];

  const newBuckets = new Array(newCapacity);
  for (let i = 0; i < newCapacity; i++) {
    newBuckets[i] = []; // 初始化新数组
  }

  // 关键:重新哈希!因为取模的分母变了
  // 原来 % 8,现在 % 16,所有元素的下标都可能变
  for (const bucket of oldBuckets) {
    for (const [key, value] of bucket) {
      const newIndex = hash(key) % newCapacity; // 注意这里用新容量
      newBuckets[newIndex].push([key, value]);
    }
  }
  return newBuckets;
}

扩容的代价不小------要遍历所有元素重新哈希,时间复杂度 O(n)。所以 Java HashMap 有个优化:扩容时 2 的幂次特性让元素要么留在原位,要么挪到 原位 + 旧容量 的位置,不用重新算哈希。我们 mini 版本做了简化,直接重新算。

还有一个容易忽略的点:如果你提前知道要存多少数据,初始化时直接给够容量 ,能避免运行中多次扩容。Java 里 new HashMap<>(128) 就是这个意思。

完整实现:一个能跑的 mini HashMap

把上面的东西合在一起,这是一个功能完整的 mini HashMap:

javascript 复制代码
class MiniHashMap {
  constructor(initialCapacity = 8) {
    this.capacity = initialCapacity;  // 当前数组容量
    this.size = 0;                    // 已存元素个数
    this.loadFactor = 0.75;           // 负载因子阈值
    this.buckets = new Array(this.capacity);
    for (let i = 0; i < this.capacity; i++) {
      this.buckets[i] = [];           // 链地址法:每个槽一个数组
    }
  }

  // 哈希函数:DJB2 变体,比简单累加分布更均匀
  hash(key) {
    let h = 5381;                     // DJB2 的初始种子值
    const str = String(key);
    for (let i = 0; i < str.length; i++) {
      h = ((h << 5) + h + str.charCodeAt(i)) | 0; // h*33 + charCode
    }
    return Math.abs(h) % this.capacity;
  }

  set(key, value) {
    const index = this.hash(key);
    const bucket = this.buckets[index];

    // 遍历链表,看 key 是否已存在
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        bucket[i][1] = value;        // key 存在,更新 value
        return;
      }
    }

    // key 不存在,追加到链表
    bucket.push([key, value]);
    this.size++;

    // 检查是否需要扩容
    if (this.size / this.capacity > this.loadFactor) {
      this._resize();
    }
  }

  get(key) {
    const index = this.hash(key);
    const bucket = this.buckets[index];
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        return bucket[i][1];         // 找到返回 value
      }
    }
    return undefined;                 // 没找到
  }

  delete(key) {
    const index = this.hash(key);
    const bucket = this.buckets[index];
    for (let i = 0; i < bucket.length; i++) {
      if (bucket[i][0] === key) {
        bucket.splice(i, 1);          // 从链表中删除该节点
        this.size--;
        return true;
      }
    }
    return false;                     // 没找到要删的
  }

  has(key) {
    return this.get(key) !== undefined; // 判断 key 是否存在
  }

  _resize() {
    const oldBuckets = this.buckets;
    this.capacity *= 2;               // 容量翻倍
    this.buckets = new Array(this.capacity);
    for (let i = 0; i < this.capacity; i++) {
      this.buckets[i] = [];           // 初始化新桶
    }
    this.size = 0;                    // 重置计数

    // 重新哈希所有旧数据
    for (const bucket of oldBuckets) {
      for (const [key, value] of bucket) {
        this.set(key, value);         // 复用 set,自动落到新位置
      }
    }
  }
}

// 跑一下试试
const map = new MiniHashMap();
map.set("name", "张三");
map.set("age", 25);
map.set("city", "杭州");
console.log(map.get("name"));  // "张三"
console.log(map.has("age"));   // true
map.delete("city");
console.log(map.has("city"));  // false
console.log(map.size);         // 2

跑起来没问题。这个实现包含了哈希表最核心的三个机制:哈希函数、链地址法解决冲突、负载因子触发扩容。

最后

手写一遍 HashMap,比看十篇原理分析都管用。几个关键 takeaway:

  • 哈希表的本质就是 数组 + 哈希函数,哈希函数把 key 映射成数组下标
  • 哈希冲突避不开,链地址法和开放寻址法是两大主流方案
  • 负载因子 0.75 是时间和空间的平衡点,触发扩容时所有数据要重新哈希
  • 扩容代价是 O(n),能避免就避免,提前预估容量是好习惯
  • Java HashMap 的链表转红黑树优化(阈值 8)是工程上的极致打磨
  • 哈希函数的设计直接影响冲突概率,DJB2 比简单累加好用得多

你面试的时候被问过手写 HashMap 吗?欢迎评论区聊聊你的经历。

相关推荐
宣宣猪的小花园.1 小时前
【机器学习】过拟合与泛化:模型为什么会“刷题很强、实战失灵”
人工智能·算法·机器学习
INGNIGHT1 小时前
624.数组列表中的最大距离(maximum)
算法·散列表
Niuguangshuo1 小时前
论文解读:w2v-BERT,把 wav2vec 2.0 和 BERT 合成一根管子的语音 SSL
算法·音视频·语音识别
科技小E1 小时前
国标视频分析平台EasyGBS×自动化AI算法训练服务器DLTM,把通用AI炼成你的现场AI
算法·自动化·音视频
zander2582 小时前
LeetCode 15. 三数之和
算法
AiNightVision2 小时前
NMC存算一体与AI ISP
人工智能·算法·车载系统·自动驾驶·无人机·视频·智能硬件
不穿鞋的懒羊羊2 小时前
高精度算法——加、减、乘、除
算法
liliangcsdn2 小时前
基本面因子计算的示例分析
算法
Lyyaoo.2 小时前
【动态规划】【待更新】
java·数据结构·算法