一 <<什么是哈希表>>
我们学习了数据结构,其实都在做一件事情那就是 数据的结构 无论是学了啥?目的都是为了 存储数据 数据排序 查找数据
- 线性结构有 顺序表 链表
- 非线性的有 图 树 哈希
1.1 哈希表的定义
“散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
remark: 通过某种映射关系 比方将你映射为 阿兰图灵 那么在这个名人散列表查找到图灵时就等于找到了你
通常无论是在 顺序表,链表,树,图,均是 迭代访问数据,即遍历(挨个挨个访问),效率通常比较慢,但是当来到了hash它就会变得非常快(一映射,根据映射的地方查找,很快就能找到)
哈希表的几个概念
散列函数
比如说,我现在给你个电话本,上面记录的有姓名和对应的手机号,我想让你帮我找王二的手机号是多少,那么你会怎么做呢?
你可能会说,那挨个找呗。
确实可以,那么你有没有想过,如果这个王二是在最后几页,那你去岂不是前面几页都白找了,有没有更快的方式呢?
是不是可以按照人名给分个类,比如按照首字母来排序,就abcd那样的顺序,这样根据王二我就知道去找w这些,这样不久快很多了
我们可以按照人名的首字母去弄一个表格,比如像这样:
我们取姓名的首字母作为一个标志,就可以很快的找到以这个字母开头的人名了,那么王二也就能更快的被我们找到,我们也不用再费力气去找什么张二和李二的,因为人家的名字首字母都不是w。
这里我们用到了一种方法:那就是取姓名的首字母做一个排序,那么这是不是就是通过一些特定的方法去得到一个特定的值,比如这里取人名的首字母,那么如果是放到数学中,是不是就是类似一个函数似的,给你一个值,经过某些加工得到另外一个值,就像这里的给你个人名,经过些许加工我们拿到首字母,那么这个函数或者是这个方法在哈希表中就叫做散列函数
这种映射关系我们可能称之为 做白日梦
remark:其实 十辈子 都不会成为阿兰这种天才,我们普通人所能做的是尽量去理解天才
关键值key
就像画的这个图,阿兰 是怎么得出来得,是不是由我映射而来,这个映射过程其实就是个散列函数,而我就是整个散列体系的关键值
哈希表
所以说:哈希表就是通过将关键值也就是key通过一个散列函数加工处理之后得到一个值,这个值就是数据存放的位置,我们就可以根据这个值快速的找到我们想要的数据
1.2 哈希表的存储方式
之前我们已经知道了哈希表的本质其实是个数组,数组有啥特点?
——下表从0开始,连续的,直接通过下标访问
- 键值对:有一个key和一个value对应着,比如图中的101011是键值key,对应value张三,学生的学号和姓名就是一个键值对
- Entry:在java jdk里把键值对叫做Entry,C++则为pair
- 在散列表中存储的是键值对
1.3 哈希表如何存数据
看上面的图,我们已经知道了哈希表本质是个数组,所以这里有个数组,长度是8,现在我们要做的是把这个学生信息存放到哈希表中,也就是这个数组中去,那我们需要考虑怎么去存放呢?
这里的学号是个key,我们之前也知道了,哈希表就是根据key值来通过哈希函数计算得到一个值,这个值就是用来确定这个pair要存放在哈希表中的位置的,实际上这个值就是一个下标值,来确定放在数组的哪个位置上。
比如这里的学号是101011,那么经过哈希函数的计算之后得到了1,这个1就是告诉我们应该把这个pair放到哪个位置,这个1就是数组的确切位置的下标,也就是需要放在数组中下表为1的位置,如图中所示。
我们之前已经介绍过什么是pair了,所以这里你要知道,数组中1的位置存放的是一个pair,它不是一个简单的单个数值,而是一个键值对,也就是存放了key和value,key就是学号101011,value就是张三,我们经过哈希函数计算得出的1只是为了确定这个pair该放在哪个位置而已。
现在我们就成功把这个pair放到了哈希表中了
二 <<常见的哈希函数>>
//有好多方法
除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key%p(p<=m),将关键码转换成哈希地址
哈希冲突
不同Key经过哈希函数映射,得到相同的散列地址,这时便产生了冲突,你说你是阿兰,那我还说我是图灵呢!
通常我们有 两种方法解决哈希冲突
1.链地址法 2.线性探测法
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
为啥要进行头插,因为若是尾插我们先要通过遍历找到尾部节点, O(n)浪费时间, 头插O(1)
#include<iostream>
#include<vector>
using namespace std;
template<class Type>
class HashTable;
template<class Type>
class HashNode
{
friend class HashTable<Type>;
public:
HashNode(Type d = Type(), HashNode<Type>* n = nullptr) :data(d), next(n)
{}
~HashNode()
{}
private:
Type data;
HashNode* next;
};
template<class Type>
class HashTable
{
public:
HashTable()
{
memset(m_ht, 0, sizeof(m_ht));
}
HashNode<Type>* Find(const Type& Key)
{
size_t idx = Hash(Key);//idx就是在顺序表的下表位置
HashNode<Type>* p = m_ht[idx];//对数组所悬挂的链表进行遍历
while (p != nullptr && p->data != Key)//两种逻辑找了一圈没找到返回的p就是nullptr, 找到了就把p这个节点返回了
p = p->next;
return p;
}
void Insert(const Type& Key)
{
size_t idx = Hash(Key);
HashNode<Type>* Node = new HashNode<Type>(Key);//产生节点
Node->next = m_ht[idx];//对节点进行头插
m_ht[idx] = Node;
}
void Remove(const Type& Key)
{
size_t idx = Hash(Key);
HashNode<Type>* p = m_ht[idx];
/* 第一种情况 : 删除的节点 不存在 即哈希表上啥也没挂 Key也不存在
* 第二种情况 : 删除的节点 存在 是哈希表的头节点
* 第三种情况 : 哈希表不空 遍历链表要删除的节点不存在
* 第四种情况 : 删除的节点 存在
*
*/
if (p == nullptr)
return ;
if (p->data == Key)
m_ht[idx] = m_ht[idx]->next;
else{
while (p != nullptr && p->next->data != Key)//找的是删除节点的pre节点
p = p->next;
if (p == nullptr)
return;
HashNode<Type>* pre = p;
p = p->next;
pre->next = p->next;
}
delete p;
}
void Show()const
{
int i;
cout << "Hash" << endl;
for (i = 0; i < HASH_TABLE_SIZE; ++i)
{
cout << i << " :";
HashNode<Type>* p = m_ht[i];
while (p!= nullptr)
{
cout << p->data << "->";
p = p->next;
}
cout << "NIL."<<endl;
}
}
protected:
size_t Hash(const Type& Key)
{
return Key % HASH_TABLE_SIZE;//除留余数法
}
enum {HASH_TABLE_SIZE=7};
private:
HashNode<Type>* m_ht[HASH_TABLE_SIZE];//这是个指针数组里面存储的元素是HashNode*类型
};
void main()
{
int ar[] = { 1, 9, 10, 8, 22, 20 };
int n = sizeof(ar) / sizeof(ar[0]);
HashTable<int> ht;
for (int i = 0; i < n; ++i)
{
ht.Insert(ar[i]);
}
ht.Remove(8);
ht.Show();
}
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
#include<iostream>
#include<vector>
using namespace std;
enum State { EMPTY, EXIST, DELETE };
template<class K, class V>
class HashTable
{
struct Elem
{
pair<K, V> _val;
State _state;
};
public:
HashTable(size_t sz):m_ht(sz), m_size(0)
{
for(int i=0; i<sz; ++i)
{
m_ht[i]._state = EMPTY;
}
}
public:
void Insert(const pair<K,V> &val)
{
size_t hash_idx = Hash(val);
size_t origin_idx = hash_idx;
CheckCapacity();
while(m_ht[hash_idx]._state == EXIST)
{
hash_idx = (hash_idx+1) % m_ht.capacity(); //空间循环
if(hash_idx == origin_idx)
return;
}
Elem e = {val, EXIST};
m_ht[hash_idx] = e;
m_size++;
}
int Find(const pair<K,V> &key)
{
size_t hash_idx = Hash(key);
size_t origin_idx = hash_idx;
while(m_ht[hash_idx]._state==EXIST && key!=m_ht[hash_idx]._val)
{
hash_idx = (hash_idx+1) % m_ht.capacity(); //空间循环
if(hash_idx == origin_idx)
return -1;
}
if(m_ht[hash_idx]._state == EXIST)
return hash_idx;
return -1;
}
void Remove(const pair<K,V> &key)
{
int hash_idx = Find(key);
if(hash_idx != -1)
{
m_ht[hash_idx]._state = DELETE; //标记删除法
m_size--;
}
}
int GetNextPrime(int cur_prime)
{
static int prime_table[] = {7, 13, 19, 23, 29, 43, 53, 93, 103};
int n = sizeof(prime_table) / sizeof(prime_table[0]);
int i;
for(i=0; i<n; ++i)
{
if(cur_prime == prime_table[i])
break;
}
return i<n ? prime_table[i+1] : prime_table[n-1];
}
void CheckCapacity()
{
if (m_size * 10 / m_ht.capacity() >= 7) // 0.7
{
HashTable<K, V> new_ht(GetNextPrime(m_ht.capacity()));
for (size_t i = 0; i < m_ht.capacity(); ++i)
{
if (m_ht[i]._state == EXIST)
new_ht.Insert(m_ht[i]._val);
}
m_ht.swap(new_ht.m_ht);
}
}
protected:
size_t Hash(const pair<K,V> &val)
{
return val.first % m_ht.capacity();
}
private:
vector<Elem> m_ht;
size_t m_size;
};
void main()
{
int ar[] = {1, 9, 4, 10, 8, 22, 20, 15};
int n = sizeof(ar) / sizeof(ar[0]);
HashTable<int,int> ht(7);
for(int i=0; i<n; ++i)
{
pair<int,int> v = make_pair(ar[i],ar[i]);
ht.Insert(v);
}
int idx = ht.Find(make_pair(22,22));
ht.Remove(make_pair(22,22));
idx = ht.Find(make_pair(22,22));
}
哈希桶
解决冲突的方法选了链地址法的哈希表
伴随着冲突的不断加剧,虽然链地址法可以无限解决冲突,但是挂的节点越多,当目标节点在链表尾部时,我们寻找这个元素需要遍历整个链表,
寻找效率极其低下,因此我们可以对哈希表进行扩容,这样它所悬挂的节点会分散开,以达到高效查找的目的
#include<iostream>
using namespace std;
template<class Type>
class HashTable;
const size_t primeList[] = { 7, 13, 19, 23 };
size_t GetNextPrime(size_t prime)
{
for (int i = 0; i < 4; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[3];
}
template<class Type>
class HashNode
{
friend class HashTable<Type>;
public:
HashNode(Type d = Type(), HashNode<Type>* n = NULL) :data(d), next(n)
{}
~HashNode()
{}
private:
Type data;
HashNode* next;
};
template<class Type>
class HashTable
{
public:
HashTable() :m_size(0)
{
m_ht = new HashNode<Type>*[DEFAULT_HASH_TABLE_SIZE];
memset(m_ht, 0, sizeof(HashNode<Type>*) * DEFAULT_HASH_TABLE_SIZE);
m_bucket_count = DEFAULT_HASH_TABLE_SIZE;
}
public:
HashNode<Type>* Find(const Type& key)
{
size_t idx = Hash(key);
HashNode<Type>* p = m_ht[idx];
while (p != NULL && key != p->data)
p = p->next;
return p;
}
void Insert(const Type& v)
{
m_size++;
CheckCapacity();
//hash地址
size_t idx = Hash(v);
//插入数据
HashNode<Type>* node = new HashNode<Type>(v);
node->next = m_ht[idx];
m_ht[idx] = node;
}
void Remove(const Type& key)
{
//查找
size_t idx = Hash(key);
HashNode<Type>* p = m_ht[idx];
if (p == NULL)
return;
if (key == m_ht[idx]->data)
{
m_ht[idx] = p->next;
}
else
{
while (p != NULL && p->next->data != key)
p = p->next;
if (p == NULL)
return;
HashNode<Type>* prev = p;
p = p->next;
prev->next = p->next;
}
//删除
delete p;
}
void Show()const
{
for (int i = 0; i < m_bucket_count; ++i)
{
cout << i << " : ";
HashNode<Type>* p = m_ht[i];
while (p != NULL)
{
cout << p->data << "-->";
p = p->next;
}
cout << "Nil." << endl;
}
}
protected:
size_t Hash(const Type& key)
{
//除留余数法
return key % m_bucket_count;
}
protected:
void CheckCapacity()
{
if (m_size > m_bucket_count)
{
//扩容
size_t new_bucket_count = GetNextPrime(m_bucket_count);
HashNode<Type>** new_ht = new HashNode<Type>*[new_bucket_count];
memset(new_ht, 0, sizeof(HashNode<Type>*) * new_bucket_count);
for (int i = 0; i < m_bucket_count; ++i)
{
HashNode<Type>* p = m_ht[i];
while (p != NULL)
{
m_ht[i] = p->next;
size_t hash_index = p->data % new_bucket_count;
p->next = new_ht[hash_index];
new_ht[hash_index] = p;
p = m_ht[i];
}
}
delete[]m_ht;
m_ht = new_ht;
m_bucket_count = new_bucket_count;
}
}
protected:
enum { DEFAULT_HASH_TABLE_SIZE = 7 };
private:
HashNode<Type>** m_ht;
size_t m_bucket_count;//哈希表长
size_t m_size;//已经挂载的节点个数
};
void main()
{
int ar[] = { 1, 9, 10, 8, 22, 20, 43, 32, 21, 4, 6, 76, 8, 9, 56, 54, 48, 25, 5 };
//int ar[] = {1, 9, 10, 8, 22, 20, 43, 32};
int n = sizeof(ar) / sizeof(ar[0]);
HashTable<int> ht;
for (int i = 0; i < n; ++i)
{
ht.Insert(ar[i]);
}
//ht.Remove(8);
ht.Show();
//HashNode<int> *p = ht.Find(80);
}
哈希的应用 ---布隆过滤器
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
-
用哈希表存储用户记录,缺点:浪费空间
-
用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。
-
将哈希与位图结合,即布隆过滤器
布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概 率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存 在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也 可以节省大量的内存空间。
布隆过滤器的插入
布隆过滤器通过多个哈希函数将我们期望“Key ” 映射到位图中,高效且节省空间
布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特 位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为 零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
attention:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可 能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其 他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。【上图若你将baidu 删除 ,那么tencent也将无法找到(缺少7位置的1)】
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计 数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储 空间的代价来增加删除操作。
- 缺陷: 1.
- 无法确认元素是否真正在布隆过滤器中
- 存在计数回绕
对于哈希函数的取法,是数学领域所擅长的,哈希函数取得好,我们的Key便极具敏感性,Key稍微发生改变,Key的映射便产生极大的变化
#include<iostream>
#include<string>
#include<bitset>
#include<assert.h>
using namespace std;
#define _N 10 //误判率
template<class T>
struct Hashfunc1
{
size_t operator()(const T& key)
{
return BKDRHash(key.c_str());
}
size_t BKDRHash(const char* str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = hash * 131 + ch;
}
return hash;
}
};
template<class T>
struct Hashfunc2
{
size_t operator()(const T& key)
{
return SDBMHash(key.c_str());
}
size_t SDBMHash(const char* str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = 65599 * hash + ch;
}
return hash;
}
};
template<class T>
struct Hashfunc3
{
size_t operator()(const T& key)
{
return RSHash(key.c_str());
}
size_t RSHash(const char* str)
{
register size_t hash = 0;
size_t magic = 63689;
while (size_t ch = (size_t)*str++)
{
hash = hash * magic + ch;
magic *= 378551;
}
return hash;
}
};
template<class T, class KeyToInt1 = Hashfunc1<T>,class KeyToInt2 = Hashfunc2<T>,class KeyToInt3 = Hashfunc3<T>>
class BloomFilter
{
public:
BloomFilter() :_size(0)
{}
public:
void Insert(const T& v)
{
size_t idx1 = KeyToInt1()(v) % _N;
_bitmap.set(idx1);
size_t idx2 = KeyToInt2()(v) % _N;
_bitmap.set(idx2);
size_t idx3 = KeyToInt3()(v) % _N;
_bitmap.set(idx3);
_size++;
}
bool Test(const T& key)const
{
size_t idx1 = KeyToInt1()(key) % _N;
if (_bitmap.test(idx1) == 0)
return false;
size_t idx2 = KeyToInt2()(key) % _N;
if (_bitmap.test(idx2) == 0)
return false;
size_t idx3 = KeyToInt3()(key) % _N;
if (_bitmap.test(idx3) == 0)
return false;
return true; //可能存在,有可能存在误判
}
private:
bitset<_N> _bitmap; //位图
size_t _size;
};
void main()
{
const string url1 = "www.baidu.com";
const string url2 = "www.taobao.com";
const string url3 = "www.jingdong.com";
const string url4 = "www.pinduoduo.com";
const string url5 = "www.qq.com";
const string url6 = "www.weixin.com";
BloomFilter<string> bf;
bf.Insert(url1);
bf.Insert(url2);
//bf.Insert(url3);
//bf.Insert(url4);
//bf.Insert(url5);
cout << bf.Test(url2) << endl;
}
布隆过滤器优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无 关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题