【C++】哈希表 | 闭散列 | 开散列 | unordered_map 和 unordered_set 的模拟实现(上)

简介: 【C++】哈希表 | 闭散列 | 开散列 | unordered_map 和 unordered_set 的模拟实现(上)

👉unordered系列关联式容器👈


在 C++98 中,STL 提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 O(log),即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是进行很少的比较次数就能够将元素找到。因此,在 C++11 中,STL 又提供了 4 个 unordered 系列的关联式容器,这 4 个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。本文中只对 unordered_map 和 unordered_set 进行介绍。


unordered_map

1. unordered_map 的介绍


unordered_map 是存储 <key, value> 键值对的关联式容器,其允许通过 key 快速的索引到与其对应的 value。

在 unordered_map 中,键值通常用于唯一地标识元素,而映射值是一个对象,其内容与此键值关联。键值和映射值的类型可能不同。

在内部 unordered_map 没有对 <kye, value> 按照任何特定的顺序排序, 为了能在常数范围内找到 key 所对应的value,unordered_map 将相同哈希值的键值对放在相同的桶中。

unordered_map 容器通过 key 访问单个元素要比 map 快,但它通常在遍历元素子集的范围迭代方面效率较低。

unordered_map 实现了operator[],它允许使用 key 作为参数直接访问 value。

它的迭代器至少是前向迭代器。


2. unordered_map 的桶操作

1675846499529.png

3. unordered_map 的使用


unordered_map、unordered_set 和map、set 的用法都是差不多的,现在我们来简单地使用一下 unordered_map。


在长度 2N 的数组中找出重复 N 次的元素


给你一个整数数组 nums ,该数组具有以下属性:

nums.length == 2 * n.

nums 包含 n + 1 个 不同的元素

nums 中恰有一个元素重复 n 次

找出并返回重复了 n 次的那个元素。

b2be570f546c4106bfac0657e26d122b.png


思路:先用 unordered_map 统计数字出现的次数,然后就能找出出现 N 次的数字了。


class Solution 
{
public:
    int repeatedNTimes(vector<int>& nums) 
    {
        unordered_map<int, int> countMap;
        for(auto e : nums)
            ++countMap[e];
        for(auto& kv : countMap)
        {
            if(kv.second == nums.size() / 2)
                return kv.first;
        }
        return -1;
    }
};

c74c0b23bb6a4cebbae71d4514846ad4.png


unordered 系列的容器中的数据是无序的


void SetTest()
{
  unordered_set<int> s;
  s.insert(2);
  s.insert(3);
  s.insert(1);
  s.insert(5);
  s.insert(2);
  s.insert(6);
  for (auto e : s)
  {
    cout << e << " ";
  }
  cout << endl;
}

d1860a562e094b0d94987642bb726fa9.png


map / set 和 unordered 系列的对比


c99e5bffec3d4fefbee1fa02ddc17291.png


