C++ 哈希详解(二):开放定址、哈希桶与 C++ 哈希表底层实现

C++ 哈希详解(二):开放定址、哈希桶与 C++ 哈希表底层实现


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


前言:哈希表真正难在哪里?

1. Hash 本身并不复杂

上一篇已经知道,哈希表的核心思路是:

text 复制代码
Key
 ↓
Hash Function
 ↓
Bucket

例如:

text 复制代码
h(key) = key % M

真正的问题在于:

text 复制代码
不同 Key
可能得到相同位置

也就是:

text 复制代码
哈希冲突

所以真正实现哈希表时,大量代码其实都围绕:

text 复制代码
如何处理 Collision

展开。

经典解决方案主要有:

text 复制代码
开放定址法
链地址法

这篇文章就从这两个方案出发,逐步手写哈希表。


一、开放定址法是什么?

1. 所有数据都存储在数组中

开放定址法有一个非常明显的特点:

哈希表中的所有元素都直接存放在数组本身。

例如数组大小:

text 复制代码
M = 11

开始时:

text 复制代码
0 1 2 3 4 5 6 7 8 9 10

□ □ □ □ □ □ □ □ □ □ □

插入:

text 复制代码
19

得到:

text 复制代码
19 % 11 = 8

二、发生冲突怎么办?

继续插入:

text 复制代码
30

计算:

text 复制代码
30 % 11 = 8

但是:

text 复制代码
bucket[8]

已经存在:

text 复制代码
19

此时开放定址法不会创建链表,而是:

text 复制代码
继续寻找其他空位置

寻找策略不同,就形成不同探测方法。


三、线性探测

1. 基本思想

如果:

text 复制代码
hash0 = key % M

发生冲突,就依次尝试:

text 复制代码
hash0 + 1
hash0 + 2
hash0 + 3
...

并通过 % M 回绕。

公式:

text 复制代码
hash_i = (hash0 + i) % M

其中:

text 复制代码
i = 0,1,2,3...

2. 示例

假设:

text 复制代码
M = 11

插入:

text 复制代码
19
30

首先:

text 复制代码
19 % 11 = 8

于是:

text 复制代码
bucket[8] = 19

然后:

text 复制代码
30 % 11 = 8

冲突。

尝试:

text 复制代码
(8 + 1) % 11 = 9

如果:

text 复制代码
bucket[9]

为空,就存:

text 复制代码
bucket[9] = 30

最终:

text 复制代码
8  → 19
9  → 30

四、线性探测的问题:群集

假设已经出现:

text 复制代码
7 → Data
8 → Data
9 → Data
10 → Data

后面任何原本映射到:

text 复制代码
7
8
9
10

附近的数据,都可能不断往后移动。

结果逐渐形成:

text 复制代码
████████

一大片连续被占用的区域。

这种现象称为:

text 复制代码
Primary Clustering
群集 / 堆积

群集越严重:

text 复制代码
探测次数越多

查询和插入效率也就越低。


五、二次探测

为减轻连续群集,可以不再:

text 复制代码
+1
+2
+3

而改成平方距离,例如:

text 复制代码
hash0 + 1²
hash0 - 1²

hash0 + 2²
hash0 - 2²

hash0 + 3²
hash0 - 3²

可以抽象为:

text 复制代码
hash_i =
(hash0 ± i²) % M

相比线性探测:

text 复制代码
连续向后走

二次探测会:

text 复制代码
跳跃寻找位置

因此可以在一定程度上减少一次聚集。


六、双重散列

还有一种方法是利用:

text 复制代码
第二个哈希函数

计算步长。

例如:

text 复制代码
hash_i
=
(hash0 + i × hash2(key)) % M

也就是说:

text 复制代码
第一个 Hash
决定起点

第二个 Hash
决定每次走多少步

如果不同 Key 具有不同步长,就可以进一步降低探测路径集中在一起的概率。

不过对于自己第一次实现哈希表来说:

text 复制代码
线性探测

最容易理解,因此下面使用线性探测实现。


