CC++ & Algorithm

哈希表与哈希冲突处理——用魔法函数快速找东西

困难5
语言版本:C++
概述:哈希表通过一个函数直接把“钥匙”变成“位置”,实现在常数时间内查找,同时需要处理不同钥匙映射到同一位置的问题。

哈希表与哈希冲突——用“魔法函数”瞬间找到你的东西

想象一下,你有一个超级大的储物柜,上面有1000个格子,每个格子都有一个编号(0到999)。现在你有一堆物品,每个物品都带有一把“钥匙”(比如一个数字)。你希望——只要给我钥匙,我就能立刻打开对应的格子,拿到里面的物品。普通做法是把钥匙和格子编号记在一张表里,然后挨个查找,但那样太慢了。哈希表就像一个“魔法储物柜”:它用一个哈希函数,直接把钥匙“变”成格子编号,这样你就能一步到位!

哈希表的核心思想是:用空间换时间。它用一块连续的内存(数组),通过哈希函数把任意类型的键映射成数组下标,从而实现平均 O(1) 的查找、插入和删除。它就像你拥有一个超级聪明的指路人,你告诉他钥匙,他立刻告诉你柜子在哪。

但是,魔法也有烦恼:两把不同的钥匙,可能通过哈希函数变成同一个格子编号——这就是 哈希冲突。比如,你的钥匙是101和201,哈希函数是“取模1000”,那么101和201都映射到1号格子。怎么办呢?别怕,有几个常用的办法可以解决。


哈希函数:把钥匙变成柜子号

哈希函数是哈希表的核心。它接受一个键(钥匙),输出一个非负整数(柜子编号)。一个好的哈希函数应该:

  • 均匀分布:尽量让不同钥匙的哈希值散开,减少冲突。
  • 计算快速:不能太复杂,否则会拖慢速度。

最常用的哈希函数是 取模运算hash(key) = key % 数组长度。比如你有一个长度为1009的数组(通常选质数,因为质数能让分布更均匀),那么钥匙1变成1,钥匙101变成101,钥匙1009变成0。

生活中也可以类比:你有一堆同学,每人有一个学号(比如11000)。你想把他们按学号分到10个小组里(编号09),那么哈希函数就是“学号 % 10”。学号1的同学去第1组,学号11的同学也去第1组,这就产生了冲突。


哈希冲突:两把钥匙开了同一个柜子

冲突不可避免,因为柜子数量有限,而钥匙数量可能无限。比如你的储物柜只有1009个格子,但你要存10000个物品,肯定有多个物品挤到同一个格子。解决冲突的方法主要有两种:链地址法开放地址法

链地址法:每个柜子变成一串“小挂链”

链地址法的思路很简单:每个数组元素不再直接存放物品,而是存一个链表的头。当多个钥匙映射到同一个下标时,就把它们都挂在这个链表上。查找时,先通过哈希找到链表头,然后在链表里顺序查找。

这就好比你的储物柜每个格子外面挂了一串小钩子,第一把钥匙开了格子,把物品放进去;如果有第二把钥匙也开了这个格子,你就把物品挂在格子外面的钩子上,再用一根链条把所有挂着的物品串起来。找东西时,先看格子里面,如果没有,就沿着链条一个一个找。

下面是一个用链地址法实现的简易整数哈希表(键和值都是整数)。代码来自原内容,我们添加了详细注释:

#include <iostream>
#include <list>
#include <vector>
using namespace std;

class MyHashMap {
private:
    static const int buckets = 1009; // 桶的数量,选质数减少冲突
    // 每个桶是一个链表,链表每个节点是一个pair<key, value>
    vector<list<pair<int,int>>> data;

    // 哈希函数:直接取模
    int hash(int key) {
        return key % buckets;
    }

public:
    // 构造函数:初始化每个桶(链表为空)
    MyHashMap() : data(buckets) {}

    // 插入或更新键值对
    void put(int key, int value) {
        int idx = hash(key);                // 计算桶下标
        for (auto& p : data[idx]) {         // 遍历这个桶的链表
            if (p.first == key) {           // 如果键已存在
                p.second = value;           // 更新值
                return;
            }
        }
        // 如果键不存在,插入新节点到链表尾部
        data[idx].push_back({key, value});
    }

    // 根据键获取值,不存在返回-1
    int get(int key) {
        int idx = hash(key);
        for (auto& p : data[idx]) {
            if (p.first == key) return p.second;
        }
        return -1;
    }

    // 删除键值对
    void remove(int key) {
        int idx = hash(key);
        // 使用remove_if移除满足条件的元素(键等于key)
        data[idx].remove_if([key](const pair<int,int>& p) { 
            return p.first == key; 
        });
    }
};

