【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

相关文章
|
7月前
|
编译器 C++ 容器
【c++丨STL】基于红黑树模拟实现set和map(附源码)
本文基于红黑树的实现,模拟了STL中的`set`和`map`容器。通过封装同一棵红黑树并进行适配修改,实现了两种容器的功能。主要步骤包括:1) 修改红黑树节点结构以支持不同数据类型;2) 使用仿函数适配键值比较逻辑;3) 实现双向迭代器支持遍历操作;4) 封装`insert`、`find`等接口,并为`map`实现`operator[]`。最终,通过测试代码验证了功能的正确性。此实现减少了代码冗余,展示了模板与仿函数的强大灵活性。
192 2
|
3月前
|
存储 算法 PHP
数组去重性能优化:为什么Set和Object哈希表的效率最高
在处理数组去重问题时,使用 `Set` 和 `Object` 哈希表是高效的解决方案。它们基于哈希表实现,插入和查找操作的时间复杂度为 `O(1)`,相比传统嵌套循环的 `O(n²)` 方法性能优势显著。`Set` 能保持元素插入顺序,适用于需要顺序的场景;`Object` 则通过键的唯一性实现去重,适合无需顺序的场景。两者均能在大规模数据中实现高效的去重操作,是数组去重最优选择。
|
7月前
|
存储 算法 C++
【c++丨STL】map/multimap的使用
本文详细介绍了STL关联式容器中的`map`和`multimap`的使用方法。`map`基于红黑树实现,内部元素按键自动升序排列,存储键值对,支持通过键访问或修改值;而`multimap`允许存在重复键。文章从构造函数、迭代器、容量接口、元素访问接口、增删操作到其他操作接口全面解析了`map`的功能,并通过实例演示了如何用`map`统计字符串数组中各元素的出现次数。最后对比了`map`与`set`的区别,强调了`map`在处理键值关系时的优势。
369 73
|
7月前
|
存储 算法 C++
【c++丨STL】set/multiset的使用
本文深入解析了STL中的`set`和`multiset`容器,二者均为关联式容器,底层基于红黑树实现。`set`支持唯一性元素存储并自动排序,适用于高效查找场景;`multiset`允许重复元素。两者均具备O(logN)的插入、删除与查找复杂度。文章详细介绍了构造函数、迭代器、容量接口、增删操作(如`insert`、`erase`)、查找统计(如`find`、`count`)及`multiset`特有的区间操作(如`lower_bound`、`upper_bound`、`equal_range`)。最后预告了`map`容器的学习,其作为键值对存储的关联式容器,同样基于红黑树,具有高效操作特性。
300 3
|
8月前
|
编译器 容器
哈希表模拟封装unordered_map和unordered_set
哈希表模拟封装unordered_map和unordered_set
|
11月前
|
存储 算法 C++
【C++】unordered_map(set)
C++中的`unordered`容器(如`std::unordered_set`、`std::unordered_map`)基于哈希表实现,提供高效的查找、插入和删除操作。哈希表通过哈希函数将元素映射到特定的“桶”中,每个桶可存储一个或多个元素,以处理哈希冲突。主要组成部分包括哈希表、哈希函数、冲突处理机制、负载因子和再散列,以及迭代器。哈希函数用于计算元素的哈希值,冲突通过开链法解决,负载因子控制哈希表的扩展。迭代器支持遍历容器中的元素。`unordered_map`和`unordered_set`的插入、查找和删除操作在理想情况下时间复杂度为O(1),但在冲突较多时可能退化为O(n)。
219 5
|
11月前
|
存储 C++ 容器
【C++】map的模拟实现
C++中的`map`是STL中的一种关联容器,存储键值对且键唯一。`map`基于红黑树实现,自动按键排序,支持动态调整、复杂数据类型、丰富的成员函数及双向迭代器。插入、查找等操作保证了对数时间复杂度,适用于需要快速查找和有序存储的场景。
158 3
|
8月前
|
编译器 C++ 开发者
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
|
4月前
|
人工智能 机器人 编译器
c++模板初阶----函数模板与类模板
class 类模板名private://类内成员声明class Apublic:A(T val):a(val){}private:T a;return 0;运行结果:注意:类模板中的成员函数若是放在类外定义时,需要加模板参数列表。return 0;
106 0
|
4月前
|
存储 编译器 程序员
c++的类(附含explicit关键字,友元,内部类)
本文介绍了C++中类的核心概念与用法,涵盖封装、继承、多态三大特性。重点讲解了类的定义(`class`与`struct`)、访问限定符(`private`、`public`、`protected`)、类的作用域及成员函数的声明与定义分离。同时深入探讨了类的大小计算、`this`指针、默认成员函数(构造函数、析构函数、拷贝构造、赋值重载)以及运算符重载等内容。 文章还详细分析了`explicit`关键字的作用、静态成员(变量与函数)、友元(友元函数与友元类)的概念及其使用场景,并简要介绍了内部类的特性。
181 0