数据结构初阶——哈希表的实现(C++)

目录

哈希的相关概念

哈希冲突

哈希函数

闭散列------开放地址法

开散列------链地址法

哈希表的闭散列实现

哈希表的结构

哈希表的插入

哈希表的查找

哈希表的删除

哈希表的开散列的实现

哈希表的结构

哈希表的插入

哈希表的查找

哈希表的删除

扩容说明


哈希的相关概念

哈希(Hash) 就是将任意长度的输入(包括右字符串或是数据)通过哈希算法 转换成固定长度输出的这样一个过程,我们这个输出的通常被称之为哈希值(Hash Value) 或是散列值(Hash Code)

我们在实现我们的平衡树的时候,我们的元素关键码和我们的存储的位置没有什么直接的对应关系,我们在查找一个元素的时候通常需要经过我们的关键码进行多次的比较才能找到对应我们要的值,所以我们在顺序结构中的查找时间复杂度是O(N),在平衡树里面我们因为树的结构可以做到时间复杂度是O(logN)。

于是我们就在想我们是不是可以在关键码和存储位置中找到一种对应关系类似于我们的数组随机访问呢?

这个显然就是我们的哈希啦,我们可以在关键码和存储位置之间建立对应的映射关系,这样我们的查找效率就会大幅提高。这样的方式就是我们的哈希方法了,哈希方法在实现的时候比较重要的是中间的转换函数的实现,也就是我们讲的哈希函数,通过这个方法构造出来的结构就是我们的哈希表了。

我们这里也来举个栗子:

比如我们的集合是:{1, 3, 5, 6, 8}

我们的哈希函数设置成了:hash(key) = key % capacity,其中我们的capacity就是我们底层空间的总的大小了,我们元素的对应关系如下:

哈希冲突

事实上我们在实现所谓的哈希函数的时候就是减少哈希冲突的过程,哈希冲突就是我们的哈希函数将两个不一样的值映射到了同一个位置,这种现象就是哈希冲突或是哈希碰撞了。

我们这里也来举个栗子:

就结合上面我们的栗子,当我们加入元素13的时候,我们就会发现这个元素映射到了下标3的位置了(13%10 = 3):

哈希函数

我们首先要知道哈希冲突是不可以避免的(鸽巢原理),但是我们可以设计出比较优秀的哈希函数来减少哈希冲突。

设计的原则:

  • 1、哈希函数的定义域要包括所有的关键码信息。
  • 2、哈希函数计算的结果要尽量均匀分布在整个空间中。
  • 3、哈希函数的设计要简单。

常见的哈希函数如下:

一、直接定址法(常用)

**哈希函数:**Hash(Key) = A * Key + B。

优点:每个值都有一个唯一对应的值,一次性找到。

缺点:场景比较的局限,通常是整数适用。

使用场景:整数,数据范围比较的集中。

二、除留余数法(常用)

**哈希函数:**Hash(Key) = Key % p(p <= m),这里的p就是一个不大于m但是最接近或是等于m的质数。

优点:使用比较的广泛。

缺点:有哈希冲突,冲突越多效率越低。

三、平方取中法

假设我们的关键字是1234,对这个数字开平方得1522756,于是我们取出227作为我们的哈希地址。

使用场景:位数不大的情况。

四、随机数法

我们选择一个随机函数,将关键字放入随机数函数里面得到的值就是我们要的哈希地址了,Hash(Key) = random(Key)。

使用场景:应用在关键字长度不等的时候。

我们这里只是选取了几个比较典型的哈希函数,实际上还是有很多的。

哈希冲突的解决

解决哈希冲突的两种常见的方法:闭散列和开散列

闭散列------开放地址法

闭散列,也就是我们说的开放地址法,也叫蹲坑法,就是说我们的哈希表在没有完全满的时候,我们还是可以将一些出现哈希函数冲突的值放在冲突之后的空位置的,类似我们上次厕所的蹲坑。

而我们的寻找下一个位置也是有很多种的,我们这里重点介绍比较常见的两种方式:

第一种:线性探测法

当我们发生了哈希冲突的时候,我们可以尝试着从发生冲突的位置开始以此向后探测找到一个空的位置。

也就是下面这个式子:

我们这里还是解释一下这几个参数的含义:Hi是我们要找的空位置,H0是我们发生冲突的位置,m就是我们整体表的大小了。

下面我们来举个比较右代表性的栗子:

我们可以发现,随着哈希表中的数据在增加,我们产生的哈希冲突的可能性也在增加。

我们在插入数据的时候会随着数据的增加而导致我们的冲突概率增加,我们在哈希表中引入了负载因子这一衡量参数:

负载因子 = 表里面的数据个数 / 表的大小

负载因子越大,冲突概率越小,增删查打效率越低;相反的,负载因子越小,我们的冲突概率越低,增删查的效率越高。

比如我们将上面的栗子中的表的结构扩充至20,可以明显地看到我们的冲突的概率减少了:

我们知道我们的负载因子越是小,我们的空间利用率就会变低,这个时候我们的很多的空间就会被浪费了。我们一般的会将负载因子控制在0.7~0.8这个区间,超过了0.8我们查表时的cache不命中会按照指数曲线上升。

线性探测的优点:实现起来相对的要简单。

线性探测的缺点:容易产生堆积的现象,也就是我们数据冲突出现在了一起,导致效率降低。

第二种:二次探测

为了然我们产生冲突的数据尽量地不堆在一起,我们找位置的方式就变成了跨步找:

公式如下:

介绍一下这几个参数:

H0:通过哈希函数得到的关键码的位置。

Hi:第二次探测得到的新的存放位置。

m:表的空间大小。

我们还是拿上面的栗子来进行举例子:

我们这里实现的二次探测实际上就是在线性探测的基础上面加大了我们的步长这样做,我们就可以实现哈希表中的元素变得相对稀疏,就不容易造成数据的堆积了。

和上面的线性探测一样我们的二次探测也是可以通过增加表长来减少我们冲突的次数的。

开散列------链地址法

开散列也叫链地址法,也叫拉链法,基本的思路是:对我们的关键码信息集合使用我们的哈希函数计算其对应的哈希地址,将相同地址的关键码合并在一起组成一个桶的结构,也就是我们说的哈希桶,桶里面的元素通过我们的单链表链接起来,然后我们还需要将链表的头节点放在我们的哈希表中(也就是我们第一个索引的元素)。

示意图如下:

闭散列解决了哈希冲突实际上就是规避找空位置这一个问题,相较于闭散列,我们的开散列的不同哈希地址的增删查改的效率是不受我们的冲突影响的,所以我们的开散列的负载因子可以开的更大一些,一般控制在0.0~1.0之间,有的时候可以开到超过1.0。

我们的实际的使用中还是更加推荐使用开散列的方式,原因如下:

1、负载因子更大,空间的利用率可以更高。

2、我们的开散列在极端情况下还有其他的替换结构(红黑树)。

下面这个结构就是我们所说的极端情况了,也就是所以有的元素都放在了一个哈希桶里面,这个时候我们的哈希表的增删查改的时间复杂度就变成了O(N)。

这个时候我们可以将我们桶里面的元素维护成一个红黑树的结构,然后将根节点放在表里面,这个时候的增删查改时间复杂度就是O(logN),这将大大提高我们的效率。

这种情况下,就算是我们有十亿个数据在一个桶里面,我们的增删查改的次数也就在30的量级,还是非常快的,这种实现方式我们也形象地称之为"桶里种树":

在我们的JAVA中,当我们桶里面的数据超过了8个的时候,我们的单链表的结构就会自动变成红黑树的结构,8个以下还是使用单链表。

哈希表的闭散列实现

哈希表的结构

首先就是我们的状态表示,这也是我们哈希表中比较巧妙的设计,具体的状态如下:

1、EMPTY(没有数据的空位置)

2、EXIST(存储了数据)

3、DELETE(原来是有数据的,但是被删除了,这个重点解释)

我们可以采用枚举类型来定义:

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

那么我们就要问了,我们为什么要设置这些个状态呢?

实际上,这里就是哈希表设计的巧妙之处,我们先来看看如果我们自己来实现会怎么设计,我们首先想到的可能是下面的这个逻辑:

1、通过我们的哈希函数找到元素x的哈希地址是y。

2、从y下标开始找,找到了就说明存在,反之就不存在了。

但是这里面有个大坑,那就是我们不可能把整个的哈希表都遍历一遍吧,这样我们实现了和没实现是一样的,所以我们正确的做法(目前是这样的)是从我们的y开始找,找到目标或是空位置即可。

那么可能你就要问了,为什么是空位置就失败了呢?

首先我们必须要明确一点我们的整个的一个过程都是按照线性探测实现的,找到了空说明之前线性探测的时候这里没有放入值,也就是说x不可能放在后面,因为当前这个空位置它都没放。

但是我们的这种方法还是不可以的:

我们也许会想到将我们的每个位置直接设置成存在和不存在,但是遇到了下面的这种情况,我们还是无能为力了:

我们先将11删除掉,然后我们要找21是不是在哈希表里面,于是我通过哈希函数从21 % 10 == 1开始找,找到了2位置停了下来,发现为空了,但是我们的21还是存在的。

开始时:

删除之后:

所以聪明的前人就想到了实现三个状态,也就是在上面状态的基础上加上一个删除状态,表示这里有元素被删除。这样我们在查找的时候遇到了DELETE还是会继续向后遍历的。

总结一下:

当我们查找的时候我们会跳过不匹配的和删除的。

当我们插入的时候我们会插入到状态为EMPTY和DELETE的位置。

于是我们根据上面的内容就有了下面的这个结构:

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

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

template <class K, class V>
class HashTable {
    public:
        // ...
    private:
        vector<HashDate<K, V>> _table;
        size_t n = 0; // 记录数据的个数
};

哈希表的插入

我们实现的插入步骤如下:

1、首先我们需要查看是否存在该键值的键值对,存在就不用插入了。

2、判断对应的条件是不是满足,判断哈希表的大小和我们的负载因子是不是需要我们来对大小来调整。

3、插入哈希表中,有效个数加一。

其中我们对于条件二的调整如下:

如果是哈希表的大小为0,那么初始化哈希表的大小为10。

如果我们的哈希表的负载因子是大于0.7的,就常见一个新的哈希表,然后将这个哈希表的大小开成原来两倍,之后我们将原来哈希表的元素插入到我们新的哈希表中。

具体的插入过程如下:

1、使用哈希函数计算出来我们具体的哈希地址。

2、产生了哈希冲突,我没就使用线性探测进行插入。

3、将键值对插入,然后将这个位置设置成EXIST。

下面是我们实现插入的代码:

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n) {
    static const int __stl_num_primes = 28;
    static const unsigned long __stl_prim_list[__stl_num_primes] = {
        53, 97, 193, 389, 769,
        1543, 3079, 6151, 12289, 24593,
        49157, 98317, 196613, 393241, 786433,
        1572869, 3145739, 6291469, 12582917, 25165843,
        50331653, 100663319, 201326611, 402653189, 805306457,
        1610612741, 3221225473, 4294967291
    };
    const unsigned long* first = __stl_prim_list;
    const unsigned long* last = __stl_prim_list + __stl_num_primes;
    const unsigned long* pos = lower_bound(first, last, n);
    return pos == last ? *(last - 1) : *pos;
}
bool Insert(const pair<K, V>& kv) {
    if(Find(kv.first)) {
        return false;
    }
    if(_n * 10 / _table.size() >= 7) {
        HashTable<K, V> newHashTable;
        newHashTable._table.resize(__stl_next_prime(_table.size() + 1));
        for(auto& d : _table) {
            if(d._state == EXIST) {
                newHashTable.Insert(d._kv);
            }
        }
        _table.swap(newHashTable._table);
    }
    size_t hash0 = kv.first % _table.size(); // 哈希函数的地址
    size_t hashi = hash0; // 最终的插入位置
    size_t i = 1;
    int falg = 1;
    while(_table[hashi]._state == EXIST) {
        // 线性探测
        hashi = (hash0 + i) % _table.size();
        i++;
    }
    _table[hashi]._kv = kv;
    _table[hashi]._state = EXIST;
    _n++;
    return true;
}

哈希表的查找

实现查找主要是下面几个步骤:

1、先判断哈希表是不是空的,空就返回false。

2、通过哈希函数计算出来我们的哈希地址。

3、从哈希地址开始使用线性探测的方法进行数据的查找,知道找到匹配的就返回该地址,找到了EMPTY就返回空指针,找到的位置是DELETE还是继续。

我们的实现代码如下:

cpp 复制代码
HashDate<K, V>* Find(const K& key) {
    size_t hash0 = key % _table.size();
    size_t hashi = hash0;
    size_t i = 1;
    while(_table[hashi]._state != EMPTY) {
        if(_table[hashi]._state == EXIST && _table[hashi]._kv.first == key) {
            return &_table[hashi];
        }
        hashi = (hash0 + i) % _table.size();
        i++;
    }
    return nullptr;
}

哈希表的删除

删除哈希表里面的元素还是比较的简单的,我们这里实现的也不是真正意义上的删除,只是在对应的位置打上DELETE的标记即可。删除的步骤如下:

1、判断是不是存在这个键值对,不存在就返回false。

2、存在的话,就将该键值对所在位置的状态设置为DELETE即可。

3、哈希表的有效元素的个数减一。

代码如下

cpp 复制代码
bool Erase(const K& key) {
    HashDate<K, V>* pos = Find(key);
    if(pos) {
        pos->_state = DELETE;
        _n--;
        return true;
    }
    return false;
}

哈希表的开散列的实现

哈希表的结构

在开散列结构里面,我们的哈希表实际上存储的是一个一个单链表的头节点。

如下:

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) {}
};

和我们上面实现的闭散列的方式不同,我们这里的开散列方式的哈希表是不用存储每个位置的状态的:

cpp 复制代码
template <class K, class V>
class HashTable {
    typedef HashTable<K, V> Node;
    public:
    // ...
    private:
        vector<Node*> _table;
        size_t _n;
};

哈希表的插入

我们这里哈希表的插入的步骤如下:

1、我们先要查看我们的键值对是不是存在的,如果是存在的就插入失败了。

2、我们要根据我们的负载因子和我们的哈希表的大小来决定我们的哈希表是不是要扩容。

3、将我们的键值对插入哈希表中,然后我们的有效元素加一。

哈希表的具体调整如下:

哈希表的大小为0的时候,我们就设置初始值为10.

我们的哈希因子大于了1的时候,我们就创建一个新的哈希表,这个大小根据我们预先设置好的大小来定,然后我们要讲原来的表中的元素一个一个的插入到我们的新表中(这里不是调用插入函数),然后将旧表和新表交换即可。

敲黑板:

我们这里和上面不一样,我们这里不需要每个节点再调用插入函数(重开空间,比较浪费),只需要遍历找到对应的单链表从头到尾开始取值再头插(更加方便)到新的哈希表对应位置的哈希桶即可。

实现代码如下:

cpp 复制代码
bool Insert(const pair<K, V>& kv) {
    Node* pos = Find(kv.first);
    if(pos) {
        return false;
    }
    // 刚刚超过了负载因子
    if(_n == _table.size()) {
        vector<Node*> newTable(_table.size() * 2);
        for(size_t i = 0; i < _table.size(); i++) {
            Node* cur = _table[i];
            while(cur) // 遍历原来哈希桶里面的节点
            {
                Node* next = cur->_next;
                size_t hashi = cur->_kv.first % newTable.size();
                cur->_next = newTable[hashi];
                newTable[hashi] = cur;
                cur = next;
            }
            _table[i] = nullptr; // 置空
        }
        _table.swap(newTable); // 交换
    }
    // 开始插入键值对
    size_t hashi = kv.first % _table.size();
    // 也是头插
    Node* newnode = new Node(kv);
    newnode->_next = _table[hashi];
    _table[hashi] = newnode;
    _n++;
    return true;
}

哈希表的查找

查找的逻辑就是我们正常的查找加上我们的单链表的查找。逻辑如下:

1、先要计算出来我们的哈希地址

2、通过对应的哈希地址,找到我们哈希桶里面的单链表,然后就是查找单链表了。

代码如下:

cpp 复制代码
HashNode<K, V>* Find(const K& key) {
    if(_table.size() == 0) {
        return nullptr;
    }
    size_t hashi = key % _table.size();
    HashNode<K, V>* cur = _table[hashi];
    while(cur) {
        if(cur->_kv.first == key) {
            return cur;
        }
        cur = cur->_next;
    }
    return nullptr;
}

哈希表的删除

我们这里的删除操作和之前实现的闭散列不一样,这里的删除是真的删除了。具体的步骤如下:

1、找到对应的哈希地址对应的哈希桶。

2、遍历哈希桶中的单链表,找要删除的节点。

3、删除要删除的节点,有效元素的个数减一。

代码实现如下:

cpp 复制代码
bool Erase(const K& key) {
    size_t hashi = key % _table.size();
    Node* prev = nullptr;
    Node* cur = _table[hashi];
    while(cur) {
        if(cur->_kv.first == key) {
            if(prev == nullptr) // 头节点
            {
                _table[hashi] = cur->_next;
            }else // 在中间
            {
                prev->_next = cur->_next;
            }
            delete cur;
            cur = nullptr; //养成好习惯
            _n--;
            return true;
        }else {
            prev = cur;
            cur = cur->_next;
        }
    }
    return false;
}