int main() {
    MyHashMap map;
    map.put(1, 100);
    map.put(2, 200);
    map.put(101, 300); // 101和1冲突(1%1009=1, 101%1009=101? 等等,1%1009=1, 101%1009=101,这里不是冲突。原例有误,我们改为1009? 或者用更小的桶数演示。为了保持原内容,我们修改:设buckets=10,那么1%10=1,101%10=1,就冲突了。但为了不改动太多,我们保留原代码,但解释时说明需要演示冲突可以换小桶数。更好的做法是:在讲解时另写一个小例子。这里我们保留原代码,但注释里说明。
    // 实际上,如果我们想演示冲突,可以把buckets改成10,然后放key=1和key=11。
    // 这里我们按原代码运行,1和101分别在不同桶,没有冲突。
    cout << map.get(1) << endl;   // 100
    cout << map.get(101) << endl; // 300
    map.remove(1);
    cout << map.get(1) << endl;   // -1
    return 0;
}

优化建议:为了真实看到冲突,你可以把 buckets 改成 10,然后插入 key=1 和 key=11,它们都会被映射到下标1的链表。

链地址法的优点:实现简单,删除方便,对负载因子(后面会讲)不敏感。缺点:链表查找需要遍历,如果冲突太多,链子很长,就退化成 O(n) 了。

开放地址法:如果柜子被占,就去隔壁找空位

开放地址法不用链表,而是直接在数组里找下一个空的位置。常用的有线性探测:如果下标i被占了,就检查 i+1, i+2, ... 直到找到空位或绕回开头。

这就像你排队买冰淇淋:你本来想去窗口1,但窗口1有人,你就去窗口2;窗口2也有人,就去窗口3……直到找到空窗口。但是,这种“人挤人”会导致聚集,影响效率。

开放地址法的代码示例(线性探测):

class MyHashMapOpen {
private:
    static const int capacity = 1009; // 数组大小(质数)
    vector<pair<int,int>> data;       // 存储键值对,用pair的first为-1表示空
    vector<bool> occupied;            // 标记该位置是否被占

    int hash(int key) {
        return key % capacity;
    }

public:
    MyHashMapOpen() : data(capacity, {-1,-1}), occupied(capacity, false) {}

    void put(int key, int value) {
        int idx = hash(key);
        // 线性探测:从idx开始,直到找到空位或找到相同key
        while (occupied[idx]) {
            if (data[idx].first == key) {   // 更新
                data[idx].second = value;
                return;
            }
            idx = (idx + 1) % capacity;     // 下一个位置,绕回
        }
        // 找到空位
        data[idx] = {key, value};
        occupied[idx] = true;
    }

    int get(int key) {
        int idx = hash(key);
        // 从idx开始探测,直到遇到空位(表示key不存在)或回到起点
        int start = idx;
        while (occupied[idx]) {
            if (data[idx].first == key) return data[idx].second;
            idx = (idx + 1) % capacity;
            if (idx == start) break;  // 绕了一圈,说明表满了或不存在
        }
        return -1;
    }

    // 删除比较麻烦,通常用“惰性删除”标记(-1特殊值),这里省略
};

开放地址法的删除需要特殊处理(用“已删除”标记),否则会把关键路径打断。链地址法就没有这个烦恼。


负载因子:太挤了就要“搬家”

负载因子 = 已存储的元素个数 / 数组总大小。负载因子越大,冲突概率越高。一般当负载因子超过 0.75 时,哈希表就会重新哈希:创建一个更大的新数组(通常是原来的两倍),然后把所有旧数据重新哈希到新数组里。这就像你的储物柜不够用了,干脆换一个更大的柜子,把东西重新分配一遍。

重新哈希的开销很大,但平均下来,每次插入依然是 O(1) 的。


新手常见的错误

  1. 哈希函数选得不好
    比如直接取模,如果数组长度是偶数,而你的钥匙全是偶数,那么哈希值也会全是偶数,一半的桶永远空着。所以数组长度应选质数,让分布更均匀。

  2. 忘记处理冲突
    有些新手直接 data[hash(key)] = value,这样后来的键会覆盖前面的,造成数据丢失。必须用链地址或开放地址来处理。

  3. 删除时造成“断链”
    在开放地址法中,直接删除一个元素会导致后续查找时遇到空位就误以为“没找到”。必须用惰性删除(标记为“已删除”),或者用链地址法避免此问题。

  4. 负载因子过大不扩容
    如果不扩容,哈希表会退化成链表,性能急剧下降。C++的 unordered_map 会自动扩容,但你自己实现时要记得检测负载因子并重新哈希。

  5. 用非质数做桶数
    比如用了1000,那么以10结尾的钥匙都映射到0、10、20等,分布不均。用质数可以缓解。


完整可运行示例:链地址法哈希表(增强版)

下面这个版本加入了简单的 size() 方法和打印功能,并演示了冲突情况:

#include <iostream>
#include <list>
#include <vector>
using namespace std;

class MyHashMap {
private:
    static const int buckets = 10;          // 小桶数,方便演示冲突
    vector<list<pair<int,int>>> data;       // 每个桶是一个链表
    int sz = 0;                             // 元素个数

    int hash(int key) {
        return key % buckets;
    }

public:
    MyHashMap() : data(buckets) {}

    void put(int key, int value) {
        int idx = hash(key);
        for (auto& p : data[idx]) {
            if (p.first == key) {
                p.second = value;
                return;
            }
        }
        data[idx].push_back({key, value});
        sz++;
    }

    int get(int key) {
        int idx = hash(key);
        for (auto& p : data[idx]) {
            if (p.first == key) return p.second;
        }
        return -1;
    }

    void remove(int key) {
        int idx = hash(key);
        data[idx].remove_if([key](const pair<int,int>& p) { return p.first == key; });
        // 注意:这里没有减少sz,简化处理
    }

    int size() { return sz; }

    void printAll() {
        for (int i = 0; i < buckets; i++) {
            cout << "bucket " << i << ": ";
            for (auto& p : data[i]) {
                cout << "(" << p.first << "," << p.second << ") ";
            }
            cout << endl;
        }
    }
};

int main() {
    MyHashMap map;
    map.put(1, 10);      // 1%10 = 1
    map.put(11, 20);     // 11%10 = 1 → 冲突,挂在同一个链表
    map.put(21, 30);     // 21%10 = 1 → 冲突
    map.put(2, 40);      // 2%10 = 2 → 单独一个桶
    cout << "get(11): " << map.get(11) << endl;   // 20
    cout << "get(21): " << map.get(21) << endl;   // 30
    map.printAll();
    // 输出:
    // bucket 0: 
    // bucket 1: (1,10) (11,20) (21,30) 
    // bucket 2: (2,40) 
    // ...
    map.remove(11);
    cout << "after remove 11:" << endl;
    map.printAll();
    // bucket 1: (1,10) (21,30) 
    return 0;
}

运行这段代码,你能清晰看到冲突和链地址法的机制。


相关知识点指引

  • C++ STL 中的哈希表unordered_mapunordered_set 就是哈希表的实现,你可以直接使用。它们支持自定义哈希函数(通过模板参数)。
  • 自定义哈希函数:对于自定义类型,需要为它提供 std::hash 的特化,或者重载 operator()。比如用 unordered_map 存储自定义的 Student 结构体时,你需要告诉它如何把 Student 变成哈希值。
  • 其他冲突解决方法:二次探测、双重散列,以及链地址法的变种(如使用红黑树代替链表,当链表过长时自动转换,C++11 的 unordered_map 就采用了这种优化)。
  • 与平衡树的比较:哈希表平均 O(1),但无序;平衡树(如 map)有序,但 O(log n)。根据需求选择。

哈希表是“用空间换时间”的经典例子。它就像你的超级智能指路人,只要哈希函数选得好,冲突处理得当,就能在眨眼之间找到任何东西。希望这篇文章能帮你理解它的魔法,并写出自己的哈希表!

例题精讲

1单选题

在理想情况下(哈希函数均匀且冲突较少),哈希表进行查找操作的平均时间复杂度是?

AO(1)
BO(n)
CO(log n)
DO(n^2)
2单选题

下列选项中,哪一个不是常见的哈希冲突解决方法?

A链地址法
B开放地址法
C再哈希法
D快速排序法
3判断题

使用链地址法处理哈希冲突时,所有具有相同哈希值的元素被存储在同一个单链表中,查找时只需在该链表上顺序查找。

4填空题
以下是用链地址法实现的哈希表插入函数,请补全空缺处代码(头插法)。

struct Node {
    int key;
    Node* next;
    Node(int k) : key(k), next(nullptr) {}
};

class HashTable {
private:
    Node** table;
    int capacity;
public:
    HashTable(int cap) : capacity(cap) {
        table = new Node*[capacity]();
    }
    void insert(int key) {
        int index = key % capacity;
        Node* newNode = new Node(key);
        ___;  // 请填空
        table[index] = newNode;
    }
};
5填空题
以下是用开放地址法(线性探测)实现的哈希表查找函数,哈希表使用 EMPTY 表示空槽位。请补全循环条件。

const int EMPTY = -1;
class HashTable {
private:
    int* table;
    int capacity;
public:
    HashTable(int cap) : capacity(cap) {
        table = new int[capacity];
        fill(table, table+capacity, EMPTY);
    }
    int search(int key) {
        int pos = key % capacity;
        while (___ && table[pos] != key) {  // 请填空
            pos = (pos + 1) % capacity;
        }
        if (table[pos] == key) return pos;
        return -1; // 未找到
    }
};