目錄
2.5、將 hashCode() 的返回值轉化爲一個數組索引
1、散列表
散列表其實就是用一個數組來存儲我們的鍵值對,在存儲過程中,我們會將key通過散列函數將轉換爲數組的索引index,然後將value存儲在數組的該索引下。
當然我們在存儲過程中會遇到不同的key通過相同的散列函數轉換成的索引index是相同的情況,這時候我們就需要解決這種情況,這個也叫做哈希衝突,這裏我們將講兩種解決方法:拉鍊法和線性探測法。
散列表是算法在時間和空間上作出權衡的經典例子。
缺點:無法進行有序性相關的操作,比如:獲取最大,最小值。
2、散列函數
散列函數的作用就是將key轉換成數組的索引。如果我們有 一個能夠保存M個鍵值對的數組,那麼我們就需要一個能夠將任意鍵轉化爲該數組範圍內的索引([0, M-1]範圍內的整數)的散列函數。
2.1、整數-除留餘數法
將整數散列最常用方法是除留餘數法。我們選擇大小爲素數M的數組, 對於任意正整數k,計算k除以M的餘數。
選擇素數的原因是可以比較均勻地散列數組。
2.2、浮點數
如果鍵是0到1之間的實數,我們可以將它乘以M並四捨五入得到一個0至M-1之間的索引值。 儘管這個方法很容易理解,但它是有缺陷的,因爲這種情況下鍵的高位起的作用更大,最低位對散 列的結果沒有影響。修正這個問題的辦法是將鍵表示爲二進制數然後再使用除留餘數法(Java 就是 這麼做的)。
2.3、字符串
2.4、Java-hashcode()
每種數據類型都需要相應的散列函數,於是 Java 令所有數據類型都繼承了一個能夠返回一個 32 比特整數的 hashCode() 方法。
默認散列函數會返回對象的內存地址, 但這隻適用於很少的情況。Java 爲很多常用的數據類型重寫了 hashCode() 方法(包括 String、 Integer、Double、File 和 URL)。
2.5、將 hashCode() 的返回值轉化爲一個數組索引
因爲我們需要的是數組的索引而不是一個 32 位的整數,我們在實現中會將默認的 hashCode() 方法和除留餘數法結合起來產生一個 0 到 M-1 的整數,方法如下:
private int hash(Key x){
return (x.hashCode() & 0x7fffffff) % M;
}
3、軟緩存
如果散列值的計算很耗時,那麼我們或許可以將每個鍵的散列值緩存起來,即在每個鍵中使用 一個 hash 變量來保存它的 hashCode() 的返回值。第一次調用 hashCode() 方 法時,我們需要計算對象的散列值,但之後對 hashCode() 方法的調用會直接返回 hash 變量的值。 Java 的 String 對象的 hashCode() 方法就使用了這種方法來減少計算量。
總的來說,要爲一個數據類型實現一個優秀的散列方法需要滿足三個條件:
- 一致性——等價的鍵必然產生相等的散列值;
- 高效性——計算簡便;
- 均勻性——均勻地散列所有的鍵。
4、解決哈希衝突1-拉鍊法
當然我們在存儲過程中會遇到不同的key通過相同的散列函數轉換成的索引index是相同的情況,這時候我們就需要解決這種情況,這個也叫做哈希衝突,第一種解決哈希衝突的方法是:拉鍊法。
拉鍊法的思想就是:我們將13個數據,存儲在一個大小爲5的數組中, 首先我們通過散列函數將它們的key轉換成數組索引,這時候肯定有重複的索引,我們會講重複的索引通過鏈表的形式將它們存儲起來,如下圖:
這段簡單的符號表實現維護着一條鏈表的數組,用散列函數來爲每個鍵選擇一條鏈表。簡單起見,我們使用了 SequentialSearchST。在創建 st[] 時需要進行類型轉換,因爲 Java 不允許泛型的數組。 默認的構造函數會使用 111 條鏈表,因此對於較大的符號表,這種實現比 SequentialSearchST 大約 會快 1000 倍。當你能夠預知所需要的符號表的大小時,這段短小精悍的方案能夠得到不錯的性能。一種 更可靠的方案是動態調整鏈表數組的大小,這樣無論在符號表中有多少鍵值對都能保證鏈表較短。
public class SeparateChainingHashST<Key,Value> {
private int M;//散列表的數組大小
private int N;//存放鍵值對的實際數量
private SequentialSearchST<Key,Value>[] st;//存儲鏈表對象的數組
public SeparateChainingHashST() {
this(111);
}
private SeparateChainingHashST(int m) {
this.M = m;
st=(SequentialSearchST<Key,Value>[]) new Object[M];
for (int i=0;i<M;i++){
st[i]=new SequentialSearchST<>();
}
}
//散列函數,將key轉換成數組st索引
public int hash(Key key){
return (key.hashCode()&0x7fffffff)%M;
}
public void put(Key key,Value value){
st[hash(key)].put(key,value);
}
public Value get(Key key){
return st[hash(key)].get(key);
}
}
public class SequentialSearchST<Key, Value> {
private Node first;
private int N = 0;
private class Node {
Key key;
Value val;
Node next;
public Node(Key key, Value val, Node next) {
this.key = key;
this.val = val;
this.next = next;
}
}
public Value get(Key key) {
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key)) {
return x.val;
}
}
return null;
}
public void put(Key key, Value val) {
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key)) {
x.val = val;
return;
}
}
first = new Node(key, val, first);
N++;
}
// Exercise 3.1.5
public int size() {
return N;
}
public void delete(Key key) {
first = delete(first, key);
}
private Node delete(Node x, Key key) {
if (x == null) {
return null;
}
if (x.key.equals(key)) {
N--;
return x.next;
}
x.next = delete(x.next, key);
return x;
}
public Iterable<Key> keys() {
Queue<Key> queue = new Queue<>();
for (Node x = first; x != null; x = x.next) {
queue.enqueue(x.key);
}
return queue;
}
}
5、解決哈希衝突2-線性探測法
實現散列表的另一種方式就是用大小爲 M 的數組保存 N 個鍵值對,其中 M>N。我們需要依靠 數組中的空位解決碰撞衝突。基於這種策略的所有方法被統稱爲開放地址散列表。
開放地址散列表中最簡單的方法叫做線性探測法:當碰撞發生時(當一個鍵的散列值已經被另 一個不同的鍵佔用),我們直接檢查散列表中的下一個位置(將索引值加 1)。這樣的線性探測可 能會產生三種結果:
- 命中,該位置的鍵和被查找的鍵相同;
- 未命中,鍵爲空(該位置沒有鍵);
- 繼續查找,該位置的鍵和被查找的鍵不同。
我們用散列函數找到鍵在數組中的索引,檢查其中的鍵和被查找的鍵是否相同。如果不同則繼續查找(將索引增大,到達數組結尾時折回數組的開頭),直到找到該鍵或者遇到一個空元素。
插入的操作也需要線性探測,比如數組a={0,0,B,D,R,0,0},這時候我們插入的key=C轉換後的索引是2的話,我們不能將B替換而是在它的後面查找,如果後面有C我們就替換,如果沒有,我們會將C插入到R的後面(第一個空位的位置)。
開放地址類的散列表的核心思想是與其將內存用作鏈表,不如將它們作爲在散列表的空元素。 這些空元素可以作爲查找結束的標誌。
public class LinearProbingHashST<Key, Value> {
private int N;//存儲鍵值對的實際數量
private int M;//數組大小
private Key[] keys;
private Value[] values;
public LinearProbingHashST(int m) {
M = m;
keys = (Key[]) new Object[M];
values = (Value[]) new Object[M];
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public void put(Key key, Value value) {
int i;
for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (key.equals(keys[i])) {
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 (key.equals(keys[i])) {
return values[i];
}
}
return null;
}
}
5.1、刪除操作
插入的操作需要線性探測,比如數組a={0,0,B,D,R,0,0},這時候我們插入的key=C轉換後的索引是2的話,我們不能將B替換而是在它的後面查找,如果後面有C我們就替換,如果沒有,我們會將C插入到R的後面(第一個空位的位置)。
上面我們插入C的數組變爲a={0,0,B,D,R,C,0},如果我們刪除了D之後,我們的數組會變爲a={0,0,B,0,R,C,0}這時候如果我們需要查找C的話,因爲C通過散列函數轉換的索引還是2,如果我們從2的位置找C的話,B後面變成了null,所以我們會找不到C,這時候怎麼辦呢?需要將刪除D之後,D後面的所有元素需要重新插入數組。
public void delete(Key key) {
if (!contains(key))
return;
int i = hash(key);
while (!keys[i].equals(key)) {
i = (i + 1) % M;
}
keys[i] = null;
values[i] = null;
N--;
i = (i + 1) % M;
while (keys[i] != null) {
Key keyToRedo = keys[i];
Value valToRedo = values[i];
keys[i] = null;
values[i] = null;
N--;
put(keyToRedo, valToRedo);
i = (i + 1) % M;
}
}
public boolean contains(Key key) {
for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (key.equals(keys[i])) {
return true;
}
}
return false;
}
5.2、鍵蔟
線性探測的平均成本取決於元素在插入數組後聚集成的一組連續的條目,也叫做鍵簇。例如, 數組a={0,0,B,D,R,0,0}就有一個長度爲3的鍵簇(B D R)。這意味着插入 C 需要探測 4 次,因爲 C 的散列值爲該鍵簇 的第一個位置。顯然,短小的鍵簇才能保證較高的效率。 隨着插入的鍵越來越多,這個要求很難滿足,較長的鍵簇 也會越來越多。
線性探測法的性能:當散列表快滿的時候查找所需的探測次數是巨大的(較長的鍵蔟越來越多,探測的次數也越來越大),但當使用率 N/M=α 小於 1/2 時探測的預計次數只在 1.5 到 2.5 之間。
5.3、調整數組大小
private void resize(int size){
LinearProbingHashST st=new LinearProbingHashST(size);
for (int i=0;i<M;i++){
if (keys[i]!=null){
st.put(keys[i],values[i]);
}
}
keys= (Key[]) st.keys;
values= (Value[]) st.values;
M=st.M;
}
6、內存使用
除了存儲鍵和值所需的空間之外,我們實現的 SeparateChainingHashST 保存 了 M 個 SequentialSearchST 對象和它們的引用。每個 SequentialSearchST 對象需要 16 字節, 它的每個引用需要 8 字節。另外還有 N 個 node 對象,每個都需要 24 字節以及 3 個引用(key、 value 和 next),比二叉查找樹的每個結點還多需要一個引用。
在使用動態調整數組大小來保證 表的使用率在 1/8 到 1/2 之間的情況下,線性探測使用 4N 到 16N 個引用。可以看出,根據內存用 量來選擇散列表的實現並不容易。對於原始數據類型,這些計算又有所不同。
7、拉鍊法與線性探測法比較
拉鍊法和線性探測法的詳細比較取決於實現的細節和用例對空間和時間的要求。即使基於性能 考慮,選擇拉鍊法而非線性探測法也不一定是合理的。在實踐中,兩種方法的 性能差別主要是因爲拉鍊法爲每個鍵值對都分配了一小塊內存而線性探測則爲整張表使用了兩個很 大的數組。對於非常大的散列表,這些做法對內存管理系統的要求也很不相同。在現代系統中,在 性能優先的情 下,最好由專家去把握這種平衡。
8、各種符號表性能比較