查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找。查找结果一般分为两种即,查找成功查找失败。
查找表(查找结构):用于查找的数据集合称为查找表,可以是一个数组或链表等数据类型
静态查找表:若一个查找表的操作仅涉及查询某种元素是否在表中或者检索满足某种特性的数据元素的各种属性,则称该表为静态查找表。(若存在查找插入操作以及删除操作等修改了表,则不是静态查找表,反之我们称为动态查找表)
关键字:数据元素中唯一表示该元素的某个数据项的值,使用基于关键字查找,查找结果应该是唯一的
平均查找长度:在查找过程中,一次查找长度是指需要比较的关键字次数,而平均查找长度则是所有查找过程中进行关键字比较次数的平均值,其数字定义为
A S L = ∑ i = 1 n P i C i ASL = \sum^n_{i = 1}P_iC_i ASL=i=1∑nPiCi
n — — 查 找 表 长 度 , P i 查 找 第 i 个 元 素 的 概 率 ( P i = 1 / n ) , C i 找 到 第 i 个 元 素 的 平 均 次 数 n——查找表长度,P_i查找第i个元素的概率(P_i = 1/n),C_i 找到第i个元素的平均次数 n——查找表长度,Pi查找第i个元素的概率(Pi=1/n),Ci找到第i个元素的平均次数
平均查找长度是衡量查找算法效率的最主要衡量指标。
顺序查找又称为线性查找。它对顺序表和链表都是适用的。顺序查找也分为一般线性表查找,以及有序表的顺序查找,数组是否有序对于顺序查找的平均查找无影响,但是对于不成功的查找长度则不同。
基本思想:
从线性表的一端开始,逐个检查关键字的是否满足给定的条件
int search(int *a, int len, int key){
for(int i = 0; i < len; i++){
if(a[i] == key){
return i; //查找成功返回下标
}
}
return 0; //失败返回 -1;
}
一般线性查找(无序):对于n个元素的表,给定值key与表中第i个元素相等,即定位第i个元素时,需进行n - i + 1 个关键字比较。即 C i = n − i + 1 C_i =n-i+1 Ci=n−i+1。查找成功时,顺序查找的平均长度为:
A S L 成 功 = ∑ i = 1 n P i ( n − i + 1 ) ASL_{成功} = \sum^n_{i = 1}P_i(n-i+1) ASL成功=i=1∑nPi(n−i+1)
当每个元素的查找概率相等,即 P i = 1 n P_i = \frac{1}{n} Pi=n1
A S L 成 功 = ∑ i = 1 n P i ( n − i + 1 ) = n + 1 2 ASL_{成功} = \sum^n_{i = 1}P_i(n-i+1) = \frac{n+1}{2} ASL成功=i=1∑nPi(n−i+1)=2n+1
在查找不成功的时候,与表中各关键字的比较次数显然是n+1次,所以顺序查找不成功的平均查找长度为 A S L 不 成 功 = n + 1 ASL_{不成功}= n+1 ASL不成功=n+1
有序表的顺序查找:成功的查找长度与无序的相同。因为有序,所以如果当我们当前所比较的关键字大于我们的key,且仍然未找到,则说明表中不存在我们需要的关键字,则可以提前终止查找,所以这与一般线性查找,遍历完整个线性表不同,可以提前终止。
A S L 不 成 功 = ∑ j = 1 n q j ( l j − 1 ) = 1 + 2 + 3 + 4 + . . . + n + n n + 1 = n 2 + n n + 1 ASL_{不成功}=\sum^n_{j = 1}q_j(l_j-1) = \frac{1+ 2 + 3 + 4+...+n+n}{n+1} = \frac{n}{2}+\frac{n}{n+1} ASL不成功=j=1∑nqj(lj−1)=n+11+2+3+4+...+n+n=2n+n+1n
式中 q j q_j qj是到达第j个失败节点的概率,在相等查找概率的情形下为1/( 1 + n )。 l j l_j lj是第j个失败节点所在的层数。当n = 6时, A S L 不 成 功 = 6 / 2 + 6 / 7 = 3.86 ASL_{不成功} = 6/2+6/7 = 3.86 ASL不成功=6/2+6/7=3.86
折半查找又称为二分查找,它仅适用于顺序表
基本思想:
先将key值与顺序表中间值进行比较,根据比较结果取相应的一半(比如key > mid),则取顺序表的右半部分,继续用key值与子序列的mid进行比较,如此往复直至找到目标关键字或确认表中无该关键字。
int binary_search(int *a, int head, int len, int key){
int low = 0;
int high = len;
int mid = (head + tail) / 2;
while( low <= high ){
if(mid == key){
return mid;
}
else if(mid > mid){
low = mid + 1;
}
else{
high = mid - 1;
}
}
return -1;
}
查找过程其实就像是一棵平衡二叉树,所以在平衡二叉树查找中(后续会介绍),其平均查找长度至于树的高度相关。即查找次数不会超过树的高度。
A S L = 1 n ∑ i = 1 n l i = 1 n ( 1 × 1 + 2 × 2 + . . . . + h × 2 h − 1 ) = n + 1 n l o g 2 ( n + 1 ) − 1 ≈ l o g 2 ( n + 1 ) − 1 ASL = \frac{1}{n}\sum^n_{i = 1}l_i = \frac{1}{n}(1 \times1+2\times2+....+h\times2^{h-1}) = \frac{n+1}{n} log_2(n+1)-1\approx log_2(n+1)-1 ASL=n1i=1∑nli=n1(1×1+2×2+....+h×2h−1)=nn+1log2(n+1)−1≈log2(n+1)−1
h是树的高度,所以折半查找的时间复杂度为O(logn)平均情况下要比顺序查找高效的多。
但是相较于顺序查找,二分查找只局限于顺序表中,因为他需要方便给定位查找区域所以要求线性表必须具有随机存储特性。
分块查找又称为索引顺序查找,它集合了顺序查找和折半查找的各自优点。既有动态结构,又适合快速查找。
将数据分为若干个块,块内元素可以无序,但是块与块之间是有序的。之后再对块建立一张索引表,索引表中的每个元素都有每个块中的最大关键字和第一个元素的地址,索引表按照关键字有序排列。
基本思想:
分块查找过程分两步:即先确定块,然后再块内进行顺序查找(因为块内无序)。对于确定块来讲可以选择折半查找,也可以选择顺序查找。
分块查找的平均查找长度:
A S L = L 1 + L 2 ASL = L_1+L_2 ASL=L1+L2
L 1 : 为 按 索 引 查 找 块 的 长 度 , L 2 : 为 块 内 查 找 查 找 长 度 L_1:为按索引查找块的长度 ,L_2:为块内查找查找长度 L1:为按索引查找块的长度,L2:为块内查找查找长度
将长度为n的查找表均匀的分成b块,每一块中有s个关键字,在等概率的情况下,则
A S L = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s ASL = \frac{b+1}{2} + \frac{s+1}{2} = \frac{s^2+2s+n}{2s} ASL=2b+1+2s+1=2ss2+2s+n
此时若 s = n s= \sqrt{n} s=n则查找平均长度最小值为 n + 1 \sqrt{n}+1 n+1,若对索引表采用折半查找时,则平均查找长度为 A S L = L 1 + L 2 = ⌈ l o g 2 ( b + 1 ) ⌉ + s + 1 2 ASL = L_1 + L_2 = \lceil log_2(b+1) \rceil + \frac{s+1}{2} ASL=L1+L2=⌈log2(b+1)⌉+2s+1
二叉排序树也称为二叉查找树,可以是一棵空树,然则就要满足以下条件:
从定义中我们不难看出另外一个条件,就是二叉排序树不允许有相同节点的存在。
且满足 【左子树节点值 < 根节点 < 右子树节点值】
所以也不难分析出,其中序遍历是是一个递增的有序序列。
查找思路:
与根节点进行比较,若小于根节点数值则进入左子树查找,若大于,则进入右子树,直到找到元素,或到二叉树最后一层。很明显这是一个递归的过程。但是这里为了更方便观察我用的顺序表实现,所以不会使用递归但是思路相同
#include
#include
typedef struct bst{
int *data; //数据域
int num, cap; //num是节点个数、cap表的是容量上限
}bst;
bst *initBst(){ //初始化AVL树
bst *t = (bst *)malloc(sizeof(bst));
t->data = (int *)malloc(sizeof(int) * 25);
t->num = 0;
t->cap = 25;
return t;
}
void insert(bst *t, int val){ //插入节点
t->data[0] = t->num; //因为顺序表存储树结构是不用下标为0的位置的,所以我们用它来记录表中数据个数
int n = 1;
while(t->num == t->data[0] ){ // 当有新的数据插入成功时,停止循环
if(t->data[n] == 0 ){ // 若当前位置无数据,则插入
t->data[n] = val;
t->num++;
}
else if(t->data[n] > val){ // 若插入数据小于当前节点,进入左子树
n = 2 * n ;
}
else if(t->data[n] < val){ // 若插入数据大于当前节点,进入右子树
n = 2 * n + 1;
}
else if(t->data[n] == val){ // 若插入数据等于当前节点,报错,因为二叉排序树不允许相同元素出现
printf("\n wrong number: the number has existed!\n");
return ;
}
}
}
int left_child(bst *t, int n){ //找到该树上的中序遍历第一位
if(t->data[2 * n] == 0 || 2 * n > t->cap){
return n;
}
left_child(t, 2 * n);
}
int check_child(bst *t, int n){ //检验该节点有几个孩子
if(t->data[2 * n] > 0 && t->data[2 * n + 1] > 0){
return 2;
}
else if(t->data[2 * n] > 0 || t->data[2 * n + 1] > 0){
return 1;
}
else{return 0;
}
}
void delete_ele(bst *t, int val){ //删除操作
int n = 0;
for(int i = 1; i < t->cap; i++){
if(t->data[i] == val){
n = i;
break;
}
}
if(n == 0) return ; //如果表中没有该元素则终止
if(check_child(t, n) == 0){ //叶子节点,直接删除
t->data[n] = 0;
t->num--;
}
else if(check_child(t, n) == 1){ // 有一个孩子,则孩子替代该位置
t->data[n] = t->data[2 * n] > t->data[2 * n + 1] ? t->data[2 * n]:t->data[2 * n + 1]; //未定义部分为0所以要小
t->data[2 * n + 1] = 0;
t->data[2 * n] = 0;
t->num--;
}
else{ // 两个孩子, 则选择中序遍历第一位替代
t->data[n] = t->data[left_child(t, 2 * n + 1)];
t->data[left_child(t, 2 * n + 1)] = 0;
t->num--;
}
}
int search(bst *t, int val){ // 查找操作
int n = 1;
while(n <= t->cap){ // 这里注意要在整个数据域范围内查找,而非以数据个数为标准,当n超过数据域则返回0
printf("%d ",t->data[n]);
if(t->data[n] == val){ // 找到目标节点返回位置下标
return n;
}
if(t->data[n] < val){
n = 2 * n + 1;
}
else if(t->data[n] > val){
n = 2 * n ;
}
}
return 0;
}
int main(){
bst *t = initBst(); // 初始化树
for(int i = 1; i <= t->cap; i++ ){ // 这里是初始化顺序表
t->data[i] = 0;
}
int n;
for(int i = 1; i <= 15; i++ ){ //插入操作
scanf("%d", &n);
insert(t, n);
}
for(int i = 1; i <= t->cap; i++ ){
printf("%d ", t->data[i]); // 打印整个树
}
printf("\n");
while(1){
int a;
scanf("%d", &a);
if(a == 1){ //删除操作
int b;
scanf("%d", &b);
delete_ele(t, b);
for(int i = 1; i <= t->cap; i++ ){
printf("%d ", t->data[i]); // 打印整个树
}
printf("\n");
}
else{ //查找操作
int c;
scanf("%d", &c);
int res = search(t, c);
if(res == 0){
printf("there is no the number !\n");
}
else{
printf("the loc is %d\n", res);
}
}
}
}
二叉排序树的删除一共分为三种情况。
- 如果删除的节点是叶子节点,则直接删除即可。不会破坏二叉树性质
- 如果删除的节点只有一棵子树,则让删除节点的子树替代被删除节点的位置
- 若删除节点有两棵子树,则应该取右子树的中序遍历的第一个节点的替代被删除节点位置。
由这三点性质,其实也可以看出,如果在二叉排序中删除一个节点后再加入该节点,得到的二叉排序树并不相同。
树的查找效率主要取决于高度,若左右子树的高度之差不超过1则这样的二叉树成为平衡二叉树,它的平均查找长度为 O ( l o g 2 ( n ) ) O(log_2(n)) O(log2(n)),若在极差的情况下(最差情况的二叉排序树退化成有序的单链表),则平均查找长度为O(n)。
则在等概率情况下,左图二叉排序树的查找成功查找长度为:
A S L a = 1 × 1 + 2 × 2 + 3 × 3 6 = 2.33 ASL_a = \frac{1 \times 1 + 2 \times 2 +3 \times 3}{ 6} = 2.33 ASLa=61×1+2×2+3×3=2.33
右图查找成功的查找长度为
A S L b = 1 + 2 + 3 + 4 + 5 + 6 6 = 3.5 ASL_b = \frac{1 + 2 + 3 + 4 + 5 + 6}{6} = 3.5 ASLb=61+2+3+4+5+6=3.5
正如上面所讲,二叉排序树在最好的情况下的查找效率为log级别,而在最差情况下会退化成单链表O(n)级别。所以为了避免这种情况发生,规定在插入和删除二叉树节点时,应保证任意节点的左右子树高度只差不超过1,所以我们将这样的二叉树叫做平衡二叉树。简称平衡树。
定义节点在左子树和右子树高度差为节点的平衡因子,依据平衡树的性质,平衡因子的数值只有可能是1,0,-1。
平衡二叉树定义:
平衡二叉树是一棵空树或者是一棵满足以下条件的树。
左子树与右子树都是平衡二叉树,且左右子树的高度差的绝对值不超过1。
所以基于AVL树的性质,其插入和删除操作之后有可能会破坏AVL树,所以我们需要时刻进行调整工作。
基本思想:
二叉排序树中插入或删除一个节点时,首先检查其插入路劲上的节点是否因此次操作而导致了不平衡,若导致不平衡,则先找到插入路径上离插入节点最近的平衡因子的绝对值大于1的节点A,再对以A为根的子树,再保证二叉排序树特性的前提下,调整各节点之间的关系,使之重新达到平衡。
每次进行调整的对象都是最小不平衡树,即插入路径离插入节点最近的平衡因子的绝对值大于1的节点作为根的子树。
可以看到在原AVL树中插入99,则以66为根节点的树变成了最小失衡树,因为右子树的高度更高,则,进行左旋调整,同理,如果在左侧插入,那么同样进行右旋调整。
调整情况可分为以下四种规律:
LL平衡旋转(右单旋转):
由于根节点的**左孩子(L)的左子树(L)**上插入了新节点,导致根节点的平衡因子由1变为2,导致以根节点的子树失去平衡。,将根节点的左孩子作为根节点,根节点变为右孩子,而原左孩子的右孩子作为原根节点的左孩子。
RR平衡旋转(左单旋转):
由于根节点的右孩子的右子树上插入了新节点,A的平衡因子由-1减至-2,导致以根节点的子树失去平衡,需要一次向左的旋转操作。将根节点的右孩子替代根节点,根节点成为左孩子,原右孩子的左孩子成为原根节点的右孩子。
LR平衡旋转(先左旋后右旋)
由于根节点的左孩子的右子树上插入了新节点,A的平衡因子由1增至2,导致以根节点的子树失去平衡,需要进行先左旋后右旋的操作。
RL平衡旋转(先右旋后左旋)
由于根节点的右孩子的左子树上插入了新节点,A的平衡因子由-1减至-2,导致以根节点的子树失去平衡,需要进行先右旋后左旋的操作。
删除的操作与插入类似:
- 用二叉排序树的方法对节点w执行删除操作
- 从节点w开始,向上回溯,找到第一个不平衡的节点z(即最小不平衡子树);y为节点z的高度最高的孩子节点。x是节点y的高度最高的孩子节点。
- 然后对z为根的子树进行平衡调整,其中x、y、z有四种情况:
- y是z的左孩子,x是y的左孩子(LL,右单旋)
- y是z的左孩子,x是y的右孩子(LR,先左后右)
- y是z的右孩子,x是y的右孩子(RR,左单旋)
- y是z的右孩子,x是y的左孩子(LL,先右后左)
这四种情况与插入一样,不同的是删除之后对z为根的树调整之后可能还需要回溯对z的祖先进一步进行调整
在 二 叉 排 序 中 已 经 介 绍 : O ( l o g 2 n ) 在二叉排序中已经介绍:O(log_2n) 在二叉排序中已经介绍:O(log2n)
为了保持AVL树的平衡性,插入和删除后,非常频繁地调整全树整体拓扑结构,代价较大。为此在AVL树的平衡标准进一步放宽条件引入了红黑树结构。
红黑树满足以下性质:
- 每个节点或是红色或是黑色
- 根节点是黑色的
- 叶子节点(不存放数据,NULL)都是黑色的
- 不存在两个相邻的红节点
- 对于每个节点,从该节点到任意叶子节点的简单路径上,所含黑节点的数量应该是相同的
由以上操作性质可以得出两个结论:
与AVL树一样,当红黑树进行插入删除操作的同时,也需要注意红黑是是否会被破坏,进而进行一系列调整。在这里就不展开了,会在之后与AVL分别单独记录一下。
与前面介绍的线性和树形查找中,记录在表中的位置与记录的关键字之间存在不确定关系,所以,在他们的查找之中建立在比较之上,查找效率受限于表的长度。
散列表与其不同;通过散列函数建立起关键字与地址的关系,可以直接查找到关键字位置
散列表:根据关键字而直接访问数据结构,也就是说散列表建立了关键字与存储地址之间的一种直接映射关系
散列函数:一个把查找表中的关键字映射成关键字对应的地址的函数几位 H a s h ( k e y ) = a d d r Hash(key) = addr Hash(key)=addr这里的地址可以是索引或数组下标
但是散列函数可能会把两个或两个以上的不同关键字映射到同一地址上我们称这种情况为冲突。这些发生碰撞的不同关键字成为同义词
好的散列函数应减少这种冲突的出现,但是冲突是客观不可避免的,所以我们也要做好应对冲突的工作
构造散列表的时候必须注意以下几点:
- 散列函数的定义域必须包含全部需要存储的关键字,而值域则依赖于散列表的存储地址空间大小或地址范围
- 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突地发生
- 散列函数应该尽量简单,能够在较短地时间内计算出任一关键字的散列地址
构造方法 | 方法 | 冲突处理 | 特点 |
---|---|---|---|
直接定址法 | H ( k e y ) = k e y 或 H ( k e y ) = a × k e y + b H(key) = key或H(key) = a\times key + b H(key)=key或H(key)=a×key+b | 不会出现冲突 | 方法简单。适合关键字分布基本连续的情况(否则有较大的空间浪费) |
除留余数法 | H ( k e y ) = k e y H(key) = key % p H(key)=key | 应选好p,从而减少冲突 | 最简单常用的方法 |
数字分析法 | 设关键字是r进制数,选取码位分布均匀的若干位为散列地址 | 适合于已知关键字集合,若更换了关键字,则需重新构造新的散列函数 | |
平方取中法 | 取关键字的平方值中间几位作为散列值 | 适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数 |
在不同情况下,选择相应适合关键字集合的散列函数,不存在哪一种函数最优的情况,但目标是应该尽量减少产生冲突的可能性。
1. 开放地址法
所谓开放地址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放,其数学公式为: H i = ( H ( k e y ) + d i ) % m H_i = (H(key) + d_i) \%m Hi=(H(key)+di)%m
式中,H(key)为散列函数,m代表散列表长, d i d_i di代表增量序列
2. 拉链法
以顺序表为例,我们用除留余数法时很明显我们在下面1-9的存储单元中如果放入1和11的话会发生冲突,这时我们把1和11构成一个链表,把表头放在1的位置。这就是拉链法。把发生冲突位置的关键字构成一个链表放入存储单元
1 2 3 4 5 6 7 8 9
散列表查找与散列表构造步骤基本一致,对于一个给定的关键字key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:Addr = Hash(key)
以下表为例计算ASL:
关键字 | 14 | 01 | 68 | 27 | 55 | 19 | 20 | 84 | 79 | 23 | 11 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
比较次数 | 1 | 2 | 1 | 4 | 3 | 1 | 1 | 3 | 9 | 1 | 1 | 3 |
A S L = ( 1 × 6 + 2 + 3 × 3 + 4 + 9 ) ASL = (1\times6+2+3\times3+4+9) ASL=(1×6+2+3×3+4+9)
从散列表的查找过程可见: