查找算法
基本概念
- 生活中處處有查找,例如:搜索引擎、大數據問題等等。
- 我們學習查找想要解決的問題:對於超大數據量,如何提高查找的效率?
- 基本概念:
- 關鍵碼:用以標識一個記錄的某個數據項。如果該關鍵碼可以唯一的標識一條記錄,則稱爲主關鍵碼,反之爲次關鍵碼。
- 查找:在具有相同類型的記錄集中找出滿足給定條件的記錄。
- 查找結果:在查找集中找到匹配的記錄,稱爲查找成功;否則查找失敗。一般情況下,查找需要返回記錄的位置。
P.S.
- 散列技術其實有很多,例如HASH哈希(題外話:在python中,字典就是一種哈希映射~欲知詳情,請查看我的另一篇筆記
),散列查找的效率是相當高的,在最近十幾年才崛起。 - 二叉排序樹與平衡二叉樹的區別?
因爲對於一組無序數據,通過二叉排序樹排序以後得到的樹有很大可能是不平衡的(左右子樹大小相差太多),而平衡二叉樹稱得上是二叉排序樹的升級版,可以解決左右子樹不平衡的問題。
思考: 查找結構與存儲結構有什麼區別?
線性表查找
順序查找
問題: 對於亂序數據,如何快速查找出關鍵字Key是否在亂序中?若是,如何返回位置?
方法一:簡單粗暴的直接查找
int search(int a[],int n,int key)
{
for(int i=0;i<n;i++)//①
if(a[i] == key)//②
return i+1;//找到key,返回位置
return 0;//沒有找到,返回0
}
反思: 上述代碼的時間複雜度?是O(n^2),因爲有二次比較。
思考: 如何進一步提高效率?
方法二:哨兵法–用空間換時間
思想: 對於長度爲n的亂序表,另建一個長度爲n+1的表,其中a[0]做哨兵,其值賦爲key。哨兵的意義–使函數無論如何都會返回一個值,而且只需要比較一次。
int search(int a[],int n,int key)
{
a[0] = key; //哨兵
for(int i=n;a[i]!=key;i--); //從後向前查找
return i; //如果找到key,就返回位置i,沒有找到,就返回0
}
計算ASL:
- 查找不成功 ASL = n+1
- 查找成功
折半查找(敲黑板:必考題)
思考: 折半法的前提是什麼?待查找序列爲有序表。
基本思想: 先確定待查記錄所在的範圍,再用二分法逐步縮小範圍直到找到或找不到且查完整個表。
再思考: 對存放在數組中的有序表,如何快速找到Key?
注意:
- (以上題爲例)low的第一次移動要移動到 mid+1,因爲mid原來坐在的位置,數據已經比較過一次了,可以直接跳到它的下一個。(同理:high=mid-1)
- 如何判斷沒有找到key?
low > high 時就說明沒有找到。換句話說,循環條件就是 low<=high
int Search_Bin(int a[],int n,int key)
{
int low = 1;
int high = n;
while(low<=high)
{
mid = (low+high)/2;
if(key == a[mid])
return mid;
else if (key<a[mid])
high = mid-1;
else
low = mid +1;
}
return 0;
}
折半查找的性能分析
折半查找的判定樹:
- 一般情況下,表長爲n的折半查找的判定樹的深度和含有n個結點的完全二叉樹的深度相同。
所以:
索引查找(分塊查找)
- 分塊查找的性能介於順序查找和折半查找。只用於分段有序的信息表。
- 分段查找的核心思想:在建立順序表的同時,建立一個索引表。
- 可以看出索引表可以用折半查找,而基本表不可以。
- 基本思想:首先根據索引表確定待查記錄的區間,然後再確定的主表區間採用順序查找。(這其實就是哈希映射的思想,在當前大數據處理方面應用廣泛。)
- 性能分析:
缺點:需要有輔助數組,且初始表要經過分塊排序。
三種查找方式的比較
查找方式 | 性能 | 適用條件 |
---|---|---|
順序查找 | ASL= (n+1)/2 或n+1,性能最差 | 亂序表 |
折半查找 | ASL=(2log)n 或 (2log)n+!,性能最好 | 有序表 |
分塊查找 | 性能位於前兩者中間 | 分塊有序表 |
然而這三者都只適用於靜態查找。如果我們想在查找的同時,對一些記錄進行添加、刪除操作,就要使用下面的樹表。
樹表查找
樹表查找是典型的動態查找,適用於亂序表的查找,同時也可以對記錄進行操作。
二叉排序樹
基本思想: 將亂序化有序,然後根據折半查找的思想進行查找。
定義
二叉排序樹:
- 空樹
- 具有如下性質的樹(注意體會遞歸的思想):
- 若它的左子樹不空,則左子樹上所有結點的值均小於根節點的值
- 若它的右子樹不空,則右子樹上所有結點的值均大於根結點的值
- 它的左右子樹也分別都是二叉排序樹
例如:
顯然,當我們對二叉排序樹進行中序遞歸時,就可以得到有序數表。
通常,可取二叉鏈表作爲二叉排序樹的結點存儲結構。
template<class T>
class BiNode
{
public:
T data;
BiNode<T> *lch;
BiNode<T> *rch;
BiNode():lch(NULL),rch(NULL){}; //構造函數
}
建立
基本思路:
- 若當前節點=NULL,直接插入
- 否則將給定值與當前結點進行比較
2.1. 若key<當前節點,與其左孩子繼續比較
2.2 .否則與其右孩子進行比較
反覆執行,直到插入key
插入元素
舉個栗子(所有元素均成功插入):
有興趣的朋友可以看一下這個網站:數據可視化工具better
裏面有各種動態的二叉樹建立過程,也有很多其他邏輯結構的相關算法。
代碼實現
二叉排序樹的存儲結構
template<class T>
calss BST
{
private:
BiNode<T> *Root; //根結點
public:
BST(T r[],int n); //構造函數,創建二叉排序樹
BiNode<T>*Search(BiNode<T>*R,T key); //查找關鍵字key
void InsertBST(BiNode<T> *&R,BiNode<T>*s); //插入結點
void Delete(BiNode<T> *&R); //刪除結點
bool DeteteBST(BiNode<T>*&R,T key); //根據關鍵字key刪除指定結點
~BST(); //析構函數
}
插入元素
template<class T>
void BST<T>::InsertBST(BiNode<T>*&R,BiNode *s)
//R爲二叉排序樹的根節點,s爲待插入的新結點
{
if(R == NULL) R = s; //插入R的位置
else if(s->data < R->data)
InsertBST(R->lch,s); //在左子樹中插入
else
InsertBST(R->rch,s); //在右子樹中插入
}
注意:
InsertBST算法的第一個參數類型爲 *& 即指針的引用,其目的有兩個:一,作爲輸入時,即把指針的值傳遞到了函數內部,又可以將指針的關係傳遞到函數內部;二,作爲輸出時,由於算法修改了指針R的值,可以將R的新值傳遞到函數外部。
一般情況下,若函數內部修改了指針本身的值(不是指針指向的地址的內容),則需要將該指針的參數設置爲指針的引用 *& 。
二叉排序樹的建立過程,就是把序列元素依次插入的過程
template<calss T>BST<T>::BST(T r[],int n)
{
Root = NULL;
for(int i=0;i<n;i++)
{
BiNode<T>*s = new BiNode<T>; //創建新結點
s->data = r[i];
s->lch = s->rch = NULL;
InsertBST(Root,s); //插入
}
}
刪除
和插入相反,刪除在查找成功以後進行,並且要求在刪除二叉排序樹上的某個結點後,仍然保持二叉排序樹的特性。
刪除結點的三種情況:
被刪除的結點是葉結點(最簡單)
方法:delete指向該葉結點的指針;父結點對應的指針置空
被刪除的結點只有左子樹或只有右子樹
方法:被刪除結點的雙親指向被刪除結點的孩子,隨後delete即可
被刪除的結點既有右子樹也有左子樹(最複雜)
爲了解決這一問題,我們又遇到了化繁爲簡的思想,只需要將這種情況轉化爲前兩種情況即可。
算法分析:
- 中序遍歷得到悲刪除結點p的前驅結點q(q是p的左子樹最右下結點),則q必爲單分支結點或葉結點(總之,q的右指針必爲空)
- 將q的值賦給p的值域(不必更改q的值域)
- 將刪除p的操作轉換爲刪除q的操作
代碼實現
刪除算法就兩步:已知key,查找對應結點,判斷類型;調用Delete()函數
第一步,遞歸查找
template<class T>
bool BST<T>::DeleteBST(BiNode<T> *&R, T key)
//R是二叉排序樹的根結點,key是關鍵字
{
if(R == NULL) return false; //查找失敗
else
{
if(key == R->data)
{
Delete(R); //找到域key匹配的結點,刪除
return true;
}
else if(key < R->data)
return DeleteBST(R->lch,key); //在左子樹查找
else
return DeleteBST(R->rch,key); //在右子樹查找
}
}
第二步,刪除已知結點R
template<class T>
void BST<T>::Delete(BiNode<T> *&R)
{
BiNode<T> *q,*s;
if(R->lch == NULL) //只有右子樹,刪除葉子結點包含在這種情況中
{
q = R;
R = R->rch;
delete q;
}
else if(R->rch ++ NULL) //只有左子樹
{
q = R;
R = R->lch;
delete q;
}
else //左右子樹都有
{
q = R;
s = R->rch;
while(s->rch != NULL)
{//使s指向R的前驅
q = s;
s = s->rch;
}
R -> data = s->data; //替換數值
if(q != R)
q->rch = s->rch; //s是q的右孩子
else
R->rch = s->rch; //q=R 表示s是R的左孩子
delete s;
}
}
Delete()函數採用 *& 類型傳遞指針,大大簡化了刪除算法。這是由於調用Delete函數時,傳遞參數R,不僅將R的值傳給了Delete()函數,而且將指針R與它的左右孩子的對應關係傳遞給了Delete()函數,因此“R = R->rch” 就相當於直接給R的右孩子賦值。
查找
算法性能分析:
對於每一棵特定的二叉排序樹,均可按照平均查找長度的定義來求它的ASL值,顯然,由值相同的n個關鍵字,構造所得的不同形態的每個二叉排序樹的ASL是不同的,甚至可能差別相當大。
爲什麼?
因爲二叉排序樹的結構不一定是平衡的,例如:值相同的一個左斜樹和一個結構相當平衡的二叉樹,顯然,後者ASL更小。當二叉排序樹結構比較穩定、結點數又比較多時,它的查找性能就接近於折半查找了。
template<class T>
BiNode<T>*BST<T>::Search(BiNode *R,T key)
{
if(R == NULL) return NULL; //查找失敗
if(key == R->data) return R;
else if(key < R->data) return Search(R->lch,key);
else return Search(R->rch,key);
}
平衡二叉樹(AVL)
爲了進一步優化查找效率,使二叉排序樹的結構更加平衡,平衡二叉樹應運而生。
定義
平衡二叉樹:
- 空樹
- 具有如下性質的樹:
- 左右子樹都是平衡二叉樹
- 左右子樹高度值差的絕對值小於等於1
如果在建立二叉排序樹時,保證其爲平衡二叉樹,則可避免查找的時間複雜度從O(2logn)退化成O(n)
(對於平衡二叉樹,此處不再詳細講解,有興趣的朋友可以看看這篇文章:平衡二叉樹(AVL)圖解與實現)
散列查找
散列查找的效率非常高!舉個栗子,索引查找。
散列技術
什麼是查找?
確定關鍵碼=給定值的記錄在集合中的存儲位置。由於存儲位置與關鍵碼之間不存在確定的對應關係,因此,查找時必須通過一系列與關鍵碼的比較。
理想情況:
在記錄的存儲位置與其關鍵碼之間建立一個確定的對應關係H,使得每個關鍵碼key和唯一的一個存儲位置H(key)對應。
這就是散列技術,採用散列技術將記錄存儲在一塊連續的存儲空間中,就是散列表。
散列過程:
- 存儲記錄,通過H(key)計算記錄的散列地址,並按此地址存儲記錄
- 查找記錄,通過同樣的H(key)計算記錄的散列地址,按此地址訪問該紀錄。
P.S.散列不能表達記錄之間的邏輯關係,所以是不完整的存儲結構,是主要面向查找的存儲結構。
散列函數設計
如何確定所需的哈希函數呢?這就是我們下面要討論的散列函數的設計問題。
直接定址法
哈希函數: H(key)=a*key+b
特點: 計算簡單,沒有衝突,適合關鍵碼分佈比較連續的情況,否則會浪費大量空間。實際意義不大。
舉個栗子:
除留餘數法
哈希函數: H(key)=key%p (p<m) m爲散列表長度,p最好爲素數或不包含小於20的質因數的合數。
特點: 計算機簡單,使用範圍廣。
舉個栗子:
反思: 一定能找到不會引起衝突的p嗎?
答案是不一定,這就是爲什麼要求p最好是是質數了。
衝突處理
但實際情況中,我們可能無法找到符合條件的完美哈希函數,會有 key2 != key2 但是 H(key1) == H(key2) 這樣的衝突產生。
衝突處理的實際含義: 爲產生衝突的地址尋找下一個哈希地址。
下面介紹三種方法
開放定址法
未產生衝突的地址H(key)按照某種規則產生另一個地址。
有三種方法:
線性探測法
Hi = (H(key) + di) MOD m
di = c*i
最簡單的情況:c = 1(衝突+1再取模)
產生衝突的部分需要按照規則多查找兩三次
平方探測法
Hi = (H(key) + di) MOD m
di = 1^2, -1^2, 2^2, -1^2, ……
隨機探測法
Hi = (H(key) + di) MOD m
di是一組僞隨機數,或者 di = i*H2(key)【又稱雙散列函數探測】
比如:3、1、9、2
鏈地址法(拉鍊法)
基本思想: 將所有散列地址相同的記錄都存儲在一個單鏈表中–同義詞子表,三裂變存儲所有同義詞的頭指針。
這種方法思路十分簡單,對於數據量不是很大的情況,使用起來也非常方便.只是要注意建立鏈表時的方法(頭插法或尾插法)會影響遍歷順序。
建立公共溢出區
基本思想: 散列表包含基本表和溢出表兩個部分,將發生衝突的記錄存儲在溢出表中。
查找方法: 通過H(key)函數計算散列地址,先與基本表中記錄進行比較,若相等,則查找成功,否則,到溢出表順序查找。
這種方法跟第二種一比就比較麻煩了。
散列查找的性能分析
性能分析: 散列技術中,處理衝突的方法不同,得到的散列表不同,散列表的查找性能也不同。
決定性能的因素: 比較次數取決於發生衝突的概率,產生的衝突越多,查找效率就越低。
舉個栗子:
影響衝突的因素:
- 散列函數是否均勻
- 處理衝突的方法
- 散列函數的填裝因子a
a越大,代表填入表中的記錄越多,產生衝突的可能性就越大。
後面會出有關查找算法實例的新文章(尤其是二叉排序樹)
如果對上述內容有疑問,歡迎大家評論或私聊。
一起學習,一起進步~