#include <unordered_map>
#include <unordered_set>
#include <map>
#include <set>
#include <iostream>
#include <vector>
using namespace std;
void SetTest()
{
  unordered_set<int> s;
  s.insert(2);
  s.insert(3);
  s.insert(1);
  s.insert(5);
  s.insert(2);
  s.insert(6);
  for (auto e : s)
  {
    cout << e << " ";
  }
  cout << endl;
}
void Test()
{
  int n = 10000000;
  vector<int> v;
  v.reserve(n);
  srand(time(0));
  for (int i = 0; i < n; ++i)
  {
    //v.push_back(i);
    v.push_back(rand() + i);  // 重复少
    //v.push_back(rand());  // 重复多
  }
  size_t begin1 = clock();
  set<int> s;
  for (auto e : v)
  {
    s.insert(e);
  }
  size_t end1 = clock();
  size_t begin2 = clock();
  unordered_set<int> us;
  for (auto e : v)
  {
    us.insert(e);
  }
  size_t end2 = clock();
  cout << "size:" << s.size() << endl;
  cout << "set insert:" << end1 - begin1 << endl;
  cout << "unordered_set insert:" << end2 - begin2 << endl;
  size_t begin3 = clock();
  for (auto e : v)
  {
    s.find(e);
  }
  size_t end3 = clock();
  size_t begin4 = clock();
  for (auto e : v)
  {
    us.find(e);
  }
  size_t end4 = clock();
  cout << "set find:" << end3 - begin3 << endl;
  cout << "unordered_set find:" << end4 - begin4 << endl;
  size_t begin5 = clock();
  for (auto e : v)
  {
    s.erase(e);
  }
  size_t end5 = clock();
  size_t begin6 = clock();
  for (auto e : v)
  {
    us.erase(e);
  }
  size_t end6 = clock();
  cout << "set erase:" << end5 - begin5 << endl;
  cout << "unordered_set erase:" << end6 - begin6 << endl;
}
int main()
{
  Test();
  return 0;
}


02bd16bda7824a4caa0b68385dc63b50.png


👉底层结构👈


哈希概念


顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为 O(N),平衡树中为树的高度,即 O(l o g 2 N),搜索的效率取决于搜索过程中元素的比较次数。


理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。


当向该结构中插入元素时,根据待插入元素的关键码以此函数计算出该元素的存储位置并按此位置进行存放。当搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较。若关键码相等,则搜索成功。


该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。


关于哈希的思想,其实我们在字符串中的第一个唯一字符这道题目里早就用到过了。


1d418ed339e94def80e01d819e9dd934.png


字符串中的第一个唯一字符中用到的是直接定址法,这种方法只能解决一些简单的场景。好比如:当数据的间隔较大时,就会很浪费空间了。

dbefbcfd75bf4aafb16516b9b9d3efdd.png


那为了解决上面的问题,我们可以除流余数法。


070807da834c4d1b9caab461f7f528be.png

27c2319a7a3b4cac9be8c3296a467196.png


哈希冲突


1675846711720.png


哈希冲突解决


解决哈希冲突两种常见的方法是:闭散列和开散列(拉链法 / 哈希桶)。


1. 闭散列


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


线性探测


线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。插入元素时,通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。

60d91875fd324272af9f58a8bb2ebd51.png

fad78287c47e4ad791529a38b05707c3.png删除元素时,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素 4,如果直接删除掉,44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素,并没有将该元素真正的删除掉,而是采用标记的方式处理,但是不能直接将该位置标记为空,否则会影响从该位置产生冲突的元素的查找。。哈希表每个空间给个标记:EMPTY 表示此位置空,EXIST 表示此位置已经有元素,DELETE 表示元素已经删除。



当vector快要满时,此时的哈希冲突已经出现比较多了,存在你占我的位置,我占用别人的位置的情况了。那么这时候哈希表就要扩容了。那什么时候要扩容呢?为了解决扩容问题,有大佬提出了负载因子(载荷因子)的概念。哈希表的负载因子等于填入表中的元素个数除以哈希表的长度。负载因子越小,哈希冲突的概率越小;负载因子越大,哈希冲突的概率越大。当负载因子到达一个基准值时,哈希表就需要扩容。基准越大,冲突越多,效率越低,空间利用率越高。哈希表扩容的代价比vector扩容的代价还有大,因为原来存在哈希冲突的数据,有可能就不冲突了,需要重新映射,并不能直接将数据拷贝到原来的位置上。

6d64b435daed4450ae1b35eb1fb3a91d.png


