loading...

07查找

7.1 查找基本概念

  • 查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
  • 查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
  • 关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的
  • 评价查找算法效率指标
    • 查找长度:在查找运算中,需要对比关键字的次数称为查找⻓度
    • 平均查找长度,ASL,Average Search Length:所有查找过程中进行关键字的比较次数的平均值
      • ASL=\(\displaystyle \sum^{n}_{i=1}P_iC_i\),其中n表示数据元素个数,\(P_i\)表示查找第i个元素的概率,\(C_i\)表示查找第i个元素的查找长度
      • 通常认为查找任何一个元素的概率是相同的
      • 评价一个查找算法效率是通常考虑查找成功/失败时的ASL,ASL的数量级反应了查找算法时间复杂度

7.2 顺序查找

又名线性查找,通常用于线性表。算法思路:

  • 从头到尾(序号从小到大)遍历所有元素进行查找
  • 若查找成功返回元素下标,查找失败返回-1

顺序查找还可以使用哨兵用于存放查询结果下标,一般为数组的0号元素

  • 相比普通顺序查找,数组a[n+1]需要存放n+1个元素,若查找成功a[0]即为查询结果,查找失败a[0]=0
  • 查找顺序可从第n+1个元素到第1号元素,如果均未找到下标返回0

顺序查找时间复杂度为O(n)

  • 顺序查找

    \[ASL_{成功}=\frac{1+2+3+...+n}{n}=(n+1)/2 \]

    \[ASL_{失败}=n+1 \]

7.2.1 有序优化

如果线性表本身有序,若查找元素k有a[i]<k<a[i+1],说明在该有序表中无法查找到该元素,直接结束查找

  • 因此该种顺序查找共存在n+1中失败情况,$$ASL_{失败}=\frac{1+2+3+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}$$
  • 其中当a[n]<k和a[n-1]<k<a[n]两种情况均需要遍历整个表,因此都需要查找n次

结合这一优化方式,算法的ASL可通过有序节点链构成的查找判定树进行分析

  • 该树包含n个查找成功节点以及n+1个查找失败节点
  • 一个成功节点的查找长度=该节点在查找判定树的所在层数
  • 一个失败节点的查找长度=该节点的父节点在查找判定树的所在层数
  • 默认情况下,失败情况或成功情况都等概率发生

7.2.2 概率优化

如果线性表每个节点的被查找概率不等,可按照概率从大到小排列,被查找概率大的靠前放置,从而减小\(ASL_{成功}\),但是对于\(ASL_{失败}\)仍然为n+1

7.3 折半查找

又称二分查找,只用于有序的顺序表。算法思路:

  • 假设顺序表升序,初值设置low=1,high=n
  • 每次mid=⌊(low+high)/2⌋,向下取整,查找值k与a[mid]进行比较。如果k>a[mid]说明值偏小,low=mid+1;如果k<a[mid]说明值偏大,high=mid-1
  • 重复上述过程,如果找到直接结束循环,mid即为查找元素的下标。直到low>high,结束循环,说明未找到

折半查找必须基于元素可以随机访问的特性,因此链表无法进行折半查找

7.3.1 折半查找判定树

  • 如果采用mid=⌊(low+high)/2⌋,向下取整,则有
    • 如果当前low与high之间共有奇数个元素,则mid分割后左右两部分元素个数相等
    • 如果当前low与high之间共有偶数个元素,则mid分割后左部分元素比有部分少一个元素
  • 折半查找判定树中,若采用mid=⌊(low+high)/2⌋,向下取整,则对于任何一个节点,暂不考虑失败节点,其右子树节点数-左子树节点数=0或1
    • 折半查找判定树一定是平衡二叉排序树,因此对于n个节点顺序表,其折半查找判定树的树高h=\(\lceil log_2(n+1)\rceil\),向上取整。但该树高不包含失败节点
    • 折半查找判定树具有n个成功查找节点,n+1个失败查找节点
    • \(ASL_{成功}\)≤h,\(ASL_{失败}\)≤h,折半查找时间复杂度为O(\(log_2n\))

相同节点数的折半查找判定树与完全二叉树的高度相同

7.4 分块查找

又称索引顺序查找,将顺序表中的元素进行分块存放,每个分块中元素的值均在某个范围内,呈现块内无序,块间有序的特点。由索引表去保存每个分块内最大元素值以及所在区块的下标范围,算法思路:

  • 在索引表中定位元素的所在分块,由于索引表时有序的,该定位可使用折半查找
  • 在所在块内进行顺序查找

如果使用折半查找在索引表中进行定位,假设\(n_0\)为索引表长,可能会出现三种情况

  • 所查找的值正好是索引中的值,则直接到所在区块进行顺序查找即可
  • 所查找的值并未存放在索引表中,那么必然有low > high且low = high + 1
    • 若low < \(n_0\),即查找值为超出顺序表中最大值,则到low所指的块中查找
    • 若low ≥ \(n_0\),则直接说明查找失败

7.4.1 分块查找判定树

对于分块查找而言,每个元素的被查找概率都是相同的,查找次数由索引表查找与块内顺序查找两部分决定

