6.5.散列
6.5.散列
在前面的部分中,我們可以利用關(guān)于元素在集合中存儲(chǔ)位置的信息來(lái)改進(jìn)搜索算法。例如,通過知道一個(gè)列表是有序的,我們可以使用二分搜索在對(duì)數(shù)時(shí)間內(nèi)進(jìn)行搜索。在本節(jié)中,我們將嘗試進(jìn)一步構(gòu)建一個(gè)可以在O(1)時(shí)間內(nèi)進(jìn)行搜索的數(shù)據(jù)結(jié)構(gòu)。這個(gè)概念被稱為散列。
為了做到這一點(diǎn),當(dāng)我們?cè)诩现袑ふ宜鼈儠r(shí),我們將需要知道更多關(guān)于這些元素可能在哪里的信息。如果每個(gè)元素都在應(yīng)該的位置,那么搜索可以使用單個(gè)比較來(lái)發(fā)現(xiàn)元素的存在。然而,我們將看到,通常情況并非如此。
哈希表是一組元素的集合,這些項(xiàng)的存儲(chǔ)方式使以后很容易找到它們。哈希表的每個(gè)位置(通常稱為槽)可以容納一個(gè)元素,并由從0開始的整數(shù)值命名。例如,我們將有一個(gè)名為0的插槽,一個(gè)名為1的插槽,一個(gè)名為2的插槽,依此類推。最初,哈希表不包含任何項(xiàng),因此每個(gè)槽都是空的。我們可以使用一個(gè)列表來(lái)實(shí)現(xiàn)哈希表,每個(gè)元素都初始化為特殊的Python值None。圖4顯示了一個(gè)大小為\(m=11\)的哈希表。換句話說,表中有m個(gè)插槽,名為0到10。
哈希表中該元素所屬的槽之間的映射稱為哈希函數(shù)。哈希函數(shù)將接受集合中的任何項(xiàng),并返回槽名稱范圍內(nèi)的整數(shù),介于0和m-1之間。假設(shè)我們有一組整數(shù)項(xiàng)54、26、93、17、77和31。我們的第一個(gè)散列函數(shù),有時(shí)被稱為“remainder method”,它只接受一個(gè)元素并除以表大小,返回余數(shù)作為其哈希值(h(item)=item%11)。表4給出了示例項(xiàng)的所有哈希值。注意,這個(gè)余數(shù)方法(模算術(shù))通常以某種形式出現(xiàn)在所有散列函數(shù)中,因?yàn)榻Y(jié)果必須在槽名稱的范圍內(nèi)。
54 | 10 |
26 | 4 |
93 | 5 |
17 | 6 |
77 | 0 |
31 | 9 |
一旦計(jì)算出哈希值,我們就可以將每個(gè)元素插入到哈希表中指定的位置,如圖5所示。請(qǐng)注意,11個(gè)插槽中的6個(gè)現(xiàn)在已被占用。這被稱為荷載系數(shù),通常表示為λ=numberofitemstablesizeλ=\frac{numberofitems}{tablesize}λ=tablesizenumberofitems?,對(duì)于這個(gè)例子,λ=611λ=\frac{6}{11}λ=116?。
Figure 5: Hash Table with Six Items
現(xiàn)在,當(dāng)我們想要搜索一個(gè)條目時(shí),我們只需使用hash函數(shù)來(lái)計(jì)算該條目的槽名,然后檢查哈希表以查看它是否存在。這個(gè)搜索操作是O(1),因?yàn)橛?jì)算哈希值并在該位置索引哈希表需要一定的時(shí)間量。如果一切都在它應(yīng)該在的地方,我們已經(jīng)找到了一個(gè)固定時(shí)間搜索算法。
您可能已經(jīng)看到,只有當(dāng)每個(gè)項(xiàng)映射到哈希表中的一個(gè)唯一位置時(shí),這種技術(shù)才有效。例如,如果元素44是我們集合中的下一個(gè)元素,它的哈希值將為0(44%11==0)。因?yàn)?7的散列值也是0,我們就有問題了。根據(jù)hash函數(shù),兩個(gè)或多個(gè)項(xiàng)需要在同一個(gè)槽中。這被稱為哈希碰撞。顯然,碰撞會(huì)給哈希技術(shù)帶來(lái)問題。我們稍后會(huì)詳細(xì)討論。
6.5.1.哈希函數(shù)
給定一組元素,將每個(gè)元素映射到唯一槽的哈希函數(shù)稱為完美哈希函數(shù)。如果我們知道元素和集合永遠(yuǎn)不會(huì)改變,那么就可以構(gòu)造一個(gè)完美的哈希函數(shù)(有關(guān)完美哈希函數(shù)的更多信息,請(qǐng)參閱練習(xí))。不幸的是,給定一個(gè)任意的元素集合,沒有系統(tǒng)的方法來(lái)構(gòu)造一個(gè)完美的散列函數(shù)。幸運(yùn)的是,我們不需要哈希函數(shù)是完美的,仍然可以獲得性能效率。
始終擁有一個(gè)完美的哈希函數(shù)的一種方法是增加哈希表的大小,以便可以容納項(xiàng)范圍中的每個(gè)可能值。這保證每個(gè)項(xiàng)目都有一個(gè)唯一的槽。雖然這對(duì)于數(shù)量較少的項(xiàng)目是可行的,但是當(dāng)可能的項(xiàng)目數(shù)量很大時(shí),這是不可行的。例如,如果項(xiàng)目是9位數(shù)的社會(huì)保障號(hào)碼,這種方法將需要近10億個(gè)插槽。如果我們只想為一個(gè)25人的班級(jí)存儲(chǔ)數(shù)據(jù),我們將浪費(fèi)大量的內(nèi)存。
我們的目標(biāo)是創(chuàng)建一個(gè)哈希函數(shù),該函數(shù)將沖突數(shù)量最小化,易于計(jì)算,并且在哈希表中均勻地分布項(xiàng)目。有許多常用的方法來(lái)擴(kuò)展簡(jiǎn)單余數(shù)方法。我們將在這里考慮其中一些。
構(gòu)造散列函數(shù)的折疊方法首先將元素分成大小相等的部分(最后一部分可能大小不同)。然后將這些片段相加,得到結(jié)果散列值。例如,如果我們的項(xiàng)目是電話號(hào)碼436-555-4601,我們會(huì)將數(shù)字分成2組(43,65,55,46,01)。加上43+65+55+46+01,得到210。如果我們假設(shè)哈希表有11個(gè)槽,那么我們需要執(zhí)行除以11并保留剩余部分的額外步驟。在本例中,210%11是1,因此電話號(hào)碼436-555-4601哈希到插槽1。有些折疊方法更進(jìn)一步,在添加之前每隔一片反轉(zhuǎn)一次。對(duì)于上面的例子,我們得到了43+56+55+64+01=219,其中219%11=10。
另一種構(gòu)造散列函數(shù)的數(shù)值技術(shù)稱為中央平方法。我們首先將元素平方,然后提取得到的數(shù)字的一部分。例如,如果項(xiàng)目是44,我們將首先計(jì)算442=1936。通過提取中間兩位數(shù)93,并執(zhí)行余數(shù)步驟,我們得到5(93%11)。表5顯示了余數(shù)法和中平方法下的項(xiàng)目。您應(yīng)該確認(rèn)您了解這些值是如何計(jì)算的。
54 | 10 | 3 |
26 | 4 | 7 |
93 | 5 | 9 |
17 | 6 | 8 |
77 | 0 | 4 |
31 | 9 | 6 |
我們還可以為基于字符的元素(如字符串)創(chuàng)建哈希函數(shù)。“cat”這個(gè)詞可以看作是一系列序數(shù)值。
>>> ord('c') 99 >>> ord('a') 97 >>> ord('t') 116然后,我們可以取這三個(gè)序數(shù)值,將它們相加,然后使用remainder方法得到一個(gè)散列值(參見圖6)。Listing 1顯示了一個(gè)名為hash的函數(shù),它接受一個(gè)字符串和一個(gè)表大小,并返回0到tablesize-1范圍內(nèi)的散列值。
Figure 6: Hashing a String Using Ordinal Values
Listing 1
def hash(astring, tablesize):sum = 0for pos in range(len(astring)):sum = sum + ord(astring[pos])return sum%tablesize有趣的是,當(dāng)使用這個(gè)散列函數(shù)時(shí),anagrams將始終被賦予相同的散列值。為了彌補(bǔ)這個(gè)問題,我們可以使用角色的位置作為權(quán)重。圖7顯示了使用位置值作為權(quán)重因子的一種可能方法。對(duì)散列函數(shù)的修改作為練習(xí)。
Figure 7: Hashing a String Using Ordinal Values with Weighting
您可能可以想出許多其他方法來(lái)計(jì)算集合中遠(yuǎn)古三的哈希值。重要的是要記住哈希函數(shù)必須是高效的,這樣它就不會(huì)成為存儲(chǔ)和搜索過程的主要部分。如果哈希函數(shù)太復(fù)雜,那么計(jì)算槽名的工作量就要比前面描述的簡(jiǎn)單地進(jìn)行基本的順序或二分搜索要多。這將很快破壞散列的目的。
6.5.2.碰撞解決
現(xiàn)在我們回到碰撞問題。當(dāng)兩個(gè)項(xiàng)目哈希到同一個(gè)槽時(shí),我們必須有一個(gè)系統(tǒng)的方法將第二個(gè)項(xiàng)目放入哈希表中。這個(gè)過程稱為碰撞解決。如前所述,如果哈希函數(shù)是完美的,則不會(huì)發(fā)生沖突。然而,由于這通常是不可能的,沖突解決成為哈希的一個(gè)非常重要的部分。
一種解決沖突的方法查看哈希表,并嘗試找到另一個(gè)打開的槽來(lái)保存導(dǎo)致沖突的項(xiàng)。一種簡(jiǎn)單的方法是從原始哈希值位置開始,然后按順序在插槽中移動(dòng),直到遇到第一個(gè)空的插槽。注意,我們可能需要回到第一個(gè)槽(循環(huán))來(lái)覆蓋整個(gè)哈希表。這個(gè)沖突解決過程被稱為開放尋址,因?yàn)樗鼑L試在哈希表中查找下一個(gè)打開的插槽或地址。通過一次一個(gè)地系統(tǒng)地訪問每個(gè)插槽,我們正在執(zhí)行一種稱為線性探測(cè)的開放尋址技術(shù)。
圖8顯示了簡(jiǎn)單余數(shù)方法散列函數(shù)(54,26,93,17,77,31,44,55,20)下整數(shù)項(xiàng)的擴(kuò)展集。上面的表4顯示了原始項(xiàng)的哈希值。圖5顯示了原始內(nèi)容。當(dāng)我們?cè)噲D將44放入插槽0時(shí),會(huì)發(fā)生碰撞。在線性探測(cè)下,我們按順序逐槽查找,直到找到一個(gè)打開的位置。在本例中,我們找到插槽1。
同樣,55應(yīng)該進(jìn)入插槽0,但必須放在插槽2中,因?yàn)樗窍乱粋€(gè)打開的位置。最后一個(gè)值是20個(gè)哈希到槽9。由于插槽9已滿,我們開始進(jìn)行線性探測(cè)。我們?cè)L問插槽10、0、1和2,最后在位置3找到一個(gè)空插槽。
Figure 8: Collision Resolution with Linear Probing
一旦我們使用開放尋址和線性探測(cè)構(gòu)建了一個(gè)哈希表,就必須使用相同的方法來(lái)搜索項(xiàng)。假設(shè)我們要查找93號(hào)條目。當(dāng)我們計(jì)算散列值時(shí),我們得到5。在第5槽中顯示93,我們可以返回True。如果我們要找20個(gè)呢?現(xiàn)在哈希值是9,槽位9當(dāng)前保存31。我們不能簡(jiǎn)單地返回False,因?yàn)槲覀冎揽赡苡信鲎病N覀儸F(xiàn)在被迫從第10位開始進(jìn)行順序搜索,直到找到第20項(xiàng)或者找到一個(gè)空的插槽。
線性探測(cè)的一個(gè)缺點(diǎn)是傾向于聚集;項(xiàng)在表中變得聚集。這意味著,如果在同一哈希值下發(fā)生多個(gè)沖突,則線性探測(cè)分辨率將填充周圍的許多插槽。這將對(duì)正在插入的其他項(xiàng)產(chǎn)生影響,正如我們?cè)趪L試添加上面的第20項(xiàng)時(shí)看到的那樣。必須跳過散列到0的值簇,才能最終找到打開的位置。這個(gè)集群如圖9所示。
Figure 9: A Cluster of Items for Slot 0
處理集群的一種方法是擴(kuò)展線性探測(cè)技術(shù),這樣我們就不用按順序查找下一個(gè)打開的插槽,而是跳過插槽,從而更均勻地分布導(dǎo)致沖突的項(xiàng)目。這可能會(huì)減少發(fā)生的群集。圖10顯示了使用“plus 3”探針進(jìn)行沖突解決時(shí)的項(xiàng)目。這意味著一旦發(fā)生碰撞,我們將每隔三個(gè)槽查看一次,直到找到一個(gè)空槽為止。
Figure 10: Collision Resolution Using “Plus 3”
這個(gè)在碰撞后尋找另一個(gè)插槽的過程的通稱是重新散列。對(duì)于簡(jiǎn)單的線性探測(cè),rehash函數(shù)是newhashvalue=rehash(oldhashvalue),其中rehash(pos)=(pos+1)%sizeoftable。“plus 3”再哈希可以定義為rehash(pos)=(pos+3)%sizeoftable。通常,rehash(pos)=(pos+skip)% sizeoftable。需要注意的是,“跳過”的大小必須使表中的所有槽最終都被訪問。否則,該表的一部分將不被使用。為了確保這一點(diǎn),通常建議表大小為質(zhì)數(shù)。這就是我們?cè)谑纠惺褂?1的原因。
線性探測(cè)思想的一種變體稱為二次探測(cè)。我們不使用常量“skip”值,而是使用一個(gè)rehash函數(shù),該函數(shù)將哈希值遞增1、3、5、7、9,依此類推。這意味著,如果第一個(gè)散列值是h,則后續(xù)值為h+1、h+4、h+9、h+16,依此類推。一般來(lái)說,i將是i2 rehash(pos)=(h+i2)。換句話說,二次探測(cè)使用由連續(xù)的完美正方形組成的跳躍。圖11顯示了使用此技術(shù)放置后的示例值。
Figure 11: Collision Resolution with Quadratic Probing
處理沖突問題的另一種方法是允許每個(gè)槽保存對(duì)項(xiàng)目集合(或鏈)的引用。鏈接允許許多元素存在于哈希表中的同一位置。當(dāng)發(fā)生沖突時(shí),該項(xiàng)仍然放在哈希表的適當(dāng)槽中。隨著越來(lái)越多的項(xiàng)散列到同一位置,在集合中搜索該項(xiàng)的難度增加。圖12顯示了將這些項(xiàng)添加到哈希表中時(shí)的情況,該表使用鏈接來(lái)解決沖突。
Figure 12: Collision Resolution with Chaining
當(dāng)我們要搜索一個(gè)項(xiàng)目時(shí),我們使用哈希函數(shù)生成它應(yīng)該駐留的槽。由于每個(gè)槽都包含一個(gè)集合,所以我們使用搜索技術(shù)來(lái)確定該項(xiàng)是否存在。這樣做的好處是,平均而言,每個(gè)槽中的項(xiàng)目可能會(huì)少很多,因此搜索效率可能會(huì)更高。我們將在本節(jié)末尾查看哈希分析。
6.5.3.實(shí)現(xiàn)Map抽象數(shù)據(jù)類型
最有用的Python集合之一是dictionary。回想一下,字典是一種關(guān)聯(lián)數(shù)據(jù)類型,您可以在其中存儲(chǔ)鍵-數(shù)據(jù)對(duì)。鍵用于查找關(guān)聯(lián)的數(shù)據(jù)值。我們經(jīng)常把這個(gè)想法稱為map。
map抽象數(shù)據(jù)類型的定義如下。結(jié)構(gòu)是鍵和數(shù)據(jù)值之間關(guān)聯(lián)的無(wú)序集合。映射中的鍵都是唯一的,因此鍵和值之間存在一對(duì)一的關(guān)系。操作如下所示。
- Map()創(chuàng)建一個(gè)新的空映射。它返回一個(gè)空的映射集合。
- put(key,val)向映射添加新的鍵值對(duì)。如果鍵已經(jīng)在映射中,則用新值替換舊值。
- get(key)給定一個(gè)鍵,返回存儲(chǔ)在映射中的值,否則返回None。
- del使用del map[key]格式的語(yǔ)句從映射中刪除鍵值對(duì)。
- len()返回存儲(chǔ)在映射中的鍵值對(duì)的數(shù)目。
- in如果給定鍵在映射中,則返回True,否則返回False。
字典的最大好處之一是,給定一個(gè)鍵,我們可以很快地查找相關(guān)的數(shù)據(jù)值。為了提供這種快速查找功能,我們需要一個(gè)支持有效搜索的實(shí)現(xiàn)。我們可以使用帶有順序或二進(jìn)制搜索的列表,但是使用如上所述的哈希表會(huì)更好,因?yàn)樵诠1碇胁檎翼?xiàng)可以達(dá)到O(1)的性能。
在Listing 2中,我們使用兩個(gè)列表來(lái)創(chuàng)建一個(gè)實(shí)現(xiàn)映射抽象數(shù)據(jù)類型的哈希表類。一個(gè)名為slots的列表將保存密鑰項(xiàng),而名為data的并行列表將保存數(shù)據(jù)值。當(dāng)我們查找一個(gè)鍵時(shí),數(shù)據(jù)列表中相應(yīng)的位置將保存相關(guān)的數(shù)據(jù)值。我們將使用前面介紹的思想將密鑰列表視為哈希表。注意,哈希表的初始大小被選擇為11。雖然這是任意的,但重要的是大小是一個(gè)素?cái)?shù),以便沖突解決算法可以盡可能有效。
Listing 2
class HashTable:def __init__(self):self.size = 11self.slots = [None] * self.sizeself.data = [None] * self.sizehashfunction實(shí)現(xiàn)了簡(jiǎn)單的余數(shù)方法。碰撞解決技術(shù)是一種帶有“加1”重影函數(shù)的線性探測(cè)。put函數(shù)(見Listing 3)假設(shè)最終會(huì)有一個(gè)空槽,除非鍵已經(jīng)存在于自身插槽. 它計(jì)算原始哈希值,如果該槽不為空,則迭代rehash函數(shù),直到出現(xiàn)空槽為止。如果非空插槽已經(jīng)包含密鑰,則舊數(shù)據(jù)值將替換為新數(shù)據(jù)值。處理沒有空位的情況是一種練習(xí)。
Listing 3
def put(self,key,data):hashvalue = self.hashfunction(key,len(self.slots))if self.slots[hashvalue] == None:self.slots[hashvalue] = keyself.data[hashvalue] = dataelse:if self.slots[hashvalue] == key:self.data[hashvalue] = data #replaceelse:nextslot = self.rehash(hashvalue,len(self.slots))while self.slots[nextslot] != None and \self.slots[nextslot] != key:nextslot = self.rehash(nextslot,len(self.slots))if self.slots[nextslot] == None:self.slots[nextslot]=keyself.data[nextslot]=dataelse:self.data[nextslot] = data #replacedef hashfunction(self,key,size):return key%sizedef rehash(self,oldhash,size):return (oldhash+1)%size同樣,get函數(shù)(參見清單4)首先計(jì)算初始哈希值。如果該值不在初始槽中,則使用rehash來(lái)定位下一個(gè)可能的位置。注意,第15行通過檢查確保沒有返回到初始槽位來(lái)保證搜索將終止。如果發(fā)生這種情況,我們已經(jīng)用盡了所有可能的插槽,并且該項(xiàng)目不能出現(xiàn)。
HashTable類的final方法提供了額外的字典功能。我們重載了__getitem__和__setitem__方法,以允許使用[]進(jìn)行訪問。這意味著一旦創(chuàng)建了哈希表,熟悉的索引操作符就可以使用了。我們把剩下的方法留作練習(xí)。
def get(self,key):startslot = self.hashfunction(key,len(self.slots))data = Nonestop = Falsefound = Falseposition = startslotwhile self.slots[position] != None and \not found and not stop:if self.slots[position] == key:found = Truedata = self.data[position]else:position=self.rehash(position,len(self.slots))if position == startslot:stop = Truereturn datadef __getitem__(self,key):return self.get(key)def __setitem__(self,key,data):self.put(key,data)下面的會(huì)話顯示HashTable類的實(shí)際操作。首先,我們將創(chuàng)建一個(gè)哈希表,并使用整數(shù)鍵和字符串?dāng)?shù)據(jù)值存儲(chǔ)一些項(xiàng)。
>>> H=HashTable() >>> H[54]="cat" >>> H[26]="dog" >>> H[93]="lion" >>> H[17]="tiger" >>> H[77]="bird" >>> H[31]="cow" >>> H[44]="goat" >>> H[55]="pig" >>> H[20]="chicken" >>> H.slots [77, 44, 55, 20, 26, 93, 17, None, None, 31, 54] >>> H.data ['bird', 'goat', 'pig', 'chicken', 'dog', 'lion', 'tiger', None, None, 'cow', 'cat']接下來(lái)我們將訪問和修改哈希表中的一些項(xiàng)。請(qǐng)注意,鍵20的值正在被替換。
>>> H[20] 'chicken' >>> H[17] 'tiger' >>> H[20]='duck' >>> H[20] 'duck' >>> H.data ['bird', 'goat', 'pig', 'duck', 'dog', 'lion', 'tiger', None, None, 'cow', 'cat'] >> print(H[99]) None完整的哈希表示例可以在ActiveCode 1中找到。
6.5.4.散列分析
我們?cè)谇懊嬲f過,在最好的情況下,散列將提供O(1)恒定時(shí)間搜索技術(shù)。然而,由于碰撞,比較的數(shù)量通常不是那么簡(jiǎn)單。盡管對(duì)哈希的完整分析超出了本文的范圍,但我們可以聲明一些眾所周知的結(jié)果,這些結(jié)果近似于搜索一個(gè)項(xiàng)目所需的比較數(shù)量。
在分析哈希表的使用時(shí),最重要的信息是負(fù)載因子λ。從概念上講,如果λ很小,那么發(fā)生碰撞的幾率就更低,這意味著項(xiàng)目更有可能位于它們所屬的插槽中。如果λ很大,意味著表已滿,則會(huì)有越來(lái)越多的碰撞。這意味著沖突解決更加困難,需要更多的比較才能找到空槽。使用鏈接,增加的碰撞意味著每個(gè)鏈上的項(xiàng)目數(shù)增加。
和以前一樣,我們會(huì)有一個(gè)成功和不成功的搜索結(jié)果。
總結(jié)
- 上一篇: Linux(树莓派)安装 python-
- 下一篇: 621. Task Scheduler