查找的基本概念:
一般情况下,被查找的对象称为查找表,查找表包含一组元素(或记录),每个元素由若干个数据项组成,并假设有能唯一标识元素的数据项,称为主关键字(默认按主关键字查找)。
查找:给定一个值k,在含有n个元素的查找表中找出关键字等于k的元素。
若找到这样的元素,表示查找成功,返回该元素的信息或该元素在表中的位置;
否则查找不成功或者查找失败,返回相应的指示信息。
若整个查找过程都在内存进行,则称之为内查找。
反之,若查找过程中需要访问外存,则称之为外查找。
查找表的分类:
静态查找表是只作查找操作的查找表,主要操作有查询某个“特定的”数据元素是否在查找表中,检索某个“特定的”数据元素及其属性。
动态查找表是在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。
查找的性能评价:
查找算法中的主要操作是关键字之间的比较,所以通常把查找过程中关键字平均比较次数也就是平均查找长度作为衡量一个查找算法效率优劣的依据。
平均查找长度ASL(Average Search Length)定义为
其中,n是查找表中元素的个数,pi是查找第i个元素的概率,一般地,除特别指出外,均认为每个元素的查找概率相等,即pi=1/n(1≤i≤n),ci是查找到第i个元素所需的关键字比较次数。
由于查找的结果有查找成功和不成功两种情况,所以平均查找长度也分为成功情况下的平均查找长度和不成功情况下的平均查找长度。
查找表T:含有n个元素。
成功情况下(概率相等)的平均查找长度ASL成功是指找到T中任一记录平均需要的关键字比较次数。
不成功情况下的平均查找长度ASL不成功是指查找失败(在T中未查找到)平均需要的关键字比较次数。
线性表的查找:顺序查找、折半查找、分块查找
线性表采用顺序表存储,
由于顺序表不适合数据修改操作(插入和删除元素几乎需要移动一半的元素)→顺序表是一种静态查找表。
若待查找的顺序表仅由若干整数构成,直接采用vector<int>向量表示。
若待排序表中每个元素除整数关键字外还有其他数据项,可以采用向量vector<T>表示,T为相应的元素类型。
顺序查找:
从顺序表的一端开始依次遍历,将遍历的元素关键字和给定值k相比较,
若两者相等,则查找成功,返回该元素的序号。
若遍历结束后,仍未找到关键字等于k的元素,则查找失败,返回-1。
默认从顺序表的前端开始遍历。
int SeqSearch1(vector<int>& R,int k) //顺序查找算法1
{ int n=R.size();
int i=0;
while (i<n && R[i]!=k)
i++; //从表头往后找
if (i>=n) return -1; //未找到返回-1
else return i; //找到后返回其序号i
}
设置一个哨兵,减少循环条件的比较次数,提高算法的效率。
int SeqSearch2(vector<int>& R,int k) //顺序查找算法2
{ int n=R.size();
R.push_back(k); //末尾添加一个哨兵
int i=0;
while (R[i]!=k) i++; //从表头往后找
if (i==n) return -1; //未找到返回-1
else return i; //找到后返回其序号i
}
算法分析:
1)仅考虑查找成功的情况
2)仅考虑查找不成功的情况
若k值不在表中,则总是需要n次比较之后才能确定查找失败,所以仅仅考虑查找不成功时对应的平均查找长度为:
3)既考虑查找成功又考虑查找不成功的情况
一个顺序表中顺序查找的全部情况(查找成功和失败)可以用一棵判定树或比较树(这里是单支二叉树)来描述。
设所有成功查找的概率,q表示不成功查找的概率,当既考虑查找成功又考虑查找不成功的情况时有p+q=1。
不妨假设p=q=0.5,并且所有关键字成功查找的概率相同,即pi=0.5/n,则成功情况下的平均查找长度为:
假设所有不成功查找的情况为m种,它们的查找概率相同,即qi=0.5/m,则不成功情况下的平均查找长度为:
合起来:
可以推出:顺序查找的时间复杂度为O(n)
总结:顺序查找的优点是算法简单,且对查找表的存储结构无特殊要求,无论是用顺序表还是用链表来存放元素,也无论是元素之间是否按关键字有序,它都同样适用。
顺序查找的缺点是查找效率低,因此,当n较大时不宜采用顺序查找。
折半查找:
查找表R[0..n-1]为递增有序顺序表
R[low..high]是当前的非空查找区间(下界为low,上界为high),中点位mid=[(low+high)/2](或者mid=(low+high)>>1),k值与R[mid]比较:
若k=R[mid],则查找成功并返回该元素的序号mid。
若k<R[mid],则在左子表R[low..mid-1]中查找,即下界不变,上界改为mid-1。
若k>R[mid],则在右子表R[mid+1..high]中查找,即下界改为mid+1,上界不变。
下一次查找是针对非空新查找区间进行的,其过程与上述过程类似。若新查找区间为空,表示查找失败,返回-1。
//拆半查找非递归算法
int BinSearch1(vector<int>& R,int k) //拆半查找非递归算法
{ int n=R.size();
int low=0,high=n-1;
while (low<=high) //当前区间非空时
{ int mid=(low+high)/2; //求查找区间的中间位置
if (k==R[mid]) //查找成功返回其序号mid
return mid;
if (k<R[mid]) //继续在R[low..mid-1]中查找
high=mid-1;
else //k>R[mid]
low=mid+1; //继续在R[mid+1..high]中查找
}
return -1; //当前查找区间空时返回-1
}
//拆半查找递归算法
int BinSearch2(vector<int>& R,int k) //拆半查找递归算法
{
return BinSearch21(R,0,R.size()-1,k);
}
int BinSearch21(vector<int>& R,int low,int high,int k)
//被BinSearch2方法调用
{ if (low<=high) //当前查找区间非空时
{ int mid=(low+high)/2; //求查找区间的中间位置
if (k==R[mid]) //查找成功返回其序号mid
return mid;
if (k<R[mid]) //递归在左区间中查找
return BinSearch21(R,low,mid-1,k);
else //k>R[mid],递归在右区间中查找
return BinSearch21(R,mid+1,high,k);
}
else return -1; //当前查找区间空时返回-1
}
算法分析:
一个有序顺序表R中所有元素的折半查找过程可用一棵判定树或比较树(这里是二叉树)来描述。
从成功和不成功情况下的平均查找长度看出,折半查找的时间复杂度为O(log2n),是一种高效的查找算法。
STL中的折半查找算法
对于以数组为低层结构的有序表(如数组、vector或者deque容器等),STL中提供了一系列以折半查找为基础的快速查找通用算法。
binary_search(beg,end,x,[comp])
在[beg,end)范围内查找x,如果找到则返回true,否则返回false。其中comp是与排序一致的比较函数,省略时使用底层类型的小于运算符。
lower_bound(beg,end,x,[comp])
在[beg,end)范围内查找第一个大于等于x的元素地址。其中comp是与排序一致的比较函数,省略时使用底层类型的小于运算符。
upper_bound(beg,end,x,[comp])
在[beg,end)范围内查找第一个大于x的元素地址,即插入点位置。其中comp是与排序一致的比较函数,省略时使用底层类型的小于运算符。
equal_range(beg,end,x,[comp])
返回一对地址,第一个即first为lower_bound的结果,第二个second为upper_bound的结果。其中comp是与排序一致的比较函数,省略时使用底层类型的小于运算符。
#include<iostream>
#include<vector>
#include<deque>
#include<algorithm>
using namespace std;
int main()
{ int a[]={1,2,2,2,3};
int n=sizeof(a)/sizeof(a[0]);
bool flag=binary_search(a,a+n,2);
printf(“%d\n”,flag); //输出1→数组a中存在元素2
int first=lower_bound(a,a+n,2)-a; //通过-a得到查找元素的序号
printf("%d\n",first); //输出1→a[1]是第一个>=2的元素
int last=upper_bound(a,a+n,2)-a;
printf("%d\n",last); //输出4 a[4]是第一个>2的元素
pair<int* ,int* > ia=equal_range(a,a+n,2);
printf("%d %d\n",ia.first-a,ia.second-a); //输出1和4
vector<int> v={1,2,2,2,3};
flag=binary_search(v.begin(),v.end(),2);
printf("%d\n",flag); //输出1,向量v中存在元素2
first=lower_bound(v.begin(),v.end(),2)-v.begin();
printf("%d\n",first); //输出1,v[1]是第一个>=2的元素
last=upper_bound(v.begin(),v.end(),2)-v.begin();
printf("%d\n",last); //输出4,v[4]是第一个>2的元素
pair<vector<int>::iterator,vector<int>::iterator> its
=equal_range(v.begin(),v.end(),2);
printf("%d %d\n",its.first-v.begin(),its.second-v.begin());
//输出1和4
return 0;
}
折半查找算法的变形算法设计:
当关键字k重复时基本折半查找一定能够找到一个关键字为k的元素,但不能确定是哪一个关键字为k的元素。需要利用折半查找算法的变形来实现,像STL中lower_bound通用算法查找第一个大于等于x的元素,upper_bound通用算法查找第一个大于x的元素,它们都是折半查找的变形算法。
设计折半查找的变形算法需要注意如下几点:
1.确定边界。while循环的条件是low<=high(查找区间不空时循环)还是low<high(查找区间有2个或者以上元素时循环)。
2.通常折半查找变形算法每次比较产生两个分支,每个分支对应的low或者high的修改是什么?如果边界是low<=high,mid=(low+high)/2,若某个分支修改low是low=mid,当low=high时,下一步的查找区间没有变化会导致死循环,必须避免出现这样的情况。
3.在循环结束时,分析此时目标元素的形态,确定算法返回位置的正确表示形式。
折半查找变形算法的通用设计方法:
用谓词p(x)表示解空间中结点x满足的条件,该谓词是一个bool函数,不同的问题中该谓词也不同。
在解空间(这里的解空间就是判定树)按谓词的真假来选择一个分支。
1)lower_bound查找算法设计
该算法是在有序表R中查找第一个大于等于k的元素(R中可能没有关键字k的元素,也有可能有多个),简单地说就是查找k的插入点,关键字k的插入点定义为将k插入R中使其有序的第一个位置。
默认R是递增有序的。
例如,R=(1,3,3,3,5,8),k=0的插入点为0,k=3的插入点为1,k=5的插入点为4,k=10的插入点为6。
对于R[0..n-1],插入点可能是0~n-1,还可能是n(当k大于R中所有元素时),所以初始查找区间是[0,n]而不是[0,n-1]。
设置p(x)为“x>=k”,即查找关键字大于等于k的元素。
如何保证找到p(x)为真的第一个元素呢?若查找区间为[low,high],置mid=(low+high)/2。
当p(R[mid])为真时,需要在左区间[low,mid](含mid)中继续查找,即修改high=mid。当p(R[mid])为假时与基本折半查找一样在右区间中查找。
查找区间至少含2个元素(因为low=high时出现死循环),所以确定边界是low<high。
这样在最后查找区间为[low,low]时,low就是插入点。
int* lower_bound1(vector<int>& R,int n,int k)
{ int low=0,high=n;
while (low<high)
{ int mid=(low+high)/2;
if (R[mid]>=k) //p(x)="x>=k",谓词为true
high=mid; //在左区间中查找
else //谓词为false
low=mid+1; //在右区间中查找
}
return& R[low]; //返回R[low]元素地址
}
STL中对应的简化版算法
int* lower_bound2(vector<int>& R,int n,int k) //STL版本
{ int low=0,mid;
int half,len;
len=n;
while (len>0)
{ half=len/2;
mid=low+half;
if (R[mid]>=k) //p(x)="x>=k",谓词为true
len=half; //左区间(以R[low]开始的len个元素
//含R[mid])中查找,low不变
else //谓词为false
{ low=mid+1; //修改low
len=len-half-1; //在右区间中查找
}
}
return& R[low]; //返回R[low]元素地址
}
int* lower_bound3(vector<int>& R,int n,int k)
{ int low=0,high=n-1;
while (low<=high) //当前区间至少有一个元素时
{ int mid=(low+high)/2; //求查找区间的中间位置
if (R[mid]>=k) //p(x)为x>=k,谓词为true
high=mid-1; //在R[low..mid-1]中查找,low不变
else //谓词为false
low=mid+1; //在R[mid+1..high]中查找
}
return& R[low]; //返回R[low]或者R[high+1]元素地址
}
索引存储结构和分块查找
索引存储结构 = 数据表 + 索引表
索引存储结构是在采用数据表存储数据的同时,还建立附加的索引表。
索引表中的每一项称为索引项,索引项的一般形式为(关键字,地址),
其中,关键字唯一标识一个元素,地址为该关键字元素在数据表中的存储地址,整个索引表按关键字有序排列。
按关键字k的查找过程:
先在索引表按折半查找方法找到关键字为k的索引项,得到其地址,所花时间为O(log2n)。
再通过地址在数据表中找到对应的元素,所花时间为O(1),合起来的查找时间为O(log2n)。
分块查找过程:
查找索引表(有序):可以顺序查找块,也可以二分查找块。
查找数据块(无序):只能顺序查找块中元素。
struct IdxType //索引表类型
{ int key; //关键字(这里是对应块中的最大关键字)
int link; //该索引块在数据表中的起始下标
};
//假设数据表长度为n,分为b个块,块长度为s。创建索引表I[0..b-1]
void CreateI(vector<int>& R,IdxType I[],int b) //构造索引表I[0..b-1]
{ int n=R.size();
int s=(n+b-1)/b; //每块的元素个数
int j=0;
int jmax=R[j];
for (int i=0;i<b;i++) //构造b个块
{ I[i].link=j;
while (j<=(i+1)*s-1 && j<=n-1)
//j遍历一个块,找其中最大关键字jmax
{ if (R[j]>jmax) jmax=R[j];
j++;
}
I[i].key=jmax;
if (j<=n-1) //遍历完,jmax置为下一个块首元素关键字
jmax=R[j];
}
}
int BlkSearch(vector<int>& R,IdxType I[],int b,int k)
//在R[0..n-1]和索引表I[0..b-1]中查找k
{ int n=R.size();
int low=0,high=b-1;
while (low<=high) //在索引表中折半查找,找到块号为high+1
{ int mid=(low+high)/2;
if (k<=I[mid].key) high=mid-1;
else low=mid+1;
}
if (high+1>=b) return -1; //块号超界,查找失败,返回-1
int i=I[high+1].link; //求所在块的起始位置
int s=(n+b-1)/b; //求每块的元素个数s
if (i==b-1) //第i块是最后块时
s=n-s*(b-1);
while (i<=I[high+1].link+s-1 && R[i]!=k)
i++; //在对应块中顺序查找k
if (i<=I[high+1].link+s-1)
return i; //查找成功,返回该元素的序号
else
return -1; //查找失败,返回-1
}
int main()
{ vector<int> R={8,14,6,9,10,22,34,18,19,31,40,38,54,66,46,71,
78,68,80,85,100,94,88,96,87};
int b=5;
IdxType *I=new IdxType[b];
CreateI(R,I,b);
printf("\n (1)初始数据\n ");
for (int i=0;i<R.size();i++)
printf("%d ",R[i]);
printf("\n (2)创建索引块(分为b=5个块)\n");
for (int i=0;i<b;i++)
printf(" 块%d: [%3d,%2d]\n",i,I[i].key,I[i].link);
printf(" (3)分块查找\n");
for (int i=0;i<R.size();i+=2)
{ int k1=R[i],k2=R[i+1];
printf(" k=%3d的位置:%2d\t k=%3d的位置:%2d\n",
k1,BlkSearch(R,I,b,k1),k2,BlkSearch(R,I,b,k2));
}
return 0;
}
分块查找性能分析:若有n个元素,每块中有s个元素(块数b=[n/s])
用折半查找确定元素所在的块,则分块查找成功时的平均查找长度为:
当s越小时,ASLblk的值越小,即当采用折半查找确定块时,每块的长度越小越好。
用顺序查找确定元素所在的块,则分块查找成功时的平均查找长度为:
树表的查找:
几种特殊树形结构—统称为树表。
这里的树表采用链式存储结构,由于链式存储结构既适合查找,也适合数据修改,属于动态查找表。
对于动态查找表,不仅要讨论查找方法,还讨论修改方法。
二叉排序树:BST
若它的左子树非空,则左子树上所有结点值(默认为结点关键字)均小于根结点值。
若它的右子树非空,则右子树上所有结点值均大于根结点值。
左、右子树本身又各是一棵二叉排序树。
template <typename T1,typename T2>
struct BSTNode //二叉排序树结点类
{ T1 key; //存放关键字,假设关键字为T1类型
T2 data; //存放数据项,假设数据项为T2类型
BSTNode* lchild; //存放左孩子指针
BSTNode* rchild; //存放右孩子指针
BSTNode(T1 k,T2 d) //构造函数
{ key=k;
data=d;
lchild=rchild=NULL; //新建结点默认为叶子结点
}
};
template <typename T1,typename T2>
class BSTClass //二叉排序树类模板
{
public:
BSTNode<T1,T2>* r; //二叉排序树根结点
BSTNode<T1,T2>* f; //用于临时存放待删除结点的双亲
BSTClass() //构造函数
{ r=NULL; f=NULL; }
~BSTClass() //析构函数
{ DestroyBTree(r); //调用DestroyBTree()函数
r=NULL;
}
void InsertBST(T1 k,T2 d) //插入一个(k,d)结点
{
r=_InsertBST(r,k,d);
}
//在以p为根的BST中插入关键字为k的结点
BSTNode<T1,T2>* _InsertBST(BSTNode<T1,T2>* p,T1 k,T2 d)
{ if (p==NULL) //原树为空,为根结点
p=new BSTNode<T1,T2>(k,d);
else if (k<p->key)
p->lchild=_InsertBST(p->lchild,k,d); //插入到p的左子树中
else if (k>p->key)
p->rchild=_InsertBST(p->rchild,k,d); //插入到p的右子树中
else //相同关键字,修改data域
p->data=d;
return p;
}
//创建二叉排序树r是从一个空树开始,先创建根结点,以后每插入一个关键字k,就调用一次InsertBST(k,d)算法将(k,d)插入到当前的二叉排序树中。
//由a和b向量创建一棵二叉排序树
void CreateBST(vector<T1>& a,vector<T2>& b)
{ r=new BSTNode<T1,T2>(a[0],b[0]); //创建根结点
for (int i=1;i<a.size();i++) //创建其他结点
InsertBST(a[i],b[i]); //插入(a[i],b[i])
}
BSTNode<T1,T2>* SearchBST(T1 k) //在二叉排序树中查找关键字为k的结点
{
return _SearchBST(r,k); //r为二叉排序树的根结点
}
BSTNode<T1,T2>* _SearchBST(BSTNode<T1,T2>* p,T1 k)
//被SearchBST方法调用
{ if (p==NULL) return NULL; //空树返回NULL
if (p->key==k) return p; //找到后返回p
if (k<p->key)
return _SearchBST(p->lchild,k); //在左子树中递归查找
else
return _SearchBST(p->rchild,k); //在右子树中递归查找
}
bool DeleteBST(T1 k) //删除关键字为k的结点
{ f=NULL;
return _DeleteBST(r,k,-1); //r为二叉排序树的根结点
}
bool _DeleteBST(BSTNode<T1,T2>* p,T1 k,int flag)
//被DeleteBST方法调用
{ if (p==NULL)
return false; //空树返回false
if (p->key==k)
return DeleteNode(p,f,flag); //找到后删除p结点
if (k<p->key)
{ f=p;
return _DeleteBST(p->lchild,k,0); //在左子树中递归查找
}
else
{ f=p;
return _DeleteBST(p->rchild,k,1); //在右子树中递归查找
}
}
/*从二叉排序树中删除结点p是通过修改其双亲的相关指针实现的。
为此需要标识结点p的双亲结点f,并且用flag标识结点p是结点f的何种孩子,
flag=-1表示结点p是根结点没有双亲,flag=0表示结点p是结点f的左孩子,flag=1表示结点p是结点f的右孩子。
所以,删除中的查找不能简单地采用前面的查找算法,而需要在查找中确定结点p对应的双亲结点f和左右孩子标记flag*/
bool DeleteNode(BSTNode<T1,T2>* p,BSTNode<T1,T2>* f,int flag)
//删除结点p(其双亲为f)
{ if (p->rchild==NULL) //结点p只有左孩子(含p为叶子的情况)
{ if (flag==-1) //结点p的双亲为空(p为根结点)
r=p->lchild; //修改根结点r为p的左孩子
else if (flag==0) //p为双亲f的左孩子
f->lchild=p->lchild; //将f的左孩子置为p的左孩子
else //p为双亲f的右孩子
f->rchild=p->lchild; //将f的右孩子置为p的左孩子
}
else if (p->lchild==NULL) //结点p只有右孩子
{ if (flag==-1) //结点p的双亲为空(p为根结点)
r=p->rchild; //修改根结点r为p的右孩子
else if (flag==0) //p为双亲f的左孩子
f->lchild=p->rchild; //将f的左孩子置为p的右孩子
else //p为双亲f的右孩子
f->rchild=p->rchild; //将f的右孩子置为p的右孩子
}
else //结点p有左右孩子
{ BSTNode<T1,T2>* f1=p; //f1为结点p的双亲结点
BSTNode<T1,T2>* q=p->lchild; //q转向结点p的左孩子
if (q->rchild==NULL) //若结点q没有右孩子
{ p->key=q->key; //将被删结点p的值用q的值替代
p->data=q->data;
p->lchild=q->lchild; //删除结点q
}
else //若结点q有右孩子
{ while (q->rchild!=NULL) //找到最右下结点q,其双亲结点为f1
{ f1=q;
q=q->rchild;
}
p->key=q->key; //将被删结点p的值用q的值替代
p->data=q->data;
f1->rchild=q->lchild; //删除结点q
}
}
return true;
}
};
注意:
一个关键字集合可以有多个不同顺序的关键字序列,对于不同的关键字序列,CreateBST()算法创建的二叉排序树可能不同。
例如,关键字序列为(5,2,1,6,7,4,3),创建的二叉排序树如图(a)所示。若关键字序列为(1,2,3,4,5,6,7),创建的二叉排序树如图(b)所示。
分析二叉排序树的查找性能:
1.给定含n个关键字的集合,假设所有关键字不相同,对应有n!个关键字序列,每个关键字序列构造一棵二叉排序树,所有这些二叉排序树中查找每个关键字的平均时间为O(log2n)。
2.给定含n个关键字的关键字序列构造一棵二叉排序树。其中查找性能最好的是高度最小的二叉排序树,最好查找性能为O(log2n)。查找性能最坏的是高度为n的二叉排序树(单支树),最坏查找性能为O(n)。平均情况由具体的关键字序列来确定。所以常说二叉排序树的时间复杂度在O(log2n)和O(n)之间,就是指这种分析方法。
平衡二叉树:
既保持BST性质又保证树的高度较小,通过这样的平衡规则和操作来维护O(log2n)高度的二叉排序树称为平衡二叉树,平衡二叉树有多种。
较为著名的有AVL树。
AVL树的高度平衡性质:树中每个结点的左、右子树的高度至多相差1。
也就是说,如果树T中结点v有孩子结点x和y,则|h(x)-h(y)|≤1,h(x)表示以结点x为根的子树高度。
template <typename T1,typename T2>
struct AVLNode //AVL树结点类模板
{ T1 key; //关键字k
T2 data; //关键字对应的值d
int ht; //当前结点的子树高度
AVLNode* lchild,*rchild; //左右指针
AVLNode(T1 k,T2 d) //构造函数,新建结点均为叶子,高度为1
{ key=k;
data=d;
ht=1; //当前结点的子树高度
lchild=rchild=NULL;
}
};
template <typename T1,typename T2>
class AVLTree //AVL树类模板
{ AVLNode* r; //AVL的根结点
public:
AVLTree():r(NULL) {} //构造函数
int getht(AVLNode* p) //返回结点p的子树高度
{ if (p==NULL) return 0;
return p->ht;
}
//AVL树的其他基本运算算法
};
如何使构造的二叉排序树是一棵AVL树呢?
关键是每次向树中插入新结点时使所有结点的平衡因子满足高度平衡性质,这就要求插入后一旦哪些结点失衡就要进行调整。
- AVL树插入结点的调整方法:
1)LL型调整→右旋转算法
AVLNode* LL(AVLNode* a) //LL型调整
{
return right_rotate(a);
}
AVLNode* right_rotate(AVLNode* a) //以结点a为根做右旋转
{ AVLNode* b=a->lchild;
a->lchild=b->rchild;
b->rchild=a;
a->ht=max(getht(a->rchild),getht(a->lchild))+1; //更新A结点的高度
b->ht=max(getht(b->rchild),getht(b->lchild))+1; //更新B结点的高度
return b;
}
2)RR型调整→左旋转算法
AVLNode* RR(AVLNode* a) //RR型调整
{
return left_rotate(a);
}
AVLNode* left_rotate(AVLNode* a) //以结点a为根做左旋转
{ AVLNode* b=a->rchild;
a->rchild=b->lchild;
b->lchild=a;
a->ht=max(getht(a->rchild),getht(a->lchild))+1; //更新A结点的高度
b->ht=max(getht(b->rchild),getht(b->lchild))+1; //更新B结点的高度
return b;
}
3)LR型双旋转算法:A的左子树B先左旋转,再按根结点A右旋转!
AVLNode* LR(AVLNode* a) //LR型调整
{ AVLNode* b=a->lchild;
a->lchild=left_rotate(b); //结点b左旋
return right_rotate(a); //结点a右旋
}
4)RL型双旋转算法:A的左子树B先右旋转,再按根结点A左旋转!
AVLNode* RL(AVLNode* a) //RL型调整
{ AVLNode* b=a->rchild;
a->rchild=right_rotate(b); //结点b右旋
return left_rotate(a); //结点a左旋
}
2.AVL树删除结点的调整方法:
首先在AVL树中查找关键字为k的结点x(假定存在这样的结点并且唯一),删除结点x的过程如下:
(1)如果结点x左子树为空,用其右孩子结点替换它,即直接删除结点x。
(2)如果结点x右子树为空,用其左孩子结点替换它,即直接删除结点x。
(3)如果结点x同时有左右子树(这种情况下,结点x是通过值替换间接删除的,称为间接删除结点),分为两种情况:
- 若结点x的左子树较高,在其左子树中找到最大结点q,直接删除结点q,用结点q的值替换结点x的值。
- 若结点x的右子树较高,在其右子树中找到最小结点q,直接删除结点q,用结点q的值替换结点x的值。
(4)当直接删除结点x时,沿着其双亲到根结点方向逐层向上求结点的平衡因子,若一直找到根结点时路径上的所有结点均平衡,说明删除后的树仍然是一棵平衡二叉树,不需要调整,删除结束。若找到路径上的第一个失衡结点p,就要进行调整。
- 若直接删除的结点在结点p的左子树中。
- 若直接删除的结点在结点p的右子树中,调整过程类似。
3.AVL树的查找
STL中的关联容器:
所谓关联容器就是容器中每个元素有一个key(关键字),通过key来存储和读取元素。
STL中的关联容器有集合(set)和映射(map)两类,均采用红黑树组织数据。
红黑树是一种弱平衡二叉树,在维护平衡的成本上比AVL树低,插入和删除等操作都比较稳定。
由于树结构中没有位置的概念,所以关联容器没有提供顺序容器中的[],front()、push_front()、back()、push_back()以及pop_back()操作。
1. set(集合容器)/multiset(多重集容器)
set和multiset都是集合类模板,其元素值称为关键字。set中元素的关键字是唯一的,multiset中元素的关键字可以不唯一,而且默认情况下会对元素按关键字自动进行升序排列。
查找速度比较快(时间复杂度为O(log2n)),同时支持集合的交、差和并等一些集合上的运算。
STL为set/multiset提供了通用算法lower_bound(beg,end,k)返回一个迭代器指向第一个关键字大于等于k的元素,upper_bound(beg,end,k)后者返回一个迭代器指向第一个关键字大于k的元素。
#include<iostream>
#include<set>
using namespace std;
int main()
{ set<int> s; //定义set容器s
set<int>::iterator it; //定义set容器迭代器it
s.insert(1);
s.insert(3);
s.insert(2);
s.insert(2); //2重复,不会插入
printf(" s: ");
for (it=s.begin();it!=s.end();it++)
printf("%d ",*it); //输出:1 2 3
printf("\n");
multiset<int> ms; //定义multiset容器ms
multiset<int>::iterator mit; //定义multiset容器迭代器mit
ms.insert(1);
ms.insert(3);
ms.insert(2);
ms.insert(2); //重复的2会插入
printf("ms: ");
for (mit=ms.begin();mit!=ms.end();mit++)
printf("%d ",*mit); //输出:1 2 2 3
printf("\n");
return 0;
}
2. map(映射容器)/multimap(多重映射容器)
map和multimap都是映射类模板。
映射是指元素类型为(key,value),其中key为关键字,value是对应的值,可以使用关键字key来访问相应的值value。
map/multimap中的key和value是一个pair结构类型。
STL为map/multimap提供了通用算法lower_bound(beg,end,k)和upper_bound(beg,end,k)等。前者返回一个迭代器指向第一个关键字大于等于k的元素,后者返回一个迭代器指向第一个关键字大于k的元素。
struct pair
{ T1 first; //关键字
T2 second; //值
}
map的构造函数
map共提供了6个构造函数,有的涉及到内存分配器。最简单的方式就是仅仅给出key和value的类型
map<int,string> stmap; //int关键字为学号,string值为姓名
插入元素
向map中插入元素主要有3种方法,分别是用insert函数插入pair数据,用insert函数插入value_type数据,用数组方式插入数据。例如以下3个语句对应3种插入方法:
stmap.insert(pair<int, string>(1, "Mary"));
stmap.insert(map<int, string>::value_type (2, "Smith"));
stmap[3] = "John";
获取一个关键字对应的值
直接使用map[关键字]获取该关键字对应的值
执行该语句时,只有当stmap中有这个关键字(4)时才会成功返回学号4的姓名,否则会自动插入一个元素,其关键字为4,对应值为string类型的默认值空串。
string xm=stmap[4];
按关键字最小和最大元素
假设stmap容器按关键字递增有序,则:
stmap.begin()地址为存放最小关键字的结点。
--stmap.end()地址为存放最大关键字的结点。
it所指结点的前驱和后继结点
若迭代器it指向stmap容器中的某个非空结点,则:
--it指向其前驱结点,++it指向其后继结点。
迭代器不能执行±i运算,所以it-1和it+1是错误的。
按关键字查找元素
(1)用count函数来判定关键字是否出现,其返回值要么是0,要么是1。其缺点是无法定位关键字对应元素出现位置。例如:
if (stmap.count(1)!=0) //判断stmap中是否存在关键字为1的元素
cout << "查找成功" << endl;
else
cout << "查找失败" << endl;
(2)用find函数来定位元素出现位置,它返回的一个迭代器,当查找成功时返回元素所在位置的迭代器,查找失败时返回end()函数值。例如:
map<int,string>::iterator it;
it=stmap.find(2);
if (it!=stmap.end()) //在stmap查找关键字为2的元素
cout << "查找成功,其姓名是" << it->second << endl;
else
cout << "查找失败" << endl;
(3)使用lower_bound或者upper_bound函数查找关键字,与有序vector向量的折半查找类似。
删除元素
map<int,string>::iterator it=stmap.begin();
stmap.erase(it); //删除第一个元素
stmap.erase(3); //删除关键字为3的元素
修改元素
stmap[1]="June"; //直接将学号1的姓名改为"June"
map中元素排序
map默认利用pair的“<”运算符将所有元素按key的升序排列,不能对map使用sort通用算法排序。
可以像使用sort通用算法一样定制自己的关系比较函数以确定排序顺序。
B树:一种外查找的数据组织结构
B树中所有结点的最大子树个数称为B树的阶,通常用m表示,从查找效率考虑,要求m≥3。
一棵m阶B树或者是一棵空树,或者是满足下列要求的m叉树:
(1)树中每个结点至多有m棵子树(即至多含有m-1个关键字,设Max=m-1)。
(2)若根结点不是叶子结点,则根结点至少有两棵子树。
(3)除根结点外,所有结点至少有[m/2]棵子树(即至少含有[m/2]-1个关键字,设Min=[m/2]-1)。
(4)每个结点的结构如下:
(5)所有的叶子结点在同一层。
1.B树的查找
在B树中查找给定关键字的方法类似于二叉排序树上的查找,不同的是在每个结点上确定向下查找的路径不一定是二路的,而是n+1路的(n为该结点的关键字个数)。
2.B树的插入
利用前述的查找过程找到关键字k的插入结点p(注意m阶B树的插入结点一定是某个叶子结点)。
判断结点p是否还有空位置,即其关键字个数n是否满足n<Max(Max=m-1):
① 若n<Max成立,说明结点p有空位置,直接把关键字k有序插入到结点p中(插入关键字k后结点p的所有关键字仍有序)。
② 若n=Max,说明结点p没有空位置,需要把结点p分裂成两个。
如果此时双亲结点的关键字个数也超过Max,则要再分裂,再往上插,直至这个过程传递到根结点为止。如果根结点也需要分裂,则整个m阶B树增高一层。
3.B树的删除
利用前述的查找算法找出关键字k所在的结点p。
实施关键字k的删除操作。结点p分为两种情况,情况一是结点p是叶子结点,情况二是结点p不是叶子结点。
情况二转换为情况1:
转换过程:当结点p不是叶子结点时,假设结点p中关键字key[i]=k(1≤i≤n),以p[i](或p[i-1])所指右子树(或左子树)中的最小关键字mink(或最大关键字maxk)来替代被删关键字key[i](值替代),再删除关键字mink(或maxk)。
现在考虑情况1,即在m阶B树的某个叶子结点q中删除关键字k'=mink(或者k'=maxk),根据结点q中关键字个数n又分为以下三种子情况:
① 若n>Min(=[m/2]-1),说明删除关键字k'后该结点仍满足B树的定义,则可直接从结点q中删除关键字k'。
② 若n=Min,说明删除关键字k'后该结点不满足B树的定义,此时若结点q的左(或右)兄弟可以借 →借一个关键字。
③ 假如结点q的关键字个数等于Min,并且该结点的左和右兄弟结点都不能借 →合并。
B+树
在索引文件组织中,经常使用B树的一些变形,其中B+树是一种应用广泛的变形。一棵m阶B+树满足下列条件:
(1)每个分支结点至多有m棵子树。
(2)根结点或者没有子树,或者至少有一棵子树。
(3)除根结点外,其他每个分支结点至少有m/2棵子树。
(4)有n棵子树的结点有n个关键字。
(5)所有叶子结点包含全部关键字及指向相应数据记录的指针,而且叶子结点按关键字大小顺序链接(每个叶子结点的指针指向数据文件中的记录)。
(6)所有分支结点(可看成是索引)中仅包含各子树中最大关键字
m阶的B+树和m阶的B树的主要的差异:
(1)在B+树中,具有n个关键字的结点对应n棵子树,即每个关键字对应一棵子树,而在B树中,具有n个关键字的结点对应n+1棵子树。
(2)在B+树中,每个结点(除根结点外)中的关键字个数n的取值范围是m/2≤n≤m,根结点n的取值范围是1≤n≤m。
(3)B+树中的叶子结点层包含全部关键字,即其他非叶子结点中的关键字包含在叶子结点中,而在B树中,所有关键字是不重复的。
(4)B+树中所有非叶子结点仅起到索引的作用,即这些结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录。而在B树中,每个结点的关键字都含对应的记录。
(5)在B+树上有两个标识指针,一个是指向根结点的root,另一个是指向关键字最小叶子结点的sqt,所有叶子结点链接成一个不定长的线性链表,所以B+树既可以通过root随机查找,也可以通过sqt顺序查找。而在B树只能随机查找。
哈希表查找:
哈希表:
设要存储的元素个数为n,设置一个长度为m(m≥n)的连续内存单元。
以每个元素的关键字ki(0≤i≤n-1)为自变量,通过一个哈希函数h把ki映射为内存单元的地址(或相对地址)h(ki)。并把该元素存储在这个内存单元中。
对于两个不同的关键字ki和kj(i≠j)出现h(ki)=h(kj),这种现象称为哈希冲突。
将具有不同关键字而具有相同哈希地址的元素称为“同义词”,这种冲突也称为同义词冲突。
哈希函数构造方法:
构造哈希函数的目标:使得到的哈希地址尽可能均匀地分布在m个连续内存单元地址上,同时使计算过程尽可能简单以达到尽可能高的时间效率。
根据关键字的结构和分布的不同,有多种构造哈希函数的方法。
-
直接定址法
以关键字k本身或关键字加上某个数值常量c作为哈希地址的方法。即h(k)=k+c。
这种哈希函数计算简单,并且不可能有冲突发生。
当关键字的分布基本连续时,可用直接定址法的哈希函数;否则,若关键字分布不连续将造成内存单元的大量浪费。
- 除留余数法
用关键字k除以某个不大于哈希表长度m的数p所得的余数作为哈希地址的方法。
除留余数法的哈希函数h(k)为:h(k)=k mod p (mod为求余运算,p≤m→保证地址有效)
p最好是质数(素数)→保证冲突尽可能小。
- 数字分析法
提取关键字中取值较均匀的数字位作为哈希地址的方法。
适合于所有关键字值都已知的情况,并需要对关键字中每一位的取值分布情况进行分析。
如果关键字是字符串,需要将字符串转换为唯一的整数值,例如BKDRHash函数就是一种简单快捷的哈希转换算法。
unsigned int BKDRHash(char* str) //生成str字符串的哈希函数值
{ unsigned int seed=131; //种子值,也可以为31、131、
//1313、13131、131313等
unsigned int hash = 0;
while (*str)
hash=hash*seed+(*str++);
return hash;
}
哈希冲突解决方法:
在哈希表中,虽然冲突很难避免,但发生冲突的可能性却有大有小。这主要与三个因素有关:
与装填因子有关。所谓装填因子α是指哈希表中已存入的元素数n与哈希地址空间大小m的比值,即α=n/m。α越小,冲突的可能性就越小; 但α越小,存储空间的利用率就越低。
与所采用的哈希函数有关。
与解决冲突的方法有关。
解决哈希冲突方法有许多,可分为开放定址法和拉链法两大类。
1. 开放定址法
发生冲突时查找周围一个空位置存放元素(记录)。设置一个查找周围一个空位置的函数。
(1)线性探测法
从发生冲突的地址(设为d)开始,依次循环探测d的下一个地址(当到达下标为m-1的哈希表表尾时,下一个探测的地址是表首地址0),直到找到一个空闲单元为止。
描述公式为: d0=h(k),di=(di-1+1) mod m (1≤i≤m-1)
#define NULLKEY -1 //全局变量,空关键字
template <typename T>
struct HNode //哈希表元素类型
{ int key; //关键字
T value; //数据值
HNode():key(NULLKEY) {} //构造函数
HNode(int k,T v) //重载构造函数
{ key=k;
value=v;
}
};
#define MAXM 100 //哈希表最大长度
template <typename T>
class HashTable1 //哈希表(除留余数法+线性探测法)
{ int n; //哈希表中元素个数
int m; //哈希表长度
int p;
HNode<T> ha[MAXM]; //存放哈希表元素
public:
HashTable1(int m,int p) //哈希表构造函数
{ this->m=m; this->p=p;
for (int i=0;i<m;i++) //初始化为空哈希表
ha[i].key=NULLKEY;
n=0;
}
void insert(int k,T v) //在哈希表中插入(k,v)
{ int d=k % p; //求哈希函数值
while (ha[d].key!=NULLKEY) //找空位置
d=(d+1) % m; //线性探测法查找空位置
ha[d]=HNode<T>(k,v); //放置(k,v)
n++; //增加一个元素
}
//其他运算函数
};
(2)平方探测法
发生冲突时前后查找空位置。描述公式为:d0=h(k), di=(d0±i2) mod m (1≤i≤m-1)
平方探测法可以避免出现堆积问题。
缺点是不能探测到哈希表上的所有单元,但至少能探测到一半单元。
2. 拉链法
拉链法是把所有的同义词用单链表链接起来的方法。
在这种方法中,哈希表每个单元中存放的不再是元素本身,而是相应同义词单链表的首结点指针。
由于单链表中可插入任意多个结点,所以此时装填因子α根据同义词的多少既可以设定为大于1,也可以设定为小于或等于1,通常取α=0.75。
template <typename T>
struct HNode //单链表结点类
{ int key; //关键字
T value; //数据值
HNode<T>* next; //下一个结点指针
HNode() {} //构造函数
HNode(int k,T v) //重载构造函数
{ key=k;
value=v;
next=NULL;
}
};
#define MAXM 100 //哈希表最大长度
class HashTable2 //哈希表(除留余数法+拉链法)
{ int n; //哈希表中元素个数
int m; //哈希表长度
HNode<T>* ha[MAXM]; //存放哈希表中单链表首结点地址
public:
HashTable2(int m) //哈希表构造函数
{ this->m=m;
for (int i=0;i<m;i++)
ha[i]=NULL;
n=0;
}
~HashTable2() //析构函数:释放整个哈希表空间
{
//与销毁邻接表类似
}
void insert(int k,T v) //在哈希表中插入(k,v)
{ int d=k % m; //求哈希函数值
p=new HNode<T>(k,v); //新建关键字k的结点p
p->next=ha[d]; //采用头插法将p插入到ha[d]单链表中
ha[d]=p;
n++; //哈希表元素个数增1
}
//其他运算函数
};
哈希表查找及性能分析:
1.采用开放定址法建立的哈希表的查找
假设有元素类型为HashNode的哈希表ha[0..m-1],哈希函数为h(k)=k % p,采用开放定址法中的线性探测法解决冲突,哈希表中空元素的关键字为常量NULLKEY。
int search(int k) //查找关键字k,成功时返回其位置,否则返回-1
{ int d=k % p; //求哈希函数值
while (ha[d].key!=NULLKEY && ha[d].key!=k)
d=(d+1) % m; //线性探测法查找空位置
if (ha[d].key==k) //查找成功返回其位置
return d;
else //查找失败返回-1
return -1;
}
2. 采用拉链法建立的哈希表的查找
假设哈希表中单链表结点类型为HNode,哈希函数为h(k)=k % m,采用拉链法解决冲突。
成功(找到关键字为k的结点)时:返回该结点引用(地址)
失败(没有找到关键字为k的结点)时:返回NULL
HNode<T>* search(int k) //查找关键字k,成功时返回其地址,否则返回空
{ int d=k % m; //求哈希函数值
HNode<T>* p=ha[d]; //p指向ha[d]单链表的首结点
while (p!=NULL && p->key!=k)//查找key为k的结点p
p=p->next;
return p; //返回p(查找失败时p=NULL)
}
STL中的哈希表:
在C++11中新增加了4个关联容器,分别是unordered_map,unordered_set,unordered_multimap和unordered_multiset。
它们与map/multimap和set/multiset功能基本类似,主要区别这4个新增关联容器底层采用哈希表实现,查找性能更高。下面主要讨论unordered_map容器。
unordered_map容器的哈希结构如图所示,每个关键字为key元素通过一些哈希函数映射到一个特定位置,采用拉链法解决冲突。
哈希空间由桶向量构成,其中每个元素就是一个桶,指向对应的同义词单链表。每个哈希桶中可能没有结点,也可能有多个结点
template < class Key, //关键字类型
class T, //值类型
class Hash = hash<Key>, //哈希函数
class Pred = equal_to<Key>, //相等比较函数
class Alloc = allocator< pair<const Key,T> > //分配器
> class unordered_map;
unorederd_map的主要成员函数与map的大致相同,使用方法也与map的类似。但unorederd_map容器具有如下特点:
关联性:unorederd_map是一个关联容器,其中的元素根据关键字来引用,而不是根据索引来引用。
无序性:由于采用哈希结构,unordered_map中的元素不会根据其关键字值或映射值按任何特定顺序排序,而是根据其哈希值组织到桶中,以允许通过键值直接快速访问各个元素(按关键字查找的平均时间复杂度大致为O(1))。
唯一性:unorederd_map容器中的元素的关键字是唯一的。
1)创建unorederd_map容器
创建完整的unorederd_map容器比较复杂。
最简单的是像map一样仅给出<key,value>的类型,并且可以使用“{}”进行初始化。例如,以下语句创建一个hmap容器(其中int关键字为学号,string值为姓名)并通过初始化插入一个元素:
unordered_map<int,string> hmap={{1,"Mary"}}; //创建hmap并初始化
2)插入元素
向unorederd_map中插入元素主要有两种方法,分别是用insert函数插入pair数据和用数组方式插入数据。例如以下两个语句对应两种插入方法:
hmap.insert(pair<int, string>(2, "Smith"));
hmap[3] = "John";
3)获取一个关键字对应的值
string xm=hmap[4];
与map一样,执行该语句时,只有当hmap中有这个关键字(4)时才会成功返回学号4的姓名,否则会自动插入一个元素,其关键字为4,对应值为string类型的默认值空串。
4)按关键字查找元素
(1)用count函数来判定关键字是否出现,其返回值要么是0,要么是1。其缺点是无法定位关键字对应元素出现位置。例如:
if (hmap.count(1)!=0) //判断hmap中是否存在关键字为1的元素
cout << "查找成功" << endl;
else
cout << "查找失败" << endl;
(2)用find函数来定位元素出现位置,它返回的一个迭代器,当查找成功时返回元素所在位置的迭代器,查找失败时返回end()函数值。例如:
auto it=hmap.begin(); //初始化时auto自动识别为迭代器类型
it=hmap.find(2);
if (it!=hmap.end()) //在hmap查找关键字为2的元素
cout << "查找成功,其姓名是" << it->second << endl;
else
cout << "查找失败" << endl;
5)删除元素
主要使用erase函数删除map中的元素。例如
auto it=hmap.begin();
hmap.erase(it); //删除第一个元素
hmap.erase(2); //删除关键字为2的元素
6)修改元素值
在unorederd_map中修改元素值非常简单,与map类似。例如:
hmap[1]="June"; //将学号1的姓名改为"June"