#pragma once
// 标识状态
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 HashTable
{
public:
  HashData<K, V>* Find(const K& key)
  {
    if (_tables.size() == 0)
    {
      return nullptr;
    }
    size_t hashi = key % _tables.size();
    size_t start = hashi;
    while (_tables[hashi]._state != EMPTY)
    {
      // 状态不是删除才能找到,否则会有BUG
      if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
      {
        return &_tables[hashi];
      }
      ++hashi;
      hashi %= _tables.size();
      // 找了一圈都没找到
      if (hashi == start) // 防止插入又删除的场景
        break;
    }
    return nullptr;
  }
  bool Insert(const pair<K, V>& kv)
  {
    if (Find(kv.first))
      return false;
    // 负载因子到了就要扩容
    if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7)
    {
      size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
      HashTable<K, V> newHT;
      newHT._tables.resize(newSize);
      // 旧表的数据映射到新表
      for (auto& e : _tables)
      {
        if (e._state == EXIST)
        {
          newHT.Insert(e._kv);
        }
      }
      _tables.swap(newHT._tables);
    }
    size_t hashi = kv.first % _tables.size(); // 注意模除的是_table.size()
    // 线性探测
    while (_tables[hashi]._state == EXIST)
    {
      ++hashi;
      hashi %= _tables.size();
    }
    // 找到空位置就插入元素
    _tables[hashi]._kv = kv;
    _tables[hashi]._state = EXIST;
    ++_size;
    return true;
  }
  bool Erase(const K& key)
  {
    HashData<K, V>* ret = Find(key);
    if (ret)  // 元素存在
    {
      ret->_state = DELETE;
      --_size;
      return true;
    }
    else
      return false;
  }
  void Print()
  {
    for (size_t i = 0; i < _tables.size(); ++i)
    {
      if (_tables[i]._state == EXIST)
      {
        printf("[%d:%d] ", i, _tables[i]._kv.first);
      }
      else
      {
        printf("[%d:*] ", i);
      }
    }
    cout << endl;
  }
private:
  vector<HashData<K, V>> _tables;
  size_t _size = 0; // 有效数据的个数
};
void TestHT1()
{
  //int a[] = { 1, 11, 4, 15, 26, 7, 44, 9 };
  int a[] = { 1, 11, 4, 15, 26, 7, 44 };
  HashTable<int, int> ht;
  for (auto e : a)
  {
    ht.Insert(make_pair(e, e));
  }
  ht.Print();
  ht.Erase(4);
  ht.Print();
  ht.Insert(make_pair(-2, -2)); //负数也可以存在表中,整型提升
  ht.Print();
  cout << ht.Find(-2) << endl;
}

5e1a7e6cd5b44b3ca8d924824bcc0b46.png

相关文章
|
9天前
|
存储 算法 C++
【C++】unordered_map(set)
C++中的`unordered`容器(如`std::unordered_set`、`std::unordered_map`)基于哈希表实现,提供高效的查找、插入和删除操作。哈希表通过哈希函数将元素映射到特定的“桶”中,每个桶可存储一个或多个元素,以处理哈希冲突。主要组成部分包括哈希表、哈希函数、冲突处理机制、负载因子和再散列,以及迭代器。哈希函数用于计算元素的哈希值,冲突通过开链法解决,负载因子控制哈希表的扩展。迭代器支持遍历容器中的元素。`unordered_map`和`unordered_set`的插入、查找和删除操作在理想情况下时间复杂度为O(1),但在冲突较多时可能退化为O(n)。
14 5
|
5月前
|
C++ 容器
【C++】map和set封装
【C++】map和set封装
40 2
|
5月前
|
存储 C++ 容器
【C++】map和set深度讲解(下)
【C++】map和set深度讲解(下)
64 2
|
5月前
|
存储 Serverless C++
【C++】手撕哈希表的闭散列和开散列
【C++】手撕哈希表的闭散列和开散列
52 1
|
5月前
|
存储 C++ 容器
【C++】开散列实现unordered_map与unordered_set的封装
【C++】开散列实现unordered_map与unordered_set的封装
57 0
|
6天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
21 2
|
12天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
40 5
|
19天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
49 4
|
20天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
46 4
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
28 4