图源:
接下来我会用一系列示例来一步步说明散列如何实现以及为什么要使用散列。
首先来看一个最简单的在一段连续空间中保存元素的示例:
package ex1.hash;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Random;
import ch15.test2.Generator;
import ch15.test2.RandomGenerator;
import util.Fmt;
class Container<E> {
private E[] contents;
private Class<E> type;
private int index = 0;
"unchecked")
( public Container(int size, Class<E> type) {
contents = (E[]) Array.newInstance(type, size);
this.type = type;
}
"unchecked")
( public void add(E item) {
if (index >= contents.length) {
// 容量不够,扩容
E[] oldContents = contents;
contents = (E[]) Array.newInstance(type, oldContents.length * 2);
System.arraycopy(oldContents, 0, contents, 0, oldContents.length);
add(item);
return;
}
contents[index] = item;
index++;
}
public String toString() {
return Arrays.toString(contents);
}
public int size() {
return contents.length;
}
public E get(int index) {
return contents[index];
}
public int find(E target) {
for (int i = 0; i < contents.length; i++) {
if (target.equals(contents[i])) {
return i;
}
}
return -1;
}
}
public class Main {
private static Random rand = new Random();
public static void main(String[] args) {
Container<Integer> container = new Container<>(5, Integer.class);
test(container, new RandomGenerator.IntGenerator());
}
private static <E> void test(Container<E> container, Generator<E> gen) {
for (int i = 0; i < 10; i++) {
container.add(gen.next());
}
System.out.println(container);
E find = container.get(rand.nextInt(container.size()));
int index = container.find(find);
System.out.println(Fmt.sprintf("find %s in index %d", find, index));
}
}
// [58, 58, 70, 37, 80, 90, 80, 13, 64, 8]
// find 80 in index 4
这个版本的Container
底层是一个固定容量的数组,按照add
调用的顺序依次添加元素,并且在容量不够的时候将容量扩大一倍。
这样的容器存在两个问题:
-
扩容的时候需要申请新的内存,并拷贝数据,这很浪费性能。
-
查找元素的时候必须遍历整个数组,性能低下。
对第一个问题,可以改善扩容的算法,在减少扩容次数和空间浪费方面取一个平衡点,在这里并不是问题关键。而第二个问题,可以用排序来解决,我们知道排序后的数组可以用二分查找等进行快速查找。
下面是基于上面的想法修改后的Container
:
package ex1.hash2;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Collections;
import java.util.Random;
import ch15.test2.Generator;
import ch15.test2.RandomGenerator;
import util.Fmt;
class Container<E> {
private E[] contents;
private Class<E> type;
private int index = 0;
"unchecked")
( public Container(int size, Class<E> type) {
contents = (E[]) Array.newInstance(type, size);
this.type = type;
}
"unchecked")
( public void add(E item) {
if (index >= contents.length) {
// 容量不够,扩容
E[] oldContents = contents;
contents = (E[]) Array.newInstance(type, oldContents.length * 2);
System.arraycopy(oldContents, 0, contents, 0, oldContents.length);
add(item);
return;
}
contents[index] = item;
index++;
// 对数组进行排序
Arrays.sort(contents, 0, index);
}
public String toString() {
return Arrays.toString(contents);
}
public int size() {
return index;
}
public E get(int index) {
return contents[index];
}
public int find(E target) {
// 使用二分查找
return Arrays.binarySearch(contents, 0, index, target);
}
}
public class Main {
private static Random rand = new Random();
public static void main(String[] args) {
Container<Integer> container = new Container<>(5, Integer.class);
test(container, new RandomGenerator.IntGenerator());
}
private static <E> void test(Container<E> container, Generator<E> gen) {
for (int i = 0; i < 13; i++) {
container.add(gen.next());
}
System.out.println(container);
E find = container.get(rand.nextInt(container.size()));
int index = container.find(find);
System.out.println(Fmt.sprintf("find %s in index %d", find, index));
}
}
// [7, 10, 25, 28, 36, 55, 55, 60, 65, 72, 78, 86, 99, null, null, null, null,
// null, null, null]
// find 78 in index 10
因为查找元素的时候是对已排序的数组使用二分查找来寻找,所以效率是可以保证的,但问题在于每次添加元素都不得不通过Arrays.sort
对数组进行排序,这依然比较浪费性能。
不过这里可以进行一个小改动,因为现在我们在每次插入数据前都是一个有序数组,所以实际上可以通过二分查找来获取新元素“应当”插入的位置,然后我们直接在某个位置插入,并将尾部的剩余元素向后移动即可。
...
"unchecked")
( public void add(E item) {
if (index >= contents.length) {
// 容量不够,扩容
E[] oldContents = contents;
contents = (E[]) Array.newInstance(type, oldContents.length * 2);
System.arraycopy(oldContents, 0, contents, 0, oldContents.length);
add(item);
return;
}
if (index == 0) {
// 空数组,直接插入
contents[index] = item;
// index++;
} else {
// 通过二分查找获取新元素应当插入的位置
int insertIndex = Arrays.binarySearch(contents, 0, index, item);
if (insertIndex < 0) {
// 没有重复元素,转换插入点
insertIndex = Math.abs(insertIndex) - 1;
}
if (insertIndex >= index) {
// 插入位置是数组尾部,直接插入
contents[index] = item;
} else {
// 插入点之后的元素后移
for (int i = index; i > insertIndex; i--) {
contents[i] = contents[i - 1];
}
contents[insertIndex] = item;
}
}
index++;
}
...
但是每次插入数据都有很大概率需要移动尾部数据,这样依然有很大的性能开销。
用空间换时间
事实上如果你学习过《算法导论》,应该知道计算机算法实际上分为两类:
-
用时间换空间。
-
用空间换时间。
实际上对于Integer
对象,我们完全可以申请一个超大容量的数组,可以装载所有你可能要填入的Integer
对象的那种,然后直接按数组下标进行存储,这样做读写性能都很快。
我们看修改后的版本:
package ex1.hash4;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;
import ch15.test2.Generator;
import ch15.test2.RandomGenerator;
import util.Fmt;
class Container<E extends Integer> {
private List<E>[] contents;
private Class<E> type;
// private int index = 0;
"unchecked")
( public Container(int size, Class<E> type) {
contents = (List<E>[]) Array.newInstance(List.class, size);
this.type = type;
}
public void add(E item) {
if (item.intValue() >= contents.length) {
throw new IndexOutOfBoundsException();
}
if (contents[item] == null) {
contents[item] = new LinkedList<>();
}
contents[item].add(item);
}
public String toString() {
return Arrays.toString(contents);
}
public int size() {
return contents.length;
}
public E get(int index) {
if (contents[index] == null) {
return null;
}
return contents[index].get(0);
}
public int find(E target) {
return target.intValue();
}
}
public class Main {
private static Random rand = new Random();
public static void main(String[] args) {
final int MAX = 10;
Container<Integer> container = new Container<>(MAX, Integer.class);
test(container, new RandomGenerator.IntGenerator(MAX));
}
private static <E extends Integer> void test(Container<E> container, Generator<E> gen) {
for (int i = 0; i < 10; i++) {
container.add(gen.next());
}
System.out.println(container);
E find = null;
do {
find = container.get(rand.nextInt(container.size()));
if (find != null) {
break;
}
} while (true);
int index = container.find(find);
System.out.println(Fmt.sprintf("find %s in index %d", find, index));
}
}
// [[0], [1], [2, 2], null, [4], null, null, [7], [8, 8, 8], [9]]
// find 7 in index 7
为了不对代码进行大范围修改,这里采取了将类型参数E
限定为Integer
子类的方式,实际上这么做会产生一个Warning
,因为Integer
是final
类,无法被继承。
考虑到会有元素重复添加,所以数组的每一个元素都是List
,添加的元素都保存在List
中。
最终的效果就是结果中打印的那样,Integer
对象都按照相应的值保存到数组相应下标的List
中。
这样做读写性能都很快,可以看做是数组的读写性能,即一个常数级时间复杂度O(1),但问题在于如果我们需要插入的数字规模增大,变为0~1000
,那么我们就需要申请一个长度为1000的数组,而如果只是数字的范围增加,但个数其实很少,比如我们要插入随机0~1000的数字5个,那么数组中仅有5个元素是被使用了的,也就是说995个元素都被浪费掉了。
这就是用空间换时间的后果,时间复杂度的确降低了,但空间被浪费了,如果这种浪费在可接受的范围内,那就是成功的优化策略,但显然目前这种方式是不可能被接受的。
散列
散列就是为了解决上边的问题,它的思想是,既然我们是要将一个“大范围”但样本量不多的数据存放到数组中,申请一个和范围等同长度的数组不可接受,那么可不可以将样本数据进行某种“压缩”或“转换”,让它们不是按照数据范围1对1的对应到数组下标,而是经过转换后以某个值来对应一个缩小后的数组的下标。
我们看用这种思路改进后的版本:
package ex1.hash5;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;
import ch15.test2.Generator;
import ch15.test2.RandomGenerator;
import util.Fmt;
class Container<E extends Integer> {
private List<E>[] contents;
private Class<E> type;
// private int index = 0;
"unchecked")
( public Container(int size, Class<E> type) {
contents = (List<E>[]) Array.newInstance(List.class, size);
this.type = type;
}
public void add(E item) {
int hash = item.intValue() % contents.length;
if (contents[hash] == null) {
contents[hash] = new LinkedList<>();
}
contents[hash].add(item);
}
public String toString() {
return Arrays.toString(contents);
}
public int size() {
return contents.length;
}
public E get(int index) {
if (contents[index] == null) {
return null;
}
return contents[index].get(0);
}
public int find(E target) {
int hash = target.intValue() % contents.length;
return hash;
}
}
public class Main {
private static Random rand = new Random();
public static void main(String[] args) {
Container<Integer> container = new Container<>(10, Integer.class);
test(container, new RandomGenerator.IntGenerator(1000));
}
private static <E extends Integer> void test(Container<E> container, Generator<E> gen) {
for (int i = 0; i < 10; i++) {
container.add(gen.next());
}
System.out.println(container);
E find = null;
do {
find = container.get(rand.nextInt(container.size()));
if (find != null) {
break;
}
} while (true);
int index = container.find(find);
System.out.println(Fmt.sprintf("find %s in index %d", find, index));
}
}
// [null, [891, 731, 501], [982, 902, 352], [563], null, [805], null, [747], null, [569]]
// find 747 in index 7
这里的填入数据的范围是0~1000
,但我们用于保存输入的数组长度仅为10,为了将0~1000
的数据“映射”到0~
9的下标,这里采用了一个简单的取余过程,即int hash = target.intValue() % contents.length
。
最终的效果是像[891, 731, 501], [982, 902, 352]
这样,个位数相同的数保存在同一个List
中。而查找元素时,同样的可以通过取余来确定要查找的数组下标,然后通过遍历List
就可以查找到目标对象。
在这个示例中,数组实际上就是一个散列或散列表,所谓的散列实际上就是不连续保存数据的“列表”,就像示例中那样,为了一个理想的读写数据的性能,我们不得不浪费一些空间,但同时我们利用散列这种机制来让浪费的空间可以接受。
这个示例中的取余做法可以看做是散列算法,用于将要保存的数据映射到散列中。而散列算法产生的值,通常被称作散列码。
散列通常也被称作哈希(hash),散列算法也被称作哈希算法,散列码被称作哈希值(hash code)。
映射
散列最广泛的用途是映射(也称作字典),或者说关系数组。这种数据结构广泛存在于各种编程语言中,比如PHP就很极端,只有关系数组而没有普通数组。而Go、Python、Java都支持map
或者类似的数据结构。
之所以映射采用散列实现,是因为映射本身由键值对(key-value)构成,并且需要对数据的插入和读取有一个良好的性能,散列恰恰可以满足这一点。
下面用一个示例说明,这个示例同样使用了数组+List
的方式实现,只不过其中的元素是“键值对”,且通过键的散列值决定存储到数组的哪个元素的List
中,查询也依靠散列值确定位置,以此来实现一个不错的读写性能。
package ex1.map;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import ch15.test2.Generator;
import ch15.test2.RandomGenerator;
import util.Fmt;
import java.util.Set;
class MyHashMap<K, V> implements Map<K, V> {
private static class MapEntry<K, V> implements Entry<K, V> {
private K key;
private V value;
MapEntry(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public String toString() {
return Fmt.sprintf("%s=%s", key, value);
}
}
private List<Entry<K, V>>[] contents;
"unchecked")
( public MyHashMap(int cap) {
if (cap < 1) {
cap = 1;
}
contents = new List[cap];
}
public int size() {
int size = 0;
for (List<Entry<K, V>> list : contents) {
if (list != null) {
size += list.size();
}
}
return size;
}
public boolean isEmpty() {
return size() == 0;
}
public boolean containsKey(Object key) {
Entry<K, V> entry = getEntry(key);
return entry != null;
}
public boolean containsValue(Object value) {
for (List<Entry<K, V>> list : contents) {
if (list == null) {
continue;
}
for (Entry<K, V> entry : list) {
if (entry.getValue().equals(value)) {
return true;
}
}
}
return false;
}
public V get(Object key) {
Entry<K, V> entry = getEntry(key);
if (entry != null) {
return entry.getValue();
}
return null;
}
public V put(K key, V value) {
V oldValue = null;
Entry<K, V> entry = getEntry(key);
if (entry != null) {
oldValue = entry.getValue();
entry.setValue(value);
return oldValue;
}
addEntry(key, value);
return oldValue;
}
public V remove(Object key) {
Entry<K, V> entry = getEntry(key);
if (entry == null) {
return null;
}
V oldValue = entry.getValue();
int index = getContentsIndex(key);
contents[index].remove(entry);
return oldValue;
}
public void putAll(Map<? extends K, ? extends V> m) {
for (Entry<? extends K, ? extends V> entry : m.entrySet()) {
put(entry.getKey(), entry.getValue());
}
}
public void clear() {
Arrays.fill(contents, null);
}
public Set<K> keySet() {
Set<K> keys = new HashSet<>();
for (Entry<K, V> entry : entrySet()) {
keys.add(entry.getKey());
}
return keys;
}
public Collection<V> values() {
Set<V> values = new HashSet<>();
for (Entry<K, V> entry : entrySet()) {
values.add(entry.getValue());
}
return values;
}
public Set<Entry<K, V>> entrySet() {
Set<Entry<K, V>> entries = new HashSet<>();
for (List<Entry<K, V>> list : contents) {
if (list != null) {
entries.addAll(list);
}
}
return entries;
}
public String toString() {
StringBuffer sb = new StringBuffer();
sb.append("[");
for (Entry<K, V> entry : entrySet()) {
sb.append(entry.toString());
sb.append(",");
}
sb.delete(sb.length() - 1, sb.length());
sb.append("]");
return sb.toString();
}
private int getContentsIndex(Object object) {
int hashCode = object.hashCode();
int index = Math.abs(hashCode) % contents.length;
return index;
}
private Entry<K, V> getEntry(Object key) {
int index = getContentsIndex(key);
if (contents[index] == null) {
return null;
}
for (Entry<K, V> entry : contents[index]) {
if (entry.getKey().equals(key)) {
return entry;
}
}
return null;
}
private void addEntry(K key, V value) {
int index = getContentsIndex(key);
if (contents[index] == null) {
contents[index] = new LinkedList<>();
}
contents[index].add(new MapEntry<>(key, value));
}
}
public class Main {
public static void main(String[] args) {
test(new MyHashMap<Integer, String>(10), new RandomGenerator.IntGenerator(),
new RandomGenerator.StringGenerator());
}
private static void test(Map<Integer, String> map, Generator<Integer> kGen, Generator<String> vGen) {
for (int i = 0; i < 10; i++) {
map.put(kGen.next(), vGen.next());
}
System.out.println(map);
map.clear();
map.put(1, "hello");
map.put(1, "hello");
System.out.println(map);
map.put(1, "world");
System.out.println(map);
System.out.println(map.containsKey(1));
System.out.println(map.containsValue("world"));
}
}
// [14=gcsmq,47=cltks,39=flvxy,95=abqfh,65=fponx,30=cizjh,45=wamrt,37=wlymv,36=dgpmh,63=nmmka]
// [1=hello]
// [1=world]
// true
// true
散列码
Java中产生散列码的方法是Object
的hashCode
方法,标准库的大多数类都实现了这个方法,所以我们不需要担心,但如果自己要编写类,就涉及如何实现散列算法的问题了,这也是散列在实际编程中的最大障碍。
理论上,完美的散列码需要将样本数据尽可能均匀地映射到散列上,冲突可能性越低、浪费空间越少越好。但显然这只是理论上,毕竟很难预测可能的样本数据,要真的符合类似的想法,就要根据样本的录入调整散列算法,这会相当麻烦。但对于一般性的应用而言,完全可以按照一般的数学原理,设计一个过得去的散列算法即可。
关于如何产生散列码,《Java编程思想》给出了一个指导性建议,根据这个建议我编写一个简单示例来说明:
package ex1.hash_code;
class Student {
private static int counter = 0;
private int id = ++counter;
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public int hashCode() {
final int INIT_CODE = 17;
final int MULTIPLES = 37;
int code = INIT_CODE;
code = code * MULTIPLES + HashCode.hash(id);
code = code * MULTIPLES + HashCode.hash(name);
code = code * MULTIPLES + HashCode.hash(age);
return code;
}
}
public class Main {
public static void main(String[] args) {
Student s1 = new Student("Li lei", 22);
Student s2 = new Student("Han Meimei", 15);
System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
}
}
// -1766199299
// -1058088827
这里的INIT_CODE
和MULTIPLES
是可以自行设定的两个值,然后每次累加属性的散列码即可。
属性需要根据类型的不同来计算散列码:
package ex1.hash_code;
public class HashCode {
public static int hash(boolean b) {
return b ? 0 : 1;
}
public static int hash(byte b) {
return (int) b;
}
public static int hash(char c) {
return (int) c;
}
public static int hash(int i) {
return i;
}
public static int hash(short s) {
return (int) s;
}
public static int hash(long l) {
return (int) (l ^ (l >>> 32));
}
public static int hash(float f) {
return Float.floatToIntBits(f);
}
public static int hash(double d) {
long I = Double.doubleToLongBits(d);
return (int) (I ^ (I >>> 32));
}
public static int hash(Object obj) {
return obj.hashCode();
}
public static void main(String[] args) {
System.out.println(hash(11));
System.out.println(hash(2.5));
System.out.println(hash('a'));
}
}
// 11
// 1074003968
// 97
当然也可以利用包装类来获取散列码,比如Integer().valueOf(age).hashCode()
。
以前我还用过一种更简单的实现散列的方式,即将相关属性的散列值进行位异或操作:
...
public int hashCode2() {
return HashCode.hash(id) ^ HashCode.hash(name) ^ HashCode.hash(age);
}
...
关于散列就说到这了,希望对你有所帮助,谢谢阅读。
文章评论