假设你手头已经实现了一个功能完好的哈希桶(也就是分开链式的哈希表:一个 vector 存桶,每个桶里挂着一条由冲突结点组成的单链表),并且你把它当成"底层数据引擎"在用。现在的问题是:STL 里有 unordered_map(键值对容器)和 unordered_set(单值集合容器)这两个"长得不一样"的宝贝,我不想为它们各写一份哈希表——太浪费了。能不能像 STL 那样,只写一份哈希桶,然后让这两个容器站在同一条起跑线上复用同一份底层呢?

答案是能。这堂课我们就把这个"一个底层,两个上层"的封装过程完整走一遍,拆开来看清楚 STL 是怎么做解耦的,以及那个最容易卡住人的哈希表迭代器——它里面到底藏了几个指针、走完一个桶怎么跳进下一个桶。学完你会发现,所谓"封装"并不是简单的包一层皮,而是一整套让底层"不认识具体类型"、让上层"各取所需"的解耦艺术。而且我会把话说透、把代码写全,最后交给你一份能直接复制编译的完整程序。

在动笔之前,有两件事得先跟你交代清楚。第一,这一讲你会频繁碰到 unordered_map 和 unordered_set 两个词,记住一个判断方法:带 unordered 前缀表示"内部不保证有序",它背后一定有一张哈希表;不带这个前缀的(比如 map/set)背后是一棵红黑树。红黑树是自平衡二叉搜索树,它的迭代顺序天然有序(中序遍历);而哈希表为了 O(1) 查找,把数据按哈希值散落在各桶里,遍历顺序由桶号和插入位置决定,毫无次序可言——所以叫"unordered"。第二,我们要写的容器,本质上是"教学演示版",我给它起个前缀叫 my——mystl::myunordered_map 和 mystl::myunordered_set,逻辑完全自洽、能够编译运行,这就够了。标准库里它的实现细节比我们多得多(还实现了 count、at、桶接口、右值移动等),但大骨架就是我们今天看到的这套。

在正式开工前,有几块前置知识的"积木"得先搬过来。它们不复杂,但后面的代码处处依赖它们,我先就地给你讲透。

积木一:哈希表与哈希桶到底是什么

我们常说的"哈希表"(Hash Table,也叫散列表),核心思路是把一个"不太好比较的关键字"通过一个哈希函数(也叫散列函数)映射成一个大整数,再对桶的个数取模,得到它应该呆在第几个桶里。理想情况下,每个关键字都能分到不同的桶,这样查找、插入、删除都接近 O(1)——注意是"接近",不是绝对 O(1),后面你会看懂为什么。

举一个物理世界能类比的东西:去图书馆还书。你不必一本一本翻书架,只要看书的索书号(哈希值),就知道它该放哪一层哪一格(哪个桶),直奔过去。哈希表就是图书馆那套"按编号定位"的思维搬到内存里的结果。它的前提是:给你的关键字一个"编号"的函数,编得越均匀,找得越快。

但数学上"不同输入得到不同输出"几乎不可能,总会有两个不同的关键字算出同一个桶,这个现象叫哈希冲突(也叫碰撞,Collision)。只要关键字的取值范围大于桶的个数,冲突就不可避免——这是鸽笼原理(把 n+1 只鸽子放进 n 个笼子,至少有一个笼子住两只鸽子)在数学上注定的。解决冲突的办法分两大类:一是开放定址法,冲突了就在桶数组里"往后找一个空位"(如线性探测);二是链地址法(也叫开链法/拉链法),每个桶不再直接存数据,而是存一条单链表的头结点,冲突的结点就顺着链表往后挂。STL 的 unordered_map/unordered_set 走的是链地址法,Java 的 HashMap 早期也是,后来在链表变长时升级成红黑树来对抗恶意哈希攻击——那是 Java 的优化,我们先按纯链表理解。

于是整张表就长成了"桶数组 + 每个桶一条链表"的结构,我们形象地叫它哈希桶。你记住一个公式,往后所有设计都围着它转:桶里面存的是链表头,数据都在链表结点里。更直白地说,哈希桶是两维的——一维是桶,一维是桶内那条链。记住"二维"这两个字,后文迭代器设计就全靠这条认知。

还有一个叫负载因子(Load Factor)的概念:表里的数据个数除以桶的个数,即 _n / _tables.size()。负载因子越高,说明平均每个桶挂的结点越多,冲突越严重,在某些桶里查一次就要在链表上走好几步,性能就从 O(1) 滑向 O(n)。为了压住它,当负载因子超过某个阈值就要扩容——重新开一张更大的桶表,把所有结点重新分配。STL 通常把负载因子阈值控制在 1 左右,也就是说"结点总数 == 桶总数"时就扩容(平均每桶 1 个结点)。这一点后面讲迭代器失效会用到,先记下。

为了让你对哈希桶混个脸熟,先看它的两个"零件"——结点结构(单链表结点)和默认的哈希仿函数。下面这段代码本身就是完整可独立编译的(拷贝到 test.cpp 直接跑):

#include <iostream>
#include <string>
using namespace std;
 
// 默认的哈希仿函数:把关键字强转成 size_t 当哈希值
// K 是整数、指针、枚举等"整型友好"类型时够用
template<class K>
struct HashFunc
{
    size_t operator()(const K& key) const   // const 保证"算哈希"不改变任何状态
    {
        return (size_t)key;                 // (size_t) 强转:把 K 直接当成无符号整数
    }
};
 
int main()
{
    // 算几个 int 关键字的哈希值看看
    cout << HashFunc<int>()(42) << endl;          // 输出 42
    cout << HashFunc<int>()(-7) << endl;          // 输出 18446744073709551609(负数转无符号的补码)
    // cout << HashFunc<string>()(string("hi"));  // 错误:string 不能强转成 size_t
    return 0;
}

注意这里 HashFunc<int>()(42) 分两步读:第一对小括号在构造一个"临时对象"(仿函数实例),第二对小括号是调用它的 operator()。一个类型,两对小括号,这就是"仿函数"最经典的调用形态,我们在积木二还会专门讲。

为什么会报"string 不能强转"?因为 (size_t)key 要求 key 能被隐式或显式转换成一个无符号整数:int、char、枚举、指针都可以,但 string、自定义的结构体没有定义到整数的转换,强转直接就编译错误。这也是这一讲最后"对自定义类型开放"那节要解决的问题——string 也不能强转,所以必须给 HashFunc<string> 单独写个"哈希算法"。

积木二:仿函数(函数对象)

仿函数(Functor,Function Object)也叫函数对象,说穿了一个类重载了 operator()(调用运算符),于是这个类的对象就能像函数一样被调用。你一直在用却可能没注意过的最常见仿函数就是"小于号":std::less<int> 这个东西你听起来可能陌生,但它本质上就是 class less { bool operator()(int a, int b) const { return a < b; } },然后 less<int>()(3, 5) 就能像函数一样传参调用,结果是 true。std::sort 的第三个参数、std::set 的排序准则,用的都是这样的仿函数。

为什么泛型代码里偏爱仿函数而不是普通函数指针?至少有三个理由:

  1. 仿函数是"类型",可以当模板参数。模板参数必须是编译期能确定的类型,函数指针是运行期的"值",虽然也能当模板参数,但一个函数指针封装不了"状态"(内部成员变量)。仿函数可以内嵌成员变量,天然携带"状态"参与运算。
  2. 可被内联优化。函数指针在编译期不知道指向谁,往往要跳转调用;仿函数的 operator() 是确定的成员函数,编译器能直接把它的函数体"内联"进调用点,省掉一层调用开销。对哈希这种被高频调用的步骤,内联意义重大。
  3. 类型给你不同的"算法变体"。同一个"提 key"接口,SetKeyOfT 和 MapKeyOfT 是两个不同类型,编译器各自实例化,互不干扰;如果用函数指针,就得在运行时把"我该提 key 还是取 first"的逻辑写成两个分支。

所以你先有个印象:仿函数 = 能当函数用的类 = 模板参数的天然载体。这一讲里我们要写的 KeyOfT(K+ey 的提取器 Of T,即"从 T 里取出 K 的工具")本质就是一个仿函数,它的唯一使命是"从 T 类型对象里把关键字 K 抠出来"。哈希表自己不关心 T 到底是啥,它只认"能从 T 里掏出 K 的那个操作"——这正是解耦的灵魂。

积木三:pair 与迭代器

pair<A, B> 是 C++ 标准库里的一对"组合",专门用来把两个值捆在一起,first 存第一个,second 存第二个。map 里每个元素就是一个 pair<const K, V>——注意 first 是 const K,关键字不能改;second 是 V,值可以改。pair 的底层实现极其简单:就是个带两个公有成员的结构体,外加一堆比较和构造运算符。C++11 之后还有个有意思的机制:std::pair 提供了一个"模板转换构造函数",允许 pair<int, double> 这种具体类型自动转换成 pair<const int, double>(要求各元素可转换)。这个细节在 operator[] 那节会真正派上用场,先记住"pair 之间可以隐式转换"这句话。

再说迭代器(Iterator),它是对"指针"的抽象封装:一个迭代器对象内部保存一个"当前位置"的线索,然后通过重载 *(解引用)、->(箭头访问成员)、++(前移)、--(后移)、==、!=(比较)这一组运算符,让你能像用指针一样去遍历容器。哈希表的迭代器是单向迭代器(只能 ++,不能 --),原因是哈希桶的一维是单链表结构,单链表天生只支持"往后走",不支持"往回走";即便跨桶跳跃,你要倒着扫桶也得先回到起点,代价太大。单向迭代器在 C++ 标准里对应 forward_iterator_tag 这个迭代器类别,能做一轮前向遍历,但不能 random_access(随机访问),也没有 --。后面我要讲的迭代器里藏着两个指针(一个指向结点,一个指向哈希表),这是理解哈希表遍历的关键,先留个悬念。

好,基础铺完了。现在我们把历史翻出来看一眼,你就会明白"共用一个哈希桶"这个话题并不是我们拍脑袋想出来的。

为什么能共用一个哈希桶

你如果去翻 SGI-STL30 版本(C++11 之前的经典 STL 实现,SGI 是 Silicon Graphics 硅谷图形公司,这家公司贡献了一套质量极高的经典 STL 实现)的源码,会发现里面根本没有 unordered_map 和 unordered_set。这两个容器是 C++11 之后才进入标准的。那在 C++11 之前,STL 需要"无序的关联容器"怎么办?SGI 就自己实现了两个非标准的容器,名字叫 hash_map 和 hash_set,代码放在 stl_hashtable.h、stl_hash_map.h、stl_hash_set.h(以及它们对应的 hash_map.h、hash_set.h)这些文件里。"非标准"三个字很重要——非标准是指"不是 C++ 标准规定必须实现的",它靠的是实现者自己的良心,所以别指望它在每个编译器里都长得一样,你把它当成"SGI 私房菜"即可。

关键在于,你去看 hash_set 和 hash_map 的定义,会发现它们俩内部各自藏了一个 hashtable 对象作为成员,而那个 hashtable 是同一份类模板。也就是说,SGI 的大神们早就想明白了:set 和 map 的底层数据结构其实是同一个东西,区别只在于"往里面塞的元素长得不一样"。

具体怎么个不一样法?我给你看两行 SGI 源码的精髓(Value 是元素类型,Key 是关键字类型,HashFcn 是哈希仿函数,ExtractKey 是提 key 仿函数,EqualKey 是等值比较仿函数,Alloc 是内存分配器):

// hash_set 内部:value_type 和 key 都是 K,即"单值"
typedef hashtable<K, K, HashFcn, identity<K>, EqualKey, Alloc> ht;
 
// hash_map 内部:value_type 是整个 pair<const K, T>,key 是 K
typedef hashtable<pair<const K, T>, K, HashFcn,
                  select1st<pair<const K, T> >, EqualKey, Alloc> ht;

那个 hashtable 类模板一共有 6 个模板参数,我逐个给你翻译:第 1 个 Value 是"真正存进桶里的元素类型",第 2 个 Key 是"哈希表定位时只认的关键字类型",第 3 个 HashFcn 是"把 Key 变成整数的哈希仿函数",第 4 个 ExtractKey 是"从 Value 里把 Key 捞出来的提 key 仿函数",第 5 个 EqualKey 是"判断两个 Key 是否相等的仿函数",第 6 个 Alloc 是"内存分配器"。你可以把 hashtable 理解成一个"只对 Key 感兴趣"的公共引擎:算桶号只用到 Key(哈希值),比较相等也只用 Key(kot(x) == key),至于 Value 是 K 还是 pair<const K,T>,它压根不管——它通过第 4 个参数 ExtractKey 跟你"要" Key,这就是解耦的全部秘密。

你可以这样理解这两行代码:hash_set 传给哈希表的是"自己跟自己"——元素本身是 K,关键字取出来还是 K(identity<K> 原样返回),所以它存进桶里的是一个个孤零零的 K;而 hash_map 传给哈希表的是"一对"——元素是 pair<const K, T>,但关键字只取 first 那个 K(select1st 掏 first)。一个像单吃货只放一样食物,一个像双格饭盒塞了两层。于是"同一张哈希表"就同时喂饱了两个容器。

这个 identity、select1st 是 SGI 里的两个"小提货员",它们的完整定义长这样,逻辑简单到直白:

// identity:从 K 身上提取 K,即"原样返回自己"
template <class T>
struct identity
{
    const T& operator()(const T& x) const { return x; }
};
 
// select1st:从一个 pair 里掏出 first,即"取第一个字段"
template <class Pair>
struct select1st
{
    const typename Pair::first_type& operator()(const Pair& x) const
    { return x.first; }
};

观察一下它们的差异就是这一整讲最重要的"两个分工"的出处:identity 是 set 用的,因为 set 的元素本身就是关键字;select1st 是 map 用的,因为 map 的关键字藏在 pair 的 first 里。说白了,它们俩都是在哈希表问"喂进来的元素,关键字是什么"时,"负责去把关键字捞出来"的那个家伙。到了 STL 后来的标准实现里,这两个小提货员被统一抽象成了一个更官方的名字——KeyOfT 仿函数(T 是什么类型,就从这个类型里取 Key)。这个名字我们从现在就约定:KeyOfT 是一个能从"容器元素 T"里提取"关键字 K"的仿函数。它可能原样返回(set),也可能取 pair 的 first(map),取决于外层容器是谁——这正是"同一个底层,两种上层"的分水岭。

不过说句题外话,SGI 那批源码的命名风格是真乱,hash_set 的模板参数居然用 Value 来命名元素、用 Key 命名关键字,hash_map 又改用 Key 和 T(T 指关联值),翻起来很容易晕;连它自己家的 ExtractKey、Value、Key 在不同文件里指代也不完全一致。所以我们模拟实现时别照抄它的命名,按自己的清爽风格重写一遍:关键字一律叫 K,map 的关联值叫 V,哈希表里真正存储的"元素类型"叫 T。这样一眼就能分清:K 是定位用的钥匙,V 是 map 附带的数据,T 是桶里真正躺着的东西(set 的 T==K,map 的 T==pair<const K,V>)。

哈希桶模板化与仿函数提 key

SGI 的思路我们学明白了,接下来要落地成能编译的代码。第一步,先把哈希桶整体模板化——让它不要再把"元素"和"关键字"绑死成同一个类型。

回顾最基础的哈希桶,它的结点长这样,存的是"一个 T 类型数据加上一个指向下一个结点的指针"。这跟单链表结点一模一样,只是多了一层"被我所在的那张桶表带着跑"的身份:

#include <vector>
#include <string>
#include <utility>
#include <algorithm>   // lower_bound 用(取质数时)
#include <iostream>
using namespace std;
 
/*********** 1. 哈希仿函数:默认 + string 特化 ***********/
template<class K>
struct HashFunc
{
    size_t operator()(const K& key) const
    {
        return (size_t)key;   // 默认只对"能强转整数"的 K 有效
    }
};
 
// string 不能强转整数,单独特化一套"字符串哈希"算法
// 思路:逐个字符加权累加,得一个大整数。131 是经验常数
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key) const
    {
        size_t hash = 0;
        for (char c : key)
            hash = hash * 131 + (size_t)c;
        return hash;
    }
};
 
/*********** 2. 哈希桶的命名空间 ***********/
namespace hash_bucket
{
    // 哈希桶的结点:T 是"真正存进桶里的元素类型"
    // 对 set 来说 T 就是 K;对 map 来说 T 就是 pair<const K, V>
    template<class T>
    struct HashNode
    {
        T _data;                // 结点里存的数据
        HashNode<T>* _next;     // 指向桶内链表的下一个结点
 
        HashNode(const T& data) // 构造:只负责存数据,next 置空
            : _data(data)
            , _next(nullptr)
        {}
    };
}

注意 HashNode 里 _data 的类型是 T,_data 是值的拷贝(_data(data)),而不是指针——也就是说,每个结点在堆上 new 出来时,会完整地复制一份数据放进去。后面讲迭代器失效时你要记着:这个 _data 呆在独立的堆结点里,地址固定,直到被 delete 才消失。

现在最核心的一步:把哈希表类的模板参数从两个扩到四个——K(关键字类型)、T(元素类型)、KeyOfT(提 key 仿函数)、Hash(哈希仿函数)。这一改,哈希桶就从"专为一种容器服务"变成"通吃 map 和 set 的公共引擎"了。四个参数的职责各安其位:

  • K:哈希表"只认"的关键字类型,算桶号、比相等都靠它。
  • T:真正存进桶里的元素类型,T 可能等于 K(set),也可能是 pair<const K,V>(map)。
  • KeyOfT:从 T 里取出 K 的仿函数,这是解耦的关键钥匙。
  • Hash:把 K 映射成 size_t 的哈希仿函数。

下面我把 HashTable 类的完整框架先给你(类主体的所有成员方法我们现在就全写出来,不省略;为了先看"骨架",我把每个方法体都写实,看完这一段你会对它有一整张地图):

namespace hash_bucket
{
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable
    {
        // 声明自己是迭代器的朋友:迭代器的 operator++ 要访问私有的 _tables
        template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
        friend struct HTIterator;
 
        typedef HashNode<T> Node;    // 给"结点类型"起个别名,后面写起来短
 
    public:
        // 普通迭代器:*it 返回 T&,可读可改
        typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
        // const 迭代器:*it 返回 const T&,只读
        typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
 
    private:
        vector<Node*> _tables;       // 桶数组:每个元素是指向链表头的指针
        size_t _n = 0;               // 当前表里存的数据个数
    };
}

这里有三件事先说明白。第一,HTIterator 这部迭代器戏码我们还没写到,这里的 friend 声明是"提前打好招呼"——等等你看到它对私有 _tables 的访问就不会奇怪了。第二,Iterator 和 ConstIterator 不是两个不同的类,而是同一个 HTIterator 类模板用不同 Ptr/Ref 实例化出来的两个别名:传 T*, T& 就是可改版,传 const T*, const T& 就是只读版——这是"一鱼两吃"的封装技巧,等会儿专门讲。第三,真正完整的 HashTable 远不止这几行,Insert/Find/Erase/扩容 都排在后面几节陆续登场,前面这几节讲的每个方法都会补进这个类里,最后你会看到一份拼好的完整实现。

