散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
为什么需要散列表?
如果忽略内存,我们将键作为数组的索引,那么所有的查找查找操作只需要访问一次内存即可。当键很多时,这需要太大的内存。
散列的主要目的是将键均匀分布,因此散列后键就是无序的。
散列算法分为两步:
第一步用散列函数将键转化为数组的索引。这可能导致多个键都得到相同的索引。
第二部就是处理碰撞冲突。
散列的查找算法:
散列函数:如果我们有一个能保存M个键值对的数组,就需要一个可以将任意键转化为该数组范围内的索引([0,M-1]范围的整数)的散列函数。
散列函数和键的类型有关,严格说,对于每种类型的键我们都需要一个与之对应的散列函数。
优秀的散列方法需要满足三个条件:
- 一致性(等价的键必然产生相等的散列值)
- 高效性(计算简便)
- 均匀性(均匀地散列所有的键)
解决碰撞的方法:
- 拉链法
- 线性探测法
拉链法
基于拉链法的散列表:它是将大小为M的数组中的每一个元素都指向一条链表,链表中的每个节点都存储了散列值为该元素的索引的键值对。
查找算法:先根据散列值找到对应的链表,然后沿链表顺序查找相应的值。
我们使用M条链表来保存N个键,那么链表的平均长度为N/M。
hash:这里使用默认的hashCode()方法
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
& 0x7fffffff (第一位是0,后面31个1)可以屏蔽符号位。
为什么需要屏蔽符号位,而不是之间key.hashCode % m :因为Java取余的结果可能为负数。(例如-4 % 3 = -1)
% m 保证索引值在[0,M-1]之间。
为了防止链表过长,导致查找和插入成本过高,这里我们把链表的平均长度(N/M)限制在2~10之间。
我们使用重用之前实现的链表(SequentialSearchST)。无序链表
实现代码:
import java.util.LinkedList;
import java.util.Queue;
/**
* @author yuan
* @date 2019/2/28
* @description 基于拉链法的散列表
*/
public class SeparateChainingHashST<Key, Value> {
/**
* 键值对总数
*/
private int n;
/**
* 散列表大小
*/
private int m;
/**
* 存放链表对象的数组
*/
private SequentialSearchST<Key, Value>[] st;
private static final int DEFAULT_CAPACITY = 4;
public SeparateChainingHashST(){
this(DEFAULT_CAPACITY);
}
public SeparateChainingHashST(int m) {
this.m = m;
st = 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 int size(){
return n;
}
public boolean isEmpty(){
return size() == 0;
}
public Value get(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to get() is null");
}
return st[hash(key)].get(key);
}
public void put(Key key, Value value) {
if (key == null) {
throw new IllegalArgumentException("first argument to put() is null");
}
if (value == null) {
delete(key);
return;
}
// 如果 n / m (链表的平均长度) >= 10
if (n >= 10 * m) {
resize(2 * m);
}
int i = hash(key);
if (!contains(key)) {
++n;
}
st[i].put(key, value);
}
private boolean contains(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to contains() is null");
}
return get(key) != null;
}
private void resize(int chains) {
SeparateChainingHashST<Key, Value> temp = new SeparateChainingHashST<>(chains);
for (int i = 0; i < m; i++) {
for (Key key : st[i].keys()) {
temp.put(key, st[i].get(key));
}
}
this.m = temp.m;
this.n = temp.n;
this.st = temp.st;
}
private void delete(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to delete() is null");
}
int i = hash(key);
if (contains(key)) {
--n;
}
st[i].delete(key);
// 如果 链表平均长度(n / m) <= 2
if (m > DEFAULT_CAPACITY && n <= 2 * m) {
resize(m / 2);
}
}
public Iterable<Key> keys(){
Queue<Key> queue = new LinkedList<>();
for (int i = 0; i < m; i++) {
for (Key key : st[i].keys()) {
queue.offer(key);
}
}
return queue;
}
public static void main(String[] args) {
SeparateChainingHashST<String, Integer> st = new SeparateChainingHashST<>();
st.put("ccc", 2);
st.put("bbb", 3);
st.put("aaa", 1);
st.put("ddd", 1);
for (String s : st.keys()) {
System.out.println(s + " " + st.get(s));
}
System.out.print("keys = ");
st.keys().forEach(s -> System.out.print(s + ","));
System.out.println();
System.out.println(st.contains("aaa")); // true
System.out.println(st.contains("cda")); // false
}
}
线性探测法
线性探测法就是用大小为M的数组保存N个键值对,其中M>N。依靠数组中的空位解决碰撞冲突。
基本思想:当键的散列值发生冲突时,直接检查散列表中的下一个位置(将索引值加1),检查其中的键和被查找的键是否相同,如果不同则继续查找(索引增大),直到找到该键或遇到一个空元素。
删除操作
注意,不能直接将该键设为null,因为这样会导致后面的键无法被查找。
我们需要将被删除的键的右侧的所有键重新插入散列表。
键簇(cù)
就是元素插入数组后形成的一条连续的条目。
调整数组大小
当数组使用率(N/M)小于1/2时,查找次数只在1.5到2.5之间。(具体参考算法第4版)
这里我们使散列表的使用率不超过1/2。
代码:
import java.util.LinkedList;
import java.util.Queue;
/**
* @author yuan
* @date 2019/2/28
* @description 基于线性探测法的散列表
*/
public class LinearProbingHashST<Key , Value> {
private static final int DEFAULT_CAPACITY = 4;
/**
* 符号表中键值对总数
*/
private int n;
/**
* 线性探测表大小
*/
private int m;
/**
* 键
*/
private Key[] keys;
/**
* 值
*/
private Value[] vals;
public LinearProbingHashST(){
this(DEFAULT_CAPACITY);
}
public LinearProbingHashST(int capacity) {
m = capacity;
n = 0;
keys = (Key[]) new Object[m];
vals = (Value[]) new Object[m];
}
public int size(){
return n;
}
public boolean isEmpty(){
return size() == 0;
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
private void resize(int capacity) {
LinearProbingHashST<Key, Value> temp = new LinearProbingHashST<>(capacity);
for (int i = 0; i < m; i++) {
if (keys[i] != null) {
temp.put(keys[i], vals[i]);
}
}
keys = temp.keys;
vals = temp.vals;
m = temp.m;
}
/**
* 插入
* @param key
* @param val
*/
public void put(Key key, Value val) {
if (key == null) {
throw new IllegalArgumentException("first argument to put() is null");
}
if (val == null) {
// 值为null,则删除对应的键
delete(key);
return;
}
// 如果使用率大于1/2,扩大数组
if (n >= m / 2) {
resize(2 * m);
}
int i;
for (i = hash(key); keys[i] != null; i = (i + 1) % m) {
if (keys[i].equals(key)) {
// 如果键存在,更新
vals[i] = val;
return;
}
}
// 键不存在
keys[i] = key;
vals[i] = val;
++n;
}
/**
* 删除,需要将被删除键的右侧的所有键重新插入散列表
* @param key
*/
public void delete(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to delete() is null");
}
if (!contains(key)) {
return;
}
int i = hash(key);
while (!key.equals(keys[i])) {
i = (i + 1) % m;
}
// 找到要删除的键
keys[i] = null;
vals[i] = null;
i = (i + 1) % m;
// 将被删除的键的右侧重新插入
while (keys[i] != null) {
Key keyToRehash = keys[i];
Value valToRehash = vals[i];
keys[i] = null;
vals[i] = null;
--n;
put(keyToRehash, valToRehash);
i = (i + 1) % m;
}
// 减去被删除的键
--n;
// 如果使用率为1/8,缩小数组
if (n > 0 && n == m / 8) {
resize(m / 2);
}
}
/**
* 获取
* @param key
* @return
*/
public Value get(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to get() is null");
}
for (int i = hash(key); keys[i] != null; i = (i + 1) % m) {
if (keys[i].equals(key)) {
return vals[i];
}
}
return null;
}
public boolean contains(Key key) {
if (key == null) {
throw new IllegalArgumentException("argument to contains() is null");
}
return get(key) != null;
}
public Iterable<Key> keys(){
Queue<Key> queue = new LinkedList<>();
for (int i = 0; i < m; i++) {
if (keys[i] != null) {
queue.offer(keys[i]);
}
}
return queue;
}
public static void main(String[] args) {
LinearProbingHashST<String, Integer> st = new LinearProbingHashST<>();
st.put("ccc", 2);
st.put("bbb", 3);
st.put("aaa", 1);
st.put("ddd", 1);
for (String s : st.keys()) {
System.out.println(s + " " + st.get(s));
}
System.out.print("keys = ");
st.keys().forEach(s -> System.out.print(s + ","));
System.out.println();
System.out.println(st.contains("aaa")); // true
System.out.println(st.contains("cda")); // false
}
}
、
Java的TreeMap就是基于红黑树实现的。
Java的HashMap是基于拉链法的散列表实现的。