第七章 查找
目录
7.1 基本概念
- 查找:在数据集合中寻找满足条件的数据元素
- 查找表:用于查找的数据结合称之为查找表
- 静态查找表:如果一个查找表只涉及到检索操作,而不涉及删除和插入操作,则为静态查找表。
- 关键字:数据元素中唯一标识该元素的某个数据项的值,比如学生的学号
- 平均查找长度ASL:指的是一次查找需要比较的关键字的次数, 算法性能的关键
7.2 顺序查找和折半查找
一、顺序查找
最直观的查找方法,其基本思想是从线性表的一端开始,逐个检查关键字是否满足给定条件。
在顺序查找中会使用哨兵。哨兵指的是将存储结构的第0个赋值为需要查询的值,然后从后往前查找,如果返回的位置为0,则证明没有所需的元素。使用哨兵可以减少判断语句从而提高效率,哨兵是一种用途广泛的概念,不只是用于顺序查找
A
S
L
成
功
=
1
n
+
1
ASL_{成功}=\frac{1}{n+1}
ASL成功=n+11,
A
S
L
失
败
=
n
+
1
ASL_{失败}=n+1
ASL失败=n+1
ASL的数量级为O(n),
有序表的顺序查找
在有序表中,判断查找是否失败相对比较方便。如果发现第i个元素小于关键字,但是第i+1个元素却大于关键字,则可以认为表中不存在关键字,查找失败。
其中圆形结点为成功结点,方形结点为失败结点。一个成功节点的查找长度为自身所在的层数,一个失败节点的查找长度为其父节点所在的层数
A S L 失 败 = n 2 + n n + 1 ASL_{失败}=\frac{n}{2}+\frac{n}{n+1} ASL失败=2n+n+1n
根据被查概率概率降序排列
可以缩短查找成功时的ASL,但是查找失败时必须遍历一整个表,因此失败时ASL比有序表大
二、折半查找
折半查找又称为二分查找,只适用于有序的顺序表。
其特点是将顺序表划分为一颗区间二叉树。该算法只适用于顺序存储结构,不适用于链式存储结构,并且要求元素关键字有序排列。
平均时间复杂度为O(log2n)。但是折半查找的速度不一定比顺序查找快
查找判定树
二分查找的过程可以构造出一个查找判定树
当low和high之间有奇数个元素,则mid分割后,左右两部分元素相等;
当low和high之间有偶数个元素,则mid分割后,左半部分比右半部分少一个元素
因此,在判定树中,如果 m i d = ⌊ ( l o w + h i g h ) / 2 ⌋ mid=\lfloor(low+high)/2\rfloor mid=⌊(low+high)/2⌋,则右子树结点数-左子树结点数=0或者1;,如果 m i d = ⌈ ( l o w + h i g h ) / 2 ⌉ mid=\lceil(low+high)/2\rceil mid=⌈(low+high)/2⌉,则左子树结点数-右子树结点数=0或者1
折半查找判定树一定是平衡二叉树,并且只有最下面一层是不满的,因此元素个数为n的时候树高为 ⌈ l o g 2 ( n + 1 ) ⌉ \lceil log_2(n+1)\rceil ⌈log2(n+1)⌉。n个元素的判定树的失败节点有n+1个
三、分块查找
分块查找又称为索引顺序查找,在408中较少考察代码
分块查找基本思想:将查找表划分为若干个字快,块内元素可以无序,但是块之间是有序的。再建立一个索引表,索引表中的每个元素含有各块的最大关键字和第一个元素的地址,索引表按关键字有序排列。
分块查找的过程分为两步:一个是在索引表中确定待查关键字所在的块,可以使用顺序或者折半查找索引表;定位好了所在块后再在块内寻找。
在使用折半查找查找索引表时,如果索引表中不包括关键字,那么折半查找索引表会最终停到low>high的位置。如果是一般的折半查找,此时意味着查找失败,而对于折半查找索引表来说,此时意味着需要查找的元素可能位于low指针指向的索引的分块中,需要进入该分块进行查找。
也就是说,如果索引表中不包括关键字,那么折半查找需要一直运行到low>high的时候才能确定位于哪个分块
查找效率分析(ASL)
需要能够具体说出在采用顺序/折半查找索引表时,查找到某个具体元素所需要的查找次数。一般是查找索引表的次数+查找分块内元素的次数
假设长度为n的查找表被均匀分成了b块,每块有s个元素,则平均长度为:
A
S
L
=
L
I
+
L
S
ASL=L_I+L_S
ASL=LI+LS,其中如果采用顺序查找查索引表的话,查找索引长度
L
I
=
b
+
1
2
L_I=\frac{b+1}{2}
LI=2b+1, 查找分块长度
L
S
=
s
+
1
2
L_S=\frac{s+1}{2}
LS=2s+1。由于n=sb,因此将式子回代得:
A
S
L
=
b
+
1
2
+
s
+
1
2
=
1
2
s
+
1
+
n
2
s
ASL=\frac{b+1}{2}+\frac{s+1}{2}=\frac{1}{2}s+1+\frac{n}{2s}
ASL=2b+1+2s+1=21s+1+2sn
通过求导可知道,当
s
=
n
s=\sqrt n
s=n的时候,ASL最小
折半查找的ASL是:
A
S
L
=
⌈
l
o
g
2
(
b
+
1
)
⌉
+
s
+
1
2
ASL=\lceil log_2(b+1)\rceil+\frac{s+1}{2}
ASL=⌈log2(b+1)⌉+2s+1
7.3 树形查找
一、二叉排序树(BST)
定义
左子树上的结点都小于根节点、所有右子树都大于根节点
使用中序遍历可以得到一个递增的有序序列
查找具体值的代码实现
从根节点开始,如果小于根节点则往左走,大于根节点则往右走,其路径是不会出现分叉的,也就是说,对BST的查找是不会出现回溯的
// 查找二叉排序树具体结点
BiTree *BST_Search(BiTree t, int v){
while (t!=NULL){
if (t.data>v){
t=t.rchild;
}else if (t.data < v){
t = t.rchild;
}else{
return t;
}
}
return t;
}
// 查找二叉排序树具体结点(递归)
BiTree *BST_Search1(BiTree t, int v){
if (t==NULL || t.data==v){
return t;
}
if (t.data < v){
BST_Search1(t.rchild,v);
}else{
BST_Search1(t.lchild, v);
}
}
非递归的空间复杂度为O(1),递归的空间复杂度为O(n)
在BST的查找中,如果生成的BST平衡,那么其最大查找次数最小,为
l
o
g
2
n
+
1
log_2n +1
log2n+1;如果生成的BST及其不平衡,比如生成BST时的元素插入顺序恰好是递增或递减,那么最大查找次数最大,为n
插入
如果二叉树为空,则直接插入,否则,如果关键字k小于根节点,则插入左子树,如果关键字大于根节点,则插入右节点。新插入的结点一定是叶子结点。采用递归调用的方法空间复杂度为O(H),H为树高;采用非递归的空间复杂度为O(1)
// 二叉排序树的插入(递归)
int BST_insert(BiTree &t, int v){
if (t==NULL){ //已经到了根节点
t = (BiTree)malloc(sizeof (BiTree));
t.data=v;
t.lchild=t.rchild=NULL;
return 1;
}else if (k<t.data){
BST_insert(t.lchild,v);
}else if (k>t.data)
t = BST_insert(t.rchild,v);
else
return 0
}
删除
- 叶子结点直接删除
- 删除的结点n只有左子树,那么让其子树的根节点替代他的位置,成为n的父节点的子树
- 同样,删除的结点n只有右子树,那么让其子树的根节点替代他的位置,成为n的父节点的子树
- 删除的结点n既有左子树又有右子树。那么:
- 可以中序遍历右子树,其第一个节点p为右子树中最小的结点(也就是右子树中最左下的结点),让结点p替代结点n的位置
- 可以遍历左子树,取得左子树中最小的结点p(也就是左子树中最右下的结点),让结点p替代结点n的位置
效率分析
查找长度:在查找运算中,需要对比关键字的次数反映了查找操作时间的复杂度。平均查找长度又称为ASL。需要会算二叉排序树的平均查找长度。查找失败所需要的平均长度也要会计算,一般只有查找到叶子结点仍未查找到目标值,则判定为查找失败
二叉排序树的ASL的优劣很大情况下取决于树的高度,因此树的高度越小,性能越优秀。而构造二叉树的输入序列有序会导致一个倾斜的单支树,此时BST高度最高,性能最差。如果构造出来的是一颗平衡二叉树,则性能最好。其最好查找长度为O(logn),最坏查找长度为O(n)
平衡二叉树
平衡二叉树AVL是二叉排序树的一种特殊形式,在AVL中,任意结点的左子树和右子树的差不能超过1。
平衡因子:节点的左子树高-右子树高。AVL中平衡因子只可能是0\1-1三种情况。
平衡二叉树的插入
平衡二叉树的插入会使得二叉树失去平衡。由于对于AVL的插入都是插入叶子结点(因为AVL是一种二叉排序树),因此可以通过调整最小不平衡子树来使得AVL恢复平衡,
想要找到最小不平衡子树需要利用平衡因子。从插入节点处开始向上回溯,找到第一个平衡因子不是0、1或-1的结点。则以该节点为根节点的树为最小不平衡树
在导致AVL失衡插入操作中,有如下四种情况:
LL平衡旋转(右单旋转)
先判断图中各个节点的大小关系:BL<B<BR<A<AR
在结点A的左孩子的左子树上插入了新节点,导致以A为根的子树失去了平衡,需要进行一次向右旋转的操作。将A的最孩子B向右旋转代替A成文根节点,而A成为B的子节点,并且将B原来的右子树变成了A的左子树。旋转完成后依旧符合二叉排序树的性质
代码思路:
假设指针p指向B,指针f指向A,指针gf指向A的父节点
f->lchild=p->rchild;
p->rchild=f;
gf->lchild/rchild=p;
RR平衡旋转(左单旋转)
先判断图中各个节点的大小关系:AL<A<BL<B<BR
在结点A的右孩子的右子树上插入了新节点,导致以A为根的子树失去了平衡,需要进行一次向左旋转的操作。将A的右孩子B向右旋转代替A成为根节点,而A成为B的子节点,并且将B原来的左子树变成了A的右子树旋转完成后依旧符合二叉排序树的性质
LR平衡旋转(先左旋后右旋 )
先判断图中结点的大小:BL<B<CL<C<CR<A<AR
在A的左孩子的右子树上插入了新节点,导致以A为根的子树失去了平衡,需要先对C进行左旋,代替B的根节点的位置,而C的左子树称为B的右子树。然后右旋转C,代替A的根节点的位置,而C的右子树变成A的右子树。旋转完成后依旧符合二叉排序树的性质
RL平衡旋转
在A的左孩子的右子树上插入新节点,导致以A为根的子树失去平衡,需要先对C进行右旋,代替B的根节点位置,再进行左旋,代替A的根节点位置
删除
查找效率
主要影响因素来自于树的高度,也就是查找操作的时间复杂度不可能超过O(h)
假设以nh表示深度为h的平衡树中含有最少的结点数。则有n0=0,n1=1,n2=2,并且nh=nh-1+nh-2+1。平衡二叉树的平均高度为O(log2n),因此的平均查找长度AVL为O(log2n)
二、B树
考试侧重于考察性质、效率等
B树是一种多路平衡查找树。他每个节点最多有n个子树,而每一个节点是一个n-1的列表,比如根节点为10,20,30的列表,那么第一个子节点指向的是值小于10的列表,第二个子节点指向的是值位于10到20的列表,以此类推。其子节点中的元素也是有序的。另外,除了根节点之外所有非叶节点至少有m/2棵子树。 根节点都是没有数据的空节点,是失败节点。
如果每个节点内关键字太少,导致树变高,要查更多层节点,效率会下降,因此在B树中规定除根节点外每个节点至少有 ⌈ n / 2 ⌉ − 1 \lceil n/2\rceil-1 ⌈n/2⌉−1个元素和 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉颗子树,以提高效率。另外,n叉查找树越平衡,其查找效率也越高,因此B树规定,对于任意一个节点其所有子树高度都相同。
性质:
在一棵m阶的B树中:
- 树中每个节点最多有m棵子树,最多有m-1个关键字
- 如果根节点不是终端节点,则至少还有两棵子树
- 除根节点外每个节点至少有 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉个子树和 ⌈ n / 2 ⌉ − 1 \lceil n/2\rceil-1 ⌈n/2⌉−1个关键字
- 所有叶节点都出现在同一层次上,为失败结点,不携带任何信息
- 对于任意一个节点其所有子树高度都相同。
计算B树高度不会包括失败节点
含有n个关键字的m阶B树的最大、最小高度是多少
- 最小高度要求让每个节点尽可能的多关键字,也就是有m-1个关键字和m个分叉,则有 n ≤ ( m − 1 ) ( 1 + m + m 2 + m 3 + . . . + m h − 1 ) = m h − 1 n\leq (m-1)(1+m+m^2+m^3+...+m^{h-1})=m^h-1 n≤(m−1)(1+m+m2+m3+...+mh−1)=mh−1因此 h ≤ l o g m ( n + 1 ) h\leq log_m(n+1) h≤logm(n+1)
- 最大高度要求各层尽可能少,也就是根节点只有2个分叉,其他节点只有 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉个元素。因此至少有 2 ( ⌈ m / 2 ⌉ ) h − 1 2(\lceil m/2\rceil)^{h-1} 2(⌈m/2⌉)h−1个结点, 对于n个关键字的B树必然有n+1个叶子结点,则 n + 1 ≥ 2 ( ⌈ m / 2 ⌉ ) h − 1 n+1\geq 2(\lceil m/2\rceil)^{h-1} n+1≥2(⌈m/2⌉)h−1,可推出 h ≤ l o g ⌈ m / 2 ⌉ n + 1 2 + 1 h\leq log_{\lceil m/2\rceil}\frac{n+1}{2}+1 h≤log⌈m/2⌉2n+1+1
- 对于n个关键字的B树必然有n+1个叶子结点,这也会单独考
B树的插入
假设一个5叉B树,也就是每个节点有4个数据。如果插入新数据可能会使得当前节点内数据超过限制,此时需要进行节点的拆分,将一个节点拆分为一个父节点和两个相连的子节点。拆分会从中间的第
⌈
n
/
2
⌉
\lceil n/2\rceil
⌈n/2⌉个位置处拆分。
每次插入一定插入到最底层的节点,因为失败节点只能出现在最底层。在B树中,非根节点值必须有它对应的左右子树,不然会导致失败节点出现在上层,这违背了B树的定义。
在B树中非根节点中插入元素,并且导致其元素个数超出限制时,作以下变换
B树的删除
删除非终端节点
被删除关键字为非终端节点,则用直接前驱或者直接后继代替被删除的关键字。
直接前驱:当前关键字左侧指针指向的子树最右下的元素,也就是左子树中最大的节点
直接后继:当前关键字右侧指针指向的子树最左下的元素,也就是右子树中最小的节点
如下图中,删除80节点,那么可以选择其直接前驱77或者直接后继82来代替
也就是删除非终端节点的删除可以转化为对终端节点的删除
删除终端节点
只要删除后节点数依然不小于 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉,则都可以直接进行。
如果删除后节点数小于 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉:
- 如果它相邻的兄弟节点数仍多于n/2,则可以像兄弟借一个节点使用。一般是该节点的父节点借一个元素给该节点,而该节点的兄弟节点将其中一个元素移动到父节点中。如果是左孩子借元素给父节点,那么借出的结点是左孩子中最大的元素;如果是右孩子借元素给父节点,那么借出的结点是右孩子中最小的元素。
- 如果它的兄弟节点数小于n/2了,则可以将该节点和兄弟节点,以及其父节点中的关键字进行合并。合并过程中,双亲节点的关键字个数会减1。如果其双亲节点是根节点而且关键字个数减少到0了,则可以直接删除根节点。
三、B+树
一个m阶b+树满足:
- 每个分支节点最多有m棵子树
- 非叶根节点至少有2颗子树,其他每个分支节点至少有m/2棵子树。非叶根节点指的是叶节点再往上的一个节点。其他每个分支节点至少有 ⌈ n / 2 ⌉ \lceil n/2\rceil ⌈n/2⌉个子树
- 节点的子树与关键字个数相等,这是B+树和B树的区别
- 所有叶节点包含全部关键字以及指向相应记录的指针,叶节点中将关键字按大小排序,并且相邻叶节点按大小顺序相互连接
- 所有分支节点中只包含了她的各个子节点的最大值
B+树查找
无论查找成功与否,都需要走到最下面一层的节点。因为关键字都在叶子结点,非叶节点只是索引
B+树也可以通过指针p进行顺序查找
B树和B+树的区别
- B+树中n个关键字对应n个子树;B树中n个关键字对应n+1个子树
- B+树中,叶子节点包含所有关键字,非叶子节点的关键字也会在叶子节点中出现;B树中关键字不重复
- B+树中叶子节点包含信息,非叶子节点只起索引作用;B树中所有节点都包含信息。
- m阶B树的根节点关键字数为 [ 1 , m − 1 ] [1,m-1] [1,m−1],其他节点为 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2\rceil-1, m-1] [⌈m/2⌉−1,m−1];m阶B树的根节点关键字数为 [ 1 , m − 1 ] [1,m-1] [1,m−1],其他节点为 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] [\lceil m/2\rceil-1, m-1] [⌈m/2⌉−1,m−1]
在B+树中,非叶子节点不含有关关键字对应纪录的存储地址,可以使用一个磁盘块包含更多关键字,使得 B+树阶更大,树高更矮,读磁盘数更少,查找更快。如今比如MySQL等关系型数据库不少都采用了B+树。
四、散列(Hash)查找
散列表又称为哈希表,是一种数据结构,特点是数据元素的关键字与其存储地址直接相关。数据元素的值通过哈希函数处理后,就可以得出该元素应该存放的地址。
如果不同的关键字通过散列函数映射到了同一个值,则称他们为同义词
通过散列函数确定的位置已经存放了其他元素,这种情况称之为冲突
拉链法
拉链法处理冲突是将所有同义词存储在一个链表中
查找值为21的关键字,应该是21%13=8,所以查找长度为0
拉链法效率
需要会算查找成功和查找失败的平均ASL
冲突次数越多,则ASL越大,效率越低。
当完全没有冲突时,散列查找时间复杂度可达O(1)
装填因子
α
\alpha
α=表中记录数/散列表长度。装填因子的大小会影响到散列表的效率,因为装填得越多,发生冲突的几率越大
常见散列函数
除留余数法:H(key)=key%p。一般来说,如果散列表表长为m,则取不大于m但最接近于或等于m的质数p。因为取质数发生冲突的概率更小。
直接定址法:H(key)=key或者H(key)=a*key+b。这种方法计算最简单并且没有冲突,适用于关键字分布基本连续的情况。比如说一个班的学生的学号范围是2022-2222,那么就将这些学号一一对应放入长度为200的散列表中
平方取中法:取关键字的平方值的中间几位作为散列地址。具体取多少位要按照实际情况而定。这种方法得到的散列地址和关键字每一位都有关系,因此散列地址分布较为均匀,适用于关键字的每位取值都不够均匀的情况
开放定址法
开放定址法是处理冲突的一个方法。这种方法允许可存放新表项的空闲地址既向他的同义词表项开放,也向他的非同义词表项开放。它不像拉链法一样有链表跟随。
1.线性探测法
发生冲突的时候,一个一个往后探测相邻的下一个单元是否为空,如果有空单元则放入冲突的关键字。
查找失败时,需要一直向后查找直到出现第一个空元素。
直接删除一个元素,可能会导致其他的节点查找失败,因此删除可能不能直接移除元素,可以新增一个状态位用于标识该元素的有效性。在任何开放定址法中(包括平方探测法、再散列法),都不能直接删除元素。
要会算查找成功的ASL和查找失败的ASL
线性探测法很容易导致同义词和非同义词混杂在一起,严重影响查找效率。在对某个同义词进行查找的时候,还可能要遍历数个非同义词,查找精度低下,纯纯的弱智方法
2.平方探测法
有n个元素按照哈希函数运算后都需要放置到位置i中,按照数列[02, 12, -12, 22, 22, …n2]进行偏移存放,也就是第0个元素放在i处,第一个放在i+1处,第二个放在i-1处,第三个放在i+4处,以此类推
平方探测法比线性探测法不容易产生堆积问题,并且存储更有规律。
散列表长度必须表示成4j+3的素数才能探测所有位置。
3.再散列法
多准备几个散列函数,使用第一个散列函数发生冲突时再使用第二个散列函数,直到不冲突为止。
7.5 总结
算法名称 | 成功平均ASL | 失败ASL | 最坏ASL |
---|---|---|---|
顺序查找 | n + 1 2 \frac{n+1}{2} 2n+1 | n+1 | |
折半查找 | l o g 2 n log_2n log2n |