二叉搜索树具有对数平均时间的表现,但这样的表现构造在一个假设上:输入数据有足够的随机性
这里我们要介绍的是散列表数据结构,这种结构在插入、删除、搜寻等操作上具有“常数平均时间”的表现,而且这种表现是以统计为基础,不需要仰赖输入元素的随机性
Hash table提供对任何有名项的存取操作和删除操作。由于操作对象是有名项,所有hashtable可被视为一种字典结构。
hashtable的碰撞问题
使用array实现hashtable时,所产生的问题可以归为一个:
元素的范围(远)大于array本身的大小
然而,解决这个问题的办法是将元素映射为一个“大小可接受的索引”
例如,假设X是任意整数,tablesize是array的大小,则X%tablesize会得到一个整数,范围在array内,恰可以作为array的索引
然而,我们虽然可以这样做,但会带来一个问题:可能有不同的元素被映射到相同的位置,即不同元素具有相同的索引,这无法避免,即所谓的“碰撞”问题
以下我们谈一下解决的方案:
1、 线性探测
容易理解,当我们计算出某个元素的插入位置后,而该位置上的空间已不再可用了,我们就循环往下一一寻找(如果到达尾端,则绕到头部往下继续寻找),直到找到一个空闲的位置为止
至于元素的删除,必须采用惰性删除,也就是只标记删除记号,实际删除操作则待表格重新整理时再进行 ---- 因为hashtable中每个元素不仅表述他自己,也关系带其他元素的排列
观察这个例子,除非元素经过计算后直接落在位置#4~#7上,否则#4~#7永远不会被 运用,因为位置#3永远是第一考虑。或者说,新元素不论是8,9,0,1,2,3中的哪一个,都会落在#3上。新元素只有在是4、5、6、7时才回落在那几个位置上
这显示了一个问题,平均插入的成长幅度,远高于负载系数的成长幅度
(注意,负载系数是元素个数除以表格大小,系数永远在0-1之间,除非是开链策略)
这样的现象在hashing过程中称为主集团。此时我们手上时一大团被用过的方格,
插入操作极有可能在主集团所形成的泥泞中爬行,以解决碰撞,最后还助长了主集团的泥泞面积
二次探测:
二次探测主要用来解决以上提到的主集团问题,将解决碰撞的方式从 H+1,H+2,H+3…改为了H+1^2, H+2^2, H+3^2…
此时我们的疑问是
1、 相较于线性算法,二次探测似乎复杂些,是否会在效率上带来影响?
2、 不论是线性还是二次,当负载系数过高,表格是否能够动态成长?
书上对二次探测比线性探测复杂的问题给出了解决的小技巧,举个例子:
Hi = H0 + i^2(mod M);
Hi-1 = H0 + (i-1)^2(mod M);
整理可得:
Hi – Hi-1 = i^2 – (i-1)^2(mod M)
Hi = Hi-1 + (2*i-1)(mod M)
只要保留了上一次计算结果,计算下一次似乎并不是那么复杂了,乘2也可以位移解决
对于array的成长性问题,我们可能需要找到一块新的更大的空间的array,得到后我们不可能原封不动的复制,而是需要检验array中的每一个元素,计算其在新array中的(新)位置,然后在插入到新array中去
开链
在每个表格维护一个链表,SGI STL即使用的这种方法
Hashtable的buckets和nodes
我们称hash table表格内的元素为bucket, 即表格内的每个单元涵盖的不止是个节点,甚至可能是一桶节点
节点结构如下:
template <class Value>
struct __hashtable_node
{
__hashtable_node* next;
Value val;
};
这里的list并未采用STL 中的list或是slist,而是自身维护的
而buckets聚合体则是通过vector来完成的,以便具有动态扩充的能力
看一下hashtable的迭代器
struct__hashtable_iterator {
typedef hashtable<Value, Key, HashFcn, ExtractKey, EqualKey,Alloc>
hashtable;
typedef __hashtable_iterator<Value, Key, HashFcn,
ExtractKey,EqualKey, Alloc>
iterator;
typedef __hashtable_const_iterator<Value, Key, HashFcn,
ExtractKey, EqualKey, Alloc>
const_iterator;
typedef __hashtable_node<Value> node;
typedef forward_iterator_tag iterator_category;
typedef Value value_type;
typedef ptrdiff_t difference_type;
typedef size_t size_type;
typedef Value& reference;
typedef Value* pointer;
node* cur; //迭代器目前所指的节点
hashtable* ht; //迭代器相关的hashtable容器,因为将来可能从这个链表移动到另一个链表,需要用到这个buckets
__hashtable_iterator(node* n, hashtable* tab): cur(n), ht(tab) {}
__hashtable_iterator() {}
reference operator*() const { return cur->val; }
#ifndef__SGI_STL_NO_ARROW_OPERATOR
pointer operator->() const { return &(operator*()); }
#endif /*__SGI_STL_NO_ARROW_OPERATOR */
iterator& operator++();
iterator operator++(int);
bool operator==(const iterator& it) const { return cur == it.cur; }
bool operator!=(const iterator& it) const { return cur != it.cur; }
};
其中的operator++操作如下:(不提供operator- -操作)
template <class V, class K, class HF, class ExK, class EqK, class A>
__hashtable_iterator<V,K, HF, ExK, EqK, A>&
__hashtable_iterator<V,K, HF, ExK, EqK, A>::operator++()
{
const node* old = cur;
cur = cur->next; //先在本链表里找,找不到即往下一个链表走
if (!cur) {
size_type bucket =ht->bkt_num(old->val);
while (!cur && ++bucket < ht->buckets.size()) //buckets本就是一个节点指针vector
cur = ht->buckets[bucket];
}
return *this;
}
现在来看看hashtable的数据结构
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey,
class Alloc>
//关于这个template,其中按顺序来就是:
//键值类型
//实值类型
//hashfunction,是计算元素位置的函数
//从节点中取出键值的方法
//判断键值相同与否的方法
//空间配置器
class hashtable {
//…
}
我们来解析一下其中的函数:
static const int __stl_num_primes =28;
static const unsigned long__stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189,805306457,
1610612741, 3221225473, 4294967291
};
虽然开链法不要求表格大小为质数,但STL仍以质数设计表格大小。且先将28个质数计算法,以备随时访问,其提供了一个如下函数,查询这个28个质数中,最接近某数并大于该数的质数。
inline unsigned long __stl_next_prime(unsigned long n)
{
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
//关于lower_bound函数,以后我们会讲解到,这里我们大概明白他的意思就行
return pos == last ? *(last - 1) : *pos;
}
看一下hashtable的构造与内存管理
正如我们预料,就是先申请一块未初始化的内存,继而使用construct初始化:
typedef simple_alloc<node, Alloc> node_allocator;
node* new_node(const value_type&obj)
{
node* n = node_allocator::allocate();
n->next = 0;
__STL_TRY {
construct(&n->val, obj);
return n;
}
__STL_UNWIND(node_allocator::deallocate(n));
}
void delete_node(node* n)
{
destroy(&n->val);
node_allocator::deallocate(n);
}
再看一下其中一个构造函数:
hashtable(size_type n,
const HashFcn& hf,
const EqualKey& eql,
const ExtractKey&ext)
: hash(hf), equals(eql), get_key(ext),num_elements(0)
{
initialize_buckets(n);
}
void initialize_buckets(size_type n)
{
const size_type n_buckets = next_size(n);
buckets.reserve(n_buckets);
buckets.insert(buckets.end(), n_buckets,(node*) 0);
num_elements = 0;
}
size_type next_size(size_type n) const { return__stl_next_prime(n); }
根据我们之前看的源码中提供的质数表,再看此实现,可以得知,若我们想要得到的hashtable的大小是50,那么实际分配得到的则是53
插入操作与表格重整
pair<iterator, bool> insert_unique(const value_type&obj)
{
resize(num_elements+ 1); //判断是否要做调整
//要注意的是,传入的是假设插入后节点个数
return insert_unique_noresize(obj); //做真正的插入操作
}
//在经过可能的调整后,进行可能的插入操作
template <class V, class K, class HF, class Ex, class Eq, class A>
pair<typename hashtable<V, K,HF, Ex, Eq, A>::iterator, bool>
hashtable<V,K, HF, Ex, Eq, A>::insert_unique_noresize(const value_type&obj)
{
const size_type n = bkt_num(obj); //记录下当前元素要插入在第几个bucket里
node* first = buckets[n]; //得到该bucket的第一个节点
//不允许有相同键值的元素存在在同一个链表中
//因为是取模操作,所以相同的不同的可能存在在一个链表中
for (node* cur = first; cur; cur = cur->next)
if(equals(get_key(cur->val), get_key(obj))) //寻找此链表是否有相同键值的元素
//存在相同的则插入失败
return pair<iterator, bool>(iterator(cur, this), false);
//插入节点
node* tmp = new_node(obj);
tmp->next = first;
buckets[n] = tmp;
++num_elements;
return pair<iterator, bool>(iterator(tmp, this), true);
}
//可能的调整
template <class V, class K, class HF, class Ex, class Eq, class A>
//通过观察此函数的实现,我们发现要重新调整的条件是:
//当前hashtable中的元素个数大于当前hashtable的bucket的个数
//由此可以判断,容量限制其实和不使用开链方法实现的hashtable是相同的
void hashtable<V, K,HF, Ex, Eq, A>::resize(size_type num_elements_hint)
{
//因为buckets就是一个vector,所以size指的是bucket的个数,而非整个hashtable的元素个数
const size_type old_n = buckets.size();
if (num_elements_hint > old_n) { //如果有必要调整buckets的话
const size_type n = next_size(num_elements_hint); //找出一个大于且最接近当前节点个数的质数作为新申请的buckets的容量大小
if (n > old_n) { //符合条件的质数
vector<node*, A> tmp(n, (node*) 0);
__STL_TRY {
//下面开始迁移,将原buckets中的tmp移动到新的tmp中
for (size_type bucket =0; bucket < old_n; ++bucket) {
node* first = buckets[bucket];
while (first) {
size_type new_bucket =bkt_num(first->val, n);
buckets[bucket] = first->next;
first->next = tmp[new_bucket];
tmp[new_bucket] = first;
first = buckets[bucket];
}
}
//最后执行vector中的操作swap,将当前bucket指向tmp代表的vector
buckets.swap(tmp);
}
# ifdef__STL_USE_EXCEPTIONS
catch(...) {
for (size_type bucket =0; bucket < tmp.size(); ++bucket) {
while (tmp[bucket]) {
node* next = tmp[bucket]->next;
delete_node(tmp[bucket]);
tmp[bucket] = next;
}
}
throw;
}
# endif /*__STL_USE_EXCEPTIONS */
}
}
}
然而还有一种插入的方式是:
iterator equal(const value_type&obj)
{
resize(num_elements + 1);
return insert_equal_noresize(obj);
}
Resize就不再说
看一下真正的插入操作:
虽然无需查找是否有相同的节点,但是我们仍然会去寻找相同键值的节点。如果找到了,就插在第一个相同节点的后面;找不到则插在该bucket的首部
hashtable<V,K, HF, Ex, Eq, A>::insert_equal_noresize(const value_type&obj)
{
const size_type n = bkt_num(obj);
node* first = buckets[n];
for (node* cur = first; cur; cur = cur->next)
if (equals(get_key(cur->val), get_key(obj))) {
node* tmp = new_node(obj);
tmp->next = cur->next;
cur->next = tmp;
++num_elements;
return iterator(tmp, this);
}
node* tmp = new_node(obj);
tmp->next = first;
buckets[n] = tmp;
++num_elements;
return iterator(tmp, this);
}
所以这些也都不难理解
接下来看一下实现的细节函数:
1、 很多地方我们都会需要知道某个元素应该插在哪一个bucket,这里是 bkt_num和bkt_num_key两个函数处理的
只是这两个函数及其衍生函数都是调用hash function,会在下面讲到
2、 清空hashtable函数
template <class V, class K, class HF, class Ex, class Eq, class A>
void hashtable<V, K,HF, Ex, Eq, A>::clear()
{
for (size_type i = 0; i < buckets.size(); ++i) {
node* cur = buckets[i];
while (cur != 0) {
node* next = cur->next;
delete_node(cur);
cur = next;
}
buckets[i] = 0;
}
num_elements = 0;
}
//虽然元素都被清空了,但vector还是那么长,size不变<span style="font-family: Arial, Helvetica, sans-serif; background-color: rgb(255, 255, 255);"> </span>
3、 复制hashtable函数
template <class V, class K, class HF, class Ex, class Eq, class A>
void hashtable<V, K,HF, Ex, Eq, A>::copy_from(const hashtable& ht)
{
//先将本hashtable清空
buckets.clear();
//如果要ht的大小大于当前hashtable,那么此函数会增大空间;ht小于本hashtable,那么不作为
buckets.reserve(ht.buckets.size());
//从当前hashtable的vector bucket的尾端开始,插入n个null指针
//要注意的是现在这个vector bucket已经被清空了,end就是begin
//要注意的是这里是在对vector进行操作,将整个指针数组(vector)初始化
buckets.insert(buckets.end(), ht.buckets.size(),(node*) 0);
__STL_TRY {
//既然是复制,那么我们就要创建与ht中一样多的元素
//注意是复制,可不能简单的指针等于
for (size_type i = 0; i < ht.buckets.size(); ++i) {
if (const node* cur = ht.buckets[i]) {
node* copy = new_node(cur->val);
buckets[i] = copy;
for (node* next =cur->next; next; cur = next, next = cur->next) {
copy->next =new_node(next->val);
copy = copy->next;
}
}
}
num_elements = ht.num_elements;
}
__STL_UNWIND(clear());
}
最后,我们就来看看较为核心的hash functions
之前谈论过,hash function是用来计算元素位置的函数,这个函数都是由bkt_num来调用的
针对char, int, long等整数型别,hash function什么也没做只是忠实的返回原值。
对于字符串,则设计了转换函数,如下:
template <class Key> struct hash { };
inline size_t__stl_hash_string(const char* s)
{
unsigned long h = 0;
for ( ; *s; ++s)
h = 5*h + *s;
return size_t(h);
}
__STL_TEMPLATE_NULLstruct hash<char*>
{
size_t operator()(const char* s) const { return__stl_hash_string(s); }
};
__STL_TEMPLATE_NULLstruct hash<const char*>
{
size_t operator()(const char* s) const { return__stl_hash_string(s); }
};
//剩余内容为
__STL_TEMPLATE_NULLstruct hash<char> {
size_t operator()(char x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<unsigned char> {
size_t operator()(unsigned char x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<signed char> {
size_t operator()(unsigned char x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<short> {
size_t operator()(short x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<unsigned short> {
size_t operator()(unsigned short x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<int> {
size_t operator()(int x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<unsigned int> {
size_t operator()(unsigned int x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<long> {
size_t operator()(long x) const { return x; }
};
__STL_TEMPLATE_NULLstruct hash<unsigned long> {
size_t operator()(unsigned long x) const { return x; }
};
//由此看来,hashtable是无法处理上述型别之外的元素的,如string,double,float
如果需要的话,用户需要自行定义该函数