C++STL——哈希(上)

简介: C++STL——哈希(上)

unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到l o g 2 N log_2Nlog2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。

最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是

其底层结构不同。

unordered_set与unordered_map

unordered_set文档

template < class Key, // unordered_set::key_type/value_type

class Hash = hash, // unordered_set::hasher

class Pred = equal_to, // unordered_set::key_equal

class Alloc = allocator // unordered_set::allocator_type

class unordered_set;

#include<iostream>
#include<unordered_set>
using namespace std;
int main()
{
  unordered_set<int>us;
  us.insert(3);
  us.insert(4);
  us.insert(2);
  us.insert(3);
  us.insert(0);
  for (auto e : us)
  {
    cout << e << " ";
  }
  cout << endl;
  return 0;
}

这里是无序的。

unordered_map文档

template < class Key, // unordered_map::key_type

class T, // unordered_map::mapped_type

class Hash = hash, // unordered_map::hasher

class Pred = equal_to, // unordered_map::key_equal

class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type

class unordered_map;

set VS unordered_set

这里用几组测试用例来看看它们的各大接口效率。

插入效率

#include<iostream>
#include<unordered_set>
#include<set>
#include<vector>
#include<ctime>
using namespace std;
int main()
{
  const size_t N = 100000;
  unordered_set<int>arr1;
  set<int>arr2;
  vector<int>arr;
  arr.reserve(N);
  srand(time(0));
  for (int i = 0; i < N; i++)
  {
    arr.push_back(rand());
  }
  size_t begin1 = clock();
  for (auto e : arr)
  {
    arr1.insert(e);
  }
  size_t end1 = clock();
  size_t begin2 = clock();
  for (auto e : arr)
  {
    arr2.insert(e);
  }
  size_t end2 = clock();
  cout << "unordered_set:" << end1 - begin1 << endl;
  cout << "set:" << end2 - begin2 << endl;
}

这里set更慢一些,因为插入的是随机值,如果是有序的,set速度会比unordered_set更快一些。

查找效率

#include<iostream>
#include<unordered_set>
#include<set>
#include<vector>
#include<ctime>
using namespace std;
int main()
{
  const size_t N = 100000;
  unordered_set<int>arr1;
  set<int>arr2;
  vector<int>arr;
  arr.reserve(N);
  srand(time(0));
  for (int i = 0; i < N; i++)
  {
    arr.push_back(rand());
  }
  size_t begin1 = clock();
  for (auto e : arr)
  {
    arr1.insert(e);
  }
  size_t end1 = clock();
  size_t begin2 = clock();
  for (auto e : arr)
  {
    arr2.insert(e);
  }
  size_t end2 = clock();
  cout << "unordered_set:" << end1 - begin1 << endl;
  cout << "set:" << end2 - begin2 << endl;
  size_t begin3 = clock();
  for (auto e : arr)
  {
    arr1.find(e);
  }
  size_t end3 = clock();
  size_t begin4 = clock();
  for (auto e : arr)
  {
    arr2.find(e);
  }
  size_t end4 = clock();
  cout << "unordered_set:" << end3 - begin3 << endl;
  cout << "set:" << end4 - begin4 << endl;
}

把数组里面的数再多加一点,变成一百万。

这里就算set里面是最平衡的数据(插入的时候都是有序数组),查找的效率也是不如unordered_set。

删除效率

#include<iostream>
#include<unordered_set>
#include<set>
#include<vector>
#include<ctime>
using namespace std;
int main()
{
  const size_t N = 1000000;
  unordered_set<int>arr1;
  set<int>arr2;
  vector<int>arr;
  arr.reserve(N);
  srand(time(0));
  for (int i = 0; i < N; i++)
  {
    arr.push_back(rand());
  }
  size_t begin1 = clock();
  for (auto e : arr)
  {
    arr1.insert(e);
  }
  size_t end1 = clock();
  size_t begin2 = clock();
  for (auto e : arr)
  {
    arr2.insert(e);
  }
  size_t end2 = clock();
  cout << "unordered_set:" << end1 - begin1 << endl;
  cout << "set:" << end2 - begin2 << endl;
  size_t begin3 = clock();
  for (auto e : arr)
  {
    arr1.find(e);
  }
  size_t end3 = clock();
  size_t begin4 = clock();
  for (auto e : arr)
  {
    arr2.find(e);
  }
  size_t end4 = clock();
  cout << "unordered_set:" << end3 - begin3 << endl;
  cout << "set:" << end4 - begin4 << endl;
  size_t begin5 = clock();
  for (auto e : arr)
  {
    arr1.erase(e);
  }
  size_t end5 = clock();
  size_t begin6 = clock();
  for (auto e : arr)
  {
    arr2.erase(e);
  }
  size_t end6 = clock();
  cout << "unordered_set:" << end5 - begin5 << endl;
  cout << "set:" << end6 - begin6 << endl;
}

如果是有序的话,set会更快。

总结:对于查找来说,unordered系列的查找是最快的。

底层结构

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

哈希概念与哈希冲突

哈希映射:key值跟储存位置建立关联关系。(类似于计数排序一样)

但是这种方式有一个很大的问题,如果最小值和最大值差距非常大,那么值就会非常分散,并且会消耗很大的空间。

这个方法叫做:直接定址法。(一般适用于范围集中的一组值)

如果遇到那种一组值大小分散的很大,就用除留余数法:

1 10 55 3 6 88 123 1234

这里给了8个值,我们开辟10个空间就够了,然后让这组数%10就能找到值的相对位置。

但是这里就会有新问题,123%10=3,与数组里面的3相同,那么3的位置应该放哪一个?

这里就叫做哈希冲突,不同的值映射到相同的位置。

哈希冲突的解决

闭散列——开放定址法

如果映射的位置已经有值了,那么就按照某种规律找其他位置。

这里123%10等于3,但是下标为3的位置已经被占用了,所以只能向后找位置,发现下标为4的位置没有被占用,后续的1234插入的时候发现下标为4的位置被占用了,就向后找没被占用位置的位置。

但是如果冲突很多,查找的效率就会降低。

线性探测

如果我们要找1234这个值是否存在,先让1234%10,然后到4下标找,发现不是1234这个值,那么就向后继续找,最后在下标为7的位置找到1234。

如果要找66这个值,先从下标为6的地方找,然后继续往后找,但是走到下标为9的时候,发现为空,那么这里久没必要在进行查找了,说明没有此值。

如何删除一个值

如果想删除哈希表中的一个值,搜狐先不能牵动哈希表中的任何值,如果移动了其他值就会导致原本在正确位置上的值变得不正确,查找就可能会出问题:不移动只是删除然后变成空也不行,因为查找的时候遇到空停下,那么这个时候删除的这个位置后面的值就有可能无法被查找到了。

这个时候的解决办法就是:

给每个数组中元素都设置三个状态——空,删除,存在

闭散列的实现

首先考虑要给问题,扩容。

什么时候扩容最合适?肯定不是满了在扩容,有一个叫做负载因子(载荷因子)= 表中有效数据的个数/表的大小。

负载因子越小,冲突概率越小,消耗空间越多。

负载因子越大,冲突概率越大,消耗空间利用率越高。

一般负载因子控制在0.7就可以了。

那么扩容的时候就需要将原来表中的数据重新放入新表中,这样就会让一些数据回到本该属于他们的位置,线性探测消耗的时间就不会太多了。

还有一个问题,那么如果插入的不是整形数据,是一个字符串呢?

一个字符串没办法去取%,所以我们要用仿函数对于字符串等类型进行特殊处理。(转成整形在进行映射)

