【数据结构与算法】哈希表

👨‍💻 关于作者:会编程的土豆

"不是因为看见希望才坚持,而是坚持了才看见希望。"

你好,我是会编程的土豆,一名热爱后端技术的Java学习者。

📚 正在更新中的专栏:

💕作者简介:后端学习者

前言

哈希表是算法面试和开发中绕不开的数据结构。但初学者常被"哈希函数"、"冲突"、"拉链法"这些词劝退。

这篇文章用一个存包柜的比喻,帮你彻底搞懂哈希表的底层原理,并附上 C++ 手写实现。

一、什么是哈希表?

1.1 一句话定义

哈希表就是带编号的存包柜,根据编号能快速找到你的包。

1.2 为什么需要哈希表?

数据结构 查找时间 缺点
数组 O(1) 下标必须是整数,不能是字符串
链表 O(n)
哈希表 O(1) 几乎完美

哈希表能让你用任意类型(字符串、对象)当"下标",快速找到对应的值。

二、核心概念

2.1 哈希函数------"算尾号"

哈希函数的作用:把任意类型的 key 转换成一个整数

就像存包柜系统:不管你叫什么名字,只看你手机尾号,决定你去几号柜子。

cpp 复制代码
// 简单的字符串哈希函数
int hashFunc(string key, int capacity) {
    int hash = 0;
    for (char c : key) {
        hash = (hash * 131 + c) % capacity;
    }
    return hash;
}

2.2 哈希冲突------"尾号相同怎么办?"

两个不同的 key,算出了相同的哈希值,都要去同一个柜子------这就是哈希冲突

张三(尾号 23)和李四(尾号 23)都想存 23 号柜,冲突了。

解决办法只有两招:

方法 比喻 专业术语
一个柜子里挂一排包 挂钩法 拉链法 / 开散列
往后找空柜子存 往后挪法 开放寻址法 / 闭散列

三、拉链法(挂钩法)详解

3.1 核心思想

哈希表的每个位置不是一个格子,而是一根挂钩(链表)。冲突的 key 都挂在这根钩子上。

实际就是遇到相同的key更新覆盖value为 最新值;

3.2 结构示意图

cpp 复制代码
桶[0] -> (张三, 100) -> (李四, 200) -> NULL
桶[1] -> NULL
桶[2] -> (王五, 300) -> NULL
桶[3] -> (赵六, 400) -> (钱七, 500) -> (孙八, 600) -> NULL

3.3 存包过程

  1. 算哈希值,找到对应的桶。

  2. 顺着挂钩找,看有没有同名的包。

  3. 有 → 换包(覆盖 value)。

  4. 没有 → 挂个新包在钩子末尾。

3.4 取包过程

  1. 算哈希值,找到桶。

  2. 顺着挂钩一个一个看名字。

  3. 找到了 → 拿走。

  4. 找完了都没看到 → 没存过。

3.5 C++ 手写实现

cpp 复制代码
#include <iostream>
#include <list>
#include <vector>
#include <string>
using namespace std;

class MyHashMap {
private:
    vector<list<pair<string, int>>> table;
    int capacity;
    
    int hashFunc(const string& key) {
        int hash = 0;
        for (char c : key) {
            hash = (hash * 131 + c) % capacity;
        }
        return hash;
    }
    
public:
    MyHashMap(int cap = 1000) : capacity(cap) {
        table.resize(capacity);
    }
    
    // 存包
    void put(const string& key, int value) {
        int idx = hashFunc(key);
        for (auto& p : table[idx]) {
            if (p.first == key) {
                p.second = value;  // 换包
                return;
            }
        }
        table[idx].push_back({key, value});  // 挂新包
    }
    
    // 取包(安全查找)
    int get(const string& key) {
        int idx = hashFunc(key);
        for (auto& p : table[idx]) {
            if (p.first == key) {
                return p.second;
            }
        }
        return -1;  // 没找到
    }
    
    // 删包
    void remove(const string& key) {
        int idx = hashFunc(key);
        auto& bucket = table[idx];
        for (auto it = bucket.begin(); it != bucket.end(); ++it) {
            if (it->first == key) {
                bucket.erase(it);
                return;
            }
        }
    }
};

3.6 一个重要误区

Q:挂钩上挂了一串,是不是一个 key 对应多个 value?

A:不是!

挂钩上挂的是不同的 key(它们只是哈希冲突了),不是同一个 key 的多个值。

同一个 key 再次插入,会直接覆盖,不会挂新包。


四、开放寻址法(往后挪法)详解

4.1 核心思想

一个柜子只存一个包。如果自己的柜子被占了,就往后找,直到找到空柜子。

4.2 存包过程

  1. 算哈希值,去对应柜子。

  2. 有人?看下一个。

  3. 还有人?继续看下一个。

  4. 找到空的,存进去。

4.3 取包过程

  1. 算哈希值,去对应柜子。

  2. 看名字,是我的吗?

  3. 不是?继续往后看。

  4. 遇到空柜子?说明没存过。