假设每个长度为n的查找表被均匀分为j块,每块有k个元素,即n = jk。设索引表查找和块内查找的平均查找长度分别为\(L_1,L_2\),则分块查找ASL = \(L_1\) + \(L_2\)

  • 用顺序查找查索引表,则

    \[L_1 = \frac{1+2+3+...+j}{j} = \frac{1+j}{2} \]

    \[L_2 = \frac{1+2+3+...+k}{k} = \frac{1+k}{2} \]

    \[ASL = \frac{1+j}{2} + \frac{1+k}{2} = \frac{j^2+2j+n}{2s} \]

    其中,当j = \(\sqrt{n}\)时,\(ASL_{最小}\) = \(\sqrt{n}\) + 1
  • 用折半查找查索引表,则

    \[L_1 = \lceil log_2(j+1) \rceil \]

    \[L_2 = \frac{1+2+3+...+k}{k} = \frac{1+k}{2} \]

    \[ASL = \lceil log_2(j+1) \rceil + \frac{1+k}{2} \]

7.5 二叉排序树

二叉排序树,又称二叉查找树,BST,Binary Search Tree。一棵二叉树或者空二叉树,满足如下性质:

  • 左子树上所有结点的关键字均小于根结点的关键字
  • 右子树上所有结点的关键字均大于根结点的关键字
  • 左子树和右子树又各是一棵二叉排序树

二叉排序树的左子树节点值<根节点值<右子树节点值,若进行中序遍历可以得到一个递增的有序序列

7.5.1 查找

算法思路:

  • 若树非空,目标值与根节点值比较
    • 若相等则查找成功
    • 若小于根节点值,向其左子树进行查找,否则向右子树进行查找
  • 查找成功,返回节点指针;查找失败,返回NULL
typedef struct BSNode{
    ElemType data;
    struct BNode *lchild,*rchild;
}BSTNode,*BSTree;

BSTNode *SearchBST(BSTree T,ElemType key){
    //递归实现
    if(!T) return NULL;
    else{
        if(T->data==key) return T;
        else if(T->data>key) return SearchBST(T->lchild,key);
        else return SearchBST(T->rchild,key);
    }
    
    /*
    //非递归实现
    while(T && key!=T->data){
        if(T->data>key) T=T->lchild;
        else T=T->rchild;
    }
    return T;
    */
} 

两种算法实现方法时间复杂度均为O(\(log_2n\)),递归实现空间复杂度为O(h),h为二叉排序树的高度;而非递归实现空间复杂度为O(1)

7.5.2 插入

算法思路:

  • 若原二叉排序树为空,则直接插入结点
  • 否则,若关键字k小于根结点值,则插入到左子树;若关键字k大于根结点值,则插入到右子树
typedef struct BSNode{
    ElemType data;
    struct BNode *lchild,*rchild;
}BSTNode,*BSTree;

void InsertBST(BSTree &T,ElemType key){
    //递归实现
    if(!T){
        T=(BSTNode *)calloc(1,sizeof(BSTNode));
        T->data=key;
    }
    else if(T->data > key)
        InsertBST(T->lchild,key);
    else i(T->data < key)
        InsertBST(T->rchild,key);

    /*
    //非递归实现
    if(!T){
        T=(BSTNode *)calloc(1,sizeof(BSTNode));
        T->data=key;
    }
    else{
        BSTNode *p=T;
        while(key!=p->data){
            BSTNode *tmp=p;
            if(p->data>key) p=p->lchild;
            else p=p->rchild;
            if(!p){
                tmp->lchild=(BSTNode *)calloc(1,sizeof(BSTNode));
                tmp->lchild->data=key;
                break;
            }
        }
    }
    */
} 

递归实现空间复杂度为O(h),h为二叉排序树的高度;而非递归实现空间复杂度为O(1)

7.5.3 二叉排序树构造

二叉排序树根据插入的次序不同,构造的树结构也不同

typedef struct BSNode{
    ElemType data;
    struct BNode *lchild,*rchild;
}BSTNode,*BSTree;

void InsertBST(BSTree &T,ElemType key); //二叉排序树插入
void CreateBST(BSTree &T,ElemType array[],int n){
    T=NULL;
    for(int i=0;i<n;i++) InsertBST(T,array[i]);
}

7.5.4 删除

删除二叉排序中的节点,算法思路:

  • 先查找目标节点
    • 若该节点为叶子节点,即没有左右子,则直接删除
    • 若该节点只有一棵左子树或一棵右子树,则子树树根替代原先节点的位置
    • 若节点度为2,则让该节点的直接后继(或直接前驱)替代该节点,然后从BST中删除该直接后继(或直接前驱),转变为前两种情况
      • 直接后继为该节点右子树中最小的节点
      • 直接前驱为该节点左子树中最大的节点
typedef struct BSNode{
    ElemType data;
    struct BNode *lchild,*rchild;
}BSTNode,*BSTree;

BSTNode *FindNextNodeBST(BSTree T,BSTNode *p){
    BSTNode *node=T;
    while(node->lchild){
        p=node;
        node=node->lchild;
    }
    return node;
}

BSTNode *FindPreNodeBST(BSTree T,BSTNode *p){
    BSTNode *node=T;
    while(node->rchild){
        p=node;
        node=node->rchild;
    }
    return node;
}

void RemoveBST(BSTree &T,ElemType key){
    //非递归实现
    if(T){
        BSTNode *node=T,*p;
        while(node && key!=node->data){
            p=node;
            if(node->data>key) node=node->lchild;
            else node=node->rchild;
        }
        if(node){
            if(!node->rchild && !node->lchild){
                if(p->lchild==node){
                    p->lchild=NULL;
                    free(node);
                }
                else{
                    p->rchild=NULL;
                    free(node);
                }
            }
            else if(node->rchild && node->lchild){
                //直接前驱替换
                BSTNode *tmp=FindPreNodeBST(node->lchild,p);
                node->data=tmp->data;
                free(tmp);
                p->rchild=NULL;
                /*
                //直接后继替换
                BSTNode *tmp=FindNextNodeBST(node->rchild,p);
                node->data=tmp->data;
                free(tmp);
                p->lchild=NULL;
                */
            }
            else if(node->rchild){
                if(p->lchild==node){
                    p->lchild=node->rchild;
                    free(node);
                }
                else{
                    p->rchild=node->rchild;
                    free(node);
                }
            }
            else{
                if(p->lchild==node){
                    p->lchild=node->lchild;
                    free(node);
                }
                else{
                    p->rchild=node->lchild;
                    free(node);
                }
            }
        }
    }
}

7.5.5 查找效率分析

二叉排序树的查找成功/失败效率和树的高度有关,最好情况二叉排序树高度为\(\lfloor log_2n \rfloor+1\),即\(ASL_{最好}\) = O(\(log_2n\));最坏情况树高为n,即\(ASL_{最坏}\) = O(n)

二叉排序树的查找性能与折半查找相比

  • 如果该二叉排序树平衡,则查找效率相同
  • 如果该二叉排序树不平衡,则查找效率不如折半查找

7.6 平衡二叉树

平衡二叉树简称平衡树,Blanced Binary Tree,也可叫AVL树,满足以下性质

  • 树上任意节点的左子树和右子树的高度之差不超过1
  • 节点的平衡因子=左子树高度-右子树高度。因此平衡二叉树上的节点平衡因子只可能是0、-1、1
  • 对于平衡的二叉排序树,其查找效率可以保持在O(\(log_2n\))
  • 数据结构中同时存放height和balance属性,可以让节点在O(1)时间内获取某个子树的高度以及平衡因子
typedef struct AVLNode{
    ElemType data;
    int balance;
    int height;
    struct AVLNode *lchild,rchild;
}AVLNode,*AVL;

7.6.1 插入

由于新插入的节点可能对查找路径上的节点平衡因子造成影响,所以关键是找到第一个不平衡节点进行调整,故每次调整的对象都是最小不平衡子树

调整最小不平衡子树E,有如下四种情况

  • LL:在E的左子的左子树中插入,导致不平衡
  • RR:在E的右子的右子树中插入,导致不平衡
    • 如果由于这两种情况导致不平衡,则一定有根节点A的平衡因子为1,子树中被插入新节点的节点B的平衡因子为0
    • 同时一定有如下情况,假设根节点A未插入新节点一侧的子树高度为h,那么为了保持上述情况,B节点的左右子树高度也为h
    • 当作为子树E左子根节点B的左子树插入新节点时,其高度变为H+1,B节点平衡因子+1,A节点平衡因子也+1,A节点平衡因子为2,出现不平衡;作为子树E右子节点B的右子树插入新节点同理
  • LR:在E的左子的右子树中插入,导致不平衡
  • RL:在E的右子的左子树中插入,导致不平衡
    • 新增的节点最小不平衡子树的中间节点。且由于插入新节点后发生不平衡,节点B的平衡因子一定为1或-1,因而导致节点B一定有非空孩子节点C
    • 假设这两种情况如果和LL、RR一样的处理方式,假定孩子节点B所在子树高度为h,直接单旋会导致原根节点A所在子树高度变为h+2,而B节点另一个子树高度仍为h,最小不平衡子树仍然不平衡
    • 考虑上述的情况在于调整后,不平衡树中高度最高的最小子树并没有调整到单独的一侧,从而导致失衡。因此采用先从最底层旋转,再向上旋转到根的方式,即先将孙子节点C与根儿子节点B进行单旋互换,过程同LL、RR;然后再和根节点进行单旋互换

对于LL和RR情况的调整

  • 新增的节点处于最小不平衡子树的最大或最小子树P,因此所在子树不需要调整位置
  • 记子树P的父节点B的另一个子树为Q,节点B替换子树E的根,原根节点A与子树P构成节点B的左右子,子树Q与节点A原子树构成节点A的左右子
  • LL对应右单旋,RR对应左单旋。节点B和节点A的平衡因子均变为0
  • 调整后最小不平衡树高度-1,并向上传递更新祖先节点平衡因子

对于LR和RL情况的调整

  • 假定孙子节点C插入新节点的子树P高度为h,另一个子树P的高度则为h-1,则有
  • 若孙子节点C的平衡因子为-1
    • 第一次单旋
      • 如果节点C在最小不平衡树根节点A的右子树中(RL),儿子节点B继承节点C的右子作为节点C的右子,且下次单旋不需要调整,由于节点C平衡因子为-1,节点B的平衡因子确定为0
      • 如果节点C在根节点A的左子树中(LR),节点B继承节点C的左子作为节点C的左子,由于节点C平衡因子为-1,节点B的平衡因子确定为1
    • 第二次单旋
      • 如果节点C在根节点A的右子树中(RL),节点A继承节点C的左子作为整棵树的左子树,此时左子树高为h+1,右子树高度也为h+1,因而节点C的平衡因子为0。由于节点C第二次单旋前平衡因子为-1,节点A平衡因子变为1
      • 如果节点C在根节点A的左子树中(LR),节点A继承节点C的右子作为整棵树的右子树,节点C的平衡因子也为0。由于节点C第二次单旋前平衡因子为0,节点A平衡因子也变为0
  • 若孙子节点C的平衡因子为1
    • 本质上和前一种情况相同,由于节点C的平衡因子为1,调整后节点A、B、C的平衡因子有所区别
      • 如果节点C在根节点A的右子树中(RL),最终节点A的平衡因子为0,节点B的平衡因子为-1,节点C的平衡因子为0
      • 如果节点C在根节点A的右子树中(LR),最终节点A的平衡因子为-1,节点B的平衡因子为0,节点C的平衡因子为0

