1.順序表的問題
查找和去重效率較低
對於這樣的順序表來說,如果需要查找元素,就需要從第一個元素逐個檢查,進行查找。對於需要去重的存儲來說,每次存入一個元素之前,就得將列表中的每個元素都比對一遍,效率相當低。
1.1.解決思路
我們注意到在這里的順序表中列表中的每個元素都有一個與之對應的索引,不過這里的索引只是與元素所在的位置有對應關系,也就是說:
索引與順序表中的位置有一一對應關系,但是與位置中的元素沒有必然的聯系。如果有某種方式能夠建立這樣的一個關系:
如圖中所示,也就是說每個元素與其在順序表中的索引,以及位置都有一個必然的對應關系,如此一來,每個元素在被存入數據結構之前,其位置就已經被確定了。這樣無論是要查找,還是去重都會機器方便。
2.散列表
2.1.哈希函數的作用
如前面提出的這種解決思路,給每一個元素在表中找一個唯一確定的位置的這種解決方案被稱為散列表。
那么,問題又來了,如何確定要存入數組的元素在表中的位置呢?這就是哈希函數的作用。
哈希函數的作用就是利用要存入表的元素的屬性信息,生成一個唯一的整型值,這個值被稱為哈希值,利用哈希值在表中確定一個固定的位置,用來存儲這個元素。
2.2.字符串轉整型的問題
一般來說存儲元素的屬性無外乎就兩種類型,一種是數值型的,要轉成整型沒什么好說的;另外就主要是字符串型的,對於字符串如何將其轉成整型呢?
我們知道字符串是由一個個的字符組成的,而我們可以根據ASCII碼表將字符轉換成對應的整型編碼,這樣只需要將字符串中的每個字符轉成整型,然后進行相應的計算即可。
2.3.BKDR哈希算法
哈希算法有很多種,此處我們介紹一種比較常用的哈希算法,下面是這種算法的C語言實現版本。
// BKDR Hash Function unsigned int BKDRHash(char *str) { unsigned int seed = 131; // 31 131 1313 13131 131313 etc.. unsigned int hash = 0; while (*str) { hash = hash * seed + (*str++); } return (hash & 0x7FFFFFFF); }
觀察這個函數,其實其內部的邏輯就是遍歷一個字符數組,將每個元素對應的ASCII碼值乘以一個數,然后累加起來的結果,可以轉換成如下表示的一個結果:
3.重寫hashCode()
3.1.重寫hashCode()的原因
public class Student { private String num; private String name; public Student(String num, String name) { this.num = num; this.name = name; } public static void main(String[] args) { Student stu1 = new Student("10001", "赤驥"); Student stu2 = new Student("10001", "赤驥"); Student stu3 = new Student("10002", "白義"); System.out.println("赤驥的HashCode:" + stu1.hashCode()); System.out.println("赤驥的HashCode:" + stu2.hashCode()); System.out.println("白義的HashCode:" + stu3.hashCode()); } }
這段代碼執行的結果是:
赤驥的HashCode:366712642 赤驥的HashCode:1829164700 白義的HashCode:2018699554
這段代碼中,我們打印出三個對象的哈希值,我們看到Student這個類中並沒有hashCode()方法,因為在Java的繼承體系中,Object類是所有類的超類,也就是說實際上Student類是繼承了Object類的,因此這里沒有寫hashCode()方法,那么調用的就是Object類的hashCode()方法了。
而根據我們之前對哈希函數的定義,這個Object類中繼承的hashCode()方法顯然不適用於這個Student類。因為stu1和stu2這兩個對象的屬性值是完全一樣的,那么從業務角度來說,這兩個對象應該就是重復的,那么他們生成的哈希值也應該是一致的,而現在顯然並不一致,因此我們需要為這個Student類重寫hashCode()方法。
3.2.如何重寫hashCode()方法
@Override public int hashCode() { StringBuilder sb = new StringBuilder(); sb.append(num); sb.append(name); char[] charArr = sb.toString().toCharArray(); int hash = 0; for(char c : charArr) { hash = hash * 131 + c; } return hash; }
將所有需要參與計算的屬性值都合並成一個字符串,然后轉換成一個字符數組:
char[] charArr = sb.toString().toCharArray();
然后遍歷這個字符數組進行計算。
4.Java中常用的哈希表
4.1.hashCode()在HashSet和HashMap中的作用
現在以及編寫好了hashCode()方法,我們到實際的案例中去使用一下。在Java中常用的哈希表有HashSet和HashMap。
此處我們先以HashSet為例:
import java.util.HashSet; import java.util.Set; public class Student { private String num; private String name; public Student(String num, String name) { this.num = num; this.name = name; } @Override public int hashCode() { StringBuilder sb = new StringBuilder(); sb.append(num); sb.append(name); char[] charArr = sb.toString().toCharArray(); int hash = 0; for(char c : charArr) { hash = hash * 131 + c; } return hash; } public static void main(String[] args) { Student stu1 = new Student("10001", "赤驥"); Student stu2 = new Student("10001", "赤驥"); Student stu3 = new Student("10002", "白義"); Set<Student> students = new HashSet<>(); students.add(stu1); students.add(stu2); students.add(stu3); System.out.println(students.size()); } }
觀察這段代碼,根據我們之前重寫的哈希函數,stu1和stu2應該是在相同位置的,並且他們的值是一樣的,那么應該是只能夠存放其中一個到這個set中,因此最終打印輸出的結果應該是2。
而實際測試的結果為3。這是為什么?
我們來查看一下HashSet的源碼:
public boolean add(E e) { return map.put(e, PRESENT)==null; }
我們找到HashSet中的這個add方法,看它是怎么實現的,可以看到這里調用了一個map對象的put方法來存放元素,可以才想到,實際上HashSet真正的實現是另外一個類,這個HashSet只是對其的一個封裝,我們找到這個map,看看它到底是哪個類:
private transient HashMap<E,Object> map;
在HashSet前面的屬性聲明中可以看到這樣一行代碼,根據這個我們看出來實際上Java中的HashSet是依托於HashMap的實現的。那么接下來到HashMap中去找這個添加元素的方法看看:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
這里真正存放元素的邏輯是在putVal()這個方法中,這里面代碼較多就不貼上來了,這里簡述一下其中的關鍵邏輯。它會調用存入元素的hashCode()方法,計算出元素所對應在表中的位置,然后判斷這個位置上是否已經有內容了。如果這個位置上以及有了一個元素,那么就調用傳入元素的equals()方法與已有的元素進行對比,以此來判斷兩個元素是否相同,如果不相同,就將這個元素也存入表中。
4.2.equals()方法的作用
也就是說,使用hashCode()方法確定元素在數據結構中存放的位置。而使用equals()來確認當兩個元素存放的位置發生沖突時,是應該將兩個元素都存入數據結構,還是說只需要存放其中一個。
如果equals()方法判斷兩個元素是一樣的,那么當然只需要存放其中一個既可;但如果equals()方法判斷兩個對象是不同的,那么當然兩個都需要存放到數據結構中。
5.重寫equals()方法
重寫equals()從邏輯上來說就比較簡單了,先看下實例:
@Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj instanceof Student) { if (((Student) obj).num.equals(this.num) && ((Student) obj).name.equals(this.name)) { return true; } } return false; }
首先判斷是否是自己和自己比較,如果是那么肯定是相同的,因為是同一個對象。
然后再逐個比較對象的屬性,如果屬性值都相同,那么說明就是相同的對象。
在重寫了equals()方法之后,重寫再進行之前的測試,就可以發現結果是正確的了,在該集合中三個對象只能放入其中的兩個,還有一個因為重復而無法放入。
6.String類的hashCode()方法和equals()方法
在前面的一系列介紹過程中,我們都是介紹自己定義的類的hashCode()方法和equals()方法。除了這些自定義的類,我們在平時編寫代碼過程中經常會用到一個系統的類,並且這個類也經常被用在HashMap中作為key來使用,那就是String類,我們可以看看這個類的hashCode()方法和equals()方法是如何編寫的。
6.1.hashCode()方法
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
觀察這個hashCode()方法,基本上我們實現哈希函數的思路與這個是一致的。
6.2.equals()方法
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }
觀察代碼,可以發現對於String類來說,如果要判斷兩個String的實例相同,需要逐一判斷這兩個字符串中的字符是否相同。