【数据结构与算法】查找算法(一)


一、概论

1.1 基本概念

查找(searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定制的数据元素(或记录)。

查找表(search table)是由同一类型的数据元素(或记录)构成的组合。例如下图:

关键字(key)三数据元素中某个数据项的值,又称为键值。用它可以标示一个记录的某个数据项,我们称为关键码。

1.2 分类

按照表查找方式来分有辆大众:静态查找表和动态查找表。

静态查找表(Static Search Table): 只做查找操作的查找表,主要操作有:

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

动态查找表(Dynamic Search Table)在查找的过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。动态查找表的操作是两个:

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

二、静态查找

2.1 顺序查找

顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,他的查找过程是:从表的第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,

  1. 若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;
  2. 如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。

2.3.1 顺序查找算法

算法比较简单,在数组里遍历查找:

int Sequential_Search(int *a, int n, int key)
{
    int i;
    for (i = 1; i<= n; i++) {
        if (a[i] == key) {
            return i;
        }
    }
    return 0;
}

2.3.2 顺序查找优化

由于上述算法,每次都需要判断i是否越界,稍显复杂,我们对算法进行改进,在0位加入一个哨兵,可以减少一次让 i 与 n 比较,具体如下:

int Sequential_Search(int *a, int n, int key)
{
    int i;
    // 1. 在0 位设置称为key,称为哨兵
    a[0] = key ;
    i = n;

    // 2. 从尾部开始倒序循环
    while (a[i] != key) {
        i--;
    }
    // 3. 返回>0 说明找到虚序号;=0 则 = key,表示查找失败
    return i;
}

这里改动了3个步骤

  1. 在0 位设置称为key,称为哨兵
  2. 循环倒置。从尾部开始倒序循环,好处是形成了一个完整循环(包括哨兵)
  3. 查找结果二合一:返回>0 说明找到虚序号;=0 则 = key,表示查找失败

2.4 折半查找

折半查找(Binary Search),又称为二分查找。

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

折半查找的基本思想:

  • 在有序表中,去中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;
  • 若给定值小于中间记录的关键字,则在中间记录作伴去继续查找;
  • 若给定值大于中间记录的关键字中,则在中间记录的右半区继续查找。

不断重复上述过程直到查找成功,或所有查找区域无记录,查找失败位置。

举例:现在有个数组 $<!–0–>$,查找其中 $88$ 的序号

代码实现如下:

// 折半查找
int Binary_Search(int *a, int n, int key)
{
    int low, high, mid;
    low = 1;
    high = n;
    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;
}

查找流程如下图:

2.3 插值查找

2.3.1 折半改进

由于折半查找会从中间开始查找,一旦超过一定量级,查找任务还是比较繁重。所以考虑是否还有改进空间

查找代码中求 mid的计算公式可以改进为:

$$
mid = \frac{low+high}{2} = low + \frac{1}{2} (high - low)
$$
也就是 mid等于最下标 low 加上 最高下标 highlow 的差值的一半

考虑将这个一半改进为以下方案:
$$
mid = low + \frac{ key - a [low]}{ a[high] - a[low]}(high - low)
$$
改进的好处:可以大大减少查找的次数。

比如数组 {6,12,18,25,31,37,43,50,56,62,68,75,82,88,96},查找18 ,按照折半查找需要查找4步才可以找到

使用新公示,需要 $\frac{ key - a [low]}{a[high] - a[low]}$ = $ \frac{(18 - 12)}{(96 - 12)} \approx 0.071$,那么此时 $mid\approx 1 + 0.071\times (15-1) = 1.994$,取整后,只需要查询2次即可,大大提升了效率。

这样的到了另一种有虚表查找算法,插值查找法。

2.3.2 核心概念

插值查找法(Interpolation Search) 是根据要查找的关键字key 与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值公示:
$$
\frac{key - a[low]}{a[high] - a[low]}
$$
缺点:不适合数字差距不均匀的数组。如 {0, 1, 2, 1000, 30000}

2.3.2 算法实现

由上可知,只需要改进一行代码

  // 插值查找
int Binary_Search(int *a, int n, int key)
{
    int low, high, mid;
    low = 1;
    high = n;
    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;
}

2.5 斐波那契查找

斐波那契查找(Fibonacci Search),利用了黄金分割来实现。

查找的流程,先举例实现吧,假设给定一个数组 $a[10] = $ {0, 1, 16, 24, 35, 47, 59, 62,73, 88, 99}, 此时$n = 10$,需要查找的键为 $key = 99$

  • 先定义一个斐波那契数列 F[n]

    下标 0 1 2 3 4 5 6 7 8 9
    F 0 1 1 2 3 5 8 13 21 34

    它原理之前有学习过,实现原理是递归原理,代码实现如下:

          F[0] = 0;
          F[1] = 1;
          for (int i = 2; i < 100; i++) {
              F[i] = F[i -1] + F[i - 2];
          }
  • 定义变量和下表

    int low, high, mid, i, k;
    low = 1, high = n;

    其中 lowhigh分别为数组最低下标,默认 high为最大

  • 计算 n 位于斐波那契数列的位置

    while (n > F[k]) -1
        k++;

    由于上文给出的数组长度$n = 10$,得出 $F[6] < n < F[7]$, 所以此时 $k = 7$,在队列中如图示

  • 将不满的数值补全,用a[n]为什么补齐?

        for (i = n; i < F[k] - 1; i++) {
            a[i] = a[n];
        }
  • 开始查找:

    1. 设定初始条件 $low <= high$,假设查找 n = 10,需要查找key = 59

    2. 计算当前分割下标,$mid = low + F[k - 1] -1$, 此时$mid = 1 + F[7-1] -1 =8$

    3. 判断,若 $key < a[mid]$ ,即如果查找记录小于当前分割记录

      • 最高下标调整到分隔下标的 $mid -1$ 处

        high = mid -1;
      • 斐波那契数列下标减$1$位

        k--;

      这一步中 key 为59, a[mid] 及a[8] 为73,所以$key<a[mid]$,需要进行调整, 如下图所示

      此时各项参数结果为:$k = 6, low = 1, mid = 8, high = 7$

    4. 继续循环查找。

      1. 计算当前分隔的下标:$mid = F[6-1] - 1 = 5$ 。
      2. 继续判断 key 与 a[mid],此时 $a[5] = 47 < key$,
      3. 执行两个步骤:
      • 最低下标调整到分割下标 $mid + 1$ 处

        low = mid + 1;
      • 斐波那契数列下标减2位

        k = k - 2;

      实现示意图如下:

      此时各项参数结果为:$k = 4, low = 6, mid = 5, high = 7$

    5. 再次循环。

      1. 计算当前分隔的下标:$mid = 6 + F[4-1] -1 = 7$。
      2. 继续判断 key 与 a[mid] 的大小,此时 $a[7] = 62 > key$,因此执行第三步的策略。
      3. 重复一下如下:
      • 最高下标调整到分隔下标的 $mid -1$ 处

        high = mid -1;
      • 斐波那契数列下标减$1$位

        k--

      实现示意图如下:

      此时各项参数结果为:$k = 3, low = 6, mid = 5, high = 6$

    6. 再次循环。

      1. 计算当前分隔的下标:$mid = 6 + F[3-1] =1 = 6$

      2. 判断 key 与 a[mid] ,此时 a[6] = 59 = key 。说明查找到,进行返回数组下标,返回值为 $6$

        if(mid < n)
            return mid;
        else
            return n;
  • 回答为什么补齐$a[n]$

    在循环查找的过程中,如果查找的$key=99$,

    那么第一次查找时,

    • $mid = 8$;
    • $low = mid + 1 = 9$
    • $k = k - 2 = 7 - 2 = 5$

    第二次循环时,此时改变分隔mid 下标的值,$mid =low + F[k - 1] - 1 = 9 + 3 - 1 = 11$, 而此时a[mid] 为a[11] ,数组a 中的11位时没有值,会导致下一步与 key 的比较失败,为了避免情况发生,对上文中将不满的数值补全,用 a[i] = a[n]

2.2.2 算法核心

根据上文的查找流程,总结一下算法核心

  1. 当 $key = a[mid]$ 时,查找成功
  2. 当 $key < a[mid]$ 时,新范围是第 low 个到第 mid -1 个,此时范围个数为 F[k - 1] - 1 个
  3. 当 $key > a[mid]$ 时,新范围是第 m+1 个到第 high 个,此时范围个数为 F[k-2] -1 个

示意图如下:

2.2.2 算法实现

先看看代码

int F[100];
int Fibonnaci_Search(int *a, int n, int key)
{
    int low, high, mid, i, k;
    low = 1;
    high = n;
    k = 0;
    while (n > F[k] - 1) {
        k++;
    }
    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]) {
            high = mid -1;
            k--;
        }
        else if (key > a[mid])
        {
            low = mid++;
            k = k -2;
        }
        else
        {
            if (mid <= n)
                return mid;
            else
                return n;
        }
    }
    return 0;
}

