查找技术

对于数据的基本操作是增删查改(CRUD)(Create(创建)、Retrieve(检索)、Update(更新,更改)、Delete(删除)),我们可以注意到对于删和改两个基本操作,一般在进行之前都会先进行查找操作。因此查找技术相当重要,也由此产生了专门面向查找技术的各种数据结构。查找技术是多元化的,比如数据库数据的查找,或者对于电脑文件的快速查找等等。这些查找技术一部分是需要我们牢牢掌握的,一部分是需要我们深刻理解的。下面开始对部分查找技术进行介绍:

在查找问题中,通常将数据元素称为记录
关键码:可以标识一个记录的数据项称为关键码,关键码的值称为键值,若关键码可以唯一标识一个记录,则称此关键码为主关键码,反之称此关键码为次关键码。
广义地讲,查找是在具有相同类型的记录构成的集合中找出满足给定条件的记录。给定的条件可能是多种多样的,便与讨论,我们把给定条件限制为“匹配”,即查找的关键码等于给定值的记录。
静态查找与动态查找:不涉及插入和删除的查找称为静态查找,反之为动态查找,动态查找不成功时,需要将被查找的记录插入到查找集合中,查找的结果可能会改变查找集合。
查找结构:一般而言,各种数据结构都会涉及到查找操作,有些数据结构专门面向查找操作,称为查找结构。
查找算法性能计算:将查找算法进行关键码的比较次数的数学期望值定义为平均查找长度(average search length),对于查找成功的情况,计算公式为:ASL=pi*ci(i从1到n的和),其中n为问题规模,查找集合中的记录个数。pi为查找第i个记录的概率,ci为查找第i个记录需要关键码的比较次数。
下面主要介绍的查找结构为:
线性表:适用于静态查找,主要采用顺序查找技术,折半查找技术。
树表:适用于动态查找,主要采用二叉排序树的查找技术。
散列表:静态查找和动态查找均适用,主要采用散列技术。

线性表的查找技术:

线性表对应的顺序存储结构以及链式存储结构决定了线性表的不同查找方式。
顺序查找,sequential search又称线性查找,是最基本的查找技术之一,基本思想:从线性表的一端向另一端逐个将关键码与给定值进行比较。若相等,则查找成功,给出该记录在表中的位置,若整个表检测完仍未找到与给定值相等的关键码,则查找失败,给出失败信息。
顺序表的顺序查找:
设置哨兵(待查值)(将它放在查找方向的尽头处,从另一端开始查找,减少了每次查找过程中检查是否越界的情况,实验表明,这种设置在查找大于1000次时,顺序查找的平均时间几乎减小了一半。)
实际上:一切简化边界条件而引入的附加节点(或记录)均可称为哨兵,例如单链表中的头结点。
例:

