1. 定义
如果查找关键字时不需要比较就可以获得需要记录的存储位置,这就称作【散列技术】。它是在记录的存储位置和它的关键字之间建立一个确定的对应关系 f,使得每个关键字 key 对应一个存储位置 f(key)。把这种对应关系 f 称为【散列函数】,又称【哈希(hash)函数】,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为【散列表】或【哈希表(hash table)】。那么关键字对应的记录存储位置就称为【散列地址】。
优点:适用于快速的查找,其时间复杂度为 \(O(1)\)。
缺点:占用内存空间比较大。
1.1 散列函数
设计特点:
- 计算简单(复杂的计算会降低查找的时间)
- 散列地址分布均匀(减少哈希冲突)
1.1.1 直接定址法
直接定址法就是取关键字的某个线性函数作为散列地址,即\(f(key)=a*key+b\)
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是要事先知道关键字的分布情况,适合查找表较小且连续的情况。
1.1.2 数字分析法
如果关键字是位数较多的数字,比如 11 位的手机号,其前3位是接入号,对应不同运营商公司的子品牌;中间四位是 HLR 识别号,表示用户归属地;最后四位才是真正的用户号。
那么如果采用用户手机号作为关键字,很可能前 7 位都是相同的。那么可以选择后四位成为散列地址。同时,为了解决冲突问题,还可以对抽取出来的数字再进行反转、右环移位、左环移位、甚至前两数与后两数叠加等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,可以考虑此方法。
1.1.3 平方取中法
假如关键字为 1234,那么对其平方,得到 1522756,再抽取中间的 3 位就是 227,用做散列地址。
平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。
1.1.4 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如关键字为 9876543210,散列表表长为三位,将其分为四组,987|654|321|0,然后对其叠加求和,即 987+654+321+0=1962,再求后 3 位得到散列地址为 962。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
1.1.5 除留余数法
此方法为最常用的构造散列函数的方法。对于散列表长为 m 的散列函数公式为:\(f(key)=key\mod\ p(p\le m)\),其中,mod 是取模(求余数)的意思。
根据经验,若散列表表长为 m,通常 p 为小于或等于表长(最好接近 m)的最小质数或不包含小于 20 质因子的合数。
1.1.6 随机数法
选择一个随机数,取关键字的随机函数值作为它的散列表地址。当关键字长度不等时,采用这个方法较为合适。
1.2 散列冲突
1.2.1 线性探测法
线性探测法也称为【开放定址法】,即一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列表地址总能找到,并将记录存入,即 \(f_1(key)=(f(key)+d_i)\ MOD\ m\ (d_i=1,2,3,...,m-1)\)。
比如,对于如下关键字集合,表长为 12。采用散列函数 \(f(key)=key\ mod\ 12\)。
12 | 67 | 56 | 16 | 25 | 37 | 22 | 29 | 15 | 47 | 48 | 34 |
---|
当计算前5个数时,都没有冲突的散列地址,直接存入:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 16 | 67 | 56 |
当计算 37 时,发现\(f(37)=1\),此时就与 25 所在位置产生冲突。于是应用上面的公式\(f(37)=(f(37)+1)mod\ 12=2\)。于是 37 就存入了下标为 2 的位置:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 37 | 16 | 67 | 56 |
接下来的 22、29、15、47 都没有冲突:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 37 | 15 | 16 | 29 | 67 | 56 | 22 | 47 |
当计算 48 时,有 \(f(48)=0\),与 12 所在的位置冲突了,然后 \(f(48)=(f(48)+1)mod\ 12=1\),此时又与 25 所在位置产生冲突,于是 \(f(48)=(f(48)+2)mod\ 12=2\),还是冲突,重复如此计算,直到结果为 6 时才有空位:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 37 | 15 | 16 | 29 | 48 | 67 | 56 | 22 | 47 |
将这种解决冲突的开放定址法称为【线性探测法】。对于上述例子中 48 和 37 这种本来都不是同义词却需要争夺一个地址的情况,称这种现象为【堆积】。
开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是常用的解决冲突的方法。
1.2.2 二次探测法
同样的对于上述的例子,当计算 34 时,\(f(34)=10\),与 22 所在位置冲突,但是 22 后面没有空位了,反而其前面存在空位,虽然可以不断求余数得到结果,但是效率很差。
因此改进 \(d_i=1^2,-1^2,2^2,-2^2,...,q^2,-q^2\ (q\le m/2)\),使得可以双向寻找可能的空位置。称这种方法为【二次探测法】。
还有一种方法,在冲突时,对于位移量 \(d_i\) 采用随机函数计算得到,称之为【随机探测法】。
1.2.3 链地址法
将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。
2.代码实现
2.1 线性探测哈希表
线性探测哈希表的代码实现如下:
// 桶的状态
enum State {
UNUSED = 0, // 未曾使用
USING, // 正在使用
REMOVE, // 已被删除
};
struct Bucket {
Bucket(int key = 0, State state = UNUSED)
: val_(key)
, state_(state) { }
int val_; // 存储的数据
State state_; // 桶的当前状态
};
class HashTable {
public:
HashTable(int size = primes_[0], double loadfactor = 0.75);
~HashTable();
// 插入元素
bool insert(int key);
// 删除元素
bool erase(int key);
// 查询
bool find(int key) const;
// 打印
void print() const;
private:
// 扩容
void expand();
private:
Bucket* table_; // 可动态扩容的数组
int tabSize_; // 桶的总数量
int usedCnt_; // 已使用的桶数量
double loadfactor_; // 装载因子
int primeIdx_; // 素数表下标
static const int PRIME_SIZE = 10; // 素数表的大小
static int primes_[PRIME_SIZE]; // 素数表
};
int HashTable::primes_[PRIME_SIZE] = { 3, 7, 23, 47, 97, 251, 443, 911, 1471, 42773 };
HashTable::HashTable(int size, double loadfactor)
: usedCnt_(0)
, loadfactor_(loadfactor)
, primeIdx_(0) {
// 将其调整到最近的素数
if (size != primes_[0]) {
for (; primeIdx_ < PRIME_SIZE; primeIdx_++) {
if (primes_[primeIdx_] > size) {
break;
}
}
}
// 如果size过大 已超过最后一个素数 则调整为最后一个素数
if (primeIdx_ == PRIME_SIZE) {
primeIdx_--;
}
tabSize_ = primes_[primeIdx_];
table_ = new Bucket[tabSize_];
}
HashTable::~HashTable() {
delete[] table_;
table_ = nullptr;
}
bool HashTable::insert(int key) {
double factor = usedCnt_ * 1.0 / tabSize_;
if (factor > loadfactor_) {
// 哈希表扩容
expand();
}
int idx = key % tabSize_;
int i = idx;
do {
if (table_[i].state_ != USING) {
table_[i].state_ = USING;
table_[i].val_ = key;
usedCnt_++;
return true;
}
i = (i + 1) % tabSize_;
} while (i != idx);
return false;
}
bool HashTable::erase(int key) {
int idx = key % tabSize_;
int i = idx;
do {
if (table_[i].state_ == USING && table_[i].val_ == key) {
table_[i].state_ = REMOVE;
usedCnt_--;
}
i = (i + 1) % tabSize_;
} while (table_[i].state_ != UNUSED && i != idx);
return true;
}
bool HashTable::find(int key) const {
int idx = key % tabSize_;
int i = idx;
do {
if (table_[i].state_ == USING && table_[i].val_ == key) {
return true;
}
i = (i + 1) % tabSize_;
} while (table_[i].state_ != UNUSED && i != idx);
return false;
}
void HashTable::print() const {
for (int i = 0; i < tabSize_; i++) {
if (table_[i].state_ == USING) {
cout << table_[i].val_ << " ";
} else {
cout << "x ";
}
}
cout << endl;
}
void HashTable::expand() {
++primeIdx_;
if (primeIdx_ == PRIME_SIZE) {
throw "HashTable is too large! Can't expand anymore!";
}
Bucket* newTable = new Bucket[primes_[primeIdx_]];
for (int i = 0; i < tabSize_; i++) {
if (table_[i].state_ == USING) {
int idx = table_[i].val_ % primes_[primeIdx_];
int k = idx;
do {
if (newTable[k].state_ != USING) {
newTable[k].state_ = USING;
newTable[k].val_ = table_[i].val_;
break;
}
k = (k + 1) % primes_[primeIdx_];
} while (k != idx);
}
}
delete[] table_;
table_ = newTable;
tabSize_ = primes_[primeIdx_];
}
线性探测哈希表的缺陷如下:
- 发生哈希冲突时,需要接近 \(O(n)\) 的时间复杂度来解决冲突;
- 在多线程环境中,线性探测所用到的基于数组实现的哈希表,只能给全局的表用互斥锁来保证哈希表的原子操作,以确保线程安全。
2.2 链地址法哈希表
在链地址法哈希表中,当发生哈希冲突时,将发生哈希冲突的元素采用链表数据结构连接起来。但是如果发生哈希冲突的元素过多,则会导致链表过长,那么其搜索的时间复杂度就会增加。
其常见的优化手段如下:
- 当链表长度过长时,可以将链表转化为红黑树数据结构的形式;
- 链式哈希表每个桶都可以创建自己的互斥锁,不同的桶之间可以进行并发操作;
链地址法哈希表的代码实现如下:
class HashTable {
public:
HashTable(int size = primes_[0], double loadFactor = 0.75);
~HashTable() = default;
// 增加元素
void insert(int key);
// 删除元素
void remove(int key);
// 查找元素
bool find(int key) const;
// 打印
void print() const;
private:
// 扩容
void expand();
private:
vector<list<int>> table_; // 哈希表
int useCnt_; // 已使用的桶数量
double loadfactor_; // 装载因子
int primeIdx_; // 素数表下标
static const int PRIME_SIZE = 10; // 素数表的大小
static int primes_[PRIME_SIZE]; // 素数表
};
int HashTable::primes_[PRIME_SIZE] = { 3, 7, 23, 47, 97, 251, 443, 911, 1471, 42773 };
HashTable::HashTable(int size, double loadFactor)
: useCnt_(0)
, loadfactor_(loadFactor)
, primeIdx_(0) {
if (size != primes_[0]) {
for (; primeIdx_ < PRIME_SIZE; primeIdx_++) {
if (primes_[primeIdx_] >= size)
break;
}
if (primeIdx_ == PRIME_SIZE) {
primeIdx_--;
}
}
table_.resize(primes_[primeIdx_]);
}
void HashTable::insert(int key) {
double factor = useCnt_ * 1.0 / table_.size();
if (factor > loadfactor_) {
expand();
}
int idx = key % table_.size();
if (table_[idx].empty()) {
useCnt_++;
table_[idx].emplace_front(key);
} else {
auto it = ::find(table_[idx].begin(), table_[idx].end(), key);
if (it == table_[idx].end()) {
table_[idx].emplace_front(key);
}
}
}
void HashTable::remove(int key) {
int idx = key % table_.size();
auto it = ::find(table_[idx].begin(), table_[idx].end(), key);
if (it != table_[idx].end()) {
table_[idx].erase(it);
if (table_[idx].empty()) {
useCnt_--;
}
}
}
bool HashTable::find(int key) const {
int idx = key % table_.size();
auto it = ::find(table_[idx].begin(), table_[idx].end(), key);
return it != table_[idx].end();
}
void HashTable::print() const {
cout << "==================" << endl;
for (int i = 0; i < table_.size(); i++) {
cout << "[" << i << "] -> ";
for (auto key : table_[i]) {
cout << key << " -> ";
}
cout << endl;
}
cout << "==================" << endl;
}
void HashTable::expand() {
if (primeIdx_ + 1 == PRIME_SIZE) {
throw "Hashtable cannot expand anymore!";
}
primeIdx_++;
useCnt_ = 0;
vector<list<int>> oldTable;
table_.swap(oldTable);
table_.resize(primes_[primeIdx_]);
for (auto list : oldTable) {
for (auto key : list) {
int idx = key % table_.size();
if (table_[idx].empty()) {
useCnt_++;
}
table_[idx].emplace_front(key);
}
}
}
3. STL实现
3.1 hashtable
SGI 的 hashtable 实现了哈希表的数据结构,其采用除留余数法来构造散列函数,同时通过开链法来解决哈希冲突的问题。它称 hashtable 表格内的元素为【桶子(bucket)】。
如下为 hashtable 的节点定义:
template<class Value>
struct __hashtable_node {
__hashtable_node* next;
Value val;
};
注意,bucket 所维护的 linked list,并不采用 STL 的 list 或 slist,而是自行维护上述的 hashtable node。至于 buckets 结构体,则以 vector 完成,以便有动态扩充能力。
对于 hashtable 的迭代器来说,其前进操作是首先尝试从目前所指的节点出发,前进一个节点,由于节点被安置在 list 内,所以利用节点的 next 指针即可轻易达成前进操作。如果目前节点正巧是 list 的尾端,就跳至下一个 bucket 身上,那正是指向下一个 list 的头部节点。
注意:hashtable 的迭代器没有后退操作,也没有定义所谓的逆向迭代器。
虽然开链法并不要求表格大小必须为质数,但 SGI STL 仍然以质数来设计表格大小,并且先将 28 个质数(逐渐呈现大约 2 倍关系)计算好,以备随时访问,同时提供一个函数__stl_next_prime()
,用来查询在这 28 个质数之中,最接近某数并大于某数的质数。
注意,是否重建表格的依据是拿元素个数和 bucket vector 的大小来比,如果前者大于后者,就重建表格。因此,每个 bucket(list) 的最大容量和 bucktes vector 的大小相同。
对于哈希函数来说,由于某些元素型别无法对其进行取模运算,因此 SGI 对内建的所有 hash functions 包装了一层,通过函数 bkt_num()
来获取元素落脚的位置。
在 <stl_hash_fun.h>
中定义有数个现成的 hash functions,全部都是仿函数。通过【模板特化】机制来计算相应的哈希函数。其中,针对 char、int、long 等整数型别,大部分的 hash functions 什么都不做。但是对于字符串 const char*,则设计有相应的转换函数:
inline size_t __stl_hash_string(const char* s) {
unsigned long h = 0;
for(; *s; ++s)
h = 5 * h + s;
return size_t(h);
}
而对于其他的型别,例如 string、double、float 等,用户都必须自行为它们定义 hash function。
3.2 hash_set&hash_multiset
hash_set 以 hashtable 作为底层机制。与 set 不同的是,set 的底层机制 RB-tree 有自动排序功能而 hashtable 没有,反映出来的结果就是,set 的元素有自动排序功能而 hash_set 没有。
注意:hash_set 有一些无法处理的型别,除非用户为那些型别撰写 hash function。凡是 hashtable 无法处理者,hash_set 也无法处理。
hash_set 的构造函数在缺省情况下,默认采用大小为 100 的表格,然后将被 hash table 调整为最接近且较大的质数。其插入操作则采用 hashtable 的 insert_unique()
操作,不允许键值重复。
hash_multiset 的特性与 multiset 完全相同,唯一的差别在于它的底层机制是 hashtable。也因此,hash_multiset 的元素并不会被自动排序。
hash_multiset 和 hash_set 实现上唯一的差别在于,前者的元素插入操作采用底层机制 hashtable 的 insert_equal()
,而后者采用的是 insert_unique()
。
3.3 hash_map&hash_multimap
hash_map 以 hashtable 作为底层机制。与 map 不同的是,map 的底层机制 RB-tree 有自动排序功能而 hashtable 没有,反映出来的结果就是,map 的元素有自动排序功能而 hash_map 没有。
map 的特性是,每一个元素都同时拥有一个实值(value)和一个键值(key),这一点在 hash_map 中也是一样的。hash_map 的使用方式与 map 完全相同。
注意:hash_map 有一些无法处理的型别,除非用户为那些型别撰写 hash function。凡是 hashtable 无法处理者,hash_map 也无法处理。
hash_map 的构造函数在缺省情况下,默认采用大小为 100 的表格,然后将被 hash table 调整为最接近且较大的质数。其插入操作则采用 hashtable 的 insert_unique()
操作,不允许键值重复。
hash_multimap 的特性与 multimap 完全相同,唯一的差别在于它的底层机制是 hashtable。也因此,hash_multimap 的元素并不会被自动排序。
hash_multimap 和 hash_map 实现上唯一的差别在于,前者的元素插入操作采用底层机制 hashtable 的 insert_equal()
,而后者采用的是 insert_unique()
。