七、为什么数组不能只保存 pair<K,V>?

我们可能最开始想:

cpp 复制代码
vector<pair<K, V>> _tables;

但是很快会遇到一个问题:

text 复制代码
某个位置究竟是:

从来没有使用?

还是:

原来有数据后来被删除?

这两个状态对查找来说完全不同。

因此每个位置还需要一个:

text 复制代码
State

八、三个状态:EMPTY、EXIST、DELETE

定义:

cpp 复制代码
enum State
{
    EMPTY,
    EXIST,
    DELETE
};

然后:

cpp 复制代码
template<class K, class V>
struct HashData
{
    pair<K, V> _kv;

    State _state = EMPTY;
};

三个状态含义:

text 复制代码
EMPTY
    ↓
这个位置从来没有有效元素

EXIST
    ↓
当前有有效元素

DELETE
    ↓
这里曾经有元素,但已经被删除

九、为什么删除不能直接设成 EMPTY?

假设:

text 复制代码
M = 11

有:

text 复制代码
19 % 11 = 8
30 % 11 = 8
20 % 11 = 9

经过线性探测以后可能得到:

text 复制代码
8  → 19
9  → 30
10 → 20

现在删除:

text 复制代码
30

如果直接把位置 9 设置为:

text 复制代码
EMPTY

查找 20 时:

text 复制代码
20 % 11 = 9

从:

text 复制代码
bucket[9]

开始。

如果程序规定:

text 复制代码
遇到 EMPTY
→ 说明后面不可能有元素
→ 停止

那么就无法找到真正位于:

text 复制代码
bucket[10]

的 20。

所以删除位置不能直接当成:

text 复制代码
从来没有使用过

而应该留下一个:

text 复制代码
墓碑
Tombstone

也就是:

text 复制代码
DELETE

十、开放定址哈希表结构

可以得到:

cpp 复制代码
template<
    class K,
    class V,
    class Hash = HashFunc<K>
>
class HashTable
{
private:

    vector<HashData<K, V>> _tables;

    size_t _n = 0;
};

其中:

text 复制代码
_tables

是真正的哈希数组。

而:

text 复制代码
_n

记录当前有效元素数量。


十一、HashFunc:解决 Key 不能直接取模的问题

1. 默认版本

对于整数类型,可以先设计:

cpp 复制代码
template<class K>
struct HashFunc
{
    size_t operator()(const K& key) const
    {
        return static_cast<size_t>(key);
    }
};

于是:

text 复制代码
Key
 ↓
HashFunc
 ↓
size_t

之后再:

cpp 复制代码
hash(key) % tableSize

2. string 特化

字符串显然不能:

cpp 复制代码
string % tableSize;

因此单独处理:

cpp 复制代码
template<>
struct HashFunc<string>
{
    size_t operator()(
        const string& key) const
    {
        size_t hash = 0;

        for (auto ch : key)
        {
            hash *= 131;
            hash += ch;
        }

        return hash;
    }
};

这种写法利用:

text 复制代码
之前的计算结果
×
固定因子
+
当前字符

让字符顺序也参与哈希计算。


十二、开放定址的 Find

首先:

cpp 复制代码
Hash hash;

计算初始位置:

cpp 复制代码
size_t hash0 =
    hash(key) % _tables.size();

然后线性探测。

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
    Hash hash;

    size_t hash0 =
        hash(key) % _tables.size();

    size_t hashi = hash0;

    size_t i = 1;

    while (_tables[hashi]._state
           != EMPTY)
    {
        if (_tables[hashi]._state
                == EXIST
            && _tables[hashi]._kv.first
                == key)
        {
            return &_tables[hashi];
        }

        hashi =
            (hash0 + i)
            % _tables.size();

        ++i;
    }

    return nullptr;
}

十三、Find 为什么遇到 DELETE 不能停?

例如:

text 复制代码
8  → 19 EXIST
9  → DELETE
10 → 20 EXIST

寻找 20:

text 复制代码
hash0 = 9

先看到:

text 复制代码
DELETE

这并不能证明:

text 复制代码
20 不存在

因为 20 可能是由于之前的冲突被推到了后面。

所以:

text 复制代码
DELETE
→ 继续找

只有:

text 复制代码
EMPTY

才能说明:

从当前哈希探测序列来看,后面已经不可能存在目标元素。


十四、开放定址的 Insert

插入之前先判断:

text 复制代码
Key 是否已经存在

然后判断负载因子。

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    if (Find(kv.first))
    {
        return false;
    }

    // 后面处理扩容

    Hash hash;

    size_t hash0 =
        hash(kv.first)
        % _tables.size();

    size_t hashi = hash0;
    size_t i = 1;

    while (_tables[hashi]._state
           == EXIST)
    {
        hashi =
            (hash0 + i)
            % _tables.size();

        ++i;
    }

    _tables[hashi]._kv = kv;

    _tables[hashi]._state =
        EXIST;

    ++_n;

    return true;
}

注意:

text 复制代码
DELETE

位置可以重新利用。

所以循环条件是:

cpp 复制代码
_state == EXIST

而不是:

cpp 复制代码
_state != EMPTY

十五、开放定址为什么必须控制负载因子?

开放定址中:

text 复制代码
所有数据

都必须直接存入:

text 复制代码
数组位置

因此必须保证:

text 复制代码
至少还有空位置

所以负载因子:

text 复制代码
α < 1

否则数组已经全部占满:

text 复制代码
无论怎么探测
都不可能找到新的空位置

1. 一个常见教学实现

例如可以自己规定:

text 复制代码
α >= 0.7

就扩容。

判断:

cpp 复制代码
if (_n * 10
    >= _tables.size() * 7)
{
    // rehash
}

不要写成:

cpp 复制代码
_n / _tables.size() >= 0.7

因为如果都是整数:

text 复制代码
整数除法

会丢失小数部分。


十六、扩容为什么不能简单 resize?

假设:

text 复制代码
旧表大小 = 11

元素:

text 复制代码
19

原位置:

text 复制代码
19 % 11 = 8

如果直接:

cpp 复制代码
_tables.resize(23);

19 依然留在:

text 复制代码
位置 8

但是以后查找:

text 复制代码
19 % 23 = 19

会从:

text 复制代码
位置 19

开始寻找。

自然找不到原来的:

text 复制代码
位置 8

所以:

哈希表扩容一定意味着重新映射已有元素。


十七、开放定址扩容

一种比较清晰的实现是创建新表:

cpp 复制代码
HashTable<K, V, Hash> newHT;

调整新空间:

cpp 复制代码
newHT._tables.resize(newSize);

然后把旧表所有:

text 复制代码
EXIST

元素重新插入:

cpp 复制代码
for (size_t i = 0;
     i < _tables.size();
     ++i)
{
    if (_tables[i]._state == EXIST)
    {
        newHT.Insert(
            _tables[i]._kv
        );
    }
}

最后交换:

cpp 复制代码
_tables.swap(
    newHT._tables
);

这里本质执行的是:

text 复制代码
Old Table
    ↓
重新 hash
    ↓
New Table

十八、Erase 为什么非常简单?

开放定址不能真正把位置设为空。

所以找到元素以后:

cpp 复制代码
bool Erase(const K& key)
{
    HashData<K, V>* pos =
        Find(key);

    if (pos == nullptr)
    {
        return false;
    }

    pos->_state = DELETE;

    --_n;

    return true;
}

这叫:

text 复制代码
逻辑删除

并没有立即消除:

text 复制代码
pair<K,V>

本身。

真正有效性由:

text 复制代码
_state

决定。


十九、开放定址完整核心代码

将前面的逻辑组合:

cpp 复制代码
namespace open_address
{
    enum State
    {
        EMPTY,
        EXIST,
        DELETE
    };

    template<class K, class V>
    struct HashData
    {
        pair<K, V> _kv;
        State _state = EMPTY;
    };

