slide1
Download
Skip this Video
Download Presentation
高级数据结构

Loading in 2 Seconds...

play fullscreen
1 / 378

高级数据结构 - PowerPoint PPT Presentation


  • 62 Views
  • Uploaded on

高级数据结构. 教材: 《 数据结构( C++ 描述) 》 (金远平编著,清华大学出版社) 讲课教师: 金远平,软件学院 [email protected] 将孤立地分析一次算法调用得出的结论应用于一个 ADT 的相关操作序列会产生过于悲观的结果。 例 1.12 整数容器 Bag 。 class Bag { public: Bag ( int MaxSize = DefaultSize ) ; // 假设 DefaultSize 已定义 int Add ( const int x ) ; // 将整数 x 加入容器中

loader
I am the owner, or an agent authorized to act on behalf of the owner, of the copyrighted work described.
capcha
Download Presentation

PowerPoint Slideshow about ' 高级数据结构' - kathy


An Image/Link below is provided (as is) to download presentation

Download Policy: Content on the Website is provided to you AS IS for your information and personal use and may not be sold / licensed / shared on other websites without getting consent from its author.While downloading, if for some reason you are not able to download a presentation, the publisher may have deleted the file from their server.


- - - - - - - - - - - - - - - - - - - - - - - - - - E N D - - - - - - - - - - - - - - - - - - - - - - - - - -
Presentation Transcript
slide1

高级数据结构

教材:《数据结构(C++描述)》(金远平编著,清华大学出版社)讲课教师: 金远平,软件学院[email protected]

JYP

slide2

将孤立地分析一次算法调用得出的结论应用于一个ADT的相关操作序列会产生过于悲观的结果。将孤立地分析一次算法调用得出的结论应用于一个ADT的相关操作序列会产生过于悲观的结果。

例1.12整数容器Bag。

class Bag {

public:

Bag ( int MaxSize = DefaultSize ); // 假设DefaultSize已定义

int Add (const int x ); // 将整数x加入容器中

int Delete (const int k ); // 从容器中删除并打印k 个整数

private:

int top; // 指示已用空间

int *b; // 用数组b存放整数

int n; // 容量

};

代价分摊(1.5.4)

JYP

slide3

各操作的实现如下:

Bag::Bag ( int MaxSize = DefaultSize ):n(MaxSize) {

b = new int[n];

top = -1;

}

int Bag::Add (const int x) {

if (top = = n-1) return 0; // 返回0表示加入失败

else {

b[++top] = x;

return 1;

}

}

JYP

slide4

int Bag::Delete (const int k) {

if (top + 1 < k ) return 0; //容器内元素不足k个,删除失败

else {

for (int i = 0; i < k; i++) cout << b[top – i] << “ ” ;

top = top - k;

return 1;

}

}

先分析操作成功的情况:Add(x)的时间复杂性是O(1);Delete(k)需要k个程序步,且k可能等于n,在最坏情况下其时间复杂性是O(n);一个调用Add操作 m1次,Delete操作m2次的序列的总代价则为O(m1+ m2n)。

JYP

slide5

前面是常规分析的结论。进一步观察:如果一开始容器为空,则删除的元素不可能多于加入的元素,即 m2次Delete操作的总代价不可能大于m1次Add操作的总代价。因此,在最坏情况下,一个调用Add操作 m1次,Delete操作m2次的序列的总代价为O(m1)。

操作失败时,Add(x)和Delete(k) 的时间复杂性都是O(1)。因此,在操作可能失败的情况下,一个调用Add操作 m1次,Delete操作m2次的序列的总代价为O(m1+ m2)。

JYP

slide6

常规分析并没有错,只是其推导出的总代价上界远大于实际可得的上界。其原因是这种分析法没有注意到连续的最坏情况删除是不可能的。常规分析并没有错,只是其推导出的总代价上界远大于实际可得的上界。其原因是这种分析法没有注意到连续的最坏情况删除是不可能的。

为了取得更准确的结果,还应该度量ADT数据结构的状态。对于每一个可能的状态S,赋予一个实数(S)。(S)称为S的势能,其选择应使得(S)越高,对处于S状态的数据结构成功进行高代价操作的可能越大。

例如,将容器元素个数作为容器状态的势能就很合理,因为元素越多,对容器成功进行高代价操作的可能越大。

JYP

slide7

考虑一个由m个对ADT操作的调用构成的序列,并设ti是第i次调用的实际代价,定义第i次调用的分摊代价ai为考虑一个由m个对ADT操作的调用构成的序列,并设ti是第i次调用的实际代价,定义第i次调用的分摊代价ai为

ai = ti + (Si) – (Si-1)

Si-1是第i次调用开始前ADT数据结构的状态,Si是第i次调用结束后ADT数据结构的状态。设的选择使得(Sm) ≥ (S0),则

JYP

slide8

即,分摊代价的总和是实际代价总和的上界。

例1.12将容器元素个数作为(S)。若操作序列始于空容器,则(Sm) ≥ (S0)总是成立。下表反映了容器(S)的典型变化情况。

JYP

slide9

对于Add操作,ti=1,(Si)–(Si-1)=1,所以ai=2;对于Delete操作,ti=k,(Si)–(Si-1)= –k,所以ai=0。

任何一个调用Add操作 m1次,Delete操作m2次的序列的总代价为O(m12 + m20) = O(m1)。

JYP

slide10

可见,分摊分析法将偶尔出现的高价操作调用的代价分摊到邻近的其它调用上,故而得名。可见,分摊分析法将偶尔出现的高价操作调用的代价分摊到邻近的其它调用上,故而得名。

而且,当用分摊分析法得到的一个操作调用序列的代价总和比用常规分析法得到的代价总和小时,人们就得到了更接近实际代价的分析结果,或者说对算法时间复杂性的判断更准确了。

JYP

slide11

一个字符串的子序列通过从字符串中删除零或多个任意位置的字符得到。一个字符串的子序列通过从字符串中删除零或多个任意位置的字符得到。

两个字符串x和y的最长公共子序列记为lcs(x, y)。

例如,x = abdebcbb,y = adacbcb,则lcs(x, y)是adcbb和adbcb,如下所示:

两个字符串的最长公共子序列(2.4.3)

JYP

slide12

问题的基本求解方法:

用标记空串,则lcs(x, )= lcs(, y) = 。

lcs(xa, ya) = lcs(x, y)a,即xa和ya的最长公共子序列由x和y的最长公共子序列后接a构成。

若xa和yb的最后一个字符不相等,则当lcs(xa, yb)不以a结尾时一定等于lcs(x, yb),当lcs(xa, yb)不以b结尾时一定等于lcs(xa, y)。因此lcs(xa, yb)等于 lcs(x, yb)与 lcs(xa, y)中较长者。

JYP

slide13

由此可得计算两个字符串最长公共子序列长度的递归算法lcs:由此可得计算两个字符串最长公共子序列长度的递归算法lcs:

int String::lcs ( String y ) { // 驱动器

int n = Length( ), m = y.Length( );

return lcs( n, m, y.str );

}

int String::lcs (int i, int j, char *y ) { // 递归核心

if ( i == 0 | | j == 0) return 0;

if ( str[i-1] ==y[j-1] ) return ( lcs( i-1, j-1, y) + 1);

return max( lcs( i-1, j, y), lcs( i, j-1, y));

}

JYP

slide14

设x的长度为n,y的长度为m,在最坏情况下lcs的时间复杂性为w(n, m)。

w(n, m) =

c (c为常数) n = 0或m = 0

w(n, m-1) + w(n-1, m) 否则

因此,w(n, m)≥2 w(n-1, m-1)≥…≥2min(n, m)c,即lcs的时间复杂性是指数型的。

进一步可发现,lcs(i, 0)=0(0≤i≤n),lcs(0, j) =0(0≤j≤m)。lcs(i, j)的计算依赖于lcs(i–1, j–1)、lcs(i–1, j)和lcs(i, j–1),如下图所示:

JYP

slide15

根据以上拓扑关系,可以在不用递归的情况下计算lcs(i, j)。算法Lcs实现了上述优化策略,这种策略体现了动态规划的思想。算法Lcs的时间复杂性显然是O(nm),这比其递归版有很大改进。

JYP

slide16

int String::Lcs ( String y ) {

int n = Length( ), m = y.Length( );

int lcs[MaxN][MaxM]; // MaxN和MaxM 是已定义的常数

int i, j;

for ( i = 0; i <= n; i++) lcs[i][0] = 0; // 初始值

for ( j = 0; j <= m; j++) lcs[0][j] = 0; // 初始值

for ( i = 1; i <= n; i++)

for ( j = 1; j <= m; j++)

if ( str[i-1] ==y.str[j-1] ) lcs[i][j] = lcs[i-1][j-1] + 1;

else lcs[i][j] = max(lcs[i-1][j], lcs[i][j-1]);

return lcs[n][m];

}

JYP

slide17

例如,x = abdebcbb,y = adacbcb,lcs(x, y) = adbcb,改进算法的计算如下所示:

JYP

slide18

计算机模拟(simulation):

  • 用软件模仿另一个系统的行为。
  • 将研究对象表示为数据结构,对象动作表示为对数据的操作,控制动作的规则转换为算法。
  • 通过更改数据的值或改变算法设置,可以观察到计算机模拟的变化,从而使用户能够推导出关于实际系统行为的有用结论。
  • 在计算机处理一个对象的动作期间,其它对象和动作需等待。
  • 队列在计算机模拟中具有重要应用。

机场模拟(2.9)

JYP

slide19

简单机场模拟:

  • 只有一个跑道。
  • 在每个时间单元,可起飞或降落一架飞机,但不可同时起降。
  • 飞机准备降落或起飞的时间是随机的,在任一时间单元,跑道可能处于空闲、降落或起飞状态,并且可能有一些飞机在等待降落或起飞。
  • 飞机在地上等待的代价比在空中等待的小,只有在没有飞机等待降落的情况下才允许飞机起飞。
  • 当出现队列满的情况时,则拒绝为新到达的飞机服务。

JYP

slide20

需要两个队列landing和takeoff。

飞机可描述为:

struct plane {

int id; // 编号

int tm; // 到达队列时间

};

飞机的动作为:

enum action { ARRIVE, DEPART };

JYP

slide21

模拟运行:

  • 时间单元:1 — endtime,并产生关于机场行为的重要统计信息,如处理的飞机数量,平均等待时间,被拒绝服务飞机的数量等。
  • 采用基于泊松分布的随机整数决定在每个时间单元有多少架新飞机需要降落或起飞。
  • 假设在10个时间单元中到达的飞机数分别是:2,0,0,1,4,1,0,0,0,1,那么每个时间单元的平均到达数是0.9。

JYP

slide22

一个非负整数序列满足给定期望值v的泊松分布意味着,对于该序列的一段足够长的子序列,其中整数的平均值接近v。一个非负整数序列满足给定期望值v的泊松分布意味着,对于该序列的一段足够长的子序列,其中整数的平均值接近v。

  • 在模拟中还需要建立新到达飞机的数据,处理被拒绝服务的飞机,起飞、降落飞机,处理机场空闲和总结模拟结果。
  • 下面是机场模拟类定义:

JYP

slide23

class AirportSimulation {

// 机场模拟。一个时间单元 = 起飞或降落的时间

public:

AirportSimulation( ); // 构造函数

void RunSimulation( ); // 模拟运行

private:

Queue<plane> landing(6); // 等待降落飞机队列,假设用环

// 型队列,实际长度为5

Queue<plane> takeoff(6); // 等待起飞飞机队列,同上

double expectarrive; //一个时间单元内期望到达降落飞机数

double expectdepart; //一个时间单元内期望到达起飞飞机数

int curtime; // 当前时间

int endtime; // 模拟时间单元数

int idletime ; // 跑道空闲时间单元数

int landwait ; // 降落飞机的总等待时间

JYP

slide24

int nland ; // 降落的飞机数

int nplanes; // 处理的飞机数

int nrefuse; // 拒绝服务的飞机数

int ntakeoff; // 起飞的飞机数

void Randomize( ); // 设置随机数种子

int PoissionRandom(double& expectvalue);

// 根据泊松分布和给定期望值生成随机非负整数

plane* NewPlane(plane& p, action kind);

// 建立新飞机的数据项

void Refuse(plane& p, action kind); // 拒绝服务

void Land(plane& p); // 降落飞机

void Fly(plane& p); // 起飞飞机

void Idle( ); // 处理空闲时间单元

void Conclude( ); // 总结模拟结果

};

JYP

slide25

构造函数初始化各变量,如下所示:

AirportSimulation::AirportSimulation( ) { // 构造函数

Boolean ok;

cout << “请输入模拟时间单元数:”; cin >> endtime;

idletime = landwait = nland = nplanes = 0;

nrefuse = ntakeoff = takoffwait = 0; // 初值

Randomize( ); // 设置随机数种子

do {

cout << “请输入一个时间单元内期望到达降落飞机数:”;

cin >> expectarrive;

cout << “请输入一个时间单元内期望到达起飞飞机数:”;

cin >> expectdepart;

JYP

slide26

if (expectarrive < 0.0 || expectdepart < 0.0) {

cout << “这些数不能为负!请重新输入。”<< endl;

ok = FALSE;

} else if (expectarrive + expectdepart > 1.0) {

cout << “机场将饱和!请重新输入。”<< endl;

ok = FALSE;

} else ok = TRUE;

} while (ok == FALSE);

}

JYP

slide27

RunSimulation( )是模拟运行的主控程序:

void AirportSimulation::RunSimulation( ) {

int pri; // 伪随机整数

plane p;

for (curtime = 1; curtime <= endtime; curtime++) {

cout << “时间单元” << curtime << “:”;

pri = PoissionRandom(expectarrive);

for (int i =1; i <= pri; i++) { //处理新到达准备降落的飞机

p = *NewPlane(p, ARRIVE);

if (landing.IsFull( )) Refuse(p, ARRIVE);

else landing.Add(p);

}

pri = PoissionRandom(expectdepart);

JYP

slide28

for (int i =1; i <= pri; i++) { //处理新到达准备起飞的飞机

p = *NewPlane(p, DEPART);

if (takeoff.IsFull( )) Refuse(p, DEPART);

else takeoff.Add(p);

}

if (!landing.IsEmpty( )) { // 降落飞机

p = *landing.Delete(p);

Land(p);

} else if (!takeoff.IsEmpty( )) { // 起飞飞机

p = *takeoff.Delete(p);

Fly(p);

} else Idle( ); // 处理空闲时间单元

}

Conclude( ); // 总结模拟结果

}

JYP

slide29

用库函数srand和rand生成随机数,并用时钟设置随机种子,以增强随机性:用库函数srand和rand生成随机数,并用时钟设置随机种子,以增强随机性:

void AirportSimulation::Randomize( ) {

srand((unsigned int) (time(NULL)%10000));

}

库函数time返回自格林威治时间1970年1月1日00:00:00 至今经历的秒数。这使得每次模拟运行随机数起点都不同。

rand按照均匀分布生成随机数,还需要转化为适合机场模拟的泊松分布随机数。下面直接给出根据泊松分布和给定期望值生成伪随机整数的算法(其数学推导略) :

JYP

slide30

int AirportSimulation::PoissionRandom(double& expectvalue) {

int n = 0; // 循环计数

double limit; // e-v, 其中,v是期望值

double x; // 伪随机数

limit = exp(-expectvalue);

x = rand( ) / (double) INT_MAX;

// rand( )生成0到INT_MAX之间的整数, x在0和1之间

while (x > limit) {

n++;

x *= rand( ) / (double) INT_MAX;

}

return n;

}

JYP

slide31

建立新飞机的数据项由函数NewPlane实现:

plane* AirportSimulation::NewPlane(plane& p, action kind) {

nplanes++; // 飞机总数加1

p.id = nplanes; p.tm = curtime;

switch (kind) {

case ARRIVE:

cout << “飞机” << nplanes << “准备降落。” << endl;

break;

case DEPART:

cout << “飞机” << nplanes << “准备起飞。” << endl;

break;

}

return &p;

}

JYP

slide32

处理被拒绝的飞机由函数Refuse实现:

void AirportSimulation::Refuse(plane& p, action kind) {

switch (kind) {

case ARRIVE:

cout << “引导飞机” << p.id << “到其它机场降落。”

<< endl;

break;

case DEPART:

cout << “告诉飞机” << p.id << “等一会儿再试。”

<< endl;

break;

}

nrefuse++; // 被拒绝飞机总数加1

}

JYP

slide33

处理飞机降落由函数Land实现:

void AirportSimulation::Land(plane& p) {

int wait;

wait = curtime – p.tm;

cout << “飞机” << p.id << “降落,该机等待时间:”

<< wait << “。”<< endl;

nland++; // 降落飞机总数加1

landwait += wait; // 累加总降落等待时间

}

JYP

slide34

处理飞机起飞由函数Fly实现:

void AirportSimulation::Fly(plane& p) {

int wait = curtime – p.tm;

cout << “飞机” << p.id << “起飞,该机等待时间:”

<< wait << “。”<< endl;

ntakeoff++; // 起飞飞机总数加1

takeoffwait += wait; // 累加总起飞等待时间

}

JYP

slide35

处理机场空闲由函数Idle实现:

void AirportSimulation::Idle( ) {

cout << “跑道空闲。” << endl;

idletime++; // 跑道空闲时间加1

}

总结模拟结果由函数Conclude实现:

void AirportSimulation::Conclude( ) {

cout << “总模拟时间单元数:” << endtime << endl;

cout << “总共处理的飞机数:” << nplanes << endl;

cout << “降落飞机总数:” << nland << endl;

cout << “起飞飞机总数:” << ntakeoff << endl;

JYP

slide36

cout << “拒绝服务的飞机总数:” << nrefuse << endl;

cout << “队列中剩余的准备降落飞机数:”

<< landing.Size( ) << endl;

// 假设队列成员函数Size( )返回队列中元素个数

cout << “队列中剩余的准备起飞飞机数:”

<< takeoff.Size( ) << endl;

if (endtime > 0) cout << “跑道空闲时间百分比:”

<< ((double) idletime / endtime) * 100.0 << endl;

if (nland > 0) cout << “降落平均等待时间:”

<< (double) landwait / nland << endl;

if (ntakeoff > 0) cout << “起飞平均等待时间:”

<< (double) takeoffwait / ntakeoff << endl;

}

JYP

slide37

可通过下列程序模拟运行:

#include “common.h”

#include “simdefs.h” // 存放模拟类定义及相关函数实现

void main( ) {

AirportSimulation s;

s.RunSimulation( );

}

JYP

slide38

模拟过程产生的数据如下:

请输入模拟时间单元数:30

请输入一个时间单元内期望到达降落飞机数:0.47

请输入一个时间单元内期望到达起飞飞机数:0.47

时间单元1:飞机1准备降落。

飞机1降落,该机等待时间:0。

时间单元2:跑道空闲。

时间单元3:飞机2准备降落。

飞机3准备降落。

飞机2降落,该机等待时间:0。

时间单元4: 飞机3降落,该机等待时间:1。

JYP

slide39

时间单元5:飞机4准备降落。

飞机5准备降落。

飞机6准备起飞。

飞机7准备起飞。

飞机4降落,该机等待时间:0。

时间单元6:飞机8准备起飞。

飞机5降落,该机等待时间:1。

时间单元7:飞机9准备起飞。

飞机10准备起飞。

飞机6起飞,该机等待时间:2。

时间单元8: 飞机7起飞,该机等待时间:3。

时间单元9: 飞机8起飞,该机等待时间:3。

JYP

slide40

时间单元10:飞机11准备降落。

飞机11降落,该机等待时间:0。

时间单元11:飞机12准备起飞。

飞机9起飞,该机等待时间:4。

时间单元12:飞机13准备降落。

飞机14准备降落。

飞机13降落,该机等待时间:0。

时间单元13: 飞机14降落,该机等待时间:1。

时间单元14: 飞机10起飞,该机等待时间:7。

时间单元15: 飞机15准备降落。

飞机16准备起飞。

飞机17准备起飞。

飞机15降落,该机等待时间:0。

JYP

slide41

时间单元16:飞机18准备降落。

飞机19准备降落。

飞机20准备起飞。

飞机21准备起飞。

飞机18降落,该机等待时间:0。

时间单元17: 飞机22准备降落。

飞机19降落,该机等待时间:1。

时间单元18: 飞机23准备起飞。

告诉飞机23等一会儿再试。

飞机22降落,该机等待时间:1。

JYP

slide42

时间单元19: 飞机24准备降落。

飞机25准备降落。

飞机26准备降落。

飞机27准备起飞。

告诉飞机27等一会儿再试。

飞机24降落,该机等待时间:0。

时间单元20: 飞机28准备降落。

飞机29准备降落。

飞机30准备降落。

飞机31准备降落。

引导飞机31到其它机场降落。

飞机25降落,该机等待时间:1。

JYP

slide43

时间单元21:飞机32准备降落。

飞机33准备起飞。

告诉飞机33等一会儿再试。

飞机26降落,该机等待时间:2。

时间单元22:飞机28降落,该机等待时间:2。

时间单元23:飞机29降落,该机等待时间:3。

时间单元24:飞机34准备起飞。

告诉飞机34等一会儿再试。

飞机30降落,该机等待时间:4。

JYP

slide44

时间单元25:飞机35准备起飞。

告诉飞机35等一会儿再试。

飞机36准备起飞。

告诉飞机36等一会儿再试。

飞机32降落,该机等待时间:4。

时间单元26:飞机37准备起飞。

告诉飞机37等一会儿再试。

飞机12起飞,该机等待时间:15。

时间单元27:飞机16起飞,该机等待时间:12。

时间单元28:飞机17起飞,该机等待时间:13。

时间单元29:飞机20起飞,该机等待时间:13。

JYP

slide45

时间单元30:飞机38准备起飞。

飞机21起飞,该机等待时间:14。

总模拟时间单元数:30

总共处理的飞机数:38

降落飞机总数:19

起飞飞机总数:10

拒绝服务的飞机总数:8

队列中剩余的准备降落飞机数:0

队列中剩余的准备起飞飞机数:1

跑道空闲时间百分比:3.33

降落平均等待时间:1.11

起飞平均等待时间:8.60

JYP

slide46

当n = 0或1时,只有一棵二叉树。

当n = 2,存在2棵不同(结构)的二叉树:

二叉树计数(4.9)

JYP

slide47

而当n = 3,则存在5棵不同的二叉树:

那么,具有n个结点的不同二叉树究竟有多少呢?

JYP

slide48

不失一般性,将树的n个结点编号为1到n。假设一棵二叉树的前序序列为1 2 3 4 5 6 7 8 9且其中序序列为2 3 1 5 4 7 8 6 9,则通过这对序列可以唯一确定一棵二叉树。

为了构造相应的二叉树,可找出前序第一个结点,即1。于是,结点1是树根,中序序列中所有在1之前的结点(2 3)属于左子树,其余结点(5 4 7 8 6 9)属于右子树。

JYP

slide50

接着,可根据前序序列2 3和中序序列2 3构造左子树。显然,结点2是树根。由于在中序序列中,结点2之前无结点,所以其左子树为空,结点3是其右子树,如下图所示:

JYP

slide51

如此继续,最终可唯一地构造下图所示的二叉树:如此继续,最终可唯一地构造下图所示的二叉树:

JYP

slide52

一般地,我们可以设计算法,根据二叉树的前序/中序序列对构造该树。一般地,我们可以设计算法,根据二叉树的前序/中序序列对构造该树。

可以证明,每一棵二叉树都有唯一的前序/中序序列对。

如果树中结点按前序编号,即树的前序序列为1, 2, …, n,则由上讨论可知,不同的二叉树定义不同的中序序列。

因此,不同的二叉树个数等于从前序序列为1, 2, …, n的二叉树可产生的不同的中序序列的个数。

JYP

slide54

对于每一个i,存在bi bn-i-1棵不同的树,因此有

(4.3)

为了用n表示bn,必须求解递归方程(4.3)。设

(4.4)

JYP

slide56

x B2(x) – B(x) + 1 = 0

JYP

slide57

解此一元二次方程,并注意B(0) = b0 = 1(等式(4.3)),可得

利用二项式公式展开(1 – 4x)1/2得到

JYP

slide59

比较等式(4.4) 和(4.5),并注意bn是B(x)中xn的系数,可得

JYP

slide61

5.2.1 双端优先队列与最小最大堆

双端优先队列是一种支持下列操作的数据结构:

(1) 插入一个具有任意key值的元素

(2) 删除key值最大的元素

(3) 删除key值最小的元素

当只需要支持两个删除操作之一时,可采用前一节的最小堆或最大堆。而最小最大堆可支持以上全部操作。

最小最大堆(5.2)

JYP

slide62

双端优先队列可定义为如下的抽象类:

template <class Type>

class DEPQ {

public:

virtual void Insert(const Element<Type>&) = 0;

virtual Element<Type>* DeleteMax(Element<Type>&) = 0;

virtual Element<Type>* DeleteMin(Element<Type>&) = 0;

};

其中,假设Element<Type>含有一个key数据成员。

JYP

slide63

定义:最小最大堆是一棵完全二叉树,且其中每个元素有一个key数据成员。树的各层交替为最小层和最大层。根结点在最小层。设x是最小最大堆的任意结点。若x在最小(最大)层上,则x中的元素的key值在以x为根的子树的所有元素中是最小(最大)的。位于最小(最大)层的结点称为最小(最大)结点。定义:最小最大堆是一棵完全二叉树,且其中每个元素有一个key数据成员。树的各层交替为最小层和最大层。根结点在最小层。设x是最小最大堆的任意结点。若x在最小(最大)层上,则x中的元素的key值在以x为根的子树的所有元素中是最小(最大)的。位于最小(最大)层的结点称为最小(最大)结点。

JYP

slide64

下面是一个具有12个元素的最小最大堆,其中最大层的结点用粗体字表示:下面是一个具有12个元素的最小最大堆,其中最大层的结点用粗体字表示:

JYP

slide65

最小最大堆定义为DEPQ的派生类,以确保实现DEPQ的三个操作。最小最大堆定义为DEPQ的派生类,以确保实现DEPQ的三个操作。

template <class Type>

class MinMaxHeap: public DEPQ <Type> {

public:

MinMaxHeap (const int); // 构造函数

~MinMaxHeap ( ); // 析构函数

void Insert (const Element<Type>&);

Element<Type>* DeleteMax(Element<Type>& x );

Element<Type>* DeleteMin(Element<Type>& x );

private:

Element<KeyType> *h;

int n; // 最小最大堆的当前元素个数

int MaxSize; // 堆中可容纳元素的最大个数

JYP

slide66

// 其它用于实现最小最大堆的私有数据成员

};

template <class Type> // 构造函数定义

MinMaxHeap<Type>::MinMaxHeap (const int sz = DefaultHeapSize) : MaxSize(sz), n(0) {

h = new Element<Type>[MaxSize+1]; // h[0] 不用

}

JYP

slide67

假设将key为5的元素插入图5.4的最小最大堆。插入后的堆有13个元素,其形状如下图:假设将key为5的元素插入图5.4的最小最大堆。插入后的堆有13个元素,其形状如下图:

5.2.2 插入操作

JYP

slide68

最小最大堆的插入算法也需要沿从新结点j到根的路径比较key值。最小最大堆的插入算法也需要沿从新结点j到根的路径比较key值。

比较结点j的key值5和j的双亲的key值10,由于存放key值10的结点位于最小层,且5 < 10,所以5一定小于所有从j到根的路径中位于最大层的结点的key值。

为了保持最小最大堆的性质,只需要检查从j到根的路径中的最小结点即可。首先,将key为10的元素移到结点j。接着,将key为7的元素移到10的原来位置。最后,将key为5的元素插入根结点。

JYP

slide69

由此形成的最小最大堆如下图,圆周加粗的结点内容在插入过程修改过:由此形成的最小最大堆如下图,圆周加粗的结点内容在插入过程修改过:

JYP

slide70

再假设将key为80的元素插入图5.4所示的最小最大堆。插入后的堆有13个元素,其形状也与前面相同。再假设将key为80的元素插入图5.4所示的最小最大堆。插入后的堆有13个元素,其形状也与前面相同。

由于存放key值10的结点位于最小层,且10 < 80,所以80一定大于所有从j到根的路径中位于最小层的结点的key值。

为了保持最小最大堆的性质,只需要检查从j到根的路径中的最大结点即可。图5.4中只有一个这样的结点,其元素的key值为40,将该元素移到结点j,并将key为80的新元素插入key为40的元素原来的结点。

JYP

slide72

成员函数Insert实现了上述过程,其中又用到私有成员函数VerifyMax,VerifyMin 和level。

template <class Type>

void MinMaxHeap<Type>::Insert(const Element<Type>&x ) {

if (n == MaxSize ) { MinMaxFull( ); return;}

n++;

int p = n/2; // p是新结点的双亲

if (!p) {h[1] = x; return;} // 插入初始时为空的堆

switch (level(p)) {

case MIN:

if (x.key < h[p].key) { // 沿着最小层检查

h[n]=h[p];

VerifyMin(p, x);

}

JYP

slide73

else VerifyMax(n, x); // 沿着最大层检查

break;

case MAX:

if (x.key > h[p].key) { // 沿着最大层检查

h[n]=h[p];

VerifyMax(p, x);

}

else VerifyMin(n, x); // 沿着最小层检查

} // switch结束

} // Insert结束

JYP

slide74

函数level确定一个结点是位于最小最大堆的最小层,还是位于最大层。根据引理4.2,当log2(j + 1)为偶数时,结点j位于最大层,否则位于最小层。

函数VerifyMax从最大结点i开始,沿着从结点i到最小最大堆的根的路径检查最大结点,查找插入元素x的正确结点。在查找过程中,key值小于x.key的元素被移到下一个最大层。

JYP

slide75

template <class Type>

void MinMaxHeap<Type>::VerifyMax (int i, const

Element<KeyType>&x ) { // 沿着从最大结点i

// 到根结点的路径检查最大结点,将x插入正确位置

for (int gp = i/4; gp && (x.key > h[gp].key); gp /=4) {

// gp是 i的祖父

h[i] = h[gp]; // 将h[gp]移到h[i]

i = gp;

}

h[i] = x; // 将x插入结点i

}

JYP

slide76

函数VerifyMin与VerifyMax类似,所不同的是,前者从最小结点i开始,沿着从结点i到根的路径检查最小结点,将元素x插入正确位置。函数VerifyMin与VerifyMax类似,所不同的是,前者从最小结点i开始,沿着从结点i到根的路径检查最小结点,将元素x插入正确位置。

分析:由于n个元素的最小最大堆有O(log n)层,成员函数Insert的时间复杂性是O(log n)。

JYP

slide77

最小最大堆中删除最小元素在根结点中。删除图5.4的最小元素7之后的堆有11个元素,形状如下:最小最大堆中删除最小元素在根结点中。删除图5.4的最小元素7之后的堆有11个元素,形状如下:

5.2.3 删除最小元素操作

JYP

slide78

此时应将key值为12的元素从堆中删除后再重新插入,需要沿着从根到叶的路径检查相关结点。此时应将key值为12的元素从堆中删除后再重新插入,需要沿着从根到叶的路径检查相关结点。

一般而言,将元素x插入根结点为空的最小最大堆h中有两种情况需要考虑:

(1) 根结点无子女,这时可将x插入根结点中。

(2) 根结点至少有一个子女。这时堆中的最小元素(不算元素x)位于根结点的子女结点或孙子女结点(最多6个)之一。假设该结点的编号为k,则还需要考虑下列可能性:

(a) x.key≤h[k].key。由于h中不存在比x更小的元素,所以x可插入根。

JYP

slide79

(b) x.key > h[k].key且k是根的子女。由于k是最大结点,其后代的key值都不大于h[k].key,因而也不大于x.key。所以可将元素h[k]移到根,并将元素x插入结点k。

(c) x.key > h[k].key且k是根的孙子女。这时也可将元素h[k]移到根。设p是k的双亲。若x.key > h[p].key,则将x 和h[p]交换。这时,问题转化为将x插入以k为根的子堆中,且h[k]已腾空。这与初始插入根结点为空的最小最大堆h的情形类似。

JYP

slide80

在本例中,x.key = 12,根结点的子女结点或孙子女结点中的最小元素的key值为9。

设k为该结点编号,p是k的双亲。

由于12 > 9且k是根的孙子女,这属于情形2(c)。因此,将key值为9的元素(即h[k])移到根结点。由于x.key = 12 < 70 = h[p].key,所以不将x 和h[p]交换。

这时的情形如下一页所示。

JYP

slide82

通过以上讨论,可得成员函数DeleteMin。

template <class Type>

Element<Type>* MinMaxHeap<Type>::

DeleteMin(Element<Type>&y) {

// 从最小最大堆中删除并返回最小元素

if (!n) { MinMaxEmpty( );return 0; }

y = h[1]; // 保存根元素

Element<Type> x = h[n--];

int i = 1, j = n/2; // 为重新插入x作初始化

// 寻找插入x的位置

while (i <= j) { // i 有子女,情况(2)

int k = MinChildGrandChild(i);

if (x.key <= h[k].key)

break; // 情况 2(a),可将x 插入h[i]

JYP

slide83

else { // 情况2(b) 或 (c)

h[i] = h[k];

if (k <= 2*i+1) { // k 是i的子女,情况2(b)

i = k; // 可将x插入h[k]

break;

}

else { // k是i的孙子女,情况2(c)

int p = k/2; // p是k的双亲

if (x.key > h[p].key) {

Element<Type> t = h[p]; h[p] = x; x = t;

}

} // if (k<=2*i+1)结束

i = k;

} // if (x.key<=h[k].key)结束

JYP

slide84

} // while结束

h[i] = x; // 注意,即使现在n == 0,对h[1] 赋值也没

// 错,这样简化边界判断

return &y;

}

其中又用到私有成员函数MinChildGrandChild(i),该函数返回结点i的子女结点或孙子女结点中含最小元素的结点。

如果i的子女结点或孙子女结点都含最小元素,则MinChildGrandChild返回子女结点,这样可以避免DeleteMin中while循环的进一步迭代。

JYP

slide86

5.3.1 双堆定义

与最小最大堆相比,双堆也以对数时间支持双端优先队列的插入、删除最小和删除最大操作,而且,从常数因子考虑,双堆更快,算法更简单。

定义:双堆是一棵完全二叉树。该树或者为空,或者满足下列性质:

(1) 根结点不含元素。

(2) 左子树是最小堆。

(3) 右子树是最大堆。

双堆(5.3)

JYP

slide87

(4) 若右子树不空,设i为左子树中的任意结点,j为右子树中的对应结点。若这样的j不存在,则令j为右子树中对应i的双亲的结点。结点i中的key小于或等于结点j中的key。

JYP

slide89

其中,最大堆的结点用粗体字表示。最小堆的根结点含5,最大堆的根结点含45。含10的最小堆结点与含25的最大堆结点对应。对于最小堆中含9的结点,在性质(4)中定义的结点j是最大堆中含40的结点。其中,最大堆的结点用粗体字表示。最小堆的根结点含5,最大堆的根结点含45。含10的最小堆结点与含25的最大堆结点对应。对于最小堆中含9的结点,在性质(4)中定义的结点j是最大堆中含40的结点。

根据双堆定义,在具有n个元素的双堆中(n > 1),最小元素在最小堆的根结点中,最大元素在最大堆的根结点中。若n = 1,则最小和最大元素相同,在最小堆的根结点中。

JYP

slide90

若i是最小堆中的结点,则其在最大堆中的对应结点是i + 2log2i -1。因此,双堆定义性质(4)中的j可如下确定:

j = i + 2log2i -1;

if (j > n + 1) j /= 2;

注意,如果最小堆的所有叶结点都满足性质(4),则最小堆中其余结点也满足性质(4)。

JYP

slide91

双堆Deap的类定义如下:

template <class Type>

class Deap: public DEPQ <Type> {

public:

Deap (const int);

~Deap ( );

void Insert (const Element<Type>&);

Element<Type>* DeleteMax(Element<Type>& );

Element<Type>* DeleteMin(Element<Type>& );

private:

Element<Type> *d;

int n; // 当前元素个数

int MaxSize; // 可容纳的最大元素个数

// 其它私有数据成员

JYP

slide92

};

template <class Type>

Deap <Type> ::Deap (const int sz =

DefaultHeapSize):MaxSize(sz), n(0) {

d = new Element<Type>[MaxSize+2]; // d[0] 和d[1]不用

}

JYP

slide93

假设将key为4的元素插入图5.10所示的双堆。插入后的双堆有12个元素,其形状如下图。j指向双堆中的新结点。假设将key为4的元素插入图5.10所示的双堆。插入后的双堆有12个元素,其形状如下图。j指向双堆中的新结点。

5.3.2 插入操作

JYP

slide94

为了维护双堆性质,首先比较4和最小堆中与j对应结点i中的key值,此时该值为19。为了满足性质(4),将key为19的元素移到结点j。由于19小于等于j的双亲结点的key值,移动后最大堆性质仍然保持。为了维护双堆性质,首先比较4和最小堆中与j对应结点i中的key值,此时该值为19。为了满足性质(4),将key为19的元素移到结点j。由于19小于等于j的双亲结点的key值,移动后最大堆性质仍然保持。

现在只需利用最小堆插入算法将key为4的元素插入最小堆中的叶结点i并调整即可得到下一页所示的双堆。圆周加粗的结点的内容在插入过程中修改过。

JYP

slide96

如果将key为30的元素插入图5.10所示的双堆,插入后的双堆形状仍然如前面。如果将key为30的元素插入图5.10所示的双堆,插入后的双堆形状仍然如前面。

比较30和与j对应的结点i中的key值19,由于30大于19,只需利用最大堆插入算法将key为30的元素插入最大堆中的叶结点j并调整即可满足性质(4),并得到下一页所示的双堆。

JYP

slide98

新结点j是最小堆结点的情况与上面讨论的情况对称。由此可得实现双堆插入操作的函数Deap::Insert,其中又用到下列函数:新结点j是最小堆结点的情况与上面讨论的情况对称。由此可得实现双堆插入操作的函数Deap::Insert,其中又用到下列函数:

(1) Deap::DeapFull( )。

(2) Deap::MaxHeap(int p)。判断p是否最大堆结点。对于p > 1,当2log2 p + 2log2 p -1 ≤ p < 2log2 p时,p是最大堆的结点。

(3) Deap::MinPartner(int p)。p是双堆的最后一个结点且为最大堆结点,计算与p对应的最小堆结点,这可由p – 2log2p -1确定。

JYP

slide99

(4) Deap::MaxPartner(int p)。p是双堆的最后一个结点且为最小堆结点,计算与p对应的最大堆结点,这可由(p + 2log2p-1 ) / 2确定。

(5) 函数Deap::MinInsert和Deap::MaxInsert分别将一个元素插入最小堆和最大堆的指定位置。与最小堆或最大堆的插入操作的区别仅仅在于此处最小堆的根是结点2,最大堆的根是结点3。

JYP

slide100

template <class Type>

void Deap<Type>::Insert (const Element<Type>&x ) {

// 将元素x插入双堆

int i;

if (n == MaxSize ) { DeapFull( ); return;}

n++;

if (n == 1) { d[2] = x; return;} // 插入空双堆

int p = n + 1; // p是双堆中新的最后位置

switch (MaxHeap(p)) {

case TRUE: // p是最大堆结点

i = MinPartner(p);

if (x.key < d[i].key) {

d[p] = d[i];

MinInsert(i, x);

}

JYP

slide101

else MaxInsert(p, x);

break;

case FALSE: // p是最小堆结点

i = MaxPartner(p);

if (x.key > d[i].key) {

d[p] = d[i];

MaxInsert(i, x);

}

else MinInsert(p, x);

} // switch结束

} // Insert结束

分析:插入操作所需时间与双堆的高度成线性关系,其复杂性为O(log n)。

JYP

slide102

删除最小元素的过程:

首先将从最小堆的根删除元素的问题转化为从最小堆的某一个叶结点删除元素的问题。这可通过沿着从根到叶的路径检查,上调相关元素位置,保证叶结点以上的层次满足最小堆性质实现。

这种转化使得空位由原来的最小堆的根变为叶结点i。这时再将原来双堆的最后一个元素t插入叶结点i。将t插入双堆的叶结点i的过程与Deap::Insert基本相同。但这时Deap::MaxPartner(i)返回的j应为:

j = i + 2log2i -1;

if (j > n + 1) j /= 2;

5.3.3 删除最小元素

JYP

slide103

此外,如果结点j中的元素的关键字小于t的关键字,则需要将这两个元素交换,并根据需要调整从j到t原来所在结点和j的共同祖先的路径中的元素位置。此外,如果结点j中的元素的关键字小于t的关键字,则需要将这两个元素交换,并根据需要调整从j到t原来所在结点和j的共同祖先的路径中的元素位置。

下一页给出了实现删除最小元素的函数Deap::DeleteMin。

JYP

slide104

template <class Type>

Element<Type>* Deap<Type>::DeleteMin (Element<Type>&x ) { // 删除并返回双堆的最小元素

if (!n) { DeapEmpty( ); return;}

x = d[2]; // 最小元素

int p = n+1;

Element<Type> t = d[p]; // 最后一个结点中的元素

n--;

for (int i = 2; i 至少有一个子女; i = j) {

令j 为具有较小key值的子女;

d[i] = d[j];

}

将 t 插入双堆的叶结点i;

return &x;

}

JYP

slide105

假设从图5.10所示的双堆中删除最小元素。先将最后一个元素(key为20)存入临时变量t。假设从图5.10所示的双堆中删除最小元素。先将最后一个元素(key为20)存入临时变量t。

接着,通过移动从结点2到叶结点路径上的元素填充删除最小元素后在结点2形成的空位。每次都将当前结点的子女的较小元素上移,而被移动元素所在结点成为新的当前结点。于是,叶结点10为空位。

结点10在最大堆的对应结点的key值是40。由于20 < 40,不必交换。继续将key为20的元素插入最小堆的叶结点10,可得下一页的双堆。

JYP

slide107

本节考虑对优先队列另一种扩展——合并操作,即,将两个优先队列合并为一个。本节考虑对优先队列另一种扩展——合并操作,即,将两个优先队列合并为一个。

作为合并操作的一个应用,当一个优先队列的工作服务器关闭时,需要将该优先队列与另一个正在工作的服务器的优先队列合并。

设需要合并的两个优先队列的元素共有n个。若用左偏树,则优先队列的一般操作和合并操作都可在O(log n)时间内完成。

左偏树 (5.4)

JYP

slide108

左偏树可通过扩展二叉树定义。扩展二叉树是一棵二叉树,其中所有的空二叉子树都由方结点替代。扩展二叉树的方结点称为外部结点。二叉树原来的(圆)结点称为内部结点。左偏树可通过扩展二叉树定义。扩展二叉树是一棵二叉树,其中所有的空二叉子树都由方结点替代。扩展二叉树的方结点称为外部结点。二叉树原来的(圆)结点称为内部结点。

JYP

slide109

下图为两棵二叉树,与之对应的扩展二叉树如下一页所示。下图为两棵二叉树,与之对应的扩展二叉树如下一页所示。

JYP

slide111

设x是扩展二叉树的一个结点,LeftChild(x)和RightChild(x)分别表示内部结点x的左子女和右子女。设x是扩展二叉树的一个结点,LeftChild(x)和RightChild(x)分别表示内部结点x的左子女和右子女。

定义shortest(x)为从x到一个外部结点的最短路径的长度,则有:

0 若x是一个外部结点

shortest(x) =

1 + min{ shortest(LeftChild(x)),

shortest(RightChild(x))} 否则

JYP

slide112

图5.16的每个内部结点x旁的数字是shortest(x)值。图5.16的每个内部结点x旁的数字是shortest(x)值。

定义:左偏树是一棵二叉树,且满足下列性质:若该树不空,那么对于其中的每一个内部结点x,有shortest(LeftChild(x))≥shortest(RightChild(x))。

图5.15(a)的二叉树不是左偏树。图5.15(b)的二叉树是左偏树。

JYP

slide113

引理5.1:设x是具有n个(内部)结点的左偏树的根,则引理5.1:设x是具有n个(内部)结点的左偏树的根,则

(a) n≥2shortest(x) – 1。

(b) 最右边的从根到外部结点路径是最短的从根到外部结点路径,其长度为shortest(x)。

证明:

(a)根据shortest(x)定义,左偏树的前shortest(x)层不存在外部结点

JYP

slide114

shortest(x) levels

因此该左偏树至少有

个内部结点。

JYP

slide115

(b)可由左偏树的定义直接得出。

假设左偏树的结点元素类型为Element<Type>,Element<Type>含有一个key数据成员。

定义:最小(最大)左偏树是一棵左偏树,其中每一个内部结点元素的key值不大于(小于)该结点子女(如果存在的话)的key值。

即,最小(最大)左偏树是一棵左偏树,同时是一棵最小(最大)树。

JYP

slide117

左偏树的类定义:

template <class Type> class MinLeftistTree;

template <class Type>

class LeftistNode {

friend class MinLeftistTree<Type>;

private:

Element<Type> data;

LeftistNode *LeftChild, *RightChild;

int shortest;

};

template <class Type>

class MinLeftistTree: public MinPQ <Type> {

public:

JYP

slide118

MinLeftistTree(LeftistNode<Type>* init =0) : root(init) { };

// 左偏树的三个操作

void Insert (const Element<Type>&);

Element<Type>* Delete(Element<Type>&); // 删除最小元素

void MinCombine(MinLeftistTree<Type>*);

private:

LeftistNode<Type>*MinUnion(LeftistNode<Type>*,

LeftistNode<Type>*);

LeftistNode<Type>* root;

};

JYP

slide119

由于对称性,下面只讨论最小左偏树。采用左偏树可使插入、删除最小元素以及合并操作在对数时间内完成。由于对称性,下面只讨论最小左偏树。采用左偏树可使插入、删除最小元素以及合并操作在对数时间内完成。

插入和删除最小元素操作都可以通过合并操作实现:

(1)将元素x插入一棵最小左偏树时,可先建立一棵包含单个元素x的最小左偏树,再合并这两棵最小左偏树。

(2)从一棵非空最小左偏树中删除最小元素时,可合并这棵最小左偏树的左、右子树,再删除其根结点。

JYP

slide121

a

a

b

2

2

5

+

c

=>

通过结合c和b 得到的左偏树.

(b)

必要时交换a的左、右子树。

JYP

slide122

合并两棵分别以结点a和b为根的最小左偏树:

  • 比较结点a和b的key值,若a的key值不大于b的key值,则a是合并所得树的根。
  • a的左子树暂时不变。递归调用算法,将a的右子树与以b为根的最小左偏树合并,所得最小左偏树成为a的新右子树。
  • 若该新右子树的根结点的shortest值大于a的左子树的根结点的shortest值,则交换a的左、右子树。
  • 最后计算结点a本身的shortest值。

JYP

slide123

a的key值大于b的key值的情况也类似处理,只是这时b是合并所得树的根。

下面以合并图5.17的两棵最小左偏树为例,说明上述过程。为简化描述,称key值是k的元素所在结点为结点k。

JYP

slide124

首先比较这两棵树的根结点的key值2和5。由于2 < 5,新最小左偏树的根结点应是结点2。

JYP

slide126

接着合并以结点50为根的最小左偏树和以结点8为根的最小左偏树。由于8 < 50且结点8无右子树,可使以结点50为根的最小左偏树成为结点8的右子树。于是得到下面的最小左偏树:

JYP

slide127

由于shortest(结点8) = 2 > shortest(结点9) = 1,需要交换结点5的左、右子树,从而得到下面的最小左偏树:

JYP

slide128

又由于shortest(结点5) = 2 > shortest(结点7) = 1,需要交换结点2的左、右子树,最终得到下一页所示的最小左偏树。

经过交换的指针用虚线表示。

JYP

slide130

实现最小左偏树合并操作的函数:

template <class Type>

void MinLeftistTree<Type>::MinCombine

(MinLeftistTree<Type> b) { // 合并最小左偏树b和

// *this,并将b设置为空最小左偏树

if (!root) root = b.root;

else if (b.root) root = MinUnion(root, b.root);

b.root = 0;

}

template <class Type>

LeftistNode<Type>* MinLeftistTree<Type>::MinUnion

(LeftistNode<Type>*a, LeftistNode<Type>*b) {

// 合并以a和b为根的非空最小左偏树,并返回

// 所得最小左偏树的根。

JYP

slide131

if (adata.key > bdata.key) { // 令a指向根结点中key值

LeftistNode<Type>*t = a; // 较小的最小左偏树

a = b;

b = t;

}

// 构建最小二叉树

if (!aRightChild) aRightChild = b;

else aRightChild = MinUnion(aRightChild, b);

// 维护左偏树性质

if (!aLeftChild) { // 左子树为空,交换左、右子树

aLeftChild = aRightChild;

aRightChild = 0;

}

JYP

slide132

else if (aLeftChildshortest < aRightChild shortest) {

LeftistNode<Type>* t = aLeftChild; // 交换左、右子树

aLeftChild = aRightChild;

aRightChild = t;

}

// 设置shortest 数据成员

if (!aRightChild) ashortest = 1;

else ashortest = aRightChildshortest + 1;

return a;

}

JYP

slide134

二项式堆支持与左偏树相同的功能(即插入、删除最小元素以及合并)。二项式堆支持与左偏树相同的功能(即插入、删除最小元素以及合并)。

但左偏树的各个操作需要O(log n)时间。

在二项式堆中,插入与合并操作可用O(1)时间完成,而一次单独的删除最小元素可能需要O(n)时间。

然而,如果将删除最小元素操作的代价分摊到插入操作上,则插入操作的分摊代价仍然是O(1),合并操作的分摊代价与其实际代价相同,而删除最小元素操作的分摊代价变为O(log n)。

二项式堆(5.5)

JYP

slide135

定义:度为k的二项式树(记作Bk)定义为:若k = 0,则该树只有一个结点;若k > 0,则该树的根是度为k的结点,其子树为B0,B1,…,Bk-1。

引理5.2:二项式树Bk具有2k个结点。

证明:对k应用归纳法。当k = 0,显然成立。假设k < m时成立。则当k = m时,该树共有1 + 20 + 21 + ,…,+ 2m-1 = 2m个结点。

最小二项式堆——最小二项式树的集合

最大二项式堆——最大二项式树的集合

由于对称性,下面只考虑最小二项式堆,并将其简称为B堆。

5.5.1 二项式堆定义

JYP

slide137

二项式树的类定义:

template <class Type>

class BinomialHeap; // 向前声明

template <class Type>

class BinomialNode {

friend class BinomialHeap<Type>;

private:

Element<Type> data;

BinomialNode *child, *link;

int degree;

};

JYP

slide138

template <class Type>

class BinomialHeap: public MinPQ <Type> {

public:

BinomialHeap(BinomialNode<Type>*init = 0): min(init) { }; // 构造函数

void Insert (const Element<Type>&); // 插入操作

Element<Type>* Delete(Element<Type>&); // 删除最小

// 元素操作

void MinCombine(BinomialHeap<Type>*); // 合并操作

private:

BinomialNode<Type>* min;

};

JYP

slide139

一个结点的degree表示该结点的子女个数,child指向该结点的一个子女(如果存在的话),link用于构造由兄弟组成的单链环表。一个结点的degree表示该结点的子女个数,child指向该结点的一个子女(如果存在的话),link用于构造由兄弟组成的单链环表。

一个结点的所有子女构成一个单链环表,而该结点的child指向这些子女中的一个。

构成B堆的最小二项式树的根结点也链接成一个单链环表(注意,不要求该单链环表中的二项式树具有不同的度),整个B堆可以通过数据成员min访问,min指向key最小的根结点。

JYP

slide141

其中,用双箭头将同一个单链环表的结点连接起来。其中,用双箭头将同一个单链环表的结点连接起来。

集合{10},{6},{5,4},{20},{15,30},{9},{12,7,16}和{8,3,1}分别表示图中各单链环表的key值。

由于1是三个根结点中的最小key值,min指向key为1的根结点。

空B堆的min值为空指针0。

JYP

slide142

将元素x插入B堆可以如下实现:

先将元素x存入一个新结点,然后将此结点插入由min指向的单链环表。如果min = 0或x.key小于min所指结点中的key值,则令min指向此新结点。

这些插入步骤显然可用O(1)时间完成。

5.5.2 插入操作

JYP

slide143

为了合并两个非空B堆,可将这两个B堆的顶层单链环表合并为一个。为了合并两个非空B堆,可将这两个B堆的顶层单链环表合并为一个。

新B堆的指针是原来两个B堆的min指针之一,且其所指结点中的key值较小。这只需一次比较即可确定。

由于可用O(1)时间将两个单链环表合并为一个,合并操作只需O(1)时间。

5.5.3 合并操作

JYP

slide144

若min = 0,则B堆为空,无法删除。

假设min  0,则min指向含最小元素的结点。

从顶层环表中删除该结点。

新的B堆由剩余的最小二项式树和被删除结点的子树(也是最小二项式树)构成。

5.5.4 删除最小元素

JYP

slide147

在形成最小二项式树根结点的单链环表之前,反复结合度相同的一对最小二项式树。在形成最小二项式树根结点的单链环表之前,反复结合度相同的一对最小二项式树。

结合最小二项式树a和b时,若a的根结点中key值较大,则将a作为b的根的子树;否则将b作为a的根的子树。

两棵最小二项式树结合后,所生成的最小二项式树的度比原来增加1,顶层最小二项式树的总个数减少1。

例如,可以结合以结点8和结点7为根的一对最小二项式树或以结点3和结点12为根的一对最小二项式树。

JYP

slide148

若结合第一对,则以结点8为根的最小二项式树成为结点7的一棵子树。于是得到下面的B堆:若结合第一对,则以结点8为根的最小二项式树成为结点7的一棵子树。于是得到下面的B堆:

JYP

slide149

其中有三棵度为2的最小二项式树。若再结合以结点7和结点3为根的一对最小二项式树,则得到下图所示的B堆:其中有三棵度为2的最小二项式树。若再结合以结点7和结点3为根的一对最小二项式树,则得到下图所示的B堆:

JYP

slide150

这时B堆中的最小二项式树的度都不一样,结合过程结束。这时B堆中的最小二项式树的度都不一样,结合过程结束。

接着,将最小二项式树的根结点链接起来,形成单链环表,并设置B堆指针min,使其指向含最小key值的树根。

JYP

slide151

由此可得删除最小元素操作所需的步骤:

template <class Type>

Element<Type> * BinomialHeap<Type>::DeleteMin

(Element<Type>& x) // 删除B-堆的最小元素

第1步: [处理空B-堆] if (!min) { DeleteError( ); return 0;}

第2步: [从非空B-堆中删除] x = mindata; y = minchild;

将min所指向的结点从其环表中删除; 删除之后,

min可指向新的环表中的任意一个结点; 如果无剩余

结点,则min = 0;

第3步: [结合最小二项式树] 扫描由min和y指向的表,反复

结合具有相同度的最小二项式树对,直到剩余的最

小二项式树的度都不一样为止;

JYP

slide152

第4步: [形成最小二项式树的根环表] 链接剩余最小二项式

树(如果存在的话)的根结点,形成单链环表; 设

置min,使其指向含最小key值的根结点(如果存在

的话); return &x;

第1步用O(1)时间。

第2步可通过将结点min的下一个结点next的内容复制到结点min,再删除结点next实现,这只用O(1)时间。

第3步可用数组tree实现,tree的下标范围是0到最小二项式树的最大可能的度MaxDegree。

JYP

slide153

初始时,数组tree的所有单元都设置为0。设s为第2步生成的表min和表y中最小二项式树的总个数。扫描表min和表y。对于表min和表y中的每一个最小二项式树p,执行下列代码:初始时,数组tree的所有单元都设置为0。设s为第2步生成的表min和表y中最小二项式树的总个数。扫描表min和表y。对于表min和表y中的每一个最小二项式树p,执行下列代码:

for (d = pdegree; tree[d]; d++) {

JoinMinTree(p, tree[d]); // 结合两棵最小二项式树

tree[d] = 0; // 树tree[d] 已被结合进p中

}

tree[d] = p; // 结合后,度比原来增加1

其中,JoinMinTrees使输入参数表示的最小二项式树中根结点具有较大key值的树成为另一棵树的根的子树,所生成的新树通过第一个参数返回。

JYP

slide154

最终,数组tree包含需要在第4步中链接起来的最小二项式树的根结点指针。由于每结合一对最小二项式树,最小二项式树的总数减少1,总的结合次数最多是s – 1。因此,第3步的时间复杂性是O(MaxDegree + s)。

第4步可通过扫描tree[0],…,tree[MaxDegree]并链接找到的最小二项式树来完成,顺便确定具有最小key值的根结点。显然,第4步的时间复杂性是O(MaxDegree)。

JYP

slide155

显然,B堆的插入、合并和删除最小元素操作保持其性质,即B堆中的树都是最小二项式树。显然,B堆的插入、合并和删除最小元素操作保持其性质,即B堆中的树都是最小二项式树。

引理5.3:设a是一个有n个元素的B堆,则a中每一棵树的度≤log2n。因此,MaxDegree≤log2n,删除最小元素操作的实际代价是O(log n + s)。

证明:由于a中的每一棵树都是最多有n个结点的二项式树,由引理5.2,这些树的度不大于log2n。

5.5.5 分析

JYP

slide156

定理5.1:如果将由n个插入、合并和删除最小元素操作组成的操作序列应用于一组初始为空的B堆,则通过代价分摊可使每个插入和合并操作的分摊代价为O(1),每个删除最小元素操作的分摊代价为O(log n)。

证明:对于每一个B堆

如下定义#insert:当创建初始空B堆或在B堆上完成删除最小元素操作时,将其#insert设置为0。每次在B堆上完成插入操作时,其#insert值加1。两个B堆合并后产生的新B堆的#insert值为被合并的两个B堆的#insert值之和。因此,一个B堆的#insert实际

JYP

slide157

上是自该B堆或其组成B堆(在涉及合并操作的情况下)最近一次删除最小元素操作以来完成的插入操作次数。上是自该B堆或其组成B堆(在涉及合并操作的情况下)最近一次删除最小元素操作以来完成的插入操作次数。

如下定义LastSize:当创建初始空B堆时,将其LastSize设置为0。当在一个B堆上完成删除最小元素操作时,将其LastSize设置为删除完成后B堆中包含的最小二项式树的数目。两个B堆合并后产生的新B堆的LastSize值为被合并的两个B堆的LastSize值之和。

不难看出,一个B堆的最小二项式树的数目= #insert + LastSize。

JYP

slide158

考虑操作序列中的任何一个删除最小元素操作。假设该操作针对B堆a。由于在n个操作的序列中最多有n个插入操作,所有B堆的全部元素最多为n个。考虑操作序列中的任何一个删除最小元素操作。假设该操作针对B堆a。由于在n个操作的序列中最多有n个插入操作,所有B堆的全部元素最多为n个。

设u = a.mindegree,则u≤log2n。

根据引理5.3,该删除最小元素操作的实际代价是O(log n + s)。s这一项代表了扫描表min和表y并完成最多s – 1次最小二项式树结合所需的时间。由于结点min的子树个数为u,而min所指最小二项式树被删除,这时s = #insert + LastSize + u – 1。

JYP

slide160

对于任何插入操作,我们在其实际代价基础上增加了最多1个代价单位,因此其分摊代价为O(1)。对于任何插入操作,我们在其实际代价基础上增加了最多1个代价单位,因此其分摊代价为O(1)。

合并操作未分摊任何额外代价,因此其实际代价和分摊代价相同,都是O(1)。

根据上述定理和代价分摊的定义可知,任何由i个插入、c个合并和dm个删除最小元素操作组成的序列的实际代价是O(i + c + dm log i)。

JYP

slide161

5.6.1 斐波纳契堆定义

斐波纳契堆是一种数据结构,该结构不仅支持二项式堆的插入、删除最小(最大)元素与合并这三个操作,而且支持下列新操作:

(1) 减少key:将指定结点的key减去一个正值。

(2) 删除:删除指定结点的元素。

这两个新操作的第一个可用O(1)的分摊代价完成,第二个操作可用O(log n) 的分摊代价完成。其余操作的渐进时间与在二项式堆中的相同。

斐波纳契堆(5.6)

JYP

slide162

斐波纳契堆也分为最小和最大两种。最小斐波纳契堆是最小树的集合,最大斐波纳契堆是最大树的集合。由于对称性,下面只考虑最小斐波纳契堆,并将其简称为F堆。斐波纳契堆也分为最小和最大两种。最小斐波纳契堆是最小树的集合,最大斐波纳契堆是最大树的集合。由于对称性,下面只考虑最小斐波纳契堆,并将其简称为F堆。

B堆是F堆的特殊情况,上一节的B堆实例也是F堆实例。

JYP

slide163

F堆的表示:

  • 在B堆的每个树结点中增加两个数据成员:parent和ChildCut。parent用于指向该结点的双亲(若存在的话)。ChildCut用于控制最小树的修剪。
  • 为便于删除任何指定结点,用双链环表代替B堆中的单链环表,即将原B堆的树结点中的link换为LeftLink和RightLink。
  • 插入、删除最小元素与合并这三个基本操作的实现与在B堆中的一样。
  • 下面重点考虑两个新操作:(1)从F堆中删除任意结点b;(2)将任意结点b的key减去一个正值。

JYP

slide164

从F堆中删除任意结点b可由如下步骤实现:

(1) 若min = b,则执行删除最小元素操作;否则执行下列第(2)、(3)和(4)步。

(2) 将b从其所在双链表中删除。

(3) 将b的子女的双链表与由min指向的根结点的双链表合并,并将b的子女结点的parent字段设置为0,形成一个新的根结点双链表。与删除最小元素操作不同的是,在此不结合度相同的树。

(4) 释放结点b。

5.6.2 删除操作

JYP

slide166

则得到下面的F堆:

当min  b时,任何删除操作的实际代价为O(b的度)。当min = b时,删除操作的时间就是删除最小元素操作的时间。

JYP

slide167

减少结点b中的key可由如下步骤实现:

(1) 减少b中的key值。

(2) 若b  min且b中的key值小于其双亲中的key值,则将b从其所在双链表中删除并将其插入最小树根结点的双链表。

(3) 若b中的key值小于min中的key值,则将min指向b。

5.6.3 关键字减少操作

JYP

slide169

得到的F堆如下所示:

减少key操作的代价是O(1)。

JYP

slide170

由于删除任意结点和减少key操作,F堆中的最小树不一定是二项式树。因此,定理5.1的分析不再有效。由于删除任意结点和减少key操作,F堆中的最小树不一定是二项式树。因此,定理5.1的分析不再有效。

为了保证每棵度为k的最小树有ck(c > 1)个结点,每个删除任意结点和减少key操作之后还必须进行瀑布修剪。

为此,在每个结点增加ChildCut数据成员,其值仅对不是最小树根的结点有意义。对于不是最小树根的结点x,xChildCut为TRUE当且仅当在最近一次x成为其双亲的子女后,x的一个子女被删除。

5.6.4 瀑布修剪

JYP

slide171

而且,一旦删除任意结点或减少key操作将不是最小树根的结点q从其双链表中删除,将引发瀑布修剪过程。而且,一旦删除任意结点或减少key操作将不是最小树根的结点q从其双链表中删除,将引发瀑布修剪过程。

设q的双亲为p,瀑布修剪检查从p到q的ChildCut值为FALSE的最近祖先的路径所含结点。如果不存在这样的祖先,则该路径为从p到包含p的最小树的根的路径。将该路径上所有ChildCut值为TRUE的不是最小树根的结点从它们各自的双链表中删除,并加到F堆的最小树的根结点的双链表中。若该路径存在一个ChildCut值为FALSE的结点,则将其ChildCut设置为TRUE。

JYP

slide173

引理5.4:设a是一个具有n个元素的F堆,且a通过在一组初始为空的F堆上执行一系列插入、合并、删除最小元素、删除任意结点和减少key操作形成。再设b是a的任意最小树的任意结点,m是以引理5.4:设a是一个具有n个元素的F堆,且a通过在一组初始为空的F堆上执行一系列插入、合并、删除最小元素、删除任意结点和减少key操作形成。再设b是a的任意最小树的任意结点,m是以

结点b为根的子树的元素个数,= ,则

(a) b的度≤logm。

(b) MaxDegree≤logn,删除最小元素操作的实际代价是O(log n + s)。

5.6.5 分析

JYP

slide174

证明:只需证明(a),(b)可直接由(a)推出。证明:只需证明(a),(b)可直接由(a)推出。

对b的度应用归纳法。设Ni是以度为i的结点b为根的子树的最少元素个数。显然,N0 = 1,N1 = 2。因此,(a)对于i = 0和1成立。

对于i > 1,设c1, …, ci为b的i个子女。假设cj在cj+1(j<i)之前成为b的一个子女。因此,当ck(k≤i)成为b的一个子女时,b的度至少为k–1。

唯一能使一个结点成为另一个的子女的F堆操作是删除最小元素。因此,在结合时,ck的度一定等于b的度。

JYP

slide175

结合之后,作为删除和减少key操作的后果,ck的度可能减少。然而,结合之后ck的度最多可减少1,因为删除ck的第二个子女将引起位于ck的瀑布修剪。这将使ck成为F堆的最小树的根,从而不可能成为b的子女。结合之后,作为删除和减少key操作的后果,ck的度可能减少。然而,结合之后ck的度最多可减少1,因为删除ck的第二个子女将引起位于ck的瀑布修剪。这将使ck成为F堆的最小树的根,从而不可能成为b的子女。

因此,ck的度dk至少是max{0,k – 2}。ck的元素个数至少是Ndk。

以b为根的子树包括1个根结点和i个子女(c1, …, ci),c1的度至少为0,c2, …, ci的度分别至少为0, …, i – 2,于是有

JYP

slide176

而斐波纳契数Fn满足下列等式

由此可得,Ni = Fi+2,i≥0。根据斐波纳契数理论,Fi+2≥i,从而Ni≥i。由于Ni≤m,所以 i≤logm。

JYP

slide177

定理5.2:如果将由n个插入、合并、删除最小元素、删除任意结点和减少key操作组成的操作序列应用于一组初始为空的F堆,则通过代价分摊可使每个插入、合并和减少key操作的分摊代价为O(1),每个删除最小元素和删除任意结点操作的分摊代价为O(log n)。整个操作序列的总时间复杂性是序列中各操作的分摊复杂性之和。

证明:证明与定理5.1的类似。

#insert的定义不变。

对LastSize需作如下扩充:在每次执行删除任意结点和减少key操作后,应根据F堆中最小树棵数的净增量改变LastSize的值。

JYP

slide178

经过以上扩充,不难看出,在执行删除最小元素操作时s = #insert + LastSize + u – 1。

#insert个代价单位可分摊到形成#insert计数的插入操作,每个插入操作分摊1个代价单位。

LastSize个代价单位可分摊到形成LastSize计数的删除最小元素、删除任意结点和减少key操作。这使得每个删除最小元素和删除任意结点操作分摊最多logn的代价,减少key操作分摊1个代价单位(瀑布修剪增加的最小树代价将在后面另外分摊)。因此,删除最小元素操作的分摊代价是O(log n)。

JYP

slide179

只有删除任意结点和减少key操作可能将结点的ChildCut设置为TRUE,所以瀑布修剪的总次数受到这些操作的总次数的限制。这些修剪的代价可分摊到删除任意结点和减少key操作,每个操作分摊1个代价单位(包括瀑布修剪增加的最小树代价)。只有删除任意结点和减少key操作可能将结点的ChildCut设置为TRUE,所以瀑布修剪的总次数受到这些操作的总次数的限制。这些修剪的代价可分摊到删除任意结点和减少key操作,每个操作分摊1个代价单位(包括瀑布修剪增加的最小树代价)。

删除除了最小元素以外的其它元素的实际代价是O(logn) ,且该操作从瀑布修剪分摊最多1个代价单位,从删除最小元素操作分摊最多logn个代价单位,所以其分摊代价是O(log n)。

JYP

slide180

减少key操作的实际代价是O(1) ,且该操作从瀑布修剪分摊最多1个代价单位,从删除最小元素操作分摊最多1个代价单位,所以其分摊代价是O(1)。

插入操作的实际代价是O(1),且该操作从删除最小元素操作分摊最多1个代价单位,所以其分摊代价是O(1)。

合并操作未分摊任何额外代价,因此其实际代价和分摊代价相同,都是O(1)。

JYP

slide181

双连分量在连通性方面比一般的连通分量具有更高的要求,生成双连分量的操作也更复杂一些。双连分量在连通性方面比一般的连通分量具有更高的要求,生成双连分量的操作也更复杂一些。

假设无向图G是连通的,下面给出双连分量的正式定义。

定义:G的顶点v是一个关节点当且仅当从G中删除v及其关联的边后,剩下的图至少有两个连通分量。

双连分量(6.4.2)

JYP

slide182

例如,在下面的连通图G6中,顶点1,4和7是关节点。例如,在下面的连通图G6中,顶点1,4和7是关节点。

JYP

slide183

定义:双连通图是没有关节点的连通图。

例如,图G5是双连通的:

JYP

slide184

但图G6不是双连通的。

在表示通信网络的图中,边表示通信链路,顶点表示通信站点,关节点显然是薄弱环节。

定义:一个连通图G的双连分量是G的最大双连通子图。

JYP

slide186

不难发现,同一个图的两个双连分量最多有一个共同顶点。由此可推出,一条边不可能出现在两个或两个以上的双连分量中。因此,图G的双连分量划分E(G)。不难发现,同一个图的两个双连分量最多有一个共同顶点。由此可推出,一条边不可能出现在两个或两个以上的双连分量中。因此,图G的双连分量划分E(G)。

图G的双连分量可利用G的任何深度优先生成树求得。

JYP

slide188

其中,非树边用虚线表示。顶点旁的数字称为该顶点的深度优先数,简称为dfn。一个顶点的dfn表示该顶点在深度优先搜索中被访问的顺序。其中,非树边用虚线表示。顶点旁的数字称为该顶点的深度优先数,简称为dfn。一个顶点的dfn表示该顶点在深度优先搜索中被访问的顺序。

例如,在前面的G6生成树中,dfn(0) = 1,dfn(1) = 2,以及dfn(7) = 5。

注意,在深度优先生成树中,如果顶点u是v的祖先,则dfn(u) < dfn(v)。

JYP

slide189

相对于生成树T,当且仅当u是v的祖先或v是u的祖先时,非树边 (u, v) 才是一条回边。

例如,(4, 1) 和 (6, 7) 是回边。

不是回边的非树边称为横边。根据深度优先搜索的规律,相对于任意一个图的任何深度优先生成树,该图不可能有横边。

JYP

slide190

因此,深度优先生成树的根是关节点的充分必要条件是它至少有两个子女。因此,深度优先生成树的根是关节点的充分必要条件是它至少有两个子女。

任何其它顶点u是关节点的充分必要条件是u至少有一个子女w,使得经过只由w,w的后代以及一条回边构成的路径不可能到达u的祖先,因为删除顶点u及其关联的边将使w及其后代与u的祖先断开联系。

JYP

slide191

假设顶点w的祖先包括w本身。

为了表示一个顶点经过其后代以及一条回边所能到达的最高祖先,对于图G的每个顶点w,定义low(w)为从w经过其后代以及一条回边所能到达的最高祖先的dfn。

显然,low(w)是从w经过其后代以及一条回边所能到达的顶点的dfn中最低的,并可由以下公式计算:

low(w) = min {dfn(w), min {low(x)| x是w的一个子女},

min {dfn(x)| (w, x) 是一条回边}}

JYP

slide192

于是,u是关节点的充分必要条件是:u或者是至少有两个子女的深度优先生成树的根,或者u不是根结点但有一个子女w,使得low(w)≥dfn(u)。于是,u是关节点的充分必要条件是:u或者是至少有两个子女的深度优先生成树的根,或者u不是根结点但有一个子女w,使得low(w)≥dfn(u)。

下面是G6的以顶点0为根的深度优先生成树中各顶点的dfn和low值:

JYP

slide193

修改DFS可得到计算一个连通图各顶点的dfn和low值的函数DfnLow:修改DFS可得到计算一个连通图各顶点的dfn和low值的函数DfnLow:

void Graph::DfnLow (const int x ) { // 在顶点x开始DFS

num = 1; // num是Graph 的类型为int的数据成员

dfn = new int[n]; low = new int[n]; // dfn和low都是Graph 的

// 类型为int *的数据成员

for ( int i = 0; i < n; i++ ) { dfn[i] = low[i] = 0; }

DfnLow ( x, -1 ); // x是根,其双亲是伪顶点-1

delete [ ] dfn; delete [ ] low;

}

JYP

slide194

void Graph::DfnLow ( int u, int v ) { // 从u开始深度优先搜索并

// 计算dfn和low。v是u的双亲

dfn[u] = low[u] = num++;

for (每一个与u邻接的顶点w) // 具体代码与图的表示有关

if (dfn[w] == 0) { // w 是未被访问顶点

DfnLow (w, u);

low[u] = min2 (low[u], low[w]); // min2(x,y)返回x和y的

// 较小者

}

else if (w != v) low[u] = min2 ( low[u], dfn[w] ); // 回边。注

// 意(v, u)不是回边

}

JYP

slide195

注意,在深度优先生成树中,若v是u的双亲,则 (u, v) 一定不是回边。

函数DfnLow(u, v)的参数v就是为区分这种情况而设的。

深度优先搜索的第一个顶点x无双亲,因此对其的调用形式为DfnLow(x, –1)。

函数DfnLow(u, v)用到的另一个函数min2返回其两个参数的较小者。

JYP

slide196

在DfnLow的基础上进一步处理,可以将连通图的边划分为双连分量。在DfnLow的基础上进一步处理,可以将连通图的边划分为双连分量。

注意,当DfnLow(w, u)返回后,low[w]已计算好。如果low[w]≥dfn[u],则可确定一个新的双连分量。

通过将搜索时首次遇到的边存入栈中,可以求得一个双连分量的全部边。

JYP

slide197

设深度优先搜索最近访问的顶点是u,且顶点w与u邻接但不是u的直接双亲,则在以下两种情况中,(u, w)是首次遇到的边:

(1)w是未被访问的顶点

(2)w是已被访问的顶点而且是u的祖先

由于未被访问的顶点的dfn值初始化为0,祖先的dfn值小于后代的,所以两种情况都可归结为

dfn[w] < dfn[u]。

JYP

slide198

注意:如果dfn[w] > dfn[u],则边(w,u)一定已经作为回边(当处于w点时)加到栈中,如下所示:

2

u

3

x

w

4

JYP

slide199

函数Biconnected实现了上述过程:

void Graph::Biconnected ( ) {

num = 1;

dfn = new int[n]; low = new int[n];

for ( int i = 0; i < n; i++ ) { dfn[i] = low[i] = 0; }

Biconnected (0, -1); // 从顶点0开始

delete [ ] dfn;

delete [ ] low;

}

void Graph::Biconnected (int u, int v) { // 计算dfn和low,并输

// 出各双连分量的边。v是u 的双亲,栈S是Graph的数

// 据成员,并被初始化为空。假设n>1。

dfn[u] = low[u] = num++;

JYP

slide200

for (每一个与u邻接的顶点w) { // 具体代码与图的表示有关

if (v != w && dfn[w] < dfn[u]) 将边(u,w) 加入栈S中;

if (dfn[w] == 0 ) { // w是未被访问的顶点

Biconnected (w, u); low[u] = min2 (low[u], low[w]);

if (low[w] >= dfn[u]) {

cout <<“新双连分量:”<< endl;

do {

从栈S删除一条边; 设此边为 (x, y);

cout << x << "," << y <<endl;

} while ( (x, y)与(u, w)不相同);

}

}

else if (w != v) low[u] = min2 (low[u], dfn[w]); // 回边

} // for结束

}

JYP

slide201

Biconnected的时间复杂性是O(n + e)。

注意,函数Biconnected假设输入的连通图至少有两个顶点。

只有一个顶点的连通图无边,但按定义,它们也是双连通的。对此可作特殊处理。

JYP

slide202

设TV是最小生成树的已选顶点集合,T是最小生成树的已选边集合。普瑞姆算法首先任选图G中一个顶点u,将u加入TV中。设TV是最小生成树的已选顶点集合,T是最小生成树的已选边集合。普瑞姆算法首先任选图G中一个顶点u,将u加入TV中。

然后将一条代价最小的边 (u, v) 加入T中,使得T  {(u, v)}仍然是一棵树。

重复上述步骤,直到T包含n – 1条边为止。

注意,(u, v) 关联的两个顶点必有一个在TV中,另一个不在TV中。

求解最小生成树的普瑞姆算法(6.5.2)

JYP

slide203

普瑞姆算法的框架:

TV = {0}; // 从顶点0开始构造,假设G至少有一个顶点

for ((T = ; T包含的边少于n -1条;将(u, v)加入T) {

令 (u, v) 为满足 u  TV 且 v  TV 的代价最小的边;

if (不存在这样的边) break;

将v加入TV;

}

if (T包含的边少于n-1条)

cout <<“不存在最小生成树” << endl ;

JYP

slide208

设 是不属于TV的顶点集合。

对于任何v ,定义near[v]为TV中使 cost(near[v], v)最小的顶点(若(v, w)E,则设cost(v, w) = ),则

cost(near[v],v) = 。

下一个加入TV的顶点v应满足:v ,且cost(near[v],v) = 。下一条加入T的边自然是 (near[v],v)。

JYP

slide209

设w是 中与v邻接的顶点。顶点v加入TV之后,如果cost(v, w) < cost(near[w],w) ,则将near[w]改为v。

由此可以有效地实现普瑞姆算法。

如果用邻接矩阵cost[i][j]表示图G,则算法的时间复杂性是O(n2),因为算法总共选n – 1个顶点,每选一个顶点v及处理v对near[w](w 且w与v邻接)的影响最多只需O(n)时间。

JYP

slide210

如果用邻接表表示图G并利用斐波纳契堆,则可使算法的性能更好。对于v ,定义dist[v] = cost(near[v],v),可理解为顶点v到已构造的部分最小生成树的最近距离。

每次往TV中加入一个顶点,算法都需要确定顶点v,使得v ,且dist[v] = 。这对应于 上的删除最小元素操作。

由于v加入TV, 中与v邻接的顶点的dist值可能减少。这对应于 上的关键字减少操作。

JYP

slide211

关键字减少操作的总次数最多是图中边的条数。删除最小元素操作的总次数是n – 1。

初始时, 含n – 1个顶点。如果以dist为关键字,将 组织为斐波纳契堆,则需要n – 1次插入操作初始化斐波纳契堆。

接着需要执行n – 1次删除最小元素操作和最多e次关键字减少操作。所有这些操作的代价是各操作的分摊代价之和,即 O(n log n + e)。

因此,算法的时间复杂性变为O(n log n + e)。当e远小于n2时,这显然是一种改进。

JYP

slide212

先回忆一下经典的最短路径算法:

1 void Graph::ShortestPath(const int n, const int v) {

2 for (int i = 0; i < n; i++) {

3 s[i] = FALSE; dist[i] = length[v][i];

4 if ( i != v && dist[i] < LARGEINT) path[i] = v;

else path[i] = – 1;

5 }

6 s[v] = TRUE; dist[v] = 0;

7 for (i = 0; i < n-2; i++) { // 确定从v开始的n – 1条路径

8 int u = choose(n); // 选择u,使得对于所有s[x] =

// FALSE,dist[u] = 最小的dist[x]

9 s[u] = TRUE;

斐波纳契堆在最短路径算法中的应用

JYP

slide213

10 for ( int w = 0; w < n; w++)

11 if (!s[w])

12 if (dist[u] + length[u][w] < dist[w]) {

dist[w] = dist[u] + length[u][w];

path[w] = u;

}

13 } // for (i = 0;…) 结束

14}

JYP

slide214

分析:第2行的for循环需要O(n)时间。第7行的for循环执行n – 2次,每次需要O(n)时间用于第8行的选择和第10到12行的更新dist值。因此,该循环的总时间是O(n2)。

整个算法的时间是O(n2)。

即使采用邻接表,第10到12行的总时间可减少到O(e)(因为只有邻接自u的顶点的dist可能变化),第8行的总时间仍然是O(n2)。

JYP

slide215

应用斐波纳契堆和邻接表,可使算法的时间复杂性减少为O(n log n + e)。

每次迭代,都需要确定顶点u,使得u ,且dist[u] = 。这对应于 上的删除最小元素操作。

由于u加入S, 中与u邻接的顶点的dist值可能减少。这对应于 上的关键字减少操作。

JYP

slide216

初始时, 含n – 1个顶点。如果以dist为关键字,将 组织为斐波纳契堆,则需要n – 1次插入操作。

接着需要执行n – 2次删除最小元素操作和最多e次关键字减少操作。

所有这些操作的代价是各操作的分摊代价之和,即 O(n log n + e)。

因此,算法的时间复杂性是O(n log n + e)。

当e远小于n2时,这种实现显然更好。

考察题:P223 — 23(每个同学单独完成,并提交报告,作为本课程主要成绩因素)

JYP

slide217

对于基于链表的排序结果,有时需要按次序就地重新排列,使它们在物理上也是顺序的。对于基于链表的排序结果,有时需要按次序就地重新排列,使它们在物理上也是顺序的。

设记录表R0, …, Rn-1经排序后的结果是一个按关键字非递减次序链接的链表,且first是链表的首记录指针。

将记录R0和Rfirst交换。如果first  0,则表中应有一个记录Rx,其link字段值为0。如果能够修改Rx的link字段,使其指向原位于R0的记录的新位置first,则剩余记录R1, …, Rn-1也是按关键字非递减次序链接的。

基于链表和映射表排序结果的顺序化(7.8)

JYP

slide218

但在单链表中,我们无法快速确定结点R0的前驱Rx。于是可将R0的link字段设置为first,表示原位于R0的记录已移到Rfirst。但在单链表中,我们无法快速确定结点R0的前驱Rx。于是可将R0的link字段设置为first,表示原位于R0的记录已移到Rfirst。

这样,R0还作为R1, …, Rn-1链表中的虚拟结点。借助此虚拟结点,我们可找到剩余记录链表的首结点。

重复上述过程n–1次即可完成重新排列。

一般地,设记录表R0, …, Ri-1已在物理上按序排列,Rfirst是剩余记录Ri, …, Rn-1链表的首记录,记录Ri和Rfirst交换后,将新Ri记录的link字段设置为first,表示原位于Ri的记录已移到Rfirst。

JYP

slide219

同时,注意到作为剩余记录Ri, …, Rn-1链表的首记录下标的first总是大于或等于i,我们可以经过虚拟结点,找到剩余记录链表的首记录下标。

函数list实现了上述方法:

template <class KeyType>

void list(Element<KeyType> *list, const int n, int first) {

// 重新排序由first指向的链表中的记录,使list[0],…,list[n-1]

// 中的关键字按非递减次序排列

for (int i = 0; i < n – 1; i++) {

// 找到应放到位置i的记录。由于位置0, 1, …, i-1的记录已

// 就位,该记录下标一定≥i

while (first < i) first = list[first].link; // 经过虚拟结点

JYP

slide220

int q = list[first].link; // list[q]是按非递减次序的下一个

// 记录,可能是虚拟记录

if (first != i) { // 交换list[i] 和list[first],并将list[i].link

// 设置为原list[i]的新位置

Element<KeyType> t = list[i]; list[i] = list[first];

list[first] = t;

list[i].link = first;

}

first = q;

}

}

JYP

slide221

例7.9对 (26, 5, 77, 1, 61, 11, 59, 15, 48, 19) 进行链表排序后,所得链表如下所示:

JYP

slide222

list的for循环每次迭代后记录表的状态如下,变化用粗体字表示,虚拟结点的link字段用带下划线的字体表示:

JYP

slide227

对list的分析:设有n个记录,for循环迭代n–1次。每次最多交换2个记录,需要3次记录移动。如果每个记录的长度为m,则每次交换的代价是3m。所以,最坏情况下记录移动的总代价是O(mn)。对list的分析:设有n个记录,for循环迭代n–1次。每次最多交换2个记录,需要3次记录移动。如果每个记录的长度为m,则每次交换的代价是3m。所以,最坏情况下记录移动的总代价是O(mn)。

在while循环中,任何结点最多被检查一次,所以while循环的总时间是O(n)。

显然,list所需的辅助空间是O(m)。

JYP

slide228

链表排序不适用于希尔排序、快速排序和堆排序,因为记录表的顺序表示是这些方法的基础。链表排序不适用于希尔排序、快速排序和堆排序,因为记录表的顺序表示是这些方法的基础。

对于这些方法可以采用映射表t,表的每一个单元对应一个记录。映射表单元起着对记录间接寻址的作用。

排序开始时,t[i] = i,0≤i≤n–1。如果要求交换Ri和Rj,则只需交换表单元t[i]和t[j]。排序结束时,关键字最小的记录是Rt[0],最大的记录是Rt[n-1],所要求的记录排列是Rt[0], Rt[1], …, Rt[n-1],如下一页所示。

JYP

slide230

有时为了避免间接寻址,还需要根据映射表t确定的置换在物理上重新排列记录。有时为了避免间接寻址,还需要根据映射表t确定的置换在物理上重新排列记录。

整个置换由不相交的环路组成。含记录i的环路由i, t[i], t2[i], …, tk[i]构成,且tk[i] = i。

例如,上一页的置换由两个环路组成,第一个包含记录R0和R4,第二个包含记录R1,R3和R2。

函数table首先沿着包含R0的环路将记录移到其正确位置。接着,如果包含R1的环路未被移动过,则沿着该环路将记录移到其正确位置。由此继续移动包含R2, R3, …, Rn-2的环路,最终得到物理上就序的记录表。

JYP

slide231

template <class KeyType>

void table(Element<KeyType> *list, const int n, int *t) {

// 重新排列list[0], …, list[n-1],使其对应序列list[t[0]], …,

// list[t[n-1]], n≥1

for (int i = 0; i < n – 1; i++)

if (t[i] != i) { // 存在一个开始于i的非平凡环路

Element<KeyType> p = list[i]; int j = i;

do {

int k = t[j]; list[j] = list[k]; t[j] = j;

j = k;

} while ( t[j] != i );

list[j] = p; // p中的记录应该移到位置j

t[j] = j;

}

}

JYP

slide233

对table的分析:设每个记录占用m个存储单元,则所需辅助空间为O(m)个存储单元。对table的分析:设每个记录占用m个存储单元,则所需辅助空间为O(m)个存储单元。

for循环执行了n–1次。如果对于某些i的取值,t[i]  i,则存在一个包含k > 1个不同记录Ri, Rt[i], …, Rtk-1[i]的环路。重新排列这些记录需要k+1次移动。

设kj是在for循环中i = j时以Rj开头的非平凡环路的记录个数。对于平凡环路,则令kj = 0。记录移动的总次数是

JYP

slide234

当 = n且存在n/2个非平凡环路时,记录移

动的总次数达到最大值 — 3n/2。

移动一个记录的代价是O(m),总的计算时间是O(m n)。

JYP

slide235

7.9.1 概述

需要排序的记录表大到计算机内存不能容纳时,内排序已不足以解决问题。

假设需要排序的记录表存放在磁盘上。由于计算机访问内存的速度比访问磁盘的速度快得多,影响外排序性能的主要是访问磁盘的次数。

磁盘的读、写以IO块为单位。一个IO块通常可包含多个记录。

7.9 外排序

JYP

slide236

影响磁盘读写时间的有以下三个因素:

  • 寻找时间:将读写头定位于正确的柱面所用时间。
  • 等待时间:本磁道中所需块旋转到读写头下所用时间。
  • 传输时间:将块中数据读入内存或写到磁盘所用时间。
  • 其中,就数据传输而言,寻找和等待都是辅助性的,但其时间却较长。为了提高传输效率,IO块的容量一般都较大,通常可包含数千字节。

JYP

slide237

外排序的最基本方法是归并,包括两个阶段:

(1)根据内存容量将输入记录表分为若干段,并利用某种内排序方法逐个对这些段排序。这些已排序的段又称为归并段(runs)。

(2)归并第一阶段生成的归并段,直到最终只剩一个归并段。

由于归并算法只要求同一时刻两个归并段的前端记录在内存,因此经过归并,可以生成比内存大的归并段。

JYP

slide238

例7.11设记录表有4500个记录,可用于排序的计算机内存容量是750个记录,IO块长度是250个记录。按上述方法,排序步骤如下:例7.11设记录表有4500个记录,可用于排序的计算机内存容量是750个记录,IO块长度是250个记录。按上述方法,排序步骤如下:

JYP

slide240

分析用符号:

tseek = 最长寻找时间

tlatency = 最长等待时间

trw = 读写一个IO块(250个记录)所需时间

tIO = tseek + tlatency + trw

tIS = 内排序750个记录所需时间

ntm = 将n个记录从输入缓冲区归并到输出缓冲区所

需时间

JYP

slide242

由于tIO >> tIS,tIO >> tm,影响计算时间的主要因素是输入输出操作的次数,而后者又主要依赖于对数据扫描的遍数。

例7.11中,生成初始归并段需要1遍数据扫描,

归并需要 遍数据扫描。

一遍扫描需要输入输出218个IO块,总的输入输

出时间是( + 1) 218tIO = 132tIO。

归并时间是 4500tm = 12000tm。

JYP

slide243

显然,k路归并(k >> 2)可以减少数据扫描遍数。例如,如果在上例中采用6-路归并,则只需对数据扫描一遍即可完成排序。

此外,初始归并段应尽可能长,从而减少初始归并段个数。

在内存容量给定的情况下,可以利用动态流动的思想,生成平均长度几乎是通常方法所得的两倍的归并段。

但这些归并段长短不一,对它们归并的次序也会影响计算时间。

下面将分别讨论这些问题。

JYP

slide244

按2-路归并,给定m个归并段,相应的归并树有log2m+1层,需要对数据扫描log2m遍。按2-路归并,给定m个归并段,相应的归并树有log2m+1层,需要对数据扫描log2m遍。

采用k路归并可减少数据扫描遍数。如下图对16个归并段进行4-路归并只需扫描数据2遍:

7.9.2 k路归并

JYP

slide245

一般地,采用k路归并需要对数据扫描logkm遍。因此,当k >> 2时,采用k路归并可有效减少输入输出时间。

但k路归并要求从k个归并段的前端记录中选择关键字最小的输出。

可以采用具有k个叶结点的败者树来选择关键字最小的记录。从败者树中每选一个最小记录并重新调整需要O(log2k)时间,所以对n个记录归并一遍需要的时间是O(n log2k)。

归并logkm遍的CPU处理时间是O(n log2k logkm) = O(n log2m),即与k无关。

JYP

slide246

还应该看到,当k超过一定范围时,输入输出时间并不随着k的增大而减少。因为:还应该看到,当k超过一定范围时,输入输出时间并不随着k的增大而减少。因为:

  • k路归并所需的缓冲区个数随着k的增大而增加;
  • 在内存容量给定情况下,缓冲区容量随着k的增大而减小;
  • 这又导致IO块的有效容量减小,从而使一遍数据扫描需要更多的IO块操作。
  • 因此,当k超过一定值时,输入输出时间反而会随着k的增大而增加。k值的最佳选择与磁盘参数和可用于缓冲区的内存容量有关。

JYP

slide247

用传统的内排序方法生成初始归并段,需要将内存容纳的所有记录都排序好后再全部输出到磁盘。从在排序过程中没有内外存数据交换的意义上看,这属于静态方法,由此生成的归并段最多与内存容纳的记录数同样大。用传统的内排序方法生成初始归并段,需要将内存容纳的所有记录都排序好后再全部输出到磁盘。从在排序过程中没有内外存数据交换的意义上看,这属于静态方法,由此生成的归并段最多与内存容纳的记录数同样大。

如果采用动态流动的方法,即在生成归并段的过程中不断将记录写到磁盘,同时从磁盘读入新的记录到内存,则可能生成比内存容量大的归并段。

7.9.3 生成初始归并段

JYP

slide248

设内存可容纳k个记录,用r[0], r[1], …, r[k–1]表示,记录的输入和输出通过IO缓冲实现。

输入k个记录后,这些记录都属于当前归并段。

从属于当前归并段的内存记录中选关键字最小的记录r[q](0≤q < k)输出到当前归并段。

从输入表读入下一个记录到r[q],如果此记录的关键字不小于当前归并段的最后一个记录的关键字,则该记录也属于当前归并段,否则属于下一个将生成的归并段。

JYP

slide249

将内存记录所属的归并段号作为第一子关键字,记录原来的关键字作为第二子关键字,下一个要输出的记录是k个记录中关键字最小的。将内存记录所属的归并段号作为第一子关键字,记录原来的关键字作为第二子关键字,下一个要输出的记录是k个记录中关键字最小的。

  • 败者树是组织这些记录的有效结构:
  • 每个非叶结点i有一个字段l[i](1≤i<k),表示在结点i比赛的败者。
  • rn[i]表示r[i]所属的归并段号(0≤i<k)。
  • l[0]和q都存放整个比赛的胜者记录下标。
  • rc存放当前归并段号。
  • rq存放r[q]所属的归并段号。

JYP

slide250

rmax存放将生成的实际归并段总数。

  • LastKey存放当前最后一个输出记录的关键字值。
  • 当输入记录表已读空时,我们可以构造归并段号为rmax + 1的虚拟记录,以将败者树中的实际记录“顶”出。

JYP

slide251

函数runs实现了上述采用败者树动态流动生成初始归并段的方法:函数runs实现了上述采用败者树动态流动生成初始归并段的方法:

1 template <class KeyType>

2 void runs(const int k) {

3 Element<KeyType> *r = new Element[k];

4 int *rn = new int[k]; int *l = new int[k];

5 for ( int i = 0; i < k; i++ ) { // 输入记录

6 ReadRecord( r[i] ); rn[i] = 1;

7 }

8 InitializeLoserTree(r, l, rn, k); // 初始化败者树,可用类

// 似第4.6.2小节的方法

9 int q = l[0]; // 整个比赛的胜者

10 int rq = 1; int rc = 1; int rmax = 1; KeyType LastKey;

JYP

slide252

11 while (1) { // 输出归并段

  • 12 if ( rq != rc ) { // 当前段结束
  • 13 输出归并段结束标记;
  • 14 if ( rq > rmax ) break; // 遇到虚拟记录,说明实际
  • // 记录已输出完,跳出循环
  • 15 else rc = rq;
  • 16 }
  • 17 WriteRecord(r[q]); LastKey = r[q].key;
  • 18 // 输入新记录
  • 19 if (输入结束) rn[q] = rmax + 1; // 生成虚拟记录,以把实
  • // 际记录“顶”出败者树
  • 20 else {
  • ReadRecord (r[q]);
  • 22 if ( r[q].key < LastKey) rn[q] = rmax = rq + 1;// 新记录
  • // 属于下一个归并段

JYP

slide253

23 else rn[q] = rc; // 新记录仍然属于当前归并段

  • }
  • 25 rq = rn[q];
  • 26 // 重新调整败者树
  • 27 for (int t = (k + q)/2; t; t /= 2) // t初始化为r[q]的父结点
  • 28 if ((rn[l[t]] < rq) || (rn[l[t]] == rq && r[l[t]].key <
  • r[q].key)) { // t是胜者
  • 29 int temp = q; q = l[t]; l[t] = temp;
  • 30 rq = rn[q];
  • 31 }
  • 32 } // while循环结束
  • 33 delete [ ] r; delete [ ] rn; delete [ ] l;
  • 34}

JYP

slide254

例7.12设输入表为 (99, 48, 19, 65, 3, 74, 33, 17, 21, 20, 98, 53, 22),k = 4,则runs生成初始归并段的过程如后面所示:

JYP

slide263

由runs生成的归并段长短不一定相同。这时前面所述的完整扫描所有归并段的策略所导致的计算时间不是最少的。由runs生成的归并段长短不一定相同。这时前面所述的完整扫描所有归并段的策略所导致的计算时间不是最少的。

7.9.4 归并段的最佳归并和哈夫曼树

JYP

slide264

例如,假设有四个归并段,长度分别为3,4,8和21。可用下列两种方式给进行2-路归并:例如,假设有四个归并段,长度分别为3,4,8和21。可用下列两种方式给进行2-路归并:

JYP

slide265

一个记录参与的归并次数由其所在的外部结点到根的距离确定。一个记录参与的归并次数由其所在的外部结点到根的距离确定。

由于归并时间与参与的记录个数成线性关系,总的归并时间应等于所有归并段的长度与其相应的外部结点到根的距离的乘积之和。

此和又称为加权外部路径长度。

前面两棵树的加权外部路径长度分别是

33 + 43 + 82 + 211 = 58 和

32 + 42 + 82 + 212 = 72。

JYP

slide266

如果采用具有最短加权外部路径长度的k叉归并树,则对n个长度为qi(1≤i≤n)的归并段进行k路归并的代价最小。如果采用具有最短加权外部路径长度的k叉归并树,则对n个长度为qi(1≤i≤n)的归并段进行k路归并的代价最小。

这里仅考虑k = 2的情况。

最短加权外部路径长度二叉树的另一个应用是获得最佳信息编码。

设需要建立信息M1, M2, …, Mn的一组最佳编码。每个编码为二进制位串。

在接收端,通过解码树对编码进行解码。

JYP

slide267

解码树是一棵二叉树,其外部结点表示信息。信息编码字中的二进制位决定了到达正确外部结点需经过的各层分枝。解码树是一棵二叉树,其外部结点表示信息。信息编码字中的二进制位决定了到达正确外部结点需经过的各层分枝。

例如,将0解释为左分枝,1为右分枝,右图的解码树就对应编码000,001,01和1,分别表示信息M1, M2, M3和M4。

JYP

slide268

解码的代价与编码位数成正比,而该位数就等于相应的外部结点到根结点的距离。解码的代价与编码位数成正比,而该位数就等于相应的外部结点到根结点的距离。

设qi是传输信息Mi的相对频率,di是Mi对应的外部结点到根结点的距离,则期望解码时间是

显然,通过选择具有最短加权外部路径长度的解码树对应的编码,可使期望解码时间最短。

JYP

slide269

哈夫曼给出了建立具有最短加权外部路径长度的二叉树的有效方法,因此这种树又称为哈夫曼树。哈夫曼给出了建立具有最短加权外部路径长度的二叉树的有效方法,因此这种树又称为哈夫曼树。

设二叉树的类定义为:

class BinaryTreeNode {

friend class BinaryTree;

private:

BinaryTreeNode *LeftChild;

int weight;

BinaryTreeNode *RightChild;

};

JYP

slide270

class BinaryTree {

public:

BinaryTree(BinaryTree bt1, BinaryTree bt2) {

root = new BinaryTreeNode;

rootLeftChild = bt1.root;

rootRightChild = bt2.root;

rootweight = bt1.rootweight + bt2.rootweight;

}

private:

BinaryTreeNode *root;

};

JYP

slide271

函数huffman利用一个由扩展二叉树构成的表list。函数huffman利用一个由扩展二叉树构成的表list。

void huffman(List<BinaryTree> list) {

int n = list.Size( ); // 表list中的二叉树棵数

for (int i = 0; i < n – 1; i++) { // 循环n-1次,以结合n个结点

BinaryTree first = list.DeleteMinWeight( );

BinaryTree second = list.DeleteMinWeight( );

BinaryTree *bt = new BinaryTree(first, second);

list.Insert(bt);

}

}

其中用到函数List::DeleteMinWeight( ),List::Size( )和List::Insert( )。

JYP

slide272

list.DeleteMinWeight( )返回list中权重最小的树并将其从list中删除,list.Size( )返回list中元素个数,list.Insert(bt)将新二叉树bt加入表list中。

JYP

slide273

例7.13设权重q1= 2,q2= 3,q3= 4,q4= 7,q5= 8和q6= 13,则所生成的树序列如下:

JYP

slide275

最终所得哈夫曼树的加权外部路径长度是

24 + 34 + 43 + 72 + 82 + 132 = 88

相比之下,最好的完全二叉树的外部路径长度是90。

分析:主循环执行n–1次。将表list维护为一个最小堆,每次调用DeleteMinWeight和Insert只需要O(log n)时间,总计算时间是O(n log n)。

JYP

slide276

根据哈夫曼树,很容易获得一组信息的最佳编码。根据哈夫曼树,很容易获得一组信息的最佳编码。

另外,利用构建哈夫曼树的思想,反复删除和归并当前长度最短的两个归并段,将结果加到归并段表中,直到表中只剩下一个归并段,即可实现归并段的最佳归并。

JYP

slide277

首先回顾二叉查找树的定义:

二叉查找树是一棵二叉树。如果不空,该树应满足以下性质:

(1) 每个元素有一个关键字,且任何两个不同的元素的关键字不相等(即关键字唯一);

(2) 左子树(如果存在的话)中的关键字小于根结点中的关键字;

(3) 右子树(如果存在的话)中的关键字大于根结点中的关键字;

(4) 左、右子树也是二叉查找树。

二叉查找树的结合与分裂(8.2.3)

JYP

slide278

二叉树的例子:

其中,(a)不是二叉查找树,(b)和(c)是。

JYP

slide279

除了查找、插入和删除操作以外,有的应用还需要对二叉查找树进行下列操作:除了查找、插入和删除操作以外,有的应用还需要对二叉查找树进行下列操作:

(1) C.ThreeWayJoin(A, x, B):

构建C,C由原来在A和B中的元素以及元素x构成。假设A中元素的关键字小于x.key,B中元素的关键字大于x.key。最后将A和B设置为空。

(2) C.TwoWayJoin(A, B):

构建C,C由原来在A和B中的元素构成。假设A中所有元素的关键字小于B中所有元素的关键字。最后将A和B设置为空。

JYP

slide280

(3) A.Split(i, B, x, C):

分裂为三部分:B包含A中所有关键字小于i的元素;如果A含关键字为i的元素,则将该元素复制到x,并返回x的指针,否则返回0;C包含A中所有关键字大于i的元素。最后将A设置为空。

JYP

slide281

3-路结合的实现:

获得一个新结点,使其成为C的root,并将其data字段设置为x,LeftChild字段设置为A.root,RightChild字段设置为B.root。

最后将A.root和B.root设置为0。

计算时间是O(1),新树C的高度是 max{height(A), height(B)}+1。

JYP

slide282

2-路结合可通过3-路结合实现:

如果A(或B)是空树,则将C.root设置为B.root(或A.root),并将B.root(或A.root)设置为0即可。

当A和B都不空时,首先从A中删除关键字最大的元素x,由此得到A。再执行3-路结合操作C.ThreeWayJoin(A, x, B)即可完成整个操作。

计算时间是O(height(A)),新树C的高度不超过 max{height(A), height(B)}+1。

JYP

slide283

分裂操作的实现:

在根结点(即i == A.rootdata.key)的分裂很容易。这时,B是A的左子树,x是根结点元素,C是A的右子树,如图8.4(a)所示。

如果i小于根结点的关键字,根结点及其右子树应属于C,如图8.4(b)所示。

如果i大于根结点的关键字,根结点及其左子树应属于B,如图8.4(c)所示。

JYP

slide285

一般地,可以在A中向下查找关键字为i的元素的过程中构造二叉查找树B和C。设t指向当前结点。一般地,可以在A中向下查找关键字为i的元素的过程中构造二叉查找树B和C。设t指向当前结点。

若i < tdata.key,则结点t及其右子树应加入C中。

若i > tdata.key,则结点t及其左子树应加入B中。

若i == tdata.key,则应将t的左、右子树分别加入B、C中,并将tdata复制到x。

JYP

slide286

由于当前C中已有关键字一定大于新加入部分的关键字,所以新加入部分应作为当前C中最小关键字所在结点(用L指向)的左子树,如下所示:由于当前C中已有关键字一定大于新加入部分的关键字,所以新加入部分应作为当前C中最小关键字所在结点(用L指向)的左子树,如下所示:

JYP

slide287

同理,新加入B部分应作为当前B中关键字最大元素所在结点(用R指向)的右子树。同理,新加入B部分应作为当前B中关键字最大元素所在结点(用R指向)的右子树。

为了避免判断树为空的情况,在开始时为B和C分别引入头结点Y和Z。下面是实现分裂操作的算法:

template <class Type>

Element<Type>* BST<Type>::Split(Type i, BST<Type>&B, Element<Type>&x, BST<Type>&C) { // 根据关键字i分裂

if (!root) {B.root = C.root = 0;return 0;} // 空树

BstNode<Type> *Y = new BstNode<Type>; // B的头结点

BstNode<Type> *R = Y;

BstNode<Type> *Z = new BstNode<Type>; // C的头结点

BstNode<Type> *L = Z;

JYP

slide288

BstNode<Type> *t = root;

while (t)

if (i == tdata.key) { // 在结点t分裂

RRightChild = tLeftChild;

LLeftChild = tRightChild;

x = tdata;delete t;

B.root = YRightChild;delete Y; // 删除头结点

C.root = ZLeftChild;delete Z; // 删除头结点

root = 0;

return &x;

}

else if (i < tdata.key) {

LLeftChild = t;

L = t; t = tLeftChild;

}

JYP

slide289

}

else {

RRightChild = t;

R = t; t = tRightChild;

}

RRightChild = LLeftChild = 0; // 注意这是必要的

B.root = YRightChild;delete Y; // 删除头结点

C.root = ZLeftChild;delete Z; // 删除头结点

root = 0;

return 0;

}

JYP

slide290

对Split的分析:while循环始终保证以t为根的子树中的所有关键字大于正在构造的B中关键字,小于正在构造的C中的关键字。由此容易证明算法的正确性。对Split的分析:while循环始终保证以t为根的子树中的所有关键字大于正在构造的B中关键字,小于正在构造的C中的关键字。由此容易证明算法的正确性。

算法的时间复杂性是O(height(A))。B和C的高度不超过A的高度。

JYP

slide291

为了分析二叉查找树的性能,再次引用扩展二叉树的概念,即用方结点替代所有空二叉子树。为了分析二叉查找树的性能,再次引用扩展二叉树的概念,即用方结点替代所有空二叉子树。

这些方结点又称为外部结点。

二叉树原来的(圆)结点称为内部结点。

二叉查找树的性能分析(8.2.4)

JYP

slide293

n个结点的二叉树有n + 1个外部结点。不成功的查找都会在某一个外部结点结束,所以也称外部结点为失败结点。

二叉树的外部路径长度E定义为所有外部结点到根结点的路径长度之和。

内部路径长度I是所有内部结点到根结点的路径长度之和。

图8.6的内部路径长度是

I = 0 + 1 + 1 + 2 = 4

其外部路径长度E是

E = 2 + 2 + 2 + 3 + 3 = 12

JYP

slide294

引理8.1:如果二叉树T有n 个(n≥0)内部结点,I是其内部路径长度,E是其外部路径长度,则 E = I + 2n。

证明:对n应用归纳法。

当n = 0,E = I = 0,引理8.1成立。

假设n = m时引理8.1也成立。

考虑一棵有m + 1个内部结点的树,设该树的内部路径长度为I,外部路径长度为E。设v是该树中的内部结点,且v的左、右子女都是外部结点。设k是结点v到根结点的路径长度。

JYP

slide295

将v的两个子女从树中删除,使v成为新的外部结点,树的内部结点个数减少为m,内部路径长度减少为I – k。v的每个子女到根结点的路径长度是k + 1,所以外部路径长度应减少2(k + 1)再加上新外部结点v到根结点的路径长度k。 因此,新树的外部路径长度是

E – 2(k + 1) + k = E – k – 2

根据归纳假设,

E – k – 2 = (I – k) + 2m

整理可得

E = I + 2(m + 1)

因此,当n = m + 1时引理8.1也成立。

JYP

slide296

查找二叉查找树的时间依赖于所考察的内部结点个数,假设一个内部结点的计算时间用一次比较度量,下面分析具有n个内部结点的二叉查找树的性能。查找二叉查找树的时间依赖于所考察的内部结点个数,假设一个内部结点的计算时间用一次比较度量,下面分析具有n个内部结点的二叉查找树的性能。

JYP

slide297

1 最坏情况

根据引理8.1,具有最大I的扩展二叉查找树也具有最大的E。

在二叉树完全偏斜时I达到最大值。这时,

I = =

因此,在最坏情况的二叉查找树中进行成功查找的平均比较次数Sn为

Sn = = (8.1)

JYP

slide298

2 最好情况

为了使I最小,应使尽可能多的内部结点靠近根结点。在与根结点的距离为1处最多可有2个结点,距离为2处最多可有4个结点,一般地,最小的I是

0 + 2  1 + 4  2 + 8  3 + … +

完全二叉树就是一种具有最小内部路径长度的树。采用完全二叉树的结点编号,结点i到根结点的距离是log2i。这样,最小的I是

I =

JYP

slide299

利用数学推导可得:

= (n + 1)q – 2(q + 1) + 2,

其中,q = log2(n + 1)

因此,在最好情况的二叉查找树中进行成功查找的平均比较次数Sn为

Sn = =

 log2(n + 1) –1log2n–1 (8.2)

JYP

slide300

3 平均情况

Sn — 平均成功查找所需的比较次数

Un — 平均不成功查找所需的比较次数

在树中查找到任何关键字所需的比较次数正好是含该关键字的元素首次插入所需的比较次数加1,而插入该元素所需的比较次数与该元素不在树中的不成功查找的相同。

该元素不在树中的不成功查找等概率地出现在树中含0, 1, …, n – 1个结点的情况。由此可得:

Sn = 1 +

JYP

slide301

同时,Sn = ,Un = ,E = I + 2n,所以,

Sn = =

即 Sn = Un – 1 (8.3)

JYP

slide302

又由 Sn = 和

Sn = 1 +

可得

I = U0 + U1 + …+ Un-1

再由

Un = =

可得

JYP

slide303

(n + 1)Un = 2n + U0 + U1 + …+ Un-1

为解此递归式,用n – 1替换n:

n Un-1 = 2(n – 1) + U0 + U1 + …+ Un-2

两式相减,得:

Un = Un-1 +

JYP

slide304

由于U0 = 0,所以,

Un = 2 = 2 Hn+1 – 2

其中,Hn+1是第n + 1个调和数,即

Hn+1 =

根据调和数理论,Hn  ln n + 0.6,Un  2ln n。

由于ln n = (ln 2) (log2n),所以,

Un  2 (ln 2) (log2n)  1.39 log2n (8.4)

JYP

slide305

由(8.3)式,

Sn  1.39 log2n – 1。

JYP

slide306

如果一个符号表固定,只对其进行查找操作,则称其为静态符号表。如果一个符号表固定,只对其进行查找操作,则称其为静态符号表。

电子词典就可以看成是一个静态符号表,且在实际应用中人们对不同单词的查找概率是不同的。

下面讨论表示静态符号表的二叉查找树的构造问题。

当查找各标识符的概率相同时,可以用最好情况二叉查找树表示该符号表。

最佳二叉查找树(8.2.5)

JYP

slide307

当查找各标识符的概率不相同时,完全二叉树不一定是最佳的。当查找各标识符的概率不相同时,完全二叉树不一定是最佳的。

直觉上,查找概率越大的标识符越靠近根结点性能越好。

这就要求我们为标识符查找概率不相同的符号表构造最佳二叉查找树。

设需要构造的二叉查找树包含标识符a1, a2, …, an,且a1 < a2 < … < an,查找每个ai,的概率是pi,则当查找只限于成功情况时,该二叉查找树的代价是

JYP

slide308

不成功查找在算法Search检查到失败结点时结束。不在二叉查找树中的标识符可被划分为n+1个类Ei,0≤i≤n。不成功查找在算法Search检查到失败结点时结束。不在二叉查找树中的标识符可被划分为n+1个类Ei,0≤i≤n。

E0包含所有满足x < a1的标识符x,Ei包含所有满足ai < x < ai+1的标识符x,1≤i < n,En包含所有满足x > an的标识符x。

对于Ei中的所有标识符,查找都会在同一个失败结点结束,且对于不同类中的标识符,查找会在不同的失败结点结束。

将失败结点从0到n编号,编号i的失败结点对应Ei,0≤i≤n。

JYP

slide309

设qi是所查找的标识符属于Ei的概率,则全部失败结点的代价是设qi是所查找的标识符属于Ei的概率,则全部失败结点的代价是

该二叉查找树的总代价是

(8.5)

表示a1, a2, …, an的最佳二叉查找树应使(8.5)式的值最小。

JYP

slide310

例8.1图8.8给出了用于表示a1, a2, a3 = (data, pipe, work)的所有可能的二叉查找树:

JYP

slide312

当pi = qj = 1/7时,1≤i≤3,0≤j≤3,有

树(a)的代价 = 15/7

树(b)的代价 = 13/7

树(c)的代价 = 15/7

树(d)的代价 = 15/7

树(e)的代价 = 15/7

正如可以预料到的,树(b)是最佳的。

JYP

slide313

当p1 = 0.5,p2 = 0.05,p3 = 0.1,q0 = 0.15,q1 = 0.05,q2 = 0.1,和q3 = 0.05时,有

树(a)的代价 = 2.55

树(b)的代价 = 1.95

树(c)的代价 = 1.6

树(d)的代价 = 2.05

树(e)的代价 = 1.55

树(e)是最佳的。

JYP

slide314

采用例8.1的穷举方法确定代价最小的树计算复杂性很高。当n较大时,这种方法是没有实用价值的。采用例8.1的穷举方法确定代价最小的树计算复杂性很高。当n较大时,这种方法是没有实用价值的。

通过利用最佳二叉查找树的一些性质,则可以得到相当高效的算法。

设Tij表示包含ai+1, …, aj(i < j)的最佳二叉查找树,则Tii(0≤i≤n)是空树,且对于j < i,Tij无定义。

设cij为Tij的代价。由定义,cii = 0。

设rij为Tij的根结点标识符的下标号。

JYP

slide315

再设

wij = qi +

为Tij的权重。由定义,wii = qi,0≤i≤n。

于是,T0n是包含标识符a1, a2, …, an的最佳二叉查找树,其代价是c0n,其根结点标识符的下标是r0n。

JYP

slide316

如果Tij是包含ai+1, …, aj的最佳二叉查找树,且rij = k,则i < k≤j。Tij有左、右两棵子树,如下图所示:

JYP

slide317

L是左子树,包含标识符ai+1, …, ak-1;R是右子树,包含标识符ak+1, …, aj。

根据(8.5),Tij的代价

cij = pk+L的代价+R的代价+L的权重+R的权重

其中,L的权重 = wi,k-1,R的权重 = wkj。

要使cij最小,L的代价应等于ci,k-1,R的代价应等于ckj,因此

cij = pk+ci,k-1+ckj+wi,k-1+wkj = wij+ci,k-1+ckj (8.7)

JYP

slide318

由于Tij是最佳的,根据(8.7)式,rij = k应满足

wij + ci,k-1 + ckj =

ci,k-1 + ckj = (8.8)

根据(8.7)和(8.8)式,并利用动态规划方法,可以从Tii = 和cii = 0开始,逐步得到T0n和c0n。

JYP

slide319

例8.2设n = 3,(a1, a2, a3) = (data, pipe, work),(p1, p2, p3) = (0.5, 0.05, 0.1),(q0, q1, q2, q3) = (0.15, 0.05, 0.1, 0.05)。

初始时,wii = qi,cii = 0,0≤i≤3。利用(8.7)和(8.8)式,并注意到wij = wi,j-1 + pj + qj,可得

w01 = w00 + p1 + q1 = 0.15 + 0.5 + 0.05 = 0.7

c01 = w01 + min{c00 + c11} = 0.7

r01 = 1

w12 = w11 + p2 + q2 = 0.05 + 0.05 + 0.1 = 0.2

c12 = w12 + min{c11 + c22} = 0.2

r12 = 2

JYP

slide320

w23 = w22 + p3 + q3 = 0.1 + 0.1 + 0.05 = 0.25

c23 = w23 + min{c22 + c33} = 0.25

r23 = 3

在wi,i+1和ci,i+1的基础上(0≤i < 3),再次利用(8.7)和(8.8)式,可计算出wi,i+2、ci,i+2和ri,i+2,0≤i < 2。重复此过程,最终可得到w03、c03和r03。

下一页的图8.10给出了此计算结果。

JYP

slide322

由此,c03 = 1.55是最小代价,T03的根结点含标识符a1,其左子树是T00,右子树是T13。

T13的根结点含标识符a3,其左子树是T12,右子树是T33。

T12的根结点含标识符a2,其左子树是T11,右子树是T22。由此可以构造T03,如右图所示。

JYP

slide323

由于本例子中假设的pi和qi与上一个例子的相同,所以该树在结构上与上一个例子的最佳二叉查找树相同。

由上可得计算cij和rij(0≤i < n,i < j≤n)以及根据rij构造T0n的方法:按(j – i)= 1, 2, …, n的顺序计算cij。

当j – i = m时,需要计算n – m + 1个cij。每计算一个cij需要从m个量中选择最小的((8.8)式),计算时间是O(m)。计算所有cij的总时间为O(nm – m2)。

JYP

slide324

计算全部cij和rij的总时间是

= O(n3)

Knuth的研究成果表明,(8.8)式中的最佳u的选择可限定在ri,j-1≤u≤ri+1,j的范围。

因此,当j – i = m时,1≤r0,m-1≤r1,m≤r2,m+1 ≤ … ≤rn-m+1,n≤n,总计算时间改进为

≤2n – m + 1 = O(n)

JYP

slide325

当j – i = m,且m = 1时的总计算时间为n – m + 1。计算全部cij和rij的总时间改进为

n – m + 1 + = O(n2)

假设二维数组w、c和r是类BST的私有数据成员。算法obst实现了上述方法,并根据计算结果调用函数build构造最佳二叉查找树。

JYP

slide326

template <class Type>

void BST<Type>::obst(float *p, float *q, Element<Type>*a, int n) { // 给定n个标识符a1 < a2 < … < an,和pj(1  j  n)和

// qi(0  i  n),计算表示ai+1,…, aj的Tij的cij,同时计算

// rij和wij。最后调用build构造最佳二叉查找树。

for (int i = 0; i < n; i++) {

w[i][i] = q[i]; c[i][i] = 0; // 初始化

w[i][i+1] = q[i] + q[i+1] + p[i+1]; // 只含一个结点的情况

r[i][i+1] = i+1;

c[i][i+1] = w[i][i+1];

}

w[n][n] = q[n]; c[n][n] = 0;

JYP

slide327

for (int m = 2; m <= n; m++) // 含m个结点的最佳二叉查找树

for (i = 0; i <= n - m; i++) {

int j = i + m;

w[i][j] = w[i][j-1] + p[j] + q[j];

int k; float s = MAX; // 设MAX是已定义的最大值常数

for (int u = r[i][j-1]; u <= r[i+1][ j]; u++) {

float t = c[i][u-1] + c[u][j];

if (t < s) {s = t; k = u;} // 求k

}

c[i][j] = w[i][j] + c[i][k-1] + c[k][j]; // (8.7)式

r[i][j] = k;

}

root = build(a, 0, n);

} // obst结束

JYP

slide328

template <class Type>

BstNode<Type>* BST<Type>::build(Element<Type>*a, int i, int j) { // 根据rij构造最佳二叉查找树

if (i == j) return 0; // 空树

BstNode<Type>*p = new BstNode<Type>;

int k = r[i][j];

pdata = a[k];

pLeftChild = build(a, i, k-1);

pRightChild = build(a, k, j);

return p;

}

显然,build(a, 0, n)的计算时间是O(n)。

JYP

slide329

B树只有利于单个关键字查找,而在数据库等许多应用中常常也需要范围查询。

为了满足这种需求,需要改进B树。

B+树就是这种改进的结果,也是现实中最常用的一种B树变种。

B+树与B树的最大区别是:B+树的元素只存放在叶结点中。

不是叶的结点又称为中间结点。中间结点也存放关键字,但这些关键字只起引导查找的“路标”作用。

8.6.6 B+树

JYP

slide330

定义:一棵m阶B+树或者为空,或者满足下列性质:定义:一棵m阶B+树或者为空,或者满足下列性质:

(1) 中间结点最多有m棵子树,且具有下列结构:

n, A0, (K1, A1), (K2, A2), …, (Kn, An)

其中,Ai是子树指针,0≤i≤n < m,Ki是关键字,1≤i≤n < m。

(2) Ki < Ki+1,1≤i < n,且所有中间结点的关键字互不相同。

(3) Ai中的所有关键字都大于等于Ki并小于Ki+1,0 < i < n。

JYP

slide331

(4) An中的所有关键字都大于等于Kn,A0中的所有关键字都小于K1。

(5) 根结点至少有2棵子树,其它中间结点至少有m/2棵子树。

(6) 所有叶结点都处于同一个层次,包含了全部关键字以及相应的元素或元素地址,叶结点中的关键字从小到大排序,且互不相同。

(7) 叶结点中存放的关键字个数可以大于或小于m。设mleaf是叶结点可容纳的最大失败结点个数,则其中的实际关键字个数n应满足:1≤mleaf/2 – 1≤n < mleaf。

JYP

slide332

B+树的叶结点被链接成双链表,以便范围查询。

图8.30是一个B+树的例子:

其中,m=3,mleaf=5。以后的例子都假设m=3,mleaf=5。

JYP

slide333

除了需要一直查到叶结点以外,B+树的查找方法与B树的基本相同。除了需要一直查到叶结点以外,B+树的查找方法与B树的基本相同。

即使中间结点中已有需要查找的关键字,但那也只是“路标”,并不提供实际元素的地址。

例如,在图8.30中查找35。35在根结点中,表示关键字为35的元素在第2棵子树中。由根结点的第2棵子树的第1个分枝,可到达关键字为35的元素所在的叶结点。

JYP

slide334

往B+树中插入关键字为x的元素的方法与B树的类似。往B+树中插入关键字为x的元素的方法与B树的类似。

首先,定位到关键字为x的元素可插入的叶结点p。

如果结点p不满,则加入新元素,并将修改后的结点p写到磁盘即可。

如果结点p已满,则将其分裂为p和q两个结点,这两个结点平均承载原来结点p中的内容和新元素,且结点q中元素的关键字大于结点p中的。再将结点q中最小关键字和指针q插入结点p的双亲。

JYP

slide335

这又可能使双亲结点分裂,直至使根结点分裂,整个B+树长高一层。这又可能使双亲结点分裂,直至使根结点分裂,整个B+树长高一层。

图8.31通过例子描述了上述插入过程,其中,中间结点的粗体字表示新插入的“路标”,叶结点的粗体字表示新插入的关键字。

JYP

slide338

从B+树中删除关键字x的方法也与B树的类似。

首先,定位到关键字x所在的叶结点p。

如果删除后结点p中的关键字个数仍然大于等于 mleaf/2–1,则将修改后的结点p写到磁盘即可。

JYP

slide340

得到图8.32的B+树。注意,虽然元素30已被删除,但作为“路标”的30不必从中间结点中删除。得到图8.32的B+树。注意,虽然元素30已被删除,但作为“路标”的30不必从中间结点中删除。

JYP

slide341

如果删除后结点p中只有 mleaf/2 – 2个关键字,且其最邻近兄弟q至少有mleaf/2个关键字,则可从结点q移一个到结点p,从而使这两个结点都满足要求。

此过程还需要改变双亲结点中的“路标”,以反映结点p或q中第一个关键字的值。

JYP

slide342

例如,从图8.32的B+树中删除31,使得第2个叶结点中的28被移来替代其原来的位置,得到图8.33的B+树。例如,从图8.32的B+树中删除31,使得第2个叶结点中的28被移来替代其原来的位置,得到图8.33的B+树。

JYP

slide343

如果删除后结点p中只有 mleaf/2 – 2个关键字,且其最邻近兄弟q只有mleaf/2 – 1个关键字,则需要将结点q的内容合并到结点p,并删除整个结点q。

这又可能导致双亲结点关键字个数不足,引起新一轮的旋转与合并,直至删除根结点。

JYP

slide344

图8.34 — 1

图8.34描述了从图8.33的B+树中删除35的过程,注意,“路标”28移到根结点,35移到根结点的右子女。

JYP

slide346

实验作业:

设计一个磁盘资源管理系统,实现B+树的查询、插入和删除操作,并生成测试数据集,验证这些操作的正确性。

JYP

slide347

静态散列必须静态分配散列表空间。

如果分配的表空间过大,则会浪费空间;如果分配的表空间过小,则会导致冲突频繁,而且当数据量大于散列表的容量时整个散列表需要重组。

动态散列又称为可扩展散列,其目的就是要既保持静态散列的快速查找性能,又具备动态适应数据文件大小变化的能力。

假设文件F是记录R的集合。每个记录有一个关键字key。记录存储在页面(或桶)中,每个页面的容量是p个记录。

动态散列(8.9)

JYP

slide348

在设计算法时,应尽量减少对页面的访问次数,因为页面通常存储在磁盘上。在设计算法时,应尽量减少对页面的访问次数,因为页面通常存储在磁盘上。

空间利用率定义为n/(mp),其中,n是记录个数,m是页面数。

JYP

slide349

每个关键字由一个二进制编码表示。给定一个散列表ht,关键字x在ht中的位置可通过其编码的k个最低二进制位确定。每个关键字由一个二进制编码表示。给定一个散列表ht,关键字x在ht中的位置可通过其编码的k个最低二进制位确定。

目录实际上是一个散列表,该表的每个目录项存放一个页面指针。

如果用k个二进制位区分关键字,则目录有2k个目录项,分别对应下标0, …, 2k–1。

查找关键字x时,用与x的最低k个二进制位对应的整数定位目录项,再搜索该目录项指向的页面。

8.9.1 带目录动态散列

JYP

slide350

下面通过一个例子来观察目录的动态扩展过程。下面通过一个例子来观察目录的动态扩展过程。

假设关键字由2个字符组成,每个字符由3个二进制位表示。

右图是一些关键字及其二进制表示。

JYP

slide351

假设初始时目录有4项。每个页面可容纳2个记录。需用关键字的2个最低位来确定目录项。假设初始时目录有4项。每个页面可容纳2个记录。需用关键字的2个最低位来确定目录项。

将关键字A0, B0, C2, A1, B1和C3加入表中后的结果如下图(a)所示:

JYP

slide352

再将C5加入表中。由于C5的2个最低位是01,因此应存入页面b。但b已满,从而发生溢出。再将C5加入表中。由于C5的2个最低位是01,因此应存入页面b。但b已满,从而发生溢出。

这时新增加一个页面e,同时改用3个最低位区分关键字,并使目录的目录项数扩大一倍,如右图(b)所示。

JYP

slide353

观察图(b),虽然整个散列表需要3个最低二进制位确定关键字所在页面,但实际上不是所有页面都需要3个最低二进制位来确定。观察图(b),虽然整个散列表需要3个最低二进制位确定关键字所在页面,但实际上不是所有页面都需要3个最低二进制位来确定。

例如页面a被2个目录项指向,且我们实际只需要2个最低位即可确定其中关键字所在页面。因为a中关键字的2个最低位是00,在00前面加上0或1得到000或100,由此对应的2个目录项都指向页面a。

如果对于一个页面内的关键字,实际只需要i个最低位即可确定其所在页面,则称该页面的局部深度为i。

在整个散列表中,所有局部深度的最大者称为全局深度。

JYP

slide354

在图(a)中,每个页面只被1个目录项指向,全局深度为2。在图(a)中,每个页面只被1个目录项指向,全局深度为2。

在图(b)中,页面b和e分别只被1个目录项指向,局部深度为3;其余页面都被2个目录项指向,局部深度为2;全局深度为3。

JYP

slide355

访问任何页面都需要两个步骤:

(1)通过散列函数确定目录项,并得到页面地址;

(2)访问该页面。

如果关键字在页面中的分布不均匀,目录增长可能过大,而大多数目录项都指向同一页面。

为防止这种情况,不宜直接使用关键字的二进制位序列,而应通过均匀散列函数将这些二进制位转换为随机序列。

JYP

slide356

还需要一个散列函数簇,因为在整个表动态变化的不同时刻,需要不同的二进制位个数来区分关键字。还需要一个散列函数簇,因为在整个表动态变化的不同时刻,需要不同的二进制位个数来区分关键字。

以下是一种解决方案:

hashi: key  {0, …, 2i – 1},1≤i≤d

其中,hashi的结果只是简单地在hashi-1的结果前端加上二进制位0或1。

因此,散列函数hash(key, i) 根据关键字key生成一个与i个二进制位对应的随机数。

JYP

slide357

当局部深度为i的页面溢出时,分配一个新页面,并将关键字重新散列到这两个页面。当局部深度为i的页面溢出时,分配一个新页面,并将关键字重新散列到这两个页面。

这两个页面中的关键字的i个最低位相同,称它们为伙伴。

当两个伙伴页面中的关键字个数不大于一个页面的容量时,就将这两个页面合并,释放多余的页面,并将结果页面的局部深度减少1。

假设需要向一个局部深度为i,容量为p且已经包含p个记录的页面加入一个新记录,则先从操作系统获得一个新页面。

JYP

slide358

利用i + 1个二进制位将p + 1个关键字重新散列到这两个页面,并将这些页面的局部深度设置为i + 1。

如果i + 1大于全局深度,则整个目录增长一倍,全局深度加1。

如果所有p + 1个关键字被散列到两页面之一,则上述分裂过程还须继续。

JYP

slide359

以下程序给出了实现带目录的动态散列的概要性描述:以下程序给出了实现带目录的动态散列的概要性描述:

template <class Type>

struct record { // 记录结构

Type key;

// 其它数据字段

};

template <class Type>

struct page { // 页面结构

record names[PageSize]; // 假设PageSize是已定义的常数

int NumRecords; // 本页面的记录个数

};

JYP

slide360

template <class Type>

struct DirEntry { // 目录项结构

int LocalDepth; // 该目录项所指页面的局部深度

page<Type>* paddr; // 注意这是用指针模拟页面地址

};

template <class Type>

DirEntry rdirectory[MaxDir]; // 目录,假设MaxDir是已

// 定义的常数

int gdepth; // 全局深度,要求1≤gdepth≤KeySize,

// 假设KeySize是已定义的常数

int NumGdepthPage; // 局部深度达到全局深度的页面数

JYP

slide361

template <class Type>

int hash(const Type& key, const int i); // 用均匀散列将key

//转换为二进制位序列,返回与i个最低位对应

// 的整数,并作为目录项下标

int buddy(const int index); // 根据指向一个页面的目录项下

// 标index,返回指向其伙伴页面的目录项下标,这可

// 以通过对index的首个二进制位作取反操作实现

template <class Type>

int size(const page<Type>* p); // 返回页面p中记录个数

template <class Type>

void coalesce(const page<Type>* p, const page<Type>* buddy);

// 将页面p及其伙伴buddy的记录合并到页面p,并释放页

// 面buddy

JYP

slide362

template <class Type>

Boolean PageSearch(const Type& key, const page<Type>* p);

// 在页面p中查找关键字key。找到返回TRUE,否则返回

// FALSE

template <class Type>

void enter(const record& r, const page<Type>* p); // 将新记录r

//插入页面p,并执行pNumRecords++;

template <class Type>

void PageDelete(const Type& key, const page<Type>* p); // 从

// 页面p中删除关键字为key的记录,并执行pNumRecords--;

JYP

slide363

template <class Type>

page<Type>* find(const Type& key) { // 在整个表中查找关键

// 字为key的记录。找到返回该记录所在页面的地址,

//否则返回0

int index = hash(key, gdepth);

page<Type>* p = rdirectory[index].paddr;

if (PageSearch(key, p) return p;

else return 0;

}

template <class Type>

void insert(const record<Type>& r, const Type& key) { // 将新

// 记录r插入整个表中

page<Type>* p = find(key); // 检查key是否已存在

if (p) return; // key已存在

int index = hash(key, gdepth);

JYP

slide364

Boolean StillFull = FALSE;

p = rdirectory[index].paddr;

if (pNumRecords != PageSize) enter(r, p); // 页面未满,插

// 入新记录

else do {

rdirectory[index].LocalDepth++;

if (rdirectory[index].LocalDepth > gdepth) {

gdepth++;

if (gdepth > KeySize) {报告错误; return;}

目录扩展一倍并设置新增加的目录项;

NumGdepthPage = 0; // 初始化局部深度达到全

// 局深度的页面数

}

分配一个新页面q;

JYP

slide365

int buddyindex = buddy(index);

rdirectory[buddyindex].LocalDepth =

rdirectory[index].LocalDepth;

rdirectory[buddyindex].paddr =q;

将页面p中的关键字重新散列到页面p和q;

if (rdirectory[index].LocalDepth == gdepth)

NumGdepthPage += 2;

int index = hash(key, gdepth); // 通过散列函数得到记录r

// 的目录项下标

p = rdirectory[index].paddr; // 得到r应存入的页面p,p

// 必是原来的页面p和新分配页面q之一

if (pNumRecords != PageSize) enter(r, p);

else StillFull = TRUE;

} while (StillFull == TRUE);

}

JYP

slide366

template <class Type>

void Delete(const Type& key) { // 从整个表中删除关键字

// 为key的记录

page<Type>* p = find(key);

if (p) {

PageDelete(key, p);

int index = hash(key, gdepth);

int buddyindex = buddy(index);

page<Type>* bp = rdirectory[buddyindex].paddr;

if (size(p)+size(bp) <= PageSize) {

if (index < buddyindex) { // 总是合并到小下标目录

// 项所指页面

coalesce(p, bp);

rdirectory[buddyindex].paddr = p;

}

JYP

slide367

else {

coalesce(bp, p);

rdirectory[index].paddr = bp;

}

rdirectory[index].LocalDepth--;

rdirectory[buddyindex].LocalDepth--;

if (rdirectory[index].LocalDepth + 1 == gdepth)

NumGdepthPage – = 2;

}

JYP

slide368

while (!NumGdepthPage && gdepth > 1) {

gdepth--;

将目录收缩为原来的一半; // 由于总是合并到小下标目

// 录项所指页面,前面一半目录项已设置好

根据剩下的目录项的局部深度重新设置NumGdepthPage; // 此操作只访问目录而不必访问页面

}

}

}

void main( ) {

gdepth = 1;

}

JYP

slide369

分析:利用带目录动态散列,查询任何页面只需要1次访问目录和1次访问页面。分析:利用带目录动态散列,查询任何页面只需要1次访问目录和1次访问页面。

假设页面存储在磁盘上。如果目录在初始时全部进入内存,则查询任何页面只需要1次磁盘访问。

带目录动态散列的性能很好,但也需要空间代价。

空间利用率定义为整个表中存储的记录数与分配的总空间之比。

设L(k)表示存储k个记录所需要的平均页面数。

L(k)与页面的容量p有关。当所有记录都可存入一个页面时,L(k) = 1。

JYP

slide370

当k超过页面的容量时,目录将扩展为上下两部分,上半部分有j个记录,下半部分有k – j个记录。由于从k个记录中选0, 1, …, k个记录的总共组合数是

所以上半部分有j个记录(下半部分必有k – j个记录)的概率是

JYP

slide371

因此

Mendelson的研究结果表明

于是

空间利用率 =  ln 2  0.69

JYP

slide372

往一个满页面中插入第p + 1个记录将导致溢出,从而增加一个新页面。采用均匀散列函数,这两个页面各包含约p/2的记录,即空间利用率为50%。

随着插入和删除操作的不断进行,新近分裂的页面将存储更多记录。所以,空间利用率将介于50%到100%之间。这说明了上述结论的合理性。

Fagin等人的实验研究结果表明,可扩展散列的性能至少与B树同样好或更好。在查找和插入时间方面,可扩展散列明显更好;在空间利用率方面,两者几乎相同。

JYP

slide373

带目录散列至少需要一次间接访问。

假设连续地址空间足够大,可容纳所有记录,则可以除去目录。这种方法称为无目录散列。

设初始时散列表中有4个页面,页面的地址用2个二进制位表示。

散列函数直接给出关键字所在页面的地址。每一个页面对应一个唯一的地址。

图8.43(a)表示将关键字A0, B0, C2, A1, B1和C3加入无目录散列表后的结果。

8.9.2 无目录动态散列

JYP

slide375

页面溢出时,分配一个溢出页存放新关键字,同时在文件尾端增加一个新页面,并将第一个页面的关键字在第一个页面和新页面之间分配。页面溢出时,分配一个溢出页存放新关键字,同时在文件尾端增加一个新页面,并将第一个页面的关键字在第一个页面和新页面之间分配。

如果原来的页面地址包含r个二进制位,则第一个页面和新页面的地址包含r + 1个二进制位。

往图8.43(a)中插入C5的结果如图8.43(b)所示。

接着插入C1的结果如图8.43(c)所示。

JYP

slide376

最终,页面数将增长一倍,从而完成一个扩展阶段。新的扩展阶段便开始了。页面地址由r到r + 1个二进制位确定的阶段称为第r个扩展阶段。

下图反映了文件在第r个扩展阶段的某一个时刻q的状态:

JYP

slide377

在第r个扩展阶段的开始,共有m = 2r个页面,所有页面的地址都由r个二进制位确定。

在时刻q,增加了q个新页面。分隔线q左边的页面已分裂。从分隔线q的右边到分隔线m的页面待分裂。分隔线m右边的页面是在本阶段新增加的页面。这部分页面的地址由r + 1个二进制位确定。

分隔线q也指示下一个将分裂的页面。所有小于q的页面的地址都由r + 1个二进制位确定。

JYP

slide378

为此需要采用下列程序实现的散列函数:

if ( hash(key, r) < q) page = hash(key, r + 1);

else page = hash (key, r);

如果必要,沿着溢出指针查找;

函数h(key, r)的值域是[0, 2r – 1]。

无目录动态散列总需要有溢出页的支持。

对于直接由散列函数确定地址的页面中的关键字,查询只需要一次访问。然而,对于溢出链表中的关键字,查询需要的访问次数大于等于2。

在增加新页面时,需要将被分裂页面及其溢出链表中的关键字重新散列到两个页面中。

JYP