麻豆小视频在线观看_中文黄色一级片_久久久成人精品_成片免费观看视频大全_午夜精品久久久久久久99热浪潮_成人一区二区三区四区

首頁 > 學(xué)院 > 開發(fā)設(shè)計 > 正文

Java 數(shù)組到 HashMap 之算法解釋

2019-11-14 09:25:49
字體:
供稿:網(wǎng)友

學(xué)習(xí)java的同學(xué)注意了!!! 學(xué)習(xí)過程中遇到什么問題或者想獲取學(xué)習(xí)資源的話,歡迎加入Java學(xué)習(xí)交流群,群號碼:183993990  我們一起學(xué)Java!

一、數(shù)組是什么?

忘了在哪本書里曾看到過類似這樣的一句話“所有的數(shù)據(jù)結(jié)構(gòu)都是數(shù)組的演化”,想想其實是有道理的,因為計算機的內(nèi)存其實就是線性的存儲空間。

Java示例代碼:

int[] array = new int[5]

忽略對象頭信息和數(shù)組長度信息,JVM執(zhí)行時會在堆中分配20個字節(jié)的內(nèi)存空間,看起來就是這樣的:

這樣的數(shù)據(jù)結(jié)構(gòu)可以很方便地通過數(shù)組下標(biāo)存取數(shù)據(jù),但在查找時需要遍歷數(shù)組,平均時間復(fù)雜度為O(n/2)。

當(dāng)數(shù)據(jù)量很大或者查找操作頻繁的時候,這樣的遍歷操作幾乎是不可接受的。那么,如何才能夠在更短的時間內(nèi)快速找到數(shù)據(jù)呢?

二、二分查找

假如數(shù)組內(nèi)的元素是已經(jīng)排序的,那么很自然的方式就是使用二分查找。

譬如有一個整形數(shù)組的長度為1000,數(shù)組內(nèi)的整數(shù)從小到大排列,如果我想要知道6000是否在此數(shù)組中。那么我可以先查看array[500]的數(shù)字是否為6000,array[500]的數(shù)字比6000小,那么就查看array[750]位置的數(shù)字……依次類推,那么最多經(jīng)過10次,就可以確定結(jié)果。

此算法的時間復(fù)雜度為O(logN)。

然而,大部分情況下數(shù)組元素都是無序的,而排序所需要的時間復(fù)雜度O(NlogN)通常都遠(yuǎn)遠(yuǎn)超過遍歷所需要的時間。

那么,問題又回到了原點。該如何在無序的數(shù)據(jù)中快速找到數(shù)據(jù)呢?

三、懵懂的思考

還記得剛學(xué)編程不久時看《編程珠璣》,其中有一段描述:20世紀(jì)70年代,Mike Lesk為電話公司做了電話號碼查找功能,譬如輸入LESK*M*對應(yīng)的數(shù)字鍵5375*6*,可以返回正確的電話號碼或可選列表,錯誤匹配率僅0.2%。

怎么才能做到?

當(dāng)時我還完全不了解數(shù)據(jù)結(jié)構(gòu)和算法,還原下當(dāng)時天馬行空思考的過程,現(xiàn)在看起來仍然是很有意思的。

1、加法

所有數(shù)字相加(*鍵為11),5375*6*的和為48。大多數(shù)輸入的和不超過100,意味著幾萬個電話號碼都會聚集在數(shù)組前100的位置,所以是不可行的。

2、乘法

所有數(shù)字相乘,積為381150。這似乎是可行的,但出現(xiàn)了這樣的問題:lesk、lsek、slke……的積是一樣的,每一個數(shù)字鍵還對應(yīng)著3個字母,意味著重復(fù)的概率會很高。

3、改進(jìn)后的乘法

①字母相同但字母序不同的字符串,可以通過這樣的方式來避免沖突:每一個數(shù)字先乘以序號,然后再與其它值相乘。

②每一個數(shù)字鍵對應(yīng)了3個英文字母,如果用戶沒有輸入字母在數(shù)字鍵中的序號,是沒辦法再進(jìn)一步精確計算的。能考慮的方向只能是根據(jù)給定的單詞表來做概率統(tǒng)計,所以不予考慮。

4、位置沖突

即使用改進(jìn)后的乘法,不同的姓名字母計算得出的積依然還是可能會發(fā)生重復(fù),那么當(dāng)發(fā)生沖突后應(yīng)該怎么辦?