    template<
        class K,
        class V,
        class Hash = HashFunc<K>
    >
    class HashTable
    {
    public:

        HashTable(size_t size = 11)
            : _tables(size)
        {}

        bool Insert(
            const pair<K, V>& kv)
        {
            if (Find(kv.first))
            {
                return false;
            }

            if (_n * 10
                >= _tables.size() * 7)
            {
                size_t newSize =
                    _tables.size() * 2 + 1;

                HashTable<
                    K,
                    V,
                    Hash
                > newHT(newSize);

                for (auto& data :
                     _tables)
                {
                    if (data._state
                        == EXIST)
                    {
                        newHT.Insert(
                            data._kv
                        );
                    }
                }

                _tables.swap(
                    newHT._tables
                );
            }

            Hash hash;

            size_t hash0 =
                hash(kv.first)
                % _tables.size();

            size_t hashi = hash0;
            size_t i = 1;

            while (
                _tables[hashi]._state
                == EXIST)
            {
                hashi =
                    (hash0 + i)
                    % _tables.size();

                ++i;
            }

            _tables[hashi]._kv = kv;
            _tables[hashi]._state =
                EXIST;

            ++_n;

            return true;
        }

        HashData<K, V>* Find(
            const K& key)
        {
            if (_tables.empty())
            {
                return nullptr;
            }

            Hash hash;

            size_t hash0 =
                hash(key)
                % _tables.size();

            size_t hashi = hash0;

            for (size_t i = 0;
                 i < _tables.size();
                 ++i)
            {
                if (_tables[hashi]._state
                    == EMPTY)
                {
                    return nullptr;
                }

                if (_tables[hashi]._state
                        == EXIST
                    && _tables[hashi]
                           ._kv.first
                        == key)
                {
                    return
                        &_tables[hashi];
                }

                hashi =
                    (hash0 + i + 1)
                    % _tables.size();
            }

            return nullptr;
        }

        bool Erase(const K& key)
        {
            HashData<K, V>* pos =
                Find(key);

            if (!pos)
            {
                return false;
            }

            pos->_state = DELETE;

            --_n;

            return true;
        }

    private:

        vector<HashData<K, V>>
            _tables;

        size_t _n = 0;
    };
}

二十、开放定址法有什么缺点?

最大问题在于:

text 复制代码
冲突的数据
仍然会占用数组中的其他位置

一个位置发生大量冲突,会影响:

text 复制代码
附近其他 Key

再加上线性探测会产生:

text 复制代码
群集

因此开放定址的性能会受到:

text 复制代码
负载因子

非常明显的影响。

这就引出了另一种更经典的方案:

text 复制代码
链地址法

二十一、链地址法是什么?

链地址法不再要求:

text 复制代码
所有数据都直接存在数组中

数组只保存:

text 复制代码
Node*

例如:

text 复制代码
vector<Node*> _tables;

如果:

text 复制代码
19
30
96

都映射到:

text 复制代码
bucket 8

那么:

text 复制代码
bucket[8]
   |
   v
  96
   |
   v
  30
   |
   v
  19

形成链表。

这就是:

text 复制代码
Separate Chaining

也经常叫:

text 复制代码
链地址法
拉链法
哈希桶

二十二、哈希桶结点

结点非常简单:

cpp 复制代码
template<class K, class V>
struct HashNode
{
    pair<K, V> _kv;

    HashNode<K, V>* _next;

    HashNode(
        const pair<K, V>& kv)
        : _kv(kv)
        , _next(nullptr)
    {}
};

每个结点只需要:

text 复制代码
数据
+
next

因为一个桶本质就是:

text 复制代码
单链表

二十三、哈希桶整体结构

cpp 复制代码
template<
    class K,
    class V,
    class Hash = HashFunc<K>
>
class HashTable
{
    typedef HashNode<K, V> Node;

private:

    vector<Node*> _tables;

    size_t _n = 0;
};

例如:

text 复制代码
_tables

0 → nullptr

1 → Node → Node

2 → nullptr

3 → Node