综上所述,新增一个节点,各节点平衡因子变化如下

  • RR,A=0,B=0,C=0
  • LL,A=0,B=0,C=0
  • LR
    • 若C=-1,则A=0,B=1,C=0
    • 若C=1,则A=-1,B=0,C=0
  • RL
    • 若C=-1,则A=1,B=0,C=0
    • 若C=1,则A=0,B=-1,C=0

算法如下:

typedef struct AVLNode{
    ElemType data;
    int balance;
    int height;
    struct AVLNode *lchild,*rchild;
}AVLNode,*AVL;

void UpdateBalanceAndHeight(AVLNode *T){
    T->balance=(T->lchild?T->lchild->height:0)-(T->rchild?T->rchild->height:0);
    T->height=1+((T->balance>0)?(T->lchild?T->lchild->height:0):(T->rchild?T->rchild->height:0));
}

void AdjustAVL(AVL &T){
   if(T && ( T->balance==2 || T->balance==-2)){
        //LL,右单旋转
        if(T->lchild && T->lchild->balance == 1){
            //节点A为原根节点,节点B为A的左子,节点C为B的左子
            AVLNode *A=T,*B=T->lchild,*C=T->lchild->lchild;
            //左子抬升成AVL根
            T=B;
            //原根作为左子的右子,原根继承左子的右子
            A->lchild=B->rchild;
            B->rchild=A;
            //更新平衡因子和高度
            updateBalanceAndHeight(A);
            updateBalanceAndHeight(C);
            updateBalanceAndHeight(B);
        }
        //RR,左单旋转
        else if(T->rchild && T->rchild->balance == -1){
            //节点A为原根节点,节点B为A的右子,节点C为B的右子
            AVLNode *A=T,*B=T->rchild,*C=T->rchild->rchild;
            //右子抬升成AVL根
            T=B;
            //原根作为右子的左子,原根继承右子的左子
            A->rchild=B->lchild;
            B->lchild=A;
            //更新平衡因子和高度
            updateBalanceAndHeight(A);
            updateBalanceAndHeight(C);
            updateBalanceAndHeight(B);
        }
        //LR,先左后右双旋转
        else if(T->lchild && T->lchild->balance == -1){
            //节点A为原根节点,节点B为A的左子,节点C为B的右子
            AVLNode *A=T,*B=T->lchild,*C=T->lchild->rchild;
            //孙子左单旋
            A->lchild=C;
            B->rchild=C->lchild;
            C->lchild=B;
            //儿子右单旋
            T=C;
            A->lchild=C->rchild;
            C->rchild=A;
            //更新平衡因子和高度
            updateBalanceAndHeight(B);
            updateBalanceAndHeight(A);
            updateBalanceAndHeight(C);
        }
            //RL,先右后左双旋转
        else if(T->rchild && T->rchild->balance == 1){
            //节点A为原根节点,节点B为A的右子,节点C为B的左子
            AVLNode *A=T,*B=T->rchild,*C=T->rchild->lchild;
            //孙子右单旋
            A->rchild=C;
            B->lchild=C->rchild;
            C->rchild=B;
            //儿子右单旋
            T=C;
            A->rchild=C->lchild;
            C->lchild=A;
            //更新平衡因子和高度
            updateBalanceAndHeight(B);
            updateBalanceAndHeight(A);
            updateBalanceAndHeight(C);
        }
    }
}

void InsertAVL(AVL &T,ElemType key){
    if(!T){
        T=(AVLNode *) calloc(1,sizeof(AVLNode));
        T->data=key;
        T->height=1;
        T->balance=0;
    }
    else if(T->data-key){
        if(T->data>key) InsertAVL(T->lchild,key);
        else if(T->data<key) InsertAVL(T->rchild,key);
        UpdateBalanceAndHeight(T);
        AdjustAVL(T);
    }
}

7.6.2 删除

算法思路:

  • 删除查找节点,方法同二叉排序树删除,如果未找到直接结束
    • 若该节点为叶子节点,即没有左右子,则直接删除
    • 若该节点只有一棵左子树或一棵右子树,则子树树根替代原先节点的位置
    • 若节点度为2,则让该节点的直接后继(或直接前驱)替代该节点,然后从BST中删除该直接后继(或直接前驱),转变为前两种情况
      • 直接后继为该节点右子树中最小的节点
      • 直接前驱为该节点左子树中最大的节点
  • 可从被删除节点向上查找祖先节点的平衡因子,确定是否存在最小不平衡子树,若不存在直接结束,反之找到最小不平衡子树中儿子辈、孙子辈中平衡因子为1、-1的两个节点,判断需要调整的类型(LL、RR、LR、RL)
  • 如果最小不平衡子树向上传导,则重复调整最小不平衡子树,直至整个树平衡

删除节点和新增节点的初始状态不同,删除一个节点,对树的高度影响不确定

