1.1 要用就要提前想好的數據結構——數組
要用就要提前想好?為什么?這其實是由數組的一個特點決定的,那就是對於數組這個數據結構,在用它之前必須提前想好它的長度;有了長度,才能知道該為這個存儲結構開辟多少空間;而在決定了長度之后,不管我們最后往里面填充的數據夠不夠長,沒有用到的空間也就都浪費了;如果我們想往這個數組中放入的數據超過了提前設定好的長度,那么是不可行的,因為空間只有這么大。
1.1.1 什么是數組
數組(Array),就是把有限個數據類型一樣的元素按順序放在一起,用一個變量命名,然后通過編號可以按順序訪同指定位置的元素的一個有序集合。
其實簡單來說,就是為了方便而把這些元素放在一起。我們通過編號去獲取每個元素,這個編號叫作下標或者索引(Index),一般的語言是從0開始的。
我們常說的數組一般指一維數組,當然還有多維數組,雖然多維數組並不常用。
多維的實現其實是數組的某些元素本身也是一個數組,這里以一個標准的二維數組為例進行介紹。其實,二維數組相當於每個元素的長度都一樣的一個一維數組(也就是我們常說的數組)。
在很多弱語言中,並不要求每個元素的長度都一樣,可以某些元素是數組(長度可以不一樣),某些元素不是數組,甚至每個元素的數據類型都不同。這里講的二維數組指的是標准的二維數組。
注:弱類型語言也叫作弱類型定義語言,簡稱弱語言。弱語言一般對語言的標准沒有特別的要求。比如在 JavaScript 中用 var 聲明變量,不會指定該變量是哪種類型,如果想更多地了解弱語言,則請參考JavaScript,該語言主要用於前端開發。強語言對編寫規則比較有要求。
1.1.2 數組的存儲結構
在了解了什么是數組之后,我們來看下數組的存儲結構,
1.數組的存儲結構
首先我們來看下一維數組的存儲結構,如圖1-1所示。
其實,我們先要確定一個值,也就是數組的長度:然后,系統會根據我們聲明的數據類型開辟一些空間(當然,每種數據類型需要開辟的空間也不一樣)。這時,這些空間就歸這個變量所有了。一般在編程語言的實現中,這些空間會默認對我們聲明的數據類型賦值,比如整型值是0,布爾值是false,等等。所以有以下幾種情況。
(1)只聲明了指定長度的空間,沒有初始化值(以整型為例,所有值都會默認為0,如圖1-2所示)。
(2)聲明了指定長度的空間,初始化了部分值(以整型為例,未初始化的值都會默認為0,如圖1-3所示)。
(3)聲明了指定長度的空間,初始化了全部的值,如圖1-4所示。
2. 數組在編程語言中的初始化及操作
在多數語言中,數組的聲明都是非常簡單的,一般有下面幾種聲明方式(以Java語言、整型為例,其他語言、數據類型差異不大)。
int[] num1 = new int[]; int[] num2 = {1, 2, 3}; int[] num3 = new int[3]; num3[0] = 1; num3[1] = 2; num3[2] = 3;
數組指定位置的元素的值,是通過下標獲取的,下標在大部分語言中是從0開始的。
int[] num = {1, 2, 3}; int a = num[0]; // a的值為0 int b = num[1]; // b的值為1
為數組賦值,和獲取元素的值類似,可以直接賦值。
int[] num = {1, 2, 3}; num[1] = 10; // 現在num的數組元素分別為1, 10, 3
數組常用的另一種方式是按順序訪問每一個值,一般通過編程語言中的循環語句實現(比如for循環)。
int[] num = { 1, 2, 3 }; for (int i = 0; i < num.length; i++) { System.out.println(num[i]); }
上面展示了循環打印數組的值的代碼,其中 num.length 可以獲取數組的長度。細心的同學可以發現這個 length 后面沒有括號,是的,這個 length 是數組的內置屬性(以Java為例,有些語言會同時提供兩種或更多的獲取數組長度的方式)。
下面我們看下二維數組的存儲結構,如圖1-5所示。
二維數組的初始化方式實際上和一維數組沒有太大的區別,只不過我們需要提前確定第1維和第2維的長度。
在圖1-5中,第1維的長度為3,每維的元素又是一個長度為6的數組。
3. 多維數組在編程語言中的初始化及操作
由於多維數組與一維數組的初始化及訪問區別不大,下面集中進行列舉。
int[][] num = new int[3][3]; int[][] num2 = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } }; int a = num2[1][0];// a的值是4 int b = num2[1].length;// b的值為3,即第二維的長度
二維數組的訪問方式其實和一維數組和多維數組的訪問方式沒什么區別,我們只需要理解為數組的元素也是個數組。
1.1.3 數組的特點
因為本身存儲的方式,數組有如下特點。
1. 定長
數組的長度是固定的,這是數組最重要的一個特點。只要我們在聲明時確定了數組的長度,在賦值時就算沒有給數組的所有元素賦值,未賦值的元素也是有初始默認值的;而如果我們在賦值時發現數組的長度不夠用,這時也沒有什么好辦法,因為數組的長度無法改變。要是想繼續存放數據,就只能重新聲明一個數組了。
2. 按順序訪問
我們在訪問一個數組中的某個元素時,必須從第1個元素開始按順序訪問,直到訪問到指定位置的元素。這里想說明一點,雖然我們在開發時可以直接通過下標訪問指定位置的元素,但是實際上計算機在處理時也是按順序訪問的。
1.1.4 數組的適用場景
數組其實是一個非常簡單的數據結構,用起來也比較簡單。但是,數組是所有數據結構的基礎,我們必須掌握好數組,才能更好地學習其他數據結構和算法。數組適合在什么時候用,其實根據數組的特點我們就可以想到,由於數組的長度一般是固定的,所以在不會出現變化的業務上比較適合使用數組。
注:本書所舉例的適用場景只是可以並且多數會這樣做,並不是說這種使用方式是最優的或者可不替代的、實際上有些團隊可能會有一些更好的實現方式。
1.技能快捷鍵
不知道大家對RPG(ARPG)等類似的游戲了解多少,在這類游戲中會有一排快捷鍵技能格,比如F1~F9這樣9個快捷鍵技能格,我們每個人可以把自己慣用的技能拖動到這些技能格上,這樣就可以直接通過技能快捷鍵操控技能了。一般在這種設計中,一個游戲的快捷鍵格子會有固定的個數。於是,我們在程序里就可以通過數組來存儲每個人的技能快捷鍵對應的技能(當然,肯定會通過一定的映射存到數據庫之類的磁盤上)。
2.優酷8+1
先聲明,我沒有參與制作優酷8+1,這里只是舉例。優酷8+1是什么?打開優酷頁,會看到最上面的1個大圖、8個小圖,這就是優酷8+1了。
這里我們可以聲明一個長度為9的數組,里面的每個元素是一個對象(對象是高級語言中的一個名詞,包含了一系列的變量和方法),這些對象至少應該包含圖片地址(用於展示圖片)和URL地址(用於在單擊后跳轉)。
到這里,我們應該已經很清晰地認識到了數組的劣勢,那就是在用之前必須提前確定數組的長度,而后不管我們的技能是否需要增加快捷鍵位,或者優酷首頁從8+1變成了11+1,都會導致對程序進行一定的改動。這時我們就該認識一下數組的升級版——集合了。
1.2 升級版數組——集合
數組的致命缺點就是長度固定,如果我們一開始不確定長度,該怎么辦呢?這時就需要集合了,其實集合也是基於數組實現的。在許多高級語言中,集合是對數組的一個拓展,我們在向里面放數據時,想放多少就可以放多少,不用在意集合到底能放多少(當然得內存夠用才行)。
1.2.1 什么是集合
集合的概念有些寬泛。本節講的集合主要是可變長度的列表(也叫作動態數組)。下面這些都是集合。
- 列表:一般是有序的集合,特點就是有順序,比如鏈表、隊列、棧等。
- 集:一般是無序的集合,特點就是沒有順序並且數據不能重復,多數語言是使用散列表實現的,支持對集進行添加、刪除、查找包含等操作。
- 多重集:一般是無序的集合,特點是沒有順序,但是數據可以有重復的值,支持對集進行添加、刪除、查找包含、查找一個元素在集中的個數等操作。多重集一般可以通過排序轉換為列表。
- 關聯數組:其實多數語言是使用散列表實現的,就是可以通過鍵(Key)獲取到值(Value),同樣是沒有順序的。
- 樹、圖:同樣是集合,我們會在后面進行詳細了解。
1.2.2 集合的實現
本節說的集合在數據結構書中本來是不會有的,但我還是決定介紹一下,這不管是對我們拓展思路還是了解更多的內容,都是有幫助的。
這里以 Java 中的 ArrayList 為例,它是一個數組的列表,其實就是數組的拓展,或者說是可變長度的數組。
上面一直提到,這個 ArrayList 是基於數組實現的,這如何理解呢?其實就是在 ArrayList 里有個屬性,這個屬性是一個數組。另外,還會有個屬性記錄我們放了多少數據,這樣我們再向其中放數據時,就會知道該向這個內部數組的哪個位置放數據了,但是這個數組也會有長度限制,若超過了這個限制該怎么辦呢?當超過這個限制時,其內部會創建一個具有更長的長度的數組,然后把舊數組的數據復制到新數組里面,這樣就可以繼續往里面放數據了。
在外部,我們感覺不到這個 ArrayList 是有長度限制的,它在自己內部都處理好了。下面我們通過圖1-6來形象地理解一下這個流程吧。
注:在面向對象的編程語害中一般把成員變量稱為屬性。
下面我們先來簡單地用代碼實現這個變長數組。
public class MyArrayList { private static final int INITIAL_SIZE = 10; private int size = 0; private int[] array; public MyArrayList() { array = new int[INITIAL_SIZE]; } public MyArrayList(int initial) { if (initial <= 10) { initial = INITIAL_SIZE; } array = new int[initial]; } /** * 添加元素 * * @param num */ public void add(int num) { if (size == array.length) { array = Arrays.copyOf(array, size * 2); } array[size++] = num; } /** * 獲取指定位置的元素值 * * @param i * @return */ public int get(int i) { if (i >= size) { throw new IndexOutOfBoundsException("獲取的元素位置超過了最大長度"); } return array[i]; } /** * 設置指定位置的元素值 * * @param i * @param num * @return */ public int set(int i, int num) { int oldNum = get(i); array[i] = num; return oldNum; } /** * 獲取變長數組的長度 * * @return */ public int size() { return size; } }
這里以整型為例簡單實現了變長數組。
可以看到,其中有兩個屬性:一個是array,就是內部數組;另一個是 size,用來存當前變長數組的長度。當調用 add 向變長數組中放值時,要確認內部數組是否足夠放這個值,若不夠,就生成一個長度是原數組長度的兩倍的新數組,並且復制舊數組的數據到新數組里,再放值。
這里新建數組並復制舊數組的數據,是通過 Java 內部的一個工具類實現的,底層調用的是本地方法(native),效率很高。
在調用過程中,我們完全不用在意其內部是怎么實現的,只需往里面添加值、獲取值就好了。
很多編程語言的實現,要比這個實現復雜得多,因為除了這些簡單的操作,還需要一些更復雜的操作,另外要考慮很多其他問題,比如這個數組的增幅怎樣設置比較合理等(上面的代碼是每次直接增加為兩倍。當然,這部分實現對於每種語言,甚至每種語言的每個版本都不一定一樣)。
1.2.3 集合的特點
集合的特點,也和它的實現有關,那就是變長。變長是相對而言的,內部還是通過數組實現的,只是在不夠長時根據一定的策略生成一個更長的數組,把舊數組中的數據復制到新數組里使用。
所以在正常情況下會有兩個系統開銷:、個是數組總是比我們實際使用的長度長,所以存在空間浪費:另一個是當數組不夠長時,需要新建一個更長的數組,同時把舊數組的數據復制到新數組中,這個操作會比較消耗系統的性能。
1.2.4 集合的適用場景
集合的適用場景很多。現在基本上所有的批量查詢及獲得一定條件的數據列表,都使用變長數組。比如查詢某游戲中一個玩家包裹里的所有物品,若不清楚物品的數量,則會用變長數組去存儲返回的結果。
博客的文章列表、評論列表等,只要涉及列表,就會有集合的身影。
是不是有了變長數組就夠了?當然不夠,在后面的算法學習中,我們會了解到數組的查詢效率是很低的,所以要使用一些更復雜的其他數據結構,來幫助我們完成更高效的算法實現。
1.2.5 數組與變長數組的性能
雖然集合這個變長數組比普通數組高級一些,但它本質上是基於數組實現的,所以與數組的性能差不多。
對數組的操作,並不像我們看到的那么直觀,計算機需要根據我們具體操作的位置,從頭到尾一個一個地尋找到指定的位置,所以在數組中增加元素、修改元素、獲取元素等操作的時間復雜度都為O(n)。
變長數組也有性能損耗的問題,在插入元素時若發現其中的固定數組長度不夠,則需要新建更長的數組,還得復制元素,這都會造成性能損耗。
注:在算法中,每種算法的性能指標一般有兩個,即時間復雜度和空間復雜度。在設計算法的過程中,時間復雜度和空間復雜度往往是互相影響的,所以一般會在其中根據實際應用場景尋找一個最優的實現。
- 時間復雜度:在計算機科學中,算法的時間復雜度是一個數,它定量描述了該算法的運行時間,這是一個關於代表算法輸入值的字符串的長度的函數,常用大O符號描述,不包括這個函數的低階項和首項系數,它實際上描述了算法執行的時間。
- 空間復雜度:是對一個算法在運行過程中臨時占用存儲空間大小的量度。
1.3 數組的其他應用——散列表
我們在前面提到集合其實是有很多種的,散列表也算是集合的一種。為什么需要散列表呢?實際上順序存儲的結構類型需要一個一個地按順序訪問元素,當這個總量很大且我們所要訪問的元素比較靠后時,性能就會很低。
散列表是一種空間換時間的存儲結構,是在算法中提升效率的一種比較常用的方式,但是所需空間太大也會讓人頭疼,所以通常需要在二者之間權衡。我們會在之后的具體算法章節中得到更多的領悟。
1.3.1 什么是散列表
讓我們想一下,若在手機通信錄中查找一個人,那我們應該不會從第1個人一直找下去,因為這樣實在是太慢了。我們其實是這樣做的:首先看這個人的名字的首字母是什么,比如姓張,那么我們一定會滑到最后,因為“Z”姓的名字都在最后。
還有在查字典時,要查找一個單詞,肯定不會從頭翻到尾,而是首先通過這個單詞的首字母,找到對應的那一頁;再找第2個字母、第3個字母...這樣可以快速跳到那個單詞所在的頁。
其實這里就用到了散列表的思想。
散列表,又叫哈希表(HashTable),是能夠通過給定的關鍵字的值直接訪問到具體對應的值的一個數據結構。也就是說,把關鍵字映射到一個表中的位置來直接訪問記錄,以加快訪問速度。
通常,我們把這個關鍵字稱為Key,把對應的記錄稱為Value,所以也可以說是通過Key訪問一個映射表來得到Value的地址。而這個映射表,也叫作散列函數或者哈希函數,存放記錄的數組叫作散列表。
其中有個特殊情況,就是通過不同的Key,可能訪問到同一個地址,這種現象叫作碰撞(Collision)。而通過某個Key一定會得到唯一的Value地址。
目前,這個哈希函數比較常用的實現方法比較多,通常需要考慮幾個因索:關鍵字的長度、哈希表的大小、關鍵字的分布情況、記錄的查找頻率,等等。
下面簡單介紹幾種哈希函數。
1. 直接尋址法
取關鍵字或關鍵字的某個線性函數值為散列地址。
2. 數字分析法
通過對數據的分析,發現數據中沖突較少的部分,並構造散列地址。例如同學們的學號,通常同一屆學生的學號,其中前面的部分差別不太大,所以用后面的部分來構造散列地址。
3.平方取中法
當無法確定關鍵字里哪幾位的分布相對比較均勻時,可以先求出關鍵字的平方值,然后按需要取平方值的中間幾位作為散列地址。這是因為:計算平方之后的中間幾位和關鍵字中的每一位都相關,所以不同的關鍵字會以較高的概率產生不同的散列地址。
4.取隨機數法
使用一個隨機函數,取關鍵字的隨機值作為散列地址,這種方式通常用於關鍵字長度不同的場合。
5.除留取余法
取關鍵字被某個不大於散列表的表長n的數m除后所得的余數p為散列地址。這種方式也可以在用過其他方法后再使用。該函數對m的選擇很重要,一般取素數或者直接用n。
1.3.2 對散列表函數產生沖突的解決辦法
散列表為什么會產生沖突呢?前面提到過,有時不同的Key通過哈希函數可能會得到相同的地址,這在我們操作時可能會對數據造成覆蓋、丟失。之所以產生沖突是由於哈希數有時對不同的Key計算之后獲得了相同的地址。沖突的處理方式也有很多,下面介紹幾種。
1. 開放地址法(也叫開放尋址法)
實際上就是當需要存儲值時,對Key哈希之后,發現這個地址已經有值了,這時該怎么辦?不能放在這個地址,不然之前的映射會被覆蓋。這時對計算出來的地址進行一個探測再哈希,比如往后移動一個地址,如果沒人占用,就用這個地址。如果超過最大長度,則可以對總長度取余。這里移動的地址是產生沖突時的增列序量。
2.再哈希法
在產生沖突之后,使用關鍵字的其他部分繼續計算地址,如果還是有沖突,則繼續使用其他部分再計算地址。這種方式的缺點是時間增加了。
3. 鏈地址法
鏈地址法其實就是對Key通過哈希之后落在同一個地址上的值,做一個鏈表。其實在很多高級語言的實現當中,也是使用這種方式處理沖突的,我們會在后面着重學習這種方式。
4. 建立一個公共溢出區
這種方式是建立一個公共溢出區,當地址存在沖突時,把新的地址放在公共溢出區里。
1.3.3 散列表的存儲結構
一個好的散列表設計,除了需要選擇一個性能較好的哈希函數,否則沖突是無法避免的,所以通常還需要有一個好的沖突處理方式。這里我們選擇除留取余法作為哈希函數,選擇鏈地址法作為沖突處理方式。具體存儲結構如圖下1-7所示。
在大多數時候,我們通過數組定義散列表,如圖1-7所示,這時我們聲明的數組長度是8,也就是說散列表的長度為8。
這里以 Key 的類型為整型數字為例,用 Key 對長度8取余,必定會得到很大的沖突,這時每個地址並不放真正的值,而是記錄一個鏈表的起始地址。當然,在實際情況下我們不會讓這個哈希表這么短,這里只是舉個簡單的例子。
通過這種方式,我們可以快速地找到 Key 所對應的 Value 在哪個地址上,如果在這個地址上鏈表比較長,則也需要一個個地去檢索。
在通常情況下,新增元素時如果遇到了沖突,那么鏈表會有兩種方式去插入元素。
- 一種方式是直接把新元素的下一個元素指向原來鏈表的第1個元素,然后把剛剛對應上的那個地址鏈表頭的下一個元素指向新建的元素。這種方式的好處是在插入元素時會比較快,因為不需要遍歷鏈表,而是直接改變頭部的指向關系。
- 另一種方式是使鏈表元素有序,這種方式的劣勢就是在每次插入元素時需要遍歷鏈表,在中間打開鏈表、插入元素。
注:這里涉及了一些鏈表知識,讀者可以簡單地將其理解為一條鏈子,每個元素都會有一個指向下一個元素地址的指針。也可以將其理解為數組,是有序的、挨着的。
有的讀者會問,這里是以整數為例的,萬一這個 Key 需要是一個字符串類型呢?其實在很多編程語言的實現中,都會存在將一個類型轉換為整型的方法,比如 Java 中的每個對象都有個 hashCode 方法,通過獲取字符串的這個方法,可以將一個字符串輕松地轉換為整型。當然,這種方法還可能返回負數,這也是可以直接使用絕對值解決的。
1.3.4 散列表的特點
散列表有兩種用法:一種是 Key 的值與 Value 的值一樣,一般我們稱這種情況的結構為Set(集合);而如果 Key 和 Value 所對應的內容不一樣時,那么我們稱這種情況為 Map。也就是人們俗稱的鍵值對集合。
根據散列表的存儲結構,我們可以得出散列表的以下特點。
1. 訪問速度很快
由於散列表有散列函數,可以將指定的 Key 都映射到一個地址上,所以在訪問一個Key(鍵)對應的Value(值)時,根本不需要一個一個地進行查找,可以直接跳到那個地址。所以我們在對散列表進行添加、刪除、修改、查找等任何操作時,速度都很快。
2. 需要額外的空間
首先,散列表實際上是存不滿的,如果一個散列表剛好能夠存滿,那么肯定是個巧合。而且當散列表中元素的使用率越來越高時,性能會下降,所以一般會選擇擴容來解決這個問題。
另外,如果有沖突的話,則也是需要額外的空間去存儲的,比如鏈地址法,不但需要額外的空間,甚至需要使用其他數據結構。
這個特點有個很常用的詞可以表達,叫作“空間換時間”,在大多數時候,對於算法的實現,為了能夠有更好的性能,往往會考慮犧牲些空間,讓算法能夠更快些。
3.無序
散列表還有一個非常明顯的特點,那就是無序。為了能夠更快地訪問元素,散列表是根據散列函數直接找到存儲地址的,這樣我們的訪問速度就能夠更快,但是對於有序訪問卻沒有辦法去應對。
4. 可能會產生碰撞
沒有完美的散列函數,無論如何總會產生沖突,這時就需要采用沖突解決方案,這也使散列表更加復雜。通常在不同的高級語言的實現中,對於沖突的解決方案不一定一樣。
1.3.5 散列表的適用場景
根據散列表的特點可以想到,散列表比較適合無序、需要快速訪問的情況。
1. 緩存
通常我們開發程序時,對一些常用的信息會做緩存,用的就是散列表,比如我們要緩存用戶的信息,一般用戶的信息都會有唯一標識的字段,比如ID。這時做緩存,可以把ID作為 Key,而 value 用來存儲用戶的詳細信息,這里的 Value 通常是一個對象(高級語言中的術語,前面提到過),包含用戶的一些關鍵字段,比如名字、年齡等。
在我們每次需要獲取一個用戶的信息時,就不用與數據庫這類的本地磁盤存儲交互了(其實在大多數時候,數據庫可能與我們的服務不在一台機器上,還會有相應的網絡性能損耗),可以直接從內存中得到結果。這樣不僅能夠快速獲取數據,也能夠減輕數據庫的壓力。
有時我們要查詢一些數據,這些數據與其他數據是有關聯的,如果我們進行數據庫的關聯查詢,那么效率會非常低,這時可以分為兩部分進行查詢:將被關聯的部分放入散列表中,只需要遍歷一遍;對於另一部分數據,則通過程序手動關聯,速度會很快,並且由於我們是通過散列表的 Key、value 的對應關系對應數據的,所以性能也會比較好。
我之前所在的一家公司曾要做一個大查詢,查詢和數據組裝的時間達到了40秒,當然,數據量本身也比較大。但是,40秒實在讓人無法忍受,於是我優化了這段代碼,發現可以通過散列表處理來減少很多重復的查詢,最終做到了4秒左右的查詢耗時,速度快了很多。
2.快速查找
這里說的查找,不是排序,而是在集合中找出是否存在指定的元素。這樣的場景很多,比如我們要在指定的用戶列表中查找是否存在指定的用戶,這時就可以使用散列表了.在這個場景下使用的散列表其實是在上面提到的Set類型,實際上不需要 Value 這個值。
還有一個場景,我們一般對網站的操作會有個地址黑名單,我們認為某些IP有大量的非法操作,於是封鎖了這些IP對我們網站的訪問。這個IP是如何存儲的呢?就是用的散列表。當一個訪問行為發送過來時,我們會獲取其IP,判斷其是否存在於黑名單中,如果存在,則禁止其訪問。這種情況也是使用的Set。
當然,對於上面說的兩個例子,用列表也是可以實現的,但是訪問速度會受到很大的影響,尤其是列表越來越長時,查找速度會很慢。散列表則不會。
1.3.6 散列表的性能分析
散列表的訪問,如果沒有碰撞,那么我們完全可以認為對元素的訪問是O(1)的時間復雜度,因為對於任何元素的訪問,都可以通過散列函數直接得到元素的值所在的地址。
但是實際上不可能沒有碰撞,所以我們不得不對碰撞進行一定的處理。
我們常用鏈表方式進行解決(當然,也有一些語言使用開放尋址方式解決,Java 使用鏈表解決),由於可能會產生碰撞,而碰撞之后的訪問需要遍歷鏈表,所以時間復雜度將變為O(L),其中L為鏈表的長度。當然,在大多數時候不一定會碰撞,而很多Key也不一定剛好都碰撞到一個地址上,所以性能還是很不錯的。
上面提到了一個情況,那就是有可能分配的地址即散列表的元素大部分被使用了,這時再向散列表中添加元索,就很容易產生碰撞了,甚至散列表分配的地址越在后面使用,越容易被占用。這時該怎么辦呢?解決辦法很簡單,就是上面提到的——擴容。
比如之前在存儲結構一節舉例的,散列表長度只有8,很容易被占滿,一般不會等到真的占滿了才去擴容,而是會提前擴容。這里涉及一個叫作擴充因子的術語(也叫作載荷因子,意思是達到這個值時,其性能就不好了),是一個小數,在使用散列表的過程中,不會等到把所有地址都用完了才去擴容,而是會在占用地址達到散列表長度乘以擴容因子的這個值時去擴容,一般的擴容會在原有的長度基礎上乘以2作為新的長度。
這里可以直接告訴大家,在Java中,擴容因了默認為0.75(很多語言都是0.75,這算是個經驗數值吧),以之前的存儲結構一節的總長度是8為例,當占用長度達到6時,就會擴容,而擴容后的長度會變為16。
當然,擴容有很多工作要做,除了簡單地增加原本的散列表長度,還需要把之前那些由於碰撞而存放在一個地址的鏈表上的元素重新進行哈希運算,有可能之前存在碰撞的元素,現在不會碰撞了(比如圖1-7中值為1和9的數,由於現在總長度為16了,所以它們通過除留取余法,不會指到同一個地址了)。
下面展示如何用代碼實現一個簡單的散列表,這里用數組代替散列表元素(在真實的高級語言實現中,大多數元素都是一個特別的數組,每個元素對應一個地址),每個數組元素作為一個地址。
首先需要一個元素類,這個類用於存儲Key及Value,實際上就是鏈表上的每一個元素。實現起來非常簡單。
public class Test { public static void main(String[] args) { MyHashTable table = new MyHashTable(); table.put(1, 10); table.put(2, 20); table.put(5, 50);// 和key為1的元素落到一個散列表地址上了,實際使用長度為2 System.out.println(table.getLength());// 散列表長為4 table.put(3, 30);// 總長度為4,加上該元素后長度就大於等於3了,所以擴容 System.out.println(table.getLength());// 散列表長為8 // 在擴容后4個元素又分別落到不同的地址上 table.put(6, 60);// 使用了5個地址 table.put(7, 70);// 使用了6個地址,為8的0.75倍,又需要擴容 System.out.println(table.getLength());// 散列表長為16 System.out.println(table.get(1));// 10 System.out.println(table.get(3));// 30 System.out.println(table.get(5));// 50 System.out.println(table.get(6));// 60 } } class Entry { int key; int value; Entry next; public Entry(int key, int value, Entry next) { super(); this.key = key; this.value = value; this.next = next; } } class MyHashTable { /** * 默認散列表的初始化長度 設置小一點,這樣我們能夠清楚地看到擴容 在實際使用中其實可以在初始化時傳參,要知道,擴容也是很損耗性能的 */ private static final int DEFAULT_INITIAL_CAPACITY = 4; /** * 擴容因子 */ private static final float LOAD_FACTOR = 0.75f; /** * 散列表數組 */ private Entry[] table = new Entry[DEFAULT_INITIAL_CAPACITY]; private int size = 0;// 散列表元素的個數 private int use = 0;// 散列表使用地址的個數 /** * 根據key,通過哈希函數獲取位於散列表數組中的哪個位置 * * @param key * @return */ private int hash(int key) { return key % table.length; } /** * 擴容 */ private void resize() { int newLength = table.length * 2; Entry[] oldTable = table; table = new Entry[newLength]; use = 0; for (int i = 0; i < oldTable.length; i++) { if (oldTable[i] != null && oldTable[i].next != null) { Entry e = oldTable[i]; while (null != e.next) { Entry next = e.next; // 重新計算哈希值,放入新的地址中 int index = hash(next.key); if (table[index] == null) { use++; table[index] = new Entry(-1, -1, null); } Entry temp = table[index].next; Entry newEntry = new Entry(next.key, next.value, temp); table[index].next = newEntry; e = next; } } } } public void put(int key, int value) { int index = hash(key); if (table[index] == null) { table[index] = new Entry(-1, -1, null); } Entry e = table[index]; if (e.next == null) { // 不存在值,向鏈表添加,有可能擴容,要用table屬性 table[index].next = new Entry(key, value, null); size++; use++; // 不存在值,說明是個未用過的地址,需要判斷是否需要擴容 if (use >= table.length * LOAD_FACTOR) { resize(); } } else { // 本身存在值,修改已有的值 for (e = e.next; e != null; e = e.next) { int k = e.key; if (k == key) { e.value = value; return; } } // 存在不同的值,直接向鏈表中添加元素 Entry temp = table[index].next; Entry newEntry = new Entry(key, value, temp); table[index].next = newEntry; size++; } } /** * 刪除 * * @param key */ public void remove(int key) { int index = hash(key); Entry e = table[index]; Entry pre = table[index]; if (e != null && e.next != null) { for (e = e.next; e != null; pre = e, e = e.next) { int k = e.key; if (k == key) { pre.next = e.next; size--; return; } } } } /** * 獲取 * * @param key * @return */ public int get(int key) { int index = hash(key); Entry e = table[index]; if (e != null && e.next != null) { for (e = e.next; e != null; e = e.next) { int k = e.key; if (k == key) { return e.value; } } } // 若沒有找到,則返回-1 return -1; } /** * 獲取散列表中元素的個數 * * @return */ public int size() { return size; } /** * 本身散列表是不該有這個方法的,在這里只是為了讓我們知道它確實擴容了 * * @return */ public int getLength() { return table.length; } }