4 → Node → Node → Node

二十四、哈希桶的 Find

查找先定位桶:

cpp 复制代码
Hash hash;

size_t index =
    hash(key) % _tables.size();

然后:

text 复制代码
只遍历这个桶

代码:

cpp 复制代码
Node* Find(const K& key)
{
    Hash hash;

    size_t index =
        hash(key)
        % _tables.size();

    Node* cur =
        _tables[index];

    while (cur)
    {
        if (cur->_kv.first
            == key)
        {
            return cur;
        }

        cur = cur->_next;
    }

    return nullptr;
}

核心复杂度就取决于:

text 复制代码
这个 bucket 有多长

如果 Hash 分布均匀:

text 复制代码
桶很短

查找就很快。


二十五、哈希桶的 Insert

首先防止重复:

cpp 复制代码
if (Find(kv.first))
{
    return false;
}

然后算位置:

cpp 复制代码
size_t index =
    hash(kv.first)
    % _tables.size();

插入桶中。

使用头插即可:

cpp 复制代码
Node* newnode =
    new Node(kv);

newnode->_next =
    _tables[index];

_tables[index] =
    newnode;

于是原来:

text 复制代码
bucket
 ↓
A
 ↓
B

插入 C 后:

text 复制代码
bucket
 ↓
C
 ↓
A
 ↓
B

时间复杂度:

text 复制代码
O(1)

不需要找到链表尾部。


二十六、链地址法的负载因子可以大于 1

开放定址:

text 复制代码
数组有 M 个位置

最多也只能存:

text 复制代码
M 个元素

所以:

text 复制代码
α < 1

才有继续插入的空间。

但链地址法:

text 复制代码
一个 bucket

可以挂:

text 复制代码
多个 Node

所以:

text 复制代码
N > M

是完全允许的。

例如:

text 复制代码
10 个 bucket
20 个元素

负载因子:

text 复制代码
α = 20 / 10 = 2

依然能够存储。

但是负载因子继续变大意味着:

text 复制代码
平均链长度增加

查找效率仍然会下降,所以实际实现依然会控制负载因子并适时 rehash。


二十七、哈希桶如何扩容?

假设:

text 复制代码
Old Bucket Count = 5

扩容成:

text 复制代码
New Bucket Count = 11

那么旧结点必须:

text 复制代码
重新计算哈希位置

1. 一个低效但简单的方法

可以创建:

cpp 复制代码
HashTable newHT;

然后把旧数据逐个:

cpp 复制代码
newHT.Insert(...)

这样可以工作。

但是:

text 复制代码
重新 new Node
+
旧 Node 最后再 delete

存在额外开销。


2. 更好的方法:直接搬结点

哈希桶本来就保存:

text 复制代码
Node*

所以完全可以:

text 复制代码
旧 Node
直接重新链接到新 bucket

而不重新创建结点。

流程:

text 复制代码
Old Bucket
    ↓
取出 Node
    ↓
根据 New M 重新 Hash
    ↓
头插 New Bucket

二十八、直接移动结点完成 rehash

cpp 复制代码
vector<Node*> newTables(
    newSize,
    nullptr
);

Hash hash;

for (size_t i = 0;
     i < _tables.size();
     ++i)
{
    Node* cur =
        _tables[i];

    while (cur)
    {
        Node* next =
            cur->_next;

        size_t index =
            hash(cur->_kv.first)
            % newTables.size();

        cur->_next =
            newTables[index];

        newTables[index] =
            cur;

        cur = next;
    }

    _tables[i] = nullptr;
}

_tables.swap(newTables);

这里一定要提前:

cpp 复制代码
Node* next = cur->_next;

因为后面:

cpp 复制代码
cur->_next = newTables[index];

会修改原来的 _next

如果不提前保存:

text 复制代码
原链表后面的结点

就找不到了。


二十九、哈希桶的 Erase

找到桶以后:

cpp 复制代码
Node* prev = nullptr;
Node* cur =
    _tables[index];

遍历:

cpp 复制代码
while (cur)

