第五章节 散列
目录
一、散列函数
二、分离链接法
三、不用链表的散列表
四、再散列
五、标准库中的散列表
六、可扩散列
七、小结
散列表的实现通常叫作散列,是一种用于以常数时间完成 insert, delete ,find的技术,但是任何需要元素的排序信息的操作都不支持,如
findMin, findMax ,按排序打印元素等。
一、散列函数
一个比较好的散列函数如下,但是如果关键字过长,则计算时间会有些长,这样时我们不使用所有的字符,比如只使用奇数位。这里有这样一种
想法,用计算散列函数节约下来的时间来补偿产生的对均匀分成 函数的影响。
/** * a hash rountine for String objects * 37=26+space+number * @param key * @param tableSize * @return */ public static int hashCode(String key , int tableSize){ int hashVal =0; for (int i =0;i<key.length(); i++){ hashVal =37*hashVal+key.charAt(i); } hashVal %= tableSize ; if (hashVal<0){ //溢出 hashVal+= tableSize ; } return hashVal ; }
剩下的主要编程细节是解决冲突的问题,也就是一个元素插入时与一个已经插入的元素散列到同一个值。有两个方法,分离链接法和开放定址法。
二、分离链接法
做法是将散列到同一个值的所有的元素都保存到一个表中。这个表可以用标准单元库的表(双链表)来实现 ,但是占空间较大,所以我们可以自己用单向链表实现。
上面假设散列函数是 hash(x) = x mod 10。(从这里可以看到,最好保证表的大小是素数,如下面代码 中的101)。
查找:先用散列值确定遍历那条链,再在确定的链表中查找到元素。
插入:查看相应的链表是否已经 有这个元素,如果没有,插入到链表的最前端,这不仅方便,还是因为,最新插入的元素最有可能不久会被访问。
分离链接法实现如下,散列表保存一个链表数组。
package charpter5; import java.util.LinkedList; import charpter3.List; public class SeparateChainHashTable <AnyType>{ private static final int DEFAULT_TABLE_SIZE=101 ; private List<AnyType> [] theLists ; private int currentSize ; public SeparateChainHashTable(){ this(DEFAULT_TABLE_SIZE) ; } public SeparateChainHashTable(int size ){ theLists = (List<AnyType>[]) new LinkedList [nextPrime(size)] ; for (int i =0;i<theLists.length;i++) theLists[i] = (List<AnyType>) new LinkedList<AnyType>() ; } public void insert (AnyType x){ List<AnyType> whichList = theLists[myhash(x)] ; if (!whichList.contains(x)){ whichList.add(x) ; //查看 是否要扩大散列表 if (++currentSize> theLists.length) rehash() ; } } public void remove (AnyType x ){ List<AnyType> whichList = theLists[myhash(x)] ; if (whichList.contains(x)){ whichList.remove(x) ; currentSize-- ; } } public boolean contains(AnyType x ){ List<AnyType> whichList = theLists[myhash(x)] ; return whichList.contains(x) ; } public void makeEmpty (){ for (int i=0; i<theLists.length ;i++){ theLists[i].clear(); } currentSize =0; } private void rehash(){ } private int myhash (AnyType x){ int hashVal = x.hashCode() ;//类自己的hashCode hashVal %= theLists.length ; if (hashVal<0) hashVal += theLists.length ; return hashVal ; } /** *size 下一个素数 * @param n * @return */ private static int nextPrime(int size ){ return 0; } private static boolean isPrime (int n){ return true ; } }
像二叉树只对那些是Comparable的对象工作一样,散列表只对提供了equals / hashCode的方法适用,散表表将hashCode结果通过myhash软件成适当的数组下标。
在插入的时候,如果已经存在 ,则什么都不做,如果不存在 ,将元素放入其中,这个元素可以被放到任意位置,我们用add最方便。
除链表外,别的方案也可以解决冲突,如二叉树等,但是我们希望所有的链表都是短的,所以其它 任何复杂的想法都不用考虑了。
分离链表的缺点
使用了链表,给新的单无分配地址要时间,特别是在个别语言中,导致算法速度慢,同时算法实际上还要第二种数据结构实现 (链表)。
下面是不用链表解决冲突的方法,尝试别的单元,直到找到空的单元为止。h(x)= (hash(x)+f(i)) mod tablesize.这种表叫做探测散列表,装填因子低于
0.5,比用分离链表时大。
三、不用链表的散列表
- 线性探测法
f(i)=i
占据的单元 容易 形成一些 区块,这种 就是一次聚集。
- 平方探测法
f(i)=i*i
对于线性探测表,让散列表几乎放满元素不是个好主意,会让表的性能 降低很多。
对于平方探测更是如此,当表填充超过一半时,如果表的大小 不是素数,甚至在表被填充一半前,就不能保证 一次能找到空的单元了。
定理:如果是平方探测,且表的大小是素数,当表至少有一半是空的时候 ,总能够插入一个新的元素。
在线性探测散列表中,标准的删除操作不可行,只能进行惰性删除,因为删除一个元素后,可能剩下的contains操作都会失败。
下面是平方探测法的例子:
package charpter5; public class QuadicProbingHashTable <AnyType>{ private static class HashEntry <AnyType>{ public AnyType element ; public boolean isActive ; //false if deleted public HashEntry(AnyType e ){ this(e, true) ; } public HashEntry(AnyType e , boolean i){ element = e; isActive = i; } } private static final int DEFAULT_SIZE=11; private HashEntry<AnyType> [] array ; //1:null,2:不是null,active,3.... private int cuurentSize ; public QuadicProbingHashTable(){ this(DEFAULT_SIZE) ; } public QuadicProbingHashTable(int size ){ allocateArray(size) ; makeEmpty() ; } public void makeEmpty (){ cuurentSize=0; for (int i=0; i<array.length;i++){ array[i]= null ; } } public boolean contains(AnyType x ){ int currentPos = findPos(x) ; return isActive(currentPos ); } /** * 如果装填因子 大于0.5,则进行扩大,这样就是再散列 * @param x */ public void insert (AnyType x ){ int currentPos = findPos(x) ; if (isActive(currentPos)) return ;//元素已经存在 array[currentPos] = new HashEntry<AnyType>(x, true); //rehash if (++currentSize> array.length/2){ rehash() ; } } public void remove (AnyType x){ int currentPos = findPos(x); if (isActive(currentPos)) array[currentPos].isActive = false ; } private void allocateArray (int arraySize){ array = new HashEntry[arraySize]; } private boolean isActive (int currentPos){ return array[currentPos]!=null && array[currentPos].isActive ; } /** * 解决冲突的问题 * 下面的计算ith的方法比较快,因为没有用到乘法,如果越过数组大小 ,可以减去 length */ private int findPos (AnyType x ){ int offset =1; int currentPos = myhash(x) ; //下面的判断顺序不可变,判断 是不为空,且位置冲突 情况 while (array[currentPos]!=null && !array[currentPos].element.equals(x)) { currentPos += offset ; //get ith probe,f(i)=f(i-1)+2i-1 offset+=2; if (currentPos>= array.length){ currentPos-= array.length; } } return currentPos; } private void rehash(){ } private int myhash(AnyType x ){ return 0; } private static boolean isPrime (int n){ return true ; } }
虽然平方探测没有一次聚集,但是散列到同一个位置上的那些元素将会探测相同 的备选 单元 ,这叫二次聚集。下面的技术(双散列)将排除这个问题。(略)
四、再散列
对于使用平方探测的开放地址的散列法,如果散列表太满,则操作运行的时间就会要很长,且插入操作有可能失败。我们这里可以建立一个新的原来两倍的散列表。将没有删除的元素放到新表中。
这种操作是再散列,由于在再散列前已经存在了N/2次的insert ,因此添加到每个插入上的操作是一个常数开销。对速度的影响是不明显的。
有三种方法实现 :
1.只要表到一半满,就再散列。
2.当出现 插入失败时,再散列。
3.当装填因子到一定时进行再散列。
我们选择第三种。
private void rehash(){ HashEntry<AnyType> [] oldArray = array ; allocateArray(nextPrime(2*oldArray.length)) ; currentSize=0; //copy table over for (int i=0;i<oldArray.length;i++) if (oldArray[i]!= null && oldArray[i].isActive) insert(oldArray[i].element) ; }
对于分离链表散列表的同散列,是类似的情况 。如下
private void rehash(){ List<AnyType> [] oldLists = theLists ; //create double table theLists = new List[2*nextPrime(oldLists.length)] ; for (int i=0;i< theLists.length;i++){ theLists[i]= (List<AnyType>) new LinkedList<AnyType>(); } //copy to new table for (int i=0;i<oldLists.length;i++){ for (AnyType item :oldLists[i]){ insert(item) ; } } }
五、标准库中的散列表
包括Set 和Map的散列表实现 ,也就是 HashSet /HashMap.它们中的项一定要有 equals、hashcode方法的实现 。且通常是用的分离链表实现的。
HashMap的性能常常比TreeMap的性能更好。
String类有一个技巧,闪存散列代码。就是将hashcode保存在类的一个字段中。
六、可扩散列
主要会对数据太多,装不进主存的情况,这里主要考虑的是读取磁盘的次数。(p142)
七、小结
散列表可以用常数时间来完成插入和查找操作。
- 散列表与二叉查找树比较
二叉查找树也可以用来实现 insert/contains操作,虽然平均时间为O(logN),但是二叉查找树支持需要排序的一些更加强大的功能。
使用散列表不可能找出最小元素等。
除非准确的知道一个字符串,否则不能有效的查找到它。而二叉查找树可以找到一定范围内的所有项。
此外,O(logN)也不一定比O(1)大很多,这是因为查找 树不要乘法和除法。
如果不要求有序的信息,就应该选择散列表。