此时,p或者空或者指向比搜索关键字小的前一个节点,我们只需要检验p在0级链上的后
继是否与检索关键字相等(检索成功),否则就是检索失败。即语句:
p=p->forward[0];
if(p->key==key)return(p);
两语句中用a7key=62和62进行比较,检索成功并返回指向a7的指针。我们可以画
129
《数据结构》教案 2022-3-22
出图2.5检索表的二叉检索判定树如图2.6所示。在图2.5中用关键字值62进行检索,检
索过程中走过的节点及对应的级数i序列分别是a4、i=2,a6、i=1,a7、i=0。
a4
a2
a1
a3a5
a6
a7
a8
图2.6 对应图2.5跳跃表的检索判定树
跳跃表性能
图2.5给出的是完全平衡的理想跳跃表情况,其距离是平均划分的,和平衡二叉树一样,
要在表的插入与删除过程维护完全平衡所花费的代价太大,而所谓基于概率数据结构的方
法是说,每插入一个节点为其分配的级别(指针数)是随机的,用随机分布函数给定,其
得到一个指针的概率是50%,有两个指针的概率是25%,以此类推。程序2.3可以得到一个
随机级数分配数:
程序2.3 随机级数产生函数
int randomlevel()
{
return(rand()%RANGE);
}
在函数调用前应该使用初始化随机数发生器函数srand():
srand((unsigned int)time(0));
srand()的头部函数是stdlib.h,而时间函数time()的头部函数是time.h。取模操作
将随机数范围限制在RANGE以内。下面是RANGE=7,一个循环内连续100次调用randomlevel()
所返回的随机函数值在0~7之间的分布关系(由左至右分别是指针的级数0,1,…9),共有
五次循环过程。
16,15,9,14,17,15,14,0,0,0
9,18,17,12,16,16,12,0,0,0
20,12,10,19,20,8,11,0,0,0
17,13,9,9,18,18,16,0,0,0
18,15,11,15,14,10,17,0,0,0
即,获得0级指针的节点占总数的16%,获得1级指针的节点占总数的14%,获得2级
指针的节点占总数的11%,获得3级指针的节点占总数的13%,…,获得7级指针的节点占
总数的14%。这样的随机分布如果是模拟掷骰子倒是不错,因为是分布平均。但显然并不符
合我们的需求。看来我们应该寻找一种更合适的随机函数。
在具有n个节点的平衡跳跃表结构中,其0级链上有
nn
个节点元素,在1级链上有
2
0
2
1
130
《数据结构》教案 2022-3-22
个节点元素,…,其i级链上有
n
个节点元素。所以,构建跳跃表的时候应该尽量逼近这
i
2
1
产生新的指针级数
j
2
种结构。关键是如何确定随机函数分布关系。程序2.4给出了以概率
的方法。其中,RAND_MAX是随机函数所能返回的最大值,MAXlevel是我们为跳跃表设定的
级数上限,它与表节点数n是
log
2
n
的关系。
程序2.4 按指数分布的随机级数产生函数
int randX(int &level)
{
int i,j,t;
t=rand();
for(i=0,j=2;iRAND_MAX/j)break;
if(i>level)level=i;
//level是跳跃表当前最大级数,每插入一个新节点都应更新
return(i);
}
下面是MAXlevel=7,一个循环内连续100次调用randX()所返回的随机函数值在0~7
//返回随机分配给新节点的级数i
之间的分布关系(由左至右分别是指针的级数0,1,…9),也是共有五次循环过程。
44,27,16,5,5,1,1,1,0,0,
53,26,11,3,6,0,0,1,0,0,
54,20,13,6,3,2,0,2,0,0,
47,28,13,6,2,2,1,1,0,0,
48,23,19,6,1,1,1,1,0,0,
显然,它基本满足我们的要求。一旦确定了一个新节点的级别,下一步就是找到其插入
的位置,链接到跳跃表中。我们很关心的事情是有没有这种可能出现,就是level连续返
回多个级别很高的值,比如5或者6,造成跳跃表中许多节点都有多个指针,因为没有跳过
足够多的节点,于是插入和检索性能都比较差,类似于线性表关系。反之,很多级别低的
节点也不好,极端情况是都为零级节点,跳跃表退化成一个链表,其效率也成为O(n)。我
们说,极端性能的跳跃表有可能出现,但是其概率非常低,比如,连续插入10个节点其都
为0级节点的可能性是千分之一左右。
跳跃表优于二叉排序树的另一点是其性能与节点插入顺序无关,随着跳跃表节点数的增
加,其出现最差效率的可能性会以指数方式减少,而达到平均情况
O(log
2
n)
的可能性则迅
速增加。
跳跃表插入过程
插入步骤和检索函数中根据给定的关键字搜索表中节点过程非常类似。假设跳跃表按递
增有序。程序首先从最高级数链对跳跃表进行插入位置的搜索,并在搜索过程中用一辅助
131
《数据结构》教案 2022-3-22
指针数组updata[]纪录在每一级链的搜索路径上所走过的最远一个节点位置,见图2.5(c)
所示,它也就是要插入的新节点在每一级链上的前趋节点。因为每插入一个新节点都有可
能增加跳跃表的指针级数,从而影响到搜索的起点指针级数设置。因此,必须在随机分配
新节点指针级数的同时,更新当前跳跃表的最大级数level。程序2.4是跳跃表插入函数。
程序2.4 跳跃表插入
void insert(struct node *head,int key,int &level)
{
struct node *p,*updata[MAXlevel];
int i,newlevel;
p=head;
newlevel=randX(level);
for(i=level;i>=0;i--){
while((p->forward[i]!=0)&&(p->forward[i]->keyforward[i];
updata[i]=p;
}
p=new(struct node);
p->key=key;
//设置新节点
//随机取得新节点的级数同时更新level
//updata[i]记录了搜索过程中在各级走过的最大节点位置
for(i=0;iforward[i]=0;
for(i=0;i<=newlevel;i++){
//插入是从最高的newlevel层链直至0层链
p->forward[i]=updata[i]->forward[i];
//插入到分配到的级数链
updata[i]->forward[i]=p;
}
}
我们为跳跃表设置一个独立头节点,初始化设置函数如程序2.5。对于较小的跳跃表,
最大级数限制MAXlevel取10就足够了。注意,主程序中要对随机函数作初始化设置:
srand((unsigned int)time(0));
//随机函数种子
程序2.5 跳跃表初始化设置
struct node *initialization(int &level,int &total)
{
int i;
struct node *head;
head=new(struct node);
for(i=0;iforward[i]=0;
//head节点的初始设置
head->key=0;
132
《数据结构》教案 2022-3-22
level=0;
total=0;
//设置跳跃表当前的级数为0
//节点总数为0
return(head);
}
2.3 分块检索
分块检索是存储器索引上经常采用的一种方法,效率介于顺序检索与对半检索之间。它
要求检索表分块有序。若以关键码检索,则要求每一块内的关键码是此块内最大或最小的。
因此,块内的关键码排列可以无序,但块与块之间的关键码是有序的。假设按递增有序,
则第一块内所有关键码均小于第二块内的关键码,第二块内所有关键码又均小于第三块内
的关键码,…,等等。
图2.7分块检索
分块检索首先建立一个索引表,把每块中最大的一个关键码按组顺序存放,显然此数组
也是递增有序的。检索时,先用对半或顺序检索方法检索此索引表,确定满足条件的节点
在哪一块中,然后,根据块地址索引找到块首所在的存储位置,再对该检索块内的元素做
顺序检索。
比如图2.7分块检索示意。索引表元素包含了每块中节点的上边界(最大关键码)与块
起始地址对,假设检索关键码是24,首先检索索引表A[1].key<24,继续是A[2].key>24,
确定24在第二块内,按A[2].link=7找到第二块起始地址,逐次比较到B2[5].key=24,检
索成功。分块检索效率取决于分块长度及块数:
E(n)E
a
E
b
E
a
是索引表中确定搜索节点所在块位置的平均检索长度,
E
b
是在块内检索节点的平均
检索长度。设有n个节点平均分成b块,每块有
s
虑检索成功,则:
n
个节点,再设检索概率相等且只考
b
1
b
b1
E
a
i
b
i1
2
显然
E
b
s1
,所以:分块检索的平均检索长度是:
2
133
《数据结构》教案 2022-3-22
bsns
2
E(n)11
22s
当
sn
时分块检索的平均检索长度:
E(n)n1n
为最小,当索引表很大的时候可以用对半检索,或者二级索引表结构进一步提高检索效率。
2.4 哈希检索
哈希检索(散列检索)是一类完全不同的检索方法,它的思想是用关键码构造一个散
列函数来生成与确定要插入或待查节点的地址,因此,可以认为它检索时间与表长无关,
只是一个函数的运算过程。
设F是一个包含n个节点的文件空间,R
i
是其中一个节点,i=1,2,...,n;K
i
是其关
键码,如果关键码K
i
与节点地址之间有一种函数关系存在,则可以通过该函数唯一确定的
把关键码值转换为相应节点在文件中的地址:
ADDR_R
i
=H(K
i
)
这里,ADDR_R
i
是R
i
的地址,H(K
i
)是地址散列函数,也叫哈希函数。所以,一旦选定了
哈希函数,就可以由关键码确定任一节点在文件中的位置,例如一文件有节点{R
1
,R
2
,R
3
},
其关键码是:
ABCD,BCDE,CDEF
选关键码第一个字符的ASCII值加上一常数1000,0000H为散列函数:
H(K)=ASCII(K的首字符)+1000,0000H
H(1)=ASCII(A)+1000,0000H = 1100,0001H
H(2)=ASCII(B)+1000,0000H = 1100,0010H
H(3)=ASCII(C)+1000,0000H = 1100,0011H
把节点按地址存放在内存空间中相应位置就形成了哈希表,用哈希函数构造表的过程,
即通过哈希函数实现由关键码到存储地址的转换过程叫哈希造表或地址散列。以同样的函
数用关键码对哈希表进行节点检索,称为哈希检索。显然,元素的散列存储是一种新的存
储结构,完全不同于链表或者是顺序存储结构。
哈希检索的实质是构造哈希函数,哈希函数的实质是实现关键码到地址的转换,即把关
键码空间映射成地址空间。因为关键码空间远大于哈希表地址空间,所以会产生不同的关
键码映射到同一哈希地址上的现象,我们称为‘地址冲突’。比如上例文件中另有一些节点
R
4
,R
5
,R
6
关键码是A1,B1,C1,则用该哈希函数散列后生成的地址如表2.1所示。关键码
ABCD 不等于A1,但经过地址散列后它们具有相同的哈希地址,即‘地址冲突’。我们定义,
具有相同函数值的关键码对该哈希函数来说是同义词,因此我们要求运用哈希检索时有:
134
《数据结构》教案 2022-3-22
由给定关键码集合构造计算简便、且地址散列均匀的哈希函数以减少冲突。
拟定处理冲突的办法。
关键码
ABCD
BCDE
CDEF
A1
B1
C1
表2.1
哈希函数
ASCII(首字符)+常数
ASCII(首字符)+常数
ASCII(首字符)+常数
ASCII(首字符)+常数
ASCII(首字符)+常数
ASCII(首字符)+常数
哈希地址
1100,0001H
1100,0010H
1100,0011H
1100,0001H
1100,0010H
1100,0011H
所谓地址散列均匀是指构造的哈希函数应尽可能的与关键码的所有部分都产生相关,因
而可以最大程度的反映不同关键码的差异,比如前例中的A1、B1、C1,如果我们不仅考虑
只取首位字符的ASCII码,而是取关键码各字母的ASCIIA值的平方和作为哈希函数,就可
以减少冲突。至于设定处理冲突的办法,是因为一般说冲突是不可避免的,我们需要寻求
一种有效处理它的手段。
2.4.1哈希函数
一般说关键码分布于一个相对大的范围而哈希表的大小却是有限的,既是如此,我们也
不能保证根据哈希函数得到的散列地址可以均匀的填满哈希表的每一个位置(槽),一个好
的哈希函数应该让大部分的元素记录可以存储在根据散列地址组织的(存储结构)槽位中,
或者说至少表的一半是满的,而产生的地址冲突可以由处理冲突的方法解决。
哈希函数的选择取决于具体应用条件下的关键码分布状况,如果预先知道其分布概率就
可以设计比较好的哈希函数,否则比较困难。
例2.3 下面这个函数把一个整数散列到表长为16的哈希表中。
int hx(int x){ return(x % 16);}
对于2字节二进制串来说,函数返回值仅由其最低四个比特位决定,分布应该很差,如
二进制串1000 0000 0000 1111(32783),对16取模运算,就是将其右移4位(空出的高
位填零补进):0000 1000 0000 0000(2048),余数是1111,而二进制串0000 0000 1111 1111
(255)对16取模运算结果是0000 0000 0000 1111,余数也是1111。
例2.4 下面是用于长度限制在10个大写英文字母的字符串哈希函数
int hx(ch x[10])
{
}
int i,sum;
for(sum=0;i=0;i<10;i++)sum+=(int)x[i];
return(sum % M);
该函数用输入字符串10个字符的ASCIIM之和,再取M的模,因为字母的ASCII码值分
135
《数据结构》教案 2022-3-22
布65~90之间,所以10个字符之和在650~900之间,显然,如果表长M在100以内其散
列地址分布的比较好(因为(650%100)=65,(900%100)=0,大致填满表的一半左右),如
果M在1000左右其散列地址会相当的差。下面列出几种常用的哈希函数方法。
直接定址法。 直接取关键码或关键码的某个线性函数值为哈希地址:
H
i
akeyb(a,bconst)
除留余数法。 取关键码被某个不大于哈希表长m的数p除后所得的余数为哈希地址。
H
i
keymodpp是质数且小于表长M
平方取中法。 取关键码平方后的中间几位为哈希地址。
随机数法。选择一个随机函数,取关键码的随机函数值为它的哈希地址。
实际工作中我们主要考虑如下因素为选择哈希函数的条件:
1·哈希函数的计算复杂性
2·哈希表大小
3·关键码分布情况
4·检索概率
2.4.2闭地址散列
设哈希地址集为0~M-1,冲突是在由关键码得到哈希地址为i,而此地址上已存放有其
它节点元素时发生,处理冲突就是为该关键码的节点寻找另一个空槽(哈希地址),称之为
再探测,探测方法有多种,在再探测过程中仍然可能会遇见槽位不空的情况,于是在探测
处理冲突可能会得到一个哈希地址序列,即多次冲突处理后才有可能找到空地址。
2.4.2.1线性探测法和基本聚集问题
这是基本的地址冲突处理方法,思想很简单,如果通过哈希函数得到基地址i,发现该
槽位不空,那么就由紧邻的地址开始,线性遍历哈希表内所有的地址,并将元素放到所发
现的第一个空槽内,线性探测函数是:
H
i
(H(key)i)modMi1,2,...,M1(2.1)
这里H
i
是由线性探测产生的哈希地址序列,H(key)是哈希函数,M是表长,i是增量序
列,称为线性探测再散列。即当哈希函数计算得地址i时如果位置i上已有节点存在,则
继续探测i+1地址是否为空,并且在不空时持续探测下去。 设H(r)是哈希函数,哈希表
Table[M]初始化为NULL,关键码r不能为NULL,基于线性探测再散列方法的插入程序如下:
程序2.6
void hashlnsert(int r)
{
int i,h0;
136
《数据结构》教案 2022-3-22
int pos=h0=H(r);
for(i=1;Table[pos] !=NULL;i++ ){
if(Table[pos])==r)return(-1);
//如果和后一条语句交换顺序则可能在基位置重复插入r;
pos=(h0+i) %M;
}
Table[pos]=r;
}
该程序假定在插入或检索过程中,哈希表至少有一个槽位为空,否则会出现无限循环过
程。从哈希表中检索一个元素是否在表内的过程,和插入元素的过程所使用的方法必须一
样,从而可以准确的找到不在基位置上的元素,返回其在哈希表内的位置。若返回值为空
则表示检索失败。
程序2.7
void hashlnsert(int r)
{
int i,h0;
int pos=h0=H(r);
for(i=1;(Table[pos] !=key)&&( Table[pos] !=NULL);i++ )pos=(h0+i) %M;
if(Table[pos]==key)return(pos);
else return(NULL);
}
例2.6 线性探测产生的基本聚集问题。哈希表长M=11,哈希函数采用除模取余,且p=M。
处理冲突的策略是线性探测,再探测函数
H
i
(H(key)i)mod11
, i=1,2,..,M-1,输入
元素序列是{9874,2009,1001,9537,3016,9875},得到哈希表如下图2.8(a)所示。
0
10010
1001
0
1001
1
1
9537
1
9537
9537
2
3016
2
3016
2
3016
3
4/11
3
3
8/11
41/11
4
41/11
5
1/11
5
5
1/11
6
1/11
6
6
1/11
7
9874
7
9874
7
9874
8
2009
8
2009
8
2009
99875
99875
99875
10
4/11
10
10
1052
(a)初始
(b)每一空槽接收下一
(c)输入1052后每一空槽接
记录的概率分布
收下一记录的概率分布
图2.8 线性探测产生的聚集现象
问(1)表中剩余的每一空槽接收下一个记录的概率;
问(2)继续输入关键字值1052后,表中剩余的每一空槽接收下一个记录的概率;
137
《数据结构》教案 2022-3-22
问(3)根据以上计算,说明随着输入记录的增加,线性探测法处理冲突所产生的问题。
解(1):理想情况下表中剩余的每一空槽接收下一个记录的概率应该是等分的,但是如图
2.8(a)的初始分布之后,假设新一个输入元素基地址是0,则它经过线性再探测一定会被
分配到空槽3的位置,同样,如果新元素基地址是1或者2,也会被分配到空槽3的位置,
考虑到新元素基地址也可能就是3,所以,空槽3接收新元素的可能性是
4
,空槽4,5,
11
6因为在其前的槽位是空,所以接收下一个新元素的时候不会产生线性探测问题,其接收概
率为
1
,而槽位10的情况与槽位3完全相同,它有可能接收前面非空槽位7,8,9的线
11
4
,因此,表中剩余的每一空槽接收下一个记
11
性探测结果,所以接收新元素的可能性也是
录的概率分布如图2.8(b)所示。
解(2):继续输入1052,哈希地址是(1052 % 11)=7,经过线性再探测它被放置到槽位
10,此时,由于槽位3前面连续7个槽位全部非空,所有基地址散列到这7个地址上的元
素都有可能被放置到槽位3,于是,经过元素1052的进入及线性再探测过程,槽位3现在
接收下一个新元素的概率已经变成
如图2.8(c)所示。
解(3):随着输入记录的增加,线性探测法处理冲突所产生的问题是记录在表内的分布出
现聚集倾向,称为基本聚集。随着聚集程度的增加,它将导致新元素进入或者检索过程中
出现很长的探测序列,严重的降低哈希表使用效率。
2.4.2.2删除操作造成检索链的中断问题
如果假设哈希表至少有一个槽位是空的(实际应用中一般不会占满整个哈系表空间),
8
,现在表中剩余每一空槽接收下一个记录的概率分布
11
则程序2.7的线性再探测过程是以或者找到一个匹配检索关键码的节点,或者找到一个空
槽为结束标志,这会带来所谓的删除操作造成检索链中断问题。
设已输入节点关键码序列是{K
1
,K
2
,…,K
i
,K
i+1
,…},通过哈希函数散列得到哈希表
如图2.9(a)所示。我们进行如下操作:
① 现在输入关键码K
j
,设它的基地址H(K
j
)=i,因非空而产生冲突,线性再探
测序列从i+1开始遍历哈希表寻找一个空槽,假定地址为i至j-1之间的槽位
均非空,但槽位j空,于是K
i
被放到表中的空槽j,如图2.9(b)所示;
② 现在将K
i+1
节点关键码删除,并重新设置槽位i+1为空;
③ 对哈希表检索K
j
节点关键码,程序首先比较基地址i槽位,非空,再探测地址
j,空槽,于是检索结果是关键码为K
j
的节点不在哈希表中。
造成这种情况的原因是由于删除操作造成了检索链中断,程序发现一个空槽后就会判别
138
《数据结构》教案 2022-3-22
已经达到检索链末端。解决的办法是设置一个标记,表明该位置曾经有元素插入过,这个
标记我们称之为墓碑,它的设置使得删除操作既不会影响该单元的继续使用,因为插入操
作的时候,如果是墓碑标记就可以直接覆盖,不会使槽位浪费;同时,墓碑也避免了因删
除操作中断了再探测的检索过程问题,因此,程序2.7的for语句中需要增加一个墓碑判
断条件。
0
K
1
1
K
2
i
K
i
(a)
i+1
K
i+1
j
Null
0
K
1
1
K
2
i
K
i
(b)
i+1
K
i+1
j
K
j
探测序列终点
0
K
1
1
K
2
i
K
i
(c)
i+1
Null
j
K
j
删除K
i+1
后置为空
图2.9删除操作造成检索链中断
删除操作带来的检索链中断问题无论对哪种再探测方法都是同样的。
2.4.2.3随机探测法
解决线性探测造成的基本聚集问题的原则,是让表中每一空槽接收下一记录的概率应尽
可能的相等,方法之一是采用随机探测序列,产生的再探测地址是从哈希表剩余的空槽中
随机选取的,其在探测函数定义如下:
H
i
(H(key)d
i
)modMi1,2,...,k(2.2)
d
i
为一组随机数序列。
例2.7 随机探测 顺序输入一组关键字(K
1
,K
2
,K
3
,K
4
,K
5
,K
6
,K
7
)得到的哈希地址是(2,
25,4,2,1,1,2),假设哈希表长29(表长是一个素数对提高哈希表性能很重要),哈希
函数是除模取余,模长p=M,用随机探测方法确定冲突后地址的函数是:
H
i
(H(key)d
i
)mod29i1,2,...,k
随机步长序列d
i
是23,2,19,14,…,求:
(1)画出该组输入下的哈希表,并写出产生冲突后地址探测函数的求值过程。
(2)指出对该哈希表进行哪类操作可能产生问题,说明原因并提出解决办法。
解(1)
K
1
=2,K
2
=25,K
3
=4,均为空槽,基地址一次散列成功;
K
4
:a
1
=((2+23)%29)=25,a
2
=((2+2)%29)=4,a
3
=((2+19)%29)=21,3次探测成功;
K
5
=1,空槽,基地址一次散列成功;
K
6
:a
1
=((1+23)%29)=24;
K
7
:a
1
=((2+23)% 29)=25,a
2
=((2+2)%29)=4,a
3
=((2+19)%29)=21,a
4
=((2+14)%29)=16;
139
《数据结构》教案 2022-3-22
所得哈希表是:
1 2
K
5
解(2)
K
1
3
4
K
3
……
……
16
K
7
……
……
21
K
4
22
23
24 25
K
6
K
2
…
29
如果对哈希表进行删除操作有可能会产生检索链中断问题,因为删除后该单元位置为
空,使在该冲突序列链上的后续记录探查不能进行,比如删除关键字K
4
后,位置21为空,
如果要检索K
7
就不可能,因为在插入K
7
的探测序列中达到过槽位21,由于槽位21不空继
续探测到槽位16(注意,检索时使用的随机探测序列就是插入过程使用的随机序列),当
K
4
删除后由于槽位21变为空,用关键码K
7
检索并用探测序列达到槽位21时,由于其为空
程序会判别是K
7
不在哈希表内,检索失败,因此,删除造成了K
7
检索链中断。
2.4.2.4平方探测法
设哈希函数是
H(key)
,
再探测函数可以写成
P(H,i)f
,
基于平方探测再散列的
f
描述
就
是
P(H,i)i
2
,即:
H
i
(H(key)i
2
)modMi1,2,...,k(2.3)
显然,可以把线性再探测和随机再探测函数统一描述如下:
P(H,i)i
,
P(H,i)array[i]
其中数组array[]长度为M-1,存储的是1~M-1的随机序列。
例2.8 平方探测 设哈希表长M=101,输入关键码K
1
,K
2
的哈希地址是H(K
1
)=30,H(K
2
)=29,
根据式(2.3)分别写出它们探测序列的前4个地址是:
K
1
探测序列={30,31,34,39,…}
K
2
探测序列={29,30,33,38,…}
显然,具有不同基位置的两个关键码在散列过程中,使用平方探测可以很快分开它们的
探测序列。但是,对于再探测序列来说,我们希望尽可能多的探测到哈希表的每一个槽位,
比如线性探测,它能遍历整个哈希表去搜寻每一个槽位是否为空,但是容易产生聚集,平
方探测显然会遗漏一些槽位,因为它是以跳跃方式进行再探测过程,不过我们可以证明,
它至少能探查到哈希表一半以上的地址。
例2.9 设2次探测序列是
H
i
(H(key)i
2
)modM,i1,2,...,k
,M是哈希表长(M为质数),
请证明,2次探测序列至少可以访问到表中的一半地址。
解:
只需证明当探测序列产生地址冲突时,序列下标大于等于
设
ij而d
i
d
j
,根据同余的定义有:
M
。
2
i
2
(modM)j
2
(modM)
∴
(ij)modM0
,或:
ij0(modM)
即
(ij)
能被M除尽,因式分解后有:
140
22
2222
《数据结构》教案 2022-3-22
(ij)(ij)0(modM)
因为M为质数且
i,jM
,所以
(ij)0(modM)
,因而:
(ij)0(modM)
ijcM
c为整数,所以
i或j
M
,证毕。
2
这里,使用了数论中同余概念与同余定理,描述如下:
同余定义:若a和b为整数,而m为正整数,如果m整除a-b,就说a与b模m同余,
记为:
ab(modm)
可以证明
ab(modm)
,当且仅当
a(modm)b(modm)
时成立。根据定义,如果整
数a和b模m同余,则a-b被m整除,显然,a-b-0也被m整除,所以,如果
ab(modm)
成立,则必有
ab0(modm)
成立。
同余定理:令m为正整数,整数a和b模m同余的充分必要条件是存在整数k使得:
abkm
2.4.2.5二次聚集问题与双散列探测方法
我们注意到,虽然随机探测和平方探测能解决基本聚集问题,但是它们产生的探测序列
是基位置的函数,如果构造的哈希函数让不同的关键码具有相同的基地址,那么它们就有
相同的探测序列而无法分开。由于哈希函数散列到一个特定基位置导致的地址聚集,我们
称之为二次聚集。
为避免二次聚集我们需要让探测序列是原来关键码值的函数,而不是基位置的函数,一
种简单的处理方法是仍然采用线性探测方法,但是我们设计有两个哈希函数H
1
(key)和
H
2
(key),它们都以关键码为自变量散列地址,其中,H
1
(key)产生一个0到M-1之间的散列
地址,而H
2
(key) 产生一个1到M-1之间、并且是和M互素的数做为地址补偿,双散列探
测序列是:
H
i
(H
1
(key)iH
2
(key))modMi1,2,...,k(2.4)
例2.10 双散列方法 仍设哈希表长M=101,输入关键码K
1
,K
2
和K
3
,它们的哈希地址分别
是H
1
(K
1
)=30,H
1
(K
2
)=28,H
1
(K
3
)=30,H
2
(K
1
)=2,H
2
(K
2
)=5,H
2
(K
3
)=5,根据式(2.4)分别写
出它们探测序列的前4个地址是:
K
1
探测序列={30,32,34,36,…}
K
2
探测序列={28,33,38,43,…}
K
3
探测序列={30,35,40,45,…}
实际上,H
1
(key)和H
2
(key)仍有可能产生相同的探测序列,比如H
1
(K
4
)=28,H
2
(K
4
)=2,
其探测序列与K
1
相同(注意,所有探测序列都是从基位置以后开始的):
141
《数据结构》教案 2022-3-22
K
4
探测序列={28,30,32,34,36,…}
我们可以进一步的考虑让随机探测与双散列方法结合,让i成为一个随机序列中选取的
随机数。
2.4.3开地址散列
设哈希函数产生的地址集在0~M-1区间,则可以设立指针向量组ARRAY[M],其每个分
量初值为空,我们将具有哈希地址j上的同义词所包含的关键码节点存储在以向量组第j
个分量为头指针的同一个线性链表内,存储按关键码有序。即,所有产生冲突的元素都被
放到一个链表内,于是,哈希散列过程转换为对链表的操作过程。
例2.11:设输入关键码为(19,14,23,1,68,20,84,27,55,11,10,79),其表长
为13,选择哈希函数是
H
i
keymod13
。则链地址法解决冲突后得到的哈希表如图2.10
所示。
图2.10链地址法散列得到的哈希表
2.4.4哈希表检索效率
散列存储结构是通过哈希函数运算得到元素散列地址的,但由于冲突的存在使得在检索
过程中它仍然是一个给定值和关键码的比较过程,因此平均检索长度仍是哈希检索的效率
量度,而检索过程中给定值和关键码的比较个数取决于哈希函数质量、处理冲突的方法以
及哈希表的装填因子。
当散列表为空的时候,第一条纪录直接插入到其基位置上,随着存储纪录的不断增加,
把记录插入到基位置上的可能性也越来越小,如果纪录被散列到一个基位置而该槽位已经
不空,则探测序列必须在表内搜索到另一空槽才行,换句话说散列过程增加了比较环节,
142
《数据结构》教案 2022-3-22
可以预计,随着纪录数的增加,越来越多的新纪录有可能被放到远离基位置的空槽内,即
探测序列越来越长而比较次数越来越多,因此,哈希检索效率预期是表填充程度的一个函
数,设表长为M,已存储记录数为N,定义装填因子是
节点个数/哈希表长度。
可以认为,新纪录插入的时候基位置被占用的可能性就是
,假定可以不考虑任何聚
N
,即,装填因子=表中填入
M
集问题,而发现基位置和探测序列下一个位置均非空的可能性是:
N(N1)
M(M1)
此时探测序列长度为2,档探测序列达到i+1时,表明在第i次探测仍然发生冲突,其
可能性是:
N(N1)...(Ni1)
M(M1)...(Mi1)
当N和M都很大时近似有
(
N
i
)
,所以,预期探测次数的期望值是1加上第i次探测产
M
生冲突的概率之和,约为:
1
(
i1
N
i
1
)
M1
即一次检索成功的代价与哈希表为空时相同,随着纪录数目的增加,平均检索长度(或
者说插入代价的均值)是装填因子从零到当前
的累积:
1
0
111
dxln()
1x
1
无论从哪方面看,哈希检索效率远高于O(log n),随着
的增加效率会降低,当
足
够小的时候,效率仍然可以小于2,当
接近50%的时候,效率接近2,因此,我们要求哈
希表工作的时候应该在半满状态,太小则表的空间浪费,太大则检索效率降低过多。
例2.12:设输入关键码为(13,29,1,23,44,55,20,84,27,68,11,10,79,14),
选择装填因子
=0.75,哈希表长M=19,哈希函数采用除模余数法,取模p=17,求:
(1) 线性探测产生的哈希表;
(2) 随机探测产生的哈希表,随机序列是{3,16,55,44,…};
(3) 平方探测产生的哈希表;
解1:线性探测法,注意线性探测中a
j
=(H(Key)+i)%M,使用的模是表长度M,而哈希散列
使用的模是p=17。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
68 1 20 55 23 44 27 29 13 11 10 84 79 14
27:a
1
=27%17=10,a
2
=(H(27)+1)%19=11;
143
《数据结构》教案 2022-3-22
11:a
1
=11%17=11,a
2
=(11+1)%19=12,a
3
=(11+2)%19=13,a
4
=(11+3)%19=14;
10:a
1
=10%17=10,a
2
=(10+1)%19=11,a
3
=(10+2)%19=12,a
4
=(10+3)%19=13,
a
5
=(10+4)%19=14,a
6
=(10+5)%19=15;
79:a
1
=79%17=11,a
2
=(79+1)%19=12,a
3
=(79+2)%19=13,a
4
=(79+3)%19=14,
a
5
=(79+4)%19=15,a
6
=(79+5)%19=16,a
7
=(79+6)%19=17;
14:a
1
=14%17=14,a
2
=(14+1)%19=15,a
3
=(14+2)%19=16,a
4
=(14+3)%19=17,
a
5
=(14+4)%19=18;
解2:随机探测法:已知a
j
=(H(K)+d
j
)%19,随机步长序列d
j
是{3,16,55,44,…},则:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
68 1 20 55 27 23 10 79 44 29 13 11 84 14
27:a
1
=27%17=10,a
2
=(27+3)%19=13,a
3
=(27+16)%19=5;
11:a
1
=11%17=11;
10:a
1
=10%17=10,a
2
=(10+3)%19=13,a
3
=(10+16)%19=7;
79:a
1
=79%17=11,a
2
=(79+3)%19=6,a
3
=(79+16)%19=0,a
4
=(79+55)%19=1,
a
5
=(79+44)%19=9;
14:a
1
=14%17=14,a
2
=(14+3)%19=17;
解3:平方探测法,已知H
i
=(H(key)+i)%19:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
68 1 20 55 23 79 27 44 11 29 13 10 14 84
2
27:a
1
=27%17=10,a
2
=(27+1)%19=9;
11:a
1
=11%17=11;
10:a
1
=10%17=10,a
2
=(10+1)%19=11,a
3
=(10+4)%19=14;
79:a
1
=79%17=11,a
2
=(79+1)%19=6,a
3
=(79+4)%19=7;
14:a
1
=14%17=14,a
2
=(14+1)%19=15;
例2.13 求成功检索图2.10哈希表的平均检索长度。
解:比较次数为1的节点有6,比较次数为2的节点有4,比较次数为3和4的节点分别只
有1个,所以,平均比较次数是:
ASL
16243421
1212
144
《数据结构》教案 2022-3-22
第三章 排序
排序是程序设计中的重要内容,它的功能是按元素的关键码把元素集合排成一个关键
码有序序列。排序有内部排序与外部排序之分,内部指计算机内部存储器,内部排序是元
素待排序列在内存中,当内存不足以容下所有元素集合时,我们把它存储在外部存储器上
进行排序运算,称为外部排序。我们首先给出排序定义,进而再讨论内部排序问题。 设
有n个元素序列{R
1
,R
2
,...,R
n
},其相应的关键码序列是{K
1
,K
2
,...,K
n
},需确定1,2,···,
n的一种排列P
1
,P
2
,···,P
n
,使其相应的关键码满足如下非递减(或非递增)关系:
K
p1
≤K
p2
≤K
p3
≤,...,≤K
pn
即序列按{R
p1
,R
p2
,R
p3
,...,R
pn
}成关键码有序序列,这种操作的过程称为排序。
当K
1
,K
2
,...,K
n
是元素主关键码时,即任何不同的元素有不同的关键码,此排序结
果是唯一的,上面的等号不成立。当K
1
,K
2
,...,K
n
是元素次主关键码时,排序结果不唯
一,此时涉及到排序稳定性问题,我们定义:
设K
i
=K
j
, 1≤i ,j≤n, 且i 不等于j
若排序前R
i
在 R
j
之前(ipi
pj
,即具有相同关键码的元素其在序列中
的相对顺序在排序前后不发生变化,则称此排序方法是稳定的。反之,若排序改变了R
pi
,
R
pj
的相对顺序,则称此排序方法是不稳定的。
在排序过程中主要有两种运算,即关键码的比较运算和元素位置的交换运算,我们以此
衡量排序算法的效率。对于简单的排序算法,其时间复杂度大约是O(n),对于快速排序算
法其时间复杂度是O(nlog
2
n)数量级的。我们要求必须掌握几种基本方法是线性插入排序,
快速排序,堆排序,归并排序等。下面从最基本的直接插入排序方法开始,进而再研究快
速排序方法的程序设计问题。
2
3.1 交换排序方法
3.1.1直接插入排序
直接插入排序是在插入第i个元素时,假设序列的前i-1个元素R
1
,R
2
,...,R
i-1
是已
排好序的,我们用K
i
与K
1
,K
2
,...,K
i-1
依次相比较,找出K
i
应插入的位置将其插入。原
位置上的元素顺序向后推移一位,存储结构采用顺序存储形式。为了在检索插入位置过程
中避免数组下界溢出,设置了一个监视哨在R[0]处。
算法3.1
1·待排序的n个元素在数组ARRAY[n+1]中,按递增排序。
145
《数据结构》教案 2022-3-22
2·for(i=2;i<=n;i++){
/*第一个元素已经有序*/
array[0]=array[i];
j=i-1;
while(array[0].key
array[j+1]=array[j];
/*顺序向后移动一位*/
j--;
}
/*循环中止时j+1指向第i个元素应插入的位置*/
/*监视哨*/
array[j+1]=array[0];
}
程序本身并不复杂,要注意监视哨的作用,当R
i
1
时程序也能正常中止循环,把R
i
插
入到R
1
位置。一个具体的线性排序例子过程见图3.1所示。
设待排序列是:1,9,4,7,6
j
[0]i
9 1 9 4 7 6
9 1 9 4 7 6
[0] j i
4 1 9 4 7 6
4 1 9 9 7 6
i
j
4 1 4 9 7 6
[0] j i
7 1 4 9 7 6
7 1 4 9 9 6
j
7 1 4 7 9 6
[0] j i
6 1 4 7 9 6
6 1 4 7 9 9
j
6 1 4 7 7 9
j
6 1 4 6 7 9
[0].key<[4].key,后移:[j+1]=[j];j--,
[0].key<[3].key, 后移:[j+1]=[j];j--,
[0].key<[2].key,不成立,跳出内循环:
[j+1]=[0];
[0].key<[3].key,后移:[j+1]=[j];j--,
[0].key<[2].key不成立,跳出内循环:
[j+1]=[0];
[0].key<[2].key,后移:[j+1]=[j];j--,
[0].key<[1].key不成立,跳出内循环:
[j+1]=[0];
初始
[0].key<[j].key不成立,
跳出内循环:[j+1]=[0];
i>n,退出
图 3.1线性排序过程
效率分析
直观上看,其两重循环最大都是n,因此其时间复杂度是O(n)。而在一个元素i排序
过程中,要在已排好序的i-1个元素中插入第i个元素其比较次数C
i
最多是i次,此时R
i
1
被插到第一个元素位置。比较次数最少是1次,此时R
i
≥R
i-1
,位置没有移动。因此n次循
146
2
《数据结构》教案 2022-3-22
环的最小比较次数:
C
min
=n-1
最大比较次数:
C
max
i
i2
n
(n2)(n1)
2
而一次插入搜索过程中,为插入元素i所需移动次数其最大是C
i+1
,最小是2次(包括
array[0]的1次移动)。那么,n个元素排序时,其n-1个元素需2(n-1)次,所以最小移动
次数:
M
min
=2×(n-1)
最大移动次数:
M
max
(i1)
i2
n
(n1)
(n2)(n1)
2
直接插入排序也可以考虑用链表来实现,此时没有元素移动问题,但最大、最小比较
次数一样,另外,直接插入排序的一种改进是二分法插入排序,即检索插入位置时用二分
法进行,其检索效率有所提高,但不能改变元素的移动次数,所以其平均时间复杂度不变,
且只适用于顺序存储结构。
算法判别条件while(array[0].key素array[0]关键码,则序列顺序后移,找到有序的位置后交换元素;若等于或大于就不发
生交换,因此,具有相同关键码的元素在排序前、后的相对位置不会发生变化,所以它是
一个稳定的排序方法。
3.1.2冒泡排序
冒泡(bubble sort)的意思是每一趟排序将数组内一个具有最小关键码的元素排出到
数组顶部,算法有一个双重循环,其中内循环从数组底部开始比较相邻元素关键码大小,
位居小者向上交换,并在内循环中通过两两交换将最小元素者直接排出到顶部,此时外循
环减一指向数组顶部减一位置,继续内循环过程,将数组内具有次最小关键码的元素排出
至数组顶部减一位置,如此直至循环结束,每次循环长度比前次减一,最终结果是一个递
增排序的数组。图3.2是冒泡排序示意,算法直接见程序3.1。
程序3.1 冒泡排序
void bubsort(struct node *p,int n)
147
《数据结构》教案 2022-3-22
{
struct node s;
for(int i=0;iint flag=0;
fot(int j=n-1;j>i;j--)if(*(p+j).key<*(p+j-1).key){
s=*(p+j);
*(p+j)=*(p+j-1);
*(p+j-1)=s;
flag=1;
}
}
42
20
if(flag==0)retrun;
}
13
42
20
13
14
42
20
13
14
15
42
20
13
14
15
17
42
20
13
14
15
17
20
13
14
15
17
20
13
14
15
17
20
17
13
28
14
23
15
17
14
28
15
23
17
15
28
23
17
23
28
42
23
28
23
42
28
23
28
42
23
28
图3.2 冒泡排序过程
程序3.1如果没有flag这个标志位,那么即使数组已经有序,程序也会继续双重循环
直至结束为止。另外,我们注意到,如果*(p+j).key>=*(p+j).key,相邻元素不发生
交换,所以冒泡排序是一个稳定的排序方法。
冒泡排序是一个双重循环过程,所以其比较次数是:
iO(n
i1
n
2
)
其最佳、平均和最差情况基本是相同的。
3.1.3 选择排序
选择排序(selection sort)每次寻找待排序元素中最小的排序码,并与其最终排序
位置上的元素一次交换到位,避免冒泡排序算法有元素在交换过程中不断变位的问题,比
148
《数据结构》教案 2022-3-22
如,首先选择n个元素的最小排序码,将其与排序数组[0]位置的元素交换,然后是选择剩
余n-1个元素的最小排序码,将其与排序数组[1]位置上的元素交换,即,第i次排序过程
是选择剩余n-i个元素的最小排序码,并与排序数组[i]位置的元素交换,它的特点是n个
元素排序最多只有n-1次交换。图3.3是选择排序过程示意,程序3.2是选择排序函数。
程序3.2选择排序
void selsort(struct node *p,int n)
{
struct node s;
for(int i=0;i
}
42
20
int lowindex=i;
for(int j=n-1;j>i;j--)if(p[j].key
s=p[i];
p[i]=p[lowindex];
p[lowindex]=s;
}
13
20
13
14
17
42
28
20
13
14
15
42
28
20
13
14
15
17
28
20
13
14
15
17
20
13
14
15
17
20
13
14
15
17
20
17
13
28
14
23
15
17
42
28
14
23
15
28
23
42
23
28
42
23
28
42
23
15
23
17
23
42
图3.3 选择排序过程
选择排序实际上仍然是冒泡排序,程序记住最小排序元素的位置,并一次交换到位,
它的比较次数仍然是
O(n
2
)
量级,但交换次数最多只有n-1次。如果*(p+j).key>=*
(lowindex).key,不发生交换,所以选择排序是一个稳定的排序方法。
3.1.4 树型选择排序
直接插入排序的问题在于为了从n个排序码中找出最小的排序码,需要比较n-1次,
然后又从剩下的n-1个排序码中比较n-2次,而事实上,这n-2次比较中有多个排序码已
经在前面比较过大小,只是没有保留结果而已,以至有多次比较重复进行,造成效率下降。
149
《数据结构》教案 2022-3-22
如果我们这样考虑,设n个排序元素为叶子,第一步是将相邻的叶子两两比较,取出较小
n
n
排序码者作为子树的根,共有
棵子树,然后将这
棵子树的根再次按相邻顺序两两
2
2
n
比较,取出较小排序码者作为生长一层后的子树根,共有
棵,循环反复直至排出最小
4
排序码成为排序树的树根为止,我们将树根移至另一个数组,并且将叶子数组中最小排序
码标记为无穷大,然后继续从剩余的n-1个叶子中选择次最小排序码,重复上述步骤的过
程,实际上只需要修改从树根到刚刚标记为无穷大的叶子节点这一条路径上的各节点的值,
而不用比较其它的节点,除去第一次以外,等于每次寻找排序码的过程是走过深度为
log
2
n
的二叉树,即只需要比较
log
2
n
次,我们称之为树型选择排序。图3.4是树型排序的过程
前三次循环示意,排序数组为{72,73,71,23,94,16,5,68},图3.4(a)是构造选择
排序二叉树,图3.4(b)是标记最小排序码后调整得到的次最小排序码,图3.4(c)是又
一次循环过程。
05
23
7223
16
05
05
72
16
23
7223
73
71
16
23
94
(a)
16
23
0568
23
68
1668
7223
94
68
72
73
71
23
(b)
9416
∞68
72
73
71
23
94
(c)
∞
∞68
图3.4树型选择排序过程
因为第一次构造这棵二叉树需要比较n-1次才能找到最小的排序码,以后每次在这棵
树上检索最小排序码需要比较
log
2
n
,共有n-1次检索,所以,树型选择排序总的比较次
数为:
(n1)(n1)log
2
n
时间复杂度是
O(nlog
2
n)
。
3.2 Shell排序
shell排序也称之为缩小增量法(diminishing increment sort)。它不是根据相邻元
150
《数据结构》教案 2022-3-22
素的大小进行比较和交换,而是把总长度为n的待排序序列以步长
d
i
n
2
i
进行分割,间隔
为d
i
的元素构成一组,组内用直接插入、或者是选择插入法排序。下标i是第i次分组的
间隔,i=1,2,…。随着间隔d
i
的不断缩小,组内元素逐步地增多,但因为是在d
i-1
的有序
组内基础上新增待排元素,所以比较容易排序。
若n不是2的整数冥,不妨对排序数组长度补零到整数冥的长度为止。
图3.5显示了排序数组长度为8的排序过程。程序3.3是shell排序实现。
7273
712316
94
(a)分隔d
i
=4
2373
94
(b)组内排序
23
94
(c)分隔d
i
=2
23
72
(d)组内排序
73
05
68
7216
0571
68
7216
0571
68
0516
716894
73
0516
71236894
72
(e)分隔d
i
=1,合并成一个组
6872
71
(f)整个数组排序
图3.5 shell排序过程
73
0516
2373
94
程序3.3 Shell排序
void shellsort(struct node *p,int n)
{
}
void inssort2(struct node *p,int n,int incr)
{
}
for(int i=incr;i
for(int j=i;(j>=incr)&&(p[j].key
swap((p+j),(p+j-incr));
for(int i=n/2;i>2;i/=2)
for(int j=0;j
inssort2(p,n,1);
151
《数据结构》教案 2022-3-22
3.3 快速排序
对已知的元素序列排序,在一般的排序方法中,一直到最后一个元素排好序之前,所
有元素在文件中(排序表)中的位置都有可能移动。所以,一个元素在文件中的位置是随
着排序过程而不断移动的,造成了不必要的时间浪费。快速排序基于这样一种思想,第i
次排序不是确定排序表中前i-1个元素的相对顺序,而是只确定第i个元素在排序表中的
最终位置。方法是每次排序后形成前后两个子序列,关键码小于或等于该元素关键码的所
有元素均处于它左侧,构成前子序列;关键码大于该元素关键码的所有元素均处于它右侧,
形成后子序列。我们称此元素所处的位置为枢轴,元素为枢轴元素。这样,可以对每一子
序列重复同样的处理过程,形成递归形式,直到每一元素子序列只剩一个元素为止。
此方法关键在于确定序列或子序列的哪一个元素作为枢轴元素,一般选取待排序列的
第一个元素作为枢轴元素,首先给出算法。
算法3.2
设有序列{R[s],R[s+1],...,R[t]}
1·设置指针i=s,j=t,R[s].key为待排元素关键码k
1
,指针p=&R[s];
2·执行循环:
while(i while((i=k1))j--;
//递增排序*(p+j)>枢轴k则顺序不变继续搜索
*(p+i)<==>*(p+j)
//由尾部起找到一小于枢轴的元素*(p+j),把它交换到前子序列i的位置
while((i//递增排序*(p+i)<枢轴k则顺序不变继续搜索
*(p+i)<==>*(p+j);
//自头部起找到一大于枢轴的元素*(p+i)把它交换到后子序列j的位置
}
3·当i=j时一个元素被排好序,可以递归调用此过程直到全部排序结束。
图3.6给出了一个元素的排序过程示意,如果枢轴元素K
1
选择合适,则每次平分前后
子序列可以得到较高的效率,程序3.3是C语言快速排序的完整程序。
程序3.3 快速排序
void qksort(struct node *p,int l,int t)
{
int i,j;
struct node x;
i=l;j=t;x=p[i];
//取序列第一个元素作为枢轴
do{
while((p[j].key>)&&(j>i))j--;
//从尾部开始只要没找到小于X的元素就递减循环
if(j>i){
152
《数据结构》教案 2022-3-22
}
p[i]=p[j];
//向前单向交换,j位置空出会在后续循环中被另外元素补上
i++;
}
//准备开始从头部向尾部搜寻
while((p[i].key<)&&(j>i))i++;
if(j>i){
p[j]=p[i];
//向后单向交换,i位置空出会在后续循环中被另外元素补上
j--;
}
//只要i不等于j一趟排序尚未完成
}while(i!=j);
p[i]=x;
//找到枢轴,把X放到其在序列的最终位置上,一趟排序完成
i++;
j--;
if(l//对前子序列0~j元素递归排序
if(i//对后子序列i~n-1元素递归排序
函数在主程序中调用形式为:
qksort(p,0,n-1);
k=42
i
42
i
顺序j递减
42
23
23
23
i++
23
23
23
23
23
23
23
前子序列
74
74
74
74
i
42
i
42
i
42
i
42
i
11
11
j
11
11
11
11
11
11
11
11
j
42
i
j
42
65
65
65
65
65
65
65
j--
65
65
65
i
58
58
58
58
58
58
j--
58
58
58
58
94
94
94
94
94
j--
94
94
94
94
94
后子序列
36
36
j
42
j
42
j
74
74
74
74
74
74
99
j--
99
99
99
99
99
99
99
99
99
j
87
87
87
87
87
87
87
87
87
87
n-1
i
逆序交换
36
换搜索方向
36
逆序交换
36
换搜索方向
36
顺序j递减
36
顺序j递减
36
逆序交换
36
i=j确定枢
36
轴位置
0
图3.6快速排序的一个元素排序过程图解
153
《数据结构》教案 2022-3-22
效率
快速算法的效率分析与枢轴元素选取密切相关,最差的情况是每趟排序选取的枢轴元素
在排序后处于将长度为n
i
的待排序列分割成一个子序列没有元素而另一个子序列有n
i
-1个
元素的位置上,于是,下次排序需要比较和交换的次数都是n
i
-1,如果在总长为n的排序
数组中每次都发生这种情况,则其时间花费是
iO(n
i1
n
2
)
,所以,最差情况下快速排序效
率与冒泡排序相当,如果枢轴是随机选取的,发生这种情况的可能性不大。
快速排序最好的情况是每趟排序选取的枢轴元素在排序后处于将长度为n
i
的待排序列
分割成两个长度相等的子序列位置上,下次排序需要比较和交换的次数都是
n
i
,如果每趟
2
排序都发生这种情况,则总长为n的排序数组会被分割
log
2
n
次,每次的交换和比较次数
log
2
n
是
i0
n
,这里假设n是2的整数幂,其时间花费是
O(nlog
2
n)
。
2
i
快速排序的平均效率在最好与最差情况之间,假定选取的枢轴元素位置将第i趟排序数
组的长度分割成0,1,2,…,n
i
-2,n
i
-1情况的可能性相等,概率为
1
,则平均效率是:
n
i
1
T(n)cn
n
(T(i)T(n1i))
i0
n1
T(0)c,T(1)c
它仍然为
O(nlog
2
n)
数量级。快速排序算法中,我们要注意待排元素K
1
的选取方法不是唯
一的,它对排序效率有很大影响。
3.4 堆排序
快速排序在其枢轴元素每次都选到位于其前后子序列的中点时,相当于每次递归找到一
棵平衡二叉树的根,这时其效率最高。显然,如果我们能找到总是在一棵平衡二叉树进行
排序的方法,就有可能得到比快速排序更高的效率。堆是一棵完全二叉树,堆的特点又是
根为最大值,那么基于最大值堆排序的思想就很简单,将待排序的n个元素组建一个最大
值堆,把根取出放到排序数组的位置[n-1]处,重新对剩下的n-1个元素建堆,再次取出其
根并放置到排序数组的[n-2]位置处,循环直至堆空,堆排序完成。
实际上,排序数组就是堆数组,每次取出的根直接和堆数组的[n-1]位置元素交换,根
被放到堆数组的[n-1]位置,而原来[n-1]位置的元素成为树根,于是,堆数组[0~n-2]的
那些剩余元素在逻辑上仍然保持了完全二叉树的形状,可以继续对这些n-1个剩余元素建
堆,图3.7显示了一个堆排序过程的前四步情况,程序3.3是堆排序程序。
154
《数据结构》教案 2022-3-22
存储位置
逻辑结构
6
73
57
60
42
83
73
6
57886
72
88
48
85
(a)初始
85
88
83
73
42
57
88858372734257
6
4860
6
72
48
73
(b)建堆
60
85
83
42
83
8573
8372
60
4257
6
48
88
6
72
48
73
60
57
(c)尾部n-1节点60和树根88交换后建堆
57
60
42
73
48
83735772
60
42
48
6
85
88
6
72
(d)n-2节点48和树根85交换后建堆
72
737257
6
57
60
42
48
60
42
488385
88
(e)n-3节点6和树根83交换后建堆
6
图3.7{73,6,57,88,60,42,83,72,48,85}堆排序的前四步过程
程序3.3 堆排序
void heapsort(struct node *heap,int n)
//heap指向heaparray[0],n是堆数组长度
{
}
堆排序效率
因为建堆效率是O(n),而将位置i上的元素与堆顶元素交换需要n-1次,并重建n-1
int i;
buildMAXheap(heap,n);
//在堆数组[0~n-1]建立堆
swap((heap+0),(heap+n-1));
//将当前堆数组的最后一个节点与堆顶节点交换
n--;
//堆元素减一
//排序过程直至堆剩余一个元素为止
while(n){
buildMAXheap(heap,n);
swap((heap+0),(heap+n-1));
n=n-1;
}
次堆,在最坏情况下,每次恢复堆的需要移动
log
2
i
次,那么,
155
log
i1
n1
2
i
次移动需要时间开
《数据结构》教案 2022-3-22
销是O(
nlog
2
n
),即堆排序最坏情况下的效率是
O(nlog
2
n)
。
3.5 归并排序
两路归并排序思想
将两个有序的数组合并为一个有序的数组称之为归并(Mergesort)。设待排序数组有n
个元素{R
1
,R
2
,...,R
n
},在前面讨论的直接插入排序方法中,我们对第i个元素排序时假
定前i-1个元素是已经排好序的,初始i从2开始。与此类似,归并排序初始时将n个元
素的数组看成是含有n个长度为1的有序子数组,然后将相邻子数组两两归并,归并后的
子数组的长度倍增为2,而个数减少一半为
n
,反复归并子数组,直至最后归并到一个长
2
度为n的数组为止。归并排序不同于快速排序,它的运行效率与元素在数组中的排列方式
无关,因此避免了快速排序中最差的情形。
归并排序和增量排序有些类似,它们的区别在于,shell是把长度为n的待排序序列以
步长
d
i
n
2
i
进行分割,以间隔为d
i
的元素构成一组。而归并排序是相邻的元素为一组,继
而以相邻组归并。
对包含n个元素的数组应用归并排序方法,需要一个长度为n的辅助数组暂存两路归
并的中间结果,空间开销的增加是归并排序的一个弱点,但是,任何试图通过程序技巧来
取消辅助数组的代价是程序变得极其复杂,这并不可取。
图3.8是两路归并排序示意。图中显示,一次归并结束时,序列尾部可能有1个子数
组及元素不能归并,需要进行尾部处理。因为,一次归并过程中子数组能两两归并的条件
是当前归并元素位置i≤n-2L+1,否则,余下的待排序元素一定不足两个子数组的长度,需
要进行尾部处理,方法有2:①当前归并位置i们看成是一个归并序列直接进行归并处理,并放入到归并结果序列的尾部;②i>n-L+1,余
下元素个数少于一个子数组长度,我们将这些元素直接移入到归并结果序列的尾部。
两路归算法
包含n个元素的数组两两归并排序的算法包含有两个函数,分别是①两组归并函数:
merge(归并起点,子数组1终点位置,子数组2终点位置,待排序数组,中间数组);
②一趟归并函数:
mergepass(待排序数组长度,子数组长度,待排序数组,中间数组);
156
《数据结构》教案 2022-3-22
[36],[20],[17],[13],[28],[14],[23],[15],[6],[12],[43],[51],[5]
(a)初始将数组看成是由n个长度为1的子数组构成
归并跨度+=2L
i=1
j=1
[20,36],[13,17],[14,28],[15,23],[6,12],[43,51],[5]
子数组长度=2L
(b)相邻子数组一次归并后,子数组长度倍增,子数组的个数减少一半
归并跨度>(n-2L+1)是一次归并循环过程结束条件
i=1
子数组长度=4L
看成一个归并序列
(c)一次归并结束时,尾部可能有子数组或元素不能归并,需要进行尾部处理
归并跨度>(n-2L+1),不能归并,直接进行尾部处理
j=1
[13,17,20,36],[14,15,23,28],[6,12,43,51],[5]
[13,14,15,17,20,23,28,36],[5,6,12,43,51]
子数组长度=8L
尾部子数组
(d)归并跨度大于n-2L+1,无需两两归并,直接进行尾部处理
[5,6,12,13,14,15,17,20,23,28,36,43,51]
(e)直接两两归并
图3.8 归并排序过程
我们先看子数组归并排序算法:
算法3.3 两组归并
void merge(int L,int m,int m1,struct node *array,struct node *temp)
{
int i=L,k=L,j=m+1;
while((i<=m)&&(j<=m1)){
if(array[i].key<=array[j].key){
temp[k]=array[i];
i++;
}
else{
temp[k]=array[j];
j++;
}
k++;
}
if(i>m)for(i=j;i<=m1;i++){
temp[k]=array[i];
k++;
}
157
《数据结构》教案 2022-3-22
}
else for(j=i;j<=m;j++){
temp[k]=array[j];
k++;
}
一趟归并排序算法如下:
算法3.4 一趟归并排序
void mergepass(int n,int L,struct node *array,struct node *temp)
{
}
最后,我们得到两路归并排序算法如下:
算法3.5 两路归并排序算法
for(int i=1;i<=n-2*L+1;i+=2*L)merge(i,i+L-1,i+2*L-1,array,temp);
if(ielse for(int j=i;j<=n;j++)temp[j]=array[j];
int mergesort(int n,struct node *array)
{
}
当2<n≤2时,mergepass()调用了i次(i≈log
2
n),每次调用mergepass()的时间
开销是O(n)数量级,在mergesort()中,最后有可能需要从temp[]向array[]移动n次,
所以,两路归并排序的时间花费是O(n log
2
n)数量级,相当于快速排序方法。
i-1i
int L=1;
struct node temp[N+1];
//排序元素个数是N
while(L
mergepass(n,L,array,temp);
L*=2;
if(L>=n){
for(int i=1;i<=n;i++) array[i]=temp[i];
return(0);
}
mergepass(n,L,temp,array);
L*=2;
}
158
《数据结构》教案 2022-3-22
3.6 数据结构小结
有关数据结构的内容就讨论到此,我们希望通过这一部份的学习使读者掌握基本的数
据结构内容以及程序设计初步。希望通过教学、习题、上机试验几个环节为读者建立分析
和设计数据结构的基本概念,包括理解数据逻辑结构、存储结构、排序与检索等内容,为
大家将来的学习提供良好的基础。现在重新回顾已经学过的内容。
3.6.1 数据结构的基本概念
一、什么是数据结构
这个问题涉及到两个基本概念。
1. 数据类型:程序设计语言中各个变量所具有的数据种类。比如FORTRON的基本数
据类型是整型、实数型、逻辑型、复数型。C语言中除整型、字符型、实数型等基
本数据类型外还有结构、共用体、枚举、位域等用户定义的数据类型。
2.数据集合:指某种数据类型的元素集合。
在此基础上我们说数据结构是数据元素及其相互关系的集合。数据结构不仅描述数据元
素,而且要描述它们之间相互关系所表达的元素之间存在的某种联系,称为结构,我们定
义过二元组:
S=(D,R)
就是表达了这个意义。
程序设计中需要将数据结构存储在计算机内存中,即数据结构的物理映象,我们说数据
的逻辑结构与物理结构是不可分割的两个内容,这里再次列出数据结构划分类型如图3.9
所示。
顺序存储结构(数组、队列、栈、完全二叉树)
物理结构
数据结构
线性表
逻辑结构树
图
图3.9 数据结构类型划分
链式存储结构(链表、树)
索引存储结构(倒排表,B+树)
散列存储结构(哈希表)
3.6.2 数据结构分类
我们有三种基本的数据结构类型,即表、树、图。
159
《数据结构》教案 2022-3-22
3.6.2.1数据结构中的指针问题
表结构中我们重点学习了链表设计,包括它的生长,删除等基本操作。在这里我们强调
了C语言中指针的运用问题,对指针的理解一直是困扰我们学习C语言和数据结构的一个
主要问题,我们应该清楚,指针首先是一个变量,它也有地址,其次,才是这个变量存储
的是一个指向其它变量的地址,最后,这个指针必须和它所指向的那个变量的数据类型相
同,因为在数组元素序列中,我们知道每个元素占用的存储空间大小由其数据类型决定,
指针与其同类型,表明当对指针变量(内容)进行加一操作的时候,我们可以正确的指向
数组内下一个元素的起始地址。
注意,指针只是指向变量所占存储空间起始地址,它是一个存储单元的地址,不是一段
存储区域的空间,请看如下问题:
struct node {
int key;
……
struct node next;
}*hash[40],*p,a1;
程序定义了一个node结构类型的指针数组hash[40]和指针p,操作如下:
&a1=malloc(sizeof(node));
hash[i]=a1;
p=hash[i];
问,p = hash [i]操作是把元素hash[i]指向的a1的地址赋给了p,还是把hash[i]->next
赋给了p 呢?是否可以写成p=hash[i]->next 呢?
问题出在我们对指针的理解上。
(1)首先,定义是一个node节点类型的指针数组,所以,hash[i]就是指针,它指向一个
地址a1,比如是某一个链表的第一个数据节点。
(2)注意,指针是指向某数据类型变量的地址,它和该数据类型的内部结构无关。hash[i]
只是一个node类型的指针变量,是地址的概念,它没有实际占有空间,不是node节点,
当然也就没有node节点变量的指针域,比如:
struct node a;
变量a是有指针域next的,你可以用:
a-->next=a2;
语句让a指向a2,但是,hash[i]指针没有next指针域这一说,所以:
p=hash[i]-->next;
是错误的,实际上,hash[i]指向了链表头节点a1的地址,所以,p=hash[i]就可以了。
160
《数据结构》教案 2022-3-22
3.6.2.2线性表的效率问题
顺序表、栈、队列等是基本要掌握的内容,特别在某一条件下的效率分析问题,比如,
删除操作时顺序表平均移动次数是表长n的函数:
ASL
n1
,当n很大时它的效率明显
2
低于链表结构,但在随机读写效率上顺序表优于链表。我们特别注重顺序表与链表在概念
上的区别。此外,递归也是同学必须掌握的内容之一。
3.6.2.3二叉树
在树结构中我们重点讨论了二叉排序树。包括二叉树的基本概念、几种特殊的二叉树结
构如满二叉树,完全二叉树,平衡二叉树等是同学必须了解的基本内容。二叉树程序设计
是我们学习的重点,它比链表设计增加了递归设计内容的难度,读者必须通过实际编程,
才能熟练掌握和很好的理解二叉树设计与应用问题。
我们指出二叉树的存储结构是多种形式的,有顺序存储、链式存储方式等,一般我们采
用链式存储结构,它含有两个指针域一个数据域,对于哈夫曼树还增加了父指针域。我认
为,在二叉树程序设计中最基本的思想是递归程序设计的运用。用递归实现它的动态生长,
删除,检索,遍历操作,原则上要求读者设计二叉树相关操作时应是递归结构实现的。
我们还指出,从概念上讲数据结构另一种分类方式是静态与动态数据结构的区别。所谓
静态和动态是指数据结构特性在该数据结构存在期间的变动情况。顺序表是静态的,因其
结构特性在它存在期间是不变的,只有长度的变化。树是动态的,因为它的结构特性依赖
于它的生长过程。应该说链表也是静态的数据结构,至少不能说它是动态的。因为链表插
入与删除不改变它本身特性,只是表长的变化,这点与教材看法不同,大家可以商椎。
无论是表结构还是树结构,简洁明快的结构化程序设计风格是我们所追求的,数据结构
内容本身读者可以在将来的学习中不断加深理解,但良好的C语言程序设计能力与风格则
是你们整个软件技术学习中的基础。
3.6.3排序与检索
在数据结构内容之内,属于数据结构操作的一类问题是检索与排序。在检索方面,顺序
检索、对半检索都是基本内容,我们重点讨论了它们的检索效率。
在描述数据结构的物理结构时我们知道有顺序、链表、散列和索引四种基本结构,其中
散列存储结构就是通过哈希函数实现关键码值到存储地址的转换,即哈希造表或地址散列。
这是一个重要的概念,它通过计算函数表达式求解元素地址,它所注意的是由于关键码集
合空间大于内存可分配地址空间而造成的地址冲突问题。构造哈希函数的标准是它与关键
码的相关性,以期达到地址均匀散列。解决地址冲突的办法是线性探测与链地址法。
在排序方面,对于基本概念我们主要考虑了稳定性和排序效率问题,此外还讨论了直接
161
《数据结构》教案 2022-3-22
插入排序与快速排序方法,我们特别注意的是快速排序中枢轴元素的选取问题,如果每次
选取到序列的中值则可以平分前后子序列,使效率最佳。
3.7算法分析的基本概念
算法的内容是数据结构设计中经常遇到的术语与概念。我们说程序设计是算法与数据结
构在计算机上的实现,算法也就是解决问题的步骤。衡量各种数据结构优劣时,就需要用
到算法分析内容。
3.7.1基本概念
算法分析是程序执行时间的定性估算,也就是运行时间的上、下限范围。为此,我们需
要明确一些基本概念。首先,算法分析有时间复杂度与空间复杂度两个方面,目前一般的
计算机内存已足够大,在简单程序设计情况下,我们可以做到用程序的内存工作长度空间
来换取时间上的处理速度,因此,有时候我们说对于算法分析来说更看重的是时间复杂度。
实际上不能这样简单处理。计算机内存资源是我们程序运行时间的基本条件,在当前普
遍应用的多任务操作系统中,每一个进程分配的内存资源是有限的,当你程序算法占用的
空间超过操作系统分配给你的资源时候,操作系统会在硬盘物理空间上给你开设缓存区域,
或者在你的数据文件太大(它总是保存在硬盘物理空间上),运行中需要分阶段从内存调进
的情况下,程序执行过程中会出现频繁的内外存数据交换,也就是I/O操作,常识告诉我
们,任何一种I/O操作耗时远远大于CPU与内存之间的数据交换时间,差异大于一个到两
个数量级。因此,算法设计中空间复杂度依然是一个重要因素。
衡量算法优略的一个基本标准是说,处理一定规模(Size)的输入时该算法所执行的基
本操作(basic operation)的数量。
这里,规模的概念依赖于具体算法,比如,检索和排序算法中的输入元素数量。基本操
作的概念依据计算机硬件资源不同而有所不同,比如,一般的计算机CPU都支持两个变量
之间的加减乘除的整数操作、浮点数操作,但是不支持n个整数之间的累加操作,我们定
义基本操作的性质是完成该操作所需的时间与操作数具体值无关,所以,两个变量之间的
加减乘除的整数操作可以看成是基本操作,但是n个数累加所花的时间就要n来决定,比
如for语句的循环次数。
程序3.4是在一维数组中检索最大值,它依次遍历数组每一个元素,比较每一元素值并
保存当前最大元素。
程序3.4求数组最大值
int iargest(int *array,int n)
{
162
《数据结构》教案 2022-3-22
int currlarge=0;
for(int i=0;icurrlarge)crrrlarge=array[i];
renturn(currlarge);
}
这里,n是任务规模,基本操作是比较运算、赋值操作,它们所需的时间与其在数组中
的位置i无关,也与元素数值的大小无关。影响程序运行时间的因素是规模n,设基本操作
时间是c,则程序3.4的运行时间为t=cn,定性的说,程序3.4算法时间代价是T(n),它与
输入规模呈线性关系增长。现在看程序3.5描述的情况。
程序3.5变量累加
long add(int n)
{
long sum=0;
for(int i=1;i<=n;i++)for(j=1;j<=n;j++)sum++;
renturn(sum);
}
程序3.5的输入规模是n,基本操作是加法,设基本操作时间为c,其运行时间是cn,
2
22
我们估计它的运行时间代价是T(n),这里的c包含了程序变量初始设置等因素。当规模为
平方项的时候,时间代价的增长率就很可观了。
时间函数的增长率是衡量算法性能的关键指标。时间复杂度是定性分析,要定量确定某
一算法所用时间是很困难的,我们只要确定它随规模n的增长率在某一数量级,确切的说
是确定那些具有最大执行频度(规模)的语句。所谓频度可以从下列程序语句中看到它的
概念。即频度就是某一语句(基本操作)的循环执行次数,程序的频度是程序中具有最大
频度的语句所具有的频度。下例中,因为程序1只是单一命令语句,所以它的频度为1;程
序2有一个n次循环语句,它的频度是n;程序3是双循环语句,当然它的频度就是n×m。
程序1
...
程序2
....
程序3
...
x++;
for(i=0;i
...
for(i=0;i
for(j=0;j }
...
}
频度为1 频度为n 频度为n×m
据此,我们估计程序1算法时间复杂度是O(1),程序2是O(n),程序3是O(n×m)数
量级的。
163
《数据结构》教案 2022-3-22
表3.1几种时间复杂度随规模n的增长率比较
n
1
2
4
8
16
32
log
2
n
0
1
2
3
4
5
nlog
2
n
0
2
8
24
64
160
n
1
4
16
64
256
1024
2
n
1
8
64
512
4096
32768
3
2
2
4
16
256
65536
483648
n
3.7.2上限分析
一个算法的运行时间上限用来描述该算法可能有的时间花销的最高增长率,它与输入规
模n有关,我们知道,一个算法的具体时间花费与输入元素的分布特性有关,比如快速排
序过程中的最差、最佳和平均时间代价的不同。因此,时间估计的上限也分成该算法平均
条件下的增长率上限、最佳条件下的增长率上限或最差条件下的增长率上限。
现在我们给出算法时间复杂度的上限定义:如果存在两个常数C≥0,n0≥0,当n≥n0
时有|f(n)|≤C|g(n)|,则称函数f(n)是O(g(n))的时间复杂度。
这里,大O表示时间估计上限,|f(n)|是某算法的运算时间,定义表明,如果说一个算
法的时间复杂度是O(g(n))数量级,则表明该算法实际所耗时间只是O(g(n))的某个常数倍,
n是算法规模参数。因此,当我们写O(1)时间复杂度时,意味着算法执行时间是一个常数,
而O(n)是n的线性函数,O(log
2
n)是对数函数。我们知道当n充分大时log
2
n远小于n,nlog
2
n
是远小于n的,一些常见的关系已经在表3.1给出。
例3.1 设有一算法,分析其每一语句的频数为C
k
n,则它们的和是:
p(n)= C
k
n+ C
k-1
n+ C
k-2
n+···+ C
1
n+ C
0
这里C
i
为常数,C
k
不为零,n是程序执行规模,因为:
k k-1k-21
k
2
c
k
n
k
c
k-1
n
k1
...c
0
c
k
n
k
c
k
-1
n
k
...c
0
n
k
(c
k
c
k
-1
...c
0
)n
k
由定义我们可知,该算法的时间复杂度是P(n)=O(n),即程序的算法时间复杂度只考虑具
有最大频度的语句。
例3.2 顺序检索的时间复杂度上限估计。我们知道,线性检索表的检索成功的平均比较次
数与规模n的关系是
k
n1n
,或者说它的平均检索时间估计是
c
,对于n〉1,我们有关系
22
c
n
cn
成立,所以它的平均时间复杂度上限是O(n)。
2
3.7.3下限分析
上限估计告诉我们一个算法时间的增长率极限情况,有时候我们想知道一个算法至少它
需要多少运行时间,也就是它执行时间的下限程度,我们用符号Ω表示。定义: 如果存在
164
《数据结构》教案 2022-3-22
两个常数C≥0,n0≥0,当n≥n0时有|f(n)|≧C|g(n)|,则称函数f(n)的时间复杂度下限
是Ω(g(n))。
例3.3 设一算法时间花费是T(n)= C
k
n+ C
k-1
n ,因为
c
k
n
k
c
1
nc
k
n
k
c
k
n
k
,根
k k-1
据定义,它的时间估计下限是Ω(n)。
对于线性检索,在数组中要找到关键码为k的元素,最差的情况下,我们可能需要检索
k
完整个数组长度才能确定,最好的情况下是1次,平均是数组长度的一半,因此,在平均
和最差检索情况下,至少需要检索cn次(c是基本操作),所以它的下限估计在这两种情况
下也是Ω(n)。
当一个算法的时间估计上下限相等,都是g(n)的时候,我们说该算法它的时间估计是
(g(n))
的,即g(n)既在O()中,也在Ω()中。
3.7.4空间代价与时间代价转换
算法的空间开销与时间开销是一对矛盾体。比如,计算sin、cos函数值是用级数求和
的过程,如果在算法中多次使用sin、cos函数值,可以假定一个合理的精度将sin、cos
函数值制作成一个表,比如以每分为一个步长,于是,计算sin、cos函数值的过程就转换
为查找表的过程,给出表的基地址和偏移量,我们可以很快查找到相应的函数值,而这一
过程的代价是查找表所占用的内存空间。
另外一个例子是布尔变量的问题,它取值只有两种状态,真或假,假定一个算法使用了
32个布尔变量,一种方法是每个布尔变量用以字符类型量表示,需要占用32字节。我们也
可以用一个long类型变量表示,它的每一比特(bit)位代表一个布尔变量,只需要4个
字节,显然,程序上前者简单后者繁琐,但是空间开销正好相反。
再看一个实际例子,假定一个算法中需要多次使用阶乘,最大是12的阶乘,当然可以
很容易的用递归函数计算阶乘,它需要一个长整型量和多次递归调用。而从查找表概念理
解,预先计算12!如表3.2所示。
表3.2 阶乘查找表
12!
479001600
11!
39916800
10!
3628800
9!
362880
8! 7!
40320 5040
6!
720
5! 4! 3! 2! 1!
120 24 6 2 1
表3.2是一个数组,计算12以内的阶乘就是将给定值作为偏移量访问查找表的过程,
它非常简单,是空间换时间的典型例子。
我们应该清楚,存储空间和时间代价互换的基本原则是,避免程序执行过程中出现不必
要的内外存数据交换,即,外存硬盘空间开销越小,程序执行越快,我们在前面说过,I/O
数据交换是计算机运行的瓶颈。
有关算法的详细内容同学可以参考其它教科书,算法与数据结构是密切相关的,选择一
种数据结构也就确定了元素之间的关系,它对应不同的算法,我们当然希望有一种好的数
165
《数据结构》教案 2022-3-22
据结构得到高效率的算法表达,使时间复杂度最小,所以有
算法+数据结构=程序
这一说法,有关数据结构的基本内容就讨论到此。
166
《数据结构》教案 2022-3-22
第6章 高级数据结构内容--索引技术
6.1 基本概念
索引文件用于组织磁盘中大量数据纪录检索的排列方式,主要是为提高关系数据库的
操作效率而设计的。一个应用关系数据库设计时,从逻辑结构上看每一客观实体(关系)
至少有一个能唯一的标识其所有属性或该关系的主属性,称为主关键字。若一个属性不能
唯一的标识一个关系,或者说它对应多个关系实体,则称其为该关系的次关键字。在关系
数据库设计时,我们总是按照主关键字组织数据字典的全局逻辑结构及联接关系的,在物
理实现上,也是用主关键字与纪录的物理地址相关联的。
当大量的数据纪录在内存时,为了提高检索效率就必须按检索的形式进行排序,显然,
主关键字检索是检索关系实体的基本操作之一,比如,一个学生数据库用学号唯一标识学
生这个客观实体的所有属性,要检索特定学生的情况时,输入该学生的学号可以检索到该
生所有属性值(如姓名、年龄、性别、籍贯、家庭所在地、系别等),于是,我们自然会以
主关键字对学生记录排序。
现在的问题是,检索是多角度、综合性进行的,比如,我们需要查看自动化系、来自
浙江杭州的学生情况,查找符合这一检索条件所要求的记录的方法可以有多种,比如,将
记录按‘系别’进行排序,在检索到‘系别’等于‘自动化系’的记录中,筛选出‘家庭
所在地’的属性值等于‘杭州’的记录。然而,要对‘系别’这一属性进行排序,就得把
内存物理地址与该属性相关联,换句话说,就是所有记录需要按‘系别’属性重排。此外,
同一关系数据库还有可能遇到查询‘年龄’等于20岁的女生记录的情况,或者是‘姓名’
等于‘xxx’的检索要求等。显然,我们不可能每次都按检索条件重排硬盘中关系数据库的
所有记录。
避免重排数据库记录的另一种选择是索引技术(index file)。我们称之为索引技术或
文件的是,其文件内每个关键码与标识该记录在数据库的物理位置的指针相关联(一起存
储)。因此,索引文件为记录提供了一种按索引关键字排列的顺序,而不需要改变记录的物
理位置。一个数据库系统允许有多个索引文件,每个索引文件都通过一个不同的关键字段
支持对记录有效率的访问,即,索引文件提供了用多个关键字访问数据记录的功能,也避
免了数据库记录的重排操作。简而言之,索引技术的应用使记录的检索与其物理顺序无关。
显然,我们不可能让每个关键码都与记录的物理指针相关联,这样,记录的物理位置
变化会造成所有索引文件的修改。次关键码索引文件实际上是把一个次关键码与具有这个
次关键码的每一个记录的主关键码相关联起来,而主关键码索引再与一个指向记录物理位
167
《数据结构》教案 2022-3-22
置的指针相关联,即,其访问顺序是次关键码-主关键码-记录物理位置。
可以说,索引技术是组织大型数据库的关键技术,其中,线性表索引主要用于数据库
记录的检索操作,对于记录的插入与删除操作我们广泛使用的是树形索引,即B树。
+
6.2 线性索引
6.2.1 线性索引
线性索引是一个按关键码-指针对顺序组织的索引文件,该文件按关键码的顺序排序,
指针指向记录的物理位置,见图6.1所示。线性索引文件本身的记录是定长的,其检索效
率是O(n),随数据库记录增大而降低,即使索引文件是顺序存储的能使用二分检索,但是
当数据库记录数目很大时,索引文件本身也无法在内存中一次装入,运用二分检索过程中
会多次访问硬盘,效率可能还不如线性检索。
37425273
硬盘数据记录
52
734237
图6.1线性索引可以实现变长数据库记录检索
磁盘中一个物理页大小是1024字节,即可以存储128个索引记录,设,数据库记录的线性
索引文件长度为100磁盘页(100kB),每页内记录(关键码值)按递增有序,则在内存中
建立一个二级索引文件,其每个记录是每个磁盘页的第一个关键码值,该二级索引文件长
度只有100项,数据库检索时,首先根据检索关键码值在这个顺序表文件中用二分检索找
到小于或等于它的最大值,就可以找到包含该关键码的索引文件磁盘页,将该磁盘页调入
内存只需继续在128个记录中查找即可完成,见图6.2所示。
我们熟知的多级索引结构是一种解决方案,比如,一个关键字/指针对需要8个字节,
12
二级索引文件
索引文件磁盘页
12
4700
图6.2二级线性索引
95
168
《数据结构》教案 2022-3-22
6.2.2 倒排表
二级索引存在的一个问题是,当我们向数据库插入或删除一条记录时,必须同时修改
磁盘与内存中的一、二级索引文件,当索引文件很大时,这种更改涉及到所有磁盘页数据
(索引记录)的移动,代价很大。特别是当索引文件所有纪录均与物理纪录相关联的情况
下,随着一个实体纪录的插入或者删除,数据库的所有索引文件都需要修改。另外,当次
关键码取值范围很窄时,数据库记录中有大量的记录取值相同,造成索引文件中大量的重
复引用。比如一个极端的例子是学生记录中的性别属性,非男即女,于是,索引文件中有
一半记录是重复的。
属性m索引纪录1属性m索引纪录2
属性m索引纪录n
属性2索引纪录1属性2索引纪录2
属性2索引纪录n
属性1索引纪录1属性1索引纪录2
属性1索引纪录n
主键码索引纪录1主键码索引纪录2
主键码索引纪录n
属性m属性m
属性m
属性2
属性1
主键码
实体纪录1
主键码
属性2
属性1
主键码
属性2
属性1
实体纪录2实体纪录n
数据库物理纪录
如果所有索引文件指针均和物理记录关联,则实体纪录数目变更时将影响所有索引文件
一种改进的方法是二维数组形式,其每一行对一个次关键码值,每一列包含了具有该
次关键码值的所有记录的主关键码值,于是,首先通过次关键码检索索引表找到它所对应
的所有主关键码值,下一步可以通过主关键码检索数据库记录,从而得到满足检索条件的
所有记录列表,见图6.3所示。二维索引表不但可以减少空间,而且数据库更新操作对索
引文件的影响也被限制在索引文件的一行之内,极限情况是次关键码取值范围变化,比如
新添一个次关键码值时候,需要移动二维数组,但这种情况实际中很少见。
它不足之处是数组必须有一个固定的长度,则有①与次关键码相关联的主关键码记录
数受限(限定了数据库具有相同次关键码值的最大允许记录数);②当次关键码取值对应的
数据库记录数目变化很大时,二维数组长度无法兼顾,从而造成一个次关键码值对应很少
记录数(比如只有一个主关键码相关联)的话,存储空间浪费很大。
169
《数据结构》教案 2022-3-22
次码
女
男
男
女
男
主码
文静
郭名
王东
李雯
庄全
主码
男
次码女
郭名
文静
王东
李雯
庄全
二维索引表
图6.3 二维索引文件
主关键码数组
A01
A02
A03
主关键码数组
B01
主关键码数组
C01
C02
图6.4 倒排表
物理位置
A
B
C
既然二维数组也是一个线性表,显然链表是一种解决办法。我们定义的倒排表是:每
一次关键码值有一指针指向它所对应的所有主关键码形成的数组,数组中的每一主关键码
与磁盘中的数据库记录位置有唯一的指针相关联,即,检索记录顺序是由次关键码到主关
键码,再到数据库记录,见图6.4所示。
6.3 2-3树
基于线性索引技术的关系数据库存储方式存在的最大问题是,当大量的记录频繁更新
的时候,其操作效率非常低,原因是每更新一条纪录就要修改一个索引文件内的所有的指
针内容,当你有多个索引文件,以及海量的记录数目时,线性索引效率很低, 而且,修改
指针的操作效率与线性索引结构无关,多级索引结构虽然能提高检索效率,但是记录物理
地址变更引起的指针修改是全索引文件范围内的,既线性索引文件其所有指针信息都是相
关的,如果我们有一种方法,记录的插入与删除只是影响索引文件局部区域,那么它作为
170
《数据结构》教案 2022-3-22
主关键码的索引就会完成得很好。树形索引是一种很好的索引文件组织方式,它本身是非
线性结构,磁盘页之间指针相关性很低,它可以提供有效的插入与删除操作,从以前的二
叉检索树讨论中知道,其效率是树深度的对数关系。
二叉树当然不适合作为索引文件结构,第一,它只有两个子树,不符合磁盘页划分;
第二,它会因为更新节点操作变得不平衡,尤其检索树存储在磁盘中时,不平衡情况对检
索效率影响更为重要,当一个节点深度跨越了多个磁盘页时,对节点的访问就是从第一个
磁盘页(根节点)开始达到这个节点所在磁盘页为止的所有节点路径之和,随着磁盘页在
内外存中的调进调出,它涉及到多次内外存交换,效率变得非常低下。因此,要采用树形
索引结构,必须寻找一种新的树结构,它能解决插入与删除操作带来的不平衡问题。这种
树形结构经过多次更新操作之后能自动保持平衡,并且适合按页存储,既,我们要求它的
算法具有下列特性:
(1)
(2)
(3)
以一个磁盘页为单位;
插入与删除操作之后能自动保持高度平衡;
平均访问效率最佳。
基于磁盘页的树形索引结构
5
3
246
7
1
2
3
4
6
5
7
向完全二叉树插入一个关键码值为1的节点之后,需要调整所有节点重新维持平衡
首先,我们讨论2-3树概念,在此基础上引伸出B树。
+
171
《数据结构》教案 2022-3-22
6.3.1 2-3树定义
一棵2-3树具有下例性质:
1. 一个节点包含一个或者两个关键码;
2. 每个内部节点有2个子女(如果它包含一个关键码),或者3个子女(包含2个关键码);
3. 所有叶子节点在树的同一层,因此树总是高度平衡的。
类似于二叉排序树,2-3树每一个节点的左子树中所有后继节点的值都小于其父节点第
一个关键码的值,而中间子树所有后继节点的值都大于或等于其父节点第一个关键码的值
而小于第二个关键码的值,如果有右子树,则右子树所有后继节点都大于或等于其父节点
第二个关键码的值。见图6.5所示。
root
18
12
10152021
23
33
30
2431
4547
48
5052
图6.5 2-3树[因自文献]
一个在2-3树中检索特定关键码值的函数类似于二叉排序树检索过程,见例6.1所示。
例6.1 2-3树检索函数实现
struct node *findnode(struct node *root,int key)
{
if(root==Null)return Null;
if(key==rootlkey)return root;
if((rootNumkeys==2)&&(key==rootrkey))return root;
if(keyelse{
if(rootNumkeys==1)return findnode(rootcenter,key);
else{
if(key else return findnode(rootright,key);
}
}
}
显然,2-3树节点定义为:
struct node {
172
《数据结构》教案 2022-3-22
int lkey,rkey,Numkeys;
struct node *left,*center,*right;
};
6.3.2 2-3树节点插入
向2-3树插入记录(不是节点)与二叉排序树相同的是新纪录始终是被插入到叶子节
点;不同的是2-3树不向下生长叶子,而是向上提升分列出来的记录。分以下几种情况:
(1) 被插入到的叶子节点只有一个关键码(代表一个记录),则新记录按左小右大原则
被放置到空位置上。见图6.6所示。
root
18
12
1015
14
152021
23
33
30
2431
4547
48
5052
图6.6 在图6.5中插入关键码值为14的记录
(2) 被插入的叶子节点已经有2个关键码,但其父节点只有1个关键码。当被插入叶子
节点内部已经没有空位置时,我们要创建一个节点容纳新增记录和原先2个记录。
设原叶子节点为L,首先将L分裂为2个节点L和L’,L取3个节点中值为最小者,
L’取值为最大者,居中的关键码和指向L’的指针被传回L的父节点,即,所谓提
升一次的过程。被提升到父节点的关键码按左小右大排序,并插入到父节点空位置
中。见图6.7所示。
root
18
12
10152021
23
33
被提升的记录
30
2431
4547
48
50
L
52
55
L'
图6.7 在图6.5中插入关键码值为55的记录产生1次提升
(3) 被插入的叶子节点已经有2个关键码,而且其父节点内部亦满。此时,我们用从叶
子节点提升上来的关键码对父节点重复一次分裂--提升过程,将一个关键码从父节
点中向更上一层提升,直至根结点,如果根节点被分裂,则继续提升的关键码形成
新的根节点,此时,2-3树新增一层。见图6.8所示。
173
《数据结构》教案 2022-3-22
root
18
12
10152021
23
33
30
24
初始
root
18
12
20
101519
21
24314547
5052
23
33
30
48
31
4547
48
5052
(a)插入19产生叶子分裂与提升
root
18
20
1519
21
24
33
23
12
10
30
3145
48
47
5052
(b)父节点产生分裂与提升
root
23
18
12
1015
19
20
2124
(c)根节点提升
图6.8
30
3145
33
48
47
5052
例6.2 2-3树插入函数
struct node *insert(struct node *root,int key, struct node *retptr,int retkey)
{
int myretv;
struct node *myretp=Null;
if(root==Null){
root=(struct node*)malloc(sizeof(struct node));
rootlkey=key;
rootNumkeys=1;
}
else{
if(rootleft==Null){//叶子节点
174
《数据结构》教案 2022-3-22
if(rootNumkeys==1){//只有一个关键码
rootNumkeys=2;
if(key>=rootlkey)rootrkey=key;
else{
rootrkey=rootlkey;
rootlkey=key;
}
}
else {//关键码满,分裂提升
retptr=(struct node*)malloc(sizeof(struct node));//申请L’节点
且返回指针指向L’
if(key>rootrkey){// L’节点取最大值的关键码
retptrlkey=key;
retkey=rootrkey;//提升中间值的关键码
}
else{// rootrkey是最大值的关键码
retptrlkey=rootrkey;
if(keyretkey=rootlkey;
rootlkey=key;
}
else retkey=key;
}
rootNumkeys=retptrNumkeys=1;//置L和L’关键码数为1
}
}
else {//非叶子节点小于左关键码值搜寻左子树*/
if(keyelse {/*子树为2叉或小于右关键码搜寻中间子树*/
if((rootNumkeys==1)||(keyinsert(rootcenter,key,myretp,myr
etv);
else{//搜寻右子树
insert(rootright,key, myretp,myretv);
175
《数据结构》教案 2022-3-22
}
}
if(myretp!=Null){// 有孩子节点分裂而形成提升
if(rootNumkeys==2){//分裂并提升父节点
retptr=(struct node*)malloc(sizeof(struct node));
rootNumkeys=retptrNumkeys=1;
if(myretv
retkey=rootlkey;//*返回值
rootlkey=myretv;//*原root为L
retptrlkey=rootrkey;//L’关键码
retptrleft=rootcenter;
retptrcenter=rootright;
rootcenter=myrept; //指向L’
}
else{
if(myretv
retkey=myretv;
retptrlkey=rootrkey;//L’键
retptrleft=myrept;
retptrcenter=rootright;
}
else{//提升右关键码
retkey=rootrkey;
retptrlkey=myretv;
retptrleft=rootright;
retptrcenter=myrept;
}
}
}
else{//root节点内只有一个键,可增加一个
rootNumkeys=2;
if(myretv
rootrkey=rootlkey;
rootlkey=meretv;
176
《数据结构》教案 2022-3-22
rootright=rootcenter;
rootcenter=myretp;
}
else{
rootrkey=myretv;
rootright=myretp;
}
}
}
}
}
}
调用例6.2返回的是提升关键码的值(如有则为新的根节点键)与指向(如有则为新的根
节点指向)L’的指针。其中要注意的是分裂父节点几种处理情况:①提升左关键码值,此
时一定是从左子树中分裂提升,见图6.9所示;②提升中间关键码值是从中间子树中分裂
提升上来的,见图6.10所示;③提升右关键码情况见图6.11所示。
root
18
插入17
12162330
(a)
4853
33
myretp
myretv=16
12
L
myretv=18
L=root
16
左指针未变
12
center
myretp
17
33
left
2330
myretp
17
L'
root
1833
2330
(b)
4853
L'=retptr
retptr->left=root->center
center
retptr->center=root->right
4853
(c)
图6.9父节点分裂提升左关键码
177
《数据结构》教案 2022-3-22
root
18
插入20
33
root
18
L
33
myretv
23
L'
30
(b)
myretp
4853312
1620
(a)
L=root
18
myretv=23
myretp
L'=retptr
33
center未变center
left
repptr->left=myretp
左指针未变
myretpretptr->center=root->right
12
16
20304853
(c)
图6.10父节点分裂提升中间关键码
root
1833
插入43
root
1833
myretv=48
L
myretp
L'
53
312
16203043
(a)
myretv=33
L=root
18
myretp
(b)
L'=retptr
48
retptr->left=root->right
center
center未变left
左指针未变
retptr->center=myretp
root->rightmyretp
43
12
16
203053
(c)
图6.11父节点分裂提升右关键码
关于2-3树的删除操作,需要考虑三种情况:①从包含两个关键码的叶节点中删除一
个关键码时只需要简单清除即可,不会影响其它节点;②唯一个关键码从叶节点中删除;
③从一个内部节点删除一个关键码。后两种情况特别复杂,我们留待B+树时讨论。
6.4 B+树
6.4.1 B+树定义
定义:一个m阶的B树具有以下特性:
(1) 根是一个叶节点或者至少有两个子女;
(2) 除了根节点和叶节点以外,每个节点有m/2到m个子女,存储m-1个关键码;
(3) 所有叶节点在树的同一层,因此树总是高度平衡的;
(4) 记录只存储在叶节点,内部节点关键码值只是用于引导检索路径的占位符;
178
+
《数据结构》教案 2022-3-22
(5) 叶节点用指针联接成一个链表。
(6) 类比于二叉排序树的检索特性。
B树的叶节点与内部节点不同的是,叶节点存储实际记录,当作为索引树应用时,就是记录
的关键码值与指向记录位置的指针,叶节点存储的信息可能多于或少于m个记录。见图6.12
所示。
33
18
10 12 15
23
23 30 31
图6.12 4阶B
+
树
48
33 45 47
48 50 52
+
18 19 20 21 22
B树节点结构定义为:
Struct Bpnode{
Struct PAIR recarray[MAXSIZE];//关键码/指针对数组
int numrec;
Bpnode *left,*right;
}
其中,PAIR结构定义为:
Struct PAIR{
int key;
Struct BPnode *point;
}
因为point同时也是指向文件记录的指针,我们需要注意同构问题,这里假设文件记
录与节点结构相同,当然,实际是不可能的。此外,这里定义的叶子节点只是存储了指向
记录位置的指针与关键码key,实际上应该是记录的关键码与数据信息(文件名等)。
一个B树的检索函数如例6.3所示, 子函数binaryle()调用后返回数组recarray[]内
等于或小于检索关键码值key的那个最大关键码的位置偏移。
currec
recarray[i].key <= key
recarray[0].keyrecarray[1].keyrecarray[i].keyrecarray[m-1].key
+
+
具有m个子女的B+树节点k的关键码-指针对数组
例6.3 B树检索函数
struct BPnode *find(struct BPnode *root,int key)
{
int currec;
currec=binaryle(root->recarray,root->numrec,key);
179
+
《数据结构》教案 2022-3-22
if(root->left==Null){//叶子节点
if(root->recarray[currec].key==key)
return root->recarray[currec].point;
else return Null;
}
}
我们注意到,一个节点的左指针为空时表明到达了叶子节点,从节点结构定义可知,内部
节点的左指针应该指向其左子树的根节点,而叶子节点链上的每个节点左指针为空,只有
右指针指向其兄弟节点,且链尾右指针亦为空。
else find(root->recarray[currec].point,key);
6.4.2 B+树插入与删除
插入操作过程
+
一棵B的生长过程见图6.13所示,首先找到包含记录的叶子节点,如果叶子未满,则
只需简单将关键码(与指向其物理位置的指针)放置到数组中,记录数加一;如果叶子已
经满了,则分裂叶子节点为两个,记录在两个节点之间平均分配,然后提升右边节点关键
码值最小(数组第一个位置上的记录关键码)的一份拷贝,提升处理过程与2-3树一样,
可能会形成父节点直至根节点的分裂过程,最终可能让B树增加一层。
+
180
《数据结构》教案 2022-3-22
50
10 12 23 33 48
(a)
10 12 23
33
33 48 50
(b)
3348
10 12 15 18 2333 45 4748 50 52
(d)47插入之后
183348
33
10 12 15 18 2333 45 48 50 52
(c)15,18,52,45插入之后
10 12 15
18 21 23
33 45 47
(e)21插入之后
183348
48 50 52
18 20 21 23 31
33 45 47
(e)20,31插入之后
内部节点关键码数达到m分裂时,
33
提升排序位置上的m-1关键码
18
23
10 12 15
18 20 2123 30 31
48
33 45 47
10 12 15
48 50 52
48 50 52
(f)继续插入30引起分裂提升过程
图6.13
删除操作过程
+
在B树中删除一个记录要首先找到包含记录的叶子节点,如果该叶子内的记录数超过
m/2,我们只需简单的清除该记录,因为剩下的记录数至少仍是m/2。
如果一个叶子节点内的记录删除后其余数小于m/2则称为下溢,于是我们需要采取如
下处理:
(1) 如果它的兄弟节点记录数超过m/2,可以从兄弟节点中移入,移入数量应让兄弟节
点能平分记录数,以避免短时间内再次发生下溢。同时,因为移动后兄弟节点的第
一个记录关键码值产生变化,所以需要相应的修改其父节点中的占位符关键码值,
以保证占位符指向的节点其第一个关键码值一定是大于或等于该占位符;
(2) 如果没有左右兄弟节点能移入记录(均小于或等于m/2),则将当前叶节点的记录移
出到兄弟节点,且其和一定小于等于m,然后将本节点删除。把一个父节点下的两
棵子树合并之后,因为要删除父节点中的一个占位符就可能会造成父节点下溢,产
生节点合并,并继续引发直至根节点的合并过程,从而树减少一层。
(3) 一对叶节点合并的时候应清除右边节点。
对一棵B树的删除操作过程见图6.14所示。
+
181
《数据结构》教案 2022-3-22
33
18
10 12 15
18
23
18 19 20 2123 30 31 32
48
33 45 4748 50 52
(a)设下限为3,删除关键码值18的记录不影响占位符18
33
18
30
10 12 15
20 21 2330 31 32
48
33 45 4748 50 52
(b)再删除关键码值19的记录之后从兄弟节点移入23并修改符节点占位符为30
33
18
30
10 12 15
20 21 2330 31 32
48
只剩一个子树
45 47 48 50 52
(c)删除关键码值33的记录合并兄弟叶节点
30
18
10 12 15
20 21 23
30
1831
10 12 15
20 21 23
1830
10 12 15
20 21 23
(f)根节点合并
图6.14
31 32 45 47 52
33
30 31 32
45 47 48 50 52
(d)传递叶节点到右子树并调整占位符
31 32 45 47 52
(e)删除右子树的48,50,30之后叶节点合并引起父节点合并
6.4.3 B+树实验设计
(1)
设计一个4阶B树,要求:
记录应该包括4字节(long)关键码值和60字节的数据字段(存储文件名等),设
每个叶子可以存储5条记录,而内部节点应该是关键码值/指针对。此外,每个节
点还应该有指向同层下一个节点的指针、本节点存储的关键码数等;
(2)
+
此4阶B树应该支持插入、删除以及根据给定关键码值进行精确检索与关键码范围
182
+
《数据结构》教案 2022-3-22
检索;
(3)
(4)
显示(打印)此4阶B树的生长(含删除节点)过程实例;
有能力的同学增加4阶B树的存/取(从硬盘文件)功能。
+
+
程序设计时建议注意以下几点:
节点结构定义,即内部节点与外部节点同构问题;
初始化设置;
B
树输入(文件名与关键码)/输出(显示)功能;
+
183
发表评论