查找:就是根據給定的某個值,在查找表中確定一個其關鍵字等於給定值的數據元素(或記錄)。
8.1 開場白
8.2 查找概論
查找表:是由同一類型的數據元素(或記錄)構成的集合。
關鍵字:是數據元素中某個數據項的值,又稱爲鍵值,用它可以標識一個數據元素。
若此關鍵字可以唯一標識一個記錄,則稱此關鍵字爲主關鍵字。
對於那些可以識別多個數據元素(或記錄)的關鍵字,稱之爲次關鍵字。
查找表按照操作方式分有兩種:靜態查找表和動態查找表。
靜態查找表:只作查找操作的查找表。主要操作有:
(1)查詢某個“特定的”的數據元素是否在查找表中。
(2)檢索某個“特定的”數據元素和各種屬性。
動態查找表:在查找過程中同時插入查找表中不存在的數據元素,或者從查找表中刪除已存在的某個數據元素。操作如下:
(1)查找時插入數據元素。
(2)查找時刪除數據元素。
8.3 順序表查找
順序查找又叫線性查找,是最基本的查找技術,它的查找過程是:從表中第一個(或最後一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,若某個記錄的關鍵字和給定值相等,則查找成功,找到所查的記錄;如果直到最後一個(或第一個)記錄,其關鍵字和給定值比較都不相等時,則表中沒有所查的記錄,查找不成功。
8.1 順序表查找算法
//順序查找,a爲數組,n爲要查找的數組長度,key爲要查找的關鍵字
int Sequential_Search(int *a, int n, int key)
{
int i;
for (i = 1; i <= n; i++)
{
if (a[i] == key)
return i;
}
return 0;
}
8.3.2 順序表查找優化
//有哨兵順序查找
int Sequential_Search2(int *a, int n, int key)
{
int i;
a[0] = key; //設置啊【0】爲關鍵字值,稱之爲哨兵
i = n; //循環從數組尾部開始
while (a[i] != key)
{
i--;
}
return i; //返回 0 表示查找失敗
這種在查找方向的盡頭設置哨兵免去了在查找過程中每一次比較後都要判斷查找位置是否越界的小技巧,在數據很多時,效率提高很多。
8.4 有序表查找
8.4.1 折半查找
折半查找,又稱爲二分查找,它的前提是線性表中的記錄必須是關鍵碼有序,線性表必須採用順序存儲。折半查找的基本思想是:在有序表中,取中間記錄作爲比較對象,若給定值與中間記錄的關鍵字相等,則查找成功;若給定值小於中間記錄的關鍵字,則在中間記錄的左半區繼續查找;若給定值大於中間記錄的關鍵字,則在中間記錄的右半區繼續查找;不斷重複上述過程,知道查找成功,或所有查找區域無記錄,查找失敗爲止。
//折半查找
int Binary_Search(int *a, int n, int key)
{
int low, high, mid;
low = 1; //定義最低下標爲記錄首位
high = n; //定義最高下標爲記錄末位
while (low <= high)
{
mid = (low+high)/2;
if (key < a[mid])
high = mid -1;
else if (key > a[mid])
low = mid + 1;
else
return mid;
}
return 0;
}
折半查找的時間複雜度爲O(logn)。不過由於折半查找的前提條件是需要有序表順序存儲,對於靜態查找表,一次排序後不在變化,這樣的算法已經比較好了。但對於需要頻繁執行插入或刪除操作的數據結集來說,維護有序的排序會帶來不小的的工作量,那就不建議使用。
8.4.2 插值查找
只需要在折半查找的代碼中更改以下第8行代碼如下:
mid = low + (high- low) * (key - a[low])/(a[high] - a[low]);
插值查找是根據要查找的關鍵字key與查找表中最大最小記錄的關鍵字比較後的查找方法,其核心就在於插值的計算公式
8.4.3 斐波那契查找
利用黃金分割原理。
//斐波那契查找
int Fibonacci_Search(int *a, int n, int key)
{
int low, high, mid, i, k;
low = 1;
high = n;
k = 0;
while (n > F[k]-1) //計算n位與斐波那契數列的位置
k++;
for (i = n; i < F[k]-1; i++) //將不滿的數值補全
a[i] = a[n];
while(low <= high)
{
mid = low + F[k-1] - 1; //計算當前分隔的下標
if (key < a[mid])
{
high = mid -1;
k = k -1; //斐波那契數列下標減一位
}
else if (key > a[mid])
{
low = mid + 1;
k = k -2 ; //斐波那契數列下標減兩位
}
else
{
if (mid <= n)
return mid; //若相等則說明mid即爲查找到的位置
else
return n; //若mid>n說明是補全數值,返回n
}
}
return 0;
}
儘管斐波那契查找的時間複雜度也爲O(logn),但就平均性能來說,斐波那契查找要優於折半查找。但是最壞情況,查找效率要低於折半查找。
8.5 線性索引查找
數據結構的最終目的是提高數據的處理速度,索引是爲了加快查找速度設計的一種數據結構。索引就是把一個關鍵字與它對應的記錄相關聯的過程。索引按照結構可以分爲線性索引、樹形索引和多級索引。這裏只介紹線性索引,所謂線性索引就是將索引項集合組織爲線性結構,也稱爲索引表。重點介紹三種索引:稠密索引、分塊索引、倒排索引。
8.5.1 稠密索引
稠密索引是指在線性索引中,將數據集合中的每個記錄對應一個索引項。對於稠密索引,索引項一定是按照關鍵碼有序的排列。
8.5.2 分塊索引
分塊有序,是把數據集的記錄分成了若干塊,並且這些快需要滿足兩個條件:
-塊內無序,即每一個塊內的記錄不要求有序。
- 塊間有序。例如要求第二塊所有記錄的關鍵字均要大於第一塊中所有記錄。
對於分塊有序的數據集,將每塊對應一個索引項,這種索引方法叫做分塊索引。定義的分塊索引的索引項結構分三個數據項: - 最大關鍵碼,它存儲每一塊中的最大關鍵字。
- 存儲了塊中的記錄個數
- 用於指向塊首數據元素的指針
分塊索引在兼顧了對細分塊不需要有序的情況下,大大增加了整體查找的速度,所以普遍被用於數據庫表查找等技術的應用中。
8.5.3 倒排索引
最簡單的也是最基本的搜索技術——倒排索引。
索引項的通用結構是:
- 次關鍵碼:
- 記錄號表
其中記錄好表存儲具有相同次關鍵字的所有記錄的記錄號(可以是指向記錄的指針或者是該記錄的關鍵字)。這樣的索引方法就是倒排索引。
8.6 二叉排序數
二叉排序樹,又稱爲二叉排序數。它或者是一顆空樹,或者是具有下列性質的二叉樹:
- 若它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值
- 若它的右子樹不空,則右子樹上所有結點的值均大於它的根結點的值。
- 它的左右子樹又分爲而二叉排序樹。
構造一顆二叉排序樹的目的,其實並不是爲了排序,而是爲了提高查找和插入刪除關鍵字的速度。
8.6.1 二叉排序樹查找操作
//二叉樹的二叉鏈表結點結構定義
typedef struct BitNode //結點結構
{
int data; //結點數據
struct BitNode *lchild, *rchild; //左右孩子指針
}BitNode, *BiTree;
//遞歸查找二叉排序樹T中是否存在kye
//指針f指向T的雙親,其初始調用值爲NULL
//若查找成功,則指針p指向該數據元素結點,並返回TRUE
//否則指針P指向查找路徑上訪問的最後一個結點並返回FALSE
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
if (!T) //查找不成功
{
*p = f;
return FALSE;
}
else if (key == T->data) //查找成功
{
*p = T;
return TRUE;
}
else if (key < T->data)
return SearchBST(T->lchild,key,T,p); //在左子樹繼續查找
else
return SearchBST(T->rchild, key, T, p); //在右子樹繼續查找
}
8.6.2 二叉排序樹插入操作
//當二叉排序樹T中不存在關鍵字等於key的數據元素時,插入key並返回TRUE,否則返回FALSE
Status InsertBST(BiTree *T, int key)
{
BiTree p, s;
if (!SearchBST(*T, key, NULL, &p)) //查找不成功
{
s = (BiTree)malloc(sizeof(BitNode));
s->data = key;
s->lchild = s->rchild = NULL;
if (!p)
*T = s; //插入s爲新的根結點
else if (key < p->data)
p->lchild = s; //插入s爲左孩子
else
p->rchild = s; //插入s爲右孩子
return TRUE;
}
else
return FALSE; //樹中已有關鍵字相同的結點,不在插入
}
有了插入代碼,實現二叉樹的構建就非常容易了
//構建二叉樹
int i;
int a[10] = {62,88,58,47,35,73,51,99,37,93};
BiTree T = NULL;
for (i = 0; i < 10; i++)
{
InsertBST(&T, a[i]);
}
8.6.3 二叉排序樹刪除操作
刪除結點的三種情況:
- 葉子結點
- 僅有左或右子樹的結點
- 左右子樹都有的結點。
下面這個算法是遞歸方式對二叉排序數T查找key,查找到時刪除。
//若二叉排序樹T中存在關鍵字等於key的數據元素時,則刪除該數據元素結點
//並返回TRUE;否則返回FALSE
Status DeleteBST(BiTree *T, int key)
{
if (!T) //不存在關鍵字等於key的數據元素
return FALSE;
else
{
if (key == (*T)->data) //找到關鍵字等於key的數據元素
return Delete(T);
else if (key < (*T)->data)
return DeleteBST(&(*T)->lchild, key);
else
return DeleteBST (&(*T)->rchild,key);
}
}
下面是Delete函數的代碼
//從二叉排序數中刪除結點p,並重接它的左或右子樹
Status Delete(BiTree *p)
{
BiTree q, s;
if ((*p)->rchild == NULL) //右子樹空則只需重接它的左子樹
{
q = *p; *p = (*q)->lchild; free(q);
}
else if ((*p)->lchild == NULL) //只需重接它的右子樹
{
q = *p; *p = (*p)->rchild; free(q);
}
else //左右子樹均不空
{
q = *p; s = (*p)->lchild;
while (s->rchild) //轉左,然後向右到盡頭(找待刪結點的前驅)
{
q = s; s = s->rchild;
}
(*p)->data = s->data; //s指向被刪結點的直接前驅
if (q != *p)
q->rchild = s->lchild; //重接q的右子樹
else
q->lchild = s->lchild; //重接q的左子樹
free(s);
}
return TRUE;
}
8.6.4 二叉排序樹總結
因此,如希望對一個集合按二叉排序樹查找,最好是把它構建成一顆平衡的二叉排序樹,即其深度與完全二叉樹相同。
8.7 平衡二叉樹(AVL樹)
平衡二叉樹是一種二叉排序樹,其中每一個結點的左子樹和右子樹的高度差至多等於1。
將二叉樹上結點的左子樹深度減去右子樹深度的值稱爲平衡因子BF。
距離插入結點最近的,且平衡因子的絕對值大於1的結點爲根的子樹,稱爲最小不平衡子樹。
8.7.1 平衡二叉樹實現原理
平衡二叉樹構建的基本思想就是咋構建二叉排序樹的過程中,每當插入一個結點時,先檢查是否因插入而破壞了樹的平衡性,若是,則找出最小不平衡子樹。在保持二叉排序樹特性的前提下,調整最小不平衡子樹中各結點之間的鏈接關係,進行相應的旋轉,使之成爲新的平衡子樹。
8.7.2 平衡二叉樹實現算法
//二叉樹的二叉鏈表結點結構定義
typedef struct BiTNode //結點結構
{
int data;
int bf; //結點的平衡因子
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
//對以p爲根的二叉排序樹作右旋處理,處理之後p指向新的樹根結點,即旋轉處理之前的左子樹的根結點
void R_Rotate(BiTree *P)
{
BiTree L;
L = (*P)->lchild; //L指向P的左子樹根結點
(*P)->lchild = L->rchild; //L的右子樹掛接爲P的左子樹
L->rchild = (*P);
*P = L; //P指向新的根結點
}
//對以p爲根的二叉排序樹作左旋處理,處理之後p指向新的樹根結點,即旋轉處理之前的左子樹的根結點
void L_Rotate(BiTree *P)
{
BiTree R;
R = (*P)->lchild; //L指向P的左子樹根結點
(*P)->lchild = L->rchild; //L的右子樹掛接爲P的左子樹
R->rchild = (*P);
*P = R; //P指向新的根結點
}
現在來看左平衡旋轉處理的函數代碼
#define LH +1 //左高
#define EH 0 //等高
#define RH -1 //右高
//對以指針T所指結點爲根的二叉樹做左平衡旋轉處理
//本算法結束時,指針T指向新的根結點
void LeftBalance(BiTree *T)
{
BiTree L, Lr;
L = (*T)->lchild; //L指向T的左子樹根結點
switch(L->bf)
{//檢查T的左子樹的平衡度,並作相應平衡處理
case LH://新結點插入在T的左孩子的左子樹上,要作單右旋處理
(*T)->bf = L->bf = EH;
R_Rotate(T);
break;
case RH: //新結點插入在T的左孩子的右子樹上,要作單雙旋處理
Lr = L->rchild; //Lr指向T的左孩子的右子樹根
switch(Lr->bf) //修改T及其左孩子的平衡因子
{
case LH: (*T)->bf = RH;
L->bf = EH;
break;
case EH: (*T)->bf = EH;
L->bf = EH;
break;
case RH: (*T)->bf = EH;
L->bf = LH;
break;
}
Lr->bf = EH;
L_Rotate(&(*T)->lchild); //對T的左子樹作左旋平衡處理
R_Rotate(T); //對T做右旋處理
}
}
同樣的,右平衡旋轉處理的函數代碼非常類似。
//若在平衡的二叉排序樹T中不存在和e有相同關鍵字的結點,則插入一個
//數據元素爲e的新結點並返回1,否則返回0。若因插入而使二叉排序樹失去平衡,
//則做平衡處理,布爾變量taller反映T長高與否
Status InsertAVL(BiTree *T, int e, Status *taller)
{
if (!*T)
{//插入新結點,樹“長高”,置taller爲TRUE
*T = (BiTree)malloc(sizeof(BiTNode));
(*T)->data = e;
(*T)->lchild = (*T)->rchild = NULL;
(*T)->bf = EH;
*taller = TRUE;
}
else
{
if (e == (*T)->data)
{//數中已存在和e相同關鍵字的結點則不再插入
*taller = FALSE;
return FALSE;
}
if (e < (*T)->data)
{//應繼續在T的左子樹中進行搜索
if(!InsertAVL(&(*T)->lchild, e, taller)) //未插入
return FALSE;
if (*taller) //已插入到T的左子樹中且左子樹長高
{
switch((*T)->bfz)//檢查T的平衡度
{
case LH: //原本左子樹比右子樹高,需要作左平衡處理
LeftBalance(T);
*taller = FALSE;
break;
case EH: //原本左右子樹等高,現因左子樹增高而樹增高
(*T)->bf = LH;
*taller = TRUE;
break;
case RH: //原本右子樹比左子樹高,現左右子樹等高
(*T)->bf = EH;
*taller = FALSE;
break;
}
}
}
else
{//應繼續在T的右子樹中進行搜索
if(!InsertAVL(&(*T)->rchild, e, taller)) //未插入
return FALSE;
if (*taller) //已插入到T的右子樹中且右子樹長高
{
switch((*T)->bfz)//檢查T的平衡度
{
case LH: //原本左子樹比右子樹高,現在左右子樹等高
(*T)->bf = EH;
*taller = FALSE;
break;
case EH: //原本左右子樹等高,現因右子樹增高而樹增高
(*T)->bf = LH;
*taller = TRUE;
break;
case RH: //原本右子樹比左子樹高,需要作右平衡處理
RightBalance(T);
*taller = FALSE;
break;
}
}
}
}
return TURE;
}
對於這段代碼來說,只需要在需要構建平衡二叉樹的時候,執行如下列代碼即可在內存中生成一顆平衡二叉樹。
int i;
int a[10] = {3,2,1,4,5,6,7,10,9,8};
BiTree T = NULL;
Status taller;
for (i = 0; i < 10; i++)
{
InsertAVL(&T, a[i], &taller);
}
若要查找的集合本身沒有次序,在頻繁查找的同時也需要經常的插入和刪除操作,顯然,需要構建一顆二叉排序樹,但是必須是平衡二叉樹,此時時間複雜度爲O(logn)。
8.8 多路查找樹(B樹)
多路查找樹,其每一個結點的孩子樹可以多餘兩個,且每一個結點處可以存儲多個元素。
主要講解它的4種形式:2-3樹、2-3-4樹、B樹和B+樹
8.8.1 2-3樹
2-3 樹是這樣的一棵多路查找樹:其中每一個結點都具有兩個孩子(稱它爲2結點)或3個孩子(稱它爲3結點)。
一個2結點包含一個元素和兩個孩子(或沒有孩子)。不能只有一個孩子。
一個3結點包含一小一大兩個元素和三個孩子(或沒有孩子)。
並且2-3樹中所有的葉子都在同一層次。
2-3樹複雜的地方就在於新結點的插入和已有結點的刪除。不在贅述。
8.8.2 2-3-4 樹
包括了4結點的使用。一個4結點包含小中大三個元素和四個孩子(或沒有孩子)。
8.8.3 B樹
B樹是一個種平衡的多路查找樹,2-3樹和2-3-4樹都是B樹的特例。結點最大的孩子數目稱爲B樹的階。因此,2-3樹是3階B樹,2-3-4樹是4階B樹。
B樹的數據結構就是內外存的數據交互準備的。
8.8.4 B+樹
B+樹是應文件系統所需而出的一種B樹的變形樹,在B+樹中,出現分支結點中的元素會被當做它們在該分治結點位置的中序後繼者(葉子結點)中再次列出。另外,每一個葉子結點都會保存一個指向後一葉子結點的指針。
一棵m階的B+樹和m階的B樹的差異在於:
- 有n棵子樹的結點中包含有n個關鍵字;
- 所有的葉子結點包含全部關鍵在的信息,及指向含這些關鍵字記錄的指針,葉子結點本身依關鍵字的大小自小而大順序鏈接。
- 所有分治結點可以看成是索引,結點中僅含有其子樹中的最大(或最小)關鍵字。
8.9 散列表查找(哈希表)概述
8.9.1 散列表定義
散列技術是在記錄的存儲位置和它的關鍵字之間建立一個確定的對應關係f,使得每個關鍵字key對應一個存儲位置f(key)。
這裏把這種對應關係f稱爲散列函數,又稱爲哈希函數。按這個思想,採用散列技術將記錄存儲在一塊連續的存儲空間中,這塊連續存儲空間稱爲散列表或哈希表。
8.9.2 散列表查找步驟
整個散列過程就是兩步:
(1)在存儲時,通過散列函數計算記錄的散列地址,並按此散列地址存儲該記錄。
(2)當查找記錄時,通過同樣的散列函數計算記錄的散列地址,按此散列地址訪問該記錄。
所以所,散列技術即是一種存儲方法,也是一種查找方法。散列主要是面向查找的存儲結構。
散列技術最適合的求解問題是查找與給定值相等的記錄。
但是,不適合範圍查找,和具有同樣關鍵字的集合。
若兩個關鍵字key1 != key2,但是卻又f(key1) = f(key2),這種現象稱爲衝突,並把key1和key2稱爲這個散列函數的同義詞。
8.10 散列函數的構造方法
什麼纔算是好的散列函數,給出兩個原則參考:
(1)計算簡單
(2)散列地址分佈均勻
8.10.1 直接定址法
取關鍵字的某個線性函數值爲散列地址。優點:簡單、均勻,也不會產生衝突,但問題是需要事先知道關鍵字的分佈情況,適合查找表較小且連續的情況。
8.10.2 數字分析法
數字分析常常適合處理關鍵字位數比較大的情況,如果事先知道關鍵字的分佈且關鍵字的若干位分佈腳均勻,就可以採用這個方法。
8.10.3平方取中法
比較適合不知道關鍵字的分佈,而位數又不是很大的情況。
8.10.4 摺疊法
是將關鍵字從左到右分割成位數相等的幾部分(最後一部分位數不夠是可以短些),然後,將這幾部分疊加求和,並按散列表表長,取後幾位作爲散列地址。
摺疊法實現不需知道關鍵字的分佈,適合關鍵字位數較多的情況。
8.10.5 除留餘數法
爲最常用的構造散列函數方法,對於散列表長爲m的散列函數公式爲:
f(key) = key mod p (p<=m)
mod是取模(求餘數)的意思。
8.10.6 隨機數法
選擇一個隨機數,取關鍵字的隨機函數值爲它的散列地址。當關鍵字的長度不等時,採用這個方法構造散列函數是比較合適的。
總之,視不同的情況採用不同的散列函數,給出一些參考因素:
- 計算散列地址所需要的時間
- 關鍵字的長度
- 散列表的大小
- 關鍵字的分佈情況
- 記錄查找的頻率。
8.11 處理散列衝突的方法
8.11.1 開放定址法
開放定址法就是一旦發生了衝突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。
fi(key) = (f(key) + di) MOD m (di = 1,2,3…,m-1)
8.11.2 再散列函數法
每當發生散列地址衝突時,就換一個散列函數計算。
8.11.3 鏈地址法
鏈地址法對於可能會造成很多衝突的散列函數來說,提供了絕不會出現找不到地址的保障。當然,也帶來了查找時需要遍歷單鏈表的性能損耗。
8.11.4 公共溢出區法
8.12 散列表查找實現
8.12.1 散列表查找算法實現
首先是需要定義一個散列表的結構以及一些相關的常數。其中HashTable就是散列表結構。結構當中elem爲一個動態數組。
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //定義散列表長爲數組的長度
#define NULLKER -32768
typedef struct
{
int *elem; //數據元素存儲基址,動態分配數組
int count; //當前數據元素個數
}HashTable;
int m = 0; //散列表表長,全局變量
有了結構的定義,可以對散列表進行初始化。
//初始化散列表
Status InitHashTable(HashTable *H)
{
int i;
m = HASHSIZE;
H->count = m;
H->elem = (int *)malloc(m*sizeof(int));
for (i = 0; i < m; i++)
H->elem[i] = NULLKER;
return OK;
}
爲了插入時計算地址,需要定義散列函數,散列函數可以根據不同情況更改。
//散列函數
int Hash(int key)
{
return key % m; //除留餘法
}
初始化完成後,可以對散列表進行插入操作。假設插入的關鍵字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}
//插入關鍵字進散列表
void InsertHash(HashTable *H, int key)
{
int addr = Hash(key); //求散列地址
while (H->elem[addr] != NULLKER) //如果不爲空,則衝突
addr = (addr + 1) % m; //開放地址法的線性探測
H->elem[addr] = key; //直到有空位後插入關鍵字
}
代碼插入關鍵字時,首先算出散列地址,如果當前地址不爲空關鍵字,則說明有衝突。此時應用開放地址法的線性探測法進行重新尋址,此處理也可更改爲鏈地址法等其他解決衝突的方法。
散列表存在後,在需要時就可以通過散列表查找要的記錄。
//散列表查找關鍵字
Status SearchHash(HashTable H, int key, int *addr)
{
*addr = Hash(key); //求散列地址
while(H.elem[*addr] != key) //如果不爲空,則衝突
{
*addr = (*addr + 1) % m; //開放地址法的線性探測
if (H.elem[*addr] == NULLKER || *addr == Hash(key))
{//如果循環回到原點
return UNSUCCESS; //則說明關鍵字不存在
}
}
return SUCCESS;
}
查找的代碼與插入的代碼非常類似,只需做一個不存在關鍵字的判斷而已。
8.12.2 散列表查找性能分析
- 散列函數是否均勻
- 處理衝突的方法
- 散列表的裝填因子
所謂的裝填因子 = 填入表中的記錄個數 / 散列表的長度。