查找就是在由若干記錄組成的集合中找出關鍵字值與給定值相同的記錄。如查找成功,返回找到的記錄的信息或者在表中的位置,查找失敗就返回一個表明失敗的標誌。一個查找算法的優劣取決於查找過程當中的比較次數,使用平均比較長度(平均比較次數)ASL來衡量查找算法的效率,ASL是和指定值進行比較的關鍵字的個數的指望值。算法
ASL=∑n1Pi*Ci
數組
其中,Ci表示查找到第i個數據元素時已經比較的次數,Pi是查找表中第i個數據元素的機率。數據結構
根據查找算法是否改變查找表的內容,將查找算法分爲靜態查找和動態查找。靜態查找對查找表查找時,查找成功就返回記錄的信息或在查找表中的位置,查找失敗就返回一個表明失敗的標誌,並不對查找表進行插入和刪除,或通過一段時間以後再對查找表進行集中式的插入和刪除操做。動態查找是查找與插入和刪除在同一階段進行,例如,在某些問題中,查找成功時,刪除查找到的記錄,查找失敗時,插入被查找的記錄。ide
爲了提升查找效率,爲帶查找序列選擇合適的數據結構以存儲這些數據,這種面向查找的數據結構就稱爲查找結構。主要有如下三種數據結構:函數
1)線性表:適用於靜態查找,查找方法有順序查找和二分查找。性能
2)樹表:適應於動態查找,查找方法是採用二叉排序樹進行查找(相似二分查找過程)。spa
3)哈希表:靜態和動態查找均合適,查找方法是哈希技術。設計
無序查找要求查找表有序無序都可。有序查找要求查找表必須是有序的。
3d
順序查找算法是最簡單的查找算法,從查找表的一端開始,順序查找每一個記錄,直至另外一端爲止。實現代碼以下:指針
1 //順序查找,n是數組長度,a從[0,n-1] 2 public static int sequenceSearch(int[] a,int value,int n){ 3 int i=0; 4 while (i<n&&a[i]!=value){//比較大小和檢查邊界同時進行 5 ++i; 6 } 7 if(i<n){ 8 return i;//查找成功 9 } 10 else { 11 return -1;//查找失敗 12 } 13 }
查找成功時的平均查找長度是:
查找失敗時平均查找長度是(無序表的查找,到最後一個元素才能肯定查找失敗):n。
爲簡化邊界條件而引入的附加節點(記錄)都可稱爲哨兵,哨兵經常使用在循環和遞歸中,用於邊界條件的檢查。上述順序查找算法不只要比較查詢表中元素是否與查找值相同,同時還要檢測是否越界,所以能夠添加哨兵,簡化程序,添加哨兵後的代碼以下:
1 //添加哨兵的順序查找,n是數組最後一個元素的索引,[1,n]是查找表內容,a[0]是哨兵 2 public static int sequenceSearch2(int[] a,int value,int n){ 3 int i=n; 4 a[0]=value; 5 while (a[i]!=value){//經過哨兵避免了邊界檢查 6 --i; 7 } 8 if(i>0){ 9 return i;//查找成功 10 } 11 else { 12 return -1;//查找失敗 13 } 14 }
查找成功時的平均查找長度是仍然是(n+1)/2。查找失敗時的平均查找長度是n+1(最後須要與哨兵比較,多了一次比較)。
在查找表元素個數大於1000時,添加哨兵的查找時間幾乎減小了一半。順序查找算法對查找表的存儲結構沒有要求,順序存儲和鏈式存儲都可應用,而且對錶中記錄的順序也沒有要求,不過,查找效率低下,時間複雜度是O(n)。
折半查找要求查找表是有序的,而且是順序存儲,另外,通常應用於靜態查找。實現代碼以下:
1 //折半查找 2 public static int binarySearch(int[] a,int value,int n){ 3 /* 4 邊界檢查 5 */ 6 int low=0; 7 int high=n-1; 8 int mid=low+(high-low)/2;//防止溢出.(high+low)/2可能會溢出。 9 while (low<=high){ 10 if(a[mid]==value){ 11 return mid; 12 } 13 else if (a[mid]>value){ 14 high=mid-1; 15 } 16 else if(a[mid]<value){ 17 low=mid+1; 18 } 19 mid=low+(high-low)/2; 20 } 21 return -1;//查找失敗 22 }
根據折半查找的過程,也就是mid的不斷變化,能夠生成二叉斷定樹,經過mid構造這棵斷定樹,這棵樹的左子樹節點的值都小於其根節點的值,右子樹的值都大於根節點的值,其實,根據折半查找的過程生成的二叉斷定樹就是一棵二叉排序樹。根據已排序的查找表生成二叉斷定樹的過程以下:
1)對於元素個數爲n的查找表,生成的二叉斷定樹高度(高度從1開始計數)h=⌈log(n+1)⌉,與n個節點的徹底二叉樹的高度相同。推導過程以下:
對於高度爲h的二叉斷定樹,第一層至第h-1層爲滿二叉樹,因此有:
2d-1-1<=n<=2d-1,進而有2d-1<=n+1,n+1<=2d,最後d-1<=log2(n+1)<=d,而d-1和d都是整數,因此有d=⌈log(n+1)⌉。
2)根據二叉斷定樹獲得查找成功時的ASL:
ASL=1/9*(1+2*2+3*4+4*2)=25/9。
3)查找到外部節點時表示查找失敗,外部節點比斷定樹節點個數多1個,失敗時的ASL=1/10*(3*6+4*4)=17/5。
折半查找的最壞性能與平均性能至關接近,其時間複雜度是O(log n)。
基於比較的查找算法,其下界是O(log n)~O(n),要突破這個下界就不能依賴比較來進行查找。哈希技術的思想是把記錄在表中的位置和記錄對應的關鍵字之間創建一種映射關係,經過記錄的關鍵字就能夠獲得記錄的位置,理想狀況下,經過哈希函數根據記錄的關鍵字計算直接獲得記錄的位置,此時查找的時間複雜度是是O(1)。哈希查找要解決的關鍵問題是:構造哈希函數和衝突的解決方法。
哈希函數的設計遵循兩個原則:
1)計算簡單。簡單的哈希函數能夠快速獲得哈希地址,加快查找效率。
2)函數值均勻分佈。經過哈希函數計算獲得的哈希地址分佈的越均勻產生衝突的可能性越小,查找效率越高。在不產生衝突的理想條件下,哈希表的查找、插入(查找失敗就在空白處插入查找值)、刪除(查找到元素能夠選擇刪除)的時間複雜度都是O(1)。另外,哈希地址均勻分佈也是提升了空間的利用率。爲了達到理想狀況,在不考慮空間的狀況下,徹底能夠申請一塊巨大的存儲空間,這樣就避免了衝突的發生。
哈希函數的構造方法有不少,主要使用的是除留餘數法。本着抓大放小的原則,這裏只介紹除留餘數法。
除留餘數法最關鍵的是選擇模值p,假設查找表的元素個數爲n,選擇大於n的最小質數做爲模值p。某正整數的質因數確定不大於其開方值,以這個開方值做爲上界,2做爲下界,獲得p的代碼實現以下:
1 //計算大於查找表元素個數的最小質數。 2 public static int calHashTableLength(int len){ 3 int maxPrimeFactor; 4 int i; 5 while (true){ 6 maxPrimeFactor=(int)Math.sqrt(++len);//某數的的一個質因數確定不超過其平方 7 for (i=2; i <=maxPrimeFactor ; i++) { 8 if(len%i==0){ 9 break;//有一個質因數就表示不是質數,退出循環 10 } 11 } 12 if(i>maxPrimeFactor){//是質數 13 return len; 14 } 15 } 16 }
通常狀況下,很難有理想的哈希函數存在。兩個不一樣的關鍵字可能會映射到同一個哈希地址,稱之爲衝突。發生衝突時,就須要另外找一個地址用來存放記錄。下面介紹衝突的處理方法。
使用開放地址法處理獲得的哈希表成爲閉哈希表。所謂的開放地址法,就是在由關鍵字獲得的哈希地址一旦發生衝突,就去尋找下一個空的哈希地址,只要哈希表還有空位置,就必定能夠找到新地址。如下介紹開放地址法中經常使用的線性探測法。
當衝突發生時,線性探測法從衝突位置的下一個位置開始,依次尋找空哈希地址(沒有存值的),直至繞了一圈到達衝突位置的前一個位置,此時表示哈希表已滿,未找到一個空白位置。公式表示以下:
開放定址法的步驟以下:
1)根據關鍵字key計算哈希地址hashAddr,若是ht[hashAddr]等於key,那麼查找成功,返回key的哈希地址hashAddr,不然轉2
2)若是hashAddr對應的位置爲空,表示查找失敗,將ht[hashAddr]置爲key,而後返回-1(表示查找失敗),不然轉3
3)走到這一步表示,newHashAddr對應的位置不爲空而且ht[hashAddr]!=key,這代表發生了衝突,令newHashAddr=(hashAddr+1)%m,獲得新的哈希地址,若是ht[newHashAddr]不爲空(也就是已經填充了元素),比較其與key是否相等,相等就查找成功,返回newHashAddr,不然繼續計算下一個哈希地址newHashAddr=(newHashAddr+1)%m,一直到newHashAddr+1=newHashAddr,也就是移動了一圈以後到達了原衝突地址的前一個位置(循環檢測條件是ht[newHashAddr]!=-1&&newHashAddr!=hashAddr)。
4)若是循環中止有兩種狀況,其一,ht[newHashAddr]==-1,也就是找到一個空白處,將查找值填入,返回失敗標識。其二,經過不斷的訪問下個位置,到了原衝突位置的前一個位置,表示賺了一圈都沒有空白位置,說明表已經滿了,拋出「表滿」異常,而後返回查找失敗標識。
具體實現代碼以下:
1 //除留餘數法構造哈希函數,其中hashTableLength是根據查找表大小求得的大於查找表個數的最小質數 2 //開放定址法之線性探測法,是一種處理衝突的方式。新地址計算:H=(H(key)+d)%m (d=1,2....m-1),哈希函數使用除留餘數法. 3 //假設哈希表中存儲的都是正整數,以-1表示哈希表中並無存儲內容 4 public static int hashSearch(int[] hashTable,int m,int key){//既包含查找又包含刪除 5 int hashAddr=key%m; 6 if(hashTable[hashAddr]==key){//根據哈希地址查找到關鍵字 7 System.out.println("直接找到,位置是:"+hashAddr); 8 return hashAddr;//查找成功 9 } 10 if(hashTable[hashAddr]==-1){//對應的哈希地址位置爲空 11 hashTable[hashAddr]=key; 12 return -1;//查找失敗 13 } 14 System.out.println(String.format("%s發生衝突",key)); 15 int newHashAddr=(hashAddr+1)%m; 16 while (hashTable[newHashAddr]!=-1&&newHashAddr!=hashAddr){//向後探測的元素不爲空且還未移動到第一次的探測位置 17 if(hashTable[newHashAddr]==key){ 18 System.out.println("探測成功,最終位置:"+newHashAddr); 19 return newHashAddr;//查找成功 20 } 21 System.out.println(String.format("向後探測位置:%s",newHashAddr)); 22 newHashAddr=(newHashAddr+1)%m;//繼續向後探測 23 } 24 25 if(hashTable[newHashAddr]==-1){//探測到的位置爲空,中止探測,查找失敗,插入元素。 26 System.out.println("探測成功,最終位置:"+newHashAddr); 27 hashTable[newHashAddr]=key; 28 return -1;//查找失敗 29 } 30 System.out.println("沒有查找到元素,哈希表已滿不能插入元素"); 31 return -1; 32 33 }
下面是線性探測法的一個應用實例。
從閉散列表中刪除一個記錄不是採起直接刪除的方式,而是作一個刪除的標誌,表示曾經有記錄佔用,可是如今再也不佔用。當查找至這個位置時,應該繼續沿着序列查找,而不是表明查找失敗,直接插入。當插入時,爲了保證關鍵字不重複,不能直接插入,應該沿着探測序列探測下去。
關鍵字通過哈希函數的映射獲得相同的哈希地址,稱兩個關鍵字爲同義詞。線性探測法會致使不是同義詞的關鍵字爭搶哈希地址,也就是發生衝突,稱之爲堆積現象。堆積致使衝突的發生,增長了比較次數(比較是否與哈希表中值相等),下降了查找效率。
用鏈地址法處理衝突構造的哈希表稱爲開哈希表。鏈地址法是爲將具備相同哈希地址的記錄存到一個鏈表中(同義詞子表),哈希表中存放指向各個鏈表的頭指針。以下所示:
鏈地址法的實現代碼以下:
1 //鏈地址法 2 public static Node hashSearch2(Node[] hashTable,int m,int key){ 3 int hashAddr=key%m; 4 System.out.println(String.format("關鍵字%s對應的哈希地址是:%s",key,hashAddr)); 5 Node subListNode=hashTable[hashAddr].next; 6 while (subListNode!=null&&!subListNode.ele.equals(key)){ 7 System.out.println("檢測到衝突,查詢同義詞子表下個元素"); 8 subListNode=subListNode.next; 9 } 10 ////查找到同義詞的末尾都未找到或者對應的哈希地址後沒有同義詞子表 11 // 查找失敗,將待查元素插入表頭(時間局部性) 12 if(subListNode==null){ 13 Node head=hashTable[hashAddr].next; 14 hashTable[hashAddr].next=new Node(key,head==null?null:head); 15 System.out.println("查找失敗,插入查找元素"); 16 return null;//查找失敗 17 } 18 //查找成功 19 System.out.println("查找成功"); 20 return subListNode; 21 }
下面是鏈地址法的一個應用實例以及僞代碼:
能夠看到,對於同一個查找表,使用鏈地址法的平均查找長度較開放定址法的小,也就是查找效率更高。鏈地址法(開散列表)添加了單鏈表,增長了存儲開銷,不過也達到了以空間換時間的目的,而且不會產生堆積現象,查找、插入(頭插法)、刪除操做易於實現。而閉散列表沒有多餘的單鏈表,存儲效率更高,不過因爲堆積現象,使得查找效率較低,又因爲將空位置做爲查找失敗的標識,因此閉散列表刪除操做較複雜。
能夠建立一個相對查找表個數合適的較大長度的哈希表,達到以空間換時間的目的。
參考:數據結構 C++版 第二版 王紅梅。