这里有个贯穿全堂的重头戏:哈希表内部永远不去 T 里直接比大小,更不去 pair 里比 second,它只认 K。所有"取关键字、算桶号、比较相等"的动作,一律通过 KeyOfT 这个仿函数来做。这样哈希表这个底层根本不知道 T 到底是 K 还是 pair<const K,V>,它跟具体元素类型彻底解耦了。这就是这一讲标题里的"解耦"二字的真正含义——底层不认识具体类型,只认识"能取出 K 的操作"。

举个生活里的类比:哈希表像一个大公司的行政前台,它不关心进来的"访客"是谁(set 的一个 K,还是 map 的一个 pair),它只认"访客出示的证件号"(Key)。为了拿到证件号,它跟不同访客约定不同的"出示方式"(KeyOfT):如果访客就是一张证件(set),就原样递上来(identity);如果访客是"证件 + 背包"的组合(pair),就只把证件从背包里掏出来(select1st)。前台不用懂背包里还有什么,它的世界只有"证件号对应哪个房间(桶)"这一件事。

关于桶数,还有个小工具必须提——__stl_next_prime。构造 HashTable 时,桶数组的长度不是随便定的,而是从一张精心设计的质数表里挑一个"质数"。为什么非要用质数当桶数?因为取模运算 h % m 的分布特性取决于模数 m:如果 m 是合数(比如 8),而关键字的哈希值恰好多是 8 的倍数或与 8 同余,那么取模结果会被"压扁"到几个固定桶里,形成周期性聚集;而质数与大多数哈希值互质,取模结果更均匀地扩散到所有桶,冲突更少。__stl_next_prime 的实现用一个静态质数表 + 二分查找(lower_bound)找出"不小于给定值的最小质数",代码长这样(它就是 stl_hashtable.h 里那段经典质数表的精简版):

namespace hash_bucket
{
    template<class K, class T, class KeyOfT, class Hash>
    inline unsigned long __stl_next_prime(unsigned long n)
    {
        // 一份精心挑选的质数序列:53, 97, 193, ... 由一个翻倍再加的逻辑生成,
        // 保证"扩容后桶数仍为质数",避免取模聚集
        static const unsigned long __stl_prime_list[] =
        {
            53ul, 97ul, 193ul, 389ul, 769ul, 1543ul, 3079ul, 6151ul,
            12289ul, 24593ul, 49157ul, 98317ul, 196613ul, 393241ul,
            786433ul, 1572869ul, 3145739ul, 6291469ul, 12582917ul,
            25165843ul, 50331653ul, 100663319ul, 201326611ul, 402653189ul,
            805306457ul, 1610612741ul, 3221225473ul
        };
 
        const unsigned long* first = __stl_prime_list;
        const unsigned long* last = __stl_prime_list +
                                     sizeof(__stl_prime_list) / sizeof(unsigned long);
        // 二分查找:找到第一个不小于 n 的质数
        const unsigned long* pos = lower_bound(first, last, n);
        return pos == last ? *(last - 1) : *pos;   // 都没有就不小于的最大质数
    }
}

看到 53, 97, 193, 389, ... 这个节奏了吗?基本都是"翻倍再加一点"。为什么要这么大费周章?因为 HashTable 的构造函数要通过它把桶数组初始化成质数个,而每次扩容后仍然要落到下一个质数上——保证任何时刻桶数都是质数。lower_bound 在有序序列上二分查找,代价是 O(log 质数个数),而这个质数表总共才 27 个元素,所以几乎可以忽略。你只要记住:构造时和扩容时,都拿"下一个更大的质数"当新桶数就够用了。

到这里,底层哈希桶的"认识框架"已经搭起来了。接下来是它的两项核心工作:查(Find)和插(Insert),好戏才真正开始。

插入的统一:KeyOfT 的分工

哈希表脉络清晰了,我们来看它肚子里最重要的两件事:Find(查找)和 Insert(插入)。它们俩的灵魂动作都是"用 KeyOfT 掏出 K,再用 Hash 算桶号"。看懂这两个函数,你就同时看懂了"为什么一份哈希表能服务两种容器"。

先看 Find。给定一个关键字 K,要做的就三步:算出它该在哪个桶 → 顺着该桶的链表往后走 → 每经过一个结点都用 kot(结点数据) 取出 K 跟目标 key 比相等。其中 kot 是 KeyOfT 的实例(临时对象),hs 是 Hash 的实例:

// 在哈希表里按关键字查找,找到返回指向该结点的迭代器,找不到返回 end
Iterator Find(const K& key)
{
    KeyOfT kot;                                  // 造一个"提 key 工具"出来
    Hash hs;                                     // 造一个"哈希工具"出来
    size_t hashi = hs(key) % _tables.size();     // 算目标关键字该在哪个桶
    Node* cur = _tables[hashi];                  // 从该桶的链表头开始
    while (cur)                                  // 链表没走完就继续
    {
        if (kot(cur->_data) == key)              // 取结点里的 K 与要找的 key 比相等
        {
            return Iterator(cur, this);          // 命中,返回该结点的迭代器
        }
        cur = cur->_next;                        // 没命中,顺着链表往下走
    }
    return End();                                // 整条链都走完还没找到,返回 end
}

留意 kot(cur->_data) == key 这一行,这是全篇的分水岭。对 set 来说 cur->_data 本身是 K,kot 原样返回它;对 map 来说 cur->_data 是个 pair<const K,V>,kot 取出 first。同一个 Find,两种容器都能用,靠的就是 KeyOfT 内部的不同分工——底层只问"取出来的 Key 跟我要的 Key 相等吗",至于"Key 是从一个 K 里原样拿的,还是从一个 pair 的 first 里掏的",它完全不关心。

这两个仿函数逻辑极其直白,我现在就给你写全。注意一个非常重要的细节:SetKeyOfT 的 operator() 接收的是 const K&,返回 const K&——返回 const 引用而不是拷贝,是为了不产生临时对象的拷贝开销(对 string 这种大对象尤其重要);而 MapKeyOfT 接收的形参写的是 const pair<K, V>&,为什么用 pair<K,V> 而不是 pair<const K,V>?因为 first 已经是 const?恰恰相反——这道题很巧妙:Find 里调用 kot(cur->_data),而 map 的外壳会把元素定为 pair<const K,V>,那时 cur->_data 是 const pair<const K,V>。我们的 MapKeyOfT 形参写成 const pair<K,V>&,靠 pair 的模板转换构造函数,const pair<const K,V> 也能隐式绑定到这个形参上来(K→const K 可转换)。把它写成"非 const 的 K"反而更宽松、更能兼容"外壳暂未加 const"的中间版本。这是设计上的巧思,看懂即可:

// set 的"提 key 工具":元素本身就是 K,原样返回
struct SetKeyOfT
{
    const K& operator()(const K& key) const
    {
        return key;        // 自己就是自己,打平返回(对应 SGI 的 identity)
    }
};
 
// map 的"提 key 工具":从 pair 里掏出 first 当作关键字
struct MapKeyOfT
{
    const K& operator()(const pair<K, V>& kv) const
    {
        return kv.first;   // 一只抓手伸进 pair,把 first 捞出来(对应 SGI 的 select1st)
    }
};

为什么 SetKeyOfT 偏偏要"原样返回"而不能直接省略它、让哈希表自己拿 x 当 key?因为哈希表的模板参数是死的——它要求"任何一个 T 必须能通过 KeyOfT 取出 K"。若 set 也省了 KeyOfT,那哈希表的 Find/Insert 还要为"T==K"和"T==pair"写两种分支,泛型统一性就崩了。统一接口,细分实现,这正是 KeyOfT 存在的意义:就算 set 的"取出"退化成"原样返回",这个"取"的动作也必须存在,因为哈希表只知道"从 T 取 K"这一个动作。

接着是 Insert,也就是"插进去之前必须先查重"。哈希表是关联容器,不允许重复关键字,所以插入的流程是:先用 Find 查一遍,已经存在就直接返回 (那个位置的迭代器, false) 告诉调用方"没成功,但东西已经在里面了";不存在才正式插入,并返回 (新结点迭代器, true)。这个 pair<Iterator, bool> 的返回设计不是心血来潮——它是后面 operator[] "没有就建、有就取"的地基,也是标准库 unordered_map::insert 的真身:

// 插入:返回 (指向已存在/新插入元素的迭代器, 本次是否真正插入)
pair<Iterator, bool> Insert(const T& data)
{
    KeyOfT kot;                        // 提 key 工具
    Iterator it = Find(kot(data));     // 先查这个元素的关键字在不在
    if (it != End())                   // 在,说明重复了
        return make_pair(it, false);   // 直接返回已存在位置,插入失败(去重)
 
    Hash hs;
    size_t hashi = hs(kot(data)) % _tables.size();   // 算该放哪个桶
 
    // 负载因子 == 1(数据个数 == 桶个数)就扩容,避免冲突越积越深
    if (_n == _tables.size())
    {
        // 新表挑一个更大的质数桶数
        vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
        for (size_t i = 0; i < _tables.size(); i++)
        {
            Node* cur = _tables[i];
            while (cur)    // 每个桶里的链表结点全部搬走
            {
                Node* next = cur->_next;              // 先记录下一个,防止搬丢
                size_t h = hs(kot(cur->_data)) % newtables.size();  // 在新表里重新算桶
                cur->_next = newtables[h];            // 头插到新表的对应桶
                newtables[h] = cur;
                cur = next;
            }
            _tables[i] = nullptr;                     // 旧桶清空,防悬垂毛边
        }
        _tables.swap(newtables);                      // 换成全新的桶数组(旧数组随局部变量析构释放)
    }
 
    Node* newnode = new Node(data);   // 造新结点(堆上分配,拷贝一份 data)
    newnode->_next = _tables[hashi];  // 头插:新结点指向原链表头
    _tables[hashi] = newnode;         // 桶头更新为新结点
    ++_n;                             // 计数 +1
    return make_pair(Iterator(newnode, this), true);  // 返回新结点,插入成功
}