找到目标。

如果删除的是头结点:

cpp 复制代码
if (prev == nullptr)
{
    _tables[index] =
        cur->_next;
}

否则:

cpp 复制代码
prev->_next =
    cur->_next;

最后:

cpp 复制代码
delete cur;
--_n;

完整代码:

cpp 复制代码
bool Erase(const K& key)
{
    Hash hash;

    size_t index =
        hash(key)
        % _tables.size();

    Node* prev = nullptr;
    Node* cur =
        _tables[index];

    while (cur)
    {
        if (cur->_kv.first
            == key)
        {
            if (prev == nullptr)
            {
                _tables[index] =
                    cur->_next;
            }
            else
            {
                prev->_next =
                    cur->_next;
            }

            delete cur;

            --_n;

            return true;
        }

        prev = cur;
        cur = cur->_next;
    }

    return false;
}

三十、析构函数为什么必须自己释放结点?

我们的:

cpp 复制代码
vector<Node*> _tables;

只保存:

text 复制代码
指针

当 vector 析构时,只会销毁:

text 复制代码
Node*

本身。

不会自动:

cpp 复制代码
delete Node

所以必须遍历所有桶。

cpp 复制代码
~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;
    }
}

否则就会产生:

text 复制代码
内存泄漏

三十一、链地址哈希表完整核心版本

cpp 复制代码
namespace hash_bucket
{
    template<class K, class V>
    struct HashNode
    {
        pair<K, V> _kv;

        HashNode<K, V>* _next;

        HashNode(
            const pair<K, V>& kv)
            : _kv(kv)
            , _next(nullptr)
        {}
    };

    template<
        class K,
        class V,
        class Hash = HashFunc<K>
    >
    class HashTable
    {
        typedef HashNode<K, V>
            Node;

    public:

        HashTable(size_t size = 11)
            : _tables(
                size,
                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;
                }
            }
        }

        Node* Find(const K& key)
        {
            Hash hash;

            size_t index =
                hash(key)
                % _tables.size();

            Node* cur =
                _tables[index];

            while (cur)
            {
                if (cur->_kv.first
                    == key)
                {
                    return cur;
                }

                cur = cur->_next;
            }

            return nullptr;
        }

        bool Insert(
            const pair<K, V>& kv)
        {
            if (Find(kv.first))
            {
                return false;
            }

            Hash hash;

            // 这里使用负载因子达到 1
            // 就扩容的教学策略
            if (_n == _tables.size())
            {
                size_t newSize =
                    _tables.size()
                    * 2 + 1;

                vector<Node*>
                    newTables(
                        newSize,
                        nullptr
                    );

                for (size_t i = 0;
                     i < _tables.size();
                     ++i)
                {
                    Node* cur =
                        _tables[i];

                    while (cur)
                    {
                        Node* next =
                            cur->_next;

                        size_t index =
                            hash(
                                cur
                                ->_kv
                                .first)
                            % newSize;

                        cur->_next =
                            newTables[
                                index];

                        newTables[index]
                            = cur;

                        cur = next;
                    }

                    _tables[i] =
                        nullptr;
                }

                _tables.swap(
                    newTables
                );
            }

            size_t index =
                hash(kv.first)
                % _tables.size();

            Node* newnode =
                new Node(kv);

            newnode->_next =
                _tables[index];

            _tables[index] =
                newnode;

            ++_n;

            return true;
        }

        bool Erase(const K& key)
        {
            Hash hash;

            size_t index =
                hash(key)
                % _tables.size();

            Node* prev = nullptr;

            Node* cur =
                _tables[index];

            while (cur)
            {
                if (cur->_kv.first
                    == key)
                {
                    if (prev == nullptr)
                    {
                        _tables[index] =
                            cur->_next;
                    }
                    else
                    {
                        prev->_next =
                            cur->_next;
                    }

                    delete cur;

                    --_n;

                    return true;
                }

                prev = cur;
                cur = cur->_next;
            }

            return false;
        }

    private:

        vector<Node*> _tables;

        size_t _n = 0;
    };
}