int SeqSearch(int r[],int n,int k){
	r[0]=k;  //利用下标0作为监视哨
	i=n;
	while(r[i]!=k){  //不用判断下标是否越界
	i--;
	return i;
}

单链表的顺序查找算法:

int SeqSearch(Node<int>*firsdt,int k){
    p=first->next;  //工作指针p初始化为指向第一个元素节点
    count=1;
    while(p!=NULL&&p->data!=k){
        p=p->next;  //工作指针后移
        j++;
    }
    if(p->data==k){
        return j;
    }
    else{
        return 0;
    }
}

比较:顺序查找与其他查找技术比较,平均查找长度较大,特别是n较大时,查找效率很低。
折半查找:
要求:线性表中的记录必须按关键码有序,并且采用顺序存储结构。折半查找技术一般只能用于静态查找。

例:

折半查找——非递归算法:

int BinSearch1(int r[ ], int n, int k){ 
  int  low=1; high=n,mid;
    while (low<=high)                   
    {
       mid=(low+high)/2;            
       if (k<r[mid])  high=mid-1;
       else if (k>r[mid])  low=mid+1; 
              else return mid;
    }
    return 0;
}

折半查找——递归算法:

int BinSearch2(int r[ ], int low, int high, int k){    
    if (low>high) return 0;  
    else {
           mid=(low+high)/2;
           if (k<r[mid]) 
                 return BinSearch2(r, low, mid-1, k);
           else  if (k>r[mid]) 
                  return BinSearch2(r, mid+1, high, k); 
           else return mid;
     }
 }

根据折半查找的过程,我们可以想到二叉树的逻辑结构,我们尝试用二叉树的结构来模拟折半查找的过程,由此形成了判定树:
树中的每个结点对应有序表中的一个记录,结点的值为该记录在表中的位置。通常称这个描述折半查找过程的二叉树为折半查找判定树,简称判定树。
判定树的构造方法:
当n=0时,折半查找判定树为空;
当n>0时,折半查找判定树的根节点为mid=(n+1)/2;
根节点的左子树是与有序表r[1]r[mid-1]相对应的折半查找判定树,根节点的右子树是与r[mid+1]r[n]相对应的折半查找判定树。
具体结构如下:

性质:
任意结点的左右子树中结点个数最多相差1
任意结点的左右子树的高度最多相差1
任意两个叶子所处的层次最多相差1

查找成功:
在表中查找任一记录的过程,即是折半查找判定树中从根结点到该记录结点的路径,和给定值的比较次数等于该记录结点在树中的层数。
查找成功时的平均查找长度ASL:

查找不成功:
查找失败的过程就是走了一条从根结点到外部结点的路径,和给定值进行的关键码的比较次数等于该路径上内部结点的个数(失败情况下的平均查找长度等于树的高度)。

上述结构查找成功时:
该树的ASLsucc=(1+22+34+4*4)/11=33/11=3

上述结构查找失败时:
查找不成功时的ASLusucc= ( 34+48) /12=11/3
任意两棵折半查找判定树,若它们的结点个数相同,则它们的结构完全相同
具有n个结点的折半查找树的高度为:

树形查找的引出:

线性表查找是静态的查找,要在线性表上进行动态查找,存在以下的问题:
一、无序顺序表上进行动态查找,插入操作简单,但查找的复杂性高
二、有序顺序表上进行动态查找,查找的时间复杂性好,但是插入操作时间复杂性高
三、单链表上进行动态查找,插入操作简单,但查找操作复杂性高
解决办法:
采用二叉树这种数据结构,实现动态查找。

二叉排序树(也称二叉查找树)
(1)若它的左子树不空,则左子树上所有结点的值均小于根结点的值;
⑵若它的右子树不空,则右子树上所有结点的值均大于根结点的值;
⑶ 它的左右子树也都是二叉排序树。
中序遍历二叉排序树可以得到一个按关键码有序的序列。
二叉排序树的插入算法:
若二叉排序树为空树,则新插入的结点为新的根结点;
否则,如果插入的值比根节点值大,则在右子树中进行插入;否则,在左子树中进行插入。
递归实现。
例:

void BiSortTree::InsertBST(BiNode<int> * &root, BiNode<int> *s)
{  
    if (root==NULL)  
       root=s;
    else{
            if (s->data<root->data)
                 InsertBST(root->lchild, s);
            else   
                 InsertBST(root->rchild, s);
	}
}

二叉排序树的删除:
二叉排序树的删除分为以下三种情况考虑:
1.被删除的结点是叶子:操作:将双亲结点中相应指针域的值改为空。
2.被删除的结点只有左子树或者只有右子树:操作:将双亲结点的相应指针域的值指向被删除结点的左子树(或右子树)。
3.被删除的结点既有左子树,也有右子树:
查找结点p的右子树上的最左下结点s及s双亲结点par;
将结点s数据域替换到被删结点p的数据域;
若结点p的右孩子无左子树,则将s的右子树接到par的右子树上; 否则,将s的右子树接到结点par的左子树上;
删除结点s;
例:

void BiSortTree::DeleteBST(BiNode<int> *p, BiNode<int> *f ) {
	 if (!p->lchild && !p->rchild) 	{   
              if(f->child==p)        f->lchild= NULL;  
              else  f->lchild= NULL; 
              delete p;
	  }
	 else if (!p->rchild) {     //p只有左子树
             if(f->child==p)   f->lchild=p->lchild;
             else f->rchild=p->lchild;
	               delete p;
	 }
	 else if (!p->lchild) {   //p只有右子树
		 if(f->child==p)  f->lchild=p->rchild;
		 else f->rchild=p->rchild;
            delete p;
        	}
        else {   //左右子树均不空
             par=p;  s=p->rchild;  
             while (s->lchild!=NULL)   //查找最左下结点
             {
               par=s;
               s=s->lchild;
             }
             p->data=s->data;
             if (par==p) p->rchild=s->rchild;  //处理特殊情况
                 else par->lchild=s->rchild;    //一般情况
             delete s;
           } //左右子树均不空的情况处理完毕
 }

二叉排序树的查找:二叉排序树的查找性能取决于二叉排序树的形状,在O(log2n)和O(n)之间。
在二叉排序树中查找给定值k的过程是:
⑴ 若root是空树,则查找失败;
⑵ 若k=root->data,则查找成功;否则
⑶ 若k<root->data,则在root的左子树上查找;否则
⑷ 在root的右子树上查找。
上述过程一直持续到k被找到或者待查找的子树为空,如果待查找的子树为空,则查找失败。
二叉排序树的查找效率在于只需查找二个子树之一。
例:

BiNode *BiSortTree::SearchBST(BiNode<int> *root, int k)
{
    if (root==NULL)
    return NULL;
    else if (root->data==k) 
              return root;
           else if (k<root->data) 
                      return SearchBST(root->lchild, k);
                  else 
				return SearchBST(root->rchild, k);
}

二叉排序树的判断(判断一棵树是否是二叉排序树):
如果是空树,返回true;
如果是叶子,返回true;
否则
bst=true
如果root->lchild 是BST
如果root->rchild是BST,
寻找root的最左的分支和root的最右分支并比较大小
如果不满足,bst= false
bst=false
例:

bool BiTree<T>:: Is_BST(BiNode<T> *root){
	if(root==NULL)
		return true;
	if( root->lchild==NULL && root->rchild==NULL)
		return true;
	else	{
		bool l_bst,r_bst,bst=true;
		l_bst=Is_BST(root->lchild);
		if(l_bst)		{
			r_bst=Is_BST(root->rchild);
			if(r_bst)			{
				BiNode <T> *l_child,*l_pre=NULL, *r_child,*r_pre=NULL;
				l_child=root->lchild;
				r_child=root->rchild;
				while(l_child)
				{
					l_pre=l_child;
					l_child=l_child->rchild;
				}
				while(r_child)
				{
					r_pre=r_child;
					r_child=r_child->lchild;
				}
				if(l_pre&&root->data<l_pre->data || r_pre&&root->data>r_pre->data)
					bst=false;
			}
			else
				bst=false;
		}
		else
			bst=false;
		return bst;
	}
}

基于遍历的思想判断
在中序遍历的过程中查看中序遍历中相邻的两个节点之间是否正序,是则继续遍历,否则,为非二叉排序树。
例:

template<class T>
void BiTree<T>::In_BST(BiNode<T> *root,int &key,bool &bst){
	if(root&&bst)	{
		In_BST(root->lchild,key,bst);
		if(key<=root->data )		{
			key=root->data;
		}
		else
			bst=false;
		In_BST(root->rchild,key,bst);
	}
}
template<class T>
bool BiTree<T>:: Is_BST_2(BiNode<T> *root)
{
	int data;
	BiNode<T> * pre,*t_root;
	t_root=root;
	while(root!=NULL)
	{
		pre=root;
		root=root->lchild;
	}
	data=pre->data; //找着中序遍历的第一个节点;
	bool bst=true;
	In_BST(t_root,data,bst);
	if(bst)
		return true;
	else
		return false;
}

平衡二叉树(AVL树)
或者是一棵空的二叉排序树,或者是具有下列性质的二叉排序树:
⑴ 根结点的左子树和右子树的深度最多相差1;
⑵ 根结点的左子树和右子树也都是平衡二叉树。
平衡因子:结点的平衡因子是该结点的左子树的深度与右子树的深度之差。 在平衡树中,结点的平衡因子可以是1,0,-1。(由平衡二叉树的定义性质决定)
最小不平衡子树:在平衡二叉树的构造过程中,以距离插入结点最近的、且平衡因子的绝对值大于1的结点为根的子树。 这时我们就要对最小不平衡子树作出相应调整使得该树仍然为平衡二叉树。因此在构造二叉排序树的过程中,每插入一个结点时,首先检查是否因插入而破坏了树的平衡性。
设结点A为最小不平衡子树的根结点,对该子树进行平衡调整归纳起来有以下四种情况:

  1. LL型
  2. RR型
  3. LR型
  4. RL型

1.LL型:

B=A->lchild;
A->lchild=B->rchild; 
B->rchild=A;
A->bf=0;   B->bf=0;
if  (FA==NULL)   root=B; 
else  if  (A==FA->lchild)    FA->lchild=B; 
else  FA->rchild=B;
  1. RR型
B=A->rchild;
A->rchild=B->lchild;
B->lchild=A;
A->bf=0;   B->bf=0; 
if  (FA==NULL)   
	root=B; 
else  if  (A==FA->lchild) 
	FA->lchild=B; 
else
	FA->rchild=B; 

3.LR型:

4.RL型

散列表的查找技术:

概念:
前面接触到的查找技术(顺序查找,折半查找,二叉排序树等)都是通过一系列的给定值与关键码的比较,查找效率依赖于查找过程中进行的给定值与关键码的比较次数。为了提高效率,可以采用不比较的方式,在存储位置与关键码之间建立一个确定的对应关系。这样,不经过比较,一次读取就能得到所查元素的查找方法。
散列表:采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表。
散列函数:将关键码映射为散列表中适当存储位置的函数。
散列地址:由散列函数所得的存储位置址 。
关系如下:

由上图得:散列既是一种查找技术,也是一种存储技术。
是通过记录的关键码定位该记录,没有完整地表达记录之间的逻辑关系,所以,散列主要是面向查找的存储结构。
限制:散列技术一般不适用于允许多个记录有同样关键码的情况。散列方法也不适用于范围查找,不能查找最大值、最小值,也不可能找到在某一范围内的记录。
散列技术的关键问题:散列函数的设计,冲突的处理。
冲突:对于两个不同关键码ki≠kj,有H(ki)=H(kj),即两个不同的记录需要存放在同一个存储位置,ki和kj相对于H称做同义词。

设计散列函数一般应遵循以下原则:
⑴ 计算简单。散列函数不应该有很大的计算量,否则会降低查找效率。
⑵ 函数值即散列地址分布均匀。函数值要尽量均匀散布在地址空间,这样才能保证存储空间的有效利用并减少冲突。

散列函数的构造:
直接定址法:H(key) = a key + b (a,b为常数)使用条件:事先知道关键码,关键码集合不是很大且连续性较好。
除留余数法:H(key)=key mod p 除留余数法是一种最简单、也是最常用的构造散列函数的方法,并且不要求事先知道关键码的分布。 一般情况下,选p为小于或等于表长(最好接近表长)的最小素数。
数字分析法:根据关键码在各个位上的分布情况,选取分布比较均匀的若干位组成散列地址。适用情况: 事先知道关键码的分布,关键码的分布均匀:
例:关键码为8位十进制数,散列地址为2位十进制数

平方取中法:对关键码平方后,按散列表大小,取中间的若干位作为散列地址(平方后截取)。 适用情况:事先不知道关键码的分布且关键码的位数不是很大。

折叠法(分段叠加法):将关键码从左到右分割成位数相等的几部分,将这几部分叠加求和,取后几位作为散列地址。 适用情况:关键码位数很多,事先不知道关键码的分布。
例:设关键码为2 5 3 4 6 3 5 8 7 0 5,散列地址为三位。

冲突处理方法:堆积:在处理冲突的过程中出现的非同义词之间对同一个散列地址争夺的现象。
一、闭散列方法( closed hashing,也称为开地址方法,open addressing ,开放定址法)。由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,并将记录存入。
方法:
(1)线性探测法:
当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。
对于键值key,设H(key)=d,闭散列表的长度为m,则发生冲突时,寻找下一个散列地址的公式为:
Hi=(H(key)+di) % m (di=1,2,…,m-1)
线性探测法构造的散列表的查找算法:

int HashSearch1(int ht[ ], int m, int k) 
{
     j=H(k);  
     if (ht[j]==k) return j;   //没有发生冲突,比较一次查找成功
     i=(j+1) % m;
     while (ht[i]!=Empty && i!=j)  
     {
         if (ht[i]==k) return i;  //发生冲突,比较若干次查找成功
         i=(i+1) % m;    //向后探测一个位置
     }
     if (i==j) throw "溢出";
     else ht[i]=k;   //查找不成功时插入
}

(2)二次探测法:
当发生冲突时,寻找下一个散列地址的公式为:
Hi=(H(key)+di)% m
(di=12,-12,22,-22,…,q2,-q2且q≤m/2)

(3)随机探测法:
当发生冲突时,下一个散列地址的位移量是一个随机数列,即寻找下一个散列地址的公式为:
Hi=(H(key)+di)% m
(di是一个随机数列,i=1,2,……,m-1)

(4)再hash法
二、开散列方法( open hashing,也称为拉链法,separate chaining ,链地址法):
基本思想:将所有散列地址相同的记录,即所有同义词的记录存储在一个单链表中(称为同义词子表),在散列表中存储的是所有同义词子表的头指针。
设n个记录存储在长度为m的散列表中,则同义词子表的平均长度为n / m。
例:

在拉链法构造的散列表查找算法:

Node<int> *HashSearch2(Node<int> *ht[ ], int m, int k)
{     
     j=H(k);
     p=ht[j];
     while (p && p->data!=k)
           p=p->next;
     if (p->data= =k) return p;
     else { 
         q=new Node<int>; q->data=k;
         q->next= ht[j];
         ht[j]=q;  
     }
}

开散列表与闭散列表的比较:

三、建立公共溢出区:散列表包含基本表和溢出表两部分(通常溢出表和基本表的大小相同),将发生冲突的记录存储在溢出表中。
查找时,对给定值通过散列函数计算散列地址,先与基本表的相应单元进行比较,若相等,则查找成功;否则,再到溢出表中进行顺序查找。
散列查找的性能分析:
由于冲突的存在,产生冲突后的查找仍然是给定值与关键码进行比较的过程。
在查找过程中,关键码的比较次数取决于产生冲突的概率。而影响冲突产生的因素有:
(1)散列函数是否均匀
(2)处理冲突的方法
(3)散列表的装载因子
α=表中填入的记录数/表的长度
几种不同处理冲突方法的平均查找长度: