unordered_map/hash等相关整理。

目录

unordered_map

哈希(散列)函数

哈希概念

哈希冲突

哈希冲突解决

闭散列

1. 线性探测

哈希函数的插入/删除/查找的三大基本功能实现

开散列

以下是开散列函数的基本操作实现

只能存储key为整形的元素,其他类型怎么解决?

除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

模拟实现 

哈希的应用

位图

位图的应用

布隆过滤器


unordered_map

在学习hash函数之前,首先需要了解基于hash表实现的unordered_map

  • unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
  • 键和映射值的类型可能不同。
  • 内部没有排序。 而在基于红黑树的 map 存在排序。
  • 查找效率:map的查找效率为O(log n),其中n为元素个数;unordered_map的查找效率为O(1),但在哈希冲突较多时,查找效率会降低到O(n)。 (此处暂时只用知道这俩的时间复杂度不同即可。)

下面是一个使用 unordered_map 的例子:

#include <iostream>
#include <unordered_map>

int main() {
  // 定义一个 unordered_map 对象,key 是 std::string 类型,value 是 int 类型
  std::unordered_map<std::string, int> umap;

  // 插入键值对
  umap["apple"] = 10;
  umap["banana"] = 20;
  umap["cherry"] = 30;

  // 遍历输出每个键值对
  for (const auto& p : umap) {
    std::cout << "key: " << p.first << ", value: " << p.second << std::endl;
  }

  return 0;
}

哈希(散列)函数

哈希概念

一种存储结构(哈希表),通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系。

哈希函数是将任意大小的数据映射到固定大小的数据的一种方法。哈希函数将数据映射到数组的索引上,以此来实现快速的查找、插入和删除操作。

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小

哈希冲突

不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”

问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

按照给定的哈希函数将元素44插入到哈希表中,它会被放在索引为4的位置上,即44 % 10 = 4。但是在数据集合{1,7,6,4,5,9}中,元素4已经被哈希到了索引为4的位置上,因此会出现哈希冲突的情况,这种情况被称为“哈希冲突”(Hash Collision)

通常使用解决哈希冲突的方法是使用开放地址法或者链式法,将相同哈希值的元素存储在同一个位置上,以避免数据的丢失。

引起哈希冲突的一个原因可能是:哈希函数设计不够合理

哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值
  • 域必须在0到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

常用的方法:

1. 直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B

力扣此题用hash来解。

2. 除留余数法--(常用)

除留余数法是一种常用的哈希函数,其思想是先将关键码key除以一个特定的数p,然后取其余数,即:

Hash(key) = key % p

其中,p是一个不大于哈希表大小m的质数。这个质数的选择非常重要,一般来说,应该选择一个和m比较接近的质数,以尽可能减少哈希冲突的发生。

使用除留余数法的步骤如下:

1. 选择一个适当的质数p作为除数。
2. 对于每个关键码key,计算哈希值Hash(key) = key % p。
3. 将哈希值作为关键码在哈希表中的存储位置。

需要注意的是,由于除留余数法只使用了关键码的低位部分,因此如果关键码的高位部分包含了关键信息,那么使用除留余数法就可能导致哈希冲突的发生。

哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

哈希冲突解决

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

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。

1. 线性探测

线性探测是一种哈希冲突解决方法,它的基本思想是:如果哈希函数将一个关键字映射到某个槽位已经被占用,就顺序地往后找下一个空闲的槽位,直到找到空闲的槽位为止。

插入

  • 通过哈希函数获取待插入元素在哈希表中的位置
  • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影
响。因此线性探测采用标记的伪删除法来删除一个元素

下面是一个使用线性探测法解决哈希冲突的简单例子,其中我们将整数键插入到哈希表中。

#include <iostream>
using namespace std;

// 哈希表的槽位类
class Slot {
public:
    int key;          // 存储的关键字
    bool occupied;    // 槽位是否被占用
    Slot() {          // 构造函数,初始化key和occupied
        key = 0;
        occupied = false;
    }
};

// 哈希表类
class HashTable {
private:
    const int TABLE_SIZE = 10;    // 哈希表的槽位数目
    Slot table[TABLE_SIZE];       // 存储关键字的数组
public:
    // 哈希函数,用于将关键字映射到哈希表的槽位上
    int hashFunction(int key) {
        return key % TABLE_SIZE;
    }

    // 将关键字插入到哈希表中
    void insert(int key) {
        int index = hashFunction(key);    // 通过哈希函数计算出key对应的槽位index
        while (table[index].occupied) {  // 如果槽位被占用,则往后遍历
            index = (index + 1) % TABLE_SIZE;
        }
        table[index].key = key;           // 将key存储到该槽位
        table[index].occupied = true;     // 将该槽位标记为被占用
    }

    // 输出哈希表中所有的槽位及其存储的关键字
    void print() {
        for (int i = 0; i < TABLE_SIZE; i++) {
            if (table[i].occupied) {
                cout << i << ": " << table[i].key << endl;
            } else {
                cout << i << ": " << "empty" << endl;
            }
        }
    }
};

int main() {
    HashTable ht;        // 创建一个哈希表对象
    ht.insert(12);       // 将关键字12插入到哈希表中
    ht.insert(22);       // 将关键字22插入到哈希表中
    ht.insert(32);       // 将关键字32插入到哈希表中
    ht.insert(42);       // 将关键字42插入到哈希表中
    ht.insert(52);       // 将关键字52插入到哈希表中
    ht.print();          // 输出哈希表中所有的槽位及其存储的关键字
    return 0;
}

我们使用 (index + 1) % TABLE_SIZE 的方式往后遍历槽位。

哈希表什么情况下进行扩容?如何扩容?

散列表的载荷因子定义为α = 填入表中的元素个数 / 散列表的长度

通常情况下,当装载因子超过某个设定的阈值时,哈希表就需要进行扩容,以避免哈希表中出现过多的冲突,影响哈希表的性能。 因子应该控制在0.7-0.8以下

扩容操作一般包括以下步骤:

  1. 新建一个更大的数组,通常容量是原数组的两倍或更大。
  2. 遍历原数组,将元素重新插入到新数组中。由于新数组容量更大,哈希函数可能需要重新计算,因此每个元素都需要重新计算哈希值并插入到新数组中。
  3. 删除原数组,将原数组指针指向新数组。

哈希的扩容  参数在最后的哈希函数的插入/删除/查找的三大基本功能实现

void _Expand()
{
    // 创建一个新的哈希表,容量是旧表的两倍
    size_t newCapacity = _ht.capacity() * 2;
    vector<Elem> newHt(newCapacity);
    for (size_t i = 0; i < newCapacity; ++i)
        newHt[i]._state = EMPTY;

    // 将旧表中的元素重新插入
    for (size_t i = 0; i < _ht.capacity(); ++i)
    {
        if (_ht[i]._state == EXIST)
        {
            // 计算元素在新表中的哈希地址
            size_t newHashAddr = newHt.HashFunc(_ht[i]._val.first);

            // 线性探测法解决哈希冲突,直到找到新表中的空位置
            while (newHt[newHashAddr]._state == EXIST)
            {
                newHashAddr++;
                if (newHashAddr == newHt.capacity())
                    newHashAddr = 0;
            }

            // 插入元素到新表中
            newHt[newHashAddr]._state = EXIST;
            newHt[newHashAddr]._val = _ht[i]._val;
        }
    }

    // 交换旧表和新表
    _ht.swap(newHt);
}

哈希函数的插入/删除/查找的三大基本功能实现

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};

template<class K, class V>
class HashTable
{
    // 内部结构体,用于存储元素和元素状态
    struct Elem
    {
        pair<K, V> _val; // 元素本身
        State _state; // 元素状态
    };
public:
    // 构造函数
    HashTable(size_t capacity = 3)
        : _ht(capacity), _size(0)
    {
        // 初始化哈希表,全部设置为空
        for(size_t i = 0; i < capacity; ++i)
            _ht[i]._state = EMPTY;
    }
    
    // 插入元素
    bool Insert(const pair<K, V>& val)
    {
        // 哈希地址
        size_t hashAddr = HashFunc(val.first);
        while(_ht[hashAddr]._state != EMPTY)
        {
            // 如果已经存在相同 key 的元素,插入失败
            if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == val.first)
                return false;
            // 线性探测,寻找下一个空闲的地址
            hashAddr = (hashAddr + 1) % _ht.capacity();
        }
        // 在空闲地址处插入元素
        _ht[hashAddr]._state = EXIST;
        _ht[hashAddr]._val = val;
        _size++;
        return true;
    }
    
    // 查找元素
    int Find(const K& key)
    {
        // 哈希地址
        size_t hashAddr = HashFunc(key);
        while(_ht[hashAddr]._state != EMPTY)
        {
            // 找到相应的元素,返回哈希地址
            if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)
                return hashAddr;
            // 线性探测,寻找下一个可能的地址
            hashAddr = (hashAddr + 1) % _ht.capacity();
        }
        // 没有找到,返回 -1
        return -1;
    }
    
    // 删除元素
    bool Erase(const K& key)
    {
        // 查找元素的哈希地址
        int index = Find(key);
        if(index != -1)
        {
            // 将元素状态标记为删除状态
            _ht[index]._state = DELETE;
            _size--;
            return true;
        }
        // 没有找到,删除失败
        return false;
    }
    
    // 获取元素个数
    size_t Size() const
    {
        return _size;
    }
    
    // 判断是否为空
    bool Empty() const
    {
        return _size == 0;
    }
    
    // 交换哈希表
    void Swap(HashTable<K, V>& ht)
    {
        _ht.swap(ht._ht);
        swap(_size, ht._size);
    }
    
private:
    // 哈希函数
    size_t HashFunc(const K& key)
    {
        return key % _ht.capacity();
    }
private:
    vector<Elem> _ht;
    size_t _size;
};

开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。

开散列中每个桶中放的都是发生哈希冲突的元素

 

以下是开散列函数的基本操作实现

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

// 定义哈希表中的每个元素
struct Elem {
    int key;
    int val;
    Elem *next;
    Elem(int k = 0, int v = 0, Elem *n = nullptr) : key(k), val(v), next(n) {}
};

// 定义哈希表类
class HashTable {
public:
    // 构造函数
    HashTable(size_t capacity = 100) : _ht(capacity), _size(0) {}

    // 插入操作
    void insert(int key, int val);

    // 查找操作
    bool find(int key, int &val);

    // 删除操作
    bool erase(int key);

private:
    vector<Elem*> _ht;  // 哈希表
    size_t _size;  // 哈希表中元素的个数
    void _expand();  // 扩容函数
};

// 插入操作
void HashTable::insert(int key, int val) {
    // 计算哈希值
    size_t h = key % _ht.size();
    // 查找哈希桶中是否已经存在该键值
    Elem *p = _ht[h];
    while (p) {
        if (p->key == key) {
            p->val = val;
            return;
        }
        p = p->next;
    }
    // 若哈希桶中不存在该键值,则插入新节点
    _ht[h] = new Elem(key, val, _ht[h]);
    ++_size;
    // 判断是否需要扩容
    if (_size * 2 > _ht.size())
        _expand();
}

// 查找操作
bool HashTable::find(int key, int &val) {
    // 计算哈希值
    size_t h = key % _ht.size();
    // 在哈希桶中查找该键值
    Elem *p = _ht[h];
    while (p) {
        if (p->key == key) {
            val = p->val;
            return true;
        }
        p = p->next;
    }
    return false;
}

// 删除操作
bool HashTable::erase(int key) {
    // 计算哈希值
    size_t h = key % _ht.size();
    // 在哈希桶中查找该键值
    Elem *p = _ht[h], *prev = nullptr;
    while (p) {
        if (p->key == key) {
            if (prev)
                prev->next = p->next;
            else
                _ht[h] = p->next;
            delete p;
            --_size;
            return true;
        }
        prev = p;
        p = p->next;
    }
    return false;
}

// 扩容函数
void HashTable::_expand() {
    // 创建一个新的哈希表,容量是旧表的两倍
    size_t newCapacity = _ht.size() * 2;
    vector<Elem*> newHt(newCapacity, nullptr);

    // 将旧表中的元素重新插入
    for (size_t i = 0; i < _ht.size(); ++i) {
        Elem* curr = _ht[i];
        while (curr != nullptr) {
            // 计算新哈希表的索引位置
            size_t newIndex = hash(curr->_key) % newCapacity;

            // 如果该位置为空,则直接插入元素
            if (newHt[newIndex] == nullptr) {
                newHt[newIndex] = curr;
            }
            // 如果该位置不为空,则使用开放地址法解决冲突
            else {
                Elem* next = curr->_next;
                while (newHt[newIndex] != nullptr) {
                    newIndex = (newIndex + 1) % newCapacity;
                }
                newHt[newIndex] = curr;
                curr->_next = nullptr;
            }
            curr = next;
        }
    }

    // 更新哈希表的容量和哈希表指针
    _capacity = newCapacity;
    _ht.swap(newHt);
}

只能存储key为整形的元素,其他类型怎么解决?

string可以转成整形,这里有一个巨佬的BKDR hash算法

template<class T>  
size_t BKDRHash(const T *str)  
{  
    register size_t hash = 0;  
    while (size_t ch = (size_t)*str++)  
    {         
        hash = hash * 131 + ch;   // 也可以乘以31、131、1313、13131、131313..  
        // 有人说将乘法分解为位运算及加减法可以提高效率
        //,如将上式表达为:hash = hash << 7 + hash << 1 + hash + ch;  
    }  
    return hash;  
} 

//上面大佬代码
//这是地址https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html

// key为字符串类型,需要将其转化为整形
class Str2Int
{
public:
    size_t operator()(const string& s)
    {
        const char* str = s.c_str();
        unsigned int seed = 131; // 31 131 1313 13131 131313
        unsigned int hash = 0;
        while (*str)
        {
            hash = hash * seed + (*str++);
        }
            return (hash & 0x7FFFFFFF);
        }
};

如果使用的是其他类型的 key,可以将其转换为字符串,然后再使用 BKDR Hash Function 生成哈希值。

除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

在哈希表中,选择一个好的素数作为哈希表的容量可以减少哈希冲突的概率,从而提高哈希表的效率。通常情况下,我们选择一个与质数相邻的素数作为哈希表的容量。当然,我们也可以在程序中使用算法来寻找素数。

以下是一种方法来快速计算一个与给定数字 n 相邻的素数:

1. 首先检查 n 是否为偶数,如果是,则将 n 加一,使之变为奇数。

2. 从 n 开始,依次增加 2,检查每个数是否为素数。

3. 检查素数的方法可以使用试除法或者 Miller-Rabin 算法。

4. 如果找到了一个素数,返回它。

这种方法并不能保证找到最小的相邻素数,但是可以在很短的时间内找到一个足够大的素数。

开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=
0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

模拟实现 

暂定

1. 模板参数列表的改造

2. 增加迭代器操作

3. 增加通过key获取value操作

哈希的应用

位图

位图(Bit Map)是一种用于表示比特位(bit)集合的数据结构,通常用于高效地处理大量二进制数据。它是一种紧凑的数据结构,可节省内存空间,同时可以快速执行位操作。

  • 在位图中,每个元素只占用一个比特位,通常用0或1来表示该元素是否存在。
  • 常见的应用场景包括布隆过滤器(Bloom Filter)、文本压缩、数据库索引等。
  • 通常是用来判断某个数据存不存在的。

给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在 这40亿个数中。【腾讯】

1. 遍历,时间复杂度O(N)

2. 排序(O(NlogN)),利用二分查找: logN

3. 位图解决 数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一 个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0 代表不存在。

位图解决的具体步骤如下:

  1. 首先需要将这40亿个不重复的无符号整数转化为比特位,用于表示这些数的存在状态。因为40亿个无符号整数需要用2^32个比特位才能全部表示,所以需要一个长度为2^32的比特位数组来表示这些数的存在状态。

  2. 遍历这40亿个无符号整数,对于每个数,将对应的比特位置为1,表示该数存在。

  3. 对于给定的无符号整数,通过将该数对应的比特位取出并判断其值是否为1,即可快速判断该数是否在40亿个数中。

需要注意的是,如果使用一个bool类型数组来表示比特位,会占用大量内存空间,因为一个bool类型通常需要占用1个字节(8个比特位)。因此,使用unsigned int类型数组来表示比特位,每个unsigned int类型可以表示32个比特位,能够有效减小内存占用。

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

class BitMap {
public:
    BitMap(size_t n = 0) : _bits(n) {}

    void set(size_t pos) {
        size_t idx = pos >> 5;// 一个 unsigned int 存储 32 个比特位
                              //,所以右移 5 位相当于除以 32
        size_t bit = pos & 0x1F;// 取余数,用于确定在 unsigned int 中的具体位置
        _bits[idx] |= (1 << bit);// 将指定位置的比特位设为 1
    }

    bool test(size_t pos) const {
        size_t idx = pos >> 5;
        size_t bit = pos & 0x1F;
        return (_bits[idx] & (1 << bit)) != 0;
    }

private:
    vector<unsigned int> _bits;
};

int main() {
    // 构建一个包含 40 亿个随机无符号整数的数组
    const size_t n = 1000000000;
    vector<unsigned int> data(n);
    for (size_t i = 0; i < n; ++i) {
        data[i] = rand();
    }

    // 使用位图来表示整数是否出现过
    BitMap bm(1 << 30);
    for (size_t i = 0; i < n; ++i) {
        bm.set(data[i]);
    }

    // 验证一个数是否存在
    unsigned int num = rand();
    if (bm.test(num)) {
        cout << num << " exists." << endl;
    } else {
        cout << num << " does not exist." << endl;
    }

    return 0;
}

位图的应用

1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记

布隆过滤器

概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存
在”

详解布隆过滤器的原理,使用场景和注意事项 - 知乎

这里有大佬的解释。

布隆过滤器主要基于哈希函数实现,它使用一个比特数组和一组哈希函数。

在将元素加入布隆过滤器时,将元素通过哈希函数映射到比特数组的多个位置,并将这些位置的值设为1。

在查询元素是否存在时,将元素通过相同的哈希函数映射到比特数组上的多个位置,并检查这些位置的值是否都为1。如果有一个或多个位置的值为0,则可以确定该元素不存在于集合中。

如果所有位置的值都为1,则该元素可能存在于集合中,需要进一步检查或者可以根据实际情况确定是否存在。

布隆过滤器的优点是空间效率高和查询速度快,因为它不需要存储元素本身,只需要存储每个元素的哈希值。缺点是可能存在误判(false positive),即在布隆过滤器中没有存储的元素被误判为存在于集合中。误判的概率可以通过调整哈希函数的数量和比特数组的大小来控制。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值