三十二、开放定址与链地址对比

现在两种方式都实现完了,可以放到一起比较。

特点 开放定址 链地址
元素位置 数组内部 独立结点
冲突处理 找其他空槽 挂到同一个桶
删除 DELETE 墓碑 真正删除结点
负载因子 必须小于 1 才有空槽 可以大于 1
内存局部性 较好 指针跳转较多
实现 探测逻辑复杂 链表逻辑较直观
冲突影响 不同 Key 可能相互挤占 主要影响本桶

学习数据结构时,两种方法都必须理解。

而传统的:

text 复制代码
哈希桶

是理解 unordered_map / unordered_set 实现思想非常重要的一种结构。


三十三、为什么哈希桶平均能够做到 O(1)?

假设:

text 复制代码
M 个 bucket
N 个元素

负载因子:

text 复制代码
α = N / M

如果哈希函数比较均匀:

text 复制代码
每个桶
平均大约 α 个元素

查找:

text 复制代码
1. Hash        O(1)
2. 定位 bucket O(1)
3. 遍历桶      平均 O(α)

如果实现把:

text 复制代码
α

控制在常数范围内,那么:

text 复制代码
O(1 + α)

仍然可以看成平均:

text 复制代码
O(1)

这就是哈希表平均常数复杂度背后的真正原因。


三十四、最坏情况下为什么是 O(N)?

如果 Hash 很差,例如:

text 复制代码
所有 Key
 ↓
bucket 5

那么:

text 复制代码
bucket[5]
 ↓
Node
 ↓
Node
 ↓
Node
 ↓
...
 ↓
N 个

查找最后一个元素:

text 复制代码
需要检查 N 个结点

所以最坏:

text 复制代码
O(N)

这再次说明:

text 复制代码
哈希函数的均匀性

对哈希表极其重要。


三十五、rehash 为什么是一次比较昂贵的操作?

扩容时需要把:

text 复制代码
所有已有元素

重新计算 bucket。

所以一次 rehash 的成本大约是:

text 复制代码
O(N)

那么为什么还说:

text 复制代码
insert 平均 O(1)

原因是扩容不是:

text 复制代码
每插入一次都发生

而是间隔很多次插入才出现。

如果容量按一定比例增长,例如:

text 复制代码
11
 ↓
23
 ↓
47
 ↓
95
...

那么大量插入操作共同分摊扩容成本。

这种分析通常叫:

text 复制代码
Amortized Analysis
摊还分析

所以哈希表插入可以保持较好的平均/摊还性能。


相关推荐
迪丽热爱1 小时前
每日英语-17
学习
lingran__1 小时前
C++ STL unordered系列(哈希) 底层剖析与模拟实现万字详解 | 基于哈希表,复刻 SGI-STL 泛型哈希容器架构
开发语言·c++·后端·哈希算法·哈希表·泛型编程·unordered系列
鸿芯微控科技1 小时前
MFC设定流量达不到怎么办?上游供气能力、压差与管路阻力排查
c++·mfc·质量流量控制器·流量不足排查·压差测试·气体流量控制
2501_926978331 小时前
AGI封锁的物理边界:发现模式决定封锁可行性
人工智能·经验分享·笔记·ai写作·agi
郝学胜_神的一滴2 小时前
C++20 高级编程 003:吃透现代基础语法与标准容器
c++
码匠许师傅2 小时前
【C++ 面试真题】聊聊 C++ 的关联容器
开发语言·c++·面试
Mr_liu_6662 小时前
Claude非专业入门实战笔记(附录):关闭Telemetry-遥测,消除无用网络请求与日志噪音
linux·笔记·ubuntu·ai编程
打工陈2 小时前
电路理论知识学习--(4)RC、LC、LR低通滤波对比
学习·智能硬件
Hhy_11073 小时前
《C++深度解构02》类和对象(上)——定义规范、内存对齐与 this 指针全解
c语言·开发语言·c++·学习·visual studio