哈希表

来源:互联网 发布:2016年cms系统排行榜 编辑:程序博客网 时间:2024/05/15 09:39
这篇文章的内容是基于Mark Allen Weiss的《数据结构与算法分析(C++描述)》整理出来的。


文章分为下面5个方面做介绍。

1. 基本思想

2. 散列函数

3. 分离链接法

4. 开放定址法

5. 再散列

6. 小结


散列是一种用于以常数时间执行插入、删除和查找的技术。但是那些需要元素间任何排序信息的操作将不会得到有效的支持。

1. 基本思想

散列表数据结构是包括一些项的具有一定大小的数组。查找一般是对项的某个部分进行,这部分称为键(key)。

将每个键映射到从0到TableSize-1这个范围中的某个数,并且将其放到适当的单元。这种映射就称为“散列函数”(hash function),理想情况下它应该计算简单并且应该使任何两个不同的键映射到不同的内存单元。实际中我们选定的散列函数应该尽可能在单元之间均匀地分配键。

这就是散列的基本思想。剩下的问题则是要选择一个函数(冲突解决函数),决定当两个键散列到同一个单元的时候(称为冲突(collision)),应该怎么处理。


2.散列函数

如果输入的键是整数,则一般合理的方法就是直接返回“Key mod TableSize”。如果输入的键是字符串,我们也可以把字符串中字符的ASCII码值加起来。
这些散列函数实现简单但是容易造成冲突。如果表很大,假设TableSize = 10007,并假设所有键至多有8个字符。由于ASCII字符的值最大为127,因此这个散列函数只能在0-1016之间取值,显然不合理。

另一种散列函数如下图所示。


当散列表足够大的时候,上面这个函数还是不够合适。
散列函数的第三种尝试涉及键中的所有字符。

程序根据Horner法则计算一个37的多项式函数。


再好的散列函数也难以避免,一个新的元素在插入时,可能与一个已经插入的元素散列到相同的值,那么就产生一个冲突。解决冲突的方法有几种,最简单的两种是:分离链接法和开放定址法。

3. 分离链接法

其做法是将散列到同一个值的所有元素保留到一个链表中。

考虑一个简单的情形,我们建立0-9的完全平方数,选取hash(x) = x mod 10。下图对其进行的描述。


我们用C++里面的vector和list来实现分离链接法。
类框架。

