STL源码分析之hashtable

本文深入探讨散列表数据结构,包括其工作原理、碰撞处理策略(如线性探测与二次探测)、开链法及内部实现细节。此外,还介绍了STL中散列表的构造、内存管理、插入操作与扩容机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

二叉搜索树具有对数平均时间的表现,但这样的表现构造在一个假设上:输入数据有足够的随机性

这里我们要介绍的是散列表数据结构,这种结构在插入、删除、搜寻等操作上具有“常数平均时间”的表现,而且这种表现是以统计为基础,不需要仰赖输入元素的随机性

 

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
如果需要的话,用户需要自行定义该函数


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值