第 8 章 查找

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

8.1 开场白

8.2 查找概论

  查找表:是由同一类型的数据元素(或记录)构成的集合。
  关键字:是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。
  若此关键字可以唯一标识一个记录,则称此关键字为主关键字。
  对于那些可以识别多个数据元素(或记录)的关键字,称之为次关键字。
  查找表按照操作方式分有两种:静态查找表和动态查找表。
  静态查找表:只作查找操作的查找表。主要操作有:
  (1)查询某个“特定的”的数据元素是否在查找表中。
  (2)检索某个“特定的”数据元素和各种属性。
  动态查找表:在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已存在的某个数据元素。操作如下:
  (1)查找时插入数据元素。
  (2)查找时删除数据元素。
  

8.3 顺序表查找

  顺序查找又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不相等时,则表中没有所查的记录,查找不成功。
  

8.1 顺序表查找算法

//顺序查找,a为数组,n为要查找的数组长度,key为要查找的关键字
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;
}

8.3.2 顺序表查找优化

//有哨兵顺序查找
int Sequential_Search2(int *a, int n, int key)
{
    int i;
    a[0] = key;         //设置啊【0】为关键字值,称之为哨兵
    i = n;              //循环从数组尾部开始
    while (a[i] != key)
    {
        i--;
    }
    return i;       //返回 0 表示查找失败

这种在查找方向的尽头设置哨兵免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,在数据很多时,效率提高很多。

8.4 有序表查找

8.4.1 折半查找

折半查找,又称为二分查找,它的前提是线性表中的记录必须是关键码有序,线性表必须采用顺序存储。折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找;不断重复上述过程,知道查找成功,或所有查找区域无记录,查找失败为止。

//折半查找
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;
}

折半查找的时间复杂度为O(logn)。不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不在变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据结集来说,维护有序的排序会带来不小的的工作量,那就不建议使用。

8.4.2 插值查找

只需要在折半查找的代码中更改以下第8行代码如下:

mid = low + (high- low) * (key - a[low])/(a[high] - a[low]);

插值查找是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式 keya[low]a[high]a[low] 。应该说从时间复杂度上来说,它也是O(logn)。但对于表长较大,而关键字分布比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好的多。

8.4.3 斐波那契查找

利用黄金分割原理。

//斐波那契查找
int Fibonacci_Search(int *a, int n, int key)
{
    int low, high, mid, i, k;
    low = 1;
    high = n;
    k = 0;      
    while (n > F[k]-1)  //计算n位与斐波那契数列的位置
        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 = k -1;           //斐波那契数列下标减一位
        }
        else if (key > a[mid])
        {
            low = mid + 1;
            k = k -2 ;      //斐波那契数列下标减两位
        }
        else 
        {
            if (mid <= n)
                return mid;     //若相等则说明mid即为查找到的位置
            else
                return n;       //若mid>n说明是补全数值,返回n
        }
    }
    return 0;
}

尽管斐波那契查找的时间复杂度也为O(logn),但就平均性能来说,斐波那契查找要优于折半查找。但是最坏情况,查找效率要低于折半查找。

8.5 线性索引查找

数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程。索引按照结构可以分为线性索引、树形索引和多级索引。这里只介绍线性索引,所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。重点介绍三种索引:稠密索引、分块索引、倒排索引。

8.5.1 稠密索引

稠密索引是指在线性索引中,将数据集合中的每个记录对应一个索引项。对于稠密索引,索引项一定是按照关键码有序的排列。

8.5.2 分块索引

分块有序,是把数据集的记录分成了若干块,并且这些快需要满足两个条件:

-块内无序,即每一个块内的记录不要求有序。

  • 块间有序。例如要求第二块所有记录的关键字均要大于第一块中所有记录。
    对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。定义的分块索引的索引项结构分三个数据项:
  • 最大关键码,它存储每一块中的最大关键字。
  • 存储了块中的记录个数
  • 用于指向块首数据元素的指针
      分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用中。
      

8.5.3 倒排索引

最简单的也是最基本的搜索技术——倒排索引。
索引项的通用结构是:

  • 次关键码:
  • 记录号表
    其中记录好表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的关键字)。这样的索引方法就是倒排索引。

8.6 二叉排序数

二叉排序树,又称为二叉排序数。它或者是一颗空树,或者是具有下列性质的二叉树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
  • 它的左右子树又分为而二叉排序树。
    构造一颗二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。

8.6.1 二叉排序树查找操作

//二叉树的二叉链表结点结构定义
typedef struct BitNode      //结点结构
{
    int data;       //结点数据
    struct BitNode *lchild, *rchild;        //左右孩子指针
}BitNode, *BiTree;

//递归查找二叉排序树T中是否存在kye
//指针f指向T的双亲,其初始调用值为NULL
//若查找成功,则指针p指向该数据元素结点,并返回TRUE
//否则指针P指向查找路径上访问的最后一个结点并返回FALSE
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
    if (!T)     //查找不成功
    {
        *p = f;
        return FALSE;
    }
    else if (key == T->data)    //查找成功
    {
        *p = T;
        return TRUE;
    }
    else if (key < T->data)
        return SearchBST(T->lchild,key,T,p);        //在左子树继续查找
    else 
        return SearchBST(T->rchild, key, T, p);     //在右子树继续查找
}

8.6.2 二叉排序树插入操作

//当二叉排序树T中不存在关键字等于key的数据元素时,插入key并返回TRUE,否则返回FALSE
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;     //插入s为新的根结点
        else if (key < p->data)
            p->lchild = s;      //插入s为左孩子
        else 
            p->rchild = s;      //插入s为右孩子
        return TRUE;
    }
    else 
        return FALSE;       //树中已有关键字相同的结点,不在插入
}

有了插入代码,实现二叉树的构建就非常容易了

//构建二叉树
int i;
int a[10] = {62,88,58,47,35,73,51,99,37,93};
BiTree T = NULL;
for (i = 0; i < 10; i++)
{
    InsertBST(&T, a[i]);
}

8.6.3 二叉排序树删除操作

删除结点的三种情况:

  • 叶子结点
  • 仅有左或右子树的结点
  • 左右子树都有的结点。
    下面这个算法是递归方式对二叉排序数T查找key,查找到时删除。
//若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点
//并返回TRUE;否则返回FALSE
Status DeleteBST(BiTree *T, int key)
{
    if (!T)     //不存在关键字等于key的数据元素
        return FALSE;
    else 
    {
        if (key == (*T)->data)      //找到关键字等于key的数据元素
            return Delete(T);
        else if (key < (*T)->data)
            return DeleteBST(&(*T)->lchild, key);
        else 
            return DeleteBST (&(*T)->rchild,key);
    }
}

下面是Delete函数的代码

//从二叉排序数中删除结点p,并重接它的左或右子树
Status Delete(BiTree *p)
{
    BiTree q, s;
    if ((*p)->rchild == NULL)   //右子树空则只需重接它的左子树
    {
        q = *p; *p = (*q)->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;       //s指向被删结点的直接前驱
        if (q != *p)
            q->rchild = s->lchild;      //重接q的右子树
        else 
            q->lchild = s->lchild;      //重接q的左子树
        free(s);
    }
    return TRUE;
}

8.6.4 二叉排序树总结

因此,如希望对一个集合按二叉排序树查找,最好是把它构建成一颗平衡的二叉排序树,即其深度与完全二叉树相同。

8.7 平衡二叉树(AVL树)

  平衡二叉树是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1。
  将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF。
  距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,称为最小不平衡子树
  

8.7.1 平衡二叉树实现原理

平衡二叉树构建的基本思想就是咋构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。

8.7.2 平衡二叉树实现算法