順序保存到下一個空白位置?仔細(xì)想想這是行不通的。如果下一個空白位置剛好又是另外的字母集合的積,那就產(chǎn)生了二次沖突。

幸好,還有質(zhì)數(shù)。

質(zhì)數(shù)只能被1和自身整除,那么上述乘法得出的任何積都不可能是質(zhì)數(shù),所以質(zhì)數(shù)位置是沒有保存電話號碼的。

因此,以當(dāng)前積為起點向右搜索下一個質(zhì)數(shù),如果該質(zhì)數(shù)位置依然被使用,那么就繼續(xù)查找下一個最近的質(zhì)數(shù)……

至此,問題似乎是解決了。

用戶輸入一串?dāng)?shù)字,根據(jù)公式計算得到積,返回該下標(biāo)位置的電話號碼。如果不正確,再順序查找后面的質(zhì)數(shù),直到某個質(zhì)數(shù)下標(biāo)的數(shù)組元素為空為止,最后返回所有查找到的電話號碼。大部分情況下,只需要O(1)的時間復(fù)雜度就可以找到電話號碼。

5、數(shù)組太大

唯一的問題是,按照上述思路建立的數(shù)組實在是太大了。一個姓名有10個字母,假如每一個字母對應(yīng)的數(shù)字都是9,最后得到的積約是9的17次方。這意味著要建立9^17大小的數(shù)組,這是完全不可行的。

6、后來

即使不考慮數(shù)組過大因素,以我當(dāng)時初學(xué)編程的水平,這樣的程序也是沒有能力寫出來的。

我之所以還原這個思考的過程,是覺得獨立思考的過程非常有趣也很有價值。想想,其實現(xiàn)有的算法都是當(dāng)年那些大牛在實際工程中一步一步尋求解決方案而最終推導(dǎo)得到的。

因此,當(dāng)在工程中碰到一些棘手的難題時,只要樂于思考分解問題并尋求每一個小問題解決方案,那么很多所謂的難題都是可以解決的。

后來看了《數(shù)據(jù)結(jié)構(gòu)與算法分析.Java語言描述》,我才知道原來我的思路其實就是開放尋址法(Open Addressing)。

JDK的HashMap使用的則是分離鏈接法(separate chaining)。不同:增加了桶的概念來保存沖突的數(shù)據(jù);進(jìn)行求余運算來降低數(shù)組大小。

那么,就談?wù)凧ava中的HashMap吧。

四、HashMap結(jié)構(gòu)及算法詳解

HashMap的本質(zhì)依然是數(shù)組,而且是一個不定長的多維數(shù)組,近似于下圖這樣的結(jié)構(gòu):

1、簡單介紹

HashMap中的數(shù)組保存鏈表的頭節(jié)點。

保存數(shù)據(jù):

計算key的hashCode,再與數(shù)組長度進(jìn)行求余運算得到數(shù)組下標(biāo)(鏈表頭節(jié)點)。

如果該位置為空,生成一個新的鏈表節(jié)點并保存到該數(shù)組。

如果該位置非空,循環(huán)比對鏈表的每一個節(jié)點:已經(jīng)存在key相同的節(jié)點,覆蓋舊節(jié)點;不存在key相同的節(jié)點,將新節(jié)點作為鏈表的尾節(jié)點(注:查看JDK1.8中的源碼,新節(jié)點總是加入到鏈表末尾,而不是如JDK1.6一般作為鏈表頭節(jié)點)。

查找數(shù)據(jù):

計算key的hashCode,再與數(shù)組長度進(jìn)行求余運算得到數(shù)組下標(biāo)(鏈表頭節(jié)點)。如果鏈表不為空,先比對首節(jié)點,如果首節(jié)點key相同(hashCode相同且equals為true),直接返回首節(jié)點的value;首節(jié)點key不同則順序遍歷比對鏈表其它節(jié)點,返回key相同的節(jié)點的value(未找到key相同的節(jié)點則返回null)。

HashMap引入鏈表的目的就是為了解決上一節(jié)討論過的重復(fù)沖突問題。

注意事項:

如果所有key的hashcode相同,假定均為0,則0%4 = 0,所有元素就會都保存到鏈表0,保存和查找每一個數(shù)據(jù)都需要遍歷鏈表0。那么,此時的HashMap實質(zhì)上已經(jīng)退化成了鏈表,時間復(fù)雜度也從設(shè)計的O(1)上升到了O(n/2)。

