(C++)数据结构课程笔记8/9 - 查找

143 阅读24分钟

§8 - 查找

1 - 概述

相关概念

  1. 查找表是由同一类型的记录构成的集合(松散)
  2. 关键字是用来标识一个记录的某个数据项的值
  3. 若此关键字可唯一地标识一个记录,则称此关键字为主关键字
  4. 查找是指根据给定值,在查找表中确定一个其关键字等于给定值的记录

相关操作

  1. 查询某个特定记录是否在查找表中
  2. 检索某个特定记录的各种属性
  3. 向查找表中插入一个数据元素
  4. 从查找表中删除某个数据元素

根据上述操作,查找表可分为:

  • 静态查找表:仅作查询和检索操作
  • 动态查找表:表结构在查找过程中动态生成(若查询结果为在查找表中,则成功返回,否则插入该记录)

由于查找表中的记录之间是较松散的集合关系,因此不便于查找。所以,为了提高查找效率,需要在查找表中的数据元素之间人为地附加某种确定的关系。

2 - 静态查找表

抽象数据类型

ADT StaticSearchTable {
	数据对象:
		D是由同一类型的数据元素构成的集合,每个数据元素含有类型相同的关键字,可唯一标识该数据元素。
	数据关系:
		数据元素同属一个集合。
	基本操作:
		Create(&ST,n)        // 构造静态查找表ST(含有n个数据元素)
		Destroy(&ST)         // 销毁静态查找表ST
		Search(ST,key)       // 在静态查找表ST中查询是否存在其关键字等于key的数据元素
		Traverse(ST,visit()) // 遍历静态查找表ST
} ADT StaticSearchTable

顺序存储表示

typedef struct {
    keyType key;
    // 其它数据项
} ElemType;

typedef struct {
    ElemType* elem; // 从1开始
    int length;
} SSTable;

(1) 顺序查找

给定值与数据元素的关键字逐一比较

// 从前往后查
int Search(SSTable ST,keyType key) {
    int i;
    for (i=1;i<=ST.length;i++)
        if (compare(ST.elem[i].key,key))
            break;
    if (i==ST.length+1)
        return 0;
    return i;
}

// 从后往前查
int Search(SSTable ST,keyType key) {
    ST.elem[0].key=key; // 监视哨
    int i;
	for (i=ST.length;!compare(ST.elem[i].key,key);i--);
    return i;
}
// ST.elem[0]起到了监视哨的作用,避免在查找过程中每一步都要判断整个表是否查找完毕
// 这样改进使得在查找表的长度大于1000时,查找所需的平均时间减少一半
时间性能分析

查找不成功时的比较次数:n(若表中数据元素有序,则查找不成功时不用与整张表比较)

查找成功时的比较次数:取决于查找结果在表中的位置,利用平均查找长度(Average Search Length)分析:

ASL=i=1nPiCi=1ni=1n(ni+1)=n+12ASL=\sum_{i=1}^nP_iC_i=\frac1n\sum_{i=1}^n(n-i+1)=\frac{n+1}2PiP_i 为结果是第 i 个数据元素的概率,CiC_i 为查到第 i 个数据元素时需要比较的次数,在等概率查找的情况下 Pi=1nP_i=\frac1n

优缺点
  • 优点:算法简单,适用面广,不要求有序,如果要插入可插在表尾
  • 缺点:平均查找长度较大,表长很大时效率较低

(2) 二分查找

在表中数据元素按关键字有序的前提下,给定值不必与数据元素逐一比较,而采用二分查找(折半查找)

算法描述:设待查元素所在区域的下界为 low,上界为 high,则中间位置 mid = (low + high) / 2,先与中间位置数据元素的关键字比较

  • 若中间位置数据元素的关键字等于给定值,则查找成功
  • 若中间位置数据元素的关键字大于给定值,则在区域 [low, mid-1] 内进行二分查找
  • 若中间位置数据元素的关键字小于给定值,则在区域 [mid+1, high] 内进行二分查找
int Search(SSTable ST,keyType key) {
    int low=1,high=ST.length;
    while (low<=high) {
        int mid=(low+high)/2;
        if (ST.elem[mid].key==key)
            return mid;
        else if (ST.elem[mid].key>key)
            high=mid-1;
        else
            low=mid+1;
    }
    return 0;
}
时间性能分析

举例:

ii1234567891011
CiC_i34234134234

上述查找过程可用二叉树(判定树)来描述:

查找不成功时的比较次数:log2n⌊log_2n⌋log2n+1⌊log_2n⌋+1(判定树的深度)

