👉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 的桶操作
3. unordered_map 的使用
unordered_map、unordered_set 和map、set 的用法都是差不多的,现在我们来简单地使用一下 unordered_map。
在长度 2N 的数组中找出重复 N 次的元素
给你一个整数数组 nums ,该数组具有以下属性:
nums.length == 2 * n.
nums 包含 n + 1 个 不同的元素
nums 中恰有一个元素重复 n 次
找出并返回重复了 n 次的那个元素。
思路:先用 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; } };
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; }
map / set 和 unordered 系列的对比
#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; }
👉底层结构👈
哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为 O(N),平衡树中为树的高度,即 O(l o g 2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中插入元素时,根据待插入元素的关键码以此函数计算出该元素的存储位置并按此位置进行存放。当搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较。若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
关于哈希的思想,其实我们在字符串中的第一个唯一字符这道题目里早就用到过了。
字符串中的第一个唯一字符中用到的是直接定址法,这种方法只能解决一些简单的场景。好比如:当数据的间隔较大时,就会很浪费空间了。
那为了解决上面的问题,我们可以除流余数法。
哈希冲突
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列(拉链法 / 哈希桶)。
1. 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去。那如何寻找下一个空位置呢?
线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。插入元素时,通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除元素时,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素 4,如果直接删除掉,44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素,并没有将该元素真正的删除掉,而是采用标记的方式处理,但是不能直接将该位置标记为空,否则会影响从该位置产生冲突的元素的查找。。哈希表每个空间给个标记:EMPTY 表示此位置空,EXIST 表示此位置已经有元素,DELETE 表示元素已经删除。
当vector快要满时,此时的哈希冲突已经出现比较多了,存在你占我的位置,我占用别人的位置的情况了。那么这时候哈希表就要扩容了。那什么时候要扩容呢?为了解决扩容问题,有大佬提出了负载因子(载荷因子)的概念。哈希表的负载因子等于填入表中的元素个数除以哈希表的长度。负载因子越小,哈希冲突的概率越小;负载因子越大,哈希冲突的概率越大。当负载因子到达一个基准值时,哈希表就需要扩容。基准越大,冲突越多,效率越低,空间利用率越高。哈希表扩容的代价比vector扩容的代价还有大,因为原来存在哈希冲突的数据,有可能就不冲突了,需要重新映射,并不能直接将数据拷贝到原来的位置上。
#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; }