數據結構與算法(C++)學習筆記:查找(更新完畢)

基本概念

  • 生活中處處有查找,例如:搜索引擎、大數據問題等等。
  • 我們學習查找想要解決的問題:對於超大數據量,如何提高查找的效率?
  • 基本概念:
  1. 關鍵碼:用以標識一個記錄的某個數據項。如果該關鍵碼可以唯一的標識一條記錄,則稱爲主關鍵碼,反之爲次關鍵碼。
  2. 查找:在具有相同類型的記錄集中找出滿足給定條件的記錄。
  3. 查找結果:在查找集中找到匹配的記錄,稱爲查找成功;否則查找失敗。一般情況下,查找需要返回記錄的位置。
    基本概念

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:

  1. 查找不成功 ASL = n+1
  2. 查找成功
    哨兵法ASL

折半查找(敲黑板:必考題)

思考: 折半法的前提是什麼?待查找序列爲有序表。
基本思想: 先確定待查記錄所在的範圍,再用二分法逐步縮小範圍直到找到或找不到且查完整個表。
再思考: 對存放在數組中的有序表,如何快速找到Key?
折半
折半
折半
注意:

  1. (以上題爲例)low的第一次移動要移動到 mid+1,因爲mid原來坐在的位置,數據已經比較過一次了,可以直接跳到它的下一個。(同理:high=mid-1)
  2. 如何判斷沒有找到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+!,性能最好 有序表
分塊查找 性能位於前兩者中間 分塊有序表

然而這三者都只適用於靜態查找。如果我們想在查找的同時,對一些記錄進行添加、刪除操作,就要使用下面的樹表。

樹表查找

樹表查找是典型的動態查找,適用於亂序表的查找,同時也可以對記錄進行操作。

二叉排序樹

基本思想: 將亂序化有序,然後根據折半查找的思想進行查找。

定義

二叉排序樹:

  1. 空樹
  2. 具有如下性質的樹(注意體會遞歸的思想):
  • 若它的左子樹不空,則左子樹上所有結點的值均小於根節點的值
  • 若它的右子樹不空,則右子樹上所有結點的值均大於根結點的值
  • 它的左右子樹也分別都是二叉排序樹
    例如:
    二叉排序樹
    顯然,當我們對二叉排序樹進行中序遞歸時,就可以得到有序數表。

通常,可取二叉鏈表作爲二叉排序樹的結點存儲結構。

template<class T>
class BiNode
{
public:
    T data;
    BiNode<T> *lch;
    BiNode<T> *rch;
    BiNode():lch(NULL),rch(NULL){};   //構造函數
}

建立

基本思路:

  1. 若當前節點=NULL,直接插入
  2. 否則將給定值與當前結點進行比較
    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即可
在這裏插入圖片描述

被刪除的結點既有右子樹也有左子樹(最複雜)

爲了解決這一問題,我們又遇到了化繁爲簡的思想,只需要將這種情況轉化爲前兩種情況即可。

算法分析:

  1. 中序遍歷得到悲刪除結點p的前驅結點q(q是p的左子樹最右下結點),則q必爲單分支結點或葉結點(總之,q的右指針必爲空)
  2. 將q的值賦給p的值域(不必更改q的值域)
  3. 將刪除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. 空樹
  2. 具有如下性質的樹:
  • 左右子樹都是平衡二叉樹
  • 左右子樹高度值差的絕對值小於等於1
    如果在建立二叉排序樹時,保證其爲平衡二叉樹,則可避免查找的時間複雜度從O(2logn)退化成O(n)
    (對於平衡二叉樹,此處不再詳細講解,有興趣的朋友可以看看這篇文章:平衡二叉樹(AVL)圖解與實現)

散列查找

散列查找的效率非常高!舉個栗子,索引查找。

散列技術

什麼是查找?
確定關鍵碼=給定值的記錄在集合中的存儲位置。由於存儲位置與關鍵碼之間不存在確定的對應關係,因此,查找時必須通過一系列與關鍵碼的比較。
理想情況:
在記錄的存儲位置與其關鍵碼之間建立一個確定的對應關係H,使得每個關鍵碼key和唯一的一個存儲位置H(key)對應。
這就是散列技術,採用散列技術將記錄存儲在一塊連續的存儲空間中,就是散列表。
散列過程:

  1. 存儲記錄,通過H(key)計算記錄的散列地址,並按此地址存儲記錄
  2. 查找記錄,通過同樣的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)函數計算散列地址,先與基本表中記錄進行比較,若相等,則查找成功,否則,到溢出表順序查找。
這種方法跟第二種一比就比較麻煩了。
公共溢出法

散列查找的性能分析

性能分析: 散列技術中,處理衝突的方法不同,得到的散列表不同,散列表的查找性能也不同。
決定性能的因素: 比較次數取決於發生衝突的概率,產生的衝突越多,查找效率就越低。
舉個栗子:
性能分析
影響衝突的因素:

  1. 散列函數是否均勻
  2. 處理衝突的方法
  3. 散列函數的填裝因子a在這裏插入圖片描述
    a越大,代表填入表中的記錄越多,產生衝突的可能性就越大。

後面會出有關查找算法實例的新文章(尤其是二叉排序樹)
如果對上述內容有疑問,歡迎大家評論或私聊。
一起學習,一起進步~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章