目錄導讀
┃
1.幾個概念
2.查找分類
3.順序表查找
4.有序表查找
折半查找(二分查找)
插值查找
斐波那契查找(只涉及加減運算)
5.線性索引查找
6.二叉排序樹
7.平衡二叉樹
8.多路查找樹(B樹)
9.散列表查找(哈希表)概述
查找(Searching): 就是根據給定的某個值,在查找表中確定一個其關鍵字等于給定值的數據元素(或記錄).
如搜索引擎中的搜索某個關鍵字,它的過程就是查找
1.幾個概念
查找表(Search Table):是由同一類型的數據元素(或記錄)構成的集合。它是需要被查找的數據的集合
關鍵字(Key):是數據元素中某個數據項(字段)的值,又稱為鍵值。用它可以標識一個數據元素(記錄),也可以標識一個記錄的某個數據項,
次關鍵字(Secondary Key):不以唯一標識一個數據元素的關鍵字
數據元素:又稱為記錄
數據項:又稱為字段
查找:就是根據給定的某個值,在查找表中確定一個其關鍵字等于給定值的數據元素(或記錄).
2.查找分類
┃
靜態(tài)查找(Static Search Table):只做查找操作的查找表,即在現有的數據(不會變動)中查找
順序查找
主要操作:
1)查詢某個"特定的"數據元素是否在查找表中
2)檢索某個"特定的"數據元素和各種屬性
動態(tài)查找(Dynamic Search Table):在查找過程中同時插入查找表中不存在的數據元素,或者從
查找表中刪除已經存在的某個數據元素。
主要操作:
1)查找時插入數據元素
2)查找是刪除數據元素
注意:從邏輯上來說,查找所基于的數據結構是集合,這個集合中的記錄(即數據)之間沒有本質關系。
但是為了獲得較高的查找性能,我們就需要改變這些數據元素之間的關系,例如在存儲時可以將
查找集合組織成表、樹等結構。
3.順序表查找
又叫線性查找,是最基本的查找技術
思想:從表中第一個(或最后一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,若某個記錄
的關鍵字和給定值相等,則查找成功,找到所查找的記錄;若直到最后一個(或第一個)記錄,其
關鍵字和給定值比較都不相等時,則表中沒有所查找的記錄,查找不成功。
算法分析:
時間復雜度(比較次數):
最好的情況:O(1)
最壞情況:O(n) , 平均比較次數:(n+1)
平均情況:O(n), 平均比較次數:(n+1)/2
算法簡單,但是效率低下
//---------------------------------------------------------------------------------------------
"代碼描述"
public int sequential_search(int[] a, int key) {
for(int i = 0; i < a.length; i++) {
if(key == a[i]) {
return i;
}
}
return 0;
}
評價:上面這段代碼,不但要判斷 "key == a[i]",還要判斷 i < a.length。 因此,可以設置一個哨兵,這樣
就不需要沒讓i與a.length比較。
"代碼"
public sequential_search(int[] a, int key) {
a[0] = key; //設置a[0]為關鍵字值,讓其作為哨兵
int i = a.length; //循環(huán)從數組尾部開始
while(a[i] != key) {
i --;
}
return i; //返回0則表明沒有查找的,返回非0就表明查找到了
}
4.有序表查找
將數據事先進行排序后存放,在查找時就很有幫助了。
核心思想時:每次查找時,選擇分割點(這里是最重要的地方),在不同的區(qū)域進行查找。
(1)折半查找(二分查找)
前提要求:線性表中的記錄必須是關鍵碼有序(通常是從小到大),線性表必須采用'順序存儲'。
基本思想:在'有序'的表中,取中間記錄作為比較對象,
若給定值等于中間記錄的關鍵字,則查找成功。
若給定值小于中間記錄的關鍵字,則在中間記錄的左半區(qū)繼續(xù)查找
若給定值大于中間記錄的關鍵字,則在中間記錄的右半區(qū)繼續(xù)查找
不斷重復上述過程,知道查找成功為止,或查遍所有記錄均沒有查到為止。
將折半查找的過程繪制成二叉樹,盡管該二叉樹并不是完全二叉樹,但是仍可以依據完全二叉樹的
結構推導概述的深度:logn+1, 即最壞的查找次數為 logn+1
算法分析:
時間復雜度:
最好的情況:O(1)
最壞的情況:O(logn), 這個相對于順序查找已經好很多了
缺點:
需要順序表是有序的,這在頻繁插入、刪除的數據集中來說,維護有序會帶來不小的
工作量。
"代碼描述"
/**
* 折半查找
*/
public int binarySearch(int[] a, int key) {
int low = 0; //最小位置處,初始值為0處,
int high = a.lenght - 1; //最大位置處
int mid; //中間位置處
while(low <= high) {
mid= (low + high) / 2 ;
if(key == a[mid]) {
return mid;
} else if(key < a[mid]) {
high = mid - 1;
} else if(key > a[mid]) {
low = mid + 1;
}
}
return -1; //返回-1則表明沒有查找到
}
/*********************************************************************/
(2)插值查找
是對折半查找的改進,
將 mid = (low + high) / 2;
改進為:
mid = low + (key - a[low])/(a[high - a[low]])*(high - low);
/**
* 插值查找
*/
public int interpolationSearch(int[] a, int key) {
int low = 0; //最小位置處,初始值為0處,
int high = a.lenght - 1; //最大位置處
int mid; //中間位置處
while(low <= high) {
//改進的地方
mid = low + (key - a[low])/(a[high - a[low]])*(high - low);
if(key == a[mid]) {
return mid;
} else if(key < a[mid]) {
high = mid - 1;
} else if(key > a[mid]) {
low = mid + 1;
}
}
return -1; //返回-1則表明沒有查找到
}
/*******************************************************************/
(3)斐波那契查找
利用黃金分割原理來實現的,即利用斐波那契數列來分割,
算法分析:
1)時間復雜度:O(logn)
平均行能斐波那契查找性能較折半查找好,但是在最壞的情況下,比如要查找的數在數列
的最前面,那每次只能向前移動 (1- 0.618)個長度,而折半查找可以移動 0.5 個長度,
因此,這種情況下折半查找的性能略優(yōu)點
2)斐波那契查找只用加減運算就實現了查找,而'折半查找'與'插值查找'都使用了加減法、除法
運算。因此,在海量的數據查找中,斐波那契查找是有優(yōu)勢的
斐波那契數列:
F: 0 1 1 2 3 5 8 13 21 34 ...
下標 (0) (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) ...
"代碼描述"
/**
* @param a 所有的記錄
* @param key 待查的關鍵字
* @return 返回查找結果,-1表名沒有查找到相關記錄
*/
public int fiboSearch(int[] a, int key) {
int low = 0;
int high = a.length - 1;
int mid;
int k = 0;
//為了計算a數組的長度在斐波那契數列的位置
while(a.length > fo(k) - 1) {
k++;
}
int[] new_a = new int[fo(k) - 1];
//要將給數列補全至 fo(k) -1 時才能使用斐波那契進行分割
for(int i = 0; i < fo(k) - 1; i++) {
if(i >= a.length) {
new_a[i] = a[a.length - 1]; //將剩余部分補齊至fo(k) - 1
}else {
new_a[i] = a[i]; //將a中的數組全部復制到新的數組
}
}
a = new_a; //將新的數組賦予以前的數組
//開始進行查找
while(low <= high) {
mid = low + fo(k-1) - 1; //計算當前分割的下標
if(key < a[mid]) {
//在前半分繼續(xù)查找
high = mid - 1;
/**
* 這個k是為了控制在數列中的前半段的位置fo(k)
* 此時前半段有fo(k - 1) - 1個元素,因此k=k-1
*/
k = k - 1;
}else if(key > a[mid]) {
//在后半部分繼續(xù)查找
low = mid + 1;
/**
* 這個k是為了控制在數列中的后半段的位置fo(k)
* 此時后半段有fo(k - 2) - 1個元素,因此k=k-2
*/
k = k - 2;
}else {
//說明key = a[mid]
if(mid <= a.length) {
//說明是在數組的原始部分查找的
return mid;
}else {
//表明是在補全的那一部分查找到的
return a.length;
}
}
}
return -1; //返回-1表明沒有查找到該關鍵字
}
/**
* 產生斐波那契數列
* @param k
* @return
*/
private int fo(int k) {
if(0 == k) {
return 0;
}else if(1 == k) {
return 1;
}else {
return fo(k - 1) + fo(k - 2);
}
}
5.線性索引查找
有序表的查找是基于有序的基礎之上的,因此比較高效。但事實,有許多數據集由于數量極多,
通常是按其先后產生時間順序存儲的,對這類數據集只能設立索引。
索引:就是把一個關鍵字與它對應的記錄相關聯(lián)的過程。它是為了加快查找速度而設計的一種"數據結構"。
一個索引由若干個索引項構成,每個索引項至少應該包含關鍵字和其對應的記錄在存儲器中的位置等信息。
索引分類(按結構分):
┃
線性索引:就是將'索引項'集合組織為線性結構,也稱為索引表
┃
稠密索引(空間要求很大)
分塊索引(多用于數據庫中的查找)
倒排索引
樹形索引
多級索引
(1)稠密索引(空間要求很大)
稠密索引:是指在線性索引中,將數據集中的每個記錄對應一個'索引項'(理解為記錄)。
換言之,就是將數據集中的每一個數據(記錄)與索引表中的索引項是一一對應。(數據集中有多少個數據索引表就有多長)
為了快速找到相關關鍵碼在索引表中的位置,將索引項按照關鍵碼有序排列。(這樣就可以使用
前面介紹的折半查找、插入產找、斐波那契查找方法在索引表中查找相應的關鍵碼)
(索引表數據結構) (數據集)
下標 關鍵碼 指針 關鍵碼 其他數據項
┌──┬──────┐ ┌──┬────────┐
0 │5 │0x002 │ 0x001 │32│ ... │
├──┼──────┤ └──┴────────┘
1 │26│0x004 │ ┌──┬────────┐
├──┼──────┤ 0x002 │5 │ ... │
2 │32│0x001 │ └──┴────────┘
├──┼──────┤ ┌──┬────────┐
3 │89│0x003 │ 0x003 │89│ ... │
└──┴──────┘ └──┴────────┘
┌──┬────────┐
0x004 │26│ ... │
└──┴────────┘
注意:
從邏輯上來說,查找所基于的數據結構是集合,這個集合中的記錄稠密索引雖然方便查找,但是不適合海量數據。因為索引表的長度會和數據集的數據量一樣。這樣會
造成頻繁訪問磁盤,反而是性能下降了。
2)分塊索引(多用于數據庫中的查找)
由于稠密索引的空間代價很大,為了減少索引項的個數,可以對數據集進行分塊,使其分塊有序,然后
再對每一個塊建立一個索引項,從而減少索引項的個數。
注意:
1)分塊索引的各個塊需要滿足的條件:
塊間有序:塊與塊之間是由順序的,即后面的塊中所有記錄的關鍵字均要大于前面塊中的所有記錄的關鍵字
塊內無序:塊內的記錄不要關鍵字有序,當然有序是最好的。
2)分塊索引表中的每一條索引項要有三個數據項:
最大關鍵碼:用于存儲該塊中的所有記錄的最大的關鍵碼
塊中記錄的個數
指向塊首數據的指針
(分塊索引表數據結構)
最大關鍵碼 塊長 塊首指針
┌───┬───┬────────┐
│27 │4 │0x001 │
├───┼───┼────────┤
│57 │2 │0x007 │
├───┼───┼────────┤
│96 │3 │0x012 │
└───┴───┴────────┘
分塊索引的查找步驟:
1.在分塊索引表中查找待查關鍵字所在的塊(即位置)
因為塊間是有序的,因此可以使用折半查找、插入查找、斐波那契查找迅速的定位
2.在塊內順序查找待查關鍵碼具體所在地方
因為塊內通常是無序的,因此只能使用順序查找。
分塊索引的平均長度分析:
設數據集記錄個數為n,平均分為m個塊,每個塊中有t個記錄。
容易得:
m = n / t 或 t = n / m
索引表中查找的平均查找長度(即次數) Lb = (m + 1) / 2,
塊中查找的平均查找長度(即次數) Lw = (t + 1) / 2
則,分塊索引查找的平均查找長度為:
ASLw = Lb + Lw = (m + 1)/2+(t+1)/2
= 1/2(n/t+t)+1
最佳的情況就是分的塊數和塊中記錄數相同,即 m = t
那么,n = m*t = t^2
ASLw = n^(1/2)+1
因此,分塊查找的時間復雜度:O(n^(1/2)),
這個效率比折半查找的O(logn)低一些,但是,它不要求塊內有序,從而大大增加了
整體的速度,所以普遍被用于數據庫表查找等技術應用中。
3)倒排索引
多用于在搜索引擎中搜索數據,這種查詢過程是由記錄的屬性值來確定相應記錄的位置,與
通常由記錄來確定屬性值的過程是相反的,因次被稱為倒排索引。
倒排索引的索引表的數據結構:
次關鍵碼 記錄號表(如所在文章編號)
┌────┬──────────┐
│and │ 2,3,5 │
├────┼──────────┤
│be │ 1,2 │
├────┼──────────┤
│free│ 2,3,4 │
├────┼──────────┤
│hand│ 4,8 │
└────┴──────────┘
次關鍵碼:可能被查詢記錄的屬性(即該記錄的字段、次關鍵字,即通常在搜索引擎中輸入的關鍵字)
記錄號表:存儲具有相同次關鍵字的所有記錄的記錄號(可以是指向記錄的指針或是該記錄的主關鍵字)
6.二叉排序樹
二叉排序樹(Binary Sort Tree),又稱為二叉查找樹。它或者是一顆空樹,或者
是具有下列特點的二叉樹:
1)若它的左子樹不空,則左子樹上所有結點的值均'小于'它的根結點的值
2)若它的右子樹不空,則右子樹上所有結點的值均'大于'它的根結點的值
3)它的左右子樹也分別為二叉排序樹。
注意:遞歸的定義方法定義二叉排序樹
構造二叉排序樹并不是為了排序,而是為了提高查找和插入刪除關鍵字的速度。
算法分析:
1.同樣的數組可以構造不同的二叉排序樹(主要表現在樹的深度不同),
2.插入刪除性能較好,對于查找操作,整個過程走的就是同根結點到要待查找結點的路徑,
其比較次數等于關鍵字結點所在的二叉樹的層數。因此,至少需要比較1次,最多比較次數
不超過樹的深度。換言之,二叉排序樹的查找性能取決于樹的形狀。
最優(yōu)的情況:
二叉排序樹是比較平衡的,其深度與完全二叉樹相同,均為 logn+1
時間復雜度:O(logn)
最壞的情況
二叉排序樹是斜樹,深度為n
時間復雜度:O(n)
因此,二叉排序樹越平衡越好
"代碼描述"
1.二叉樹結點的數據結構
public class BinTreeNode<T> {
T data; //結點的值
BinTreeNode<T> lChild; //結點的左子樹
BinTreeNode<T> rChild; //結點的右子樹
}
2.二叉排序樹的查找
/**
* 輸入二叉排序樹root, 待查找的關鍵字為key
* f為指向tr的雙親,初始值為null
* p是個臨時變量,方法執(zhí)行完畢,如果找到,它指向查找的結點,
* 如果沒有查找到時,它指向查找路徑上訪問的最后那個結點。
* 如果查找到了,就返回true,否則就返回false
*/
/**
* 查找結點
* @param r 二叉樹
* @param key 要查找的關鍵字
* @param f
* @param p
* @return
*/
public boolean search(BinTreeNode r, int key, BinTreeNode f, BinTreeNode p) {
if(r == null) {
//表明沒有查找到
p = f;
return false;
}else if(key == r.data) {
//查找成功
p = r;
return true;
}else if(key < r.data) {
return search(r.lChild, key, r, p); //在左子樹中繼續(xù)查找
}else if(key > r.data) {
return search(r.rChild, key, r, p); //在右子樹中繼續(xù)查找
}
}
3.二叉排序樹的插入操作
/**
* 向二叉排序樹中插入結點
* @param tree 二叉樹
* @param key 要插入的內容
* @return
*/
public int insertBinTree(BinTreeNode tree, int key) {
BinTreeNode p = null;
BinTreeNode s = null; //新的根結點
//插入前先看看該結點存在嗎
if(!search(tree, key, null, p)) {
//表明該結點不存在,則需要插入
s = new BinTreeNode();
s.data = key;
s.lChild = null;
s.rChild = null;
if(p == null) {
//表明此樹為空樹,因此將s作為根結點
tree = s;
}else if(key < p.data) {
p.lChild = s; //插入s為左孩子
}else if(key > p.data){
p.rChild = s; //插入s為右孩子
}
return 1; //插入成功
}
return -1; //表明樹種已有關鍵字相同的結點,則不再插入
}
4.二叉排序樹的刪除操作
/**
* 刪除而二叉排序樹的結點
* 情況稍顯復雜,需要分情況處理
* @param tree 待刪除的那個子樹
* @param key
* @return 返回-1表明沒有刪除,返回1則表明刪除成功
*/
public int deleteBinTree(BinTreeNode tree, int key) {
BinTreeNode p = null;
if (tree != null) {
return -1; //不存在關鍵字為key的結點
}else {
if(key == p.data) {
//找到關鍵字等于key的結點
return delete(p);
}else if(key < p.data) {
return deleteBinTree(p.lChild, key);
}else {
return deleteBinTree(p.rChild, key);
}
}
}
/**
* 輔助方法,用于刪除結點
* @param p
* @return
*/
private int delete(BinTreeNode p) {
BinTreeNode q;
BinTreeNode s;
if(p.rChild == null) {
//右子樹為空,只有左子樹
p = p.lChild;
}else if(p.lChild == null) {
//左子樹為空,只有右子樹
p = p.rChild;
}else {
//左右子樹都存在的
q = p; //
s = p.lChild;
//尋找要刪除結點的左子樹的最右結點
while(s.rChild != null) {
q = s;
s = s.rChild;
}
//至此,s指向左子樹的最右結點,即要刪除結點的直接前驅結點
p.data = s.data; //這里為了后面方便(因為刪除葉子結點比較容易)
if(q != p) {
//q和p指向不一致
q.rChild = s.lChild; //將s的左子樹接到q的右子樹上
}else {
//q和p指向一致,則表明該刪除點沒有右子樹
q.lChild = s.lChild; //將s的左子樹接到q的左子樹上
}
}
return 1;
}
7.平衡二叉樹(AVL樹, AVL是兩位科學家名字的縮寫)
(1)"平衡二叉樹"含義:
平衡二叉樹(Self-Balancing Binary Search Tree 或 Height-Balanced Binary Search Tree),是
一種二叉排序樹,其中每一個結點的左子樹和右子樹的高度最多差1
注意:平衡二叉樹的前提是二叉排序樹
(2)平衡因子(Balance Factor)
將二叉樹結點的左子樹深度減去右子樹深度的值稱為平衡因子,由平衡二叉樹的含義可知,
該平衡因子取值只能為:-1, 0, 1
因此,只要,一顆二叉樹上有一個結點的平衡因子的絕對值大于1,就表明該二叉樹不是平衡二叉樹
(3)最小不平衡子樹
距離插入結點最近的,且平衡因子的絕對值大于1的結點為根的子樹,我們稱之為最小不平衡子樹
(4)平衡二叉樹的實現
1.實現原理
就是在構建二叉排序樹的過程中,每當輸入一個結點后,先檢查是否因為插入這個結點而破壞了
樹的平衡性。若是,則找出最小不平衡子樹(|BF| > 1),保持二叉排序樹特性的前提下,調整最
小不平衡子樹中各結點之間的鏈接關系,進行相應的旋轉(左旋或右旋轉),使之成為新的平衡子樹。
注:二叉排序樹還有另外的平衡算法,如紅黑樹
2.算法的具體實現
/**
* 平衡二叉樹的實現
* @author Administrator
*/
public class AVLTree {
/**
* 不平衡子樹右旋操作
* @param p 不平衡子樹的根結點
*/
private void right_Rotate(AVLTreeNode p) {
AVLTreeNode newNode; //輔助變量
newNode = p.lChild; //讓newNode指向p的左子樹
p.lChild = newNode.rChild; //將newNode的右子樹掛接到p的右子樹的位置處
newNode.rChild = p; //將現在的p掛接到newNode的右子樹的位置處
p = newNode; //讓P指向newNode
}
/**
* 不平衡子樹左旋轉
* @param p 不平衡子樹的根結點
*/
private void left_Rotate(AVLTreeNode p) {
AVLTreeNode newNode; //輔助變量
newNode = p.rChild; //讓newNode指向p的右子樹
p.rChild = newNode.lChild; //將newNode的左子樹掛接到p的右子樹上,
newNode.lChild = p; //將p掛接到newNode的左子樹上
p = newNode; //讓p指向newNode
}
/**
* 對以p為根結點的二叉樹做左平衡旋轉處理
* 注意:此時該子樹p的左子樹的高度已經大于右子樹的高度,即p子樹是不平衡的樹
* 因此,整體要做右旋轉處理
* @param p
*/
private void leftBalance(AVLTreeNode p) {
AVLTreeNode newTree;
AVLTreeNode newTree_Right;
newTree = p.lChild; //newTree指向p的左子樹的根結點
switch (newTree.bf) {
//檢查T的左子樹的平衡度,并做相應的平衡處理
case 1:
//新結點插入在p的左孩子的左子樹上,要做單右旋轉處理。
//記得要處理相應結點的平衡因此bf
p.bf = newTree.bf = 0;
right_Rotate(p); //做右旋轉
break;
case -1:
/**
* 新結點插入在p的左孩子的右子樹上(此時會造成p的左子樹根結點
* 的bf和p.lChild的右子樹的bf符號不一致),因此,要作雙旋處理
* p.lChild的左子樹高度低于其右子樹的高度,因此,這部分的子樹
* 要做左單旋的處理
*/
//左單旋轉處理前,先將p的左孩子的右子樹的根結點的bf調整為與p的bf符號一致。
newTree_Right = newTree.rChild; //newTree_Right指向p的左孩子的右子樹根
switch (newTree_Right.bf) {
//修改P及其左孩子的平衡因子
case 1:
p.bf = -1;
newTree.bf = 0;
break;
case 0:
p.bf = newTree.bf = 0;
break;
case -1:
p.bf = 0;
newTree.bf = 1;
break;
}
newTree_Right.bf = 0;
left_Rotate(p.lChild); //對p的左子樹做單左旋轉平衡處理
right_Rotate(p); //對p做右旋轉的平衡處理
break;
}
}
/**
* 若在平衡的二叉排序樹P中不存在和e有相同關鍵字的結點,則插入一個
* 數據元素為e的新結點并返回1,否則返回0,若因為插入而使二叉排序樹
* 失去平衡,則做平衡旋轉處理。
* @param p
* @param e
* @param isTaller 反映P樹長高與否
* @return
*/
public boolean insertAVL(AVLTreeNode p, int e, boolean isTaller) {
if(p == null) {
//表明此樹為空,則插入新結點,即為根結點
p = new AVLTreeNode();
p.data = e;
p.lChild = null;
p.rChild = null;
p.bf = 0; //平衡因子為0
isTaller = true;
}else {
if(e == p.data) {
//p樹中已存在和e相同關鍵字的結點,那么就不用插入
isTaller = false;
return false; //沒有插入新的元素,就返回0
}
if(e < p.data) {
//待插入的元素小于p樹的根結點關鍵字,故在左子樹上進一步尋找合適的插入位置
if(!insertAVL(p.lChild, e, isTaller)) {
return false ;//未插入
}
if(isTaller) {
//已插入到p的左子樹中且左子樹“長高”
switch (p.bf) { //檢查p的平衡度
case 1:
//原本左子樹比右子樹高,需要做左平衡處理
leftBalance(p);
isTaller = false;
break;
case 0:
//原本左右子樹登高,現因左子樹增高而樹高度增加
p.bf = 1;
isTaller = true;
break;
case -1: //原本右子樹比左子樹高,先左右子樹等高
p.bf = 0;
isTaller = false;
break;
}
}
}else {
//在p的右子樹上進行尋找相應的適合的插入位置
if(!insertAVL(p.rChild, e, isTaller)) {
return false;
}
if(isTaller) {
//已插入到p的右子樹且右子樹“長高”
switch (p.bf) { //檢查p的平衡度
case 1:
//原本左子樹比右子樹高,現在左右子樹等高
p.bf = 0;
isTaller = false;
break;
case 0:
//原本左右子樹登高,現因右子樹增高而整個樹的高度增高
p.bf = -1;
isTaller = true;
break;
case -1:
//原本右子樹比左子樹高,需要做右平衡處理
rightBalance(p); //待完成
isTaller = false;
break;
}
}
}
}
return true;
}
}
8.多路查找樹(B樹)
為了處理海量數據(是存放在外存磁盤中),比如從成千上問的文件中尋找一個文件,如果采用之前的樹的結構(如每個節(jié)點
中只能存放一個元素,二叉樹中只能有兩個孩子結點),那么對于這些海量數據建立的樹結構,將會
變得十分龐大(表現在樹的度和樹的高度上),這會使內存存取外存中數據的次數非常多,造成時
間效率上的瓶頸。因此,為了解決這些問題,要打破每個結點只存儲一個元素的限制。
多路查找樹(Muitl-way search tree),其每一個結點的孩子數可以多于兩個,且每一個結點處
可以存儲多個元素。
注意:由于它是查找樹,所有元素之間存在某種特定的排序關系。
至于每一個結點可以存儲多少個元素,以及它可以擁有幾個孩子,這是很關鍵的。
這里只討論4個特殊的形式:
2-3樹
2-3-4樹
B 樹
B+ 樹
(1)2-3樹
定義:其中的每一個結點都具有兩個孩子(稱為2結點)或三個孩子(稱為3結點)
注意:
1) 一個結點要么有兩個孩子要么有三個孩子,要么沒有孩子,不可能出現一個孩子的情況
2) 2結點包含:1個元素和兩個孩子(或沒有孩子),其結構類似二叉排序樹。區(qū)別在于:這
個2結點要么有2個孩子要么沒有孩子,不能是只有一個孩子
3結點包含:2個元素(一小一大)和三個孩子(或沒有孩子)。如果3結點有孩子的話,左孩子的所有元素
小于較小元素,右孩子的所有元素大于較大元素。中間孩子元素介于這個兩個元素之間。
3) 2-3樹所有葉子都在同一層次上。
插入操作:
2-3樹的插入操作一定是發(fā)生在葉子結點上。但是這個插入過程有可能會對該樹的其余結構
造成連鎖反應。(這是因為插入一個新元素有可能會破壞2-3樹的定義,因此需要改變樹的其余結
構使其符合2-3樹的定義)
具體可分為三種情況:
1)對于空樹,插入一個2結點即可
2)插入結點到一個2結點的葉子上,由于該葉子本身只有一個元素,因此只需將其升級為3結點即可
3)插入結點到一個3結點中,這是情況最復雜的,需要調整樹的相應結構,甚至增加樹的高度
刪除操作:
2-3樹的刪除操作同插入思想一致,刪除相應位置的元素后,就需要調其余部分結構,以使該樹
符合2-3樹的定義。
具體可分為三種情況:
1)刪除元素位于3結點的葉子結點上,直接刪除即可。(刪除后該結點自動變成2結點)
2)刪除元素位于2結點上,此時是最復雜的情況,甚至降低整顆樹的高度來滿足2-3樹的定義
3)所刪除的元素位于非葉子的分支結點。此時通常是將樹按中序遍歷后得到此元素的前驅或后繼
元素,考慮讓其來補充位置即可。
(2)2-3-4樹
含義:是2-3樹的擴展,增加了一個4結點的使用。一個4結點包含小中大三個元素和四個孩子(或者
就沒有孩子)。
注意:4結點有么有4個孩子要么沒有孩子
4結點的結構:
┌─────────┐
│ 3 5 8 │
└─────────┘
╱ ╱ ╲ ╲
┌───┐ ┌───┐ ┌───┐ ┌─┐
│1 2│ │ 4 │ │6 7│ │9│
└───┘ └───┘ └───┘ └─┘
(3)B樹(B-tree)
含義:是一種平衡的多路查找樹,2-3樹、2-3-4樹都是B樹的特例。結點最大的孩子數目稱為B樹的
階(order),因此,2-3樹是3階B樹,2-3-4樹是4階B樹。
一個m階的B樹具有如下屬性:
1.如果根結點不是葉結點,則至少有兩顆子樹
2.每一個非根的分支結點都有k-1個元素和k個孩子,其中[m/2]≤k≤m; [m/2]表示不小于m/2的最小整數
3.所有葉子結點都位于同一層次上
4.所有分支結點包含下列信息數據:(n,A0,K1,A1,K2,A2,...,Kn,An)
其中,Ki(i=1,2,...n)為關鍵字,且Ki<Ki+1; Ai(i=1,2,...n)為指向子樹根結點的指針,且
指針Ai-1所指子樹中所有結點的關鍵字均小于Ki,An所指子樹中所有結點的關鍵字均大于Kn,
n([m/2]-1≤n≤m-1)為關鍵字的個數,n+1為子樹的個數
在B樹上查找的過程是一個順指針查找結點和在結點中查找關鍵字的交叉過程。
例如,要查找某個關鍵字,首先從外存中讀取根結點信息,如果發(fā)現待查找關鍵字不在里面,則
讀取該待查找關鍵字應該在的子樹根結點,以此查找,直至成功或查找結束都沒有找到而失敗為止
對于具有n個關鍵字的m階B樹,最壞情況下要查找?guī)状文兀?第一次層至少有1個結點,第二層至少有2個結點,除根結點外每個分支結點至少有[m/2]棵子樹,則
第三層至少有 2*[m/2] 個結點,...,這樣第 k+1 層至少有 2*([m/2])^(k-1) 個結點,而實際上
k+1層的結點就是葉子結點。若m階B樹有n個關鍵字,那么當找到了葉子結點,其實也就等于查找
不成功的結點為n+1,
因此,n+1 ≥ 2*([m/2])^(k-1)
k ≤ log[m/2]((n+1)/2) + 1
也就是說,在含有n個關鍵字的B樹上查找時,從根結點到關鍵字結點的路徑上涉及的結點樹
不超過 log[m/2]((n+1)/2) + 1
(4)B+樹
B樹雖然好處多,但也是有缺陷的,對于樹結構而言,可以通過中序遍歷來順序查找樹中的元素,
這都是在內存中進行。但是,這期間要進行多次外存和內存之間的交互,即使是已經訪問過的
外存中的資源所在頁面(同一頁面的不同數據)。
因此,為了能夠解決所有元素遍歷等基本問題,在原有的B樹結構基礎上增加了新的元素組織方式
這就是B+樹。它是應文件系統(tǒng)所需而設計的一種B樹的變形樹,從嚴格意義上講,它已不再是前面
章節(jié)中定義的樹了。
結構特點:在B+樹中,出現在分支結點中的元素會被當作它們在該分支結點位置的中序后繼者(葉子結點)中再
次列出。此外,每個葉子結點都會保存一個指向后一葉子結點的指針。
一棵m階的B+樹和m階的B樹的差異在于(是B+樹的特點):
1.有n棵子樹的結點中包含有n個關鍵字
2.所有的葉子結點包含全部關鍵字的信息,及指向含這些關鍵字記錄的指針,葉子結點本身以
關鍵字的大小自小而大順序鏈接
3.所有分支結點應該看成是索引,結點中僅含有其子樹中的最大(或最小)關鍵字
注意:它只能用來索引,而不能提供實際記錄的訪問,最終還是需要到達包含此關鍵字的終端結點(即葉子結點)
B+的優(yōu)點:
1.如果需要從最小到最大的關鍵字進行順序查找,就可以從葉子結點層(所有的葉子結點是在
同一層的)最左側不用經過分支結點,順序訪問下去即可遍歷所有的關鍵字。
2.B+樹特別適合帶有范圍的查找,比如查找班級 18~22 歲之間的學生人數,只需從根結點出發(fā)
找到第一18歲的學生,然后在葉子結點中安順序依次查找符合范圍的所有記錄
3.B+樹的插入和刪除是在葉子結點上進行的
9.散列表查找(哈希表)總述
前面的'順序查找'和'有序表查找'等都是通過待查找關鍵字與記錄中的關鍵字"比較" 來查找的,但是這種
比較并非非用不可,在某種方式中,可以直接通過待查找關鍵字就能得到其在內存中的位置。
(1)散列技術
查找某個關鍵字時,不用比較就可以通過某種方式(函數)直接獲得其存儲位置,這就是散列技術。
準確來說:散列技術是在記錄的存儲位置和它的關鍵字之間建立了一個確定的對應關系f,使得每個
關鍵字key對應一個存儲位置 f(key) 。那么在查找時,根據這個對應關系f直接找到key的映射f(key),即
其存儲位置。換言之,如果查找集合中存在這個記錄key,則必定在f(key)的位置上
注:這里講的對應關系f,就稱是哪里額為'散列函數'或'哈希(Hash)函數'
采用散列技術將記錄(即關鍵字)存儲在一塊連續(xù)的存儲空間中,該存儲空間被稱為'散列表'或'哈希表'
這些記錄對應的存儲位置,被稱為'散列地址'或'哈希地址', 這個散列地址是通過散列函數計算的存儲位置
┌─────────┐ f ┌─────────────┐
│ 散列表 │ │ 散列地址 │
│(哈希表) │ ──────> │ (哈希地址) │
├─────────┤ ├─────────────┤
│ (記錄1) │ │ (存儲地址1) │
│ (記錄2) │ │ (存儲地址2) │
│ ... │ │ ... │
│ (記錄n) │ │ (存儲地址n) │
└─────────┘ └─────────────┘
(2)散列表的查找過程
整個散列過程就是兩步:
1.存儲。通過散列函數,計算記錄的散列地址,并按該地址存儲該記錄
2.查找。查找是,我們通過同樣的散列函數,計算待查找記錄的散列地址,按此地址去訪問該記錄
散列技術的優(yōu)缺點:
優(yōu)點:適合求解查找與給定值相等的記錄。由于查找過程沒有了比較過程,效率提高了很多
缺點:不適合那種同樣的關鍵字,對應不同的記錄的情況。例如,一根班級的男生,
散列表也不適合范圍查找
想獲得表中記錄的排序情況也不可能,如最大、最小值等
(3)散列技術中的核心問題
1.散列函數的設計
如何設計出簡單、均勻、存儲利用率高的散列函數是散列技術中最關鍵的問題
2.沖突(collision)的解決
理想中,是不同的記錄對用不同的存儲地址。但是,在實際中,這是很難的一件事,往往
會出現 key1 ≠ key2,但 f(key1) == f(key2), 這種現象被稱為'沖突', 并把 key1 和 key2稱為
散列函數的同義詞(synoym)
注意:沖突不可能完全解決,只能盡量避免
10.散列函數的設計
什么樣的散列函數才是好的?
?'計算簡單'
在查找過程中,時間性能是很重要的,因此,散列函數不宜設計的過于復雜,否則導致計算存
儲地址時花費大量的時間,得不償失了
?'散列地址分布均勻'
分布越均勻,存儲空間越能被有效利用,并能減少沖突的發(fā)生。進一步,減少為了處理沖突而耗費的時間
幾種常用的散列函數構造方法
(1)直接定址法(實際中很少使用)
含義: 取關鍵字的某個線性函數值作為散列地址,即
' f(key) = a*key + b ; a, b 均為常數 '
優(yōu)缺點:
優(yōu):簡單、均勻、也不會產生沖突
缺:需要事先知道關鍵字的分布情況
適合查找表較小且連續(xù)的情況
(2)數字分析法
含義:所謂的分析法就是對關鍵字的各個部分進行分析,選取其中某些部分(往往是那些取值較分散的部分)來計算散列地址
當關鍵字的位數比較大時(如手機號),如果事先知道這些關鍵字的分布且關鍵字的若干位(即某些部分)分布較為均勻,
就可以考慮用分析法。
為了解決沖突,可以將這些部分進行折疊,循環(huán)移位,疊加等處理,其目的就是為了提供一個散列函數,能夠
合理的將關鍵字分配到散列表的各位置。
(3)平方取中法
含義:將關鍵字平方后,取平方值的中間幾位(具體是幾位具體分析),作為散列地址
平方取中的目的就是擴大差別(防止計算出來的散列地址堆積到一起),同時平方值的中間各位又能受到整個關鍵字中各位的影響。
適合于:不知道關鍵字的分布,而且位數又不是很大的情況,
或者關鍵字中每一位都有某些數字重復出現頻度很高的現象
(4)折疊法
含義:是將關鍵字分割成位數相同的幾部分(最后一部分的位數可以不同),然取它們的疊加
和(舍去進位)做散列地址
適用于:事先不需要知道關鍵字的分布,適合關鍵字位數較多的情況
具體分為:移位疊加法(低位對齊相加)
間界疊加法(來回折疊相加)
例如:對于關鍵字 0442205864, 散列表長取為4(即按4為劃分部分)
移位疊加法 間界疊加法
5864 5864
4220 0224
+ 04 + 04
─────────── ───────────
10088 6092
散列地址:0088 散列地址:6092
(5)除留余數法(重要)
最常用的構造散列函數的方法。對于散列表長為m的散列函數公式:
'f(key) = key mod p, 其中 P ≤ m '
mod是取模操作,即求余數。
實際,該方法不僅可以對關鍵字直接進行取模,還可以在折疊、平方取中后再取模
該方法的關鍵在于p的選取,如果選的不好,易產生沖突。
根據前人的經驗,若散列表的表長為m,通常p為小于或等于表長(最好接近m)的最小質數或不包含
小于20的質因子的合數
(6)隨機數法
含義:選擇一個隨機數,取關鍵字的隨即函數值為它的散列地址,即
' f(key) = random(key) , random是隨機函數'
適合于:關鍵字的長度不等時
注意:關鍵字不一定非得是常見的數字形式才行,因為,那些字符串等是可以轉換為某種數字的,本質還是數字
/*-------------------------------------------------------------------------------------*/
總結:
實際中,應視具體情況選取合適的散列函數,通常考慮如下:
1.計算散列地址所需的時間
2.關鍵字的長度
3.散列表的大小
4.關鍵字的分布情況
5.記錄查找的頻率。
11.處理散列沖突的方法
既然沖突無法避免,那就用勇敢的面對吧。
(1)開放定址法(重要)
'核心思想':就是一旦發(fā)生了沖突,就去尋找下一個空的散列地址,并將記錄存入。只要散列表足夠大,空的散列地址總能找到。
具體的處理方法:
1.線性探測法
' fi(key) = (f(key) + di) mod m , (di=1,2,3, ... , m-1),m為散列表長 '
對于每一個關鍵字計算散列地址時,若發(fā)現f(key)沖突了,則將di依次取1,2,..., 計算新的fi(key)
注意:
·只能向前尋找新的存儲位置
·堆積:那些本來不是同義詞的關鍵字,現在卻在存儲過程中會出現爭奪同一個地址的情況(即產生沖突),稱這種現象為堆積。
·堆積的現象會使存入和查找效率大大降低
2.二次探測法
' fi(key) = (f(key) + di) mod m , (di=1^2, -1^2, 2^2, -2^2, ..., q^2, -q^2) , q≤m/2 '
注意:
可以雙向尋找可能的空位置(提高的空間利用率)
3.隨機探測法
位移量di采用隨機函數計算得到(生成為隨機數叫偽隨機數)
' fi(key) = (f(key) + di) mod m , (di是一個隨機數列) '
注意:
·這里位移量di是偽隨機數,所謂的偽隨機數就是通過一定的算法生成的一系列數,因此隨機
種子如果是一樣的,那么這個隨機序列就是固定的。因此,存入和查找仍然是一致的。
(2)再散列函數法
'核心思想':另起爐灶,即當前發(fā)生沖突時,就更換散列函數,重新計算散列地址。
' fi(key) = RHi(key) , (i = 1,2,...,k) , RHi代表不同的函數'
RHi可以是除留余數法、平方取中法、折疊法等前面介紹的那些散列函數
缺點:要更換散列函數重新計算散列地址,因此在時間性能上就要打折扣了
(3)鏈地址法
白話:我就要做老賴,既然看上這個房子了,就是不走,你能住我也能住 ^_^?
'核心思想':將所有發(fā)生沖突的關鍵字存儲在單鏈表中(該鏈表被稱為同義詞子表),掛接在發(fā)生沖突的這個散列地址處。
如下圖所示:
用除留余數法做散列函數
關鍵字:[12,67,56,16,25,37,22,29,15,47,48,34]
m = 12
┌────┐
0 │ │→ 48 → 12
├────┤
1 │ │→ 37 → 25
├────┤
2 │ ^ │
├────┤
3 │ │→ 15
├────┤
...
注意:
優(yōu)點:該方法對于那些容易產生沖突的散列函數而言,是絕不會出現找不到地址的保障。
缺點:查找是需要遍歷單鏈表,這就使得查找性能受到影響
(4)公共溢出區(qū)法
白話:將那些不安分的家伙,單獨關起來(即放到公共區(qū))
'核心思想':將產生沖突的關鍵字單獨順序存儲到溢出區(qū)中。
基本表 溢出表
┌────┐ ┌─────┐
0 │ 12 │ 0 │ 37 │
├────┤ ├─────┤
1 │ 25 │ 1 │ 48 │
├────┤ ├─────┤
2 │ ^ │ 2 │ 34 │
├────┤ ├─────┤
3 │ 15 │ 3 │ ^ │
├────┤ ├─────┤
... ...
查找過程:
通過散列函數計算關鍵字的散列地址,在基本表中相應位置對比;
如果相等,則查找成功;
如果不想等,則到溢出表中進行'順序查找'。
注意:
該方法適合于沖突情況較少的情況。這樣溢出表就不會過大了。
12.散列表查找的代碼實現
'代碼實現'
public class HashTableSearch {
private int hashSize; //定義散列表長
private int[] elem; //關鍵字存放的數組
private int count; //當前元素的個數
private final int NULLKEY = -32768; //用于標識沒有元素存入
private int m;
//初始化
private void init0(int hashSize) {
this.hashSize = hashSize;
this.m = hashSize;
elem = new int[hashSize];
for(int i = 0; i < hashSize; i++) {
elem[i] = NULLKEY;
}
}
//定義散列函數:也可以是其他的函數
private int hash(int key) {
return key % m; //采用除留余數法
}
//對關鍵字進行插入操作
public void insertHash(int[] keyArr) {
init0(keyArr.length); //初始化
int addr; //表示地址
int d; //線性探測的移位步長
for(int i = 0; i < keyArr.length; i++) {
addr = hash(keyArr[i]);
int addr0 = addr;
d = 1; //線性探測的移位步長
while(elem[addr] != NULLKEY) {
//說明位置已經被占了,需要解決沖突:可以是開發(fā)定址法、再散列法、鏈地址法等
addr = (addr0 + d++) % m; //使用開發(fā)定址法的線性探測法
}
//說明已經找到了空的位置
elem[addr] = keyArr[i]; //插入關鍵字
}
}
/**
* 通過散列表查找記錄
* 返回-1表示沒有該關鍵字
* @param key
* @return
*/
public int search(int key) {
int addr = hash(key);
int addr0 = addr;
int d = 1; //線性探測的移位步長
while(elem[addr] != key) {
addr = (addr0 + d++) % m;
if(elem[addr] == NULLKEY || addr0 == addr) {
//搜索到NULLKEY或搜索了一圈回到起點,則表明沒有該關鍵字
return -1; //
}
}
return addr;
}
}
13.散列表查找性能分析
如果沒有沖突(實際是不可能的)的話,時間復雜度:O(1)
實際其查找長度取決一下幾點:
1.選取的散列函數
散列函數是否均勻?
2.處理沖突的方法
即使相同的關鍵字、相同的散列函數,但處理沖突的方法不同,會使平均查找長度不同。
比如,線性探測處理就可能產生堆積,顯然沒有二次探測好
鏈地址法不會產生任何堆積,因而其平均查找性能更好點
3.散列表的裝填因子
含義:裝填因子 α = 裝入表中的記錄個數/散列表長
α表示散列表的裝滿程度。α越小發(fā)生沖突的可能性越低,反之越高。
通常將散列表的長度設計的比查找集合大,這樣就是α較小,從而使沖突的產生減小
一般情況下,認為選取的散列函數是均勻的,則在討論平均查找長度ASL時只考慮處理沖突的方法和裝填因子
證明得:
(1)線性探測法處理沖突:ASL≈ 1/2*(1 + 1/(1-α))
(2)平方探測法:ASL≈ -1/α*ln(1 - α)
(3)鏈地址法:ASL≈ 1 + α/2