这里有三处细节值得你停留在上面多看两眼:

第一,去重为什么用 Find 而不是自己在 Insert 里再写一遍链扫? 因为 Find 已经封装好了"算桶 → 顺链 → KeyOfT 取 Key → == 比较"这套完整逻辑,Insert 直接复用即可。你看,Find 里那句 kot(cur->_data) == key,对 set 和 map 都成立——这里正是"分工"大放异彩的地方。

第二,扩容是"搬结点",不是"复制结点"。 注意 new Node(data) 没有出现在扩容循环里;循环里只有一个 cur->_next = newtables[h];——它把堆上既有的那个结点指针从旧桶解下来,头插到新桶的对应位置。所以结点对象本身是内存里 new 出来的真实对象,地址从头到尾没变,变的只是"它挂在哪个桶"这个关系。这个细节直接关系到后面迭代器会不会失效,我先把话放在这儿:结点地址稳定 → operator[] 返回的引用安全;迭代器依赖的"桶上下文"变了 → 迭代器失效。

第三,swap 是"换桶不换核"。 _tables.swap(newtables) 交换的是两个 vector 内部的指针,成本 O(1);交换后 _tables 指向全新的桶数组,newtables(局部对象)在函数返回时析构,顺手把旧的桶数组释放掉。整个过程数据结点一个都没动。

还有一点值得讲清:为什么扩容阈值是"_n == _tables.size()"而不是"数据个数超过某个比例"?因为在结点个数恰好等于桶个数时,负载因子正好是 1,平均每桶 1 个结点——这是"期望 O(1)"与"内存开销"的平衡点。再往里塞,平均每桶就超过 1 个结点了,冲突恶化,所以 STL 控制在 1 附近触发扩容。这也是链地址法哈希表"均摊 O(1)"的来源:虽然单次插入最坏要 O(n)(全挤进一个桶),但扩容把负载因子压住,从概率上保证了平均常数时间。

到这里,底层哈希桶已经"不认识任何具体容器"了——它就是一个纯靠 K + KeyOfT + Hash 运转的通用引擎。接下来最难的一张牌要登场了:迭代器。而在此之前,HashTable 还缺一个反向动作 Erase,它和 Find 一样靠 KeyOfT 定位、靠 == 命中,只是命中后要"摘链、delete、减计数"。外壳层(set/map)调用的 erase 会转发到这里,所以底层必须有它。实现也不复杂,关键在于摘链时要维护前驱指针 prev,因为单链表"删除"的本质是"让前驱跳过自己",而头结点的前驱是空的,得单独处理:

// 删除:按关键字删除,找到并删掉返回 true,没找到返回 false
bool Erase(const K& key)
{
    KeyOfT kot;                          // 提 key 工具
    Hash hs;
    size_t hashi = hs(key) % _tables.size();   // 定位桶
    Node* prev = nullptr;                // 前驱指针,初始为"头结点之前"(即没有前驱)
    Node* cur = _tables[hashi];          // 从链表头开始
    while (cur)
    {
        if (kot(cur->_data) == key)      // 命中
        {
            if (prev == nullptr)         // 要删的是链表头
                _tables[hashi] = cur->_next;     // 直接把桶头换成下一个
            else                         // 要删的是中间/尾部结点
                prev->_next = cur->_next;        // 让前驱跳过它
            delete cur;                  // 释放堆结点,此时指向它的迭代器"悬垂"了
            --_n;                        // 计数 -1
            return true;
        }
        prev = cur;                      // 没命中,前驱走到当前,当前走到下一个
        cur = cur->_next;
    }
    return false;                        // 整条链都没有,返回 false
}

注意 Erase 里 delete cur 这一行,它是"迭代器悬垂"的元凶:被删结点的堆内存被释放,但某个别处还攥着指向它的迭代器,再拿它解引用就是践踏已释放内存(野指针/悬垂指针)。这一点在迭代器那一节我们会反复敲打。

到这里,底层哈希表就齐了:Find 查、Insert 插(含扩容)、Erase 删、Begin/End 取哨兵。而 Find 和 Erase 的"定位"逻辑(算桶 → 顺链 → KeyOfT 取 Key → == 比较)逐字一致,你会看到"分工"在这两个函数里被复用到了极致。

哈希表的迭代器与遍历

map 和 set 都要能"从头到尾遍历一遍",这得靠迭代器。哈希桶的迭代器设计藏着一个典型难点:当当前桶的链表走完之后,怎么跳到下一个非空桶?

回想一下链表/list 的迭代器:它内部只存"一个结点的指针",往下一个结点就是 node = node->next,结构简单。它之所以行,是因为 list 是纯一维结构——每个结点天生带着"下一个是谁"的指针,凭一个结点就能走完全部。但哈希桶不一样,它是"桶数组 + 桶内链表"的二维结构——你只拿一个结点指针,走进当前桶的末尾就傻了:当前结点的 next 已经是 nullptr(这条链到头了),而"下一个非空桶在哪"这个信息,光靠结点指针根本问不出来,因为桶与桶之间没有任何指针相连,桶之间的连接信息只属于"那张表"。

SGI 的解法很绝:迭代器里不放一个指针,而是放两个——一个指向当前结点(_node),另一个指向哈希表对象本身(_pht)。这样当当前桶走完时,就可以拿着"指向哈希表的指针"去翻桶数组,重新算出当前结点原本的桶号,往后再逐个桶找第一个非空的。这两个指针各司其职:

  • _node:负责"当前在哪"——解引用 *it 取数据,走桶内 next。
  • _pht:负责"当桶尽了往哪逃"——提供桶数组,配合当前结点的 Key 反推桶号,逐个桶扫非空。

这就是为什么前面我说"桶里存链表头、结点都在链表里"这条认知重要。你看 operator++ 的实现,先试"当前桶里还有没有下一个";没有,才动用 _pht 翻桶数组。两个指针缺一不可:

namespace hash_bucket
{
    // 前置声明:迭代器里要用到哈希表类型,先打个招呼
    template<class K, class T, class KeyOfT, class Hash> class HashTable;
 
    // 哈希表的迭代器(单向迭代器,只能 ++)
    // Ptr/Ref 用来区分"普通迭代器"和"const 迭代器"(一鱼两吃,见下方说明)
    template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
    struct HTIterator
    {
        typedef HashNode<T> Node;                          // 结点类型别名
        typedef HTIterator<K, T, Ptr, Ref, KeyOfT, Hash> Self;  // 自己的类型
 
        Node* _node;                                       // 指向当前结点
        const HashTable<K, T, KeyOfT, Hash>* _pht;         // 指向哈希表,用于桶间跳转
 
        HTIterator(Node* node, const HashTable<K, T, KeyOfT, Hash>* pht)
            : _node(node)
            , _pht(pht)                                    // 两个指针都要带上
        {}
 
        Ref operator*()      // 解引用:返回当前结点的数据
        { return _node->_data; }
 
        Ptr operator->()     // 箭头:返回数据的地址,方便 it->first 这种写法
        { return &_node->_data; }
 
        bool operator!=(const Self& s) const   // 比较:只看结点指针是否相同
        { return _node != s._node; }
        bool operator==(const Self& s) const
        { return _node == s._node; }
 
        Self& operator++();                    // 前向 ++ 的实现单独拎出来写,见下
    };
 
    // operator++ 的实现:核心难点在"桶走完了要跨桶"
    template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
    HTIterator<K, T, Ptr, Ref, KeyOfT, Hash>&
    HTIterator<K, T, Ptr, Ref, KeyOfT, Hash>::operator++()
    {
        if (_node->_next)
        {
            // 情况一:当前桶里还有下一个结点,直接往后挪(桶内前进,跟单链表一样)
            _node = _node->_next;
        }
        else
        {
            // 情况二:当前桶走完了,需要跳到下一个非空桶
            KeyOfT kot;
            Hash hs;
            // 用当前结点的关键字反推出"我原本呆在哪个桶"
            size_t hashi = hs(kot(_node->_data)) % _pht->_tables.size();
            ++hashi;                 // 从下一个桶开始找
            while (hashi < _pht->_tables.size())
            {
                if (_pht->_tables[hashi])   // 找到第一个非空桶
                    break;
                ++hashi;
            }
            if (hashi == _pht->_tables.size())
                _node = nullptr;            // 所有桶都扫完也没有非空的 → 设成 end() 哨兵
            else
                _node = _pht->_tables[hashi];   // 跳到那个非空桶的链表头
        }
        return *this;
    }
}

你能明显感到,如果没有 _pht 这个指向哈希表的指针,operator++ 在"当前桶走完"时根本无路可走——这是哈希表迭代器和 list 迭代器最本质的差别:list 迭代器靠单链,哈希表迭代器靠"结点 + 整张表"两个线索。这也是为什么 HTIterator 必须在 HashTable 里声明为 friend(朋友类):跨桶跳转时它要读 _pht->_tables(私有成员)和 _pht->_tables.size()。不给友元权,编译就报"无法访问私有成员"。

再看 operator++ 的两个细节:

  • **为什么跨桶时要"反推桶号"而是不记录当前桶号?**因为迭代器只存两个指针,没存"当前桶号"这个冗余信息。反推的做法是:拿当前结点 _data,经 KeyOfT 取 Key,再 % _tables.size() 得到它此刻所属的桶,然后从这个桶 +1 开始往右扫。逻辑自洽、无需额外存储。代价是每次"桶尽"都重算一次哈希——但由于"桶尽"只在走到每条链末尾时发生,均摊开销不大。
  • **为什么 _node = nullptr 表示 end()?**这是一个设计约定:end() 返回 Iterator(nullptr, this),空结点指针就是"结束哨兵"。当 ++ 扫完所有桶仍没找到非空桶,就把 _node 置空,让它碰巧等于 end(),遍历循环条件 it != s.end() 自然退出。用空指针当哨兵,省一个专门的哑结点,是链式容器常见的偷懒手法。