<span style="font-size:12px;">class HashTable{public:       HashTable();       ~HashTable();        //explicit HashTable(int size = 101);        bool contains( const int& x) const;        void make_empty();        bool insert( const int& x);        bool remove( const int& x);        void print();private:        vector< list< int> > the_lists;        int current_size;        void rehash();        int myhash( const int& x) const;        int hash( const int& x) const;};</span>

Hash函数,这里选取最简单的形式,hash(x) = x mod TableSize。

myhash函数,这里是确保映射的值没有超出范围。

make_empty用于清空哈希表,contains检查元素是否被包含,remove移除一个元素。

<span style="font-size:12px;">int HashTable ::hash(const int & x ) const{        int r = x % the_lists.size();        return r;}int HashTable ::myhash(const int & x ) const{        int hash_val = hash( x);       hash_val %= the_lists.size();        if (hash_val < 0) {              hash_val += the_lists.size();       }        return hash_val;}bool HashTable ::contains(const int& x) const{        const list< int> which_list = the_lists [myhash( x) ];        return (find(which_list.begin(), which_list.end(), x) != which_list.end());}void HashTable ::make_empty(){        for ( int i = 0; i < the_lists.size(); i++) {              the_lists [i ].clear();       }}</span>
<span style="font-size:12px;">bool HashTable::remove(const int& x){<span style="white-space:pre"></span>list<int>& which_list = the_lists[myhash(x)];<span style="white-space:pre"></span>auto itr = find(which_list.begin(), which_list.end(), x);<span style="white-space:pre"></span>if ( itr == which_list.end())<span style="white-space:pre"></span>return false;<span style="white-space:pre"></span>which_list.erase(itr);<span style="white-space:pre"></span>--current_size;<span style="white-space:pre"></span>return true;}</span>

对于插入操作,为了实现简单,这里假设哈希表不允许出现重复的元素。如果要插入的项已经存在,那么什么都不错。否则将其放至表的前端。

<span style="font-size:12px;">bool HashTable ::insert(const int & x ){        list< int>& which_list = the_lists [myhash( x) ];        if (find(which_list.begin(), which_list.end(), x) != which_list.end())               return false;       which_list.push_back( x);        // rehash        if (++current_size > the_lists.size())              rehash();        return true;}</span>

我们定义散列表的装填因子(load factor)λ为散列表的元素个数与散列表大小的比值。分离链接法的一般方法是使得装填因子约等于1。
在插入操作中,我们需要检查表的大小是否已经超出范围(装填因子超过1)。需要当前表已经容纳不下所有元素,我们需要执行再散列操作。其基本思想是把表的空间扩大(这里我们把表的空间扩大到原空间的两倍大后的第一个质数),同时所有元素需要重新散列到新建立的哈希表空间。(因为TableSize发生了变化,哈希函数hash(x) = x mod TableSize 的值也发生了变化)

<span style="font-size:12px;">void HashTable ::rehash(){        vector< list< int> > old_lists = the_lists;       the_lists.resize(next_prime(2 * the_lists.size()));        for ( int i = 0; i < the_lists.size(); i++) {              the_lists [i ].clear();       }       current_size = 0;        for ( int i = 0; i < old_lists.size(); i++) {               auto itr = old_lists [i ].begin();               while (itr != old_lists [i ].end())                     insert( *itr ++);       }}int HashTable ::next_prime(const int& x){        int t = x;        while (t != INT_MAX) {              ++t;               int i = 2;               for (; i <= sqrt(t); ++i) {                      if (t % i == 0)                            break;              }               if (i > sqrt(t))                      return t;       }}</span><span style="font-size:18px;"></span>


4. 开放定址法

分离链接法的缺点是使用了链表。由于给新单元分配地址需要时间,因此这将导致算法的速度有些减慢。
散列表的另一个冲突解决方法是。当冲突发生的时候,尝试寻找另外的空闲单元。定义函数

当发生冲突时,依次对单元  进行试选。函数f就是冲突解决函数。因为所有的数据都要放在表内,所以这个方案所需要的空间一般比较大。其装填因子一般应该低于0.5。我们称这样的表为探测散列表(probing hash tables)。
下面介绍三种常见的解决冲突的方法。

(1)线性探测

在线性探测中,函数f是i的线性函数,一般情况下f(i)=i。这相当于逐个探测每个单元来查找出空单元。
如下图。

(2)平方探测

平方探测就是冲突函数为二次函数的探测方法。一般选择 f(i)=i2
定理:如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能够插入一个新的元素。
在平方探测散列表中不能执行标准的删除操作。因为相应的单元可能已经引起过冲突,元素绕过它存储在别处。因此散列表需要懒惰删除。
定义一个数据元素的结构
<span style="font-size:12px;">        struct HashEntry       {               int element;               EntryType info;              HashEntry( int ele = 0, EntryType i = EMPTY) : element( ele), info( i) {}       };</span>
散列表的数据成员

<span style="font-size:12px;">       std:: vector< HashEntry> the_array;</span>
比较困难的是查找操作。这里介绍一种进行平方探测的快速方法。由平方解法函数的定义可知,f(i)=f(i-1)+2i-1,因此下一个要探测的单元位置由上一个探测所使用的距离加上自增2后的偏移距离所得的和来确定。

<span style="font-size:12px;">int HashTableProbing ::find_pos(const int& x) const{        int offset = 1;        int current_pos = myhash( x);        while (the_array [current_pos ].info != EMPTY && the_array[current_pos] .element != x) {              current_pos += offset;              offset = offset + 2;               if (current_pos >= the_array.size())                     current_pos -= the_array.size();       }        return current_pos;}</span>
插入和删除

<span style="font-size:12px;">bool HashTableProbing ::insert(const int & x ){        int current_pos = find_pos( x);        if (is_active(current_pos))               return false;       the_array [current_pos ] = HashEntry( x, ACTIVE);        if (++current_size > the_array.size() / 2)              rehash();        return true;}</span>
<span style="font-size:12px;">bool HashTableProbing ::remove(const int & x ){        int current_pos = find_pos( x);        if (is_active(current_pos))              the_array [current_pos ].info = DELETED;        else               return false;        return true;}</span>

虽然平方探测排除了一次聚焦,但是散列到同一个位置上的那些元素将探测相同的备选单元。这称为二次聚焦(secondary clustering)。
双散列可以消除二次聚焦,但是以计算额外的散列函数为代价。

(3)双散列

双散列一种流行的选择

这个公式是说,将第二个散列函数应用到x并在距离


等处探测。第二个散列函数的选择很重要。

一般应该保证改值和TableSize互素。一个简单的做法就是保证TableSize的大小为一个素数。

4. 再散列

对于使用平方探测的开放定址散列表,如果表的元素填的太满,那么操作的运行时间将开始消耗过长,且插入操作可能失败。这可能发生在有太多的删除和插入混合的场合。此时的一个就解决办法就是建立另外一个大约两倍大的表,而且使用一个相关的新散列函数,扫描整个原始散列表,计算每个元素的新散列值并插入到新表中。
整个操作称为再散列。
再散列的触发时机选择也很重要。第一种做法是表满到一半就散列。第二种做法比较极端,只有当新的插入失败的时候才再散列。第三种方法即途中(middle-of-the-road)策略,当表到达某一个装填因子时进行再散列。

5.小结

    散列表可以用来以常数平均时间实现insert和contains操作。当使用散列表时,注意诸如装填因子这样的细节是特别重要的,否则时间界将不再有效。当键不是短字符串或整数时,仔细选择散列函数也是很重要的。
    对于分离链接散列法,虽然装填因子不大时性能并不明显降低,但装填因子还是应该接近于1。对于探测散列,除非完全不可避免,否则装填因子不应该超过0.5。如果使用线性探测,那么性能随着装填因子接近于1而急速下降。再散列运算可以通过使表增长(和收缩)来实现,这样可以保持合理的装填因子。对于空间紧缺并且不可能声明巨大散列表的情况,这是很重要的。
    二叉查找树也可以用来实现insert和contains操作。虽然平均时间界为O(logN),但是二叉查找树也支持那些需要排序的例程,从而功能更强大。使用散列表不可能找出最小元素。除非准确知道一个字符串,否则散列表也不可能有效地查找它。二叉查找树可以迅速找到一定范围内的所有项,散列表却做不到。不仅如此,因为查找树不需要乘法和除法,O(logN)这个时间界也不必比O(1)大那么多。
    另一方面,散列的最坏情形一般来自于实现错误,而有序的输入却可能使二叉树运行得很差。平衡查找树实现的代价相当高,因此,如果不需要排序的信息或者不确定输入是否己经排序,那么就应该选择散列这种数据结构。
    散列的应用很广。编译器使用散列表跟踪源代码中声明的变量,这种数据结构叫作符号表(symbol table )。散列表是这种问题的理想选择。标识符一般都不长,因此散列函数能够迅速完成运算。此外,按字母顺序排列变量通常也是不必要的。
    散列表适用于任何其结点有实名而不是数字名的图论问题。这里,当输入被读入的时候,顶点则按照它们出现的顺序从1开始指定为一些整数。再有,输入很可能有一组一组按字母顺序排列的项。例如,顶点可以是计算机。此时,如果一个特定的计算中心把它的计算机列表成ibm1, ibm2, ibm3,…,那么,若使用查找树则在效率方面可能会有戏剧性的效果。
    散列表的第三种常见的用途是在为游戏编制的程序中。当程序搜索游戏的不同的运动路径时,它通过计算基于位置的散列函数而跟踪一些已知的位置(并把对于该位置的移动存储起来)。如果同样的位置再次出现,程序通常通过简单的移动变换来避免昂贵的重复计算。游戏程序的这种一般特点叫作置换表(transposition table )。
    散列的另一个用途是在线拼写检查程序。如果拼写检查程序的主要功能是检查拼写错误(而非纠正错误),那么可以预先将整个词典进行散列,这样就可以在常数时间内检查单词拼写。散列表很适合这项工作,因为以字母顺序排列单词并不重要;而以它们在文件中出现的顺序显示错误拼写当然也是可以接受的。

1 0
原创粉丝点击