typedef struct AVLNode{
    ElemType data;
    int balance;
    int height;
    struct AVLNode *lchild,*rchild;
}AVLNode,*AVL;

void AdjustAVL(AVL &T);  //调整AVL平衡
void UpdateBalanceAndHeight(AVLNode *T); //更新节点高度和平衡因子

void RemoveAVL(AVL &T,ElemType key){
    if(T){
        if(T->data>key) RemoveAVL(T->lchild,key);
        else if(T->data<key) RemoveAVL(T->rchild,key);
        else{
            if(!T->rchild || !T->lchild){
                AVLNode *tmp=T->rchild?T->rchild:T->lchild;
                if(!tmp){
                    tmp=T;
                    T=NULL;
                }
                else {
                    T->data=tmp->data;
                    if(T->rchild) T->rchild=NULL;
                    else T->lchild=NULL;
                }
                free(tmp);
            }
            else{
                //直接前驱替换
                AVLNode *tmp=T->lchild;
                while(tmp->rchild) tmp=tmp->rchild;
                T->data=tmp->data;
                RemoveAVL(T->lchild,tmp->data);
                /*
                //直接后继替换
                AVLNode *tmp=T->rchild;
                while(tmp->lchild) tmp=tmp->lchild;
                T->data=tmp->data;
                RemoveAVL(T->rchild,tmp);
                */
            }
        }
        if(T){
            UpdateBalanceAndHeight(T);
            AdjustAVL(T);
        }
    }
}

7.6.3 查找效率分析

含n个节点的平衡二叉(排序)树最大深度为O(\(log_2n\)),因此平均搜索长度为O(\(log_2n\))。由于数据结构中新增了balance、height两个属性,实际上每次调整平衡因子、以及计算平衡因子在O(1)时间内可以完成,但是每次操作都需要经过很多次运算,该次数和查找长度有关。因而平衡二叉树插入、删除操作时间复杂度可视为O(\(log_2n\))

注:AVL树本身并没有强调元素之间的前后顺序,在代码实现上为了方便编写和测试,因此一般实现的是平衡二叉排序树的增删
无序的AVL树的查找、插入、删除节点操作均需要遍历整颗树,效率上有所损失,无法达到O(\(log_2n\))

7.7 红黑树

平衡二叉排序树在普通二叉排序树的基础上,优化了树的高度从而提高查找效率。但是由于插入、删除节点很容易造成AVL树失衡,需要计算节点的平衡因子以及调整树的结构造成时间浪费,而红黑树在进行插入、删除节点操作很多情况下并不会破坏树本身的特性,尽管需要调整树结构仅需O(1)时间内可完成

平衡二叉树跟适合以查为主,插入、删除比较少的应用环境,红黑树适合频繁插入、删除的场景

红黑树,RBT,Red-Black Tree

  • 红黑树是二叉排序树,并满足一下条件
    • 红黑树每个节点为红色或黑色
    • 根节点颜色为黑色
    • 叶节点均为黑色,这里的叶节点也叫外部节点、NULL节点、失败节点。原先树中具有数值的叶节点不一定是黑的
    • 不存在两个相邻的红色节点,即红色节点的父节点和孩子节点均为黑色
    • 对每个节点,从该节点到任一叶节点的简单路径上,所含黑结点的数目相同

      此处的节点专指内部节点,不包括外部节点

typedef struct RBNode{
    ElemType data;  //数据域
    bool color; //节点颜色,false表示黑,true表示红色
    int blankHeight;    //节点黑高,默认为0
    struct RBNode *parent;  //指针域,父节点指针
    struct RBNode *lchild,*rchild;  //指针域,左右子指针
}*RBT,RBNode;

节点的黑高BH:从某节点出发(不包含该节点)到达任意空叶节点(外部节点)的路径上黑节点的数量

7.7.1 红黑树性质

  • 从根节点到叶节点的最长路径不大于最短路径的长度的两倍,因此任意节点左、右子树的高度之差不会相差两倍
    • 任何一条查找失败路径上的黑节点数量都相同,而路径上不能连续出现两个红节点,即红节点只能穿插在各个黑节点中间
    • 最长路径则为每个黑节点中间均穿插一个红节点,最短路径则是全为黑节点
  • 某节点的黑高为h时,内部节点数量至少为\(2^h\)-1个
    • 如果要求内部节点最少的情况,只可能是满二叉树,且包括外部节点所有节点全为黑节点,即内部节点构成高度为h-1的满二叉树
  • 有n个内部节点的红黑树高度h ≤ 2\(log_2\)(n+1)
    • 若红黑树总高度为h,则根节点的黑高 ≥ h/2,因此内部节点数n ≥ \(2^{h/2}\)-1,由此得出

7.7.2 查找

BHT由于是二叉排序树,因此查找节点和BST、AVL是相同的,若找到外部节点则说明查找失败

7.7.3 插入

算法思路

  • 查找,确定插入位置,同BST插入
  • 如果新节点是根,染黑色;如果非根,则染红色
    • 插入新节点后,如果整颗BHT的结构没有破坏,则直接结束
      • 检查新节点和他的父节点是不是均为红色
    • 若插入后BHT结构发生变化,需要调整
  • 调整:查看新节点的叔叔节点的颜色,即新节点的父节点的兄弟节点的颜色决定
    • 如果叔叔节点为黑色,旋转在染色,旋转同AVL
      • LL:右单旋,父节点与爷节点互换,父、爷节点均染色
      • RR:左单旋,父节点与爷节点互换,父、爷节点均染色
      • LR:左、右双旋,子节点与爷节点互换,子、父节点均染色
      • RL:右、左双旋,子节点与爷节点互换,子、父节点均染色
    • 如果叔叔节点为红色
      • 叔、父、爷节点染色,爷节点视为刚刚新插入节点,从第二步开始重新判断

扩展:

  • 如果关键字为升序,依次插入2\(^n\)-1个节点到空红黑树中,最终的红黑树不一定呈现满二叉树,一般会偏右
  • 如果关键词为降序,依次插入2\(^n\)-1个节点到空红黑树中,最终的红黑树不一定呈现满二叉树,一般会偏左

7.7.4 删除

红黑树删除操作的时间复杂度为O(\(log_2n\)),在红黑树中删除节点的操作和BST删除节点一样。由于删除节点操作可能会破坏红黑树的性质,因此需要对节点位置、颜色进行调整

7.8 B树

m叉查找树,又名多路平衡查找树,因此B树也是一棵AVL树,简称B树: 每个节点最多可以存放m-1个关键词,m个指针

  • 每个节点最少1个关键词,2个分叉;最多m-1个关键词,m个分叉
  • 节点内关键词有序
  • 规定m叉查找树中,除了根节点外,任何节点至少有\(\lceil m/2 \rceil\)个分叉,即至少有\(\lceil m/2 \rceil\)-1个关键词
    • 如果每个节点内关键字太少,会导致树变高,需要查找更多层节点造成查找效率降低
    • 根节点无法满足这个条件,当树内元素小于m/2个时,根节点只能存放这么多,因此根节点的关键字数∈[2,m],子树数∈[1,m-1]
  • 规定m叉查找树中,任何一个节点其所有子树高度均相同
    • 结合AVL性质,避免了失去平衡导致树的高度过高
  • 所有叶节点都出现在同一层次,且不带信息,其实是空指针,表示查找失败
    • 终端节点位于叶节点上一层,是深度最深的关键字节点
    • B树的树高不包含失败节点
struct Node{
    ElemType data[m-1];     //最多存放m-1个关键字
    struct Node *child[m];  //最多m个孩子
    int num;    //节点有多少个关键词
};

7.8.1 B树性质

  1. 对于n个关键字的m阶B树,不论m为何值,叶节点/失败节点数量为n+1
  • B树结构是为了加快对n个关键字查找效率,因此本质上B树的查找失败就是对n个顺序结构的数据查找失败的次数,即n+1个失败情况,故B树的叶节点共n+1个
  • 由于B树是链式结构,无法支持顺序查找
  1. 含n个关键字的m阶B树,最小高度\(h_{min}\)\(log_m\)(n+1)
  • 计算B树高度一般不包括外部节点
  • 最小高度考虑让每个节点的关键字个数达到最多,每个节点最多有m个子树,m-1个关键字,即最小高度为n个关键字的满m叉树的高度
  • 对于高度为h,m阶B树关键字的个数n ≤ (m-1)(1 + m + m\(^2\) + m\(^3\) + ... + m\(^{h-1}\)) = m\(^h\) - 1
  1. 含n个关键字的m阶B树,最大高度为\(h_{max}\)\(log_{\lceil m/2\rceil}\frac{n+1}{2} +1\)
  • 最大高度考虑让每个节点的分叉尽可能少。根节点最少2个分叉,其他节点至少\(\lceil m/2 \rceil\)
  • 第一层至少1个节点,第二层至少2个节点,第三层至少2\(\lceil m/2 \rceil\)个节点,第h层至少2\(\lceil m/2 \rceil^{h-2}\)个节点
  • 第h+1层共2\(\lceil m/2 \rceil^{h-1}\)个节点
  • n个关键字的B树必有n+1个失败节点/外部节点,则有n+1 ≥ 2\(\lceil m/2 \rceil^{h-1}\)
  1. 对于高度为h的m阶B树
  • 每个节点至多有m个子树,除根以外的非叶子节点至少有2棵子树
  • 那么整棵B树所包含的节点数(包括失败节点)
    • 最少节点数情况为,高度为h的满m-1叉树
    • 最多节点数情况为,高度为h的满m叉树

7.8.2 插入

  • 考虑只有根节点状态
    • 忽略失败节点,每个节点按照顺序依次插入,直至节点中填满m-1个元素
    • 当插入第m个元素时,m个元素进行排序,并选择中间位置节点mid = \(\lceil m/2 \rceil\),将其中关键词分为两部分
    • mid左半边关键词放在原节点,右半边放入新的节点,mid节点作为两个节点的父节点,且父节点的第一、二指针分别指向左半边、右半边节点
  • 当B树有多个节点时,每次新插入的元素都是插入到最底层的终端节点,通过查找的方式确定插入位置
    • 由于要保证任意节点的子树高度相同,因此在根节点插满后,才会增加整颗B树的高度
    • 假设某孩子节点插入第m个元素,依旧是找到中间位置节点mid,将关键词再分为两部分
      • mid左半边节点留在原节点中,右半边节点放入新的节点
      • mid节点插入到父节点上
        • 由于原节点所有元素都在父节点有大小关系,假设父节点指向该节点的下标为i,则mid节点插入到父节点关键字集合的第i位置,后置节点后移
        • 指针i依旧指向原节点,后续指针全部后移,指针i+1指向新节点
    • 由于每次只右根节点满了才会调整整棵树高度,同时一分为二,左右子树的高度是相同的。同时所有子树插满后继续插入只会向兄弟方向延展,即整颗B树越来越胖,而不是直接变高