為了盡可能地讓HashMap的查找和保存的時間復(fù)雜度保持在O(1),就需要讓元素均勻地分布在每一個鏈表,也就是每一個鏈表只保存一個元素。

那么影響因素有哪些?

一是key的hashcode不能重復(fù),如果重復(fù)就肯定會有鏈表保存至少2個元素;二是哈希函數(shù)設(shè)計,如果只是簡單的求余,那么余數(shù)會有大量重復(fù);三是鏈表的大小,如果100個元素要分布在長度為10的數(shù)組,無論怎么計算都會導(dǎo)致其中有鏈表保存多個元素,最好的情況是每個鏈表保存10個;

下面分別詳細(xì)介紹這三個因素的算法設(shè)計。

2、hashcode生成

這是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可以轉(zhuǎn)換成UTF-8編碼。譬如,’a’、’b’、’c’的UTF-8編碼分別是97,98,99;“abc”根據(jù)上面的代碼轉(zhuǎn)換成公式就是:

h = 31 * 0 + 97 = 97;h = 31 * 97 + 98 = 3105;h = 31 * 3105 + 99 = 96354;

使用31作為乘數(shù)因子是因為它是一個比較合適大小的質(zhì)數(shù):如值過小,當(dāng)參與計算hashcode的項數(shù)較少時會導(dǎo)致積過小;如為偶數(shù),則相當(dāng)于是左位移,當(dāng)乘法溢出時會造成有規(guī)律的位信息丟失。而這兩者都會導(dǎo)致重復(fù)率增加。

如果使用32作為乘數(shù)因子(相當(dāng)于 << 5),以字符串“abcabcabcabcabc”為例,它的hashcode的每一步計算結(jié)果如下圖:

如上圖所示,字符串末尾每3個字母就會產(chǎn)生一個重復(fù)的hashcode。這并不是一個巧合,即使換成其它的英文字母,也有很容易產(chǎn)生重復(fù),而使用質(zhì)數(shù)則會大大地減少重復(fù)可能性。有興趣的可以參照上圖去作一下左位移運算,會發(fā)現(xiàn)這并不是偶然。

《Effective Java》一書中詳細(xì)描述了hashcode的生成規(guī)則和注意事項,有興趣的可以去看看。

從源代碼的hashCode()方法可知,String類對象保存了已經(jīng)計算好的hashCode,如果已經(jīng)調(diào)用過hashCode()方法,那么第二次調(diào)用時不會再重新生成,而是直接返回已經(jīng)計算好的hashCode。

String對象總是會存放到String類私有維護(hù)的常量池中,不顯式使用new關(guān)鍵字時,如果常量池中已經(jīng)有value相同的對象,那么總是會返回已有對象的引用。因此,很多情況下生成hashCode這種比較昂貴的操作實際上并不需要執(zhí)行。

3、哈希函數(shù)設(shè)計

現(xiàn)在,已經(jīng)得到了重復(fù)率很低的hashCode,還有什么美中不足的地方嗎?

擾動函數(shù)

  static final int hash(Object key) {      int h;      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  }

下圖還是以字符串“abcabcabcabcabc”為例,使用上面方法得到的運算過程和結(jié)果。

為什么要先無符號右位移16位,然后再執(zhí)行異或運算?看看下圖的二進(jìn)制的與運算,你就會明白。

你會發(fā)現(xiàn)只要二進(jìn)制數(shù)后4位不變,前面的二進(jìn)制位無論如何變化都會出現(xiàn)相同的結(jié)果。為了防止出現(xiàn)這種高位變化而低位不變導(dǎo)致的運算結(jié)果相同的情況,因此需要將高位的變化也加入進(jìn)來,而將整數(shù)的二進(jìn)制位上半部與下半部進(jìn)行異或操作就可以得到這個效果。

為什么要使用與運算?

因為哈希函數(shù)的計算公式就是hashCode % tableSize,當(dāng)tableSize是2的n次方(n≥1)的時候,等價于hashCode & (tableSize – 1)。

擾動函數(shù)優(yōu)化前:1954974080 % 16 = 1954974080 & (16 – 1) = 0擾動函數(shù)優(yōu)化后:1955003654 % 16 = 1955003654 & (16 – 1) = 6

這就是為什么需要增加擾動函數(shù)的原因。

源代碼詳解

代碼解釋之前需要補充說明一下,jdk1.8引入了紅黑樹來解決大量沖突時的查找效率,所以當(dāng)一個鏈表中的數(shù)據(jù)大到一定規(guī)模的時候,鏈表會轉(zhuǎn)換成紅黑樹。因此在jdk1.8中,HashMap的查找和保存數(shù)據(jù)的最大時間復(fù)雜度實際上就是紅黑樹的時間復(fù)雜度O(logN)。

以下是HashMap中的保存數(shù)據(jù)的方法源代碼,相信經(jīng)過以上的描述,已經(jīng)非常容易看懂這一段代碼。

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {    Node<K,V>[] tab;    //HashMap數(shù)組    Node<K,V> p;      //初始化需要保存的數(shù)據(jù)    int n;         //數(shù)組容量    int i;         //數(shù)組下標(biāo)    /* 如果數(shù)組為空或0,初始化容量為16 */    if ((tab = table) == null || (n = tab.length) == 0){      n = (tab = resize()).length;    }    /* 使用哈希函數(shù)獲取數(shù)組位置(如果為空,保存新節(jié)點到數(shù)組) */    if ((p = tab[i = (n - 1) & hash]) == null){      tab[i] = newNode(hash, key, value, null);    }    /* 如果數(shù)組位置已經(jīng)有值,則使用下列方式保存數(shù)據(jù) */    else {      Node<K,V> e;    //臨時節(jié)點保存新值      K k;        //臨時變量用于比較key      //如果頭節(jié)點與新節(jié)點的key的hash值相同 且 key的值相等,e賦值為舊節(jié)點      if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){        e = p;      }      //如果頭節(jié)點是一個紅黑樹節(jié)點,那么e將保存為樹節(jié)點      else if (p instanceof TreeNode){      	e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);      }      //如果頭節(jié)點與新節(jié)點不同,且頭節(jié)點不是紅黑樹節(jié)點,循環(huán)比對鏈表的所有節(jié)點      else {        for (int binCount = 0; ; ++binCount) {          if ((e = p.next) == null) {            //如果直到鏈表尾未找到相同key的節(jié)點,將新結(jié)點作為最后一個節(jié)點加入到鏈表            p.next = newNode(hash, key, value, null);            //如果鏈表節(jié)點數(shù)大于等于8-1,轉(zhuǎn)換成紅黑樹;轉(zhuǎn)換成紅黑樹的最小節(jié)點數(shù)是8            if (binCount >= TREEIFY_THRESHOLD - 1){              treeifyBin(tab, hash);            }            break;          }          //如果循環(huán)過程中發(fā)現(xiàn)新舊key的值相同,跳轉(zhuǎn):是否覆蓋舊值          if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){            break;          }          p = e;        }      }      //是否覆蓋舊值      if (e != null) {        V oldValue = e.value;        //如果新值不為空且(允許修改舊值 或 舊值為空),覆蓋舊節(jié)點的值        if (!onlyIfAbsent || oldValue == null){          e.value = value; 	        }        afterNodeaccess(e);  //回調(diào)函數(shù),這里是空函數(shù),但在linkedHashMap中實現(xiàn)了此方法        return oldValue;    //返回舊值      }    }    //用于比較判斷是否在遍歷集合過程中有其它線程修改集合,詳情請網(wǎng)上搜索fail-fast快速失敗機制    ++modCount;    //當(dāng)key數(shù)量大于允許容量時進(jìn)行擴容。允許容量在HashMap中是數(shù)組長度 * 裝填因子(默認(rèn)0.75)    if (++size > threshold){    	resize();    }    //回調(diào)函數(shù),這里是空函數(shù),但在linkedHashMap中實現(xiàn)了此方法    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的舊節(jié)點,覆蓋舊節(jié)點        if(key == nextNode.key){          nextNode = new node(key, value);            break;        }        //如果到隊列末尾依然沒有找到相同key的舊節(jié)點,將新結(jié)點加入到最后一個節(jié)點的末尾        if(nextNode.next == null){          nextNode.next = new node(key, value);          break;        }        nextNode = nextNode.next;      }    }  }

鏈表大小設(shè)計

代碼注釋中已經(jīng)稍有提及,這里再分別展開討論。

①容量選擇

HashMap的初始容量是 1 << 4,也就是16。以后每次擴容都是size << 1,也就是擴容成原來容量的2倍。如此設(shè)計是因為 2^n-1(n≥1)的二進(jìn)制表示總是為重復(fù)的1,方便進(jìn)行求余運算。

《數(shù)據(jù)結(jié)構(gòu)與算法分析.Java語言描述》一書中的建議是容量最好是質(zhì)數(shù),有助于降低沖突,但沒有給出證明或?qū)嶒灁?shù)據(jù)。

質(zhì)數(shù)雖然是神奇的數(shù)字,但個人感覺在這里并沒有特別的用處。

根據(jù)公式index = hashCode % size可知,無論size是質(zhì)數(shù)還是非質(zhì)數(shù),index的值區(qū)間都是0至(size-1)之間,似乎真正起決定作用的是hashCode的隨機性要好。

這里先不下結(jié)論,待以后再寫一個隨機函數(shù)比較下質(zhì)數(shù)和非質(zhì)數(shù)重復(fù)率。

②裝填因子

裝填因子默認(rèn)是0.75,也就是說如果數(shù)組容量為16,那么當(dāng)key的數(shù)量大于12時,HashMap會進(jìn)行擴容。

裝填因子設(shè)計成0.75的目的是為了平衡時間和空間性能。過小會導(dǎo)致數(shù)組太過于稀疏,空間利用率不高;過大會導(dǎo)致沖突增大,時間復(fù)雜度會上升。

關(guān)于其它

紅黑樹是在JDK 1.8中引入的,想用簡單的語言來講清楚紅黑樹的數(shù)據(jù)結(jié)構(gòu)、增刪改查操作及時間復(fù)雜度分析,這是一個復(fù)雜且艱難的任務(wù),更適合單獨來描述,因此留待以后吧。

五、最小完美哈希函數(shù)(Minimal Perfect Hash Function, MPHF)

Jdk中的HashMap解決了任意數(shù)據(jù)集的時間復(fù)雜度問題,所設(shè)計的哈希函數(shù)在未知數(shù)據(jù)集的情況下有良好的表現(xiàn)。

但如果有一個已知數(shù)據(jù)集(如Java關(guān)鍵字集合),如何設(shè)計一個哈希函數(shù)才能同時滿足以下兩方面的要求:

⑴ 容器容量與數(shù)據(jù)集合的大小完全一致;⑵ 沒有任何沖突。

也就是說,當(dāng)給定一個確定的數(shù)據(jù)集時,如果一個哈希函數(shù)能讓容器的每一個節(jié)點有且僅有一個數(shù)據(jù)元素,這樣的哈希函數(shù)便稱之為最小完美哈希函數(shù)。

最近在研究編譯原理,提到說如何解決關(guān)鍵字集合的O(1)時間復(fù)雜度的查找問題,提到了可以采用最小完美哈希函數(shù)。看到一個這樣的名詞,瞬間就覺得很好很高大上。

學(xué)習(xí)Java的同學(xué)注意了!!! 學(xué)習(xí)過程中遇到什么問題或者想獲取學(xué)習(xí)資源的話,歡迎加入Java學(xué)習(xí)交流群,群號碼:183993990  我們一起學(xué)Java!


發(fā)表評論 共有條評論
用戶名: 密碼:
驗證碼: 匿名發(fā)表
主站蜘蛛池模板: 91成人在线网站 | 久久久三级免费电影 | 国产羞羞视频在线观看免费应用 | 成人视屏在线 | 99精品国产成人一区二区 | 91精品国产综合久久久动漫日韩 | 日本特级a一片免费观看 | 青青草在线免费观看 | 久久艹国产精品 | 久久精品欧美视频 | 久久精品国产清自在天天线 | 天堂精品久久 | 免费观看一区二区三区视频 | 欧美亚洲国产成人综合在线 | 免费国产精品视频 | 黄色网址电影 | 久久噜噜噜精品国产亚洲综合 | 一级黄色免费大片 | 日本免费一区二区三区四区 | 一级电影免费在线观看 | 日韩av成人 | 黄色久| 久久精品欧美视频 | 精品久久久久久久久久中文字幕 | 色播久久| 国产一级毛片高清视频完整版 | 久久精品性视频 | 欧美成人精品一级 | 99爱视频在线观看 | 国产69精品久久久久久久久久 | 爱唯侦察 国产合集 亚洲 | 黄色av片三级三级三级免费看 | 亚洲欧美不卡视频 | 亚洲va久久久噜噜噜久牛牛影视 | 一级黄色毛片免费 | 婷婷亚洲一区二区三区 | 一级黄色毛片播放 | 亚洲天堂在线电影 | 在火车上摸两乳爽的大叫 | 香蕉视频网站在线观看 | 国产一级在线观看视频 |