在介紹Hash算法之前,先給大家來個數據結構中對hash表(散列表)的簡單解釋,然后我再逐步深入,講解一下hash算法。
一、Hash原理——基礎篇
1.1 概念
哈希表就是一種以 鍵-值(key-indexed) 存儲數據的結構,我們只要輸入待查找的值即key,即可查找到其對應的值。
哈希的思路很簡單,如果所有的鍵都是整數,那么就可以使用一個簡單的無序數組來實現:將鍵作為索引,值即為其對應的值,這樣就可以快速訪問任意鍵的值。這是對於簡單的鍵的情況,我們將其擴展到可以處理更加復雜的類型的鍵。
使用哈希查找有兩個步驟:
1. 使用哈希函數將被查找的鍵轉換為數組的索引。在理想的情況下,不同的鍵會被轉換為不同的索引值,但是在有些情況下我們需要處理多個鍵被哈希到同一個索引值的情況。所以哈希查找的第二個步驟就是處理沖突
2. 處理哈希碰撞沖突。有很多處理哈希碰撞沖突的方法,本文后面會介紹拉鏈法和線性探測法。
哈希表是一個在時間和空間上做出權衡的經典例子。如果沒有內存限制,那么可以直接將鍵作為數組的索引。那么所有的查找時間復雜度為O(1);如果沒有時間限制,那么我們可以使用無序數組並進行順序查找,這樣只需要很少的內存。哈希表使用了適度的時間和空間來在這兩個極端之間找到了平衡。只需要調整哈希函數算法即可在時間和空間上做出取舍。
在Hash表中,記錄在表中的位置和其關鍵字之間存在着一種確定的關系。這樣我們就能預先知道所查關鍵字在表中的位置,從而直接通過下標找到記錄。使ASL趨近與0. (ASL:Average Search Length,平均查找長度)
1) 哈希(Hash)函數是一個映象,即: 將關鍵字的集合映射到某個地址集合上,它的設置很靈活,只要這個地址集合的大小不超出允許范圍即可;
2) 由於哈希函數是一個壓縮映象,因此,在一般情況下,很容易產生“沖突”現象,即: key1!=key2,而 f (key1) = f(key2);
3) 只能盡量減少沖突而不能完全避免沖突,這是因為通常關鍵字集合比較大,其元素包括所有可能的關鍵字, 而地址集合的元素僅為哈希表中的地址值。
在構造這種特殊的“查找表” 時,除了需要選擇一個“好”(盡可能少產生沖突)的哈希函數之外;還需要找到一 種“處理沖突” 的方法。所以,下文我們從這兩方面分別來介紹:
1.2 Hash構造函數的方法
1.2.1 直接定址法:
直接定址法是以數據元素關鍵字k本身或它的線性函數作為它的哈希地址,即:H(k)=k 或 H(k)=a * k + b ; (其中a,b為常數)
例1,有一個人口統計表,記錄了從1歲到100歲的人口數目,其中年齡作為關鍵字,哈希函數取關鍵字本身,如圖(1):
地址 |
A1 |
A2 |
…… |
A99 |
A100 |
年齡 |
1 |
2 |
…… |
99 |
100 |
人數 |
980 |
800 |
…… |
495 |
107 |
可以看到,當需要查找某一年齡的人數時,直接查找相應的項即可。如查找99歲的老人數,則直接讀出第99項即可。
地址 |
A0 |
A1 |
…… |
A99 |
A100 |
年份 |
1980 |
1981 |
…… |
1999 |
2000 |
人數 |
980 |
800 |
…… |
495 |
107 |
如果我們要統計的是80后出生的人口數,如上表所示,那么我們隊出生年份這個關鍵字可以用年份減去1980來作為地址,此時f(key)=key-1980
這種哈希函數簡單,並且對於不同的關鍵字不會產生沖突,但可以看出這是一種較為特殊的哈希函數,實際生活中,關鍵字的元素很少是連續的。用該方法產生的哈希表會造成空間大量的浪費,因此這種方法適應性並不強。
此法僅適合於:地址集合的大小 = 關鍵字集合的大小,其中a和b為常數。
1.2.2 數字分析法:
假設關鍵字集合中的每個關鍵字都是由 s 位數字組成 (u1, u2, …, us),分析關鍵字集中的全體,並從中提取分布均勻的若干位或它們的組合作為地址。
數字分析法是取數據元素關鍵字中某些取值較均勻的數字位作為哈希地址的方法。即當關鍵字的位數很多時,可以通過對關鍵字的各位進行分析,丟掉分布不均勻的位,作為哈希值。它只適合於所有關鍵字值已知的情況。通過分析分布情況把關鍵字取值區間轉化為一個較小的關鍵字取值區間。
例2,要構造一個數據元素個數n=80,哈希長度m=100的哈希表。不失一般性,我們這里只給出其中8個關鍵字進行分析,8個關鍵字如下所示:
K1=61317602 K2=61326875 K3=62739628 K4=61343634
K5=62706815 K6=62774638 K7=61381262 K8=61394220
分析上述8個關鍵字可知,關鍵字從左到右的第1、2、3、6位取值比較集中,不宜作為哈希地址,剩余的第4、5、7、8位取值較均勻,可選取其中的兩位作為哈希地址。設選取最后兩位作為哈希地址,則這8個關鍵字的哈希地址分別為:2,75,28,34,15,38,62,20。
此法適於:能預先估計出全體關鍵字的每一位上各種數字出現的頻度。
1.2.3 折疊法:
將關鍵字分割成若干部分,然后取它們的疊加和為哈希地址。兩種疊加處理的方法:移位疊加:將分 割后的幾部分低位對齊相加;邊界疊加:從一端沿分割界來回折疊,然后對齊相加。
所謂折疊法是將關鍵字分割成位數相同的幾部分(最后一部分的位數可以不同),然后取這幾部分的疊加和(舍去進位),這方法稱為折疊法。這種方法適用於關鍵字位數較多,而且關鍵字中每一位上數字分布大致均勻的情況。
折疊法中數位折疊又分為移位疊加和邊界疊加兩種方法,移位疊加是將分割后是每一部分的最低位對齊,然后相加;邊界疊加是從一端向另一端沿分割界來回折疊,然后對齊相加。
例3,當哈希表長為1000時,關鍵字key=110108331119891,允許的地址空間為三位十進制數,則這兩種疊加情況如圖:
移位疊加 邊界疊加 8 9 1 8 9 1 1 1 9 9 1 1 3 3 1 3 3 1 1 0 8 8 0 1 + 1 1 0 + 1 1 0 (1) 5 5 9 (3)0 4 4
用移位疊加得到的哈希地址是559,而用邊界疊加所得到的哈希地址是44。如果關鍵字不是數值而是字符串,則可先轉化為數。轉化的辦法可以用ASCⅡ字符或字符的次序值。
此法適於:關鍵字的數字位數特別多。
1.2.4 平方取中法
這是一種常用的哈希函數構造方法。這個方法是先取關鍵字的平方,然后根據可使用空間的大小,選取平方數是中間幾位為哈希地址。
哈希函數 H(key)=key2 的中間幾位”因為這種方法的原理是通過取平方擴大差別,平方值的中間幾位和這個數的每一位都相關,則對不同的關鍵字得到的哈希函數值不易產生沖突,由此產生的哈希地址也較為均勻。
例4,若設哈希表長為1000則可取關鍵字平方值的中間三位,如圖所示:
關鍵字 |
關鍵字的平方 |
哈希函數值 |
1234 |
1522756 |
227 |
2143 |
4592449 |
924 |
4132 |
17073424 |
734 |
3214 |
10329796 |
297 |
此法適於:關鍵字中的每一位都有某些數字重復出現頻度很高的現象
1.2.5 減去法
減去法是數據的鍵值減去一個特定的數值以求得數據存儲的位置。
例5,公司有一百個員工,而員工的編號介於1001到1100,減去法就是員工編號減去1000后即為數據的位置。編號1001員工的數據在數據中的第一筆。編號1002員工的數據在數據中的第二筆…依次類推。從而獲得有關員工的所有信息,因為編號1000以前並沒有數據,所有員工編號都從1001開始編號。
1.2.6 基數轉換法
將十進制數X看作其他進制,比如十三進制,再按照十三進制數轉換成十進制數,提取其中若干為作為X的哈希值。一般取大於原來基數的數作為轉換的基數,並且兩個基數應該是互素的。
例6:Hash(80127429)=(80127429)13=8*137+0*136+1*135+2*134+7*133+4*132+2*131+9=(502432641)10如果取中間三位作為哈希值,得Hash(80127429)=432
為了獲得良好的哈希函數,可以將幾種方法聯合起來使用,比如先變基,再折疊或平方取中等等,只要散列均勻,就可以隨意拼湊。
1.2.7 除留余數法:
取關鍵字被某個不大於哈希表表長m的數p除后所得余數為哈希地址,即設定哈希函數為 Hash(key)=key mod p (p≤m),其中,除數p稱作模。
除留余數法不僅可以對關鍵字直接取模,也可以在折疊、平方取中等運算后取模。對於除留余數法求哈希地址,關鍵在於模p的選擇。使得數據元素集合中每一個關鍵字通過該哈希函數映射到內存單元的任意地址上的概率相等,從而盡可能減少發生哈希沖突的可能性。
理論研究表明,除留余數法的模p取不大於表長且最接近表長m素數時效果最好,且p最好取1.1n~1.7n之間的一個素數(n為存在的數據元素個數)。例如:當n=7時,p最好取11、13等素數。 又例下圖:
表長m |
8 |
16 |
32 |
64 |
128 |
256 |
512 |
1000 |
模p |
7 |
13 |
31 |
61 |
127 |
251 |
503 |
997 |
由於除留余數法的地址計算方法簡單,而且在許多情況下效果較好。
例7,公司有236個員工,而員工編號介於1000到9999,除留余數法就是員工編號除以數據個數236后,去余數即為數據的位置。編號5428員工的數據(編號5428除以236取余數得0)放數據中的第一筆,編號3512員工數據(編號3512除以236取余數得8)放數據中的第九筆…依次類推。
1.2.8 隨機數法:
亦稱為“乘余取整法”。隨機乘數法使用一個隨機實數f,0≤f<1,乘積f*k的分數部分在0~1之間,用這個分數部分的值與n(哈希表的長度)相乘,乘積的整數部分就是對應的哈希值,顯然這個哈希值落在0~n-1之間。其表達公式為:Hash(k)=「n*(f*k%1)」其中“f*k%1”表示f*k 的小數部分,即f*k%1=f*k-「f*k」[5] ↑
例8,對下列關鍵字值集合采用隨機乘數法計算哈希值,隨機數f=0.103149002 哈希表長度n=100得圖(6):
k |
f*k |
n*((f*k)的小數部分) |
Hash(k) |
319426 |
32948.47311 |
47.78411 |
47 |
718309 |
74092.85648 |
86.50448 |
86 |
629443 |
64926.41727 |
42.14427 |
42 |
919697 |
84865.82769 |
83.59669 |
83 |
此方法的優點是對n的選擇不很關鍵。通常若地址空間為p位就是選n=2p.Knuth對常數f的取法做了仔細的研究,他認為f取任何值都可以,但某些值效果更好。如f=(-1)/2=0.6180329...比較理想。
1.2.9 隨機乘數法
亦稱為“乘余取整法”。隨機乘數法使用一個隨機實數f,0≤f<1,乘積f*k的分數部分在0~1之間,用這個分數部分的值與n(哈希表的長度)相乘,乘積的整數部分就是對應的哈希值,顯然這個哈希值落在0~n-1之間。其表達公式為:Hash(k)=「n*(f*k%1)」其中“f*k%1”表示f*k 的小數部分,即f*k%1=f*k-「f*k」
例9,對下列關鍵字值集合采用隨機乘數法計算哈希值,隨機數f=0.103149002 哈希表長度n=100得圖:
k |
f*k |
n*((f*k)的小數部分) |
Hash(k) |
319426 |
32948.47311 |
47.78411 |
47 |
718309 |
74092.85648 |
86.50448 |
86 |
629443 |
64926.41727 |
42.14427 |
42 |
919697 |
84865.82769 |
83.59669 |
83 |
此方法的優點是對n的選擇不很關鍵。通常若地址空間為p位就是選n=2p.Knuth對常數f的取法做了仔細的研究,他認為f取任何值都可以,但某些值效果更好。如f=(-1)/2=0.6180329...比較理想。
1.2.10 字符串數值哈希法
在很都情況下關鍵字是字符串,因此這樣對字符串設計Hash函數是一個需要討論的問題。下列函數是取字符串前10個字符來設計的哈希函數
Int Hash _ char (char *X) { int I ,sum i=0; while (i 10 && X[i]) Sum +=X[i++]; sum%=N; //N是記錄的條數 }
這種函數把字符串的前10個字符的ASCⅡ值之和對N取摸作為Hash地址,只要N較小,Hash地址將較均勻分布[0,N]區間內,因此這個函數還是可用的。對於N很大的情形,可使用下列函數
int ELFhash (char *key ) { Unsigned long h=0,g; whie (*key) { h=(h<<4)+ *key; key++; g=h & 0 xF0000000L; if (g) h^=g>>24; h & =~g; } h=h % N return (h); }
這個函數稱為ELFHash(Exextable and Linking Format ,ELF,可執行鏈接格式)函數。它把一個字符串的絕對長度作為輸入,並通過一種方式把字符的十進制值結合起來,對長字符串和短字符串都有效,這種方式產生的位置不可能不均勻分布。
1.2.11 旋轉法
旋轉法是將數據的鍵值中進行旋轉。旋轉法通常並不直接使用在哈希函數上,而是搭配其他哈希函數使用。
例11,某學校同一個系的新生(小於100人)的學號前5位數是相同的,只有最后2位數不同,我們將最后一位數,旋轉放置到第一位,其余的往右移。
新生學號 |
旋轉過程 |
旋轉后的新鍵值 |
5062101 |
5062101 |
1506210 |
5062102 |
5062102 |
2506210 |
5062103 |
5062103 |
3506210 |
5062104 |
5062104 |
4506210 |
5062105 |
5062105 |
5506210 |
運用這種方法可以只輸入一個數值從而快速地查到有關學生的信息。
在實際應用中,應根據具體情況,靈活采用不同的方法,並用實際數據測試它的性能,以便做出正確判定。通常應考慮以下五個因素 :
1.計算哈希函數所需時間 (簡單)
2.關鍵字的長度
3.哈希表大小
4.關鍵字分布情況
5.記錄查找頻率
1.3 Hash處理沖突方法
通過構造性能良好的哈希函數,可以減少沖突,但一般不可能完全避免沖突,因此解決沖突是哈希法的另一個關鍵問題。創建哈希表和查找哈希表都會遇到沖突,兩種情況下解決沖突的方法應該一致。下面以創建哈希表為例,說明解決沖突的方法。常用的解決沖突方法有以下四種:
1.3.1 開放定址法
所謂的開放定址法就是一旦發生了沖突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入
公式為:fi(key) = (f(key)+di) MOD m (di=1,2,3,……,m-1)
※ 用開放定址法解決沖突的做法是:當沖突發生時,使用某種探測技術在散列表中形成一個探測序列。沿此序列逐個單元地查找,直到找到給定的關鍵字,或者
碰到一個開放的地址(即該地址單元為空)為止(若要插入,在探查到開放的地址,則可將待插入的新結點存人該地址單元)。查找時探測到開放的地址則表明表
中無待查的關鍵字,即查找失敗。
比如說,我們的關鍵字集合為{12,67,56,16,25,37,22,29,15,47,48,34},表長為12。 我們用散列函數f(key) = key mod l2
當計算前S個數{12,67,56,16,25}時,都是沒有沖突的散列地址,直接存入:
計算key = 37時,發現f(37) = 1,此時就與25所在的位置沖突。
於是我們應用上面的公式f(37) = (f(37)+1) mod 12 = 2。於是將37存入下標為2的位置:
1.3.2 再哈希法
再哈希法又叫雙哈希法,有多個不同的Hash函數,當發生沖突時,使用第二個,第三個,….,等哈希函數
計算地址,直到無沖突。雖然不易發生聚集,但是增加了計算時間。
1.3.3 鏈地址法
鏈地址法的基本思想是:每個哈希表節點都有一個next指針,多個哈希表節點可以用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點可以用這個單向
鏈表連接起來,如:
鍵值對k2, v2與鍵值對k1, v1通過計算后的索引值都為2,這時及產生沖突,但是可以通道next指針將k2, k1所在的節點連接起來,這樣就解決了哈希的沖突問題
1.3.4 建立公共溢出區:
這種方法的基本思想是:將哈希表分為基本表和溢出表兩部分,凡是和基本表發生沖突的元素,一律填入溢出表
二、Hash算法——進階篇
2.1 Hash是什么,它的作用
先舉個例子。我們每個活在世上的人,為了能夠參與各種社會活動,都需要一個用於識別自己的標志。也許你覺得名字或是身份證就足以代表你這個人,但是這種代表性非常脆弱,因為重名的人很多,身份證也可以偽造。最可靠的辦法是把一個人的所有基因序列記錄下來用來代表這個人,但顯然,這樣做並不實際。而指紋看上去是一種不錯的選擇,雖然一些專業組織仍然可以模擬某個人的指紋,但這種代價實在太高了。
而對於在互聯網世界里傳送的文件來說,如何標志一個文件的身份同樣重要。比如說我們下載一個文件,文件的下載過程中會經過很多網絡服務器、路由器的中轉,如何保證這個文件就是我們所需要的呢?我們不可能去一一檢測這個文件的每個字節,也不能簡單地利用文件名、文件大小這些極容易偽裝的信息,這時候,我們就需要一種指紋一樣的標志來檢查文件的可靠性,這種指紋就是我們現在所用的Hash算法(也叫散列算法)。
散列算法(Hash Algorithm),又稱哈希算法,雜湊算法,是一種從任意文件中創造小的數字「指紋」的方法。與指紋一樣,散列算法就是一種以較短的信息來保證文件唯一性的標志,這種標志與文件的每一個字節都相關,而且難以找到逆向規律。因此,當原有文件發生改變時,其標志值也會發生改變,從而告訴文件使用者當前的文件已經不是你所需求的文件。
這種標志有何意義呢?之前文件下載過程就是一個很好的例子,事實上,現在大部分的網絡部署和版本控制工具都在使用散列算法來保證文件可靠性。而另一方面,我們在進行文件系統同步、備份等工具時,使用散列算法來標志文件唯一性能幫助我們減少系統開銷,這一點在很多雲存儲服務器中都有應用。
當然,作為一種指紋,散列算法最重要的用途在於給證書、文檔、密碼等高安全系數的內容添加加密保護。這一方面的用途主要是得益於散列算法的不可逆性,這種不可逆性體現在,你不僅不可能根據一段通過散列算法得到的指紋來獲得原有的文件,也不可能簡單地創造一個文件並讓它的指紋與一段目標指紋相一致。散列算法的這種不可逆性維持着很多安全框架的運營,而這也將是本文討論的重點。
2.2 Hash算法有什么特點
一個優秀的 hash 算法,將能實現:
- 正向快速:給定明文和 hash 算法,在有限時間和有限資源內能計算出 hash 值。
- 逆向困難:給定(若干) hash 值,在有限時間內很難(基本不可能)逆推出明文。
- 輸入敏感:原始輸入信息修改一點信息,產生的 hash 值看起來應該都有很大不同。
- 沖突避免:很難找到兩段內容不同的明文,使得它們的 hash 值一致(發生沖突)。即對於任意兩個不同的數據塊,其hash值相同的可能性極小;對於一個給定的數據塊,找到和它hash值相同的數據塊極為困難。
但在不同的使用場景中,如數據結構和安全領域里,其中對某一些特點會有所側重。
2.2.1 Hash在管理數據結構中的應用
在用到hash進行管理的數據結構中,就對速度比較重視,對抗碰撞不太看中,只要保證hash均勻分布就可以。比如hashmap,hash值(key)存在的目的是加速鍵值對的查找,key的作用是為了將元素適當地放在各個桶里,對於抗碰撞的要求沒有那么高。換句話說,hash出來的key,只要保證value大致均勻的放在不同的桶里就可以了。但整個算法的set性能,直接與hash值產生的速度有關,所以這時候的hash值的產生速度就尤為重要,以JDK中的String.hashCode()方法為例:
public int hashCode() { int h = hash; //hash default value : 0 if (h == 0 && value.length > 0) { //value : char storage char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
很簡潔的一個乘加迭代運算,在不少的hash算法中,使用的是異或+加法進行迭代,速度和前者差不多。
2.2.2 Hash在在密碼學中的應用
在密碼學中,hash算法的作用主要是用於消息摘要和簽名,換句話說,它主要用於對整個消息的完整性進行校驗。舉個例子,我們登陸知乎的時候都需要輸入密碼,那么知乎如果明文保存這個密碼,那么黑客就很容易竊取大家的密碼來登陸,特別不安全。那么知乎就想到了一個方法,使用hash算法生成一個密碼的簽名,知乎后台只保存這個簽名值。由於hash算法是不可逆的,那么黑客即便得到這個簽名,也絲毫沒有用處;而如果你在網站登陸界面上輸入你的密碼,那么知乎后台就會重新計算一下這個hash值,與網站中儲存的原hash值進行比對,如果相同,證明你擁有這個賬戶的密碼,那么就會允許你登陸。銀行也是如此,銀行是萬萬不敢保存用戶密碼的原文的,只會保存密碼的hash值而而已。在這些應用場景里,對於抗碰撞和抗篡改能力要求極高,對速度的要求在其次。一個設計良好的hash算法,其抗碰撞能力是很高的。以MD5為例,其輸出長度為128位,設計預期碰撞概率為1/2128,這是一個極小極小的數字——而即便是在MD5被王小雲教授破解之后,其碰撞概率也非常低。而對於兩個相似的字符串,MD5加密結果如下:
MD5("version1") = "966634ebf2fc135707d6753692bf4b1e"; MD5("version2") = "2e0e95285f08a07dea17e7ee111b21c8"
可以看到僅僅一個比特位的改變,二者的MD5值就天差地別了.
ps : 其實把hash算法當成是一種加密算法,這是不准確的,我們知道加密總是相對於解密而言的,沒有解密何談加密呢,HASH的設計以無法解為目的的。並且如果我們不附加一個隨機的salt值,HASH口令是很容易被字典攻擊入侵的。
2.3 Hash算法是如何實現的?
密碼學和信息安全發展到現在,各種加密算法和散列算法已經不是只言片語所能解釋得了的。在這里我們僅提供幾個簡單的概念供大家參考。
作為散列算法,首要的功能就是要使用一種算法把原有的體積很大的文件信息用若干個字符來記錄,還要保證每一個字節都會對最終結果產生影響。那么大家也許已經想到了,求模這種算法就能滿足我們的需要。
事實上,求模算法作為一種不可逆的計算方法,已經成為了整個現代密碼學的根基。只要是涉及到計算機安全和加密的領域,都會有模計算的身影。散列算法也並不例外,一種最原始的散列算法就是單純地選擇一個數進行模運算,比如以下程序。
1 # 構造散列函數 2 def hash(a): 3 return a % 8 4 5 # 測試散列函數功能 6 print(hash(233)) 7 print(hash(234)) 8 print(hash(235))
# 輸出結果 - 1 - 2 - 3
很顯然,上述的程序完成了一個散列算法所應當實現的初級目標:用較少的文本量代表很長的內容(求模之后的數字肯定小於8)。但也許你已經注意到了,單純使用求模算法計算之后的結果帶有明顯的規律性,這種規律將導致算法將能難保證不可逆性。所以我們將使用另外一種手段,那就是異或。
再來看下面一段程序,我們在散列函數中加入一個異或過程。
# 構造散列函數 def hash(a): return (a % 8) ^ 5 # 測試散列函數功能 print(hash(233)) print(hash(234)) print(hash(235))
# 輸出結果
- 4
- 7
- 6
很明顯的,加入一層異或過程之后,計算之后的結果規律性就不是那么明顯了。
當然,大家也許會覺得這樣的算法依舊很不安全,如果用戶使用連續變化的一系列文本與計算結果相比對,就很有可能找到算法所包含的規律。但是我們還有其他的辦法。比如在進行計算之前對原始文本進行修改,或是加入額外的運算過程(如移位),比如以下程序。
# 構造散列函數 def hash(a): return (a + 2 + (a << 1)) % 8 ^ 5 # 測試散列函數功能 print(hash(233)) print(hash(234)) print(hash(235))
# 輸出結果
- 0
- 5
- 6
這樣處理得到的散列算法就很難發現其內部規律,也就是說,我們並不能很輕易地給出一個數,讓它經過上述散列函數運算之后的結果等於4——除非我們去窮舉測試。
上面的算法是不是很簡單?事實上,下面我們即將介紹的常用算法MD5和SHA1,其本質算法就是這么簡單,只不過會加入更多的循環和計算,來加強散列函數的可靠性。
2.4 Hash有哪些流行的算法
目前流行的 Hash 算法包括 MD5、SHA-1 和 SHA-2。
-
MD4(RFC 1320)是 MIT 的 Ronald L. Rivest 在 1990 年設計的,MD 是 Message Digest 的縮寫。其輸出為 128 位。MD4 已證明不夠安全。
-
MD5(RFC 1321)是 Rivest 於1991年對 MD4 的改進版本。它對輸入仍以 512 位分組,其輸出是 128 位。MD5 比 MD4 復雜,並且計算速度要慢一點,更安全一些。MD5 已被證明不具備”強抗碰撞性”。
-
SHA (Secure Hash Algorithm)是一個 Hash 函數族,由 NIST(National Institute of Standards and Technology)於 1993 年發布第一個算法。目前知名的 SHA-1 在 1995 年面世,它的輸出為長度 160 位的 hash 值,因此抗窮舉性更好。SHA-1 設計時基於和 MD4 相同原理,並且模仿了該算法。SHA-1 已被證明不具”強抗碰撞性”。
-
為了提高安全性,NIST 還設計出了 SHA-224、SHA-256、SHA-384,和 SHA-512 算法(統稱為 SHA-2),跟 SHA-1 算法原理類似。SHA-3 相關算法也已被提出。
可以看出,上面這幾種流行的算法,它們最重要的一點區別就是”強抗碰撞性”。
2.5 那么,何謂Hash算法的「碰撞」?
你可能已經發現了,在實現算法章節的第一個例子,我們嘗試的散列算法得到的值一定是一個不大於8的自然數,因此,如果我們隨便拿9個數去計算,肯定至少會得到兩個相同的值,我們把這種情況就叫做散列算法的「碰撞」(Collision)。
這很容易理解,因為作為一種可用的散列算法,其位數一定是有限的,也就是說它能記錄的文件是有限的——而文件數量是無限的,兩個文件指紋發生碰撞的概率永遠不會是零。
但這並不意味着散列算法就不能用了,因為凡事都要考慮代價,買光所有彩票去中一次頭獎是毫無意義的。現代散列算法所存在的理由就是,它的不可逆性能在較大概率上得到實現,也就是說,發現碰撞的概率很小,這種碰撞能被利用的概率更小。
隨意找到一組碰撞是有可能的,只要窮舉就可以。散列算法得到的指紋位數是有限的,比如MD5算法指紋字長為128位,意味着只要我們窮舉21282128次,就肯定能得到一組碰撞——當然,這個時間代價是難以想象的,而更重要的是,僅僅找到一組碰撞並沒有什么實際意義。更有意義的是,如果我們已經有了一組指紋,能否找到一個原始文件,讓它的散列計算結果等於這組指紋。如果這一點被實現,我們就可以很容易地篡改和偽造網絡證書、密碼等關鍵信息。
你也許已經聽過MD5已經被破解的新聞——但事實上,即便是MD5這種已經過時的散列算法,也很難實現逆向運算。我們現在更多的還是依賴於海量字典來進行嘗試,也就是通過已經知道的大量的文件——指紋對應關系,搜索某個指紋所對應的文件是否在數據庫里存在。
2.5.1 MD5的實際碰撞案例
下面讓我們來看看一個真實的碰撞案例。我們之所以說MD5過時,是因為它在某些時候已經很難表現出散列算法的某些優勢——比如在應對文件的微小修改時,散列算法得到的指紋結果應當有顯著的不同,而下面的程序說明了MD5並不能實現這一點。
import hashlib # 兩段HEX字節串,注意它們有細微差別 a = bytearray.fromhex("0e306561559aa787d00bc6f70bbdfe3404cf03659e704f8534c00ffb659c4c8740cc942feb2da115a3f4155cbb8607497386656d7d1f34a42059d78f5a8dd1ef") b = bytearray.fromhex("0e306561559aa787d00bc6f70bbdfe3404cf03659e744f8534c00ffb659c4c8740cc942feb2da115a3f415dcbb8607497386656d7d1f34a42059d78f5a8dd1ef") # 輸出MD5,它們的結果一致 print(hashlib.md5(a).hexdigest()) print(hashlib.md5(b).hexdigest()) ### a和b輸出結果都為: cee9a457e790cf20d4bdaa6d69f01e41 cee9a457e790cf20d4bdaa6d69f01e41
而諸如此類的碰撞案例還有很多,上面只是原始文件相對較小的一個例子。事實上現在我們用智能手機只要數秒就能找到MD5的一個碰撞案例,因此,MD5在數年前就已經不被推薦作為應用中的散列算法方案,取代它的是SHA家族算法,也就是安全散列算法(Secure Hash Algorithm,縮寫為SHA)。
2.5.2 SHA家族算法以及SHA1碰撞
安全散列算法與MD5算法本質上的算法是類似的,但安全性要領先很多——這種領先型更多的表現在碰撞攻擊的時間開銷更大,當然相對應的計算時間也會慢一點。
SHA家族算法的種類很多,有SHA0、SHA1、SHA256、SHA384等等,它們的計算方式和計算速度都有差別。其中SHA1是現在用途最廣泛的一種算法。包括GitHub在內的眾多版本控制工具以及各種雲同步服務都是用SHA1來區別文件,很多安全證書或是簽名也使用SHA1來保證唯一性。長期以來,人們都認為SHA1是十分安全的,至少大家還沒有找到一次碰撞案例。
但這一事實在2017年2月破滅了。CWI和Google的研究人員們成功找到了一例SHA1碰撞,而且很厲害的是,發生碰撞的是兩個真實的、可閱讀的PDF文件。這兩個PDF文件內容不相同,但SHA1值完全一樣。(對於這件事的影響范圍及討論,可參考知乎上的討論:如何評價 2 月 23 日谷歌宣布實現了 SHA-1 碰撞?)
所以,對於一些大的商業機構來說, MD5 和 SHA1 已經不夠安全,推薦至少使用 SHA2-256 算法。
2.6. Hash在Java中的應用
2.6.1 HashMap的復雜度
在介紹HashMap的實現之前,先考慮一下,HashMap與ArrayList和LinkedList在數據復雜度上有什么區別。下圖是他們的性能對比圖:
獲取 | 查找 | 添加/刪除 | 空間 | |
ArrayList | O(1) | O(1) | O(N) | O(N) |
LinkedList | O(N) | O(N) | O(1) | O(N) |
HashMap | O(N/Bucket_size) | O(N/Bucket_size) | O(N/Bucket_size) | O(N) |
可以看出HashMap整體上性能都非常不錯,但是不穩定,為O(N/Buckets),N就是以數組中沒有發生碰撞的元素,Buckets是因碰撞產生的鏈表。
注:發生碰撞實際上是非常稀少的,所以N/Bucket_size約等於1
HashMap是對Array與Link的折衷處理,Array與Link可以說是兩個速度方向的極端,Array注重於數據的獲取,而處理修改(添加/刪除)的效率非常低;Link由於是每個對象都保持着下一個對象的指針,查找某個數據需要遍歷之前所有的數據,所以效率比較低,而在修改操作中比較快。
2.6.2 HashMap的實現
本文以JDK8的API實現進行分析
2.6.2.1 對key進行Hash計算
在JDK8中,由於使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode並移動到低位就可以了。
static final int hash(Object key) { int h; //計算hashCode,並無符號移動到低位 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
舉個例子: 363771819^(363771819 >>> 16)
0001 0101 1010 1110 1011 0111 1010 1011(363771819) 0000 0000 0000 0000 0001 0101 1010 1110(5550) XOR --------------------------------------- = 0001 0101 1010 1110 1010 0010 0000 0101(363766277)
這樣做可以實現了高地位更加均勻地混到一起。
下面給出在Java中幾個常用的哈希碼(hashCode)的算法。
-
Object類的hashCode. 返回對象的經過處理后的內存地址,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。這個是native方法,取決於JVM的內部設計,一般是某種C地址的偏移。
-
String類的hashCode. 根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串的內容相同,返回的哈希碼也相同。
-
Integer等包裝類,返回的哈希碼就是Integer對象里所包含的那個整數的數值,例如Integer i1=new Integer(100), i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。
-
int,char這樣的基礎類,它們不需要hashCode,如果需要存儲時,將進行自動裝箱操作,計算方法同上。
2.6.2.2 獲取到數組的index的位置
計算了Hash,我們現在要把它插入數組中了
i = (tab.length - 1) & hash;
通過位運算,確定了當前的位置,因為HashMap數組的大小總是2^n,所以實際的運算就是 (0xfff…ff) & hash ,這里的tab.length-1相當於一個mask,濾掉了大於當前長度位的hash,使每個i都能插入到數組中。
2.6.2.3 生成包裝類
這個對象是一個包裝類,Node
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; //getter and setter .etc. }
2.6.2.4 插入包裝類到數組
(1). 如果輸入當前的位置是空的,就插進去,如圖,左為插入前,右為插入后
0 0 | | 1 -> null 1 - > null | | 2 -> null 2 - > null | | ..-> null ..- > null | | i -> null i - > new node | | n -> null n - > null
(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到后面,這叫做鏈地址法處理沖突。
0 0 | | 1 -> null 1 - > null | | 2 -> null 2 - > null | | ..-> null ..- > null | | i -> old i - > new - > old | | n -> null n - > null
我們可以發現,失敗的hashCode算法會導致HashMap的性能由數組下降為鏈表,所以想要避免發生碰撞,就要提高hashCode結果的均勻性。
2.6.3 擴容
如果當表中的75%已經被占用,即視為需要擴容了
(threshold = capacity * load factor ) < size
它主要有兩個步驟:
2.6.3.1 容量加倍
左移1位,就是擴大到兩倍,用位運算取代了乘法運算
newCap = oldCap << 1; newThr = oldThr << 1;
2.6.3.2 遍歷計算Hash
for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //如果發現當前有Bucket if ((e = oldTab[j]) != null) { oldTab[j] = null; //如果這里沒有碰撞 if (e.next == null) //重新計算Hash,分配位置 newTab[e.hash & (newCap - 1)] = e; //這個見下面的新特性介紹,如果是樹,就填入樹 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //如果是鏈表,就保留順序....目前就看懂這點 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } }
由此可以看出擴容需要遍歷並重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。
2.6.4 擴容如何提升性能?
-
解決擴容損失:如果知道大致需要的容量,把初始容量設置好以解決擴容損失;
比如我現在有1000個數據,需要 1000/0.75 = 1333 個坑位,又 1024 < 1333 < 2048,所以最好使用2048作為初始容量。 -
解決碰撞損失:使用高效的HashCode與loadFactor,這個…由於JDK8的高性能出現,這兒問題也不大了。
2.6.5 HashMap與HashTable的主要區別
在很多的Java基礎書上都已經說過了,他們的主要區別其實就是Table全局加了線程同步保護
- HashTable線程更加安全,代價就是因為它粗暴的添加了同步鎖,所以會有性能損失。
- 其實有更好的concurrentHashMap可以替代HashTable,一個是方法級,一個是Class級。
2.6.6 在Android中使用SparseArray代替HashMap
官方推薦使用SparseArray([spɑ:s][ə’reɪ],稀疏的數組)或者LongSparseArray代替HashMap。官方總結有一下幾點好處:
-
SparseArray使用基本類型(Primitive)中的int作為Key,不需要Pair
總結
可以看到無論是密碼學、數據結構等計算機領域,還是現實生活中的應用,到處可以看到Hash的影子。希望這篇總結的博文,可以幫助到大家更好的學習哈希算法。
最后要感謝一下博文的創作者,謝謝!
【時間倉促,如有錯誤,歡迎指正! || 歡迎留下您的評語! 大家一起探討、學習區塊鏈!】
【轉載請注明出處!http://www.cnblogs.com/X-knight/】
Reference
- https://blog.csdn.net/asdzheng/article/details/70226007
- https://jizhi.im/blog/post/sha1decrypt
- http://www.jianshu.com/p/e54047b2b563
- https://www.zhihu.com/question/26762707
- http://mp.weixin.qq.com/s?__biz=MzA5ODUzOTA0OQ==&mid=2651688220&idx=1&sn=a3f9cb1e186ffe22d9825bca00e85c76&chksm=8b692e5abc1ea74ce61a819f5666dd7d73ee45d6145c92b993de271a315d4f3d3fb3874f9be3&mpshare=1&scene=23&srcid=0414EOLCuLSu17uo8Aw8refB#rd
- http://mp.weixin.qq.com/s/oRLkR7jplqO2qhHtUeTMIA
- https://blog.csdn.net/feinik/article/details/54974293
- https://blog.csdn.net/tanggao1314/article/details/51457585