查找节点的过程不一定使用顺序查找,由于本身节点内关键字是有序的,可以使用适用于有序序列的查找算法加快效率

7.8.3 删除

  • 考虑删除的元素为非终端节点中的元素,则直接找到该元素的直接前驱或直接后继元素进行替换,从而转换为对终端节点中的元素进行删除
    • 直接前驱:该关键字的左侧指针所指子树中的最右下的元素
    • 直接后继:该关键字的右侧指针所指子树中的最左下的元素
  • 考虑删除的元素为终端节点的元素
    • 若删除元素后,所在的终端节点仍旧满足每个节点中至少有\(\lceil m/2 \rceil\)-1个关键字,则删除后直接结束
    • 若删除元素后,终端节点元素数量小于下限
      • 如果左右相邻兄弟元素够用,即兄弟节点中至少有\(\lceil m/2 \rceil\)个关键字,则将该关键字的与父节点、兄弟节点进行位置互换
        • 左兄弟够用,用当前关键字的前驱、前驱的前驱来填补空缺
        • 有兄弟够用,用当前关键词的后继、后继的后继来填补空缺
      • 如果左右相邻兄弟元素不够用,即兄弟节点中均只有\(\lceil m/2 \rceil\)-1个关键字,则删除其一兄弟节点,并将兄弟节点元素、父节点元素进行合并
        • 如果由于删除影响会向上传递,可能导致根节点中删除了元素后为空,则直接将合并后的节点作为根节点,原根节点直接删除,同时树高度-1

7.9 B+树

一棵m阶B+树,需要满足以下条件:

  • 每个分支节点最多有m棵子树
  • 非叶根节点至少有两棵子树,其他每个分支节点至少有\(\lceil m/2 \rceil\)棵子树
    • 其中B+树中,最底层关键字节点为叶子节点,上面层中的节点为分支节点
    • B+树和B树类似,追求所有子树高度相同的平衡状态
  • 节点的子树个数和关键字个数相等
  • 所有叶节点包含全部关键字以及指向相应记录的指针,叶节点中关键字有序,并且相邻叶节点之间有序,并且用指针相链
    • 因此B+树支持顺序查找,在顺序存储的基础上新增了分块查找的树形索引表
  • 所有分支节点中仅包含他的各个子节点中关键字的最大值,以及指向子节点的指针

B+树查找过程

  • B+树的查找过程类似于分块查找,从根节点出发,节点内有序,一直查到所需节点。在B+树种,无论查找成功与失败,最终都会从树的根找到叶子节点
  • 由于B+树叶子节点是有序相连的,可以进行顺序查找。但由于节点之间是分离的且节点数量不确定,无法进行折半等查找方式

7.9.1 B+树与B树对比

  1. B+树中节点的n个关键字对应n棵子树,而B树节点n个关键字对应n+1棵子树
  2. B+树和B树对节点中的关键字数量均有最小下限且均相同,但B+树每个节点最多可拥有m个关键字
  3. B+树中包含了全部的关键字,非叶节点中出现的关键词在叶子节点中也会出现;而B树所有节点只会出现一次
  4. B树和B+树均可以用于磁盘的文件索引
  • B+树也节点中包含全部记录信息,所有非叶节点仅有索引作用且不含有记录实际存储地址;而B树中每个节点中的关键字均保存了相应的记录信息
  • 因此B+树更加适合作为操作系统磁盘索引表的数据结构,一个磁盘块中可以包含更多的关键字,使B+树阶更大,树更加矮,从而读写磁盘次数更少
  • 分支节点不需要存储记录信息,只用于作为索引所占用的空间小,B+树阶数越大,从而指向磁盘空间越大
  • 关系型数据库MySQL的索引也是通过B+树进行实现

7.10 散列表

散列表,又叫哈希表,Hash Table,可以根据元素的关键字计算它在散列表中的存储地址

每个散列表均有散列函数,也叫哈希函数,散列函数建立了关键字->存储地址的映射关系

因此,理想状态下,在散列表中查找一个元素的时间复杂度为O(1)

其他概念

  • 冲突(碰撞):在散列表中插入一个数据元素,需要根据关键字的值确定存储地址,若该地址已经存储了其他元素,则发生冲突(碰撞)
  • 同义词:若不同关键词通过散列函数映射到同一个存储地址,则称他们为同义词

7.10.1 散列函数

通过构造更合适的散列函数,让各个关键字尽可能映射到不同的存储位置,从而减少冲突

散列函数应满足的条件:

  1. 定义域必须涵盖所有可能出现的关键字
  2. 值域不能超出散列表的地址范围
  3. 尽可能减少冲突,且计算出来的地址尽可能分布在整个地址空间
  4. 散列函数应尽量简单,能够快速计算出任意一个关键字对应的散列地址

除留余数法

H(key) = key % p
散列表长为m,取一个不大于m但最接近或等于m的质数p

质数,又名素数,除了1和该整数本身外,不能被其他自然数不能整除

  • 由于取余运算会被公因子影响,质数的公因子足够少,对质数进行取余可以使散列分布更均匀,从而减小冲突

使用场景:比较通用,只要关键字是整数即可

