前面的树形结构的关联式容器介绍完,下面要说的是哈希结构的关联式容器。
1. unordered系列
看到unordered
就知道其是无序的,在前面的map和set当中,我们知道,这个容器会根据数据的key值进行排序,但是这个系列是无序的。其也分unordered_map和unordered_set等,这两种系列关联式容器是无缝对接的,也就是说,这两种系列的各种类型的接口是相同的,所以这里不在介绍接口,直接用一些题来认识一下吧。
两句话中不常见的单词
class Solution {
public:
vector<string> uncommonFromSentences(string A, string B) {
unordered_map<string, int> cmap;
vector<string> ret;
FenCi(cmap, A);
FenCi(cmap, B);
for(const auto& str : cmap)
{
if(str.second == 1)
ret.push_back(str.first);
}
return ret;
}
private:
void FenCi(unordered_map<string, int>& m, string& str)
{
string tmp;
for (size_t i = 0; i <= str.size(); ++i)
{
if (str[i] != ' ' && i != str.size())
tmp += str[i];
else
{
++m[tmp];
tmp.clear();
}
}
}
};
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_map<int, int> m1;
unordered_map<int, int> m2;
vector<int> ret;
for(const auto& num : nums1)
++m1[num];
for(const auto& num : nums2)
++m2[num];
for(const auto& m : m1)
if(m2[m.first] >= 1)
ret.push_back(m.first);
return ret;
}
};
现在完成上面的用法,接下来就要看看底层的实现。
2. 哈希
2.1 概念
在前面的搜索树中,每次查询元素的时候,都需要进行比较,所以是一个O(logN)
的算法。在哈希中,通过对哈希函数对 key值进行计算,得到一个位置来存储元素,当通过key值查询元素时,用哈希计算位置后,直接访问该位置,所以这个效率还是比较高的。
哈希函数又可以叫散列函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
2.2 哈希冲突
其实通过上面的图可以很容易的发现,如果两个数计算的位置在同一个位置时,就会有冲突,如key=8,和key=1时两个都在1的位置上,这就是哈希冲突。后面的一系列方法都是为了解决这个冲突或者说是优化这个冲突。
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
2.3 哈希函数
下面说两种哈希函数,很常见的。在第一个图中用的哈希函数叫除留余数法。
2.3.1 除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m)
,将关键码转换成哈希地址。
例如第一个图。
所以如果p太小的话,这个方法的哈希冲突就很严重。
2.3.2 直接定址法
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B。
这个函数的几乎不会造成哈希冲突,但是有一个问题,就是浪费空间。例如A很大的时候,key很大的时候,空间的需求是一个线性增长的。
A = 100,key = 100,这是就需要10000以上的位置,如果数据的大小不是一个字节的时候,更需要很大的空间。
后续的方法基本是采用第一种,第二种只适合查找比较小且连续的情况。
2.4 哈希冲突的解决
哈希冲突的解决方式有两种:闭散列和开散列
2.4.1闭散列
闭散列又有线性探测和二次探测。
2.4.1.1 线性探测
线性探测就是说,计算出位置后,如果这个位置已经有数据了,我就往后找位置,知道找到一个空位置。而找这个值的时候,也是同时位置,如果该位置不是这个元素,就往后找,如果找到空位置都没有找到,说明哈希表里面没有这个值。
但是如果在找1之前,8的位置的数删除了怎么办,在线性探测中,数据删除的时候,不是真的删除,而是把结点的标记置为DELETE,当查询的时候,直接跳过这个数据就好。
下面来实现一下。
定义结点状态
//定义结点的状态
enum STATUS
{
EMPTY,//空
EXIST,//存在
DELETE//删除
};
定义成员
//哈希结点的成员
template<class K, class V>
struct HashNode
{
pair<K, V> kv_;
STATUS status_ = EMPTY;
};
//哈希表
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
typedef Node* pNode;
public:
HashTable(size_t N = 10)
{
ht_.resize(N);
size_ = 0;
}
private:
//实现简单,扩容都交给vector
vector<Node> ht_;
//有效元素的数量
size_t size_;
};
}
而且哈希表不会存满,因为会有一个负载因子进行控制,也就是数据到达一定程度,哈希表进行扩容。
void CheckCapacity()
{
if (size_ * 10 / ht_.size() >= 7)
{
size_t newsize = ht_.size() * 2;
HashTable<K, V> tmp(newsize);
for (size_t i = 0; i < ht_.size(); ++i)
{
if (ht_[i].status_ == EXIST)
{
tmp.Insert(ht_[i].kv_);
}
}
ht_.swap(tmp.ht_);
}
}
插入数据
//插入
bool Insert(const pair<K, V>& kv)
{
//首先需要检查容量
//设置负载因子,降低哈希冲突概率
CheckCapacity();
size_t index = kv.first % ht_.size();
while (ht_[index].status_ == EXIST)//找到不是存在状态的结点位置
{
if (ht_[index].kv_.first == kv.first)//如果该位置的key和插入结点的key相同,则不能插入
return false;
++index;
if (index == ht_.size())
index = 0;
}
ht_[index].kv_ = kv;
ht_[index].status_ = EXIST;//设置为存在数据状态
++size_;
return true;
}
查询数据,从计算出的位置一直往后找,如果找到空了,说明没有该节点。
pNode Find(const K& key)
{
size_t index = key % ht_.size();
while (ht_[index].status_ != EMPTY)
{
if (ht_[index].status_ == EXIST && ht_[index].kv_.first == key)
return &ht_[index];
++index;
if (index == ht_.size())
index = 0;
}
return nullptr;
}
删除数据,不是真的删除,只是设置状态,下次有数据来,直接覆盖。
bool Erase(const K& key)
{
pNode ret = Find(key);
if (ret)
{
ret->status_ == DELETE;
--size_;
return true;
}
return false;
}
注意当前的哈希表只能存储整型值,所以后续再慢慢优化,目前先记录思想。
2.4.1.2 二次探测
对于线性探测来说,每次一个一个的往后找,一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低效率比较低,于是出现了二次探测,跳着找位置。
找下一个空位置的方法为:
H
i
H_i
Hi = (
H
0
H_0
H0 +
i
2
i^2
i2)% m,
或者:
H
i
H_i
Hi = (
H
0
H_0
H0 -
i
2
i^2
i2) % m。
其中:i = 0,1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小
2.4.2 开散列
2.4.2.1 哈希桶
闭散列介绍完了,下面来说一下开散列吧。开散列采用的方式是,哈希桶。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
所以开散列中每个桶中放的都是发生哈希冲突的元素。
2.4.2.2 具体实现
定义哈希结点
template<class V>
struct HashNode
{
V data_;
HashNode<V>* next_;
HashNode(const V& data)
:data_(data)
,next_(nullptr)//桶中的元素指向下一个元素,如果是桶中的最后一个元素,就指向空
{}
};
哈希表的定义,哈希表中存的都是指向这些同的指针。
typedef HashNode<V> Node;
typedef Node* pNode;
size_t size_;
vector<pNode> ht_;
这里只实现一个插入。开散列的插入方式如下,举个例子
由于哈希表里面存的都是指向同的指针,因此,如果有新的结点来了,计算出位置之后,将给哈希表位置的指针,指向这个结点,这个新的结点在只想哈希表的当前位置原本指向的那个结点。
整个过程就是链表的头插。
这其中有一个哈希表的扩容过程。检测就是,如果结点的个数和哈希表的长度一样大,则扩容。
void CheckCapacity()
{
if (size_ == ht_.size())
{
size_t newsize = 2 * ht_.size();
vector<pNode> newht;
newht.resize(newsize);
for (size_t i = 0; i < size_; ++i)
{
pNode cur = ht_[i];
while (cur)
{
pNode next = cur->next_;
size_t index = kov(cur->data_) % newsize;
cur->next = newht[i];
cur = next;
}
ht_[i] = nullptr;
}
ht_.swap(newht);
}
}
插入的具体实现
bool Insert(const V& data)
{
CheckCapacity();
//仿函数实现
KeyOfVal kov;//获取V的K,通过数据获取key
size_t index = kov(data) % ht_.size();
pNode cur = ht_[index];
while (cur)
{
if (kov(cur->data_) == kov(data))
return false;
cur = cur->next;
}
//头插
cur = new Node(data);
cur->next = ht_[index];
ht_[index] = cur;
++size_;
}