数据结构与算法(C++)学习笔记:查找(更新完毕)

基本概念

  • 生活中处处有查找,例如:搜索引擎、大数据问题等等。
  • 我们学习查找想要解决的问题:对于超大数据量,如何提高查找的效率?
  • 基本概念:
  1. 关键码:用以标识一个记录的某个数据项。如果该关键码可以唯一的标识一条记录,则称为主关键码,反之为次关键码。
  2. 查找:在具有相同类型的记录集中找出满足给定条件的记录。
  3. 查找结果:在查找集中找到匹配的记录,称为查找成功;否则查找失败。一般情况下,查找需要返回记录的位置。
    基本概念

P.S.

  • 散列技术其实有很多,例如HASH哈希(题外话:在python中,字典就是一种哈希映射~欲知详情,请查看我的另一篇笔记
    ),散列查找的效率是相当高的,在最近十几年才崛起。
  • 二叉排序树与平衡二叉树的区别?
    因为对于一组无序数据,通过二叉排序树排序以后得到的树有很大可能是不平衡的(左右子树大小相差太多),而平衡二叉树称得上是二叉排序树的升级版,可以解决左右子树不平衡的问题。
    思考: 查找结构与存储结构有什么区别?

线性表查找

顺序查找

问题: 对于乱序数据,如何快速查找出关键字Key是否在乱序中?若是,如何返回位置?

方法一:简单粗暴的直接查找

int search(int a[],int n,int key)
{
    for(int i=0;i<n;i++)//①
        if(a[i] == key)//②
            return i+1;//找到key,返回位置
    return 0;//没有找到,返回0
}

反思: 上述代码的时间复杂度?是O(n^2),因为有二次比较。
思考: 如何进一步提高效率?

方法二:哨兵法–用空间换时间

思想: 对于长度为n的乱序表,另建一个长度为n+1的表,其中a[0]做哨兵,其值赋为key。哨兵的意义–使函数无论如何都会返回一个值,而且只需要比较一次。

int search(int a[],int n,int key)
{
    a[0] = key;  //哨兵
    for(int i=n;a[i]!=key;i--);  //从后向前查找
    return i; //如果找到key,就返回位置i,没有找到,就返回0
}

哨兵法顺序表查找

计算ASL:
  1. 查找不成功 ASL = n+1
  2. 查找成功
    哨兵法ASL

折半查找(敲黑板:必考题)

思考: 折半法的前提是什么?待查找序列为有序表。
基本思想: 先确定待查记录所在的范围,再用二分法逐步缩小范围直到找到或找不到且查完整个表。
再思考: 对存放在数组中的有序表,如何快速找到Key?
折半
折半
折半
注意:

  1. (以上题为例)low的第一次移动要移动到 mid+1,因为mid原来坐在的位置,数据已经比较过一次了,可以直接跳到它的下一个。(同理:high=mid-1)
  2. 如何判断没有找到key?
    low > high 时就说明没有找到。换句话说,循环条件就是 low<=high
int Search_Bin(int a[],int n,int key)
{
    int low = 1;
    int high = n;
    while(low<=high)
    {
        mid = (low+high)/2;
        if(key == a[mid])
            return mid;
        else if (key<a[mid])
            high = mid-1;
        else
            low = mid +1;
    }
    return 0;
}

折半查找的性能分析

折半查找的判定树:
性能分析

  • 一般情况下,表长为n的折半查找的判定树的深度和含有n个结点的完全二叉树的深度相同。
    所以:
    折半性能

索引查找(分块查找)

  • 分块查找的性能介于顺序查找和折半查找。只用于分段有序的信息表。
  • 分段查找的核心思想:在建立顺序表的同时,建立一个索引表。
    分块
  • 可以看出索引表可以用折半查找,而基本表不可以。
  • 基本思想:首先根据索引表确定待查记录的区间,然后再确定的主表区间采用顺序查找。(这其实就是哈希映射的思想,在当前大数据处理方面应用广泛。)
  • 性能分析:
    分块查找性能
    缺点:需要有辅助数组,且初始表要经过分块排序。

三种查找方式的比较

查找方式性能适用条件
顺序查找ASL= (n+1)/2 或n+1,性能最差乱序表
折半查找ASL=(2log)n 或 (2log)n+!,性能最好有序表
分块查找性能位于前两者中间分块有序表

然而这三者都只适用于静态查找。如果我们想在查找的同时,对一些记录进行添加、删除操作,就要使用下面的树表。

树表查找

树表查找是典型的动态查找,适用于乱序表的查找,同时也可以对记录进行操作。

二叉排序树

基本思想: 将乱序化有序,然后根据折半查找的思想进行查找。

定义

二叉排序树:

  1. 空树
  2. 具有如下性质的树(注意体会递归的思想):
  • 若它的左子树不空,则左子树上所有结点的值均小于根节点的值
  • 若它的右子树不空,则右子树上所有结点的值均大于根结点的值
  • 它的左右子树也分别都是二叉排序树
    例如:
    二叉排序树
    显然,当我们对二叉排序树进行中序递归时,就可以得到有序数表。

通常,可取二叉链表作为二叉排序树的结点存储结构。

template<class T>
class BiNode
{
public:
    T data;
    BiNode<T> *lch;
    BiNode<T> *rch;
    BiNode():lch(NULL),rch(NULL){};   //构造函数
}

建立

基本思路:

  1. 若当前节点=NULL,直接插入
  2. 否则将给定值与当前结点进行比较
    2.1. 若key<当前节点,与其左孩子继续比较
    2.2 .否则与其右孩子进行比较
    反复执行,直到插入key

插入元素
插入
举个栗子(所有元素均成功插入):
建立
有兴趣的朋友可以看一下这个网站:数据可视化工具better
里面有各种动态的二叉树建立过程,也有很多其他逻辑结构的相关算法。

代码实现

二叉排序树的存储结构

template<class T>
calss BST
{
private:
    BiNode<T> *Root;  //根结点
public:
    BST(T r[],int n);  //构造函数,创建二叉排序树
    BiNode<T>*Search(BiNode<T>*R,T key);  //查找关键字key
    void InsertBST(BiNode<T> *&R,BiNode<T>*s);  //插入结点
    void Delete(BiNode<T> *&R);  //删除结点
    bool DeteteBST(BiNode<T>*&R,T key);  //根据关键字key删除指定结点
    ~BST(); //析构函数
}

插入元素

template<class T>
void BST<T>::InsertBST(BiNode<T>*&R,BiNode *s)
//R为二叉排序树的根节点,s为待插入的新结点
{
    if(R == NULL)  R = s;  //插入R的位置
    else if(s->data < R->data) 
        InsertBST(R->lch,s);  //在左子树中插入
    else
        InsertBST(R->rch,s);  //在右子树中插入
}

注意:
InsertBST算法的第一个参数类型为 *& 即指针的引用,其目的有两个:一,作为输入时,即把指针的值传递到了函数内部,又可以将指针的关系传递到函数内部;二,作为输出时,由于算法修改了指针R的值,可以将R的新值传递到函数外部。
一般情况下,若函数内部修改了指针本身的值(不是指针指向的地址的内容),则需要将该指针的参数设置为指针的引用 *& 。

二叉排序树的建立过程,就是把序列元素依次插入的过程

template<calss T>BST<T>::BST(T r[],int n)
{
    Root = NULL;
    for(int i=0;i<n;i++)
        {
            BiNode<T>*s = new BiNode<T>;  //创建新结点
            s->data = r[i];
            s->lch = s->rch = NULL;
            InsertBST(Root,s);  //插入
        }   
}

删除

和插入相反,删除在查找成功以后进行,并且要求在删除二叉排序树上的某个结点后,仍然保持二叉排序树的特性。

