数据结构:散列

散列表也是我们常称的哈希表(hash table),他只支持二叉查找树的所允许的一部分操作。
散列表的实现称为散列(hashing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是,那些需要元素间任何排序信息的树操作将不会得到有效的支持。因此,诸如 findMin 和 findMax 以及在线性时间内按照顺序打印整个表的操作都是散列所不支持的。
理想的散列表数据结构只不过是一个包含一些项的具有固定大小的数组。
将每个键映射到从 0 到 TableSize-1 这个范围中的某个数,并且将其放到适当的单元中。这个映射就称为散列函数(hash function)理想的情况下他应该运算简单且保证两个不同的键不会映射到相同的内存单元中去。不过这是不可能的,因为单元的空间总是有限的,而键实际上是用不完的。因此我们寻找一个散列函数,该函数能够均匀的分配键。同时也要在两个不同键映射到一个值的时候(称为冲突 collision)应该做什么以及如何确定散列表的大小。

散列函数
如果输入的键是整数我们一般合理的处理的方法就是 “key mod TableSize“;一般我们可以保证表的大小是一个素数。当输入的键是随机的整数时,散列函数不仅运算简单而且键的分配也很均匀。

对于键为字符串的情况使用字符串的ASCII码值相加
int hash(const string&key, int tableSize)
{
    int hashVal = 0;
    for(int i = 0;i<key.length();i++)
        hashVal+=key[i];
    return hashVal%tableSize;
}
好的散列函数
int hash(const string&key, int tableSize)
{
    int hashVal = 0;
    for(int i = 0;i<key.length();i++)
        hashVal = 37*hashVal + key[i];
    hashVal %= tableSize;
    if(hashVal<0)
        hashVal+=tableSize;
    return hashVal;
}

解决冲突的方法

  1. 分离链接法:将散列到同一个值的所有元素保留到一个链表中。
    具体实现:执行 search 操作,使用散列函数来确定究竟应该遍历哪一个链表,然后查找相应的表。执行 insert 操作,我们检查相应的表来确定表中是否已经存在该元素了(如果需要插入重复元,那么通常要留出一个额外的数据成员,当出现匹配事件时这个数据成员增 1)。如果要插入新元素,那么它将被插入到表的前端,一来因为插入方便,二来最后被插入的元素很有可能不久之后再次被访问。
template<typename HashedObj>
class HashTable
{
    public:
        explicit HashTable(int size = 101);
        bool contains(const HashedObj& x)const;
        void makeEmpty();
        void insert(const HashedObj& x);
        void remove(const HashedObj& x);
    private:
        vector<list<HashedObj> > theLists;
        int currentSize;
        void rehash();
        int myhash(const HashedObj& x)const;
};
int hash(const string& key);
int hash(int key);                                                                                                                                                                                                                                        
int HashTable::myhash(const HashedObj&x)const
{
    int hashVal = hash(x);
    hashVal %= theLists.size()
    if(hashVal<0)
        hashVal += theLists.size();
    return hashVal;
}
void HashTable::makeEmpty()
{
    for(int i = 0;i<theLists.size();i++)
        theLists[i].clear();
}
bool HashTable::contains(const HashedObj&x)const
{
    const list<HashedObj>& whichList = theLists[myhash(x)];
    return find(whichList.begin(),whichList.end(),x)!=whichList.end();
}
bool HashTable::remove(const HashedObj&x)
{
    list<HashedObj>& whichList = theLists[myhash(x)];
    list<HashedObj>::iterator itr = find(whichList.begin(),
    whichList.end(),x);
    if(itr == whichList.end())
        return false;
    whichList.erase(itr);
    --currentSize;
    return true;    
}
bool HashTable::insert(const HashedObj& x)
{
    list<HashedObj>& whichList = theLists[myhash(x)];
    if(find(whichList.begin(),whichList.end(),x)!=whichList.end())
        return false;
    whichList.push_back(x);
    return true;
}

当然除了链表,任何方案都可能解决冲突现象,例如二叉查找树,另一个散列表等。为了衡量散列表设计是否合理,我们定义散列表的装填因子(load factor) lamda为散列表中元素的个数和散列表的大小的比值,我们一般希望装填因子为1,这样的话我们相当与充分的利用了存储空间。一般我们超着一次不成功所需要考察的结点个数为lamda,成功查找则需要遍历1+lamda/2。在N个元素的表和M个链表中“其他结点“的期望个数为(N-1)/M = lamda - 1/M,也就近似是lamda。因此也就是说散列表的实际大小并不重要,重要的是装填因子。

不使用链表的散列表的算法的缺点是使用一些链表。由于给新单元分配地址需要时间,这就导致了算法的速度有些减慢,同时算法实际上还要求第二种数据结构的出现。当出现冲突的时候,还有一种处理方法就是尝试选择另一个单元。

线性探测:
在线性探测中,函数f是i的线性函数,一般情况下f(i)= i。这相当于逐个探测每个单元(使用回绕)来查找空单元。但是这样存在的问题是散列到区块中的任何键都需要多次试选才能解决冲突,然后该键才被添加到相应的区块中。一般成功查找要比不成功查找费时较少。

平方探测:
冲突函数f(i)= i * i,对于线性探测而言,散列表几乎填满会降低表的性能,但是对于平方探测,性能会下降的更糟:一旦表被填满超过一半,若表的大小不是素数,那么甚至在表被填满一半之前,就不能保证找到空的单元了。这是因为最多只有表的一般可以用做解决冲突的备选位置。
定理:如果使用平方探测,如果表有一半是空的,且表的大小为素数,那么当表至少有一半是空的时候,总能够插入一个新的元素。

双散列:
对于双散列,一种比较流行的选择是f(i)=ihash2(x)。这个公式表明将第二个散列函数应用到x并在hash2(x),2hash2(x),...等处进行探测。hash2(x)选择不当将会非常糟糕。

标准库中散列表
标准库中不包括set和map的散列表实现。但是许多编译器提供具有于set和map类相同的成员函数hash_set和hash_map。
散列表可以用来以常数平均时间实现insert和contains操作。当使用散列表时,注意诸如装填因子这样的细节十分重要,否则时间界将不再有效。当键不是短字符或整数时,仔细选择散列函数也很重要的。
对于分离链接散列法,虽然装填因子增大性能不会明显的降低,但是装填因子还是应该接近于1。对于探测散列,除非完全不可避免,否则装填因子不应该超过0.5。如果使用线性探测,那么性能随着装填因子接近于1而极速下降。再散列可以通过增加或者收缩表的长度来实现,这样可以保持合理的散列因子。
二叉查找树也可以用来实现insert和contains操作。虽然平均时间界为O(logN),但是二叉查找树也支持那些需要排序的例程,从而功能更加强大。使用散列表不可能找出最小元素,除非准确的知道一个字符串,否则散列表也不可能有效的查找它。

没有更多推荐了,返回首页