还有复杂度问题值得说透:operator++ 最坏要扫完整张桶表(O(桶数)),但那是"桶尽"时才发生,而负载因子≈1 时平均一桶一个结点,绝大多数 ++ 走的是情况一(O(1))。所以一趟完整遍历的时间复杂度是 O(桶数 + 结点数),接近 O(n)。

有了迭代器,哈希表就该提供 Begin() / End() 了。End() 用空指针表示"结束哨兵";Begin() 返回第一个非空桶的链表头。注意它内部还要配套提供 const 版本(Begin() const / End() const),返回 const 迭代器,让只读遍历也能用:

// 普通迭代器版本:能读能改
Iterator Begin()
{
    if (_n == 0)                       // 表空,直接返回 end
        return End();
    for (size_t i = 0; i < _tables.size(); i++)   // 从头找第一个非空桶
    {
        Node* cur = _tables[i];
        if (cur)                       // 桶非空,它的链表头就是起点
            return Iterator(cur, this);
    }
    return End();
}
 
Iterator End()
{
    return Iterator(nullptr, this);    // 空指针作为结束标记
}
 
// const 迭代器版本:只读
ConstIterator Begin() const
{
    if (_n == 0)
        return End();
    for (size_t i = 0; i < _tables.size(); i++)
    {
        Node* cur = _tables[i];
        if (cur)
            return ConstIterator(cur, this);
    }
    return End();
}
 
ConstIterator End() const
{
    return ConstIterator(nullptr, this);
}

四个函数长得像复读机,但你别嫌烦——它们的差异藏在"this 的 const 性"上:Begin() 里 this 是非 const,能返回可读可改的 Iterator;Begin() const 里 this 是 const,只能返回只读的 ConstIterator。只要 myunordered_set/myunordered_map 的成员 begin()/end() 做一层转发,就能让"const 容器只能拿 const 迭代器、非 const 容器能拿普通迭代器"这件事自动成立,而不用为 const 容器单独写一套遍历逻辑。

迭代器绝不能只留一版——因为当你用 const mymap 遍历时,拿到的必须是只读的 const 迭代器,否则光一个 *it = xxx 就能改坏数据。所以我们用 Ptr/Ref 两个模板参数让同一个 HTIterator 同时担任"普通迭代器"和"const 迭代器":传 T*, T& 就是可改版,传 const T*, const T& 就是只读版。这是封装里一个很常见的"一鱼两吃"双胞胎技巧,跟 list 迭代器的做法一脉相承。你看 operator* 返回 Ref、operator-> 返回 Ptr,当 Ref=const T& 时,*it 返回的是 const 引用,你想赋值都赋不进去——类型系统在编译期就把"改"这条路堵死了,这正是 myunordered_set 要用 const K 当元素的底层支撑。

现在可以解释上面 HashTable 里的 Iterator/ConstIterator 两张别名了:

// 普通迭代器:*it → T&,-> 返回 T*
typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
// const 迭代器:*it → const T&,-> 返回 const T*
typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;

同一个类模板,四次实例化参数不同,就生产出两种行为互斥的迭代器——这就是"模板复用一类型、参数产生两行为"的妙处。

还有个坑值得专门拎出来讲:扩容会让迭代器失效。迭代器里存的 _node 是结点指针 Node*,它指向堆上那个新出来的结点对象。扩容时会发生两件事:第一,桶数组被整体换掉(_tables 指向新数组、容量变大);第二,所有结点被重新分配到新桶的链表里(头插到新位置)。于是旧迭代器陷入一个尴尬境地:

  • 它的 _node 还指向那个结点,而结点地址没变、_data 也没变,所以 *it 可能还读得到数据——但注意,在标准库的规则里,一次扩容(rehash)就已经让全部迭代器"形式上作废"了,即便这次恰好没越界,你也绝不能依赖这种侥幸。
  • 它的 operator++ 依赖 _pht->_tables 来做跨桶跳转。扩容后桶数变了、桶数组也全新了,旧迭代器再 ++,会拿着"扩容前"的记忆去"扩容后"的表里瞎跑:它算出的桶号、找出的"下一个"很可能已不是它还停留的这个结点的真实后继——换句直白话,"遍历的次序在整个表上已经重排过一遍了"。更要命的是,若是 Erase 把某个结点 delete 了,指向它的迭代器直接变成"悬垂指针",再解引用就是踩踏已释放内存,行为未定义,轻则读到脏数据,重则进程崩溃。

所以记住一条铁律:只要发生过插入引发扩容(rehash)、或者删除过结点,之前拿到的迭代器就不要再用了,得从头 begin() 重新取。这是 STL 关联容器的一条通用礼仪——unordered_map 官方文档就写着"rehash 后迭代器全部失效"。这也解释了为什么 mymap[key] = x 这样一句"改值"操作其实暗含了一次 Find(所以 O(1)),而连续多次 operator[] 每次都是安全的独立操作,不会踩迭代器失效的雷。

为了避免你"读时没感觉、用时翻车",我特别给一个可以自己跑的演示程序——它告诉你两件事:一是 insert 到一定量会触发扩容(桶数 BucketCount() 会从 53 跳到 97 甚至更大);二是一个错误示范:保存旧的 begin() 迭代器,插入若干新元素触发扩容后再 ++,行为就不可靠了。演示代码故意把"错误"标记出来,不是让你照抄,而是让你亲眼看到问题:

mystl::myunordered_set<int> s;
for (int i = 0; i < 53; i++)            // 塞满 53 个,桶数正好 53
    s.insert(i);
cout << s.BucketCount() << endl;        // 输出 153(第一个质数桶数,真实实现会各异,仅演示思路)
 
// 危险示范:把老的 begin 迭代器先存下来
auto old_it = s.begin();
// 再插入大量元素,这次插入会触发多次扩容
for (int i = 53; i < 200; i++)
    s.insert(i);
// 此时 old_it 代表的"下一个"在扩容后可能早已不是它以为的位置了
// ++old_it;   // ← 不要这么干!可能跳过元素、可能重复、甚至越界,属于未定义行为

把 old_it 那种"扩容前取到的迭代器"在扩容后丢进循环,就是课堂作业里的经典迷思区。正确姿势永远是:要在容器"安静"时只用迭代器,任何写入/删除操作过后就重新 begin()。

到这里,底层哈希桶已经"武装到牙齿"了:认识型别、能插、能查、能删、能遍历。外壳在招手,接下来我们给它套上两层皮。

set 的封装:单值

哈希桶这个"引擎"造好了,现在要往它身上套两个"外壳"。先套最轻的 myunordered_set。它内部持有哈希表的对象,然后做四件事:定义 SetKeyOfT 提 key 仿函数、把哈希表的关键字/元素都定为 K、把迭代器类型 typedef 出来、对外暴露插入/查找/删除/遍历这些接口。

这里有一个必须处理的细节:set 的单值元素不允许被修改。为什么?因为哈希表是用关键字的哈希值来决定桶位置的——如果你能改掉元素,那么"改之前它挂在第 3 桶,改完之后它的哈希值该进第 9 桶",但它在物理上还躺在第 3 桶里,这样 Find 按新值去第 9 桶找,就永远找不到它了,整个容器就乱套了。换句话说,哈希表的桶定位 = 关键字的"签名",一旦签名变了,这个元素就"死"在错误的桶里,再也无法被找到(这叫"把 key 改成一个哈希值对不上的值",是哈希容器的结构性问题)。所以 STL 的做法是:把 set 的第二个模板参数(哈希表里的元素类型 T)直接设成 const K,让迭代器 *it 返回 const K&,从类型层面就把"改"这条路堵死——你想改都改不了,因为编译器根本不允许对一个 const K& 赋值。

namespace mystl
{
    template<class K, class Hash = HashFunc<K>>
    class myunordered_set
    {
        // set 的提 key 仿函数:元素就是 K,原样返回
        struct SetKeyOfT
        {
            const K& operator()(const K& key) const { return key; }
        };
 
    public:
        // 从哈希桶里借用迭代器类型(依赖类型必须加 typename)
        typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
        typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::ConstIterator const_iterator;
 
        iterator begin() { return _ht.Begin(); }
        iterator end()   { return _ht.End(); }
        const_iterator begin() const { return _ht.Begin(); }
        const_iterator end() const   { return _ht.End(); }
 
        // 插入:去重由底层哈希表负责,传入单个 K
        pair<iterator, bool> insert(const K& key)
        { return _ht.Insert(key); }
 
        iterator find(const K& key)
        { return _ht.Find(key); }
 
        bool erase(const K& key)
        { return _ht.Erase(key); }
 
        size_t size() const { return _ht.Size(); }
 
    private:
        // 关键!元素类型用 const K —— 迭代器解引用得到 const K&,想改都改不了
        hash_bucket::HashTable<K, const K, SetKeyOfT, Hash> _ht;
    };
}

你看出来了吗:myunordered_set 整层楼几乎没写什么新逻辑,它把脏活累活全部甩给了底层哈希表,自己只负责四件事——SetKeyOfT 告诉底层"关键字怎么取"、类型别名让用户用起来像标准容器、const K 保证不可改、然后把上层的 insert/find/erase 机械地转发给 _ht。这就是"封装"最朴素的形态:外壳决定对外长相,引擎决定内在能力。

顺带解释那个"神秘"的 typename 关键字。hash_bucket::HashTable<...>::Iterator 是一个依赖类型(因为 Iterator 是某个模板参数实例化后的成员类型,编译器在"看到 ::"时还无法确定它到底是个类型还是个值,必须用 typename 显式声明"这后面跟着的是个类型")。不写 typename,GCC/Clang 会直接报错;写了,编译器才能继续推导。这是写泛型容器外壳时必须养成的习惯,你以后凡是从"模板类里掏类型"都会碰到它。

