请先参阅深入理解散列表
散列表的另一种实现
用大小为M的数组保存N个键值对,其中M>N。我们需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。
开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),我们直接检测散列表的下一个位置(将索引加一)。这样的线性探测可能会产生三种情况:
- 命中,该位置的键和被查找的键相同;
- 未命中,键为空(该位置没有键);
- 继续查找,该位置的键和被查找的键不同
我们用散列函数找到键在数组中的索引,检查其中的键和被查找的键是否相同。如果不同则继续查找(将索引增大,达到数组结尾时折回数组的开头),直到找到该键或者遇到一个空元素,
如下图。我们习惯将检查一个数组位置是否含有被查找的键的操作称为探测。在这里它可以等价于我们一直使用的比较,不过有些探测实际上是在探测键是否为空。
核心思想
开放地址类的散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表的空元素。这些空元素可以作为查找结束的标志。
在下面的实现中使用了并行数组,一条保存键,一条保存值,并像前面讨论的那样使用散列函数产生访问数据所需的数组索引。
public
这段符号表的实现将键和值分别保存在两个数组中(与BinarySerachST类型中一样),使用空(标记为null)来表示一簇键的结束。
如果一个新键的散列值是一个空元素,那么就将它保存在那里;如果不是,我们就顺序查找一个空元素来保存它。
要查找一个键,我们从它的散列值开始顺序查找,如果找到则命中,如果遇到空元素则未命中。
删除操作
如何从基于线性探索的散列表中删除一个键?我们仔细想一想就会发现,直接将该键所在的位置设为null是不行的,因为这会使得在此位置之后的元素无法被查找。
我们还可以再看上图,假设在轨迹图的例子中我们需要用这种方法删除键C,然后查找H。H的散列值为4,但它实际存储在这一簇键的结尾,即7号位置。如果我们将5号位置设为null,get()方法将无法找到H。因此我们需要将簇中被删除键的右侧的所有键重新插入散列表。
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException("argument to delete() is null");
if (!contains(key)) return;
// find position i of key
int i = hash(key);
while (!key.equals(keys[i])) {
i = (i + 1) % m;
}
// delete key and associated value
keys[i] = null;
vals[i] = null;
// 重新散列所有键
i = (i + 1) % m;
while (keys[i] != null) {
// delete keys[i] an vals[i] and reinsert
Key keyToRehash = keys[i];
Value valToRehash = vals[i];
keys[i] = null;
vals[i] = null;
n--; //此处应为减减,显示错误
put(keyToRehash, valToRehash);
i = (i + 1) % m;
}
n- -; //此处应为减减,显示错误
// 减小数组的大小
if (n > 0 && n <= m/8) resize(m/2);
assert check();
}
和拉链法一样,开放地址类的散列表的性能也依赖于
α = N/M //比值,
但意义有所不同。我们将α称为散列表的使用率。
- 对于基于拉链法的散列表,α是每条链表的长度,因此一般大于1;
- 对于基于线性探测的散列表,α是表中已被占用空间的比例,它是不可能大于1的。
事实上在LinearProbingHashST中我们不允许α达到1(散列表被沾满),因为此时未命中的查找会导致无限循环。为了保证性能,我们会动态的调整数组的大小来保证使用率在1/8到1/2之间。
键簇
线性探索的平均成本取决于元素在插入数组后聚集成的一组连续的条目,也叫键簇。例如,在示例中插入键C会产生一个长度为3的键簇(A C S)。这意味着插入H需要探测4次,因为H的散列值为该键簇的第一个位置,显然,短小的键簇才能保证较高的效率。随着插入的键越来越多,这个要求很难满足,较长的键簇会越来越多。
调整数组大小
private void resize(int capacity) {
LinearProbingHashST<Key, Value> temp = new LinearProbingHashST<Key, Value>(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;
}
我们可以通过调整数组的大小来保证散列表的使用率永远不会超过1/2。
首先,我们的LinearProbingHashST需要一个新的构造函数,它接受一个固定的容量作为参数。然后,我们需要上面的resize()方法。它会创建一个新的给定大小的LinearProbingHashST,保存原表中的keys和values变量,然后将原表中所有的键重新散列并插入到新表中。这样可以使我们的数组长度发生改变。
put()方法的第一句会调用resize()来保证散列表最多为半满状态。
if( n >= m/2 ) resize( 2 * m )
当然在散列表过大时,我们在delete()时也需要
if ( n > 0 && n <= m/8 ) resize( m/2 );
以保证所使用的内存量和表中的键值对数量的比例总在一定范围内。动态调整数组大小可以为我们保证α不大于1/2。
小小总结:
拉链法和线性探测法的详细比较取决于实现的细节和用例对空间和时间的要求。即使基于性能考虑,选择拉链法而非线性探索法也不一定是合理的。
在实践中,两种方法的性能差别主要是因为拉链法为每个键值对都分配了一小块内存而线性探测则为整张表使用了两个很大的数组。对于很大的散列表,这些做法对内存管理系统的要求也很不相同。
期望散列表能够支持和数组大小无关的常数级别的查找和插入操作时可能的,对于任意的符号表实现,这个期望都是理论上最优性能。
但散列表并非包治百病,因为:
- 每种类型的键都需要一个优秀的散列函数
- 性能保证来自于散列函数的质量
- 散列函数的计算可能复杂而且昂贵
- 难以支持有序性相关的符号表操作。