忘了在哪本書裡曾看到過類似這樣的一句話“所有的數據結構都是數組的演化”,想想其實是有道理的,因為計算機的內存其實就是線性的存儲空間。
Java範例程式碼:int[] array = new int[5]
# 忽略物件頭資訊和陣列長度訊息,JVM執行時會在堆中分配20個位元組的記憶體空間,看起來就是這樣的:
# 這樣的資料結構可以很方便地透過數組下標存取數據,但在尋找時需要遍歷數組,平均時間複雜度為O(n/2)。
當資料量很大或查找操作頻繁的時候,這樣的遍歷操作幾乎是不可接受的。那麼,如何才能夠在更短的時間內快速找到資料呢?
# 假如數組內的元素是已經排序的,那麼很自然的方式就是使用二分查找。
譬如有一個整形數組的長度為1000,數組內的整數從小到大排列,如果我想要知道6000是否在此數組中。那我可以先查看array[500]的數字是否為6000,array[500]的數字比6000小,那麼就查看array[750]位置的數字…依次類推,那麼最多經過10次,就可以確定結果。
此演算法的時間複雜度為O(logN)。
然而,大部分情況下數組元素都是無序的,而排序所需的時間複雜度O(NlogN)通常都遠遠超過遍歷所需的時間。
那麼,問題又回到了原點。該如何在無序的數據中快速找到數據呢?
還記得剛學編程不久時看《編程珠璣》,其中有一段描述:20世紀70年代,Mike Lesk為電話公司做了電話號碼查找功能,譬如輸入LESK*M*對應的數字鍵5375*6*,可以返回正確的電話號碼或可選列表,錯誤匹配率僅0.2%。
怎麼才能做到?
當時我還完全不了解資料結構和演算法,還原下當時天馬行空思考的過程,現在看起來仍然是很有意思的。
所有數字相加(*鍵為11),5375*6*的和為48。大多數輸入的和不超過100,意味著幾萬個電話號碼都會聚集在數組前100的位置,所以是不可行的。
所有數字相乘,積為381150。這似乎是可行的,但出現了這樣的問題:lesk、lsek、slke……的積是一樣的,每一個數字鍵還對應著3個字母,這意味著重複的機率會很高。
①字母相同但字母序不同的字串,可以透過這樣的方式來避免衝突:每一個數字先乘以序號,然後再與其它值相乘。
# ②每一個數字鍵對應了3個英文字母,如果使用者沒有輸入字母在數字鍵中的序號,是沒辦法再進一步精確計算的。能考慮的方向只能是根據給定的單字表來做機率統計,所以不考慮。
即使用改進後的乘法,不同的名字字母計算得出的積依然還是可能會發生重複,那麼當發生衝突後該怎麼辦?
順序儲存到下一個空白位置?仔細想想這是行不通的。如果下一個空白位置剛好又是另外的字母集合的積,那就產生了二次衝突。
幸好,還有質數。
質數只能被1和自身整除,那麼上述乘法得出的任何積都不可能是質數,所以質數位置是沒有保存電話號碼的。
因此,以當前積為起點向右搜尋下一個質數,如果該質數位置依然被使用,那麼就繼續找出下一個最近的質數…
至此,問題似乎是解決了。
用戶輸入一串數字,依公式計算得到積,傳回該下標位置的電話號碼。如果不正確,再依序找出後面的質數,直到某個質數下標的陣列元素為空為止,最後傳回所有查找到的電話號碼。大部分情況下,只需要O(1)的時間複雜度就可以找到電話號碼。
唯一的問題是,按照上述思路建立的數組實在是太大了。一個姓名有10個字母,假如每一個字母對應的數字都是9,最後得到的積約是9的17次方。這意味著要建立9^17大小的數組,這是完全不可行的。
即使不考慮數組過大因素,以我當時初學編程的水平,這樣的程式也是沒有能力寫出來的。
我之所以還原這個思考的過程,是覺得獨立思考的過程非常有趣也很有價值。想想,其實現有的演算法都是當年那些大牛在實際工程中一步一步尋求解決方案而最終推導得到的。
因此,當在工程中碰到一些棘手的難題時,只要樂於思考分解問題並尋求每一個小問題解決方案,那麼很多所謂的難題都是可以解決的。
後來看了《資料結構與演算法分析.Java語言描述》,我才知道原來我的想法其實就是開放尋址法(Open Addressing)。
JDK的HashMap使用的則是分離連結法(separate chaining)。不同:增加了桶的概念來保存衝突的資料;進行求餘運算來降低陣列大小。
那麼,就談談Java中的HashMap吧。
# HashMap的本質依然是數組,而且是一個不定長的多維數組,近似於下圖這樣的結構:
#
HashMap中的陣列保存鍊錶的頭節點。
儲存資料:
# 計算key的hashCode,再與數組長度進行求餘運算得到數組下標(鍊錶頭節點)。
如果該位置為空,產生一個新的鍊錶節點並儲存到該陣列。
如果該位置非空,則循環比對鍊錶的每一個節點:已經存在key相同的節點,覆蓋舊節點;不存在key相同的節點,將新節點作為鍊錶的尾節點(註:查看JDK1.8中的原始碼,新節點總是加入到鍊錶末尾,而不是如JDK1.6一般作為鍊錶頭節點)。
找出資料:
# 計算key的hashCode,再與數組長度進行求餘運算得到數組下標(鍊錶頭節點)。如果鍊錶不為空,先比對首節點,如果首節點key相同(hashCode相同且equals為true),直接返回首節點的value;首節點key不同則順序遍歷比對鍊錶其它節點,返回key相同的節點的value(未找到key相同的節點則回傳null)。
HashMap引入鍊錶的目的就是為了解決上一節討論過的重複衝突問題。
注意事項:
# 如果所有key的hashcode相同,假定均為0,則0%4 = 0,所有元素就會都保存到鍊錶0,保存和查找每一個資料都需要遍歷鍊錶0。那麼,此時的HashMap實質上已經退化成了鍊錶,時間複雜度也從設計的O(1)上升到了O(n/2)。
為了盡可能地讓HashMap的查找和保存的時間複雜度保持在O(1),就需要讓元素均勻地分佈在每一個鍊錶,也就是每一個鍊錶只保存一個元素。
那麼影響因素有哪些?
一是key的hashcode不能重複,如果重複就一定會有鍊錶保存至少2個元素;
二是雜湊函數設計,如果只是簡單的求餘,那麼餘數會有大量重複;
# 三是數組的容量,如果100個元素要分佈在長度為10的數組,無論怎麼計算都會導致其中有鍊錶保存多個元素,最好的情況是每個鍊錶保存10個;
以下分別詳細介紹這三個因素的演算法設計。
這是String類別的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; }
String類別的value是char[],char可以轉換成UTF-8編碼。譬如,’a’、’b’、’c’的UTF-8編碼分別是97,98,99;「abc」依照上面的代號轉換成公式就是:
h = 31 * 0 + 97 = 97;
# h = 31 * 97 + 98 = 3105;
# h = 31 * 3105 + 99 = 96354;
使用31作為乘數因子是因為它是一個比較合適大小的質數:如值過小,當參與計算hashcode的項數較少時會導致積過小;如為偶數,則相當於是左位移,當乘法溢出時會造成有規律的位元訊息遺失。而這兩者都會導致重複率增加。
如果使用32作為乘數因子(相當於 << 5),以字串「abcabcabcabcabc」為例,它的hashcode的每一步計算結果如下圖:
# 如上圖所示,字串結尾每3個字母就會產生一個重複的hashcode。這並不是一個巧合,即使換成其它的英文字母,也有很容易產生重複,而使用質數則會大大減少重複可能性。有興趣的可以參考上圖去作一下左位移運算,會發現這並不是偶然。
《Effective Java》一书中详细描述了hashcode的生成规则和注意事项,有兴趣的可以去看看。
从源代码的hashCode()方法可知,String类对象保存了已经计算好的hashCode,如果已经调用过hashCode()方法,那么第二次调用时不会再重新生成,而是直接返回已经计算好的hashCode。
String对象总是会存放到String类私有维护的常量池中,不显式使用new关键字时,如果常量池中已经有value相同的对象,那么总是会返回已有对象的引用。因此,很多情况下生成hashCode这种比较昂贵的操作实际上并不需要执行。
现在,已经得到了重复率很低的hashCode,还有什么美中不足的地方吗?
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
下图还是以字符串“abcabcabcabcabc”为例,使用上面方法得到的运算过程和结果。
为什么要先无符号右位移16位,然后再执行异或运算?看看下图的二进制的与运算,你就会明白。
你会发现只要二进制数后4位不变,前面的二进制位无论如何变化都会出现相同的结果。为了防止出现这种高位变化而低位不变导致的运算结果相同的情况,因此需要将高位的变化也加入进来,而将整数的二进制位上半部与下半部进行异或操作就可以得到这个效果。
为什么要使用与运算?
因为哈希函数的计算公式就是hashCode % tableSize,当tableSize是2的n次方(n≥1)的时候,等价于hashCode & (tableSize - 1)。
扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0
扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6
这就是为什么需要增加扰动函数的原因。
代码解释之前需要补充说明一下,jdk1.8引入了红黑树来解决大量冲突时的查找效率,所以当一个链表中的数据大到一定规模的时候,链表会转换成红黑树。因此在jdk1.8中,HashMap的查找和保存数据的最大时间复杂度实际上就是红黑树的时间复杂度O(logN)。
以下是HashMap中的保存数据的方法源代码,相信经过以上的描述,已经非常容易看懂这一段代码。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; //HashMap数组 Node<K,V> p; //初始化需要保存的数据 int n; //数组容量 int i; //数组下标 /* 如果数组为空或0,初始化容量为16 */ if ((tab = table) == null || (n = tab.length) == 0){ n = (tab = resize()).length; } /* 使用哈希函数获取数组位置(如果为空,保存新节点到数组) */ if ((p = tab[i = (n - 1) & hash]) == null){ tab[i] = newNode(hash, key, value, null); } /* 如果数组位置已经有值,则使用下列方式保存数据 */ else { Node<K,V> e; //临时节点保存新值 K k; //临时变量用于比较key //如果头节点与新节点的key的hash值相同 且 key的值相等,e赋值为旧节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){ e = p; } //如果头节点是一个红黑树节点,那么e将保存为树节点 else if (p instanceof TreeNode){ e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); } //如果头节点与新节点不同,且头节点不是红黑树节点,循环比对链表的所有节点 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //如果直到链表尾未找到相同key的节点,将新结点作为最后一个节点加入到链表 p.next = newNode(hash, key, value, null); //如果链表节点数大于等于8-1,转换成红黑树;转换成红黑树的最小节点数是8 if (binCount >= TREEIFY_THRESHOLD - 1){ treeifyBin(tab, hash); } break; } //如果循环过程中发现新旧key的值相同,跳转:是否覆盖旧值 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){ break; } p = e; } } //是否覆盖旧值 if (e != null) { V oldValue = e.value; //如果新值不为空且(允许修改旧值 或 旧值为空),覆盖旧节点的值 if (!onlyIfAbsent || oldValue == null){ e.value = value; } afterNodeAccess(e); //回调函数,这里是空函数,但在linkedHashMap中实现了此方法 return oldValue; //返回旧值 } } //用于比较判断是否在遍历集合过程中有其它线程修改集合,详情请网上搜索fail-fast快速失败机制 ++modCount; //当key数量大于允许容量时进行扩容。允许容量在HashMap中是数组长度 * 装填因子(默认0.75) if (++size > threshold){ resize(); } //回调函数,这里是空函数,但在linkedHashMap中实现了此方法 afterNodeInsertion(evict); return null; }
putval(key, value){ index = key.hashcode % tablesize; if(null == table[index]){ table[index] = new node(key, value); }else{ firstNode = table[index]; nextNode = firstNode.next; while(nextNode.hasNextNode()){ //如果找到相同key的旧节点,覆盖旧节点 if(key == nextNode.key){ nextNode = new node(key, value); break; } //如果到队列末尾依然没有找到相同key的旧节点,将新结点加入到最后一个节点的末尾 if(nextNode.next == null){ nextNode.next = new node(key, value); break; } nextNode = nextNode.next; } } }
代码注释中已经稍有提及,这里再分别展开讨论。
①数组容量选择
HashMap的初始容量是 1 << 4,也就是16。以后每次扩容都是size << 1,也就是扩容成原来容量的2倍。如此设计是因为 2^n-1(n≥1)的二进制表示总是为重复的1,方便进行求余运算。
《数据结构与算法分析.Java语言描述》一书中的建议是容量最好是质数,有助于降低冲突,但没有给出证明或实验数据。
质数虽然是神奇的数字,但个人感觉在这里并没有特别的用处。
根据公式index = hashCode % size可知,无论size是质数还是非质数,index的值区间都是0至(size-1)之间,似乎真正起决定作用的是hashCode的随机性要好。
这里先不下结论,待以后再写一个随机函数比较下质数和非质数重复率。
②装填因子
装填因子默认是0.75,也就是说如果数组容量为16,那么当key的数量大于12时,HashMap会进行扩容。
装填因子设计成0.75的目的是为了平衡时间和空间性能。过小会导致数组太过于稀疏,空间利用率不高;过大会导致冲突增大,时间复杂度会上升。
红黑树是在JDK 1.8中引入的,想用简单的语言来讲清楚红黑树的数据结构、增删改查操作及时间复杂度分析,这是一个复杂且艰难的任务,更适合单独来描述,因此留待以后吧。
Jdk中的HashMap解决了任意数据集的时间复杂度问题,所设计的哈希函数在未知数据集的情况下有良好的表现。
但如果有一个已知数据集(如Java关键字集合),如何设计一个哈希函数才能同时满足以下两方面的要求:
⑴ 容器容量与数据集合的大小完全一致;
⑵ 沒有任何衝突。
也就是說,當給定一個確定的資料集時,如果一個雜湊函數能讓容器的每一個節點有且僅有一個資料元素,這樣的雜湊函數便稱之為最小完美雜湊函數。
最近在研究編譯原理,提到如何解決關鍵字集合的O(1)時間複雜度的查找問題,提到了可以採用最小完美雜湊函數。看到一個這樣的名詞,瞬間覺得很好很高大上。
以上是從數組到HashMap之演算法解釋的詳細內容。更多資訊請關注PHP中文網其他相關文章!