4.4 关键问题解答

Q:我往后挪,不是把别人的位置占了吗?

A:不会。因为别人来取包时,也是从自己的柜子开始往后找。看到你的包(名字不对),他会继续往后走,不会拿错。

Q:那尾号 24 的人来,发现 24 号被尾号 23 的人占了,怎么办?

A:他很委屈,但只能继续往后找空柜子。

Q:这样不就乱套了吗?

A:这就是线性探测最大的缺点------连累邻居 。队伍会越来越长,叫做聚集现象

4.5 删除的坑

开放寻址法不能直接删

张三(尾号23)存 23 号,李四(尾号23)存 24 号。

张三删包走人,23 号空了。

李四来取包:去 23 号→空的→"哦,我没存过"→拿不到自己的包!

解决办法:删的时候不真删,放个"墓碑"标记,表示这里曾经有人。

4.6 C++ 手写实现(线性探测)

cpp 复制代码
class MyHashMap_OpenAddressing {
private:
    vector<pair<string, int>> table;
    vector<bool> occupied;  // 是否有人
    vector<bool> tombstone; // 墓碑标记
    int capacity;
    
    int hashFunc(const string& key) {
        int hash = 0;
        for (char c : key) hash = (hash * 131 + c) % capacity;
        return hash;
    }
    
public:
    MyHashMap_OpenAddressing(int cap = 2000) : capacity(cap) {
        table.resize(capacity);
        occupied.resize(capacity, false);
        tombstone.resize(capacity, false);
    }
    
    void put(const string& key, int value) {
        int idx = hashFunc(key);
        while (occupied[idx] && table[idx].first != key) {
            idx = (idx + 1) % capacity;
        }
        table[idx] = {key, value};
        occupied[idx] = true;
        tombstone[idx] = false;
    }
    
    int get(const string& key) {
        int idx = hashFunc(key);
        while (occupied[idx] || tombstone[idx]) {
            if (occupied[idx] && table[idx].first == key) {
                return table[idx].second;
            }
            idx = (idx + 1) % capacity;
        }
        return -1;
    }
    
    void remove(const string& key) {
        int idx = hashFunc(key);
        while (occupied[idx] || tombstone[idx]) {
            if (occupied[idx] && table[idx].first == key) {
                occupied[idx] = false;
                tombstone[idx] = true;  // 留墓碑
                return;
            }
            idx = (idx + 1) % capacity;
        }
    }
};

五、拉链法 vs 开放寻址法

拉链法 开放寻址法
比喻 一个柜子挂一串 往后找空柜子
优点 实现简单,删除方便 省空间,缓存友好
缺点 需要额外指针空间 删除麻烦,可能聚集
Java HashMap
C++ unordered_map
Python dict ✅(随机探测)

六、C++ unordered_map 使用避坑指南

6.1 [] 的坑

cpp 复制代码
unordered_map<string, int> mp;
cout << mp["张三"];  // 张三不存在,但不会报错!

[] 如果 key 不存在,会自动插入一个 (key, 0)。

6.2 安全写法

cpp 复制代码
// 只查找,不插入
auto it = mp.find("张三");
if (it != mp.end()) {
    cout << it->second;
}

// 或者用 C++11 的 at(不存在会抛异常)
cout << mp.at("张三");

七、总结

  1. 哈希表 = 带编号的存包柜

  2. 哈希函数 = 根据 key 算柜子编号

  3. 哈希冲突 = 多个人想去同一个柜子

  4. 拉链法 = 一个柜子挂一串包

  5. 开放寻址法 = 柜子被占就往后找空柜子

  6. mp[key] 有坑,安全查找用 find()

相关推荐
爱写代码的倒霉蛋28 分钟前
2022年天梯赛L1-8真题解析(哈希+排序)
数据结构·算法
代码中介商35 分钟前
顺序表完全指南:从原理到实现
数据结构·顺序表
澈20744 分钟前
C++ list容器完全指南
数据结构·c++·链表
承渊政道2 小时前
【动态规划算法】(完全背包问题从状态定义到空间优化)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法
爱写代码的倒霉蛋2 小时前
2023年天梯赛L1-8
数据结构·算法
上弦月-编程3 小时前
C语言指针超详细教程——从入门到精通(面向初学者)
java·数据结构·算法
莫等闲-3 小时前
代码随想录一刷记录Day44——leetcode1143.最长公共子序列 53. 最大子序和
数据结构·c++·算法·leetcode·动态规划
承渊政道3 小时前
【动态规划算法】(背包问题经典模型与解题套路)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法
我头发多我先学4 小时前
C++ 红黑树:从规则到实现,手把手带你写一棵红黑树
数据结构·c++·算法
努力努力再努力wz5 小时前
【MySQL进阶系列】拒绝冗余SQL:带你透彻理解视图的底层逻辑
android·c语言·数据结构·数据库·c++·sql·mysql