数据结构——查找篇
查找的基本概念
- 査找:根据给定的关键字的值,检索某个与该值相等的数据元素是否在查找表中找到为查找成功,找不到为查找失败
- 查找表: 是由类型相同的数据元素(或记录)的集合。
- 关键字: 是数据元素(或记录)中某个数据项的值,用它可以标识一个数据元素。
- 主关键字: 此关键字可以唯一地标识一个记录
- 次关键字: 用以识别若干记录的关键字
- 查找成功: 表中存在这样一个记录
- 查找失败:表中不存在关键字等于给定值的记录
- 动态查找表: 在查找的同时对表进行修改操作(如插入,删除)。反之称为 静态查找表
- 平均查找长度(ASL):为确定数据元素在査找表中的位置,需和给定值进行比较的关键字个数的期望值,称为查找算法在查找成功时的平均查找长度.
- 冲突:两个不同的关键字,其散列函数值相同,因而被映射到同一表位置的现象称为冲突
线性表的查找
适合静态查找,主要采用顺序查找技术,折半查找技术(二分查找)。
顺序查找
顺序查找: 查找过程为从表的一端开始,依次将记录的关键字和给定值进行比较,若某个记录的关键字和给定值相等,则查找成功。
即适用于线性表的顺序存储结构,和 链式存储结构。
时间复杂度: O(n)
// 设置监视哨的顺序查找
int Search_Seq(SSTable T, int key)
{ // 在顺序表T中顺序查找其关键字等于key的数据元素。若找到,则函数值为该元素在表中的位置,否则为0
T.e[0].key = key; // 在表中的零位置设置哨兵
int i=0;
for ( i = T.length; T.e[i].key != key; --i); // 从后往前找
return i+1;
}
ASL=n+1/2
顺序查找的优点:
-
算法简单
-
对表结构无任何要求
-
无论记录是否按关键字有序均可
缺点:
- 平均查找长度较大
- 查找效率较低
折半查找
时间复杂度: O(log2^n)
折半查找 也叫 二分查找,它是一种效率较高的查找方法。
但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。不适用于数据元素经常变动的线性表
// mid 中间值 key待查找的值 high 尾值 low 头值
mid = (low + high)/2;
key<mid 则 high = mid-1;
key>mid 则 low = mid+1;
key==mid,找到;
hight<low,结束;
int Search(SSTable T, int key)
{
// 在有序表T中折半查找其关键字等于key的数据元素。若找到,则函数值为该元素在表中的位置,否则为0
int low = 1;
int height = T.length-1; // 置查找区初值
int mid;
while (low<=height)
{
mid = (low + height) / 2;
if (key == T.e[mid].key) return mid; //找到待查元素
else if (key < mid) // 小于中间值 , height提前
height = mid - 1;
else if (key > mid) // 大于中间值 low 往后
low = mid + 1;
}
return 0;
}
ASL=log2^(n+1)-1
优点:比较次数少,查找效率高
缺点: 对表结构要求高,只能用于顺序存储的有序表
算法分析 折半查找过程可用二叉树描述。树中每一结点对应表中一个记录,但结点值不是记录的关键字,而是记录在表中的位置序号。把当前查找区间的中间位置作为根,左子表和右子表分别作为根的左子树和右子树,由此得到的二叉树称为折半查找的
判断树
查找成功:
比较次数= 路径上的结点数
比较次数= 结点的 层数
比较次数<= 树的深度([log2^n]+1——完全二叉树的深度)
查找不成功:
比较次数=路径上的内部结点数
比较次数<= 树的深度([log2^n]+1——完全二叉树的深度)
折半查找判定树的最后一层的节点数为 n-2^(h-1)+1,n=100,h=7,代入得37。
分块查找
又称 索引顺序查找,是一种性能介于顺序查找和折半查找之间的查找方式。
此查找法中,除表本身之外,尚需建立一个“索引表”。把原表分为几个子表,对每个子表(或称块)建立一个索引项,其中内容包括两项:关键字项(其值为子表内最大关键字)和指针项(指示该子表的第一个记录在表中位置)。
索引表按关键字有序,则表或者有序或分块有序 分块有序即,第二个子表中所有记录的关键字均大于第一个子表中的最大关键字,以此类推
查找过程: 先确定待查找记录所在块(顺序或折半查找),再在块内查找(顺序查找)。
ASL= log2^(n/s+1)+s/2
优点:
- 在表中插入和删除元素时,只要找到该元素对应的块,就可以在该表进行插入和删除运算。
由于块内是无序的,故插入和删除比较容易,无需进行大量移动。
(如果线性表即要快速查找又经常动态变化,则可采用分块查找)
缺点: 要增加一个索引表的存储空间并初始索引表进行排序运算。
线性表查找方法比较
顺序查找 | 折半查找 | 分块查找 | |
---|---|---|---|
ASL | 最大 | 最小 | 适中 |
结构 | 有序表,无序表都行 | 有序表 | 分块有序 |
存储结构 | 顺序存储结构,链式存储结构都可 | 必须采用顺序存储结构 | 顺序存储结构,链式存储结构都可 |
树表的查找
适用于动态查找,主要采用二叉排序树的查找技术
二叉排序树
二叉排序树,又称二叉查找树 ,它是一种对排序和查找都有用的特殊二叉树。
定义:
二叉排序树或是一棵空树,或者是具有以下性质的二叉树
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
- 它的左,右子树也分别为二叉排序树。
二叉排序树是递归定义的。由定义可以得出二叉排序树的一个重要性质: 中序遍历一棵二叉排序树可以得到一个结点值 递增的有序序列。
二叉排序树的形态和折半查找的判定树相似
,其平均查找长度和log2^n成正比。
可见,二叉排序树上的查找是和折半查找相差不大,
但就维护表的有序性而言,二叉排序树更加有效,只需修改指针即可完成对结点的插入和删除操作。
二叉排序树适合经常进行插入,删除和查找运算的表。
// 二叉排序树的存储结构
typedef struct
{
int key; // 关键字
}elemt;
typedef struct _BSTNode
{
elemt data; // 数据域
struct _BSTNode* lchild, * rchild;
}BSTNode,*BSTree;
// 二叉排序树的插入 ——————————————————————————————————————————————————————————————————————
/*
分析: 若二叉排序树为空树,则新插入的结点为新的根结点,否则,
新插入的结点必为一个新的叶子节点,其插入位置由查找过程得到。
*/
// 插入一个结点的时间复杂度为O(log2n)
void InsertBST(BSTree* T, int key)
{ // 当二叉排序树T中不存在关键字等于key的数据元素时,则插入该元素
if (*T == NULL)
{ // 找到插入位置,递归结束
*T = (BSTree)malloc(sizeof(BSTNode)); // 生成新结点
(*T)->data.key = key; // 新结点*s的数据域置为key
(*T)->lchild = (*T)->rchild = NULL; // 新结点作为叶子节点
}
else if (key < (*T)->data.key) // key比根结点的值小,则插入左子树
InsertBST(&(*T)->lchild, key);
else if (key > (*T)->data.key) // key比根结点的值大,则插入右子树
InsertBST(&(*T)->rchild, key);
}
void creatBST(BSTree* T) // 时间复杂度为O(nlog2n)
{ // 依次读入一个关键字为key的结点,将此结点插入二叉排序树T中
(*T) = NULL; // 将二叉排序树T初始化为空树
int key;
scanf("%d", &key);
while (key!=0) // key为0时,结束插入
{
InsertBST(T, key); // 将此结点插入二叉排序树T中
scanf("%d", &key);
}
}
// ——————————————————————————————————————————————————————————————————————————————————————
// 中序遍历
void Inorder(BSTree T)
{
if (T)
{
Inorder(T->lchild);
printf("%d ", T->data.key);
Inorder(T->rchild);
}
}
// 二叉排序树的查找 -----------------------------------------------------------------------
SearchBST(BSTree T,int key)
{ // 在根指针T所指二叉排序树中递归地查找某关键字等于key的数据元素
// 若存在成功,则返回指向该数据结点的指针,否则返回0
if (T == NULL) return 0;
if (key == T->data.key) return T->data.key; // 找到了,查找结束
else if (key > T->data.key) // key比根结点的值大,在右子树查找
return SearchBST(T->rchild, key);
else if (key < T->data.key) // key比根结点的值小,在左子树查找
return SearchBST(T->lchild, key);
}
// --------------------------------------------------------------------------------------
//-删除结点--------------------------------------------------------------------------------
void deletNode(BsTree* T)
{
if ((*T)->lchild == NULL && (*T)->rchild == NULL) // 叶子节点 如果该节点既没有左子树也没有右子树
{
BsTree p = (*T);
(*T) = NULL;
free(p);
}
else if ((*T)->lchild == NULL) // 左子树为空
{
BsTree p = (*T);
(*T) = (*T)->rchild; // 将删除点的右子树连接到其双亲结点
free(p);
}
else if ((*T)->rchild == NULL) //子树为空
{
BsTree p = (*T);
(*T) = (*T)->lchild; // 将删除点的左子树连接到其双亲结点
free(p);
}
else { // 左右子树均不为空
BsTree parent = (*T); // 保持要删除节点
BsTree pre = (*T)->lchild; // 找其左子树的最大结点替代要删除结点
while (pre->rchild) // 转左,左子树的右子树尽头就是最大结点
{
parent = pre;
pre = pre->rchild;
}
(*T)->data.key= pre->data.key; // pre指向要删除结点的前驱,替换(*T)数据
if (parent != (*T)) // 判定是否执行了上面的while循环 如果存在最大右子树结点则 替换后将该右子树结点的左子树放到其双亲上
parent->rchild = pre->lchild; // 执行了,重接pre右子树
else
parent->lchild = pre->lchild; // 未执行,重接pre左子树
free(pre);
}
}
// 删除结点
void deletBS(BsTree* T, int key)
{
if (*T == NULL) return 0;
if ((*T)->data.key == key) deletNode(T); // 找到关键词,删除一个结点
else if (key < (*T)->data.key) deletBS(&(*T)->lchild, key);
else if (key > (*T)->data.key) deletBS(&(*T)->rchild, key);
}
// --------------------------------------------------------------------------------------
int main()
{
BSTree T;
creatBST(&T);
Inorder(T);
printf("%d",SearchBST(T, 51));
return 0;
}
// eg: 63 90 70 55 58 0
// eg: 38 12 34 56 13 6 98 3 17 40 78
/*
叶子节点40
删除34 只有左子树
删除13 只有右子树
根节点38 左右子树都有 while
删除12 左右子树都有 不while
不存在结点4
*/
二叉排序树插入的小结:
基本过程是查找,是时间复杂度同查找一样 O(log2^n)
- 一个无序序列可以通过构造一棵二叉排序树而变成一个有序序列
- 每次插入的新结点都是二叉排序树上新的叶子结点;
- 找到插入位置后,不必移动其它结点,仅需修改每个结点的指针;
- 在左子树/右子树的查找过程与在整棵树上查找过程相同;
- 新插入的结点没有破坏原有结点之间的关系。
二叉排序树的查找效率在于只需查找二分子树之一。 O(log2^n)
二叉排序树的查找性能取决于二叉排序树的形状(而二叉排序树的形状取决于其数据集)
在O(log2n)和O(n)之间。
二叉排序树的 删除 O(log2^n)
同二叉排序树插入一样,二叉排序树删除的基本过程也是查找。
在二叉排序树上删除某个结点之后,仍然保持二叉排序树的特性。
分三种情况讨论:
- 被删除的结点是叶子;
- 操作: 将双亲结点中相应指针域的值改为空。
- 被删除的结点只有左子树或者右子树;
- 操作: 将双亲结点的相应指针域的值指向被删除结点的左子树(或右子树)。
- 被删除的结点既有左子树,也有右子树。、
- 操作: 以其左子树中最大值结点(或右子树中的最小结点)替代之,然后再删除该结点
平衡二叉排序树 (AVL)
一种特殊类型的二叉排序树
平衡二叉树或是空树,或者是具有如下特征的二叉排序树:
- 根节点的左子树和右子树的深度之差的绝对值小于等于1;
- 根节点的左子树和右子树也是平衡二叉树。
若将二叉树上结点的 平衡因子: 该结点的左子树的深度和右子树的深度之差,
平衡二叉树上所以结点的平衡因子只能是-1,0,1. 只要一个超过1,则该二叉树失衡。
最小不平衡子树: 在平衡二叉树的构造过程中,以距离 插入结点最近的,且平衡因子的绝对值大于1的结点为**根(问题发现者)**的子树
构造平衡二叉树的基本思想: 每插入一个结点:
- 从插入结点开始向上计算各结点的平衡因子,如果某结点平衡因子的绝对值超过1,则说明插入操作破坏了二叉树的平衡性,需要进行平衡调整;否则继续执行插入操作。
- 如果二叉树不平衡,则找出最小不平衡子树的根结点,根据新插入结点域最小不平衡子树根结点之间的关系判断调整类型。
- 根据调整类型进行相应的调整,使之成为新的平衡子树。
对子树进行平衡调整的四种情况
- LL型
事件发现者的左子树的左子树是事件发生者
- 向顺时针方向旋转。 (扁担原则)
- RR型
事件发现者的右子树的右子树是事件发生者
- 向逆时针方向旋转
- LR型
事件发现者的左子树的右子树是事件发生者
- 左子树先逆时针旋转(变LL型),再整体顺时针旋转。
- **RL型 **
事件发现者的右子树的左子树是事件发生者
- 右子树先顺时针旋转(变RR型),再整体逆时针旋转。
调整步骤:
每插入一个结点,
- 计算平衡因子(从插入结点开始向上计算)
- 判断平衡性
- 若不平衡,找到最小不平衡子树的根结点,
- 判断调整类型,
- 根据调整类型进行调整。 (从问题发现者的下一个结点开始操作)
散列表的查找
静态查找和动态查找均适用,主要采用散列技术
在各种查找方法中,平均查找长度与结点个数无关的查找方法是 散列查找
如果能在元素的存储位置和其关键字之间建立某种直接关系,那么在进行查找时,就无需做比较或很少次的比较,按照这种关系直接由关键字找到相应的记录 ,就是 散列查找法的思想,它通过对元素的关键字值进行某种运算,直接求出元素的地址,而不需要反复比较
。 散列查找法又叫杂凑法或散列法。
散列即是一种查找技术,也是一种存储技术。
散列技术一般不适用于允许多个记录有同样关键码的情况。散列方法也不适用于范围查找,换言之在散列表中,我们不可能找到最大或最小关键码的记录,也不可能找到某一范围内的记录。
术语:
- 散列表: 一个有限连续的地址空间,用以存储散列函数计算得到相应散列地址的数据记录。
通常散列表的存储空间是一个一维数组,散列地址是数组的下标。
- **散列函数: ** 将关键码映射为散列表中适当存储位置的函数。
- 散列地址: 由散列函数所得的存储地址
- 冲突 : 对不同的关键字可能得到同一地址,这种现象称为冲突。
- 同义词: 具有相同函数值的关键字对该散列函数来说称作同义词。
散列函数的构造方法
考虑的因素:
- 散列表的长度;
- 关键字的长度;
- 关键字的分布情况;
- 计算散列函数所需的时间;
- 记录的查找频率。
好的散列函数原则:
- 函数计算要简单,每一关键字只能有一个散列地址与之对应
- 函数的值域需在表长范围内,计算出的散列地址的分布均匀,尽可能减少冲突。
散列函数设计方法:
- 直接定址法:
- 除留余数法:
- 数字分析法:
-
平方取中法:
-
折叠法:
处理冲突
开放定址法
由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,并将记录存入
由开放地址法处理冲突得到的散列表叫 闭散列表。
-
线性探测法: 当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。
公式:
Hi=(H(key)+di)%m (di=1,2,……,m-1)
堆积: 在处理冲突的过程中出现的 非同义词之间对同一个散列地址争夺的现象。
-
二次探测法:
同一次探测类似但+的di不同
公式
Hi=(H(key)+di)%m (di=1^2,-1^2,2^2,-2^2,……,q^2,-q^2 且 q<=m/2)
-
随机探测法:
当发生冲突时,下一个散列地址的位移量是一个随机数列,即查找下一个散列地址的
公式是
Hi=(H(key)+di)%m (di是一个随机数列,i=1,2,……,m-1)
拉链法(链地址法)
适合表长不确定的情况
基本思想: 将所有散列地址相同的记录,即所有同义词记录存储在一个单链表中(称为同义词子表),在散列表中存储的是所有同义词子表的头指针。
用拉链法处理冲突构造的散列表叫做 开散列表。
开散列表不会出现堆积现象。
设n个记录存储在长度为m的散列表中,则同义词子表的平均长度为n/m.
性能分析
由于冲突的存在,产生冲突后的查找仍然是给定值与关键码进行比较的过程。
在查找过程中,关键码的比较次数取决于产生冲突的概率。影响冲突产生的因素有:
-
散列函数是否均匀
-
处理冲突的方法
-
散列表的装载因子
a=表中填入的记录数/散列表长度
产生堆积现象,即产生了冲突,它对存储效率,散列函数和装填因子均不会有影响,而平均查找长度会因为堆积现象而增大
注:本文仅为本人笔记,图片截自哔哩哔哩懒猫老师