删除结点的三种情况:
被删除的结点是叶结点(最简单)

方法:delete指向该叶结点的指针;父结点对应的指针置空
删除叶结点

被删除的结点只有左子树或只有右子树

方法:被删除结点的双亲指向被删除结点的孩子,随后delete即可
在这里插入图片描述

被删除的结点既有右子树也有左子树(最复杂)

为了解决这一问题,我们又遇到了化繁为简的思想,只需要将这种情况转化为前两种情况即可。

算法分析:

  1. 中序遍历得到悲删除结点p的前驱结点q(q是p的左子树最右下结点),则q必为单分支结点或叶结点(总之,q的右指针必为空)
  2. 将q的值赋给p的值域(不必更改q的值域)
  3. 将删除p的操作转换为删除q的操作
    在这里插入图片描述
代码实现

删除算法就两步:已知key,查找对应结点,判断类型;调用Delete()函数
第一步,递归查找

template<class T>
bool BST<T>::DeleteBST(BiNode<T> *&R, T key)
//R是二叉排序树的根结点,key是关键字
{
    if(R == NULL)  return false;  //查找失败
    else 
    {
        if(key == R->data)
        {
            Delete(R);  //找到域key匹配的结点,删除
            return true;
        }
        else if(key < R->data)
            return DeleteBST(R->lch,key);  //在左子树查找
        else
            return DeleteBST(R->rch,key);  //在右子树查找
    }
}

第二步,删除已知结点R

template<class T>
void BST<T>::Delete(BiNode<T> *&R)
{
    BiNode<T> *q,*s;
    if(R->lch == NULL)  //只有右子树,删除叶子结点包含在这种情况中
    {
        q = R;
        R = R->rch;
        delete q;
    }
    else if(R->rch ++ NULL)  //只有左子树
    {
        q = R;
        R = R->lch;
        delete q;
    }
    else  //左右子树都有
    {
        q = R;
        s = R->rch;
        while(s->rch != NULL)
        {//使s指向R的前驱
            q = s;
            s = s->rch;
        }
        R -> data = s->data;  //替换数值
        if(q != R)
            q->rch = s->rch;  //s是q的右孩子
        else
            R->rch = s->rch;  //q=R 表示s是R的左孩子
        delete s;
    }
}

Delete()函数采用 *& 类型传递指针,大大简化了删除算法。这是由于调用Delete函数时,传递参数R,不仅将R的值传给了Delete()函数,而且将指针R与它的左右孩子的对应关系传递给了Delete()函数,因此“R = R->rch” 就相当于直接给R的右孩子赋值。

查找

算法性能分析:
对于每一棵特定的二叉排序树,均可按照平均查找长度的定义来求它的ASL值,显然,由值相同的n个关键字,构造所得的不同形态的每个二叉排序树的ASL是不同的,甚至可能差别相当大。
在这里插入图片描述
为什么?
因为二叉排序树的结构不一定是平衡的,例如:值相同的一个左斜树和一个结构相当平衡的二叉树,显然,后者ASL更小。当二叉排序树结构比较稳定、结点数又比较多时,它的查找性能就接近于折半查找了。

template<class T>
BiNode<T>*BST<T>::Search(BiNode *R,T key)
{
    if(R == NULL)  return NULL;  //查找失败
    if(key == R->data)  return R;
    else if(key < R->data)  return Search(R->lch,key);
    else  return Search(R->rch,key);
}

平衡二叉树(AVL)

为了进一步优化查找效率,使二叉排序树的结构更加平衡,平衡二叉树应运而生。

定义

平衡二叉树:

  1. 空树
  2. 具有如下性质的树:
  • 左右子树都是平衡二叉树
  • 左右子树高度值差的绝对值小于等于1
    如果在建立二叉排序树时,保证其为平衡二叉树,则可避免查找的时间复杂度从O(2logn)退化成O(n)
    (对于平衡二叉树,此处不再详细讲解,有兴趣的朋友可以看看这篇文章:平衡二叉树(AVL)图解与实现)