查找成功时的比较次数:取决于从根结点到查找成功处结点的路径上结点的个数,不超过 log2n+1⌊log_2n⌋+1 (判定树的深度),具体分析如下:

假设 n=2h1n=2^h-1,则判定树为深度为 h 的满二叉树,层次为 k 的结点有 2k12^k-1 个,则在等概率查找 Pi=1nP_i=\frac1n 的情况下:ASL=1ni=1nCi=1n[k=1hk2k1]=n+1nlog2(n+1)1ASL=\frac1n\sum_{i=1}^nC_i=\frac1n[\sum_{k=1}^hk*2^{k-1}]=\frac{n+1}nlog_2(n+1)-1,当 n 较大时,ASL=log2(n+1)1ASL=log_2(n+1)-1

优缺点
  • 优点:效率高
  • 缺点:需要预先排序,只适合有序的顺序表,不能是链表

(3) 分块查找

n 个数据元素被分成 m 块,第一块中任一元素的关键字都小于第二块中任一元素的关键字,第二块中任一元素的关键字都小于第三块中任一元素的关键字,以此类推,而每一块中元素的关键字不一定是有序的,此时采用分块查找

算法描述:“缩小区间”的思想

  1. 抽出各块所含数据元素的最大关键字构成一个索引表
  2. 查找分两步进行:
    • 对索引表进行二分查找或顺序查找,确定待查记录在哪一块
    • 对已确定的那一块进行顺序查找

索引表按关键字有序,包含:

  1. 该块所含数据元素的最大关键字
  2. 指示该块第一个数据元素在静态查找表中位置的指针
时间性能分析

ASL = 查找索引表的平均查找长度 + 查找一个记录块的平均查找长度

关注最优性能:

假设长度为 n 的表平均分成 b 块,每块含有 s 个记录,即 b = ⌈n/s⌉,每条记录的查找概率相等,则每块的查找概率为 1/b,块中每个记录的查找概率为 1/s,采用顺序查找索引表和顺序查找被确定的块的方法,平均查找长度为:ASL=b+12+s+12=ns+s2+1ASL=\frac{b+1}2+\frac{s+1}2=\frac{\frac{n}s+s}2+1,当 s 取 n\sqrt{n} 时,ASL 取最小值 n+1\sqrt{n}+1

小结

三种查找方法比较
顺序查找二分查找分块查找
等概率下ASL最大最小适中
记录是否有序有序无序均可仅适用于有序记录逐段有序
表的存储结构顺序链式均可仅适用于顺序顺序链式均可
四种查找表增删查操作时间性能比较
查找插入删除
无序顺序表O(n)O(1)(无需查找)O(n)
无序链表O(n)O(1)(无需查找)O(1)
有序顺序表O(logn)O(n)(需查找)O(n)
有序链表O(n)O(1)(需查找)O(1)

从时间性能上看:频繁查找,使用有序顺序表;频繁增删,使用无序链表

3 - 动态查找表

抽象数据类型

ADT DynamicSearchTable {
	数据对象:
		D是由同一类型的数据元素构成的集合,每个数据元素含有类型相同的关键字,可唯一标识该数据元素。
	数据关系:
		数据元素同属一个集合。
	基本操作:
		Create(&DT)          // 构造空的动态查找表DT
		Destroy(&DT)         // 销毁动态查找表DT
		Search(DT,key)       // 在动态查找表DT中查询是否存在其关键字等于key的数据元素
		Insert(&DT,e)        // 若DT中不存在其关键字等于e.key的数据元素,则插入e到DT
		Delete(&DT,key)      // 若DT中存在其关键字等于key的数据元素,则删除之
		Traverse(DT,visit()) // 遍历动态查找表DT
} ADT DynamicSearchTable

(1) 二叉搜索树(BST)

定义
  1. 二叉搜索树或是一棵空树,或是具有如下特性的二叉树:
    • 若左子树不空,则左子树上所有结点的值均小于根结点的值
    • 若右子树不空,则右子树上所有结点的值均大于根结点的值
    • 它的左、右子树也都分别是二叉搜索树(递归定义)
  2. 中根序遍历二叉搜索树,可以得到递增序列,所以,一个无序序列可以通过构造一棵二叉搜索树变成一个有序序列,构造过程即为排序过程,故二叉搜索树又称二叉排序树
  3. 相应地,可以构造递减序列的二叉搜索树
存储结构

二叉链表

typedef struct BiTNode {
    T data;
    struct BiTNode *lchild,*rchild;
} BiTNode,*BiTree;
查找算法

若二叉搜索树为空,则查找失败;否则

  • 若给定值等于根结点的关键字,则查找成功
  • 若给定值小于根结点的关键字,则继续在左子树上进行查找
  • 若给定值大于根结点的关键字,则继续在右子树上进行查找
/*
 * 在根结点指针T所指二叉搜索树中递归地查找其关键字等于key的数据元素。若查找成功,则返
 * 回指向该数据元素结点的指针p,并返回函数值true;若查找失败,则返回指向查找路径上访
 * 问的最后一个结点的指针p,并返回函数值false。指针f指向当前访问结点的双亲。
 *
 * 三个参数:T(初始值指向根结点),key,f(初始值为NULL)
 * 两个返回值:状态值,p
 */
bool Search(BiTree T,keyType key,BiTree f,BiTree &p) {
    // 查找失败
    if (!T) {
        p=f;
        return false;
    }
	// 查找成功
	else if (key==T->data.key) {
        p=T;
        return true;
    }
    // 继续在左子树上进行查找
    else if (key<T->data.key) {
        if (Search(T->lchild,key,T,p))
    		return true;
    	else
    		return false;
    }
    // 继续在右子树上进行查找
    else {
        if (Search(T->rchild,key,T,p))
    		return true;
    	else
    		return false;
    }
}
插入算法

插入操作在查找不成功时才进行,新插入的结点必为一个新的叶子结点(若二叉搜索树为空树,则新插入的结点为根结点),显然插入操作后的二叉树仍然保持二叉搜索树的特性

/*
 * 若二叉搜索树T中不存在其关键字等于e.key的数据元素,则插入数据域为e的结点,并返回
 * true,否则返回false。
 */
bool Insert(BiTree &T,T e) {
    BiTree p;
    if (!Search(T,e.key,NULL,p)) {
        BiTree s=(BiTree)malloc(sizeof(BiTNode));
        s->data=e;
        s->lchild=s->rchild=NULL;
        if (!p)
            T=s; // 根结点
		else if (e.key<p->data.key)
            p->lchild=s; // p->lchild必定为空,此时插入*s
        else
            p->rchild=s; // p->rchild必定为空,此时插入*s
        return true;
    }
    else
        return false;
}
删除算法

和插入相反,删除操作在查找成功时进行,并且要求删除操作后的二叉树仍然保持二叉搜索树的特性,被删除的结点有三种情况:

  • 为叶子结点,则将其双亲结点中相应指针域改为空
  • 为只有左子树或只有右子树的结点,则将其双亲结点中相应指针域改为“指向被删除结点的左子树或右子树”
  • 为既有左子树又有右子树的结点,则先将其数据域改为其中序前驱结点数据域,再删除其中序前驱结点
/*
 * 若二叉搜索树T中存在其关键字等于key的数据元素,则删除该数据元素结点,并返回true,
 * 否则返回false。
 */
bool Search(BiTree T,int key,BiTree &f,BiTree &p) { // f需要引用传递,要为Delete修改原先的Search
    if (!T) {
        p=f;
        return false;
    }
	else if (key==T->data) {
        p=T;
        return true;
    }
    else if (key<T->data) {
    	f=T; // 更新f
    	if (Search(T->lchild,key,f,p))
    		return true;
    	else
    		return false;
	}
    else {
    	f=T; // 更新f
    	if (Search(T->rchild,key,f,p))
    		return true;
    	else
    		return false;
	}
}

bool Delete(BiTree &T,keyType key) {
    BiTree f=NULL,p;
    if (Search(T,key,f,p)) {
        if (!p->lchild&&!p->rchild) {
            (f->lchild==p?f->lchild:f->rchild)=NULL;
            free(p);
        }
        else if (p->lchild&&!p->rchild) {
            (f->lchild==p?f->lchild:f->rchild)=p->lchild;
            free(p);
        }
        else if (!p->lchild&&p->rchild) {
            (f->lchild==p?f->lchild:f->rchild)=p->rchild;
            free(p);
        }
        else {
            BiTree q=p,s=p->lchild;
            while (s->rchild) { // 先转左,再一路向右
                q=s;
                s=s->rchild;
            }
            p->data=s->data;
            if (q!=p)
                q->rchild=s->lchild;
            else
                q->lchild=s->lchild; // q==p表明p的左孩子s没有右孩子,s就是p的中序前驱
            free(s);
        }
        return true;
    }
    else
        return false;
}
时间性能分析

由相同的 n 个数据元素按照不同顺序构造所得的二叉搜索树不同,其平均查找长度也不同,例如:左图 ASL = (1+2+3+4+5)/5 = 3,右图 ASL = (1+2+2+3+3)/5 = 2.2

结论:ASL=2n+1nlogn+CASL=2\frac{n+1}nlogn+C

在进行插入和删除操作时,无需移动其它结点,仅需修改某个结点的指针,因此,对于需要经常插入和删除记录的有序表,采用二叉搜索树的数据结构更为合适

(2) 平衡二叉树(AVL 树)

定义
  1. 平衡二叉树或是一棵空树,或是具有如下特性的二叉搜索树
    • 左子树和右子树的高度之差(平衡因子)的绝对值不超过 1
    • 它的左、右子树也都分别是平衡二叉树(递归定义)
  2. 平衡二叉树的作用是:避免二叉搜索树退化为链表,提高其查找时间性能
插入算法

先插入,再对最小不平衡子树进行调整

最小不平衡子树有四种类型,对应四种操作:

  • 类型 1:(LL 型)新结点插入在根结点左子树的左子树上,导致根结点的平衡因子由 1 变为 2

    操作 1:对根结点左子树的根结点进行“右旋”

  • 类型 2:(RR 型)新结点插入在根结点右子树的右子树上,导致根结点的平衡因子由 -1 变为 -2

    操作 2:对根结点右子树的根结点进行“左旋”

  • 类型 3:(LR 型)新结点插入在根结点左子树的右子树上,导致根结点的平衡因子由 1 变为 2

    操作 3:先对根结点左子树的右子树的根结点进行“左旋”,再(同操作 1)对根结点左子树的根结点进行“右旋”

  • 类型 4:(RL 型)新结点插入在根结点右子树的左子树上,导致根结点的平衡因子由 -1 变为 -2

    操作 4:先对根结点右子树的左子树的根结点进行“右旋”,再(同操作 2)对根结点右子树的根结点进行“左旋”

// source: https://github.com/imxtx/algorithms

#include <stdio.h>
#include <stdlib.h>

// 树结点定义
typedef struct Node
{
    int key;
    struct Node *left;
    struct Node *right;
    int height;
} Node;

// 辅助函数:比大小
int max(int a, int b)
{
    return a > b ? a : b;
}

// 辅助函数:计算树的高度
int height(Node *root)
{
    if (root == NULL)
        return 0;
    return 1 + max(height(root->left), height(root->right));
}

// 辅助函数:创建新结点
Node *newNode(int key)
{
    Node *node = (Node *)malloc(sizeof(Node));
    node->key = key;
    node->left = NULL;
    node->right = NULL;
    node->height = 0; // 这里node->height可以定义为任意值
    return node;
}

// 辅助函数:获得结点的平衡因子
int getBalanceFactor(Node *node)
{
    if (node == NULL)
        return 0;
    return height(node->left) - height(node->right);
}

// 右旋
Node *rightRotate(Node *y)
{
    /* 树结构示意图:
                y
               / \
              x   O
             / \
            O   O
    */
    Node *x = y->left;
    Node *xr = x->right;
    // 旋转
    x->right = y;
    y->left = xr;
    // 更新结点的高度
    x->height = height(x);
    y->height = height(y);
    // 返回旋转后的根结点
    return x;
}

// 左旋
Node *leftRotate(Node *y)
{
    /* 树结构示意图:
                y
               / \
              O   x
                 / \
                O   O
    */
    Node *x = y->right;
    Node *xl = x->left;
    // 旋转
    x->left = y;
    y->right = xl;
    // 更新结点的高度
    x->height = height(x);
    y->height = height(y);
    // 返回旋转后的根结点
    return x;
}

/*
 * @brief 向以node为根结点的树中插入key
 *
 * @param node 根结点
 * @param key 插入值
 * @return Node* 插入后该树的新的根结点
 */
Node *insert(Node *node, int key)
{
    // 1. 按照BST的方法在叶结点上插入新值
    if (node == NULL)
        return newNode(key);
    if (key < node->key)
        node->left = insert(node->left, key);
    else if (key > node->key)
        node->right = insert(node->right, key);
    else
        return node;

    // 2. 更新插入路径上每棵子树的高度
    node->height = height(node);

    // 3. 计算平衡因子,不平衡则需要调整
    int bf = getBalanceFactor(node);

    // LL型不平衡
    if (bf > 1 && key < node->left->key)
        return rightRotate(node);
    // RR型不平衡
    if (bf < -1 && key > node->right->key)
        return leftRotate(node);
    // LR型不平衡
    if (bf > 1 && key > node->left->key)
    {
        node->left = leftRotate(node->left);
        return rightRotate(node);
    }
    // RL型不平衡
    if (bf < -1 && key < node->right->key)
    {
        node->right = rightRotate(node->right);
        return leftRotate(node);
    }

    // 如果是平衡的直接返回根结点
    return node;
}

// 辅助函数:输出树的先序遍历
void preOrder(Node *root)
{
    if (root != NULL)
    {
        printf("%d ", root->key);
        preOrder(root->left);
        preOrder(root->right);
    }
}

int main(int argc, char const *argv[])
{
    Node *root = NULL;

    /* 测试,最终树结构应该如下图所示:
            30
           /  \
         20   40
        /  \     \
       10  25    50
    */
    root = insert(root, 10);
    root = insert(root, 20);
    root = insert(root, 30);
    root = insert(root, 40);
    root = insert(root, 50);
    root = insert(root, 25);

    printf("Preorder traversal of the constructed AVL tree is \n");
    preOrder(root);
    putchar('\n');

    return 0;
}
时间性能分析

平衡二叉树优化了二叉搜索树的查找时间性能:与二叉搜索树相同,在平衡二叉树上进行查找时,和给定值进行比较的关键字的个数不超过平衡二叉树的深度;经过优化,含有 n 个结点的平衡二叉树能达到的最大深度约为 log n,因此最坏情况下查找长度也仅仅约为 log n

平衡二叉树虽然查找时间性能能够始终保持 O(log n),但是为了保持平衡,在插入和删除时导致太多调整树结构的操作

(3) B 树

B Trees and B+ Trees. How they are useful in Databases - YouTube

定义
  1. 一棵 m 阶 B 树是具有如下特性的 m 叉树:

    • 每个结点至多有 m 棵子树(至多有 m-1 个关键字)
    • 根结点至少有 2 棵子树(至少有 1 个关键字)或根结点为叶子结点(此时为空树)
    • 其他结点至少有 ⌈m/2⌉ 棵子树(至少有 ⌈m/2⌉-1 个关键字)或其为叶子结点
    • 所有的非叶子结点包含以下信息:(n, A0, K1, A1, K2, A2, ..., Kn, An),其中:
      • n 为关键字个数:⌈m/2⌉-1 ≤ n ≤ m-1(根结点:1 ≤ n ≤ m-1)
      • Ki (i = 1, 2, ..., n) 为关键字:K1 < K2 < ... < Kn
      • Ai (i = 0, 1, ..., n) 为指向子树根结点的指针:指针 Ai-1 所指子树中所有结点的关键字均小于 Ki (i = 1, 2, ..., n),指针 An 所指子树中所有结点的关键字均大于 Kn
      • 可能还有指向记录的指针 Di (i = 1, 2, ..., n)
    • 所有的叶子结点出现在同一层次上,并且不带信息(可以看作是查找失败的结点)

    例如:下图为一棵 4 阶 B 树,深度为 3(不包括失败结点),根结点 1 ≤ n ≤ 3,其他结点 1 ≤ n ≤ 3

  2. B 树是一种平衡的多路查找树

存储结构
#define M 10 // B树的阶

typedef struct BTNode {
    int keynum;              // 关键字个数
	keyType key[M+1];        // 关键字向量(0号单元不用,开多一个单元插入用)
    struct BTNode* ptr[M+1]; // 指向子树根结点的指针向量
    T* recptr[M+1];          // 指向记录的指针向量(0号单元不用)
    struct BTNode* parent;   // 指向双亲结点的指针(插入用)
} BTNode,*BTree;
查找算法

两个操作交叉进行:

  • 从根结点出发,沿指针查找结点
  • 在结点内顺序(或折半)查找关键字
/*
 * 在m阶B树T中查找关键字key,返回查找结果(tag,p,i)。若查找成功,则特征值tag为1,指
 * 针p所指结点中第i个关键字等于key;否则特征值tag为0,关键字key应插入在指针p所指结
 * 点中第i个关键字和第i+1个关键字之间。
 */
typedef struct Result {
    int tag;
    BTree p;
	int i;
    
    Result(int tmp_tag,BTree tmp_p,int tmp_i):
    	tag(tmp_tag),p(tmp_p),i(tmp_i) {}
    Result(const Result &result):
    	tag(result.tag),p(result.p),i(result.i) {}
} Result;

int Search(BTree p,keyType key) {
    int low=1,high=p->keynum,mid;
    while (low<=high) {
        mid=(low+high)/2;
        if (p->key[mid]==key)
            return mid;
        else if (p->key[mid]>key)
            high=mid-1;
        else
            low=mid+1;
    }
    return (p->key[mid]<key?mid:mid-1);
}

Result SearchBTree(BTree T,keyType key) {
    BTree p=T,q=NULL;
    int found=0,i=0;
    while (p&&!found) {
        i=Search(p,key);
        if (p->key[i]==key)
            found=1;
        else {
            q=p;
            p=p->ptr[i];
        }
    }
    if (found)
        return Result(1,p,i);
    else
        return Result(0,q,i);
}
插入算法

插入操作在查找不成功时才进行,关键字插入的位置必定在最下层的非叶子结点(若 B 树为空树,则用新插入的关键字创建根结点),插入后有两种情况:

  • 若该结点的关键字个数 n<m,则不修改指针
  • 若该结点的关键字个数 n=m,则进行“分裂”:(令 s = ⌈m/2⌉)
    1. 原结点保留有:(A0, K1, ..., Ks-1, As-1),建新结点:(As, Ks+1, ..., Kn, An)
    2. 将 Ks 递归地插入到双亲结点中,若双亲为空,则创建新的根结点
/*
 * 若m阶B树T中不存在关键字key,则插入key,并返回true,否则返回false。
 */
BTree Insert(BTree &p,int i,keyType key,T* record,BTree child,BTree new_child,BTree &T) {
    if (!p) { // 创建新的根结点
        p=(BTree)malloc(sizeof(BTNode));
        p->keynum=1;
        p->key[1]=key;
        p->ptr[0]=child,p->ptr[1]=new_child;
        p->recptr[1]=record;
        p->parent=NULL;
        T=p; // 标识新的根结点
        return NULL;
    }
    for (int j=p->keynum;j>=i+1;--j) {
        p->key[j+1]=p->key[j];
        p->ptr[j+1]=p->ptr[j];
        p->recptr[j+1]=p->recptr[j+1];
    }
    ++p->keynum;
    p->key[i+1]=key;
    p->ptr[i+1]=new_child;
    p->recptr[i+1]=record;
    if (p->keynum==M) { // 分裂
        int s=M%2==0?M/2:M/2+1;
        BTree q=(BTree)malloc(sizeof(BTNode));
        p->keynum=s-1;
        q->keynum=M-1-p->keynum;
        q->ptr[0]=p->ptr[s];
        for (int j=1;j<=q->keynum;++j) {
            q->key[j]=p->key[s+j];
        	q->ptr[j]=p->ptr[s+j];
        	q->recptr[j]=p->recptr[s+j];
        }
        if (p->parent)
        	i=Search(p->parent,p->key[s]);
        BTree t=Insert(p->parent,i,p->key[s],p->recptr[s],p,q,T); // 函数Insert第一个参数p为引用,目的是给p->parent赋值
        q->parent=t?t:p->parent;
        return q;
    }
    return NULL;
}

bool InsertBTree(BTree &T,keyType key,T* record) {
    if (!T) { // 创建根结点
        T=(BTree)malloc(sizeof(BTNode));
        T->keynum=1;
        T->key[1]=key;
        T->ptr[0]=NULL,T->ptr[1]=NULL;
        T->recptr[1]=record;
        T->parent=NULL;
        return true;
    }
    Result res=SearchBTree(T,key);
    if (res.tag)
        return false;
    Insert(res.p,res.i,key,record,NULL,NULL,T);
    return true;
}
删除算法

和插入相反,删除操作在查找成功时进行,被删除的关键字可以在最下层的非叶子结点或其上的结点:

  • 若被删除的关键字在最下层的非叶子结点之上,则该关键字的位置由其中序后继(先向右,再一路向左)填充,相当于被删除的关键字转换为在最下层的非叶子结点的该中序后继

  • 删除后:

时间性能分析

B 树常用于对外存数据或数据库组织索引,对数据进行查找时,查找时间花费在**搜索结点(访问外存)在结点内找关键字(访问内存)**上,而访问外存比访问内存慢非常多,所以查找时间主要取决于访问外存的次数即在磁盘上搜索结点的次数,故查找时间主要取决于查找树的深度

推导含有 N 个关键字的 m 阶 B 树可能达到的最大深度 H(不包括失败结点):

​ 先推导深度为 H(不包括失败结点)的 m 阶 B 树最少含有的结点数

​ 推导每一层最少含有的结点数:

​ 第 1 层:1 个

​ 第 2 层:2 个

​ 第 3 层:2m/22*⌈m/2⌉

​ 第 4 层:2m/222*⌈m/2⌉^2

​ 第 H+1 层:2m/2H12*⌈m/2⌉^{H-1}

​ 第 H+1 层为叶子结点,而由 B 树中含有 N 个关键字可知叶子结点必为 N+1 个,故:

N+12m/2H1N+1≥2*⌈m/2⌉^{H-1}

H1logm/2N+12H-1≤log_{⌈m/2⌉}\frac{N+1}2

Hlogm/2N+12+1H≤log_{⌈m/2⌉}\frac{N+1}2+1

所以,在含有 N 个关键字的 m 阶 B 树上进行一次查找,需访问结点个数(访问外存次数)小于等于 Hlogm/2N+12+1H≤log_{⌈m/2⌉}\frac{N+1}2+1,在对外存数据或数据库组织索引方面,B 树的时间性能远高于二叉搜索树和平衡二叉树

(4) B+ 树

B+ 树结合了分块查找和 B 树,常用于对数据库组织索引,具有如下特性:

  1. 所有的叶子结点出现在同一层次上,结点之间指针连接,所有叶子结点包含全部关键字和相应记录指针,结点内关键字增序排列,结点也增序排列,使得从第一个结点的第一个关键字到最后一个结点的最后一个关键字总体递增
  2. 所有的非叶子节点是所有叶子结点构成的递增序列的多层索引,其中关键字 Ki 为其相应指针 Ai 所指子树中关键字的最大值
  3. 设置两个头指针:一个指向根结点,一个指向含有最小关键字的叶子结点,对应地支持分块查找和顺序查找两种查找方式
  4. 每个叶子结点中关键字个数均介于 ⌈m/2⌉ 和 m 之间;每个非叶子结点至多含有 m 个关键字和 m 棵子树;除根结点外,每个非叶子结点至少含有 ⌈m/2⌉ 个关键字和 ⌈m/2⌉ 棵子树(根结点可以没有子树,若有子树,那么至少有 2 棵子树和 2 个关键字)

例如:下图为一棵 4 阶 B+ 树

4 - 哈希表(散列表)

定义

背景

以上介绍的查找表的共同点:查找过程为给定值依次与记录集合中各数据元素的关键字进行比较;查找性能取决于比较次数

对于需要频繁使用的查找表,希望 ASL = 0,办法是:预先知道所查记录在表中的位置,避免比较

“哈希函数”“哈希表”

关键字与记录在表中的存储位置之间建立函数关系,即 h(key) 为关键字为 key 的记录在表中的位置,h(key) 称为哈希函数,该表称为哈希表

“冲突”

有效的哈希函数应该是单射,但一般情况下,容易产生“冲突”现象,即 key1 ≠ key2,而 h(key1) = h(key2)

很难找到一个不产生冲突的哈希函数,所以在构造哈希表时,既要构造使冲突尽可能少产生的哈希函数,又要给出处理冲突的算法

构造哈希函数的方法

对于非数字的关键字,需先将其数字化,可以用 ASCII 码等;对于数字的关键字,构造哈希函数的方法有:

(1) 直接定址法

哈希函数为关键字的线性函数

(2) 数字分析法

若每个关键字数字位数相同,则可分析关键字集合中的全体,并取各关键字值中分布近似随机的若干位组成哈希地址

举例:有 1000 个记录,关键字为 10 位十进制整数 x1x2...x10,哈希表长度为 2000;假设经过分析,各关键字值中 x3, x5, x7 的取值分布近似随机,则可构造哈希函数:h(key) = h(x1x2...x10) = x3x5x7,例如,h(3778597189) = 757,h(9166372560) = 632

适用场合:能够预先估计各关键字值中的每一位上各个数字出现的频度,某些位上各个数字频度均匀

(3) 平方取中法

数字分析法的拓展,取关键字平方值的中间几位组成哈希地址(目的是扩大差别)

适用场合:能够预先估计各关键字值中的每一位上各个数字出现的频度,每一位上都有数字频度很高,或无法预先估计

(4) 折叠法

数字分析法的拓展,将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)组成哈希地址(目的是扩大差别),叠加的方法有移位叠加和边界叠加

举例:若哈希表长度为 1000,关键字 key = 110108331119891,则将关键字分割成位数都为 3 的几部分,如图进行叠加:

适用场合:关键字位数较多,且预先估计出各关键字值中的每一位上都有数字频度很高或无法预先估计

(5) 除留余数法

h(key) = key mod p, p ≤ m(m 为表长),不仅可以对关键字直接取模,也可以在平方取中、折叠等处理后取模;理论研究表明,为了减少冲突,p 取最接近 m 的素数或取 1.1n ~ 1.7n(n 为记录数) 之间的素数最好

m81632641282565121000
p7133161127251503997
(6) 伪随机数法

h(key) = Random(key),Random 为伪随机函数

处理冲突的方法

处理冲突的含义是:h(key) 是关键字为 key 的记录本应该在表中的位置,但是该位置已经被关键字为 key1 的记录占据,此时需要更新 h(key);方法有:

(1) 开放定址法

p = (h(key) + di) mod m,m 为表长,di 为约定的增量序列的值,i 从 1 开始直至找到不发生冲突的位置(此时,更新后的 h(key) = p)

要求哈希表需要有能力包含所有关键字对应的记录,即表长至少等于关键字个数;p 需要有能力覆盖哈希表中除了关键字为 key 的记录本应该在的位置之外的 m-1 个位置;di 的取法有:

  1. 线性探测再散列

    di = c * i(需要自行检验覆盖能力)

    最简单的情况:c = 1, di = 1, 2, ..., m-1

    优点:相比以下两种取法,限制较少且容易操作

    缺点:容易发生“堆积”现象,即哈希地址本不相同的关键字发生了冲突

  2. 平方探测再散列

    di = 121^2, 12-1^2, 222^2, 22-2^2, ...(为保证有能力覆盖,应满足表长 m 为形如 4j + 3 的素数,如 7, 11, 19, 23, ...)

  3. 随机探测再散列

    di 是一组伪随机序列或 di = i * h2(key)(为保证有能力覆盖,应满足表长 m 和增量 di 没有公因子)

哈希表中数据不经常变化时,常用开放定址法;而表中数据经常变化时,可用链地址法

(2) 链地址法

将所有哈希地址相同的记录都链接在同一链表中,同一链表中按关键字有序

若使用除留余数法构造哈希函数,则表长只需要等于 mod 值;例如,关键字为 19, 1, 23, 14, 55, 68, 11, 82, 36,哈希函数为 h(key) = key mod 7,则哈希表如下图:

(3) 再哈希法

构造多个不同的哈希函数,产生冲突时用另一个哈希函数得到哈希地址,直到冲突不再产生;这种方法不易发生“堆积”现象,但增加时间开销

(4) 建立公共溢出区法

设置 HashTable(基本表)和 OverTable(溢出表)两个向量,如果发生冲突,不论哈希地址是什么,都填入溢出表

查找算法及其时间性能分析

查找过程和造表过程一致,算法描述如下:(以使用开放定址法处理冲突构造的哈希表为例,使用其他方法处理冲突构造的哈希表的查找同理)

对于给定值 k,得到哈希地址 p = h(k)

  • 若 HashTable[p] = NULL,则查找不成功
  • 若 HashTable[p].key = k,则查找成功
  • 否则根据约定求下一地址,直至 HashTable[p] = NULL(查找不成功)或 HashTable[p].key = k(查找成功)
#define KEYINIT 0x3f3f3f3f

int sizelist[]={997}; // 哈希表容量表(一个合适的递增素数序列)

typedef struct {
    T* elem; // 记录存储基址
    int count; // 当前记录数
    int sizeindex; // sizelist[sizeindex]为当前容量
} HashTable;

// 查找成功,返回true;查找失败,返回false
bool Search(HashTable H,int k,int &p) {
    int i=0;
    p=h(k);
    while (H.elem[p].key!=KEYINIT&&H.elem[p].key!=k) {
        ++i;
        p=(h(k)+d[i])%sizelist[H.sizeindex];
    }
    if (H.elem[p].key==k)
        return true;
    else
        return false;
}

// 插入成功,返回true;因哈希表中已有相同记录而插入失败,返回false
bool Insert(HashTable &H,T e) {
    int p;
    if (Search(H,e.key,p))
        return false;
    H.elem[p]=e;
    ++H.count;
    return true;
}

由查找过程可知,实际上哈希表 ASL ≠ 0,决定比较次数的因素有:

  • 哈希函数
  • 处理冲突的方法
  • 哈希表的饱和程度 α = n/m (n 为记录数,m 为表长)

理论研究表明:当 α < 0.5 即哈希表将近半满时,大部分情况下 ASL < 2;所以,用哈希表构造查找表时,可以规定一个阈值 α(通常为 0.5),使得平均查找长度限定在某个范围内

应用

  • 操作系统中的可执行程序名
  • 编译系统中的符号表
  • 数据库中的索引
  • 搜索引擎中的关键字词典
  • 域名解析(域名与 IP 地址对应)
  • 压缩算法、加密算法
  • C++ STL 中的 hash_map