2.6 查找效率

三种查找方式的公式如下,三种方式各有优劣,适合不同场景:

  • 折半查找: $mid = (low + high) / 2$
  • 插值查找,适合: $mid = low + \frac{key - a[low]}{a[high] - a[low]} * (high - low)$
  • 斐波那契查找: $mid = low + F[k - 1] - 1$

优劣点即使用场景:

  • 折半查找:适合数据规模较小,执行效率高
  • 插值查找:适合数据规模较大,比折半查找更高效,但规模较小,以数值递增波动较大时与折半查找区别不大;
  • 斐波那契:相比插值查找,适合数据递增波动较大的场景。

三、动态查找

3.1 二叉排序树

3.1.1 定义

二叉排序树(Binary Sort Tree),又称为二叉查找树。它是一种空树,或者具有下列性质的二叉树:

  • 若它的左子树不空,则右子树上所有结点的值均小于它的根结点的值
  • 若它的右子树不空,则左子树上所有结点的值均大于它的根结点的值
  • 它的左、右子树分别为二叉排序树

3.1.2 查找

  1. 二叉树的结构如下:

    typedef struct BitNode{
        int data;                                                 // 数据域
        struct BitNode *lChild, *rChild; // 指针分别指向左、右子树
    }BitNode, *BiTree;
  2. 代码实现如下:

    Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
    {
        if (!T) {
            *p = f;
            return FALSE;
        }else if( key == T->data){
            *p = T;
            return SUCCESS;
        }else if(key < T->data){
            return SearchBST(T->lChild, key, T, p);
        }else{
            return SearchBST(T->rChild, key, T, p);
        }
    }
  3. 分析查找流程:

    1. 判断树的有效性
    2. 指针 f 指向 T 结点的双亲,如果第一次,使用值为 NULL
    3. 判断结点数值与 目标值元素结点的数据是否相等
      1. 结点的值与目标值相等——指针 p 指向元素结点,返回成功
      2. 如果目标值key小于元素结点数据,在左子树上,进行查找函数递归
      3. 如果目标 key大于元素结点数据,在右子树上,进行查找函数递归
  4. 示意图如下:

3.1.3 插入

插入的流程有以下几个步骤

  • 先查找目标元素,如果存在,无需插入,返回
  • 如果不存在,判断 目标值 key 与 指针p
    • 如果大于,则 插入 s 为左孩子
    • 如果小于,则插入 s 为右孩子

代码实现如下

Status InsertBST(BiTree *T, int key)
{
    BiTree p, s;
    if (!SearchBST(*T, key, NULL, &p)) {
        s = (BiTree)malloc(sizeof(BiTree));
        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 SUCCESS;
    }else{
        return FALSE;
    }
}

3.2.4 删除

删除结点有三种情况分析:

  • 叶子结点
  • 仅有左/右子树的结点
  • 左右子树都有结点的

对应的策略如下:

  • 当删除叶子结点:

    无需操作,free 即可

  • 当删除仅有左/右子树的结点时:

    将它的原子树移动到它的位置,替代他即可

  • 当删除结点左右子树都有结点时:

    • 找到待删结点的前缀
    • 重接q 的右子树
    • 重接q 的左子树

代码实现如下:

Status DeleteBST(BiTree *p)
{
    BiTree q,s;
    // 右子树为空
    if ((*p)->rChild == NULL) {
        q = *p;
        *p = (*p)->lChild;
        free(q);

    // 左子树为空
    }else if ((*p)->lChild == NULL){
        q = *p;
        *p = (*p)->rChild;
        free(q);

    // 左右子树均不为空
    }else{
        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;
        }
        free(s);
    }
    return SUCCESS;
}

示意图如下:

  • 找到目标结点 $8$,标记为 p
  • 将p 赋值给临时变量 q,再将其左子树赋值给临时变量 s

  • 循环找到左子树的右结点,知道右侧尽头,得到结点$7$,将临时变量s指向该结点

  • 将删除结点的数值赋值为 临时变量 s-> data,即 p->data = s->data

  • 最后一步,将目标结点7 进行释放,至此,删除完成。

上述情况时最右侧结点为叶子结点,还有一种情况最右侧结点右子结点,该如何处理呢。

  • 这里的步骤和上文一样,赋值临时变量p,s

  • 找到目标结点6,将临时变量s 指向6

  • 将删除结点的数值赋值为 临时变量 s-> data,即 p->data = s->data。此时相当于结点6 被删除,它的子结点需要妥善处理。

  • 此时如果 p 和 q 指向不相同,则将 6 的子树交给 它的父结点。

    q->rChild = s->lChild。这样节点6 留下的左结点被链接进入树了。

  • 最后一步,free 掉删除的目标结点

3.2.5 总结

二叉排序树是以连接的方式存储,保持了链式存储结构在执行插入和删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。


文章作者: 李佳
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 李佳 !
评论
 上一篇
【数据结构与算法】查找算法(二)平衡二叉树 【数据结构与算法】查找算法(二)平衡二叉树
一、概念 平衡二叉树 (Self-Balancing Banary Search Tree 或者 Height-Balanced Binary Search Tree),是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1。
2020-05-15 李佳
下一篇 
【数据结构与算法10】拓扑排序 【数据结构与算法10】拓扑排序
一、概念定义 拓扑排序,其实就是对一个有向图构造拓扑序列的过程。 AOV 网在一个表示工程的有向图中,用定点表示活动,用弧表示活动之间的有限关系,这样的有向图为顶点表示活动的网,我们称为AOV 网(Activity On Vertex N
2020-05-13 李佳
  目录