扩容说明

我们一般的设置哈希表的大小的时候通常是会将大小尽量的设置成素数的,主要如下:

使用除留余数法计算哈希值的时候,我们使用合数通常会导致哈希值的分布不是很均匀,但是我们使用素数的时候会比较的均匀,我们这里自己用栗子来实验一下。

测试用的代码:

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

// 简单的哈希函数
size_t HashFunction(int key, size_t table_size) {
    return key % table_size;
}

// 插入到哈希表中并计算冲突次数
int InsertWithCollisions(int table_size) {
    vector<bool> table(table_size, false);  // 初始化哈希表(所有槽为空)
    int collisions = 0;
    
    for (int key = 1; key <= 1000; ++key) {
        size_t index = HashFunction(key, table_size);
        
        if (table[index]) {  // 如果当前位置已经被占用,则发生冲突
            collisions++;
        }
        
        table[index] = true;  // 插入元素
    }

    return collisions;
}

int main() {
    // 合数
    int composite_sizes[] = {10, 12, 15};
    // 素数
    int prime_sizes[] = {7, 11, 13};

    // 测试合数大小
    for (int size : composite_sizes) {
        int collisions = InsertWithCollisions(size);
        cout << "Table size: " << size << " (Composite), Collisions: " << collisions << endl;
    }

    // 测试素数大小
    for (int size : prime_sizes) {
        int collisions = InsertWithCollisions(size);
        cout << "Table size: " << size << " (Prime), Collisions: " << collisions << endl;
    }

    return 0;
}

测试的结果总结:

表一:

哈希表大小 (合数) 插入次数 冲突次数
10 1000 631
12 1000 512
15 1000 450
20 1000 380
30 1000 301

表二:

哈希表大小 (素数) 插入次数 冲突次数
7 1000 347
11 1000 313
13 1000 271
17 1000 236
19 1000 212

我们很明显的可以看到,素数造成哈希冲突的次数是要比合数要少的,感兴趣的友友可以看看算法导论的相关介绍。

那么我们这里是怎么实现的呢?

我们这里既要保证每次扩容都是尽量两倍且是素数,我们就预处理了28个素数的数组,方便我们的扩容使用:

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n) {
    static const int __stl_num_primes = 28;
    static const unsigned long __stl_prim_list[__stl_num_primes] = {
        53, 97, 193, 389, 769,
        1543, 3079, 6151, 12289, 24593,
        49157, 98317, 196613, 393241, 786433,
        1572869, 3145739, 6291469, 12582917, 25165843,
        50331653, 100663319, 201326611, 402653189, 805306457,
        1610612741, 3221225473, 4294967291
    };
    const unsigned long* first = __stl_prim_list;
    const unsigned long* last = __stl_prim_list + __stl_num_primes;
    const unsigned long* pos = lower_bound(first, last, n);
    return pos == last ? *(last - 1) : *pos;
}
相关推荐
未知陨落4 小时前
LeetCode:46.二叉树展开为链表
算法·leetcode·链表
小欣加油4 小时前
leetcode 206 反转链表
数据结构·c++·算法·leetcode·链表·职场和发展
野犬寒鸦4 小时前
力扣hot100:环形链表II(哈希算法与快慢指针法思路讲解)
java·数据结构·算法·leetcode·链表·哈希算法
强化学习与机器人控制仿真5 小时前
LeRobot 入门教程(九)使用 Android、iOS 手机控制机械臂
开发语言·人工智能·stm32·深度学习·神经网络·算法·机器人
listhi5205 小时前
自适应全变分模型的图像平滑去噪与边缘保留算法
图像处理·算法·计算机视觉
wei-dong-183797540087 小时前
嵌入式硬件笔记:三种滤波电路的对比
笔记·嵌入式硬件·算法
泊风9367 小时前
深入C语言底层系列28-埃拉托斯特尼筛法
c语言·开发语言·算法
爱吃煎蛋的小新7 小时前
C#语法回忆零散巩固(持续更新最新版)
java·开发语言·笔记·学习·算法·c#
CoovallyAIHub7 小时前
时隔 8 年,李飞飞领衔,CS231n 2025版来了!
深度学习·算法·计算机视觉