算法学习 (门徒计划)3-2 哈希表与布隆过滤器及经典问题 学习笔记
前言
(7.3,还差3课,尽力而为!)
本文为开课吧门徒计划算法课第八讲学习笔记。
3-2 第五章第2节: 哈希表与布隆过滤器
(按着惯例,本次依然挑战最短学习时间,期望3倍时间以内)
(本课难度直线上升,为简化课程内容,本次不讲哈希算法,只需要理解hash算法能将任何数据结构尽可能均匀的映射到目标内存空间中)
学习目标
- 学习哈希思想
- 重点讲一种特殊的哈希表布隆过滤器的实现方式。
- 学习哈希表的应用场景
- 期望通过本课学习对哈希表有一个整体的概念认识
哈希表
- 解决快速获取存储的信息的索引的方式
- 回顾数组结构,数组根据索引取数的时间复杂度很低
- 哈希操作实现将任意数据映射为一个数组下标(降维为整形数)(关键操作)
- 实现数据的高速存取
- 是一种设计感很强的结构
- 完成数据的高速存取
哈希操作
- 将数据降维进行映射
- 降维会遗失信息,会出现信息重叠,因此哈希操作时可能出现两个不同的数据被映射到同一个坐标上(称为哈希冲突)
- 举例一种简化的哈希规则导致的冲突(
index=n%size
) - 哈希冲突是多个元素映射到同一个坐标,这种冲突是不可避免的(但是期望避免,或规避冲突)
- 哈希冲突的避免方案(冲突处理)
冲突处理
设计冲突规则有4种方式:
- 开放定址法
- 再哈希法
- 建立公共溢出区
- 链式地址法(拉链法、自行设计哈希表时建议采用这个方式应对冲突)
开放定址法
当某个地址被使用(冲突)时,将数据试图映射为该地址后方的空闲地址上。试图映射的规则有线性或指数两种,其中线性选择等间距试图映射,而指数则是幂指数间距试图映射。(线性被称为线性探测法,幂指数则称为二次再散列因为常用2的指数作为间距)而这些映射规则的使用是很灵活的,一般来说会选择简单的规则。
但二者思路一致都是试图映射为后方的闲置地址。
再哈希法
当冲突时,试图使用另一套哈希规则来映射地址,如果再失败则再换一套,这种换用其他哈希函数的方式就是再哈希法。
(但是这种方式能提供的映射是有限的,因为准备的哈希规则是有限的。因此虽然这种方式能极大的减少冲突率,但是需要配合剩下3种规则进行使用)
(因此自行设计哈希表时不建议使用)
公共溢出区
当冲突时,将新数据存入公共溢出区。这部分公共溢出区可以采用任何一种合适的的数据结构进行存储,比如再建一个哈希表,或者红黑树等。
这个方法和其余方法的不同之处在于将冲突的数据交给了额外的空间。
链式地址法
链式地址法中,哈希表的每一个位置存储的都是链表的头节点(当冲突时,就延长链表)。由于采用了链表结构一定程度上其实相当于重新进行升维。
扩容哈希表
当一个哈希表过分频繁的出现哈希冲突时,需要进行哈希表的扩容(或者觉得要扩容就扩容),一般扩容哈希表是扩容一倍。
哈希表的时间复杂度为均摊时间复杂度o(1),原理在于哈希表当前大小为n时,假设是通过扩容到达当前大小的,则扩容次数解决n,因此每个元素均摊的时间复杂度为1。
扩容时执行的具体操作为将旧数据映射到新空间,再将新空间覆盖旧空间
设计简易哈希表
(在下方设计的哈希表中,存在一个问题,只进行了数据的存取,但没有做存入数据的删除,这个课上没讲,跳过考虑)
哈希表的执行核心操作:
- 插入时,如果当前位置没有有元素时,就进行插入;否则执行冲突处理规则。
- 查找时,如果当前位置有元素,且等于待查找的元素时,进行返回;否则去公共溢出去区内寻找
- 扩容时,旧表内的数据存入新区,并将冲突规则处理的元素也试图存入新区
(以拉链法举例)
class MyHashTable {
private Node [] data ;
private int size ;
private int nowDataLen;
public MyHashTable () {
data = new Node [16];
size = data.length;
nowDataLen = 0;
}
public MyHashTable (int len) {
data = new Node [len];
size = data.length;
nowDataLen = 0;
}
private int hash_func(String str) {
//自定义哈希规则
char [] c = str.toCharArray();
int seed = 131,hash =0;
for(int i=0;i<c.length;i++) {
hash = hash*seed + c[i];
}
return hash & 0x7fffffff;
}
public void expand() {
int n = size*2;
MyHashTable ht = new MyHashTable(n);
for(int i = 0;i < size;i++) {
Node node = data[i];
if(node!=null) {
node= node.next;
while( node !=null) {
ht.insert(node.data);
node= node.next;
}
}
}
data = ht.data;
size = data.length;
}
public void insert(String str) {
Node wantFind = find(str);
if(wantFind != null) return;
int key = hash_func(str);
int index = key % size;
if(data[index] == null)
data[index] = new Node();
data[index].insertNext(new Node(str,null));
nowDataLen ++;
if(nowDataLen>size*3) //自定义规则为负载达到3倍进行一次扩容
expand();
}
public boolean contains(String str) {
Node wantFind = find(str);
if(wantFind == null)
return false;
return true;
}
private Node find(String str) {
if(str == null) return null;
int key = hash_func(str);
int index = key % size;
Node wantSet = data[index];
if(wantSet==null){
return null;
}else{
while(wantSet!=null){
if(str.equals(wantSet.data))
return wantSet;
wantSet = wantSet.next;
}
}
return null;
}
}
class Node{
public String data;
public Node next;
public Node() {
}
public Node(String data,Node next){
this.data = data;
this.next = next;
}
public void removeNext (){
if(next == null)return;
Node p = next;
next = p.next;
}
public void insertNext (Node newNode){
newNode .next = next;
next = newNode;
}
}
总结
设计哈希表有2个要点:
- 设计哈希规则(不同数据,不同情况,适用不同的哈希函数)(但都期望到达最大的空间利用率)
- 设计冲突处理(同理,不同情况适用不同的冲突规则)
设计冲突规则有4种方式:
- 开放定址法
- 再哈希法
- 建立公共溢出区
- 链式地址法(拉链法、自行设计哈希表时建议采用这个方式应对冲突)
开放定址法和再哈希法都是在原数据范围内进行降低冲突的概率,建立公共溢出区是将冲突出的内容交给其他规则处理,链式地址法是重新进行一次升维从而解决降维导致的冲突。
综上:
哈希表是一套基于数组的性质采用哈希思想进行地址映射实现均摊时间性能为o(1)的设计感极强的数据存储结构,使用过程中需要配合冲突规则,扩容规则规避哈希冲突。
布隆过滤器
利用哈希规则实现:确保某一个需要判断的数据是否出现过。
对比哈希表
哈希表是用判断某存储的数值是否存在时使用的,哈希表是必须确保知道某个数据存在或不存在,并能够获取到存储的内容。对于降维操作引起的地址冲突,哈希表需要设计冲突规则并且根据情况需要进行扩容。综上:
哈希表的存储空间与元素数量有关。
而布隆过滤器是用于判断某数据是否一定不存在,因此容许了哈希操作导致的地址冲突,并且也不需要获取存储数值,所以在实现上更为简单,并且也不需要进行扩容,但要进行清空(如果不清空,还是得扩容,否则过滤性能会下降)。综上:
布隆过滤器的存储空间与元素数量无关。
应用场景举例
爬虫代码从网址中获取信息时,不期望从相同的网址获取信息,因此需要一个规则去规避已经经过的网址。
如果采用哈希表可以通过存储url来进行判断,但是随着网页的发展,URL的信息变的越发丰富,继续采用哈希表会造成大量的空间开销。而需求仅是为了避免重复,对于存储的内容实际上没有什么需求。
而用布隆过滤器就确保通过判定的都是没有经过的网址,并且布隆过滤器并不存储具体的URL,从而极大的减少了空间的使用,虽然准确性不如哈希表