#include<iostream>
#include<cassert>
#include<vector>
#include<string>
using namespace std;
template<class K>
struct HashFunc//能直接转成整型类型的仿函数
{
  size_t operator()(const K& key)
  {
    return (size_t)key;
  }
};
template<class K>
struct Hashstring//让string类型转成整形,这里也可以用HashFunc的特化支持string类型
{
  size_t operator()(const K& key)
  {
    size_t sum = 0;
    size_t count = 0;
    for (auto e : key)
    {
      ++count;
      sum += e;
    }
    return sum;
  }
};
//闭散列的状态
enum State
{
  EMPTY,//空
  EXIST,//存在
  DELETE//删除
};
//闭散列的每个元素
template<class K, class V>
struct Closed
{
  pair<K, V> _kv;
  State _state = EMPTY;
};
template<class K, class V, class HashF = HashFunc<K>>
class Closed_hash
{
public:
  typedef Closed<K, V> data;
  Closed_hash()
    :_n(0)
  {
    _hash.resize(10);
  }
  bool Insert(const pair<K, V>& kv)
  {
    if (find(kv.first))
      return false;
    if (_n * 10 / _hash.size() > 7)//如果等于0.7就扩容
    {
      Closed_hash<K, V, HashF> node;
      node._hash.resize(_hash.size() * 2);
      for (auto& e : _hash)
      {
        if (e._state == EXIST)//如果存在就插入新开辟的node里面
        {
          node.Insert(e._kv);
        }
      }
      node._hash.swap(_hash);//交换两个表中的数组
    }
    HashF datum;
    size_t hashi = datum(kv.first) % _hash.size();//找到原本插入的位置
    while (_hash[hashi]._state == EXIST)//线性探测
    {
      ++hashi;
      hashi = hashi % _hash.size();//防止越界
    }//如果找到删除或者是空的位置就插入数据
    _hash[hashi]._kv = kv;
    _hash[hashi]._state = EXIST;//改变状态
    ++_n;
    return true;
  }
  data* find(const K& key)
  {
    HashF datum;
    size_t hashi = datum(key) % _hash.size();
    size_t start = hashi;
    while (_hash[hashi]._state != EMPTY)
    {
      if (_hash[hashi]._state == EXIST && _hash[hashi]._kv.first == key)
      {
        return &_hash[hashi];
      }
      ++hashi;
      hashi %= _hash.size();
      if(start == hashi)//极端场景下,表中的数据不是存在就是删除,没有空
        break;
    }
    return nullptr;
  }
  bool Erase(const K& key)
  {
    data* ret = find(key);
    if (ret != nullptr)
    {
      ret->_state = DELETE;
      return true;
    }
    return false;
  }
private:
  vector<data> _hash;//哈希表
  size_t _n = 0;//哈希表中有效数据的个数
};

二次探测

二次探测是按照二次方的顺序探测。

这个是为了解决连续冲突数据。

