数据结构与算法--散列表
之前学习了基于链表的顺序查找、基于有序数组的二分查找、二叉查找树、红黑树,这些算法在查找某个key时,都免不了在所有键值对中一一比较或者折半比较。有没有办法通过key直接定位到value呢?好比你要去隔壁班找你的好朋友二狗,你可以挨桌子一个个找,也可以站在讲台上一排排扫视,这样的查找速度好比顺序查找和二分查找;当然你可以站在教室外面,托个同学叫他出来,这样你就直接找到他了。比喻可能不太恰当,不过只要意识到这是一种比顺序查找和二分查找都要直接要快的方法就好了,它就是即将要学习的散列表。
散列表顾名思义,元素的位置是分散且无序的,因此散列表并不适合范围查找。
如果将键作为数组的索引,那么所有查找只需访问一次内存即可完成,所以使用散列技术的算法第一步是将各种类型的键通过一个函数映射成非负整数(数组索引非负),这个整数称为散列值。这个函数称为散列函数。理想情况下,不同的键能转化成不同的散列值;然而也存在多个键转化成了相同的散列值的情况,此时发生了碰撞冲突,因此散列算法的第二步就是处理碰撞冲突。一般解决方法是拉链法和线性探测法。
键的种类各种各样,我们都需要将其转化为一个非负整数。如果键是非负int型,我们可以直接将键作为数组索引——即散列值f(key) = key,但是键可能非常大,比如1,000,000,难道要使用占用空间这么大的数组吗?我们希望对于任意键都能将其散列到一个固定的区间。一个简单但应用广泛的方法是除留余数法。即对于任意非负整数k,k % M一定将键有效地分布在了[0, M-1]的范围内。这里M的选用最好是素数,能够有效利用键中包含的信息,更容易均匀的散列散列值。
如上图所示,M=97比M=100时散列得更均匀,因此碰撞出现的几率也会低一些。
如果键浮点数,可以将浮点数转化成二进制数后再使用除留余数法。
如果键是字符串,因为字符串中每个字符都对应一个ASCII码,在Java中通过charAt
方法可以返回一个非负16位整数(即无符号的两个字节)。可以用一个线性函数将所有字符都利用起来得到一个值,最后再利用除留余数法得到散列值。
总之对于各种各样的类型,我们总有办法将其转化成一个非负整数。当然在Java中就更简单了:Java为每种类型都定义了hashCode方法,表示每个对象的内存地址,这个地址是有符号的32位整数,为了得到非负整数只需屏蔽最高位的符号位取余下的31位即可,然后再使用除留余数法得到散列值,如下
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
一个优秀的散列函数需要满足以下条件:
- 一致性——等价的键必然产生相等的散列值
- 高效性——计算简单
- 均匀性——均匀地散列所有的键
不过使用Java内置库的hashCode方法,以上条件都可以不用我们去操心——它已经足够优秀了。
关于hashCode方法,补充一句。hashCode方法必须和equals方法逻辑一致。也就是说如果a.equals(b)
,必须保证a.hashCode() == b.hashCode()
,这表示两个相同的键其散列值也相等;但是反过来,如果两个对象的hashCode相同,不一定有a.equals(b)
,这表示发生了碰撞冲突,必须通过equals方法来比较,不过如果两个对象的hashCode不相同,这两个对象一定不相同。
基于拉链法的散列表
拉链法的思想:将键转换成数组索引,如果键不冲突在数组中的索引自然也不一样;如果发生了碰撞,只需在该索引的链表中查找即可。于是拉链法的数据结构就很简单了,建立一个大小为M或者可调整大小的数组,每个索引都拥有一条链表,每个索引对应的链表里的键散列值全都是相同的——换句话说,只要遇到碰撞,比如多个键散列值都是k,就将这些键统统插入到数组索引为k的那条链表中。这实现起来很简单,只需让每个索引都关联一个基于链表的顺序查找符号表即可,这个符号表以前就写过了,直接拿来用。
拉链法中,使用M个索引存放N个键值对,通常来说N比M要大,因此链表的平均长度为N / M,即使是在最坏情况——所有键散列值都相同,那么这个所谓的散列表也变成了普通的顺序查找符号表——链表的平均长度依然是(N+0+0...+0) / M = N / M,因此可以说在平均情况下插入和未命中的查找所需的比较次数为~N/M。
实现很简单,如下
package Chap8;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public class SeparateChainingHashST<Key, Value> {
private int M; // 散列表的大小
private int N; // 键值对的个数
private SequentialST<Key, Value>[] st; // 存放链表的数组
public SeparateChainingHashST(int M) {
this.M = M;
st = (SequentialST<Key, Value>[]) new SequentialST[M];
// 每个索引都有一条链表
for (int i = 0; i < st.length; i++) {
st[i] = new SequentialST<>();
}
}
public SeparateChainingHashST() {
this(31);
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public void put(Key key, Value value) {
if (N >= M / 2) {
resize(2 * M);
}
st[hash(key)].put(key, value);
N++;
}
public Value get(Key key) {
return st[hash(key)].get(key);
}
public Value delete(Key key) {
Value value = st[hash(key)].delete(key);
N--;
if (N > 0 && N <= M / 8) {
resize(M / 2);
}
return value;
}
// 每条链表中的键都加到一个集合中
public Set<Key> keys() {
Set<Key> keys = new HashSet<>();
for (int i = 0; i < M; i++) {
keys.addAll(st[i].keys());
}
return keys;
}
public boolean isEmpty() {
return N == 0;
}
public boolean contains(Key key) {
return st[hash(key)].contains(key);
}
public int size() {
return N;
}
private void resize(int max) {
SeparateChainingHashST<Key, Value> temp = new SeparateChainingHashST<>(max);
// 所有键值对重新插入,可保证每个索引的链表长度保持在一个小的常数范围内
for (Key key : keys()) {
temp.put(key, get(key));
}
// 更新散列表的大小
M = temp.M;
// 更新散列表
st = temp.st;
}
@Override
public String toString() {
Iterator<Key> keys = keys().iterator();
if (isEmpty()) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append("{");
while (true) {
Key key = keys.next();
sb.append(key).append("=").append(get(key));
if (!keys.hasNext()) {
return sb.append("}").toString();
} else {
sb.append(", ");
}
}
}
public static void main(String[] args) {
SeparateChainingHashST<String, Integer> a = new SeparateChainingHashST<>();
a.put("a", 1);
a.put("b", 2);
a.put("c", 3);
a.put("d", 4);
a.delete("c");
System.out.println(a.keys());
System.out.println(a.size());
System.out.println(a);
}
}
我们采用了可动态调整大小的数组,实际上如果你知道将要插入的键值对的大概值,那么直接在初始化的时候选定一个合适的M值就可以了,不仅能保证容量足够还能使得散列更加均匀,因此数组容量的调整就不必要了。如果你不清楚插入的键值对的多少,为了防止数组脚标越界,可以采用上面的实现,具体来说是新建一个SeparateChainingHashST
将所有键值对重新插入到这个新的散列表中,之后更新st和M的值即可。这样可保证链表的平均长度为2到8之间(代码中调整大小的参数为M / 2和8 / M),一言以蔽之:调整数组大小在拉链法中并不是必须的。
上面的各个方法,只是简单地间接调用SequentialST
的各个方法而已,没有什么难度,就不再解释了。
基于线性探测法的散列表
实现散列表的另一种方式使用大小为M的数组保存N个键值对,其中M > N,这保证了数组中总是有空位,利用这些空位解决碰撞冲突,基于此策略的所有方法称作开放地址散列表。
开放地址散列表中最简单的方法叫线性探测法,当发生碰撞时,直接检查数组的下一个位置是不是空位,如果是就存放于此;如果不是空位继续看下一个位置;当检查完数组最后一个元素时,折返回数组的开头继续检查是否有空位。因此这些空位也是查找结束的标志。千万不可让键值对充满整个数组,这会因为没有空位导致查找不会停止,陷入死循环。
键值对在数组中的存放情况如下图所示,可以看到有很多空位。
实现也不难,像二分查找中那样,使用了并行数组keys[]
和values[]
分别存放键和值。
package Chap8;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public class LinearProbingHashST<Key, Value> {
private int N; // 键值对的个数
private int M; // 散列表的大小
private Key[] keys;
private Value[] values;
public LinearProbingHashST(int cap) {
M = cap;
keys = (Key[]) new Object[cap];
values = (Value[]) new Object[cap];
}
public LinearProbingHashST() {
this(31);
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public void put(Key key, Value value) {
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)) {
values[i] = value;
return;
}
}
// 若干位置后的第一个空位,插入新键值对
keys[i] = key;
values[i] = value;
N++;
}
public Value get(Key key) {
// 碰撞冲突,看下一个位置,如果这个过程中发现键已经存在,则更新并直接返回
for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (keys[i].equals(key)) {
return values[i];
}
}
return null;
}
public Value delete(Key key) {
Value value = null;
int i = hash(key);
while (keys[i] != null) {
if (keys[i].equals(key)) {
value = values[i];
break;
}
i = (i + 1) % M;
}
// 找到键了,删除键值对
keys[i] = null;
values[i] = null;
// 删除后,这条键簇中,i之后的键值对都需要重新插入
// 因为get方法终止循环的条件是keys[i] != null,删除后键簇中那个位置是空位,之后的键都访问不到了
i = (i + 1) % M; // i之后的第一个位置
// 对这条键簇i之后的进行重新插入
while (keys[i] != null) {
Key keyRedo = keys[i];
Value valueRedo = values[i];
// 删了再插入
keys[i] = null;
values[i] = null;
N--;
put(keyRedo, valueRedo);
i = (i + 1) % M;
}
N--;
if (N > 0 && N <= M / 8) {
resize(M / 2);
}
return value;
}
private void resize(int max) {
LinearProbingHashST<Key, Value> temp = new LinearProbingHashST<>(max);
// 所有键值对重新插入,可保证每个索引的链表长度保持在一个小的常数范围内
for (int i = 0; i < M; i++) {
if (keys[i] != null) {
temp.put(keys[i], values[i]);
}
}
// 更新散列表的大小
M = temp.M;
// 更新散列表的键和值
keys = temp.keys;
values = temp.values;
}
public Set<Key> keys() {
Set<Key> keySet = new HashSet<>();
for (int i = 0; i < M; i++) {
if (keys[i] != null) {
keySet.add(keys[i]);
}
}
return keySet;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
public boolean contains(Key key) {
return get(key) != null;
}
@Override
public String toString() {
Iterator<Key> keys = keys().iterator();
if (isEmpty()) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append("{");
while (true) {
Key key = keys.next();
sb.append(key).append("=").append(get(key));
if (!keys.hasNext()) {
return sb.append("}").toString();
} else {
sb.append(", ");
}
}
}
public static void main(String[] args) {
LinearProbingHashST<String, Integer> a = new LinearProbingHashST<>();
a.put("a", 1);
a.put("b", 2);
a.put("c", 3);
a.put("d", 4);
a.delete("c");
System.out.println(a.keys());
System.out.println(a.size());
System.out.println(a);
}
}
同样采用了可调整大小的数组方案,我们将看到,这种做法在线性探测法中尤为重要。
键簇
元素在插入数组中会聚集成一条条连续的条目,也叫做键簇。例如在上图中,插入键C就产生了一个长度为3的键簇(ACS),之后要插入H需要探测4次,因为H的散列值和A相同,需要从此处开始往后探测数组中的空位,可以预见,键簇越长,插入和查找效率越低,因此保证短小的键簇能提高效率,所以在适当时候对数组调整容量变得尤为重要。α = N / M
表示数组的使用率,上面代码实现中保证了数组使用率始终维持在1 / 8 ~ 1 / 2,因此产生的键簇不会很长。下图可以反映数组使用率对键簇长度的影响。
在查找中也是一样,根据给定的key得到散列值,在数组中从该散列值开始往后探测,直到遇到空位,此时有一个问题,需要跳过空位和后面的键继续比较吗?答案是否定的,根据插入时候的规律,新插入的键key总是和key的散列值所在位置处于同一条键簇(因为它是沿着这条键簇直到末端遇到空位才插入的),因此查找结束于这条键簇的末端就行了,后面的键肯定不会包含这个要查找的键。
然后是删除操作,仅仅把该键所在位置的键值置null是不够的,因为get操作是沿着键簇进行的,在这条键簇上直到遇到null查找结束。因此如果单纯把该键的位置置null,那么同在这条键簇的该键位置之后所有键都访问不到了。仔细理解下这句话。举个例子,比如我们删除键C之后查找H,H的散列值是4需要从A开始探测,对于ACSH这条键簇,C现在是null了,因此查找结束于此,根本就get不到H。为此,需要将处于同一条键簇中被删除键的位置之后的所有键重新插入一次。这些话理解了,再回头看delete方法应该就一目了然了。
by @sunhaiyu
2017.10.23
网友评论