数据结构与算法(十七) -- 查找

598 阅读10分钟

一、查找概论

在使用电脑的时候, 就会涉及到查找技术. 例如在硬盘中查找文件, 玩游戏加载数据等, 都要涉及到查找. 所有这些需要被查的数据所在的集合, 统一叫查找表.

查找表是由同一类型的数据元素构成的集合.

关键字是数据元素中某个数据项的值.

若此关键字可以唯一的标示一个记录, 则称此关键字为主关键字.

对于那些可以识别多个数据元素的关键字, 称之为次关键字.

查找实际上就是根据给定的某个值, 在查找表中确定一个关键字等于给定值的数据元素.

查找表按照操作方式分为两大种: 静态查找表和动态查找表.

静态查找表: 只操作查找操作的查找表.

主要操作为:

  1. 查询某个“特定的”数据元素是否在查找表中
  2. 检索某个“特定的”数据元素的各种属性

动态查找表: 在查找过程中同时插入查找表中不存在的数据元素, 或者从查找表中删除已存在的某个数据元素;

显然动态查找表的操作就是两个:

  1. 查找时插入数据元素
  2. 查找时删除数据元素

二、顺序表查找

例如, 在一大堆散乱的书籍中找到自己想要的那本非常的麻烦, 但是在图书馆里就很容易找到需要的书籍. 所以面对散乱的书都会先考虑做一件事, 那就是把这些书排列整齐. 这样就很容易找到需要的书籍.

散乱的图书可以理解为一个集合, 把它排列整齐, 就是将此集合构造成一个线性表. 针对这一线性表进行查找操作, 因为它就是静态查找表.

顺序查找又叫线性查找, 查找过程是: 从表的第一个或者最后一个记录开始, 依次逐个的进行对比, 如果一致则查找成功, 如果数据查找完毕仍没有相等的关键字, 则表中没有此数据.

2.1、顺序表查找算法

//a为查找表   n为查找表长度  key为关键字 a[0]为哨兵
int Sequential_Search(int *a, int n, int key) {
    
    for (int i = 1; i <= n; i++) {
        if (a[i] == key) {
            return i;
        }
    }
    return 0;;
}

在这里每次循环都要对i判断是否越界. 即 i <= n. 可以设置一个哨兵, 解决不需要每次让i与n做比较.

//a为查找表   n为查找表长度  key为关键字 a[0]为哨兵
int Sequential_Search2(int *a, int n, int key) {
    
    int i = n;
    a[0] = key;
    while (a[i] != key) {
        i--;
    }
    return 0;;
}

这样就直接少了一次比较处理.

三、有序查找表

3.1、折半查找

折半查找技术, 又称二分查找

  • 它对前提是线性表中对记录必须是关键码有序(通常从大到小), 线性表必须采用顺序存储;

  • 折半查找的基本思路是

    • 在有序表中, 取中间记录作为比较对象, 若给定值与中间记录的关键字相等则查找成功.
    • 若给定值小于中间记录的关键字, 则在中间记录的左半区继续查找
    • 若给定值大于中间记录的关键字, 则在中间记录的右半区继续查找
    • 不断重复此过程, 直到查找成功, 若无记录, 则查找失败
//a为查找表   n为查找表长度  key为关键字 a[0]为哨兵
int Binary_Search(int *a, int n, int key) {
    
    int low = 1;//最低下标
    int high = n;//最高下标
    int mid = 0;//折半标记
    
    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;
}

3.2、插值查找

当我们在查找字典的时候, 例如查找 ‘apple’, 我们会下意识的翻到靠前的位置, 查找‘zoo’肯定会翻到靠后的位置, 绝对不会直接从中间开始. 同样的, 例如在0 ~ 10000 之间100个元素从小到大均匀分布的数组中查找5, 自然会考虑从数组较小的开始查找.

所以有了新的方程式:

{\frac{key - a[low]}{a[high] - a[low]}}
mid = {low + \frac{key - a[low]}{a[high] - a[low]} * (high - low)}
//a为查找表   n为查找表长度  key为关键字 a[0]为哨兵
int Binary_Search(int *a, int n, int key) {
    
    int low = 1;//最低下标
    int high = n;//最高下标
    int mid = 0;//折半标记
    
    while (low <= high) {
        //折半计算
        mid = low + (high - low) * (key - a[low]) / (a[high] - a[low]);
        if (key < a[mid]) {
            //将左半边定为查找区
            high = mid - 1;
        } else if (key > a[mid]) {
            //将右半边定为查找区
            low = mid + 1;
        } else {
            return mid;
        }
    }
    return 0;
}

3.3、斐波那契查找

假设 a = {1, 16, 24, 35, 47, 59, 62, 73, 88, 99} n = 10, key = 99

  1. F[6] < n < F[7], 记录k = 7. 但是最大的位置只有a[10]. 所以将后续的两个元素赋值: a[11] = a[12] = a[10] = 99, 即 a = {1, 16, 24, 35, 47, 59, 62, 73, 88, 99, 99, 99}

  2. low = 1, high = 10, mid = low + F[k-1] - 1 = 1 + F[6] - 1 = 8.

  3. key > a[8], 所以 low = mid + 1 = 9

  4. 此时新范围为 [mid + 1, high] , 所以k = k - 2 = 5

  5. low = 9, high = 10, mid = low + F[k-1] - 1 = 9 + F[4] - 1 = 11

  6. key = a[11]. 此时 mid > 10, 即返回10, 表示这个key在数组的10位置上

斐波那契的核心在于:

  1. 当 key = a[mid]时, 表示查找成功.
  2. 当 key < a[mid]时, 新范围是第low个到第新mid-1个, 此范围个数为F[k-1] - 1个
  3. 当 key > a[mid]时, 新范围是第mid+1 个到第新high个, 此范围个数为F[k-2] - 1个
//斐波拉契查找
int F[100]; /* 斐波那契数列 */
int Fibonacci_Search(int *a,int n,int key){
  
    int low,high,mid,i,k;
    //最低下标为记录的首位;
    low = 1;
    //最高下标为记录的末位;
    high = n;
    k = 0;
    
    //计算n为斐波拉契数列的位置;
    while (n > F[k]-1) {
        k++;
    }
    
    //将数组a不满的位置补全值;
    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]) {
            //若查找的记录小于当前分隔记录;
            //将最高下标调整到分隔下标mid-1处;
            high = mid-1;
            //斐波拉契数列下标减1位;
            k = k-1;
            
        }else if(key > a[mid]){
            //若查找的记录大于当前的分隔记录;
            //最低下标调整到分隔下标mid+1处
            low = mid+1;
            //斐波拉契数列下标减2位;
            k = k-2;
            
        }else{
            if (mid <= n) {
                //若相等则说明,mid即为查找的位置;
                return mid;
            }else
            {
                //若mid>n,说明是补全数值,返回n;
                return n;
            }
        }
    }
    return 0;
}

四、二叉排序树

在某些情况下, 对一个线性表进行查找后删除、插入等操作, 会变的非常的麻烦, 所以就有了一种动态查找法. 二叉搜索树、二叉排序树就是对这样动态查找.

当在线性表中插入一个数据的时候, 需要让比这个数据大的数据向后移动, 这样操作就变的非常麻烦.

但是在二叉树中, 需要插入一个数据, 只需要判断是否比根节点大还是小, 小就直接放在这个结点的左子结点.

例如构建一段数据: 62, 88, 58, 47, 35, 73, 51, 99, 37, 93

以第一个数据62为根结点 58 88为子结点, 构建一个二叉树. 接下来依次读取接下来的数据.

4.1、二叉排序树的查找

先来构建一个二叉树:

typedef struct BiTNode {
    int data;//结点数据
    struct BiTNode *lchild, *rchild;//左右孩子指针
} BiTNode, *BiTree;

对二叉树进行查找:

  1. 首先判断二叉树是不是已经到达来叶子结点(递归条件), 到达叶子结点即查找完毕
  2. 判断当前数据是否查找到, 查找到即结束
  3. 判断key是否小于当前结点, 是则以在左子树继续查找
  4. 判断key是否大于当前结点, 是则以在右子树继续查找
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p) {
    if (!T) {
        //已到达叶子结点
        *p = T;
        return 0;
    } else if (T->data == key) {
        //查找成功
        *p = T;
        return 1;
    } else if (T->data > key) {
        //查找左子树
        return SearchBST(T->lchild, key, T, p);
    } else {
        //查找右子树
        return SearchBST(T->rchild, key, T, p);
    }
}

4.2、二叉排序树的插入

例如向这个二叉树中插入一个95

  1. 首先对二叉树进行一次查找, 如果查找成功则直接返回, 否则就会来到一个合适的位置
  2. 当二叉树中不存在这个key, 判断key大小, 放在这个合适位置的左边还是右边

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;
        } else if (key < p->data) {
            p->lchild = s;
        } else {
            p->rchild = s;
        }
        return 1;
    }
    return 0;
}

4.3、二叉排序树的删除

4.3.1、删除叶子结点

删除叶子结点, 对整棵树的结构是没有影响的. 所以对于删除叶子结点, 只需要将叶子结点所在的位置设置为NULL;

4.3.2、删除的结点只有一个子结点

结点删除后, 将它的左子树或者右子树整个移动到删除结点的位置即可

4.3.3、删除的结点有左右子树

第一种方法:

将被删除的结点的一个子树直接移动到被删除的结点的位置, 另一个子树则重新逐个插入到这棵树中.

第二种方法:

找到被删除的前驱或者后继替换到被删除的位置

4.3.4、代码实现

//从二叉排序树中删除结点p,并重接它的左或者右子树;
Status Delete(BiTree *p){

    BiTree temp,s;
    
    if((*p)->rchild == NULL){
       
        //右子树为空只需要重新连接它的左子树
        temp = *p;//将结点p临时存储到temp中;
        *p = (*p)->lchild;//将p指向到p的左子树上;
        free(temp);//释放需要删除的temp结点;
        
    }else if((*p)->lchild == NULL){
        
        //左子树为空只需要重新连接它的右子树
        temp = *p;//将结点p临时存储到temp中;
        *p = (*p)->rchild;//将p指向到p的左子树上;
        free(temp);//释放需要删除的temp结点;
        
    }else{
        
        //删除的当前结点的左右子树均不为空;
        temp = *p;//将结点p存储到临时变量temp, 并且让结点s指向p的左子树
        s = (*p)->lchild;
      
        //将s指针,向右到尽头(目的是找到待删结点的前驱)
        //在待删除的结点的左子树中,从右边找到直接前驱
        while (s->rchild) {
            temp = s;//使用temp保存好直接前驱的双亲结点
            s = s->rchild;
        }
        
        //将要删除的结点p数据赋值成s->data;
        (*p)->data = s->data;
        
        if(temp != *p)
            temp->rchild = s->lchild;//重连右子树
        else
            temp->lchild = s->lchild;//重连左子树
        
        //删除s指向的结点; free(s)
        free(s);
    }
    
    return  TRUE;
}