之前一段时间在准备上海交通大学软件学院的上机考试。2019年的题目是实现Linear Probing Hashtable和Cuckoo Hashtable。在实现Linear Probing Hashtable的删除操作时,遇到了一个非常棘手的问题。
假如我们现在哈希表存储的是键值对,且其长度为8. Hash函数为 key % 8
。首先插入键值对{key: 2, val: 5}
,哈希表的状态如下所示(此处只显示key值):
现在再其中插入键值对{key: 10, val: 7}
。由于hashFunc(10)
和hashFunc(2)
的值相同,都为2,所以键值对{key: 10, val: 7}
不能插入到index
为2处,根据Linear Probing Hashtable的探测算法,需要将这个键值对插入到2后面的3处,现在哈希表的状态如下图所示:
此后我们从哈希表中删除key值为2的键值对,hashFunc(2)
的值为2,而且数组index为2处的键值对,key值的确为2,只需要将此处清空即可,现在哈希表的状态如下图所示:
但是如果我们尝试删除key值为10的键值对时,会发现一个问题。hashFunc(10)
的值为2,但是在index为2处没有找到键值对,为空,直接返回,但是key值为10的键值对在index为3处,删除操作失败。
所以如何解决这个问题?如果说每次进行删除的时候,都要从开始的位置不断向后遍历哈希表,直到找到有对应的键值对?但是如果说要删除的键值对本来就不在哈希表里面,那么删除操作需要将整个哈希表遍历一遍,时间复杂度为\(O(n)\) ,这显然是不能接受的。
重新翻阅Data Structures and Algorithm Analysis in C++ 的Hashtable部分后,才了解到,删除线性哈希表中的元素直接删除,而是应该懒惰删除(Lazy deletion),来防止出现上面的情况。懒惰删除的实现也比较简单,只需要为我们的Hashtable Entry新添加一个bool字段,表示这个entry是否已经被删除了,已经删除置为true,未被删除置为false。
将lazy deletion 应用到我们的算法中:在第二步删除key值为2的键值对之后,再删除key值为10的键值对;由于index 2处entry不为空,我们需要继续向后寻找,找到key值为10的键值对,将其deleted域置为true。问题得到完美解决。
需要注意的是,被标记删除的entry会一直留在哈希表中,不能被清空,也不能被替换。在哈希表中元素个数过多,进行rehash操作时,被懒惰删除的entry被丢弃,不会进入到rehash之后的哈希表中。
允许清空懒惰删除的entry导致的问题如上所述。如果允许替换懒惰删除的entry,也会带来棘手的问题。
在上面的例子中,懒惰删除index为2的键值对之后,再向哈希表中插入键值对{key: 10, val: 20}
;允许替换懒惰删除的entry后,那么这键值对会直接被放置在index为2处,现在哈希表的状态为:
哈希表中有两个键值为10的键值对,这是不能接受的。我们插入键值对{key: 10, val: 20}
的目的,是想替换原来已经有的键值为10的键值对,其实就是想改变一下key值对应的value,但是如果允许替换懒惰删除的entry,得到的结果会与我们的目的背道而驰。
附:Linear Probing Hashtable的Java实现
仓库地址:https://github.com/TimeSea05/sjtu-se/tree/master/hashtable-2019
HashTable.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class HashTableEntry { public int key; public int value; public boolean deleted; HashTableEntry(int key, int value) { this .key = key; this .value = value; this .deleted = false ; } } public interface HashTable { public void Set (int key, int value) ; public String Get (int key) ; public void Delete (int key) ; }
LinearHashTable.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 public class LinearHashTable implements HashTable { private HashTableEntry[] table; private int nElem; private int cap; final int INITIAL_CAP = 8 ; public LinearHashTable () { table = new HashTableEntry [INITIAL_CAP]; nElem = 0 ; cap = INITIAL_CAP; } private int hashFunc (int key) { return key % cap; } private void expandCap () { cap *= 2 ; rehash(); } private void rehash () { HashTableEntry[] oldTable = table; table = new HashTableEntry [cap]; nElem = 0 ; for (HashTableEntry entry: oldTable) { if (entry != null && !entry.deleted) { Set(entry.key, entry.value); } } } public void Set (int key, int value) { if (nElem >= cap / 2 ) { expandCap(); } int pos = hashFunc(key); int firstTombstone = -1 ; while (table[pos] != null ) { if (table[pos].deleted && firstTombstone == -1 ) { firstTombstone = pos; } if (table[pos].key == key && !table[pos].deleted) { table[pos].value = value; return ; } pos = (pos + 1 ) % cap; } if (firstTombstone != -1 ) { table[firstTombstone] = new HashTableEntry (key, value); } else { table[pos] = new HashTableEntry (key, value); nElem++; } } public String Get (int key) { int pos = hashFunc(key); while (table[pos] != null ) { if (table[pos].key == key && !table[pos].deleted) { return Integer.toString(table[pos].value); } pos = (pos + 1 ) % cap; } return "null" ; } public void Delete (int key) { int pos = hashFunc(key); while (table[pos] != null ) { if (table[pos].key == key) { table[pos].deleted = true ; } pos = (pos + 1 ) % cap; } } }