哈希表的想法是您希望能够以有效的方式实现称为字典的数据结构。字典是一种键/值存储,即您希望能够将某些对象存储在某个键下,然后能够使用相同的键再次检索它们。
访问值的最有效方法之一是将它们存储在数组中。例如,我们可以实现一个字典,它使用整数作为键,使用字符串作为值,如下所示:
String[] dictionary = new String[DICT_SIZE];
dictionary[15] = "Hello";
dictionary[121] = "world";
System.out.println(dictionary[15]); // prints "Hello"
不幸的是,这种方法根本不是很通用:数组的索引必须是整数值,但理想情况下,我们希望能够为我们的键使用任意种类的对象,而不仅仅是整数。
现在,解决这个问题的方法是将任意对象映射到整数值,然后我们可以将其用作数组的键。在 Java 中,这就是 hashCode() 所做的。所以现在,我们可以尝试实现一个 String->String 字典:
String[] dictionary = new String[DICT_SIZE];
// "a" -> "Hello"
dictionary["a".hashCode()] = "Hello";
// "b" -> "world"
dictionary["b".hashCode()] = "world";
System.out.println(dictionary["b".hashCode()]); // prints world
但是,嘿,如果我们想将某个对象用作键,但其hashCode 方法返回的值大于或等于DICT_SIZE,该怎么办?然后我们会得到一个 ArrayIndexOutOfBoundsException ,这是不可取的。所以,让我们把它做得尽可能大,对吧?
public static final int DICT_SIZE = Integer.MAX_VALUE // Ooops!
但这意味着我们必须为我们的数组分配大量内存,即使我们只打算存储几个项目。所以这不是最好的解决方案,事实上我们可以做得更好。假设我们有一个函数h,它对于任何给定的DICT_SIZE 将任意整数映射到[0, DICT_SIZE[ 范围内。然后,我们可以将h 应用于键对象的hashCode() 方法返回的任何内容,并确保我们停留在底层数组的边界内。
public static int h(int value, int DICT_SIZE) {
// returns an integer >= 0 and < DICT_SIZE for every value.
}
该函数称为散列函数。现在我们可以调整我们的字典实现来避免 ArrayIndexOutOfBoundsException:
// "a" -> "Hello"
dictionary[h("a".hashCode(), DICT_SIZE)] = "Hello"
// "b" -> "world"
dictionary[h("b".hashCode(), DICT_SIZE)] = "world"
但这引入了另一个问题:如果h 将两个不同的键索引映射到同一个值怎么办?例如:
int keyA = h("a".hashCode(), DICT_SIZE);
int keyB = h("b".hashCode(), DICT_SIZE);
keyA 和 keyB 可能会产生相同的值,在这种情况下,我们会不小心覆盖数组中的值:
// "a" -> "Hello"
dictionary[keyA] = "Hello";
// "b" -> "world"
dictionary[keyB] = "world"; // DAMN! This overwrites "Hello"!!
System.out.println(dictionary[keyA]); // prints "world"
好吧,你可能会说,那么我们只需要确保我们以永远不会发生这种情况的方式实现h。不幸的是,这通常是不可能的。考虑以下代码:
for (int i = 0; i <= DICT_SIZE; i++) {
dictionary[h(i, DICT_SIZE)] = "dummy";
}
这个循环在字典中存储DICT_SIZE + 1 值(实际上总是相同的值,即字符串“dummy”)。嗯,但是数组只能存储DICT_SIZE 不同的条目!这意味着,当我们使用h 时,我们将覆盖(至少)一个条目。或者换句话说,h 会将两个不同的键映射到相同的值!这些“碰撞”是无法避免的:如果 n 只鸽子试图进入 n-1 个鸽子洞,其中至少有两个必须进入同一个洞。
但是我们可以做的是扩展我们的实现,以便数组可以在同一个索引下存储多个值。这可以通过使用列表轻松完成。所以不要使用:
String[] dictionary = new String[DICT_SIZE];
我们写:
List<String>[] dictionary = new List<String>[DICT_SIZE];
(旁注:请注意,Java 不允许创建泛型类型的数组,因此上述行无法编译——但您明白了)。
这将改变对字典的访问,如下所示:
// "a" -> "Hello"
dictionary[h("a".hashCode(), DICT_SIZE)].add("Hello");
// "b" -> "world"
dictionary[h("b".hashCode(), DICT_SIZE)].add("world");
如果我们的哈希函数 h 为我们所有的键返回不同的值,这将导致每个列表只有一个元素,并且检索元素非常简单:
System.out.println(dictionary[h("a".hashCode(), DICT_SIZE)].get(0)); // "Hello"
但我们已经知道,通常h 有时会将不同的键映射到同一个整数。在这些情况下,列表将包含多个值。对于检索,我们必须遍历整个列表才能找到“正确”的值,但是我们如何识别它呢?
好吧,我们总是可以将完整的 (key,value) 对存储在列表中,而不是单独存储值。然后查找将分两步执行:
- 应用哈希函数从数组中检索正确的列表。
- 遍历存储在检索列表中的所有对:如果找到具有所需键的对,则返回该对中的值。
现在添加和检索已经变得如此复杂,以致于为这些操作单独对待我们自己的方法并不少见:
List<Pair<String,String>>[] dictionary = List<Pair<String,String>>[DICT_SIZE];
public void put(String key, String value) {
int hashCode = key.hashCode();
int arrayIndex = h(hashCode, DICT_SIZE);
List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
if (listAtIndex == null) {
listAtIndex = new LinkedList<Pair<Integer,String>>();
dictionary[arrayIndex] = listAtIndex;
}
for (Pair<String,String> previouslyAdded : listAtIndex) {
if (previouslyAdded.getKey().equals(key)) {
// the key is already used in the dictionary,
// so let's simply overwrite the associated value
previouslyAdded.setValue(value);
return;
}
}
listAtIndex.add(new Pair<String,String>(key, value));
}
public String get(String key) {
int hashCode = key.hashCode();
int arrayIndex = h(hashCode, DICT_SIZE);
List<Pair<String,String>> listAtIndex = dictionary[arrayIndex];
if (listAtIndex != null) {
for (Pair<String,String> previouslyAdded : listAtIndex) {
if (previouslyAdded.getKey().equals(key)) {
return previouslyAdded.getValue(); // entry found!
}
}
}
// entry not found
return null;
}
因此,为了使这种方法起作用,我们实际上需要两个比较操作:在数组中查找列表的 hashCode 方法(如果 hashCode() 和 h 都很快,这会很快)和 @987654354 @ 遍历列表时需要的方法。
这是散列的一般概念,你会从java.util.Map. 中认出put 和get 方法当然,上面的实现过于简单化了,但它应该能说明一切的要点。
当然,这种方法不仅限于字符串,它适用于所有类型的对象,因为方法hashCode() 和equals 是顶级类 java.lang.Object 的成员,所有其他类都继承自那个。
如您所见,如果两个不同的对象在其hashCode() 方法中返回相同的值并不重要:上述方法将始终有效!但仍然希望它们返回不同的值以降低h 产生的哈希冲突的机会。我们已经看到,这些通常不能 100% 避免,但是我们得到的冲突越少,我们的哈希表就越高效。在最坏的情况下,所有键都映射到同一个数组索引:在这种情况下,所有对都存储在一个列表中,然后找到一个值将成为一个成本与哈希表大小成线性关系的操作。