1. 前言
在复杂编程中,常常遇到查找问题,如何能更快更简单的找到我们需要的数据是一个经典问题。在各种查找方法中最简单的是使用数组或者链表,进行线性查找,但是往往效率比较低。因为如果要查找的数据恰好在线性表前面那么自然很快就能得到结果,而目标处在最后则要耗费很多时间去挨个查找。另一方面,大量的线性查找也意味着我们的程序当中要嵌套大量的循环语句,这本身对于程序可读性和运行效率都是不利的。我个人不建议在程序中循环或者分支语句超过三层,多了就会让人感觉代码像一团浆糊,单单辨别划分程序块的括号就很困难了。
为了能更快找到数据产生了很多更高级的数据结构,比如BST,跳跃表,散列表等等。这些数据结构都采用非线性方式查询数据,可以说各有优缺点。本文就是探讨如何使用开放式散列表来加速查找。
2. 原理分析
在各种查找问题中遇到最普遍的问题往往是这样的:给你一个键值(key),在一堆的数据当中到底存不存在与其相关的记录,如果有的话记录的具体值是多少?我们可以想象,使用线性表的话,随着记录数目增多查找变得越发困难,散列表(Hash Table)也就应运而生。
散列表本身不像BST或者跳跃表那样属于有序结构。因此你的数据可以不必事先排序,散乱的堆放到表中。查找的核心在于散列函数(Hash Function),通过将键值带入散列函数而计算得到一个索引值(Index),这个索引值或者我们称之为槽号(Slot Number)就是数据记录的位置。因此理想的情况下我们根本不需要去轮询数据,只需要一次简单的计算就能得到数据的准确位置。
既然如此,整个算法的核心就在于这个散列函数。最简单的一个散列函数就是取模,也就是key % M。其中M代表散列表的大小,也就是槽的总数。不同的键值取得的模不同,数据也就通过运算被“分散”到一个线性空间里。到这里,你可能会发现并非所有的键值都会产生不同的散列值。比如,M为10的话,key如果为1,11,21都会取得余数为1,这样不同元素的散列就会发生碰撞,称之为散列冲突(Hash Collision)。那么,我们衡量一个好的散列函数就是通过尽可能少的计算,将键值更平均的分散到线性地址空间中,尽可能的减小冲突发生的可能性。显然,上面提到的这个例子并不是一个很好的解决方案。
这里有一个典型例子是UNIX系统中使用的著名的ELFHash函数,就是经典的字符串散列函数。该函数被用在UNIX的ELF文件系统中因此而得名。它能很好的实现任意长字符串的平均分布。
unsigned long Hashfn(char* pKey) { unsigned long h = 0, g; while(*pKey) { h = (h << 4) + *pKey++; g = h & 0xF0000000L; if (g) h^= g >> 24; h &= ~g; } return h % M; } |
然而即便如此,在一个散列表中冲突发生还是不可避免的。我们几乎很难找到一个具有完美散列(Perfect Hashing)性质的函数。那么要想构建一个散列表就不得不考率冲突解决方案。由此,也引出了下面将要讨论的开放式散列表。
所谓的解决散列冲突就是将已经被占用的槽进行二次分配。一般的解决方案可以分为两类:一种是地址开放式的散列表,冲突的数据可以被放在表外,称为开散列(Open Hashing)。另外一种是将冲突数据按照一定的探查序列放在表内,为闭合散列。这里我选择开散列的原因在于其实现简单,而且更加灵活,利于内存操作。而闭合散列需要额外的探查函数(Probe Function)甚至二次散列函数进行二次探查,比较复杂。另外最重要的一点,闭合散列在进行删除操作时必须保留该槽曾经被占用的信息,有的书中将其形象称为“墓碑”,否则将会中断探查序列。然而长时间的增删操作之后,表内的墓碑就会越来越多,查询速度也就会越来越慢,为了提升效率只能重建探查序列甚至重建整个散列表,实在是麻烦。
3. 开放式散列
开放式散列实现的典型办法是在槽外挂接链表。建立时,键值被散列后查询该槽,如果发生冲突则在槽外挂接一个节点,由此实现了冲突解决。查询同理,发生冲突则根据后继指针遍历整个链表,非常方便。
由于开放式散列比闭合散列存在查询速度的劣势,为了提高查询效率,加入了自适应查询的功能。将链表改为双链表,并且在节点当中记录访问次数,每次访问后根据查询次数沿着前驱指针查找,将被访问最多的节点置换到更靠近槽的位置。换句话说,可以理解为根据槽内的访问次数实时排序。
4. 数据结构
目前的demo中将散列表封装成类CHashTable。为了节省内存空间,散列表内只记录节点指针,数据被加入表中时再动态申请空间。因此那些未被占用的槽就不会造成太严重的浪费。在CHashTable内部同样只记录了表指针,这样你可以根据自己的需要通过Create函数定制任意大小的散列表了。结构如下:
class CHashTable { protected: unsigned long m_nSize; PHASHTBLELEM* m_pHashTbl; void ReSort(PHASHTBLELEM pElem); void Remove(PHASHTBLELEM pElem); public: CHashTable(void); ~CHashTable(void); void Create(unsigned long nHashMaxSize); unsigned long GetSize(); unsigned long Hashfn(LPCTSTR pKey); void HashElem(LPWSTR pKey, LPVOID pData); BOOL UnHashElem(LPWSTR pKey); LPVOID FetchElem(LPWSTR pFetchKey); };
|
链表节点被封装为结构体HASHTBLELEM,具体结构如下:
typedef struct _HASHTBLELEM { LPWSTR pKey; LPVOID pData; UINT nAccessCount; _HASHTBLELEM* pNextElem; _HASHTBLELEM* pPrevElem; } HASHTBLELEM, *PHASHTBLELEM; |
pKey中记录键值信息。pData被声明为无类型指针,你可以根据实际需要将任何东西放到节点当中,可以是一个变量,一个数组,一个结构体,甚至一个对象。这样做的好处在于给使用者更大的灵活性,缺点在于你必须自己手动强制转换成需要的类型,而且要特别小心接下来的指针操作,以免发生问题。在demo中我向pData中存放的是一个字符串。nAccessCount记录的是访问计数。最后两个成员是链表需要的前驱指针和后继指针。
5. 关于函数的说明
Create函数可以帮助你建立一个理想大小的散列表。
void CHashTable::Create(unsigned long nHashMaxSize) { m_nSize = nHashMaxSize; m_pHashTbl = new PHASHTBLELEM[m_nSize]; memset(m_pHashTbl, NULL, sizeof(PHASHTBLELEM)*m_nSize); }
|
HashElem可以向表内增加一个元素。当然你需要指定键值和要存储的数据。
void CHashTable::HashElem(LPWSTR pKey, LPVOID pData) { UINT Index = Hashfn(pKey); PHASHTBLELEM* pSlot = &m_pHashTbl[Index]; PHASHTBLELEM pElem = *pSlot; if (!pElem) { *pSlot = pElem = new HASHTBLELEM; pElem->pKey = pKey; pElem->pData = pData; pElem->nAccessCount = 0; pElem->pNextElem = NULL; pElem->pPrevElem = NULL; } else { while(pElem->pNextElem) { pElem = pElem->pNextElem; } pElem->pNextElem = new HASHTBLELEM; PHASHTBLELEM pPrevElem = pElem; pElem = pElem->pNextElem; pElem->pKey = pKey; pElem->pData = pData; pElem->nAccessCount = 0; pElem->pPrevElem = pPrevElem; pElem->pNextElem = NULL; } }
|
UnHashElem是删除函数,可以删除相关键值的节点。如果成功返回TRUE,失败则返回FALSE。
BOOL CHashTable::UnHashElem(LPWSTR pKey) { UINT Index = Hashfn(pKey); PHASHTBLELEM* pSlot = &m_pHashTbl[Index]; PHASHTBLELEM pElem = *pSlot; if (pElem) { PHASHTBLELEM pNextElem = pElem->pNextElem; do { if (!StrCmp(pKey, pElem->pKey)) { Remove(pElem); if (!pNextElem || !pNextElem->pPrevElem) *pSlot = pNextElem; return TRUE; } else { pElem = pElem->pNextElem; } } while (pElem); } return FALSE; }
|
FetchElem是查询数据,只需要将键值带入就会返回相关数据的内容。需要注意的是返回值仍然是LPVOID注意类型转换。如果查询失败,函数会返回NULL。
LPVOID CHashTable::FetchElem(LPWSTR pFetchKey) { unsigned long Index = Hashfn(pFetchKey); PHASHTBLELEM* pSlot = &m_pHashTbl[Index]; PHASHTBLELEM pElem = *pSlot; if (pElem) { do { if (!StrCmp(pFetchKey, pElem->pKey)) { pElem->nAccessCount++; ReSort(pElem); if (!pElem->pPrevElem) *pSlot = pElem; return pElem->pData; } else { pElem = pElem->pNextElem; } } while (pElem); } return NULL; }
|
需要了解的一点是:这里UnHashElem和FetchElem分别调用了另外两个保护成员函数,Remove和Resort。前者的作用是删除并重新挂接节点,后者则是根据访问次数进行链表排序。代码如下:
void CHashTable::Remove(PHASHTBLELEM pElem) { PHASHTBLELEM pPrevElem = pElem->pPrevElem; PHASHTBLELEM pNextElem = pElem->pNextElem; if (!pPrevElem) { delete[] pElem->pKey; delete[] pElem->pData; delete[] pElem; if (pNextElem) pNextElem->pPrevElem = NULL; } else { delete[] pElem->pKey; delete[] pElem->pData; delete[] pElem; pPrevElem->pNextElem = pNextElem; if (pNextElem) pNextElem->pPrevElem = pPrevElem; } } void CHashTable::ReSort(PHASHTBLELEM pElem) { if (pElem->pPrevElem && pElem->pPrevElem->nAccessCount < pElem->nAccessCount) { PHASHTBLELEM pPrevElem = pElem->pPrevElem; PHASHTBLELEM pPrevPrevElem = pPrevElem->pPrevElem; PHASHTBLELEM pNextElem = pElem->pNextElem; if (pPrevPrevElem) pPrevPrevElem->pNextElem = pElem; pElem->pPrevElem = pPrevPrevElem; pElem->pNextElem = pPrevElem; pPrevElem->pPrevElem = pElem; pPrevElem->pNextElem = pNextElem; if (pNextElem) pNextElem->pPrevElem = pPrevElem; ReSort(pElem); } else { return; } }
|
6. 总结
关于开放式散列表我写了一个demo程序,可以作为参考。总的来说中心思想就是通过牺牲一点存储空间来大幅加快访问速度,而且不需要手动对内部元素做排序处理。
另外,还存在一种比较坏的访问情况,就是反复不断的交替访问某两个元素,而恰恰它们又都位于一个槽当中。这会导致链表当中的元素不断的互相挤占被访问的有利位置而造成程序效率下降。虽然可能不常见,但是想想任何数据结构都没有十全十美的解决方案吧。
附demo下载地址:http://download.csdn.net/source/3126408