//二叉树的二叉链表结点结构定义
typedef struct BiTNode      //结点结构
{
    int data;
    int bf;         //结点的平衡因子
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;

//对以p为根的二叉排序树作右旋处理,处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点
void R_Rotate(BiTree *P)
{
    BiTree L;
    L = (*P)->lchild;       //L指向P的左子树根结点
    (*P)->lchild = L->rchild;   //L的右子树挂接为P的左子树
    L->rchild = (*P);
    *P = L;     //P指向新的根结点
}
//对以p为根的二叉排序树作左旋处理,处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点
void L_Rotate(BiTree *P)
{
    BiTree R;
    R = (*P)->lchild;       //L指向P的左子树根结点
    (*P)->lchild = L->rchild;   //L的右子树挂接为P的左子树
    R->rchild = (*P);
    *P = R;     //P指向新的根结点
}

现在来看左平衡旋转处理的函数代码

#define LH  +1      //左高
#define EH  0       //等高
#define RH  -1      //右高
//对以指针T所指结点为根的二叉树做左平衡旋转处理
//本算法结束时,指针T指向新的根结点
void LeftBalance(BiTree *T)
{
    BiTree L, Lr;
    L = (*T)->lchild;   //L指向T的左子树根结点
    switch(L->bf)
    {//检查T的左子树的平衡度,并作相应平衡处理
        case LH://新结点插入在T的左孩子的左子树上,要作单右旋处理
            (*T)->bf = L->bf = EH;
            R_Rotate(T);
            break;
        case RH:    //新结点插入在T的左孩子的右子树上,要作单双旋处理
            Lr = L->rchild;     //Lr指向T的左孩子的右子树根
            switch(Lr->bf)      //修改T及其左孩子的平衡因子
            {
                case LH: (*T)->bf = RH;
                    L->bf = EH;
                    break;
                case EH: (*T)->bf = EH;
                    L->bf = EH;
                    break;
                case RH: (*T)->bf = EH;
                    L->bf = LH;
                    break;
            }
            Lr->bf = EH;
            L_Rotate(&(*T)->lchild);        //对T的左子树作左旋平衡处理
            R_Rotate(T);                    //对T做右旋处理
    }
}

同样的,右平衡旋转处理的函数代码非常类似。

//若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个
//数据元素为e的新结点并返回1,否则返回0。若因插入而使二叉排序树失去平衡,
//则做平衡处理,布尔变量taller反映T长高与否
Status InsertAVL(BiTree *T, int e, Status *taller)
{
    if (!*T)
    {//插入新结点,树“长高”,置taller为TRUE
        *T = (BiTree)malloc(sizeof(BiTNode));
        (*T)->data = e;
        (*T)->lchild = (*T)->rchild = NULL;
        (*T)->bf = EH;
        *taller = TRUE;
    }
    else 
    {
        if (e == (*T)->data)
        {//数中已存在和e相同关键字的结点则不再插入
            *taller = FALSE;
            return FALSE;
        }
        if (e < (*T)->data)
        {//应继续在T的左子树中进行搜索
            if(!InsertAVL(&(*T)->lchild, e, taller))    //未插入
                return FALSE;
            if (*taller)        //已插入到T的左子树中且左子树长高
            {
                switch((*T)->bfz)//检查T的平衡度
                {
                    case LH:    //原本左子树比右子树高,需要作左平衡处理
                        LeftBalance(T);
                        *taller = FALSE;
                        break;
                    case EH:    //原本左右子树等高,现因左子树增高而树增高
                        (*T)->bf = LH;
                        *taller = TRUE;
                        break;
                    case RH:    //原本右子树比左子树高,现左右子树等高
                        (*T)->bf = EH;
                        *taller = FALSE;
                        break;
                }
            }
        }
        else 
        {//应继续在T的右子树中进行搜索
            if(!InsertAVL(&(*T)->rchild, e, taller))    //未插入
                return FALSE;
            if (*taller)        //已插入到T的右子树中且右子树长高
            {
                switch((*T)->bfz)//检查T的平衡度
                {
                    case LH:    //原本左子树比右子树高,现在左右子树等高
                        (*T)->bf = EH;
                        *taller = FALSE;
                        break;
                    case EH:    //原本左右子树等高,现因右子树增高而树增高
                        (*T)->bf = LH;
                        *taller = TRUE;
                        break;
                    case RH:    //原本右子树比左子树高,需要作右平衡处理
                        RightBalance(T);
                        *taller = FALSE;
                        break;
                }
            }
        }
    }
    return TURE;
}

对于这段代码来说,只需要在需要构建平衡二叉树的时候,执行如下列代码即可在内存中生成一颗平衡二叉树。

int i;
int a[10] = {3,2,1,4,5,6,7,10,9,8};
BiTree T = NULL;
Status taller;
for (i = 0; i < 10; i++)
{
    InsertAVL(&T, a[i], &taller);
}

若要查找的集合本身没有次序,在频繁查找的同时也需要经常的插入和删除操作,显然,需要构建一颗二叉排序树,但是必须是平衡二叉树,此时时间复杂度为O(logn)。

8.8 多路查找树(B树)

多路查找树,其每一个结点的孩子树可以多余两个,且每一个结点处可以存储多个元素。
主要讲解它的4种形式:2-3树、2-3-4树、B树和B+树

8.8.1 2-3树

  2-3 树是这样的一棵多路查找树:其中每一个结点都具有两个孩子(称它为2结点)或3个孩子(称它为3结点)。
  一个2结点包含一个元素和两个孩子(或没有孩子)。不能只有一个孩子。
  一个3结点包含一小一大两个元素和三个孩子(或没有孩子)。
  并且2-3树中所有的叶子都在同一层次。
  2-3树复杂的地方就在于新结点的插入和已有结点的删除。不在赘述。

8.8.2 2-3-4 树

包括了4结点的使用。一个4结点包含小中大三个元素和四个孩子(或没有孩子)。

8.8.3 B树

B树是一个种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶。因此,2-3树是3阶B树,2-3-4树是4阶B树。
  B树的数据结构就是内外存的数据交互准备的。
  

8.8.4 B+树

  B+树是应文件系统所需而出的一种B树的变形树,在B+树中,出现分支结点中的元素会被当做它们在该分治结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。
  一棵m阶的B+树和m阶的B树的差异在于:
  

  • 有n棵子树的结点中包含有n个关键字;
  • 所有的叶子结点包含全部关键在的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接。
  • 所有分治结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。

8.9 散列表查找(哈希表)概述

8.9.1 散列表定义

  散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。
  这里把这种对应关系f称为散列函数,又称为哈希函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表
  

8.9.2 散列表查找步骤

整个散列过程就是两步:
(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
(2)当查找记录时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。
  所以所,散列技术即是一种存储方法,也是一种查找方法。散列主要是面向查找的存储结构。
  散列技术最适合的求解问题是查找与给定值相等的记录。
  但是,不适合范围查找,和具有同样关键字的集合。
  若两个关键字key1 != key2,但是却又f(key1) = f(key2),这种现象称为冲突,并把key1和key2称为这个散列函数的同义词。

8.10 散列函数的构造方法

什么才算是好的散列函数,给出两个原则参考:
(1)计算简单
(2)散列地址分布均匀

8.10.1 直接定址法

取关键字的某个线性函数值为散列地址。优点:简单、均匀,也不会产生冲突,但问题是需要事先知道关键字的分布情况,适合查找表较小且连续的情况。

8.10.2 数字分析法

数字分析常常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布脚均匀,就可以采用这个方法。

8.10.3平方取中法

比较适合不知道关键字的分布,而位数又不是很大的情况。

8.10.4 折叠法

是将关键字从左到右分割成位数相等的几部分(最后一部分位数不够是可以短些),然后,将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
 折叠法实现不需知道关键字的分布,适合关键字位数较多的情况。
 

8.10.5 除留余数法

为最常用的构造散列函数方法,对于散列表长为m的散列函数公式为:
f(key) = key mod p (p<=m)
mod是取模(求余数)的意思。

8.10.6 随机数法

选择一个随机数,取关键字的随机函数值为它的散列地址。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
总之,视不同的情况采用不同的散列函数,给出一些参考因素:

  1. 计算散列地址所需要的时间
  2. 关键字的长度
  3. 散列表的大小
  4. 关键字的分布情况
  5. 记录查找的频率。

8.11 处理散列冲突的方法

8.11.1 开放定址法

开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
fi(key) = (f(key) + di) MOD m (di = 1,2,3…,m-1)

8.11.2 再散列函数法

每当发生散列地址冲突时,就换一个散列函数计算。

8.11.3 链地址法

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,也带来了查找时需要遍历单链表的性能损耗。

8.11.4 公共溢出区法

8.12 散列表查找实现

8.12.1 散列表查找算法实现

  首先是需要定义一个散列表的结构以及一些相关的常数。其中HashTable就是散列表结构。结构当中elem为一个动态数组。
  

#define SUCCESS 1
#define UNSUCCESS   0
#define HASHSIZE    12      //定义散列表长为数组的长度
#define NULLKER     -32768
typedef struct
{
    int *elem;      //数据元素存储基址,动态分配数组
    int count;      //当前数据元素个数
}HashTable;
int m = 0;      //散列表表长,全局变量

有了结构的定义,可以对散列表进行初始化。

//初始化散列表
Status InitHashTable(HashTable *H)
{
    int i;
    m = HASHSIZE;
    H->count = m;
    H->elem = (int *)malloc(m*sizeof(int));
    for (i = 0; i < m; i++)
        H->elem[i] = NULLKER;
    return OK;
}

为了插入时计算地址,需要定义散列函数,散列函数可以根据不同情况更改。

//散列函数
int Hash(int key)
{
    return key % m;     //除留余法
}

初始化完成后,可以对散列表进行插入操作。假设插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}

//插入关键字进散列表
void InsertHash(HashTable *H, int key)
{
    int addr = Hash(key);   //求散列地址
    while (H->elem[addr] != NULLKER)        //如果不为空,则冲突
        addr = (addr + 1) % m;              //开放地址法的线性探测
    H->elem[addr] = key;                    //直到有空位后插入关键字
}

代码插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。此时应用开放地址法的线性探测法进行重新寻址,此处理也可更改为链地址法等其他解决冲突的方法。
散列表存在后,在需要时就可以通过散列表查找要的记录。

//散列表查找关键字
Status SearchHash(HashTable H, int key, int *addr)
{
    *addr = Hash(key);      //求散列地址
    while(H.elem[*addr] != key)     //如果不为空,则冲突
    {
        *addr = (*addr + 1) % m;        //开放地址法的线性探测
        if (H.elem[*addr] == NULLKER || *addr == Hash(key))
        {//如果循环回到原点
            return UNSUCCESS;       //则说明关键字不存在
        }
    }
    return SUCCESS;
}

查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。

8.12.2 散列表查找性能分析

  1. 散列函数是否均匀
  2. 处理冲突的方法
  3. 散列表的装填因子
    所谓的装填因子 = 填入表中的记录个数 / 散列表的长度。

8.13 总结回顾

8.14 结尾语

你可能感兴趣的:(大话数据结构)