直接定址法

H(key) = key 或 H(key) = a*key + b
其中a和b是常数,该定址不会产生冲突,但可能造成空间浪费

使用场景:关键字分布基本连续

数字分析法

选取数码分布较为均匀的若干位作为散列地址

  • 设关键字是r进制数,而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等
  • 而在某些位上分布不均匀,只有某几种数码经常出现,此时可选区数码分布较为均匀的若干位作为散列地址

使用场景:关键字集合已知,且关键字的某几个数码分布均匀

例如手机号后几位可作为关键字设计散列函数

平方取中法

取关键字的平方值的中间几位作为散列地址

  • 具体取多少位视情况而定,该方法得到的散列地址与关键字的每一位都有关系,因此散列地址分布比较均匀

使用场景:关键字每位取值都不够均匀

例如信息为985,平方后是
  985*5=4925
 985*8=7880
985*9=8865
      970225
三个结果错位相加,其中结果的低3、4位的"0"、"2"参与了所有位数码的计算。因此尽管原始信息各位的取值不够均匀,也可以将散列后地址分布变得均匀

7.10.2 解决散列冲突

两种主流的解决方案

  • 拉链法:又称链接法、链地址法,把所有同义词存储在一个链表中
  • 开放定址法:如果发生冲突,就给新元素找另一个空闲得位置
    • 一个散列地址,既对同义词开放,又对非同义词开放

除了上述解决方案外,散列表的查找效率还受到装填因子影响

  • 装填因子α:定义一个表的装满程度
    • α = 表中记录数n / 散列表长度m
    • 散列表的平均查找长度依赖于散列表的装填因子α,不直接依赖于n和m
  • 直观上看,α越大,表示装填的记录越满,发生冲突的可能性越大;反之发生冲突的可能性越小

拉链法

散列表长度为m,可以将散列表视为m个链表的头指针顺序结构

  • 插入
    • 插入过程
      • 散列函数计算新元素散列地址
      • 将新元素插入散列地址对应的链表,可用头插或者尾插
    • 优化插入过程
      • 由于插入过程并未要求有序,因此在查找时间复杂度为O(\(L_i\)),\(L_i\)表示散列下标为\(i\)的链表长度
      • 如果插入链表时就保证链表有序,查找效率可以略微提升
  • 查找
    • 查找过程
      • 散列函数计算查找元素散列地址
      • 依次比较整个散列中的列表元素,若链表仅有表头表示查找失败;若找到直接返回,否则查找失败
    • 查找长度:在查找运算中需要对比关键字的次数。一般默认空指针对比不计入查找长度
    • 因此查找成功的查找长度至少为1,而查找失败的查找长度可能为0
  • 删除
    • 删除过程
      • 散列函数计算删除元素散列地址
      • 查找删除元素,若找不到直接结束,反之链表删除该节点

开放定址法

确定一个探测的顺序,从初始散列地址出发,依次寻找下一个空闲位置
\(H_i = (H(key)+d_i)\ \%\ m\)

其中\(d_i\)表示第i次发生冲突时,下一个探测地址与初始散列地址的相对偏移量

构造探测序列\(d_i\),其中0 ≤ \(d_i\) ≤ m-1,偏移量一定从0开始

  • 线性探测法
    • 又叫探测序列、增量序列,\(d_i=0,1,2,3,...,m-1\)
  • 平方探测法
    • \(d_i=0^2,1^2,-1^2,2^2,...,k^2,-k^2\),其中k ≤ m/2
  • 双散列法
    • \(d_i=i * hash_2(key)\),其中\(hash_2(key)\)为另一个散列函数
  • 伪随机数序列法
    • \(d_i\)是一个伪随机序列,\(d_i=0,5,3,11,-1,7\)

开放定址法查找、新增

  • 插入的探测过程类似于其他数据结构的查找操作,若探测到空单元则填入;若探测到非空单元则进行下一次探测
  • 而开放定址法的查找过程和探测过程正好相反,持续探测直至找到目标关键词则查找成功,若探测到空单元说明查找失败

开放地址法删除

  • 删除操作的一般过程是先查找元素,然后删除对应位置,但是由于冲突避免的原因,会导致同义词探测序列\(d_i\)中某个位置的值为空,查找过程会判定为查找失败
  • 因此散列表中的元素删除不能直接删除,否则会截断之后探测路径上的元素,可通过删除标记进行逻辑删除
    • 但是由于做了逻辑删除,每次探测序列会被拉的很长,降低查找、插入效率
    • 可以不定期整理散列表内的数据,如同义词往探测序列之前方向移动、新元素直接插在逻辑删除的元素位置

开放地址法的探测覆盖率

  • 线性探测法:理想状态下,若散列表长为m,则最多发生m-1次冲突即可探测完整的散列表,即只要散列表有非满,一定可以成功插入
  • 平方探测法:至少可以探测到散列表中一般的位置,即尽管散列表有非满,也不一定能成功插入
    • 若散列表长为m,m可以表示为4j+3的一个素数,j为自然数,则拼房探测法可以探测到所有位置
  • 双散列法:与第二个散列函数有关,如果\(hash_2(key)\)计算得到的值和散列表长m互质,就能保证双散列可以探测所有单元
  • 伪随机数序列法:完全取决于伪随机序列设计是否合理

7.11 可视化演示

https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

posted @ 2024-07-07 13:57  GK_Jerry  阅读(74)  评论(0)    收藏  举报