浅析 Hash 表
其实数据结构学了也有那么久了,不过也只是对链表、栈、队列、图等一些常见数据结构的大致了解。我理解的数据结构其实就是对数据存放的一种特殊规则吧。在这个基础上,我们就能更方便的对存储的数据进行操作了。比如增、删、改、查等一些基本操作。为了更方便的达到不同的操作目的,就有人对这些数据做了规划和整理,也就是前面所说的数据结构。的确是这样,上面的这些数据结构都有各自的特征和适合的数据操作。比如说数组,它在内存中是分配了一块连续的存储单元,它里面的每个数据都有下标,这就方便了我们对这些数据的查找。相比之下,对链表中数据的查找就没那么方便了。因为它没有下标,分配到的存储单元也不一定是连续的。但是它也有自己的特点,它的每个数据都有一个指针域指向下一个元素的存储位置。所以修改指针域就可以改变数据的逻辑顺序,这就很方便了我们对数据的增、删等动态操作。后来我们又学习了Hash 表,当初学的时候对它也没什么感觉,它会比数组查找效率还好?
现在似乎有点明白了,以前看到的都是小数据量(而且还都是整型的),所以感觉用Hash没有数组来得方便。但是在实际中并非是这样。我们的数据不可能都是整型的而且还是那么些个数据,这个时候完全就不一样了。
哈希表是利用哈希函数将需要存储的内容的关键值转换为这个有序数组中的某个值,在被存储内容和有序数组之间建立了映射关系。而且这里的哈希函数也可以根据不同类型的数据而采取不同的方法。Hash 表的优点是把数据的存储和查找消耗的时间大大的降低,几乎可以看做是常数时间。而其代价是消耗更多的内存。若对于任意有关关键字,经哈希函数映射到地址集合中任何一个地址的概率是相等的,那么这个哈希函数就是均匀的。
常见的构造哈希函数的方法有:
1. 直接定址法
这个方法是取关键字或关键字的某个线性函数值为哈希地址
如: H(key)=a*key+b (a,b为常数)
这个方程式大家一看就知道,变量和函数值的集合大小一样,这很明显就不怎么的适应了。
2. 数字分析法
这个我个人认为我们现在还真是很少见到吧。看名字就知道,因为事先还需要对位数比较多的数据的关键字进行分析,才能确定应该采用关键字中的哪几位组成哈希地址。比如超过长整型范围而无法直接运算的就可以采用这个方法。现在我们遇到的数据都还没有达到这就级别吧。
3. 平方取中法
这个是取关键字平方后的中间几位为哈希地址。这个方法貌似比上一个方法适用些。因为在选定哈希函数时不一定要知道关键字的全部情况,取其中哪些位也不一定合适。平方后的中间几位是和原来的关键字的每一位都有关。而且取的位数由表长决定。
4. 折叠法
5. 除留余数法
取关键字被某个不大于哈希表表长m的数P除后所得的余数为哈希地址。
H(key)=key MOD p, p<=m
这个方法比较简单,也是我们稍微熟悉的。它可以直接对关键字取模,也可以在折叠、平方取中运算之后取模。在使用这个构造哈希函数时,对p的选择是非常重要的。因为不同的关键字使用模运算的结果可能相同。如果P的取值不合理就会造成相同的结果的概率太大。也就是我们常说的冲突太频繁了。
6. 随机数法
(1)哈希函数非常灵活的,通常在选择的时候要考虑以下一个因素:
1. 计算哈希函数所需的时间
2. 关键字的长度
3. 哈希表的长度
4. 关键字的分布情况
5. 记录的查找频率
(2)通常处理冲突的方法:
1. 开放定址法
Hi =( H(key) + di ) MOD m i=1,2,…,k(k<m)
其中H(key)为哈希函数,m为表长, di 为增量序列。
2. 再哈希法
Hi =RHi (key)
RHi 均是不同的哈希函数,在产生冲突的时候计算另一个地址,直到不再发生冲突。
3. 链地址法
在这里就用到了链表,将所有关键字为同义词的记录存储在同一个线性链表中。不过同一地址的新冲突元素可以插入到表头也可以插入到表尾,还可以是中间。
4. 建立一个公共溢出区
这个就比较随意了,因为所有关键字的经哈希函数后发生冲突了,就将其放入溢出表中。
以上几个解决冲突的方法中,对于小数据量时,可能要选择开放定址法了。如果是比较大的数据量的话,链地址法比较实用吧。因为这个是集合了数组和链表的双重优点了。它可以提供快速的插入操作和查找操作。但是我们也非常清楚,它同时也继承了数组和链表的缺点,数组创建后很难扩充,哈希表被基本填满时,性能下降得非常严重。但是这个数组又不能过分大,又不能太小。所以就有了后面所说的reHash。 可想而知,当reHash的次数过分大时,这个性能就大大的降低了。
Hash 表实现的代码 (链地址法解决冲突)
/** * 将新的键值对插入到 Hash表 的方法 * @param k key值 * @param v value值 */ public void put(K k,V v){ Item<K,V> nowItem=new Item(k,v); //将出入的key value构造一个Item 结点对象 //如果是否是新加入了结点 if(putItem(nowItem,items)){ count++; //每次插入了一个新的键值对后,将计数器增加1 //每次新添加键值对后要对当前的 加载因子做判断,看是否超过了0.8 if(count>=ConLen*factor){ //若超过了填充因子,则将Hash表的长度扩大到原来的两倍 reHash(2*ConLen); } } } public boolean putItem(Item<K,V> nowItem,Item[] items){ //通过Hash 函数 获得 nowItem结点 在 hash 表中的位置 int index=getIndex(nowItem.key,items); Item<K,V> item=items[index]; //获取当前Hash表中Index 位置上的item 数据,并判断其是为空 if(null==item){ item=nowItem; item.next=null; } //若当前该index位置上已经有结点了 else if(null!=item){ //判断该结点挂链表是否到达末尾 while(null!=item.next){ item=item.next; } //找到链表结尾后,将新结点链接在挂链表的末尾 item.next=nowItem; //或者是 若数组中当前index 下标位置为空,则直接将 新的键值对插入到 数组中 //item.index=index; //记录当前的其在hash表中的位置 } return true; } /** * Hash函数 ,通过该函数可以得到 该key在hash表中的位置 * @param item 要插入Hash表中的key值 * @param items 要插入的hash表 * @return 返回key 通过hash函数得到的index 值 */ public int getIndex(K k,Item[] items){ //得到k值在内存在中 int key1=k.hashCode(); int key=hash(key1); // int index=indexFor(key,items.length); //key值 求余 表长,得到其在表中的位置 //返回Hash 函数求得的 下标位置 return index; }
虽然对这个原理好像理解了,但是听了两遍写代码时还是很纠结,特别是测试的时候,怎么跟系统的时间相差这么大?说实话现在我还没明白,我改来改去,时间还是差不多。看了系统的源代码,还没懂。