散列查找

散列查找的效率非常高!举个栗子,索引查找。

散列技术

什么是查找?
确定关键码=给定值的记录在集合中的存储位置。由于存储位置与关键码之间不存在确定的对应关系,因此,查找时必须通过一系列与关键码的比较。
理想情况:
在记录的存储位置与其关键码之间建立一个确定的对应关系H,使得每个关键码key和唯一的一个存储位置H(key)对应。
这就是散列技术,采用散列技术将记录存储在一块连续的存储空间中,就是散列表。
散列过程:

  1. 存储记录,通过H(key)计算记录的散列地址,并按此地址存储记录
  2. 查找记录,通过同样的H(key)计算记录的散列地址,按此地址访问该纪录。
    P.S.散列不能表达记录之间的逻辑关系,所以是不完整的存储结构,是主要面向查找的存储结构。

散列函数设计

如何确定所需的哈希函数呢?这就是我们下面要讨论的散列函数的设计问题。

直接定址法

哈希函数: H(key)=a*key+b
特点: 计算简单,没有冲突,适合关键码分布比较连续的情况,否则会浪费大量空间。实际意义不大。

举个栗子:
直接定址法

除留余数法

哈希函数: H(key)=key%p (p<m) m为散列表长度,p最好为素数或不包含小于20的质因数的合数。
特点: 计算机简单,使用范围广。

举个栗子:
在这里插入图片描述
反思: 一定能找到不会引起冲突的p吗?
答案是不一定,这就是为什么要求p最好是是质数了。

冲突处理

但实际情况中,我们可能无法找到符合条件的完美哈希函数,会有 key2 != key2 但是 H(key1) == H(key2) 这样的冲突产生。
冲突处理的实际含义: 为产生冲突的地址寻找下一个哈希地址。

下面介绍三种方法

开放定址法

未产生冲突的地址H(key)按照某种规则产生另一个地址。
有三种方法:

线性探测法

Hi = (H(key) + di) MOD m
di = c*i
最简单的情况:c = 1(冲突+1再取模)
线性
产生冲突的部分需要按照规则多查找两三次

平方探测法

Hi = (H(key) + di) MOD m
di = 1^2, -1^2, 2^2, -1^2, ……
平方

随机探测法

Hi = (H(key) + di) MOD m
di是一组伪随机数,或者 di = i*H2(key)【又称双散列函数探测】
比如:3、1、9、2
随机

链地址法(拉链法)

基本思想: 将所有散列地址相同的记录都存储在一个单链表中–同义词子表,三裂变存储所有同义词的头指针。
拉链法
这种方法思路十分简单,对于数据量不是很大的情况,使用起来也非常方便.只是要注意建立链表时的方法(头插法或尾插法)会影响遍历顺序。

建立公共溢出区

基本思想: 散列表包含基本表和溢出表两个部分,将发生冲突的记录存储在溢出表中。
查找方法: 通过H(key)函数计算散列地址,先与基本表中记录进行比较,若相等,则查找成功,否则,到溢出表顺序查找。
这种方法跟第二种一比就比较麻烦了。
公共溢出法

散列查找的性能分析

性能分析: 散列技术中,处理冲突的方法不同,得到的散列表不同,散列表的查找性能也不同。
决定性能的因素: 比较次数取决于发生冲突的概率,产生的冲突越多,查找效率就越低。
举个栗子:
性能分析
影响冲突的因素:

  1. 散列函数是否均匀
  2. 处理冲突的方法
  3. 散列函数的填装因子a在这里插入图片描述
    a越大,代表填入表中的记录越多,产生冲突的可能性就越大。

后面会出有关查找算法实例的新文章(尤其是二叉排序树)
如果对上述内容有疑问,欢迎大家评论或私聊。
一起学习,一起进步~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值