為了消除一次聚集,我們使用一種新的方法:平方探測法。顧名思義就是沖突函數F(i)是二次函數的探測方法。通常會選擇f(i)=i2。和上次一樣,把{89,18,49,58,69}插入到一個散列表中,這次用平方探測看看效果,再復習一下探測規則:hi(x)= ( Hash(x) + F(I) ) % TableSize(I=0,1,2…)
腦內調試一下:49和89沖突時,下一個空閑位置是0號單元。58和18沖突時,i=1也沖突,再試i=2,h2(58)=(8+4)%10=2是空的可以放。69同理。
對於線性探測法而言,我們得避免元素幾乎填滿的情況,因為這時候性能會急劇降低。對於平方探測法,這會更糟:如果表超過一半被填滿,那當表的規模不是素數時,甚至在表被填滿一般之前就已經不能一下找到空單元了,需要試探好幾次才能找到一個空單元。原因是表最多有一半位置可以用來解決沖突。憑什么如此斷言呢?Talk is cheap,show me your….proof.
定理
如果使用平方探測,且表的規模是素數,那么當表至少有一半是空的時候,總能插入新的元素。
我們假設表的Size是一個大於3的素數,直接拿着定理證明有點讓人不知所措,那把這個定理的證明轉化為:證明“前$\frac{\mbox{Si}ze}{2}$個備選位置是互異的”,然后用反證法。從所有前$\frac{\mbox{Si}ze}{2}$個的位置里選兩個:( h(x) + i2 )%Size和( h(x) + j2 )%Size,其中 0 < i,j$ \leq \frac{\mbox{Si}ze}{2}$。假設這兩個位置相同,且i ≠ j,然后讓他們位置相等,推出矛盾就行了,因為都mod Size,根據等式性質我們只需要考察括號里的項就行了。
(h(x) + i2)=(h(x) + j2)
=> i2 = j2
=> (i-j)(i+j) = 0
前面說了i ≠ j,所以只可能i = - j。但是這和他們的定義域矛盾,所以也是不可能的。所以前一半位置互異,可供選擇,任何元素都有$\frac{\mbox{Si}ze}{2}$個可能被放的位置。綜上,如果最多有一半的位置可用,那么空閑單元總是能找到的。反過來講,哪怕表里有一半+1個位置被填上,那么插入都有可能失敗(雖然這比較偶然,但還是有可能的),這一點是十分重要的,要拿小本本記下來,說不定校招或考研就出題了哈哈哈。另外保證Size是素數也是非常重要的,如果不是的話,那遭遇沖突時可供選擇的空單元個數會銳減到你難以置信的地步,遠比一半少,這樣一來,我們的戰略縱深就太小了,難以迂回,這種情況沒人希望見到。
Size=16的時候,找備選的單元只能取i=1,2,3,也就是距離沖突單元1,4,9個單位的位置了。
另外,在開放定址的散列表里,我們之前意義上的刪除操作是不能進行的,因為某個數對應的單元可能已經引起過沖突了,然后他探測跑到別的位置了。比如我們要刪除69,你find一下,定位到9,發現那躺着89,那我們只能跟着平方探測的思路再找找9+12,結果發現還不對,在那的是58。得,繼續找吧,試試9+2^2,這才找到。想想吧,這才Size=10就這么費勁了,那企業級軟件要處理千萬級甚至億級的數據怎么辦,比如頭條app的數據量,那程序還不跑到天荒地老。。。因此開放定址散列表需要懶惰刪除。
談談怎么實現吧,先給出類型聲明。在這里我們不用結構體數組,而使用散列表單元的數組,而且單元是動態分配地址這和分離鏈接一樣。
#ifndef HashQuad_h #define HashQuad_h typedef unsigned int Index; typedef Index Position; struct HashTb1; typedef struct HashTb1 *HashTable; HashTable Init(int size); void DestroyTable(HashTable H); void Insert(int key, HashTable H); Position Find(int key,HashTable H); int Retrieve(Position P); HashTable ReTable(HashTable H); #endif /* HashQuad_h */ enum KindOfEntry{ Legitimate, Empty, Deleted }; struct HashEntry { int value; enum KindOfEntry Info; }; typedef struct HashEntry Cell; /*Cell *TheCells will be an array of HashEntry cells,allocated later */ struct HashTb1 { int TableSize; Cell *TheCells; };
順便一說,Hash函數還是設置為簡單的%Size
Index Hash(int key,int size) { return key%size; }
初始化由2步組成:分配空間,然后將每個單元的Info設置為Empty。
#define aPrime 307 #define MinTableSize 5 HashTable Initial(int size){ HashTable H; int i; if (size<MinTableSize) { printf("Table size too small\n"); return NULL; } //Allocate table H=(HashTable)malloc(sizeof(struct HashTb1)); H->TableSize=aPrime; //Allocate array of cells H->TheCells=(Cell*)malloc(sizeof(Cell)*H->TableSize); //Allocate list headers for (i=0; i<H->TableSize; i++) H->TheCells[i].Info=Empty;
return H; }
和分離鏈接一樣,Find返回key在散列表里的單元號碼。而且因為被標記了Empty,我們想表達查找失敗也很容易。
1 Position Find(int key,HashTable H){ 2 Position cur; 3 int CollisionNum=0; 4 cur=Hash(key,H->TableSize); 5 while (H->TheCells[cur].Info != Empty && 6 H->TheCells[cur].value!= key) 7 { 8 cur+= (++CollisionNum<<1) - 1; 9 if (cur>=H->TableSize) 10 cur-=H->TableSize; 11 } 12 return cur; 13 }
第8行到第10行是進行平方探測的快速方法,因為在實現的時候不太好判斷進行到第幾次探測了,所以直接算i^2不容易,另設個變量監測倒也可以,不過那樣挺麻煩的,還占用空間,還多了一次監測變量的++,還多了一次判斷,還多了一次平方運算,尤其是算平方開銷太大了。所有的這些都會讓效率變低。所以我們要把平方計算轉化為單純的+-計算,用i2 - ( i - 1 )2算出他們之間的差距是2 * i - 1,所以F(i)=F( i - 1 ) + 2 * i - 1這個幾乎全是加減,乘法用移位代替速度就快多了。如果新的定位越過數組,那么可以通過-Size把它拉回到數組的范圍里。這比通常辦法快多了,因為他避免了看似要做的乘法和平方。第行的判斷順序很重要,別翻過來,不然短路性質就用不上了。
然后說插入,如果Key存在,就什么也不做,否則就把插入元素放在Find的位置。
void Insert(int key, HashTable H){ Position P=Find(key, H); if (H->TheCells[P].Info != Legitimate) { H->TheCells[P].Info=Legitimate; H->TheCells[P].value=key; } }
雖然平方探測法排除了一次聚集,但是散列到同一位置上的元素將探測相同的備選單元,這么說有點抽象,就是探測的時候都會踩同樣的坑,比如說89,49,69這三個數往散列表里放,h0(49)撞到89了,試試i=1,可以了。69撞到89了然后試試i=1,算完之后h1(69)=0和h1(49)又撞了,這就叫“探測到相同的備選單元”,再試一次69才被安置。想想規模更大的表,相撞次數會更多,用f(i)=i2探測的時候分批扎堆,這就叫二次聚集,和之前相比,不是0,1,2,3這樣連着一整塊扎堆,而是在i=1,4,9,16附近扎堆。這是這兩種聚集的區別。
二次聚集是理論上的一個缺憾,下一篇里我們繼續討論如何排除這個缺憾,從而對散列表沖突問題的排解更為高效和優美。不過這需要花費另外一些時間去做乘除法,比平方探測單純的加減法慢一些,有利有弊吧,實際場景里因地制宜地選擇不同模型就好。