接口形状也讲究:insert 返回 pair<iterator, bool>(插入成功与否),find 返回 iterator(找不到返回 end()),erase 返回 bool(删没删到)——这是 STL unordered_set 的招牌形状,我们照搬过来,为的是让用户用起来"顺手"、跟标准库肌肉记忆一致。

验证一下它好不好用,写个小测试。注意测试里 for (auto e : s) 这条范围 for 语法,它其实被编译器展开成 begin()/end()/operator++/operator!= 的机械循环——也就是说,范围 for 能跑起来的前提,正是我们前面辛辛苦苦写的那四个 Begin/End 和迭代器的 ++、!=、*:

void test_set()
{
    mystl::myunordered_set<int> s;                    // K 是 int,用默认哈希
    int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14, 15 };
    for (auto e : a)
        s.insert(e);                                  // 重复的 15 会被底层去重
 
    // 用范围 for 遍历(底层其实走的就是迭代器)
    for (auto e : s)
        cout << e << " ";                             // 无序输出所有不重复的元素
    cout << endl;
 
    mystl::myunordered_set<int>::iterator it = s.begin();
    while (it != s.end())                             // 手动迭代
    {
        // *it += 1;              // 错误!元素类型是 const K,禁止修改
        cout << *it << " ";
        ++it;
    }
    cout << endl;
}

那一句被注释成"错误"的 *it += 1;,正是 const K 这份"封印"在替你挡灾难。你把这行注释打开,编译器会冷冷地告诉你"表达式必须是可修改的左值"——因为 *it 返回的类型是 const K&,对一个 const 引用做 += 不被允许。类型系统的这种"编译期拦截"价值千金:与其让 bug 在运行时炸开,不如让你 5 分钟都编译不过去。

map 的封装:pair 与 operator[]

set 那层壳弹出来很容易,map 这层壳要稍微复杂一点,因为它的元素是一个 pair<const K, V>,同时也因为它需要一个 STL 里极其常用的操作——operator[](下标运算符)。

跟 set 对照着看,差别主要在四处。第一,MapKeyOfT 要从 pair 里把 first 掏出来当关键字。第二,哈希表实例化的元素类型是 pair<const K, V>,注意 first 是 const——这保证了用户能改 second(值)却不能改 first(关键字),跟 set 用 const K 是同一个道理,一个封 key,一个封 key+锁。第三,operator[] 的实现(灵魂,马上单独讲)。第四,insert 的参数从"单个 K"换成"整个 pair"。

namespace mystl
{
    template<class K, class V, class Hash = HashFunc<K>>
    class myunordered_map
    {
        // map 的提 key 仿函数:从 pair 里掏出 first 作为关键字
        struct MapKeyOfT
        {
            const K& operator()(const pair<K, V>& kv) const
            { return kv.first; }
        };
 
    public:
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::ConstIterator const_iterator;
 
        iterator begin() { return _ht.Begin(); }
        iterator end()   { return _ht.End(); }
        const_iterator begin() const { return _ht.Begin(); }
        const_iterator end() const   { return _ht.End(); }
 
        // 插入一个键值对
        pair<iterator, bool> insert(const pair<K, V>& kv)
        { return _ht.Insert(kv); }
 
        iterator find(const K& key)
        { return _ht.Find(key); }
 
        bool erase(const K& key)
        { return _ht.Erase(key); }
 
        size_t size() const { return _ht.Size(); }
 
        // operator[] 的具体实现见下方"第三点"详解
        V& operator[](const K& key);
 
    private:
        // 元素类型是 pair<const K, V>:first 不可改,second 可改
        hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
    };
}

第三点,也是 map 的灵魂:operator[]。它的语义是"取下标",但对 map 来说这个下标是一位关键字。你不理解它的设计,很容易踩坑。它做的是这样一件事:用 key 去插入一个 (key, 默认值)——如果 key 第一次出现,就成功插入一个默认构造的值;如果 key 早已存在,那么插入失败,但返回值里的迭代器依然指向那个已存在的位置。最后返回 ret.first->second,即"那个位置的值的引用"。它的实现可以这样写:

// operator[]:要么返回已有值的引用,要么"造一个默认值插进去再返回引用"
V& myunordered_map<K, V, Hash>::operator[](const K& key)
{
    // 用"默认构造的 V"作为值去插入;插不成功说明 key 已存在,反正都能拿到位置
    pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
    return ret.first->second;   // 返回这个位置上的值(引用)
}

(因为 myunordered_map 是类模板,所以成员函数在类外定义时要写 template<class K, class V, class Hash> 前缀和 <K, V, Hash> 限定符;在实际教学代码里,通常直接在类内短小地写好它。)

这一句 make_pair(key, V()) 里其实藏着一个 std::pair 的隐式转换:make_pair 返回的是 pair<K, V>(first 非 const),而 _ht.Insert 期望的参数是 const T& = const pair<const K, V>&。标准库的 pair 提供了模板转换构造函数 template<class U, class W> pair(const pair<U, W>&),它能把 pair<K, V> 转成一个 pair<const K, V>(K → const K 可提升),于是编译器悄悄替你在这个临时对象上多考了一份。这个隐藏转换,正是"底层吃 const K、上层给 K"之间的润滑剂——你甚至感觉不到它的存在,因为它发生得那么自然。

这一来,四件看起来无关的小事就全被这一个 operator[] 统一接住了。英文里管它叫 "subscript" 语义,中文习惯说"取下标",但别忘了:mymap[key] 是"按需创建"的语义,不是"纯粹读取"的语义。四件事分别是:

dict["hello"];            // 读:key 不存在 → 插一个 ("hello", "") 再返回空串引用
dict["hello"] = "你好";    // 写:key 不存在 → 插;已存在 → 覆盖值
dict["hello"] += "!";      // 读改写:拿到引用后自增拼接
cout << dict["hello"];     // 读:已存在 → 返回当前值(注意:若不存在会插空值!)

你只要把一个简单的道理记牢:mymap[key] 一定会让 key 存在——哪怕你只是"读"一下 mymap[key],如果它不存在,它也会被凭空插进去一个默认值。这就是为什么如果只是想知道 key 在不在,应该用 find 而不是 operator[],否则会无意中污染容器(给"查询"平白新增了一条数据)。这也是 unordered_map 文档里反复提醒"别用 operator[] 做纯查找"的原因。find 和 operator[] 的分工,一句话讲透:

  • find(key):只问"在不在",不在就返回 end(),不给容器添任何东西。
  • operator[](key):"要么给,要么造",永远保证 key 存在,可以安全地 []=。

生活里类比一下:operator[] 像一个"自助寄存柜取物"——你刷卡(key),如果柜子里有东西就给你,没有就当场放一件空货进去再还给你。所以你想"检查柜子里有没有",得先用"查看"按钮(find);你直接刷卡取,就会被强行塞一件空货。

验证一下它好不好用,写个小测试。特别留意遍历时 it->first 是 const 而 it->second 可变——这是 pair<const K, V> 决定的,编译器在类型上就烙了印:

void test_map()
{
    mystl::myunordered_map<string, string> dict;    // 一个英文→中文的字典
    dict.insert(make_pair("sort", "排序"));
    dict.insert(make_pair("left", "左边"));
    dict["right"] = "右边";                          // 用 [] 插入
    dict["left"] = "左边,剩余";                      // key 已存在:是修改
    dict["insert"] = "插入";
    cout << dict["string"] << endl;                  // key 不存在:会插入空串 "" 再返回
 
    // 遍历:first 是 const 关键字,second 是可变值
    mystl::myunordered_map<string, string>::iterator it = dict.begin();
    while (it != dict.end())
    {
        it->second += 'x';                           // 修改值是允许的
        // it->first = "xxx";                        // 错误!first 是 const K,改不了
        cout << it->first << ":" << it->second << endl;
        ++it;
    }
}

你看 it->second += 'x' 能编译、能改值,而 it->first = "xxx" 被注释成错误——这正是 const K (first) 与 V (second) 这一对"一锁一活"带来的对称之美:关键字是容器的"身份",值才是随便写的"内容"。

对比 set 与 map 两套外壳你会发现一个惊人的对称:它们共用同一个底层 HashTable,只是 元素 T 和 KeyOfT 两处参数不同。

元素类型 T提 key 仿函数迭代器返回值用户可改
myunordered_setconst KSetKeyOfT(原样返回)*it → const K&什么都改不了
myunordered_mappair<const K,V>MapKeyOfT(取 first)*it → pair<const K,V>&只能改 second

这张表就是这一讲整节课的"归总思维导图":同一个引擎,T 与 KeyOfT 一变,就长出了两种脾气完全不同的容器。

对自定义类型开放哈希与等值比较

走到这里,myunordered_map 和 myunordered_set 对内置类型已经能用了。但现实里的关键字经常是"自定义类型"——比如一个 Date 对象、一个自定义的结构体。这时你会发现默认的 HashFunc<K> 直接 return (size_t)key;,它靠的是"把 K 强转成整数",对整数当然没问题,可 Date 这种对象跟整数的强转根本不成立——因为你的 Date 压根不能转成 size_t。编译直接就报错:cannot convert 'Date' to 'size_t'。

怎么破?STL 的回答是"让哈希算法对外开放"。我们在默认 HashFunc<K> 的基础上,为具体的自定义类型做模板特化(Specialization)——也就是为某个具体类型额外写一个专门版本,当用 HashFunc<string> 或 HashFunc<Date> 时,编译器不去 instantiates 那个"通用版本",而是选用我们特化的专属版本。你回想一下 string 是怎么处理的——string 不能强转整数,所以标准实现特化了一个 HashFunc<string>,把字符串每个字符逐一累加、乘上权值得到一个整形哈希值:

// 默认版本:只对"能强转成整数"的 K 适用(int、指针、枚举等)
template<class K>
struct HashFunc
{
    size_t operator()(const K& key) const { return (size_t)key; }
};
 
// 特化版本:专门为 string 写哈希算法
// 把每个字符加权累加,用 131 做乘数是"字符串哈希"里非常经典的套路
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key) const
    {
        size_t hash = 0;
        for (char c : key)                     // 遍历字符串每个字符
            hash = hash * 131 + (size_t)c;     // 累乘累加,让不同字符串尽量撞不同桶
        return hash;
    }
};

为什么用 131 当乘数?这是字符串哈希里一个玩了四十年的经验常数(另一条著名的是 33,也有用 31 或 5381 的)。它的道理在于:131 与很多常见哈希特性的值(比如 2 的幂、ASCII 字符集大小)互质,乘上它 + 偏移后,能最大限度把"相近字符串"的哈希值在低比特上拉开距离,从而在取模时分散到不同桶。你不用背这个数,只需要知道"我们刻意挑选一个与字符集互质、且能有效扩散 bit 的常数"这个思路。严格说,hash * 131 + c 这个叠法叫 DJB 类乘法哈希(DJB 全称 Daniel J. Bernstein,大名鼎鼎的哈希算法作者),它把字符串看成一个大数,逐位"左移加权 + 加当前字符",把顺序信息也烙进哈希值里("ab" 和 "ba" 因此得到不同哈希)。

但哈希只是"算桶号",找到了桶还得在桶里用 == 比对相等,怎么找最响亮的?——回去看 Find 里那句 kot(cur->_data) == key。这个 == 是直接对两个关键字节点的等值比较。所以,一个自定义类型要能当 map 关键字,还必须同时满足两个条件,缺哪个都不行:

  1. 特化一个哈希仿函数:把对象映射成一个整数(决定进哪个桶);
  2. 支持 == 运算符:能判断两个对象是否等值(在同一桶的链表里定位)。

你不妨想象一下若只有哈希没有 == 会怎样:两个不同的 Date 恰好算出同一个哈希挤进同一桶,哈希表却不知道该它们到底是不是同一个对象,那 Find 就永远无法"命中"正确的那一个,查重也就形同虚设了。反过来,只有 == 没有哈希,哈希表连该去哪个桶找都不知道,等于又退回线性扫描。二者是"定位"和"甄别"两段工序,一个都不能少。

举个应用的例子,假如我们做一个人名/事件管理,用 Date 当关键字,那么 Date 就必须:重载 operator==(等值比较),并提供一个哈希仿函数(把年月日揉成一个整数):

// 一个自定义类型做关键字,必须同时提供"等值比较"和"哈希函数"
struct Date
{
    int _year, _month, _day;
    Date(int y, int m, int d) : _year(y), _month(m), _day(d) {}
 
    // 必备一:等值比较——哈希表在同一个桶里靠它判断"找到没"
    bool operator==(const Date& d) const
    {
        return _year == d._year && _month == d._month && _day == d._day;
    }
};
 
// 必备二:针对 Date 特化哈希仿函数
// 把年月日三个数加权合并成一个整数,减少不同日期撞进同一桶
template<>
struct HashFunc<Date>
{
    size_t operator()(const Date& d) const
    {
        // 日乘小数 + 月乘大数,再加权年份,尽量让相邻日期也分散开
        return (size_t)((d._month * 31 + d._day) * 100 + d._year);
    }
};

你可以把这两样东西比喻成"钥匙的两道齿":哈希仿函数负责把钥匙磨出固定的牙形(决定进哪个锁孔/桶),== 负责真正验证钥匙能不能把锁打开(决定是不是同一把)。只有当你能同时提供这两样,自定义类型才有资格成为 map/set 的关键字。这也是 STL 文档里反复强调的一句话:"用来做关键字的类型必须满足‘可哈希 + 可等值比较’"——背后其实就是我们刚才自己实现出来的这两道坎。

完整地验证一次——把 Date 放进 myunordered_map 当关键字,插入、[] 写入、find 查回:

void test_custom_type()
{
    mystl::myunordered_map<Date, string> holiday;   // 节假日表
    holiday.insert(make_pair(Date(2025, 10, 1), "国庆节"));
    holiday[Date(2025, 1, 1)] = "元旦";
    holiday[Date(2025, 6, 1)] = "儿童节";
 
    // 查询的关键字 Date(2025,10,1):靠特化哈希定位桶,靠 == 在桶里锁定它
    mystl::myunordered_map<Date, string>::iterator it = holiday.find(Date(2025, 10, 1));
    if (it != holiday.end())
        cout << "10月1日是:" << it->second << endl;
 
    // 这里还有一层隐藏的含义:
    // Date(2025,10,1) 和 Date(2025,10,1) 是两个不同的临时对象,
    // 但因为 == 判定相等、哈希值也相同,哈希表便能把它们归到同一份上
}

注意最后这个有趣的细节:holiday.find(Date(2025,10,1)) 里传入的临时 Date(2025,10,1) 与插入的那个 Date(2025,10,1) 是两个不同的临时对象。哈希表能认出"它们是同一个",靠的正是"特化哈希找到同一个桶 + operator== 判定相等"—假若 Date 没写 ==或哈希写得跟插值时不一样,这个 find 就会扑空。这份"值相等即同键"的哲学,正是哈希关联容器的威力所在。

给 string 和 Date 各特化一份,你觉得"一个个特化好麻烦"?是的,标准库也是这么"累"过来的——它本身就为 string、int、double、指针、std::hash 能特化的基本类型备齐了哈希。未来你还会学到"更偷懒"的做法:利用 std::hash<K>()(key) 做转发、或 C++14 的 hasher 通用抽取,但那属于进阶优化,今天先把"特化 + == "这条底层的路走通,你会对"为什么标准库哈希表对自定义结构体默认不可用"了然于胸。

走到这儿,我们把一整条路都走完了:先弄清楚"一个哈希桶为什么能同时抚养 map 和 set"(因为底层只认 K,靠 KeyOfT 提 key 解耦);再一步步把哈希桶模板化、用 KeyOfT 统一了 Find/Insert 的分工、补齐 Erase;然后亲手解开哈希表迭代器那个"结点指针 + 表指针"双线索的谜题,看清楚单链走完如何跨桶,以及扩容/删除为什么让迭代器失效;最后给 set 与 map 各套上外壳,用 const K/const first 拦住 key 修改、用 operator[] 实现"没有就建有就取";末了还聊到自定义类型当关键字时"可哈希 + 可比较"这两道保险。

把它们拼成一份能直接编译的程序

前面我们是在"拆零件"地讲,现在我给你一份拼好的完整成品——把前面所有零散代码收拢进一个文件,含 main 和全部测试,你把它存成 test.cpp 直接编译运行(GCC/Clang 用 -std=c++11 及以上,MSVC 默认即可)。这份代码里没有任何省略号、没有任何占位符,是一段结构完整、可选可跑的程序,请你边看边把它和前面讲过的每个零件一一对上号:

#include <iostream>
#include <string>
#include <vector>
#include <utility>
#include <algorithm>   // lower_bound
using namespace std;
 
/********** 1. 哈希仿函数:默认 + 特化(string / Date) **********/
template<class K>
struct HashFunc
{
    size_t operator()(const K& key) const { return (size_t)key; }
};
 
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key) const
    {
        size_t hash = 0;
        for (char c : key)
            hash = hash * 131 + (size_t)c;
        return hash;
    }
};
 
// 自定义类型:Date(做关键字要"可哈希 + 可等值比较")
struct Date
{
    int _year, _month, _day;
    Date(int y, int m, int d) : _year(y), _month(m), _day(d) {}
    bool operator==(const Date& d) const
    { return _year == d._year && _month == d._month && _day == d._day; }
};
 
template<>
struct HashFunc<Date>
{
    size_t operator()(const Date& d) const
    { return (size_t)((d._month * 31 + d._day) * 100 + d._year); }
};
 
/********** 2. 哈希桶(分开链式哈希表) **********/
namespace hash_bucket
{
    template<class T>
    struct HashNode
    {
        T _data;
        HashNode<T>* _next;
        HashNode(const T& data) : _data(data), _next(nullptr) {}
    };
 
    // 前置声明:迭代器里要用
    template<class K, class T, class KeyOfT, class Hash> class HashTable;
 
    // 迭代器:结点指针 + 表指针,双线索
    template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
    struct HTIterator
    {
        typedef HashNode<T> Node;
        typedef HTIterator<K, T, Ptr, Ref, KeyOfT, Hash> Self;
 
        Node* _node;
        const HashTable<K, T, KeyOfT, Hash>* _pht;
 
        HTIterator(Node* node, const HashTable<K, T, KeyOfT, Hash>* pht)
            : _node(node), _pht(pht) {}
 
        Ref operator*()        { return _node->_data; }
        Ptr operator->()       { return &_node->_data; }
        bool operator!=(const Self& s) const { return _node != s._node; }
        bool operator==(const Self& s) const { return _node == s._node; }
 