相关文章
|
1月前
|
缓存 算法 程序员
C++STL底层原理:探秘标准模板库的内部机制
🌟蒋星熠Jaxonic带你深入STL底层:从容器内存管理到红黑树、哈希表,剖析迭代器、算法与分配器核心机制,揭秘C++标准库的高效设计哲学与性能优化实践。
C++STL底层原理:探秘标准模板库的内部机制
|
8月前
|
编译器 C++ 容器
【c++丨STL】基于红黑树模拟实现set和map(附源码)
本文基于红黑树的实现,模拟了STL中的`set`和`map`容器。通过封装同一棵红黑树并进行适配修改,实现了两种容器的功能。主要步骤包括:1) 修改红黑树节点结构以支持不同数据类型;2) 使用仿函数适配键值比较逻辑;3) 实现双向迭代器支持遍历操作;4) 封装`insert`、`find`等接口,并为`map`实现`operator[]`。最终,通过测试代码验证了功能的正确性。此实现减少了代码冗余,展示了模板与仿函数的强大灵活性。
223 2
|
8月前
|
存储 算法 C++
【c++丨STL】map/multimap的使用
本文详细介绍了STL关联式容器中的`map`和`multimap`的使用方法。`map`基于红黑树实现,内部元素按键自动升序排列,存储键值对,支持通过键访问或修改值;而`multimap`允许存在重复键。文章从构造函数、迭代器、容量接口、元素访问接口、增删操作到其他操作接口全面解析了`map`的功能,并通过实例演示了如何用`map`统计字符串数组中各元素的出现次数。最后对比了`map`与`set`的区别,强调了`map`在处理键值关系时的优势。
439 73
|
9月前
|
存储 缓存 C++
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
C++ 标准模板库(STL)提供了一组功能强大的容器类,用于存储和操作数据集合。不同的容器具有独特的特性和应用场景,因此选择合适的容器对于程序的性能和代码的可读性至关重要。对于刚接触 C++ 的开发者来说,了解这些容器的基础知识以及它们的特点是迈向高效编程的重要一步。本文将详细介绍 C++ 常用的容器,包括序列容器(`std::vector`、`std::array`、`std::list`、`std::deque`)、关联容器(`std::set`、`std::map`)和无序容器(`std::unordered_set`、`std::unordered_map`),全面解析它们的特点、用法
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
|
8月前
|
存储 算法 C++
【c++丨STL】set/multiset的使用
本文深入解析了STL中的`set`和`multiset`容器,二者均为关联式容器,底层基于红黑树实现。`set`支持唯一性元素存储并自动排序,适用于高效查找场景;`multiset`允许重复元素。两者均具备O(logN)的插入、删除与查找复杂度。文章详细介绍了构造函数、迭代器、容量接口、增删操作(如`insert`、`erase`)、查找统计(如`find`、`count`)及`multiset`特有的区间操作(如`lower_bound`、`upper_bound`、`equal_range`)。最后预告了`map`容器的学习,其作为键值对存储的关联式容器,同样基于红黑树,具有高效操作特性。
343 3
|
9月前
|
存储 算法 C++
【c++丨STL】priority_queue(优先级队列)的使用与模拟实现
本文介绍了STL中的容器适配器`priority_queue`(优先级队列)。`priority_queue`根据严格的弱排序标准设计,确保其第一个元素始终是最大元素。它底层使用堆结构实现,支持大堆和小堆,默认为大堆。常用操作包括构造函数、`empty`、`size`、`top`、`push`、`pop`和`swap`等。我们还模拟实现了`priority_queue`,通过仿函数控制堆的类型,并调用封装容器的接口实现功能。最后,感谢大家的支持与关注。
507 1
|
10月前
|
C++ 容器
【c++丨STL】stack和queue的使用及模拟实现
本文介绍了STL中的两个重要容器适配器:栈(stack)和队列(queue)。容器适配器是在已有容器基础上添加新特性或功能的结构,如栈基于顺序表或链表限制操作实现。文章详细讲解了stack和queue的主要成员函数(empty、size、top/front/back、push/pop、swap),并提供了使用示例和模拟实现代码。通过这些内容,读者可以更好地理解这两种数据结构的工作原理及其实现方法。最后,作者鼓励读者点赞支持。 总结:本文深入浅出地讲解了STL中stack和queue的使用方法及其模拟实现,帮助读者掌握这两种容器适配器的特性和应用场景。
249 21
|
9月前
|
存储 算法 C++
深入浅出 C++ STL:解锁高效编程的秘密武器
C++ 标准模板库(STL)是现代 C++ 的核心部分之一,为开发者提供了丰富的预定义数据结构和算法,极大地提升了编程效率和代码的可读性。理解和掌握 STL 对于 C++ 开发者来说至关重要。以下是对 STL 的详细介绍,涵盖其基础知识、发展历史、核心组件、重要性和学习方法。
|
11月前
|
编译器 C语言 C++
【c++丨STL】list模拟实现(附源码)
本文介绍了如何模拟实现C++中的`list`容器。`list`底层采用双向带头循环链表结构,相较于`vector`和`string`更为复杂。文章首先回顾了`list`的基本结构和常用接口,然后详细讲解了节点、迭代器及容器的实现过程。 最终,通过这些步骤,我们成功模拟实现了`list`容器的功能。文章最后提供了完整的代码实现,并简要总结了实现过程中的关键点。 如果你对双向链表或`list`的底层实现感兴趣,建议先掌握相关基础知识后再阅读本文,以便更好地理解内容。
243 1
|
11月前
|
算法 C语言 C++
【c++丨STL】list的使用
本文介绍了STL容器`list`的使用方法及其主要功能。`list`是一种双向链表结构,适用于频繁的插入和删除操作。文章详细讲解了`list`的构造函数、析构函数、赋值重载、迭代器、容量接口、元素访问接口、增删查改操作以及一些特有的操作接口如`splice`、`remove_if`、`unique`、`merge`、`sort`和`reverse`。通过示例代码,读者可以更好地理解如何使用这些接口。最后,作者总结了`list`的特点和适用场景,并预告了后续关于`list`模拟实现的文章。
365 7