查找系列合集-散列表
一、散列表
【问题】之前我们的用红黑树实现了O(logN)的查找算法,那么理论上有没有O(1)的查找算法呢?
【分析】除非我们能够单凭键值key就能确定该元素在集合中的位置,直接将其取出
【解决方法】不妨采取映射的方法,将键值k1 k2 ......kn映射到 0 1 2 3 ......n-1,也就是数组下标的位置,那么如何完成这种映射呢?
【哈希】
- 哈希方法也就是对给定的某一个值,采用一定的换算方法,将其映射为一个整数
- Java内部为每一个内置数据类型都设计了hash函数,能够返回一个32bit整数
二、 散列函数
1. 散列函数即哈希函数
2. 可以自己为某种数据类型设计一种哈希函数代替内置的hash函数
- 如一种对象包含3个域 x1 x2 x3 ,则可以设计其hash函数为 int hash = (x1 * r + x2) * r + x3
- 为了不使数据溢出,可以把常数r设置得比较小,并在每一步运算时加上对M取模
3. 基于内置hash函数定义
- 可以对任意key先返回其系统定义的hash值,然后再对M取模,在基础上将其范围限定到0 ~ M-1,防止数组越界
4. 碰撞
- 当两个不同的key所得出的hash值相同,则认为发生了碰撞,因为他们都想要占据这个位置
- 当发生碰撞时,后来者只能再寻求其他位置,解决方法有线性探测(即往后继续查找空位,见缝插针),二次探测再散列(加减一个值的平方后再看看有没有空位)
- 具体解决碰撞的方案
三、基于拉链法的散列表
1. 核心思想是避开了解决hash碰撞的需要,对于每一个hash值(0~M-1),其都拉出了一个链表,把所有hash值都等于它的键存在了这个链表之中
2. 当要寻找一个key时,只要先求出hash值,然后在该hash值所对应的链表里顺序查找
【实现】
package search; public class SeparateChainingHashST<Key , Value> { private int N; //键值对总数 private int M;//散列表的大小 private SequentialSearchST<Key, Value>[] st; public SeparateChainingHashST() { // TODO Auto-generated constructor stub this(997); } //初始化,为每一个hash值都创建一个链表 M个 public SeparateChainingHashST(int M) { // TODO Auto-generated constructor stub this.M = M; st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[M]; for(int i=0; i<M; i++) { st[i] = new SequentialSearchST(); } } private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; } public Value get(Key key) { return (Value)st[hash(key)].get(key); } public void put(Key key, Value val) { st[hash(key)].put(key, val); } public static void main(String[] args) { // TODO Auto-generated method stub } }
package search; //链表,内部类 public class SequentialSearchST<Key, Value>{ public SequentialSearchST() { super(); // TODO Auto-generated constructor stub } private Node head;//链表头指针 //结点类 内部类 private class Node{ Key key; Value val; Node next;//下一个结点指针 public Node() {} public Node(Key key, Value val, Node next) { this.key = key; this.val = val; this.next = next; } } //顺序查找 public Value get(Key key) { Node x = head; while(x != null) { if(x.key.equals(key)) { return x.val; } x = x.next; } return null; } //尾插法 public void put(Key key, Value val) { //先考虑key存在的情况 Node x = head; while(true) { if(x.key.equals(key)) { x.val = val; return; } //如果下一个结点不为空,则继续向下迭代。为空,则直接把新节点插入即可 if(x.next != null) x = x.next; else { x.next = new Node(key, val, null); } } } //头插法 public void putt(Key key, Value val) { Node x = head; //先搜索一遍防止键 存在 while(x != null) { if(x.key.equals(key)) { x.val = val; return; } x = x.next; } //如果 不存在则将该新节点指向旧的头指针, 新头指针指向 它 head = new Node(key, val, head); } }
四、基于线性探测法的散列表
1.主要思想: 当前位置发生了碰撞便找下一个位置,以此类推,直到找到空位置
2. ADT
public class LinearProbingHashST<Key , Value> { private int N = 0;//当前使用量 private int M = 16;//默认容量大小为M //并行数组 private Key[] keys; private Value[] vals; }
3. hash函数
private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; }
4. 插入操作
//插入操作 冲突 循环后移再探测 public void put(Key key, Value val) { //保证使用量不超过额定容量的一半 if(N > M/2) { resize(2*M); } int index = hash(key); for(; keys[index] != null; index = (index+1) % M) { if(keys[index].equals(key)) { vals[index] = val; return ;//如果该键已经存在则改值后 return } } keys[index] = key; vals[index] = val; N++; }
5. 扩容操作
- 当散列表大部分被填充之后,所造成的碰撞概率会大大增加,而且所带来的查找 删除操作成本也会很高
- 为什么不直接拷贝数组而选择重新put呢?因为在新容量下的散列表这些key的hash值已经发生了变化。
- 每当使用容量N到达总容量M的一半时,扩容一倍,这样即使对大规模数据的插入也不会很多次调用resize
private void resize(int cap) { LinearProbingHashST<Key , Value> t; t = new LinearProbingHashST<Key , Value>(cap); for(int i=0; i<M; i++) { if(keys[i] != null) t.put(keys[i], vals[i]); } this.keys = t.keys; this.vals = t.vals; this.M = t.M; }
6. 查找操作
- 引入“长键”的概念,长键简单来说就是连续的键,或者叫键簇,散列表可以看成是多个长键组成的,每个长键之间间隔若干个空值(首尾长键算一个)
- 由于采用的是线性探测法,可以有如下定理:hash值相等的键必然处于同一个长键簇之中
public Value get(Key key) { int index = hash(key); //首先算出hash值得到预期位置,如果正好这个位置的键等于key,那么命中,直接返回对应的val //如果发现key不相等,则说明发生冲突,由插入算法可知,要查找的键必然是和冲突键位于同一组长键之中 //继续查找直到遇到空为止 遇到空说明该键不存在 for(int i = index; keys[i] != null; i = (i+1) % M) { if(keys[i].equals(key)) { return vals[i]; } } return null; }
7. 删除操作
- 根据插入查询定理 目标键与hash值所在的键处于同一个长键之中,中间不能有缝隙
- 如果因为删除一个键后导致中间断裂,会导致查询失效
- 示例 G H KLOMN U 其中 M、L hash值相等 K、N hash值不等 长键簇是KLOMN,删除O后会导致M值无法被查询到
- 所以删除O(置空)后,如果O右边的键和O左边的键有hash值相等的情况时,右边的键必然查询不到
//只能将该【长键中】所删除的键置为空然后将其右边的所有键重新插入 public void delete(Key key) { int index = hash(key); int pos = index; boolean flag = false; for(; keys[index] != null; index = (index + 1) % M) { if(keys[index].equals(key)) { //找到这个键了 把位置记录下来一会用,index继续增加得到边界 flag = true; pos = index; keys[index] = null; vals[index] = null; N--; } } //没找到需要删除的键 就算了 if(!flag) return ; //从pos+1的位置 到 index-1的位置所有键需要重新弄插入 注意位置序号需要取模 pos = pos + 1; pos %= M; while(pos != index) { Key k = keys[pos]; Value v = vals[pos]; keys[pos] = null; vals[pos] = null; put(k , v);//重新插入 pos = (pos + 1) % M;//后移pos位置 } }
package search; import java.util.Random; public class LinearProbingHashST<Key , Value> { private int N = 0;//当前使用量 private int M = 16;//默认容量大小为M //并行数组 private Key[] keys; private Value[] vals; public LinearProbingHashST() { // TODO Auto-generated constructor stub alloc(); } public LinearProbingHashST(int cap) { M = cap; alloc(); } private void alloc() { keys = (Key[])new Object[M]; vals = (Value[]) new Object[M]; } private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; } //插入 查询 定理: 目标键与hash值所在的键 位于同一条长键之中,中间不可能有空隙 public void show() { for(int i=0; i<M; i++) { if(keys[i] != null) System.out.println(keys[i].toString() + ":" +vals[i].toString()); } } //插入操作 冲突 循环后移再探测 public void put(Key key, Value val) { //保证使用量不超过额定容量的一半 //System.out.println("put :" + N + " " + M/2); if(N > M/2) { resize(2*M); // System.out.println("*************************resize*************************" + M); // show(); // System.out.println("**************************************************"); } int index = hash(key); for(; keys[index] != null; index = (index+1) % M) { if(keys[index].equals(key)) { vals[index] = val; return ;//如果该键已经存在则改值后 return } } keys[index] = key; vals[index] = val; N++; } //这种赋值方式不行 思考一下为什么 从hash值的模出发 private void resizes(int cap) { // TODO Auto-generated method stub Key[] ks = (Key[]) new Object[2*cap]; Value[] vs = (Value[]) new Object[2*cap]; for(int i=0; i<M; i++) { ks[i] = keys[i] ; vs[i] = vals[i] ; } keys = ks; vals = vs; M = cap; } private void resize(int cap) { LinearProbingHashST<Key , Value> t; t = new LinearProbingHashST<Key , Value>(cap); for(int i=0; i<M; i++) { if(keys[i] != null) t.put(keys[i], vals[i]); } this.keys = t.keys; this.vals = t.vals; this.M = t.M; } public Value get(Key key) { int index = hash(key); //首先算出hash值得到预期位置,如果正好这个位置的键等于key,那么命中,直接返回对应的val //如果发现key不相等,则说明发生冲突,由插入算法可知,要查找的键必然是和冲突键位于同一组长 键之中 //继续查找直到遇到空为止 遇到空说明该键不存在 for(int i = index; keys[i] != null; i = (i+1) % M) { if(keys[i].equals(key)) { return vals[i]; } } return null; } //根据插入查询定理 目标键与hash值所在的键处于同一个长键之中,中间不能有缝隙 //如果因为删除一个键后导致中间断裂,会导致查询失效 //G H KLOMN U ML hash值相等 KN hash值不等 //只能将该【长键中】所删除的键置为空然后将其右边的所有键重新插入 public void delete(Key key) { int index = hash(key); int pos = index; boolean flag = false; for(; keys[index] != null; index = (index + 1) % M) { if(keys[index].equals(key)) { //找到这个键了 把位置记录下来一会用,index继续增加得到边界 flag = true; pos = index; keys[index] = null; vals[index] = null; N--; } } //没找到需要删除的键 就算了 if(!flag) return ; //从pos+1的位置 到 index-1的位置所有键需要重新弄插入 注意位置序号需要取模 pos = pos + 1; pos %= M; while(pos != index) { Key k = keys[pos]; Value v = vals[pos]; keys[pos] = null; vals[pos] = null; put(k , v);//重新插入 pos = (pos + 1) % M;//后移pos位置 } } public static void main(String[] args) { // TODO Auto-generated method stub LinearProbingHashST<Integer , Integer> hashST; hashST = new LinearProbingHashST<Integer, Integer>(); Random r = new Random(); for(int i=0; i<1500000; i++) { Integer key = new Integer(r.nextInt(10000000)); Integer value = new Integer(r.nextInt(10000000)); //System.out.println("key = " + key + " value = " + value); hashST.put(key, value); //System.out.println("N = " + hashST.N + " M = " + hashST.M); } //System.out.println("put 操作完毕"); hashST.put(88888, 88888); hashST.put(88888, 99999); //System.out.println(hashST.get(88888)); System.out.println("删除前*****************"); //hashST.show(); //hashST.delete(88888); System.out.println("删除后*****************"); //hashST.show(); System.out.println(hashST.get(88888)); System.out.println("OK"); } }
五、 关于散列表的均匀散列假设
- 我们使用的散列函数能够均匀并且独立地将所有的键散布于0到M-1之间
- 在一张大小为M且含有N = α M个键的基于线性探测的散列表中,基于上述假设,查找所需要的探测次数为
- 查找命中时:½( 1+1/(1-α) )
- 查找未命中时: ½( 1+1/(1-α)2)
- 因此一般要使α小一些,通常使它一直小于二分之一