最近在看Bartosz Milewski的C++ In Action Industrial-Strength Programming Techniques,这本书很不错,它不是语言基础教程,全书主要介绍了许多工业强度的编程技术,如清理,隐藏实现细节,资源管理,共享,资源管理,使用标准模板库,重载运算符等技术。这是Bartosz Milewski的简介:Bartosz Milewski,C++ In Action 的作者。是Reliable Software公司的总裁,Reliable Software公司是一家为程序员制造高质量开发工具的公司。在过去几年间,Bartosz Milewski在多家知名杂志发表了大量技术文章。在微软工程工作的8年期间,他担任Windows2000中Content Index组件的开发主管,他曾经在波兰的Wroclaw大学讲授C++编程课程,而且他获得了Wroclaw大学的理论物理学博士学位。 这是他为这本书管理的blog:http://www.relisoft.com/forums/index.php?s=ee4c0dc7cafef9f08c7c18a6e449b424&showforum=3,售后服务还不错,呵呵,以我目前的水平看起来还挺累的,我想最好把C++ Primer和effective c++,more effective c++看完再看这本书会比较好,不过硬着头皮看也有好处,就像有时候武侠小说里用非常规方法提高修为有时候也能起到意想不到的效果。言归正传,在这本书中多次用到哈希表,很多地方不明白,查了书整理一下。
散列表(hash table)的实现常称为散列(hashing),散列是一种用于以常数平均时间执行插入,删除和查找的技术。但是,那些需要元素间任何排序信息的树操作不会得到有效的支持。因此诸如findMin,findMax以及在线性时间内按顺序打印整个表的操作都是散列所不支持的。
我们把表的大小记作TableSize,我们把每个键映射到从0到TableSize-1这个范围中的某个数,并且将其放到适当的单元中。这个映射就称为散列函数(hash function),理想情况下它应该运算简单并且应该保证任何两个不同的键映射到不同的单元。不过,这是不可能的。因此我们寻找一个散列函数,该函数要在单元之间均匀分配键。这就是散列的基本思想,剩下的问题则是要选择一个函数,决定当两个键散列到同一个值的时候(称为(collision))应该做什么以及如何确定散列表的大小。
1.散列函数
如果输入的键是整数,一般合理的方法就是直接返回"Key mod Tablesize",但Key具有某些不理想的性质,比如若表的大小是10而键的各位都是0。则此时上述标准的散列函数就不是一个好的选择,好的办法通常是保证表的大小是素数,当输入的键是随机整数时,散列函数不仅运算简单而且键的分配也很均匀。
通常,键是字符串;在这种情形下,散列函数需要仔细选择。
一种选择方法是把字符串中字符的ASCII码值加起来,下面的例程实现了这种策略。
1int hash( const string &key, int tableSize )
2{
3 int hashVal = 0;
4 for( int i=0; i<key.length(); i++ )
5 hashVal += key[i];
6 return hashVal % tableSize;
7}
上述散列函数实现起来简单而且能够很快地算出答案。不过如果表很大,则函数就不会很好地分配键。例如,设TableSize=10007(10007是素数),并设所有的键至多8个字符长,由于ASCII字符的值最多是127,因此散列函数只能在0-1016之间取值,显然这不是一种均匀的分配。
另一个散列函数如下表示,这个散列函数假设Key至少三个字符。值27表示英文字母表的字母个数外加一个空格,而729是27×27,该函数只考察前三个字符,但是如果它们是随机的,而表的大小还是10007,那么我们就会得到一个合理的均衡分布。可是,英文不是随机的。虽然3个字符(忽略空格)有26×26×26=17576种可能的组合,但查验词汇量足够大的联机词典却揭示出,3个字母的不同组合数实际上只有2851。即使这些组合没有冲突,也不过只有表的28%被真正散列到。因此,虽然很容易计算,但是当散列表足够大的时候这个函数还是不合适的。
1int hash( const string &key, int tableSize )
2{
3 return (key[0]+27*key[1]+729*key[2]) % tableSize
4}
下面的例程列出了第3种尝试。这个散列函数涉及键中的所有字符,并且一般可以分布得很好,程序根据Horner法则计算一个37的多项式函数。这个散列函数利用了允许溢出这个事实。这可能会引进负数,因此在末尾有附加的测试,这个散列函数就表的分布而言未必是最好的,但是确实具有极其简单的优点而且速度也很快。如果键特别长,那么该散列函数计算起来将会花费过多的时间。在这种情况下通常做法是不使用所有的字符。此时键的长度和性质将影响选择。例如,键可能是完整的街道地址,散列函数可以包括街道地址的几个字符,或许也包括城市名和邮政编码的几个字符。有些程序设计人员通过只使用奇数位置上的字符来实现他们的散列函数,这里有这么一层想法:用计算散列函数节省下的时间来补偿由此产生的对均匀分布的函数的轻微干扰。
1int hash( const string &key, int tableSize )
2{
3 int hashVal = 0;
4 for(int i=0;i<key.length();i++)
5 hashVal = 37 * hashVal + key[i];
6 hashVal %= tableSize;
7 if( hashVal <0 )
8 hashVal += tableSize;
9 return hashVal;
10}
剩下的主要编程细节是解决冲突。如果当一个元素在插入时与一个已经插入的元素散列到相同的值,那么就产生一个冲突,这个冲突需要消除。
2.分离链接法
解决冲突的第一种方法通常称为分离链接法(separate chaining),其做法是将散列到同一个值的所有元素保留到一个链表中,可以使用标准库中表的实现方法。如果空间很紧,则更可取的方法是避免使用它们(因为这些表是双向链表,浪费空间),为执行search,我们使用散列函数来确定究竟该遍历那个链表,然后查找相应的表。为执行insert,我们检查相应的表来确定是否该元素已经在表中了(如果要插入重复元,那么通常要留出一个额外的数据成员,当出现匹配事件时这个数据成员增1),如果这个元素是个新的元素,那么它将被插入到表的前端,这不仅因为方便,而且还因为最后插入的元素最有可能不久再次被访问,实现分离链接法所需要的类架构在下面的例程中给出。散列表结构存储一个链表数组,这个数组在构造函数中指定。
1//分离链接散列表的类构架
2template <typename HashedObj>
3 class HashTable
4 {
5public:
6 explicit HashTable( int size = 101 );
7
8 bool contains( const HashedObj &x ) const;
9
10 void makeEmpty();
11 void insert( const HashedObj x);
12 void remove( const HashedObj x);
13
14 private:
15 vector<list<HashedObj> > theLists;// >>是c++的一个运算符,而且比>长,因此两个>之间需要一个空格
16 int currentSize;
17
18 void rehash()
19 int myhash( const HashedObj &x ) const;
20};
21
22int hash( const string &key );
23int hash( int key );
24
25
这里不使用那些将对象和类的大小作为参数的散列函数,而是使用那些仅以对象为参数的散列函数,并且返回int。散列表类有一个私有的成员函数myhash,这个函数将结果分配到一个合适的数组索引中。
1int myhash( const HashedObj &x ) const
2{
3 int hashVal = hash(x);
4 hashVal %=theLists.size();
5 if( hashVal < 0)
6 hashVal += theLists.size();
7 return hashVal;
8}
9
10
下面的例程中列出了一个可以存储在一般散列表中的Employee类,该类使用name成员作为键,Employee类通过提供相等性操作符和一个散列函数来实现HashedObj的需求。
1class Employee
2{
3public:
4 const string & getname() const
5 { return name; }
6 bool operator==( const Employee &rhs ) const
7 { return getName() == rhs.getName(); }
8 bool operator!=( const Employee & rhs } const
9 { return !(*this == rhs); }
10
11private:
12 string name;
13 double salary;
14 int seniority;
15};
16
17int hash( const Employ &item )
18{
19 return hash( item.getName() );
20}
实现makeEmpty,contains和remove的程序代码如下
1void makeEmpty()
2 {
3 for( int i=0; i<theLists.size();i++ )
4 theLists[i].clear();
5}
6
7bool contains( const HashedObj &x ) const
8{
9 const list<HashedObj> & whichList = theLists[myhash(x)];
10 return find( whichList.begin(),whichList.end(),x) !=whichList.end();
11}
12
13bool remove( const HashedObj &x )
14{
15 list<HashedObj> &whichList = theLists[myhash(x)];
16 list<HashedObj>::iterator itr = find(whichList.begin(),whichList.end(),x);
17 if( itr == whichList.end() )
18 return false;
19 whichList.erase( itr );
20 --currentSize;
21 return true;
22}
下一个操作是插入例程。如果要插入的项已经存在,那么什么都不做;否则将其放至表的前端。该元素可以放在表的任何地方;此处使用push_back是最方便的。whichList是一个引用变量
1bool insert ( const HashedObj &x )
2{
3 list<HashedObj> & whichList = theLists[ myhash(x) ];
4 if( find( whichList.begin(),whichList.end(),x )!=whichList.end() )
5 return false;
6 whichList.push_back(x);
7
8 if(++currentSize > theLists.size() )
9 rehash();
10
11 return true;
12}