CC++ & Algorithm

哈希表的实现与复杂度分析

极难2
语言版本:通用
概述:从零实现一个完整的哈希表,包括动态扩容,并分析插入、查找、删除的时间复杂度。

哈希表:你的超级储物柜——实现与复杂度分析

你有没有试过把一堆东西塞进书包,结果找一本作业本要翻半天?哈希表就像一个有编号的超级储物柜,每个物品(键)都按照一个固定规则放进对应的抽屉里,这样你下次找的时候,直接去那个抽屉翻就行,超快!这种数据结构的特点是:插入、查找、删除的速度平均都极快,接近常数时间。本文会从零开始,用两种编程语言实现一个完整的哈希表,包括自动扩容功能,并详细分析它的运行速度。


一、哈希表是什么?——从奶茶店订单说起

假设你开了一家奶茶店,门口放了一个大架子,上面有10个格子(编号0~9)。顾客来了,你根据他的电话号码(假设都是数字)决定把订单放在哪个格子里。怎么决定呢?你想了个规则:用电话号码除以格子总数(10),取余数。比如电话号码尾号是7,就放第7格。这个规则就叫哈希函数

一开始格子够用,但生意越来越好,订单越来越多,格子很快就不够用了——有的格子堆了十几张单子,你找起来就慢了!怎么办呢?你把架子换成更大的,比如20个格子,然后把所有订单按新规则(除以20取余)重新放到新格子里。这个过程就叫动态扩容。你还会记录当前已经用了多少个格子(元素个数),当“已经使用的格子比例”超过某个值(比如75%)时,就触发扩容。这个比例叫负载因子(load factor)。

哈希表的核心思想就是:用一个函数把键映射成数组下标,把数据存在对应下标的位置上。如果两个不同的键映射到了同一个下标,就使用链表把它们串起来(这叫链地址法)。这样,查找时先通过函数找到下标,再在链表里找自己的键。


二、哈希表的三个关键概念

1. 哈希函数——给每个键一个“身份证号”

哈希函数的好坏直接决定哈希表的性能。最理想的情况是:每个键都均匀地散落在各个格子里,不会扎堆。对于整数键,最简单的哈希函数就是:key % 桶的数量。但要注意,如果桶的数量是2的幂,取模运算可以加速为位运算,但有些情况下冲突会变多。通常我们会让桶的数量是质数,能减少规律性冲突。

例子:班级里有50个同学,学号从1到50。你想按学号尾数来分小组,哈希函数就是 学号 % 10。那学号11、21、31都会分到同一个组(余数1),这就是冲突。如果改成 学号 % 13(13是质数),冲突会随机分布。

2. 冲突处理——打架了怎么办?

当两个不同的键被哈希函数映射到同一个桶时,就发生了冲突。处理冲突最常用的方法是链地址法:每个桶里放一个链表,所有冲突的键都挂在这个链表上。查找时先找到桶,再在链表里顺序查找。另一种方法叫开放地址法(比如线性探测),本文使用链地址法。

生活中的例子:学校食堂有10个窗口,但红烧肉最受欢迎,大家都挤到3号窗口。3号窗口的队伍排得好长,这就是冲突。链地址法就是在每个窗口后面允许排一个长队,让所有想吃红烧肉的人都在3号窗口排队。这样虽然找得快慢取决于队伍长度,但至少不会没地方放。

3. 动态扩容——格子不够了怎么办?

当负载因子(元素个数/桶数)超过某个阈值(例如0.75),哈希表的性能会下降,因为每个桶里的链表太长。这时就需要扩容:把桶的数量翻倍(通常变成原来的2倍),然后把所有现有的元素重新计算哈希,放到新桶里。这个操作叫 rehash,时间复杂度是 O(n),因为要遍历所有元素。但扩容后,桶数增加,负载因子降低,后续操作又能保持 O(1) 的性能。

为什么翻倍而不是加一个? 因为翻倍后,每个元素的新哈希值分布会是老哈希值的两倍范围,而且扩容操作次数较少(只有负载因子每次达到阈值才触发),平均到每次插入的代价很小(平摊分析)。


三、新手最容易犯的三个错误

错误1:哈希函数导致严重冲突

如果你用 key % 桶数,但桶数选得不合适(比如全是偶数),那么一组连续整数(如 0,2,4,6…)都会落入同一个桶里。特别是当桶数是2的幂时,这种规律性冲突会更明显。建议让桶数保持为质数,或者使用更复杂的哈希函数(如 key * 大质数 % 桶数)。

错误2:忘记处理重复插入

很多初学者在插入时没有检查键是否已存在,结果同一个键被插入了多次,导致查找和删除时出现混乱。正确的做法是:先检查对应桶里有没有相同的键,有的话就跳过。

错误3:扩容后忘记更新所有数据

有些人扩容时只新建了更大的数组,却没有把旧数据搬过来,导致旧数据丢失。或者搬数据时使用了旧的哈希函数(除以旧容量),应该用新容量计算新位置。另外,搬完数据后要记得重置 numElements 为旧数据的数量(或者通过累加重新计数)。本文的实现中,rehash 里先重置 numElements 为0,然后通过 insert 重新插入,这样 insert 会正确增加计数。


四、完整实现(C++ 版)——一步一步拆解

下面我们用 C++ 实现一个针对整数键的哈希表,采用链地址法和动态扩容。代码中每一行都加了中文注释。

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

class HashTable {
private:
    vector<list<int>> table;   // 底层:每个桶是一个链表
    int numElements;           // 当前哈希表中的元素个数
    int capacity;              // 桶的数量(数组长度)
    const double LOAD_FACTOR_THRESHOLD = 0.75;  // 负载因子阈值

    // 哈希函数:将键映射为桶下标
    int hashFunction(int key) {
        // 假设 key 为非负,取模得到下标
        return key % capacity;
    }

    // 重新哈希:当负载因子超标时调用
    void rehash() {
        int oldCapacity = capacity;                  // 保存旧容量
        capacity = capacity * 2;                     // 通常翻倍
        // 实际工程中建议找下一个质数,此处简化直接翻倍
        vector<list<int>> oldTable = move(table);    // 转移旧表资源
        table.resize(capacity);                      // 重置新表,所有桶为空
        numElements = 0;                             // 重置计数器,插入时会重新增加
        // 遍历旧表的每个桶,重新插入所有键
        for (int i = 0; i < oldCapacity; ++i) {
            for (int key : oldTable[i]) {
                insert(key);   // 调用 insert,它可能会再次检查负载因子,但此时元素少不会触发
            }
        }
    }

public:
    // 构造函数,初始容量设为7(质数),numElements 为0
    HashTable(int initialCapacity = 7) : capacity(initialCapacity), numElements(0) {
        table.resize(capacity);   // 表初始化为 capacity 个空链表
    }

    // 插入键
    void insert(int key) {
        // 检查负载因子,达到阈值则扩容
        if ((double)numElements / capacity >= LOAD_FACTOR_THRESHOLD) {
            rehash();
        }
        int index = hashFunction(key);    // 计算桶下标
        // 检查该桶中是否已存在该键(避免重复)
        for (int k : table[index]) {
            if (k == key) return;        // 已存在,不插入
        }
        table[index].push_back(key);     // 插入链表尾部
        numElements++;                   // 元素个数加1
    }

    // 查找键:返回 true 表示找到,false 表示不存在
    bool search(int key) {
        int index = hashFunction(key);
        for (int k : table[index]) {
            if (k == key) return true;
        }
        return false;
    }

    // 删除键:成功删除返回 true,不存在则返回 false
    bool remove(int key) {
        int index = hashFunction(key);
        // 遍历链表查找
        for (auto it = table[index].begin(); it != table[index].end(); ++it) {
            if (*it == key) {
                table[index].erase(it);  // 删除该节点
                numElements--;           // 元素个数减1
                return true;
            }
        }
        return false;
    }

    // 打印哈希表的内容(用于调试)
    void print() {
        for (int i = 0; i < capacity; ++i) {
            cout << "桶 " << i << ": ";
            for (int k : table[i]) {
                cout << k << " ";
            }
            cout << endl;
        }
    }

    // 返回当前元素个数
    int size() { return numElements; }
};

// 测试函数
int main() {
    HashTable ht;   // 默认容量7
    // 插入 0, 3, 6, 9, ..., 57 共20个元素
    for (int i = 0; i < 20; ++i) {
        ht.insert(i * 3);
    }
    cout << "元素个数: " << ht.size() << endl;
    cout << "哈希表内容(可能已扩容):" << endl;
    ht.print();

    cout << "\n查找 30: " << (ht.search(30) ? "找到" : "未找到") << endl;
    ht.remove(30);
    cout << "删除 30 后查找 30: " << (ht.search(30) ? "找到" : "未找到") << endl;
    return 0;
}

代码说明

  • vector<list<int>> 是核心,每个内部 list 就是一个桶里的链表。
  • 初始容量选为7(质数),负载因子阈值0.75。
  • rehash 中,先用 move 把旧表拿走,然后新建 capacity*2 大小的表,再重新插入所有键。
  • 插入时先检查负载因子,再检查是否重复;查找和删除都通过哈希函数定位桶,再遍历链表。

五、完整实现(Python 版)——同样逻辑,更简洁

class HashTable:
    def __init__(self, initial_capacity=7):
        self.capacity = initial_capacity          # 桶的数量
        self.table = [[] for _ in range(self.capacity)]  # 每个桶是一个列表
        self.num_elements = 0                     # 当前元素个数
        self.threshold = 0.75                     # 负载因子阈值

    # 哈希函数
    def _hash(self, key):
        return key % self.capacity

    # 重新哈希(扩容)
    def _rehash(self):
        old_capacity = self.capacity
        old_table = self.table                     # 保存旧表
        self.capacity = self.capacity * 2          # 容量翻倍
        self.table = [[] for _ in range(self.capacity)]  # 新表
        self.num_elements = 0                      # 重置计数器
        # 遍历旧表每个桶,重新插入所有键
        for bucket in old_table:
            for key in bucket:
                self.insert(key)  # 注意:insert会再次检查负载,但此时元素少,不会触发rehash

    # 插入键
    def insert(self, key):
        # 检查负载因子
        if self.num_elements / self.capacity >= self.threshold:
            self._rehash()
        index = self._hash(key)
        # 避免重复插入
        if key not in self.table[index]:
            self.table[index].append(key)
            self.num_elements += 1

    # 查找键
    def search(self, key):
        index = self._hash(key)
        return key in self.table[index]

    # 删除键
    def remove(self, key):
        index = self._hash(key)
        if key in self.table[index]:
            self.table[index].remove(key)
            self.num_elements -= 1
            return True
        return False

    # 打印哈希表
    def print_table(self):
        for i, bucket in enumerate(self.table):
            print(f"桶 {i}: {bucket}")

    # 返回元素个数
    def size(self):
        return self.num_elements

# 测试
ht = HashTable()
for i in range(20):
    ht.insert(i * 3)   # 插入 0,3,6,...,57

print("元素个数:", ht.size())
print("哈希表内容(可能已扩容):")
ht.print_table()

print("\n查找 30:", "找到" if ht.search(30) else "未找到")
ht.remove(30)
print("删除 30 后查找 30:", "找到" if ht.search(30) else "未找到")

Python 版代码的优点是更简洁,不需要处理指针。注意 _rehash 中调用了 self.insert(key),这会触发负载因子检查,但由于此时 num_elements 为0,且插入过程中 num_elements 逐步增加,不会再次触发扩容。


六、复杂度分析——到底快不快?

平均情况(理想情况)

假设哈希函数足够均匀,每个桶里的元素数量大致等于负载因子 λ = n / M (n是元素总数,M是桶数)。那么一次查找平均需要比较 λ 个元素。如果 λ 控制在0.75以下,比较次数很少——比如 λ=0.75时,平均每个桶里不到1个元素,所以查找几乎就是 O(1)。插入同理:先计算哈希找到桶,然后检查是否存在(需要比较链表中的元素),平均也是 O(1)。删除也一样。

结论:当负载因子保持较低时,插入、查找、删除的平均时间复杂度都是 O(1)

最坏情况

如果哈希函数设计得很差,或者输入的键全都哈希到同一个桶里,那么所有数据都挂在一个链表上。这时查找、插入、删除都变成了 O(n)(n是元素总数),比普通数组还慢!所以哈希函数的质量至关重要

扩容的代价

扩容本身需要 O(n) 的时间(遍历所有元素重新插入)。但平均到每一次插入上,代价可以忽略不计,因为扩容发生的次数很少(大约每插入 n 次才触发一次,且每次扩容后容量翻倍)。这种分析叫做平摊分析。所以整体上,每次插入的平均成本仍然是 O(1)。

表格总结

操作平均时间复杂度最坏时间复杂度
插入O(1)O(n)
查找O(1)O(n)
删除O(1)O(n)
扩容平摊 O(1)O(n)

七、相关指引——学完这个,下一步可以看什么?

你现在已经掌握了最基础的哈希表实现。如果想要进一步深入,可以学习:

  1. 更优的哈希函数:例如计算字符串的哈希值(如 BKDR 哈希),或者使用 std::hash
  2. 不同的冲突处理方式:开放地址法中的线性探测、二次探测、双重哈希。这些方法不需要链表,但查找时要处理“探测”逻辑。
  3. 一致性哈希:用于分布式缓存系统(如 Redis、Memcached),当机器增减时,只有部分数据需要迁移。
  4. C++ STL 中的 unordered_map:它是 C++ 标准库提供的哈希表实现,已经包含了所有复杂功能,可以直接使用。
  5. Python 的 dict 和 set:Python 内置的字典和集合就是基于哈希表实现的,它们内部已经处理了动态扩容和冲突,你可以直接使用。

掌握了这些,你就能灵活运用哈希表解决很多实际问题,比如快速查找、去重、计数等。下一课我们将看看哈希表在生活中的应用——比如用哈希表统计班级同学的身高分布!

例题精讲

1单选题

在哈希表的动态扩容过程中,新容量通常选择为原容量的多少倍?

A1.5 倍
B2 倍
C3 倍
D4 倍
2判断题

哈希表的插入操作在平均情况下时间复杂度为 O(1),但在最坏情况下可能退化为 O(n)。

3填空题
以下是用链地址法实现的哈希表插入操作片段,请在空白处填上合适的表达式,使得当当前元素数量超过负载因子阈值时触发扩容(假设负载因子为 0.75,capacity 为当前桶数组长度,size 为已存储键值对数量)。

void insert(int key, int value) {
    if (size >= ___) {
        resize();
    }
    int index = hash(key) % capacity;
    // 将 (key, value) 添加到 table[index] 链表中
    // ...
    size++;
}
4填空题
下面是一个哈希表扩容函数的代码框架,请在空白处填入合适的数字,使新容量扩展为原容量的常见倍数。

void resize() {
    int oldCapacity = capacity;
    int newCapacity = oldCapacity * ___;
    // 创建新桶数组,重新哈希所有元素...
    capacity = newCapacity;
}
5单选题

关于哈希表删除操作的时间复杂度,下列说法正确的是?

A平均 O(1),最坏 O(n)
B平均 O(n),最坏 O(n)
C平均 O(1),最坏 O(1)
D平均 O(log n),最坏 O(n)