云计算百科
云计算领域专业知识百科平台

十二.散列表

一、基础概念类

散列表(Hash Table,也叫哈希表),核心思想是用空间换时间:通过一个哈希函数把 key 映射成数组下标,直接按下标访问元素,把查找复杂度从 O (n) 降到平均 O (1)。

核心术语

  • 哈希函数:把任意长度的 key,转换成固定长度的数组下标的函数,记为 hash(key) = index。
  • 哈希冲突:两个不同的 key,经过哈希函数计算得到相同的下标,就是冲突,必须有解决机制。
  • 负载因子(Load Factor):负载因子 = 元素个数 / 数组长度,衡量散列表的拥挤程度;负载因子越大,冲突概率越高,性能越差。
  • 扩容(Rehash):负载因子超过阈值时,申请更大的新数组,把所有元素重新哈希搬过去,保证性能。

提问:好的哈希函数应该满足什么条件?

回答:

四个核心要求:

  • 计算快:哈希函数本身不能太复杂,否则计算开销抵消了查找收益。
  • 分布均匀:尽可能把 key 均匀分布到整个数组上,减少冲突概率。
  • 确定性:同一个 key,每次计算的结果必须完全一致,不能变。
  • 雪崩效应:key 微小的变化,会导致哈希值巨大差异,避免相似的 key 集中在一块产生冲突。
  • 工业界常用的哈希函数有 MurmurHash、CityHash、XXHash,都是高性能且分布均匀的代表。

    提问:常见的哈希冲突解决方法有哪些?各自有什么优劣?

    回答:

    主流有两大类:

    方法原理优势劣势代表实现
    链地址法 每个数组位置挂一条链表,冲突的元素都放在同一条链上 简单直观,增删方便,扩容简单,冲突处理成本稳定 链表有指针开销,内存不连续,缓存命中率低 Java HashMap、C++ unordered_map
    开放寻址法 冲突了就按一定规则找下一个空位,直到找到为止 全部存在数组里,内存连续,缓存友好,无指针开销 删除麻烦(要标记删除),冲突累加效应明显,负载因子不能太高 Python 字典、Go map、ThreadLocalMap

    开放寻址法又分三种探测方式:

    • 线性探测:冲突了就往后找下一个空位,简单但容易产生 “堆积”,冲突越来越多
    • 二次探测:步长是平方级增长,缓解堆积问题
    • 双重哈希:用第二个哈希函数算步长,分布最均匀,计算略慢

    提问:为什么散列表要扩容?什么时候扩容?

    回答:

    散列表的性能和负载因子强相关:元素越多、负载因子越大,冲突概率越高,查找效率就会从 O (1) 退化。扩容就是为了把负载因子降下来,维持 O (1) 的平均性能。

    扩容时机由负载因子阈值决定:

    • 链地址法:阈值一般在 0.75 左右(Java HashMap 就是 0.75),平衡空间和冲突率。
    • 开放寻址法:阈值更低,一般在 0.6~0.7,因为开放寻址对负载因子更敏感,太高了冲突会急剧增加。

    扩容过程:申请一个原大小 2 倍的新数组,遍历旧数组所有元素,重新计算哈希值放到新数组里,最后替换掉旧数组。

    二、核心操作代码

    1. 链地址法实现散列表

    #include <vector>
    #include <list>
    #include <string>

    using namespace std;

    class HashTable {
    private:
    vector<list<pair<string, int>>> table; // 数组每个位置是一条链表
    int size;// 元素个数
    int capacity; // 数组容量
    double loadFactorThreshold; // 负载因子阈值

    // 哈希函数:字符串转数组下标
    int hash(const string& key) {
    unsigned int hashVal = 0;
    for (char c : key) {
    hashVal = hashVal * 31 + c;// 经典BKDR哈希算法
    }
    return hashVal % capacity;
    }

    // 扩容:容量翻倍,重新哈希所有元素
    void rehash() {
    int oldCapacity = capacity;
    capacity *= 2;
    vector<list<pair<string, int>>> newTable(capacity);

    // 遍历旧表所有元素,重新哈希到新表
    for (auto& bucket : table) {
    for (auto& kv : bucket) {
    int idx = hash(kv.first);
    newTable[idx].push_back(kv);
    }
    }
    table = move(newTable);
    }
    public:
    HashTable(int cap = 16) : capacity(cap), size(0), loadFactorThreshold(0.75) {
    table.resize(capactiy);
    }

    // 插入/更新
    void put(const string& key, int value) {
    int idx = hash(key);
    // 先查是否已存在,存在则更新
    for (auto& kv : table[idx]) {
    if (kv.first == key) {
    kv.second = value;
    return;
    }
    }
    // 不存在则插入链表头部
    table[idx].push_front({key, value});
    size ++ ;
    // 超过负载因子阈值,扩容
    if ((double)size / capacity > loadFactorThreshold) {
    rehash();
    }
    }

    // 查找,找到返回值,找不到返回-1
    int get(const string& key) {
    int idx = hash(key);
    for (auto& kv : table[idx]) {
    if (kv.first == key) {
    return kv.second;
    }
    }
    return -1;
    }

    // 删除
    bool remove(const string& key) {
    int idx = hash(key);
    for (auto it = table[idx].begin(); it != table[idx].end(); ++it) {
    if (it->first == key) {
    table[idx].erase(it);
    size –;
    return true;
    }
    }
    return false;
    }
    };

    提醒:哈希函数选 BKDR 算法(乘 31 加字符),简单好写且分布均匀,临场演示首选。

    2. 开放寻址法实现散列表

    #include <vector>
    #include <string>
    using namespace std;

    class HashTableOpen {
    private:
    vector<pair<string, int>> table;
    vector<bool> occupied; // 标记该位置是否有元素
    vector<bool> deleted; // 标记该位置是否是删除的(墓碑标记)
    int size;
    int capacity;

    int hash(const string& key) {
    unsigned int hashVal = 0;
    for (char c : key) {
    hashVal = hashVal * 31 + c;
    }
    return hashVal % capacity;
    }

    public:
    HashTableOpen(int cap = 16) : capacity(cap), size(0) {
    table.resize(capacity);
    occupied.resize(capacity, false);
    deleted.resize(capacity, false);
    }

    void put(const string& key, int value) {
    int idx = hash(key);
    // 线性探测找空位
    while (occupied[idx] && !deleted[idx]) {
    if (table[idx].first == key) {
    table[idx].second = value; // 已存在,更新
    return;
    }
    idx = (idx + 1) % capacity;
    }
    table[idx] = {key, value};
    occupied[idx] = true;
    deleted[idx] = false;
    size++;
    }

    int get(const string& key) {
    int idx = hash(key);
    while (occupied[idx]) {
    if (!deleted[idx] && table[idx].first == key) {
    return table[idx].second;
    }
    idx = (idx + 1) % capacity;
    }
    return -1;
    }

    // 懒删除:只标记,不真正删除,保证探测链不断
    bool remove(const string& key) {
    int idx = hash(key);
    while (occupied[idx]) {
    if (!deleted[idx] && table[idx].first == key) {
    deleted[idx] = true;
    size–;
    return true;
    }
    idx = (idx + 1) % capacity;
    }
    return false;
    }
    };

    提醒:开放寻址法删除必须用墓碑标记(懒删除),不能直接清空,否则探测链会断,后面的元素就找不到了

    三、进阶理解类

    提问:为什么 Java HashMap 链表长度超过 8 要转红黑树?

    回答:

    这是一个经典的工程权衡:

    • 正常情况下哈希分布均匀,链表长度很短,查找很快,链表实现简单开销小。
    • 极端情况下(哈希函数不好、恶意构造冲突 key),链表会变得很长,查找退化成 O (n),性能急剧下降。
    • 当链表长度到 8 时,转成红黑树,查找变成 O (logn),保证极端情况下的性能底线。
    • 为什么是 8?根据泊松分布,链表长度达到 8 的概率不到千万分之一,正常情况几乎不会触发,只有异常情况才会转树,兼顾了正常情况的性能和极端情况的安全性。
    • 元素少的时候再转回链表,因为小数据量下链表遍历比红黑树更快,且内存开销更小。

    提问:散列表扩容为什么一般是 2 倍扩容?

    回答:

    两个核心原因:

  • 位运算优化取模:当容量是 2 的 n 次幂时,hash % capacity 可以等价替换成 hash & (capacity – 1),位运算比取模运算快很多,性能更好。
  • 扩容迁移高效:2 倍扩容后,元素在新表的位置只有两种可能:要么在原下标,要么在「原下标 + 旧容量」的位置,不用重新计算完整哈希,直接看哈希值的某一位就能确定新位置,迁移速度更快。
  • 提问:什么是一致性哈希?解决了什么问题?

    回答:

    一致性哈希是分布式场景下的哈希算法,解决的是普通哈希扩容 / 缩容时,大量 key 失效迁移的问题。

    普通哈希:key % n,如果节点数从 n 变成 n+1,几乎所有 key 的映射位置都会变,全量迁移成本极高。

    一致性哈希:把哈希空间组织成一个 0~2^32-1 的环形,节点和 key 都映射到环上,key 顺时针找到的第一个节点就是它归属的节点。增减节点时,只会影响相邻的一小段 key,绝大部分 key 的映射不变,迁移量极小。

    一般还会加虚拟节点,解决节点少时分布不均、数据倾斜的问题。

    典型应用:分布式缓存(Redis 集群)、负载均衡、分布式存储。

    问题:普通哈希为什么不行?

    假设有 3 台缓存服务器(Node0、Node1、Node2),要把 key 分布到这 3 台机器上。

    普通取模哈希:

    服务器编号 = hash(key) % N (N = 服务器数量)

    一旦节点数变了:

    场景变化受影响的 key 比例
    3 台 → 4 台(扩容) N 从 3 变 4 约 75% 的 key 映射关系失效
    4 台 → 3 台(缩容) N 从 4 变 3 约 75% 的 key 映射关系失效
    N 台 → N+1 台 一般情况 约 N/(N+1) 的 key 全部失效

    几乎所有数据都要重新迁移,缓存雪崩级别的冲击。

    一致性哈希原理

    1、把整个哈希值空间(比如 0 ~ 2³²-1)想象成一个首尾相接的圆环

    2、把服务器节点映射到环上,对每个服务器节点(用 IP、ID 等唯一标识)做哈希,把结果落在环上的某个位置

    3、数据去找对应的服务器:对数据的 key 也做哈希,落在环上某个位置,然后顺时针找,遇到的第一个节点就是目标服务器,理想情况下,增删一个节点,只需要迁移 1/N 的数据。

    虚拟节点,解决数据倾斜

    节点太少时分布不均,比如三个节点挤在一起,大部分 key 都落到 NodeA 上了 ,这就是数据倾斜。

    不给每个真实节点只映射一个点,而是映射几十个甚至几百个虚拟点,增删时影响分散在环的各处,不会集中冲击某一个节点

    代码简单实现:

    #include <iostream>
    #include <map>
    #include <string>
    #include <functional>

    /**
    * @brief 一致性哈希环(带虚拟节点)
    *
    * 原理:
    * – 用 std::map 维护"哈希值 → 真实节点名"的有序映射(模拟哈希环)
    * – 每个真实节点对应多个虚拟节点,均匀分布在环上
    * – 查找 key 时,找第一个 >= key哈希值 的虚拟节点
    * – 如果找不到(key 在最后一个节点之后),取第一个节点(环的特性)
    */
    class ConsistentHash {
    public:
    /**
    * @brief 构造函数
    * @param virtual_node_count 每个真实节点对应的虚拟节点数量
    */
    explicit ConsistentHash(int virtual_node_count = 150)
    : virtual_node_count_(virtual_node_count) {}

    /**
    * @brief 添加一个真实节点
    * @param node 真实节点标识(如 IP、主机名)
    *
    * 为该节点生成 virtual_node_count_ 个虚拟节点,全部加入哈希环
    */
    void addNode(const std::string& node) {
    for (int i = 0; i < virtual_node_count_; ++i) {
    // 虚拟节点 key = "真实节点#序号",再哈希
    std::string virtual_key = node + "#" + std::to_string(i);
    size_t hash = std::hash<std::string>{}(virtual_key);
    hash_ring_[hash] = node;
    }
    }

    /**
    * @brief 移除一个真实节点
    * @param node 要移除的真实节点标识
    *
    * 把该节点对应的所有虚拟节点从哈希环上删掉
    */
    void removeNode(const std::string& node) {
    for (int i = 0; i < virtual_node_count_; ++i) {
    std::string virtual_key = node + "#" + std::to_string(i);
    size_t hash = std::hash<std::string>{}(virtual_key);
    hash_ring_.erase(hash);
    }
    }

    /**
    * @brief 根据 key 找到应该去的真实节点
    * @param key 数据的 key
    * @return 目标真实节点名;环为空返回空字符串
    *
    * 核心查找逻辑:
    * 1. 对 key 做哈希
    * 2. 在有序 map 中找第一个 >= key哈希值 的节点(lower_bound)
    * 3. 找到了就返回;没找到说明在末尾,绕回第一个节点
    */
    std::string getNode(const std::string& key) const {
    if (hash_ring_.empty()) {
    return "";
    }

    size_t key_hash = std::hash<std::string>{}(key);

    // lower_bound: 找第一个 >= key_hash 的元素
    auto it = hash_ring_.lower_bound(key_hash);

    if (it == hash_ring_.end()) {
    // 绕回环的起点
    it = hash_ring_.begin();
    }

    return it->second; // 返回真实节点名
    }

    /**
    * @brief 获取当前虚拟节点总数(调试用)
    */
    size_t size() const {
    return hash_ring_.size();
    }

    private:
    int virtual_node_count_; ///< 每个真实节点的虚拟节点数

    /**
    * 哈希环:有序 map,key = 虚拟节点哈希值,value = 真实节点名
    * 用 std::map(红黑树)保持有序,方便 lower_bound 查找
    */
    std::map<size_t, std::string> hash_ring_;
    };

    // ============ 测试 ============
    int main() {
    ConsistentHash ch(200); // 每个真实节点 200 个虚拟节点

    // 加 3 台服务器
    ch.addNode("192.168.1.10");
    ch.addNode("192.168.1.11");
    ch.addNode("192.168.1.12");

    std::cout << "虚拟节点总数: " << ch.size() << std::endl; // 600

    // 测试一些 key 落在哪个节点
    std::string keys[] = {"user:1", "user:2", "user:3", "order:100", "product:50"};

    std::cout << "\\n=== 3 台节点时 ===" << std::endl;
    for (const auto& k : keys) {
    std::cout << k << " → " << ch.getNode(k) << std::endl;
    }

    // 加一台新服务器
    ch.addNode("192.168.1.13");
    std::cout << "\\n=== 4 台节点时 ===" << std::endl;
    for (const auto& k : keys) {
    std::cout << k << " → " << ch.getNode(k) << std::endl;
    }

    return 0;
    }

    四、实战场景

    1. 缓存系统

    Redis、Memcached 等分布式缓存,本质就是超大的分布式散列表,key-value 存储,O (1) 读写,是后端系统性能优化的标配。

    本地缓存(Guava Cache、Caffeine)也都是基于散列表实现的。

    2. 编程语言内置容器

    几乎所有主流语言的字典 / 映射容器都是散列表实现:

    • C++:unordered_map、unordered_set(链地址法)
    • Java:HashMap、HashSet(链地址法 + 红黑树优化)
    • Python:dict、set(开放寻址法)
    • Go:map(开放寻址法变种)

    3. 数据库索引

    MySQL 的 Memory 存储引擎支持哈希索引,等值查询 O (1),比 B+ 树还快;但不支持范围查询、排序,适用场景有限。

    Redis 的字典、内部元数据管理,全部基于散列表实现。

    4. 去重与计数

    海量数据去重、词频统计、用户 UV 统计,都是散列表的经典应用:

    • 日志分析:统计每个接口的访问次数
    • 爬虫:URL 去重,避免重复爬取
    • 推荐系统:用户历史行为去重,避免重复推荐

    5. 路由与负载均衡

    Nginx、API 网关的路由匹配,用散列表快速根据 URL 找到对应的后端服务;

    一致性哈希负载均衡,保证同一用户的请求落到同一台服务器,实现会话保持。

    6. 编译与解释器

    编译器的符号表,用散列表存储变量名、函数名到地址的映射,查找极快,是编译过程的核心数据结构。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 十二.散列表
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!