忘了在哪本書裡曾經看到過類似這樣的一句話“所有的數據結構都是數組的演化”,想想其實是有道理的,因為計算機的內存其實就是線性的儲存空間。
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)時間複雜度的查找問題,提到了可以採用最小完美雜湊函數。看到一個這樣的名詞,瞬間覺得很好很高大上。
以上是Java數組到HashMap之演算法詳細介紹的詳細內容。更多資訊請關注PHP中文網其他相關文章!