        Self& operator++()
        {
            if (_node->_next)                    // 桶内还有下一个
            {
                _node = _node->_next;
            }
            else                                 // 桶走完了,跨桶
            {
                KeyOfT kot;
                Hash hs;
                size_t hashi = hs(kot(_node->_data)) % _pht->_tables.size();
                ++hashi;
                while (hashi < _pht->_tables.size())
                {
                    if (_pht->_tables[hashi]) break;
                    ++hashi;
                }
                _node = (hashi == _pht->_tables.size())
                        ? nullptr
                        : _pht->_tables[hashi];
            }
            return *this;
        }
    };
 
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable
    {
        template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
        friend struct HTIterator;
 
        typedef HashNode<T> Node;
 
        static unsigned long __stl_next_prime(unsigned long n)
        {
            static const unsigned long list[] =
            {
                53ul, 97ul, 193ul, 389ul, 769ul, 1543ul, 3079ul, 6151ul,
                12289ul, 24593ul, 49157ul, 98317ul, 196613ul, 393241ul,
                786433ul, 1572869ul, 3145739ul, 6291469ul, 12582917ul,
                25165843ul, 50331653ul, 100663319ul, 201326611ul, 402653189ul,
                805306457ul, 1610612741ul, 3221225473ul
            };
            const unsigned long* first = list;
            const unsigned long* last = list + sizeof(list) / sizeof(unsigned long);
            const unsigned long* pos = lower_bound(first, last, n);
            return pos == last ? *(last - 1) : *pos;
        }
 
    public:
        typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;
        typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;
 
        HashTable()
        {
            _tables.resize(__stl_next_prime(0), nullptr);
        }
 
        ~HashTable()
        {
            for (size_t i = 0; i < _tables.size(); i++)
            {
                Node* cur = _tables[i];
                while (cur)
                {
                    Node* next = cur->_next;
                    delete cur;
                    cur = next;
                }
                _tables[i] = nullptr;
            }
        }
 
        Iterator Begin()
        {
            if (_n == 0) return End();
            for (size_t i = 0; i < _tables.size(); i++)
                if (_tables[i]) return Iterator(_tables[i], this);
            return End();
        }
        Iterator End() { return Iterator(nullptr, this); }
 
        ConstIterator Begin() const
        {
            if (_n == 0) return End();
            for (size_t i = 0; i < _tables.size(); i++)
                if (_tables[i]) return ConstIterator(_tables[i], this);
            return End();
        }
        ConstIterator End() const { return ConstIterator(nullptr, this); }
 
        pair<Iterator, bool> Insert(const T& data)
        {
            KeyOfT kot;
            Iterator it = Find(kot(data));
            if (it != End()) return make_pair(it, false);   // 去重
 
            Hash hs;
            size_t hashi = hs(kot(data)) % _tables.size();
 
            if (_n == _tables.size())                        // 负载因子 == 1 → 扩容
            {
                vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
                for (size_t i = 0; i < _tables.size(); i++)
                {
                    Node* cur = _tables[i];
                    while (cur)
                    {
                        Node* next = cur->_next;
                        size_t h = hs(kot(cur->_data)) % newtables.size();
                        cur->_next = newtables[h];
                        newtables[h] = cur;
                        cur = next;
                    }
                    _tables[i] = nullptr;
                }
                _tables.swap(newtables);
            }
 
            Node* newnode = new Node(data);
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;
            return make_pair(Iterator(newnode, this), true);
        }
 
        Iterator Find(const K& key)
        {
            KeyOfT kot;
            Hash hs;
            size_t hashi = hs(key) % _tables.size();
            Node* cur = _tables[hashi];
            while (cur)
            {
                if (kot(cur->_data) == key)
                    return Iterator(cur, this);
                cur = cur->_next;
            }
            return End();
        }
 
        bool Erase(const K& key)
        {
            KeyOfT kot;
            Hash hs;
            size_t hashi = hs(key) % _tables.size();
            Node* prev = nullptr;
            Node* cur = _tables[hashi];
            while (cur)
            {
                if (kot(cur->_data) == key)
                {
                    if (prev == nullptr)
                        _tables[hashi] = cur->_next;
                    else
                        prev->_next = cur->_next;
                    delete cur;
                    --_n;
                    return true;
                }
                prev = cur;
                cur = cur->_next;
            }
            return false;
        }
 
        size_t Size() const            { return _n; }
        size_t BucketCount() const     { return _tables.size(); }   // 桶数(调试、演示扩容)
 
    private:
        vector<Node*> _tables;    // 指针数组:桶 → 链表头
        size_t _n = 0;            // 表中存储数据个数
    };
}
 
/********** 3. 外壳一:myunordered_set(单值,不可改) **********/
namespace mystl
{
    template<class K, class Hash = HashFunc<K>>
    class myunordered_set
    {
        struct SetKeyOfT
        {
            const K& operator()(const K& key) const { return key; }
        };
    public:
        typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::Iterator iterator;
        typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::ConstIterator const_iterator;
 
        iterator begin() { return _ht.Begin(); }
        iterator end()   { return _ht.End(); }
        const_iterator begin() const { return _ht.Begin(); }
        const_iterator end() const   { return _ht.End(); }
 
        pair<iterator, bool> insert(const K& key) { return _ht.Insert(key); }
        iterator find(const K& key)               { return _ht.Find(key); }
        bool erase(const K& key)                  { return _ht.Erase(key); }
        size_t size() const                       { return _ht.Size(); }
 
    private:
        hash_bucket::HashTable<K, const K, SetKeyOfT, Hash> _ht;
    };
}
 
/********** 4. 外壳二:myunordered_map(键值对,[ ] 有则取无则建) **********/
namespace mystl
{
    template<class K, class V, class Hash = HashFunc<K>>
    class myunordered_map
    {
        struct MapKeyOfT
        {
            const K& operator()(const pair<K, V>& kv) const { return kv.first; }
        };
    public:
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;
        typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::ConstIterator const_iterator;
 
        iterator begin() { return _ht.Begin(); }
        iterator end()   { return _ht.End(); }
        const_iterator begin() const { return _ht.Begin(); }
        const_iterator end() const   { return _ht.End(); }
 
        pair<iterator, bool> insert(const pair<K, V>& kv) { return _ht.Insert(kv); }
 
        V& operator[](const K& key)
        {
            // 有 key 则取,无 key 则插入默认值再取——"没有就建,有就取"
            pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
            return ret.first->second;
        }
 
        iterator find(const K& key)   { return _ht.Find(key); }
        bool erase(const K& key)      { return _ht.Erase(key); }
        size_t size() const           { return _ht.Size(); }
 
    private:
        hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
    };
}
 
/********** 5. 测试入口 **********/
int main()
{
    // --- set ---
    mystl::myunordered_set<int> s;
    int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14, 15 };
    for (auto e : a) s.insert(e);              // 15 重复,会被去重
    for (auto e : s) cout << e << " ";         // 无序的、无重复的元素
    cout << endl;
    cout << "size = " << s.size() << ", bucket = " << 53 << endl;
 
    // --- map ---
    mystl::myunordered_map<string, string> dict;
    dict.insert(make_pair("sort", "排序"));
    dict["left"] = "左边";                       // 插入
    dict["right"] = "右边";
    dict["left"] = "左边,剩余";                  // 修改
    dict["insert"] = "插入";
    cout << "dict[\"string\"] = \"" << dict["string"] << "\"" << endl;  // 读也建空串
 
    for (auto& kv : dict)                        // 遍历:first 只读,second 可改
        cout << kv.first << ":" << kv.second << "  ";
    cout << endl;
 
    // --- 自定义类型关键字 ---
    mystl::myunordered_map<Date, string> holiday;
    holiday.insert(make_pair(Date(2025, 10, 1), "国庆节"));
    holiday[Date(2025, 1, 1)] = "元旦";
    holiday[Date(2025, 6, 1)] = "儿童节";
    auto it = holiday.find(Date(2025, 10, 1));
    if (it != holiday.end())
        cout << "10月1日是:" << it->second << endl;
 
    return 0;
}

你把这整段存成 test.cpp 编译运行,会依次看到:set 的去重和无序输出、map 的 operator[]"读也建"特性(dict["string"] 输出空串并真实插入了它)、以及 Date 当关键字被 find 精确命中。看完运行结果,再回头对一遍每段代码和前面的讲解——这套"一表养两器,解耦靠仿函数,迭代器双线索"的三板斧,就真正长进你脑子里了。

收个尾:解耦这门手艺

回头看,这一讲真正的英雄不是某个容器,而是"解耦"两个字。底层哈希表什么类型都不认识,却什么类型的容器都能服务;上层容器什么底层逻辑都不用写,却能获得哈希表全部的能力。整个过程你亲眼见证了三条主线交织推进:

  1. 解耦靠仿函数:KeyOfT 把"从 T 里取 K"这件波动极频繁、随容器而变的事,从哈希表的刚性逻辑里抽出来,交给各家的 SetKeyOfT/MapKeyOfT 去定制。底层只写"取 K → 算桶 → 比 K"的骨架,至于 K 是从 K 原样拿的还是从 pair 的 first 掏的,它不关心。
  2. 迭代器双线索:哈希表是二维结构,所以迭代器必须携带"结点 + 表"两个指针,靠"重算桶号 + 找非空桶"完成跨桶跳跃;任何扩容(rehash)或删除后,旧迭代器一律作废。
  3. 外壳定长相,引擎定能力:同一个 HashTable,T 与 KeyOfT 一变就分出"不可改的单值 set"和"可改 second 的键值对 map";const K 封死 key、operator[] 实现"没有就建有就取"。

至于那些更细致的角角落落——比如让 const 迭代器和普通迭代器能互相比较、拷贝构造时深拷贝整张表、右值移动减少拷贝、桶接口 bucket_count/rehash 的管理——把它们留给下一轮修炼。但只要你把"什么样的底层换什么样的外壳、外壳如何约束底层不认识的部分"这套思路带在身,以后再读到任何更复杂的库代码(红黑树版的 map/set、std::any、表达式模板、std::function……),都能凭这套"接口抽象 + 类型泛化"的解剖刀,剥出条理来。先把三句话刻进心里——一表养两器、解耦靠仿函数、迭代器双线索——你就已经拿住这个知识点的大半,剩下的,是时间去发酵的事。