目录
数据结构-查找(第九章)的整理笔记,若有错误,欢迎指正。
查找的基本概念
- 被查找对象是由一组元素(或记录)组成的表或文件,称为查找表。查找表中的每个元素则由若干个数据项组成,其中指定一个数据项为关键字(key),所有元素在关键字上的取值是唯一的。在这种条件下,查找(search)的定义是给定一个值k,在含有n个元素的表中找出关健字等于k的元素。若找到,则查找成功,返回该元素的信息或该元素在表中的位置;否则查找失败,返回相关的指示信息。
- 若在查找的同时对表做修改操作(如插入和删除),则相应的查找表称为动态查找表(dynamic search table)。若在查找中不涉及表的修改操作,则相应的查找表称为静态查找表(static search table)。
- 查找也有内查找和外查找之分。若整个查找过程都在内存中进行,则称之为内查找(internal search);反之,若查找过程需要访问外存,则称之为外查找(external search)。
- 在查找运算中时间主要花费在关键字的比较上,把平均需要和给定值k进行比较的关键字次数称为平均查找长度(Average Search Length,ASL),其定义如下:
A
S
L
=
∑
i
=
1
n
p
i
c
i
ASL=\sum_{i=1}^{n}p_ic_i
ASL=∑i=1npici
其中,n是查找表中元素的个数。 p i p_i pi是查找第i个元素的概率,通常假设每个元素的查找概率相等,此时 p = 1 n ( 1 ≤ i ≤ n ) p=\frac1n(1≤i≤n) p=n1(1≤i≤n), c i c_i ci是找到第i个元素所需的关键字比较次数。 - ASL分为查找成功情况下的 A S L 成 功 ASL_{成功} ASL成功和查找不成功(失败)情况下的 A S L 不 成 功 ASL_{不成功} ASL不成功。
- A S L 成 功 ASL_{成功} ASL成功表示成功查找到查找表中的元素,平均需要关键字比较次数( p i p_i pi为查找到第i个元素的概率,有 ∑ i = 1 n p i = 1 \sum_{i=1}^{n}p_i=1 ∑i=1npi=1)。
- A S L 不 成 功 ASL_{不成功} ASL不成功表示没有找到查找表中的元素,平均需要关键字比较次数(假设共有m种查找失败情况, q i q_i qi为第i种情况的概率,有 ∑ i = 1 m q i = 1 \sum_{i=1}^{m}q_i=1 ∑i=1mqi=1)
- 显然,ASL是衡量查找算法性能好坏的重要指标。一个查找算法的ASL越大,其时间性能越差;反之,一个查找算法的ASL越小,其时间性能越好。
1. 线性表的查找
线性表是一种最简单的查找表,线性表有顺序和链式两种存储结构。对于顺序表,可通过数组下标递增来顺序扫描每个元素;对于链表,可通过指针next来依次扫描每个元素。顺序查找通常分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找。
顺序查找(sequential search)
- 它的基本思路是从表的一端向另一端逐个将元素的关键字和给定值k比较,若相等,则查找成功,给出该元素在查找表中的位置;若整个查找表扫描结束后仍未找到关键字等于k的元素,则查找失败。
一般线性表的顺序查找
算法实现(顺序存储结构—非递归)
方法一:
#include<stdio.h>
#define MaxSize 50 //定义数组最大长度
typedef int KeyType; //定义关键字类型为int
typedef struct
{
KeyType key; //关键字项
//InfoType data; //其他数据项
}RecType; //查找元素的类型
int SeqSearch(RecType R[], int n, KeyType k)
{
int i;
for (i = 0; i < n && R[i].key != k; i++);
return i < n ? i + 1 : 0; //找到返回逻辑序号,未找到返回0
}
int main()
{
RecType R[MaxSize] = { 0 };
int n;
printf("输入数组的长度:");
scanf("%d", &n);
KeyType data;
printf("输入各关键字:");
for (int i = 0; i < n; i++)
{
scanf("%d", &data);
R[i].key = data;
}
int k;
printf("输入要查找的关键字:");
scanf("%d", &k);
int result = SeqSearch(R, n, k);
printf("该关键字的位序为:%d", result);
if (result == 0) printf("\n查找失败!");
else printf("\n查找成功!");
return 0;
}
运行结果
程序分析
- 从顺序查找过程中可以看到, c i c_i ci(查找第i个元素所需要的关键字比较次数)取决于该元素在表中的位置。如查找表中的第1个元素R[0]时仅需比较一次;而查找表中的第n个元素R[n-1]时需比较n次,即 c i = i c_i=i ci=i。因此,成功时的顺序查找的平均查找长度为: A S L 成 功 = ∑ i = 1 n p i c i = 1 n ∑ i = 1 n i = 1 n × n ( n + 1 ) 2 = n + 1 2 ASL_{成功}=\sum_{i=1}^{n}p_ic_i=\frac1n\sum_{i=1}^{n}i=\frac1n×\frac{n(n+1)}2=\frac{n+1}2 ASL成功=∑i=1npici=n1∑i=1ni=n1×2n(n+1)=2n+1
- 也就是说,顺序查找方法在查找成功时的平均比较次数约为表长的一半。
- 若k值不在表中,则必须进行n次比较之后オ能确定查找失败,所以 A S L 不 成 功 = n ASL_{不成功}=n ASL不成功=n。
- 因此顺序查找算法的平均时间复杂度为O(n),其中n为查找表中的元素个数。
方法二:
int SeqSearch(RecType R[], int n, KeyType k)
{
int i;
R[n].key = k;
for (i = 0; R[i].key != k; i++); //从表头往后找
return i == n ? 0 : i + 1; //找到返回逻辑序号,未找到返回0
}
运行结果
程序分析
- 在上述顺序查找算法中,可以在R的末尾增加一个关键字为k的记录,称之为哨兵,这样查找过程不再需要判断是否超界,从而提高查找速度。
- A S L 成 功 = ∑ i = 1 n p i c i = 1 n ∑ i = 1 n i = 1 n × n ( n + 1 ) 2 = n + 1 2 ASL_{成功}=\sum_{i=1}^{n}p_ic_i=\frac1n\sum_{i=1}^{n}i=\frac1n×\frac{n(n+1)}2=\frac{n+1}2 ASL成功=∑i=1npici=n1∑i=1ni=n1×2n(n+1)=2n+1; A S L 不 成 功 = n + 1 ASL_{不成功}=n+1 ASL不成功=n+1。
- 因此顺序查找算法的平均时间复杂度为O(n),其中n为查找表中的元素个数。
算法实现(顺序存储结构—递归)
对应的递归模型如下:
{
f
(
R
,
n
,
k
,
i
)
=
0
当
i
≥
n
f
(
R
,
n
,
k
,
i
)
=
i
+
1
当
R
[
i
]
.
k
e
y
=
k
f
(
R
,
n
,
k
,
i
)
=
f
(
R
,
n
,
k
,
i
+
1
)
其
他
情
况
\begin{cases} f(R,n,k,i)=0\;\;\;\;当i≥n \\ f(R,n,k,i)=i+1\;\;\;\;当R[i].key=k \\ f(R,n,k,i)=f(R,n,k,i+1)\;\;\;\;其他情况 \\ \end{cases}
⎩⎪⎨⎪⎧f(R,n,k,i)=0当i≥nf(R,n,k,i)=i+1当R[i].key=kf(R,n,k,i)=f(R,n,k,i+1)其他情况
int SeqSearch(RecType R[],int n,KeyType k,int i)
{
if(i >= n) return 0;
else if(R[i].key == k) return i + 1;
else return SeqSearch(R, n, k, i + 1);
}
算法实现(链式存储结构)
#include<stdio.h>
#include<malloc.h> //用malloc函数开辟新单元时需用此头文件
typedef int KeyType;
typedef struct LNode //定义单链表结点类型
{
KeyType data; //数据域
struct LNode* next; //指针域
}LNode, * LinkList;
void InitList(LNode*& L) //初始化线性表
{
L = (LNode*)malloc(sizeof(LNode)); //创建头结点
L->next = NULL; //其next域置为NULL
}
void TailInsert(LinkList& L,int n) //正向建立单链表(尾插法)
{
LNode* s, * p = L;
int num;
printf("输入:");
while (n--)
{
scanf("%d", &num);
s = (LNode*)malloc(sizeof(LNode));
s->data = num; //为数据域赋值
s->next = p->next;
p->next = s;
p = p->next;
}
}
int SeqSearch(LinkList L, int n, KeyType k)
{
LNode* p = L;
int i = 0;
while (p != NULL)
{
if (p->data != k)
{
i++;
p = p->next;
}
else return i;
}
return 0;
}
int main()
{
LNode* L;
InitList(L); //初始化线性表,创建一个空的单链表
int n;
printf("输入元素个数:");
scanf("%d", &n);
TailInsert(L, n);
int k;
printf("输入要查找的关键字:");
scanf("%d", &k);
int result = SeqSearch(L, n, k);
printf("该关键字在单链表中的第%d个位置", result);
if (result == 0) printf("\n查找失败!");
else printf("\n查找成功!");
return 0;
}
运行结果
- 归纳起来,顺序查找的优点是算法简单,且对表的结构无特别要求,无论是用顺序表还是用链表来存放元素,也无论是元素之间是否按关键字有序,它都同样适用。顺序查找的缺点是查找效率低,因此当n较大时不宜采用顺序查找方法。
有序表的顺序查找
- 若在查找之前就已经知道表是关键字有序的,则查找失败时可以不用再比较到表的另一端就能返回查找失败的信息,从而降低顺序查找失败的平均查找长度。
- 假设表L是按关键字从小到大排列的,查找的顺序是从前往后,待查找元素的关键字为key,当查找到第i个元素时,发现第i个元素对应的关键字小于key,但第i+1个元素对应的关键字大于key,这时就可返回查找失败的信息,因为第i个元素之后的元素的关键字均大于key,所以表中不存在关键字为key的元素。
- 可以用判定树来描述有序线性表的查找过程。树中的圆形结点表示有序线性表中存在的元素;树中的矩形结点称为失败结点(若有n个结点,则相应地有n+1个査找失败结点),它描述的是那些不在表中的数据值的集合。若查找到失败结点,则说明查找不成功。
- 在有序线性表的顺序查找中,查找成功的平均查找长度和一般线性表的顺序查找一样。查找失败时,查找指针一定走到了某个失败结点。这些失败结点是虚构的空结点,实际上是不存在的,所以到达失败结点时所查找的长度等于它上面的一个圆形结点的所在层数。查找不成功的平均查找长度在相等查找概率的情形下为 A S L 不 成 功 = ∑ j = 1 n q i ( l j − 1 ) = 1 + 2 + . . . + n + n n + 1 = n 2 + n n + 1 ASL_{不成功}=\sum_{j=1}^{n}q_i(l_j-1)=\frac{1+2+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1} ASL不成功=∑j=1nqi(lj−1)=n+11+2+...+n+n=2n+n+1n,式中, q j q_j qj是到达第j个失败结点的概率,在相等查找概率的情形下,它为 1 n + 1 \frac{1}{n+1} n+11; l j l_j lj是第j个失败结点所在的层数。
算法实现(顺序存储结构)
int SeqSearch(RecType R[], int n, KeyType k)
{
int i = 0;
while (i < n)
{
if (R[i].key < k) i++; //指定关键字比当前值大
else if (R[i].key == k) return i+1; //查找成功,返回位序下标
else return 0; //查找失败
}
}
运行结果
算法实现(链式存储结构)
int SeqSearch(LinkList L, int n, KeyType k)
{
LNode* p = L;
int i = 0;
while (p != NULL)
{
if (p->data < k)
{
i++;
p = p->next;
}
else if (p->data == k) return i;
else return 0;
}
return 0;
}
运行结果
顺序查找的优化
- 被查概率不相等时,将被查概率大的放在靠前的位置。该方法能在查找成功的情况下,平均查找长度进一步的缩短,但是对于查找失败的情况,平均查找长度没有改变。
折半查找(binary search)
- 折半查找又称二分查找,它是一种效率较高的查找方法。但是,折半查找要求线性表是有序表,即表中的元素按关键字有序。
- 折半查找的基本思想:首先将给定值key与表中中间位置的元素比较,若相等,则查找成功,返回该元素的存储位置;若不等,则所需查找的元素只能在中间元素以外的前半部分或后半部分。然后在缩小的范围内继续进行同样的查找,如此重复,直到找到为止,或确定表中没有所需要查找的元素,则查找不成功,返回查找失败的信息。
算法实现(非递归)
//int BinarySearch(RecType R[], int n, KeyType k)
//{ //升序排序
// int low, high, mid;
// low = 0; high = n - 1;
// while (low <= high)
// {
// mid = (low + high) / 2; //取中间位置
// if (k == R[mid].key) return mid + 1; //查找成功则返回其逻辑序号
// else if (k < R[mid].key) high = mid - 1; //从前半部分继续查找
// else low = mid + 1; //从后半部分继续查找
// }
// return 0; //查找失败
//}
int BinarySearch(RecType R[], int n, KeyType k)
{ //降序排序
int low, high, mid;
low = 0; high = n - 1;
while (low <= high)
{
mid = (low + high) / 2; //取中间位置
if (k == R[mid].key) return mid + 1; //查找成功则返回其逻辑序号
else if (k < R[mid].key) low = mid + 1; //从后半部分继续查找
else high = mid - 1; //从前半部分继续查找
}
return 0; //查找失败
}
运行结果
程序分析
- 因为折半查找需要方便地定位查找区域,所以它要求线性表必须具有随机存取的特性。因此,该查找法仅适合于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。
!注意:不能说折半查找不能用于链式存储结构,只是说采用顺序表时折半查找算法设计更方便,效率更高。
算法实现(递归)
对应的递归模型如下:
f
(
R
,
l
o
w
,
h
i
g
h
,
k
)
{
0
当
l
o
w
>
h
i
g
h
时
m
i
d
+
1
(
m
i
d
=
l
o
w
+
h
i
g
h
2
)
当
R
[
m
i
d
]
.
k
e
y
=
k
时
f
(
R
,
l
o
w
,
m
i
d
−
1
,
k
)
当
k
<
R
[
m
i
d
]
.
k
e
y
f
(
R
,
m
i
d
+
1
,
h
i
g
h
,
k
)
当
k
>
R
[
m
i
d
]
.
k
e
y
f(R,low,high,k)\begin{cases} 0\;\;\;\;当low>high时 \\ mid+1(mid=\frac {low+high}2)\;\;\;\;当R[mid].key=k时 \\ f(R,low,mid-1,k)\;\;\;\;当k<R[mid].key \\ f(R,mid+1,high,k)\;\;\;\;当k>R[mid].key \\ \end{cases}
f(R,low,high,k)⎩⎪⎪⎪⎨⎪⎪⎪⎧0当low>high时mid+1(mid=2low+high)当R[mid].key=k时f(R,low,mid−1,k)当k<R[mid].keyf(R,mid+1,high,k)当k>R[mid].key
int BinarySearch(RecType R[], KeyType k, int low, int high)
{ //升序排序
int mid;
if (low > high) return 0;
else
{
mid = (low + high) / 2; //取中间位置
if (k == R[mid].key) return mid + 1; //查找成功则返回其逻辑序号
else if (k < R[mid].key) BinarySearch(R, k, low, mid - 1); //从后半部分继续查找
else BinarySearch(R, k, mid + 1, high); //从前半部分继续查找
}
}
//int BinarySearch(RecType R[],KeyType k,int low,int high)
//{ //降序排序
// int mid;
// if (low > high) return 0;
// else
// {
// mid = (low + high) / 2; //取中间位置
// if (k == R[mid].key) return mid + 1; //查找成功则返回其逻辑序号
// else if (k < R[mid].key) BinarySearch(R, k, mid + 1, high); //从后半部分继续查找
// else BinarySearch(R, k, low, mid - 1); //从前半部分继续查找
// }
//}
判定树
- 折半查找过程可用二叉树来描述,把当前查找区间的中间位置上的元素作为根,由左子表和右子表构造的二叉树分别作为根的左子树和右子树,由此得到的二叉树称为描述折半查找过程的判定树(decision tree)或比较树(comparison tree)。
- 树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶结点都是方形的,它表示查找不成功的情况。从判定树可以看出,查找成功时的查找长度为从根结点到目的结点的路径上的结点数,而查找不成功时的查找长度为从根结点到对应失败结点的父结点的路径上的结点数;每个结点值均大于其左子结点值,且均小于其右子结点值。若有序序列有n个元素,则对应的判定树有n个圆形的非叶结点和n+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=\frac1n\sum_{i=1}^{n}l_i=\frac1n(1×1+2×2+…+h×2^{h-1})=\frac{n+1}nlog_2^{n+1}-1≈log_2^{n+1}-1
ASL=n1∑i=1nli=n1(1×1+2×2+…+h×2h−1)=nn+1log2n+1−1≈log2n+1−1
式中,h是树的高度,并且元素个数为n时树高 h = ⌈ l o g 2 n + 1 ⌉ h=⌈log_2^{n+1}⌉ h=⌈log2n+1⌉。所以,折半查找的时间复杂度为 O ( l o g 2 n ) O(log_2^n) O(log2n),平均情况下比顺序查找的效率高。
!注意:折半查找判定树的形态只与表元素个数n相关,而与输入实例中R[0…n-1].key的取值无关。
!注意:折半查找的速度⼀定比顺序查找更快(❌)
折半查找速度在大部分情况下比顺序查找要快(√)
- k分查找:查找成功/不成功时,时间复杂度为 O ( l o g k n ) O(log_k^n) O(logkn)。
索引存储结构和分块查找
索引存储结构(index storage structure)
- 索引存储结构是在存储数据的同时还建立附加的索引表。索引表中的每一项称为索引项,索引项的一般形式为(关键字,地址)。
- 其中,关键字唯一标识一个结点,地址作为指向该关键字对应结点的指针,也可以是相对地址(如数组的下标)。
- 在索引存储结构中进行关键字查找时先在索引表中快速查找(因为索引表中按关键字有序排列,可以采用折半查找)到相应的关键字,然后通过对应的地址找到主数据表中的元素。索引存储结构优点是可以提高按关键字查找元素的效率;缺点是需要建立索引表而增加时间和空间的开销。
分块查找(block search)
- 分块查找又称索引顺序查找,它吸取了顺序查找和折半查找各自的优点,既有动态结构,又适于快速查找。
- 分块查找的基本思想:将查找表分为若干子块。块内的元素可以无序,但块之间是有序的,即第一个块中的最大关键字小于第二个块中的所有记录的关键字,第二个块中的最大关键字小于第三个块中的所有记录的关键字,以此类推。再建立一个索引表,索引表中的每个元素含有各块的最大关键字和各块中的第一个元素的地址,索引表按关键字有序排列。
- 分块查找的过程分为两步:第一步是在索引表中确定待查记录所在的块,可以顺序查找或折半査找索引表;第二步是在块内顺序查找。
- 分块查找的平均查找长度为索引查找和块内查找的平均长度之和。设索引查找和块内查找的平均查找长度分别为 L I , L S L_I,L_S LI,LS,则分块查找的平均查找长度为将长度为n的查找表均匀地分为b块,每块有s个记录。
- 在等概率情况下,若在块内和索引表中均采用顺序查找,则平均查找长度为 A S L = L I + L S = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s ASL=L_I+L_S=\frac{b+1}2+\frac{s+1}2=\frac{s^2+2s+n}{2s} ASL=LI+LS=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 I + L S = ⌈ l o g 2 b + 1 ⌉ + s + 1 2 ASL=L_I+L_S=⌈log_2^{b+1}⌉+\frac{s+1}2 ASL=LI+LS=⌈log2b+1⌉+2s+1
- 若在块内和索引表中均采用折半查找(此时块内元素均有序),则平均查找长度为 A S L = L I + L S = 2 ⌈ l o g 2 b + 1 ⌉ ASL=L_I+L_S=2⌈log_2^{b+1}⌉ ASL=LI+LS=2⌈log2b+1⌉
算法实现
#define MAXI 100
typedef struct
{
KeyType key; //关键字的类型
int link; //指向对应块的起始下标
}IdxType; //索引表元素的类型
int IdxSearch(IdxType I[], int b, RecType R[], int n, KeyType k) //分块查找
{
int s = (n + b - 1) / b; //s为每块的元素个数
int low = 0, high = b - 1, mid, i;
while (low <= high) //在索引表中进行折半查找,找到的位置为high+1
{
mid = (low + high) / 2;
if (I[mid].key >= k) high = mid - 1;
else low = mid + 1;
}
//应先在索引表的high+1块中查找,再在主数据表中进行顺序查找
i = I[high + 1].link;
while (i <= I[high + 1].link + s - 1 && R[i].key != k) i++;
if (i <= I[high + 1].link + s - 1) return i + 1; //查找成功,返回该元素的逻辑序号
else return 0; //查找失败,返回0
}