散列表类似于字典,是一种追求高效的数据存储方式。这一篇是我自己实现的散列表(链式散列表和开放寻址表),和一些习题的思路。这一章的习题有一半左右是在一致散列假设下对开放寻址散列性能的分析,以及全域散列的内容,需要一定数论知识,否则做起来比较困难,这篇博文没有包括这部分习题。也许在学习一遍附录A之后再来做这些题目会更好。因为没有包括这部分习题,所以这一篇也就比较单薄,因此我加上了前面涉及到的较短的“中位数与顺序统计学”一章,这样凑成一篇。
散列表通过直接对关键字执行某种约定好的而且无意义的计算,得到可以直接访问的对应于关键字的地址。在字典中查询某个字时,先在目录中查找拼音或者部首对应的页码(这个过程就是约定的无意义的计算),然后通过页码直接翻到那一页(虽然你可能需要翻动三到四次去找某一页,但计算机可以立刻访问到)。在理想情况下,散列表的存取效率为 $O(1)$ 。
散列表唯一的问题是:不同关键字可能会共享一个页码,就像一页上会有多个汉字。如何处理这个问题,使散列表分化为主要两种类型:链表散列表和开放寻址散列表。
链表散列表
链表散列表这样处理:在每一个地址(又称槽位slot)中存储一个指向某链表的指针,该链表中存储着所有对应于该槽位的元素。这是我的实现,我的实现中用到了上一篇笔记中实现的单链表。
template <typename K> class xLinkHashTable{ public: xLinkHashTable(int (*f)(K), int m); bool insert(K value); xNode<K>* search(K value); bool remove(xNode<K>* node); private: xLinkList<K>** slotArray; int slotNum; int (*hashFunction)(K); }; template <typename K> xLinkHashTable<K>::xLinkHashTable(int (*f)(K), int m){ slotArray = new xLinkList<K>*[m]; slotNum = m; for(int i=0; i<m; i++){ slotArray[i] = NULL; } hashFunction = f; } template <typename K> bool xLinkHashTable<K>::insert(K value){ int index = hashFunction(value); if (slotArray[index] == NULL) { slotArray[index] = new xLinkList<K>(); slotArray[index]->insert(value); } else{ slotArray[index]->insert(value); } return true; } template <typename K> xNode<K>* xLinkHashTable<K>::search(K value){ int index = hashFunction(value); if (slotArray[index] == NULL){ return NULL; } else{ return slotArray[index]->search(value); } } template <typename K> bool xLinkHashTable<K>::remove(xNode<K>* node){ int index = hashFunction(node->data); if (slotArray[index] == NULL){ return false; } else{ return slotArray[index]->deleteNode(node); } }
其中xLinkHashTable类的成员hashFunction为指向一个散列函数的指针,该函数的签名表示,接受一个模版类型的值作为输入,返回一个int值作为散列表中的索引。散列函数的选择直接决定散列性能的优劣。通常,散列函数首先将模版类型解释为一个整数(比如将一个字符串解释为以128为基的整数,将"pt"解释为128*(int)p+(int)t,但是这里直接取模版类型为整型),然后根据某种特定规则将一个任意的整数解释为 [0, m] 区间的整数。
常用的散列函数有余数散列和乘法散列。
余数散列:对整数求某个质数m的余数,得到一个 [0, m-1] 之间的整数作为索引。由于该整数只有m个可能的值,所以散列表的槽位个数也是该质数m(这是个缺陷)。
$$h(k)=k-\left \lfloor \frac{k}{m} \right \rfloor\cdot m$$
假设该质数是17,我们要获得一个17个槽位的散列表。能够很简单地写出这样的函数。
int hash_mode_17(int value){ return value%17; }
乘法散列:对整数乘以某个适当大小的无理数,然后取小数部分再乘以m,取整。槽位的个数m没有什么古怪的需求(比如,需要是质数),完全根据设备的需要设置为128之类。
$$h(k)=\lfloor(kA- \lfloor kA \rfloor)\cdot m\rfloor$$
假设无理数值为 $(\sqrt{5}-1)/2=0.681...$ ,m取128,则函数可以这样写:
int hash_time_128(int value){ double A = 0.6180339887; double tmp = A*value; return (int)(128*(tmp-(int)tmp)); }
然后在散列表类构造时将其作为参数传入。
xLinkHashTable<int>* theHashTable = new xLinkHashTable<int>(hash_time_17, 17);
练习11.2-4 在散列表内部,将所有未占用的槽位链接成一个链表来分配元素的存储空间,如何增删查?该链表是否需要是双链表。
思路:题意一开始没大弄明白,弄明白之后发现真无聊……有点像直接寻址法,但空槽位的分配由另一个链表控制。所有空的槽位都连成一个自由链表,每个槽位即是链表节点又是可以直接通过索引值访问的槽位,包含元素的槽自动脱离自由链表,而通过索引值访问,多个元素共享一个槽位时,从自由链表中拿额外的空槽位,接到那个被共享的槽位后面。
- 如何添加元素:首先通过散列函数计算索引值找到槽位:如果空,就填进去,并将槽位从自由链表中分割出来;如果非空,看槽位里填的元素计算出来的索引值是不是这个槽位(如果是,从自由链表里取个空槽位,接在后面;如果否,把那个元素取出来,这个元素填进去,在重新添加取出来的元素。)
- 如何删除元素:将指定的槽位从链表中删去,并且将槽位重新插到自由链表里。
- 如何查询元素:根据散列函数找到槽位,如果该槽位在自由链表里面,就是没查到;如果不在,算里面的元素的散列值是不是这个槽位的索引值(如果不是,也没查到;如果是,就查从这个槽位开始的链表里有没有那个元素)。
如果用双链表,效率会高很多,因为删除操作(不仅是删除元素,添加元素时也经常涉及删除操作)的效率高了很多。
练习11.3-2 一个长度为 $r$ 的字符串散列到 $m$ 个槽中,方法是将字符串视为一个以128为基的整数,然后用余数法计算这个整数的散列值。问题是长字符串以128为基的数可能会很大,耗费太多的空间。如何处理使得计算该字符串的散列值时只耗费固定大小的空间。
思路:由于字符串长度 $r$ 可能比较大,设第 $i$ 位为,以128为基的整数 $k=x_{0}+128x_{1}+128^{2}x_{2}+...+128^{r-1}x_{r-1}=\sum_{i=0}^{r-1}128^{i}x_{i}$ 可能会很大。不过我们只要求计算 $k \mod m$ ,所以利用模的两条性质:
$$(a+b)\mod m=((a \mod m)+(b \mod m))\mod m$$
$$(ab)\mod m=(a(b \mod m))\mod m$$
所以只要依次根据 $i$ 迭代计算 $128^{i}\mod m$ 再将它们加起来再取模就可以了。如果要严格保证只使用固定空间,可以每将两项相加之后就立刻取模,再与下一项相加。伪代码如下:
HASH-STRING(A, r, base, m) res=0 tmp=1 for i=0 to r-1 res+=A[i]*tmp % m res%=m tmp=(tmp*128) % m return res
练习11.3-3 考虑除法的另一个版本,其中 $h(k)=k \mod m, m=2^{p}-1$ ,而且 $k$ 为按照基数 $2^{p}$ 解释的字符串。证明,具有相同字母不同次序的字符串具有相同散列值(这是散列函数绝对不期望的性质)。
思路:比较简单,因为 $x_{i}\cdot (2^{p})^{i}\mod (2^{p}-1)=x_{i}\cdot 1\cdot (2^{p})^{i-1}\mod (2^{p}-1)=...=x_{i}$,所以 $\sum_{i=0}^{r-1}(2^{p})^{i}x_{i}=\sum_{i=0}^{r-1}x_{i}$ 。
开放寻址法
开放寻址法的每个槽位里存储着“实实在在”的元素,而不是指向一个链表的指针。开放寻址法实际上这样处理多个元素共享一个索引值,当元素法线对应的槽位已经被占用时,就去查看较小优先级的槽位。这需要散列函数不仅需要元素本身作参数,还要一个优先级。优先级共有m级(m为槽位的个数),从0到m-1。因为当第m-1优先级的槽位都不不是空的时候,已经遍历了整个散列表,散列表已经满了。
我的实现如下:
template <typename K> class xOAHashTable{ public: xOAHashTable(int (*f)(K, int), int m, K dValue); bool insert(K value); int search(K value); bool remove(int index); private: K* slotArray; int slotNum; int (*hashFunction)(K, int); K valueDeleted; }; template <typename K> xOAHashTable<K>::xOAHashTable(int (*f)(K, int), int m, K dValue){ slotArray = new K[m]; for (int i=0; i<m; i++){ slotArray[i] = NULL; } slotNum = m; hashFunction = f; valueDeleted = dValue; } template <typename K> bool xOAHashTable<K>::insert(K value){ int i = 0; while (i<slotNum){ int index = hashFunction(value, i); if (slotArray[index]!=NULL && slotArray[index]!=valueDeleted){ i++; } else{ slotArray[index] = value; return true; } } return false; } template <typename K> int xOAHashTable<K>::search(K value){ int i = 0; while (i<slotNum){ int index = hashFunction(value, i); if (slotArray[index]!=NULL){ if (slotArray[index] == value){ return i; } else{ i++; } } else{ return -1; } } return -1; } template <typename K> bool xOAHashTable<K>::remove(int index){ if (index<0 || index>=slotNum){ return false; } if (slotArray[index]==NULL || slotArray[index]==valueDeleted){ return false; } slotArray[index] = valueDeleted; }
在如何产生第 $1,2,3...$ 优先级的索引值的问题上,有以下几种方法。
线性探查,以第0优先级的后一个作为第1优先级,第1优先级的后一个作为第二优先级,类推,
$$h'(k,i)=h(k)+i$$
比如:
int hashi_mode_701(int value, int i) { return value%701+i; }
二次探查:
$$h'(k,i)=h(k)+c_{1}i+c_{2}i^{2}$$
双重散列:
$$h'(k,i)=h_{1}(k)+i\cdot h_{2}(k)$$
为了保证二次探查或双重散列能够在 $i=0,1,2...m-1$ 中不重复又“跳跃”地访问到每个槽位,$c_{1}$,$c_{2}$ 和 $h_{2}(k)$ 都要经过精心挑选。
练习11.4-3 采用双重散列解决碰撞时,如果对于某个关键字 $k$ ,有 $m$ 和 $h_{2}(k)$ 有最大公约数 $d$ ,证明则在对一次 $k$ 的不成功查找中,回到 $h_{1}(k)$ 之前,要检查散列表的 $1/d$ 。
思路:这几乎是显而易见的,设 $h_{2}(k)=xd$ , 和 $m=yd$ ,因为这二者有最大公约数 $d$ ,所以 $x$ 和 $y$ 都为整数而且互质。一次不成功的查找,最后回到的 $h_{1}(x)$ 在相位上是 $h_{1}(x)+xyd$ ,后者是 $m$ 和 $h_{2}(k)$ 的最大公倍数。检查槽位数是 $xyd/xd=y=m/d$ 。
思考题11-3 二次探查。这道题很简单,我把它列在这里的原因是这一章的其他两道思考题都是证明而且我都没有做……。很简单,不说了。
顺序统计学
顺序统计学研究如何在最短的时间代价内找到 $n$ 个元素中的第 $i$ 小的元素。由于并不需要全部排序,而只需要知道第 $i$ 小的元素,所以时间代价异常小,通常为 $n$ 。一个随机算法就是模仿快速排序的随机版本,每一次都根据一个随机的元素 $k$ 将数组划分为两部分,一个部分全大于 $k$,一个部分全部小于 $k$,并递归研究其中的一部分(而不是两部分都研究,所以它比排序算法快!)。我的实现如下:
int randomSelect(int *x, int p, int r, int i){ if (p==r){ return x[p]; } int q = partition(x, p, r); if (i==q){ return x[i]; } if (i<q){ return randomSelect(x, p, q-1, i); } else{ return randomSelect(x, q+1, r, i); } }