第一篇 基础知识                         第1章 数据结构概述            近年来,随着计算机技术的快速发展,数据规模呈现几何级增长,数据类型也变得多样化,软件开发需要处理的数据日趋复杂,数据结构在人工智能、大数据技术飞速发展的今天显得尤为重要。要想编写出好的程序,不仅需要选择好的数据结构,还要有高效的算法。数据结构与算法往往是紧密联系在一起的。   本章重点和难点: * 数据结构的相关概念。 * 数据的逻辑结构与存储结构。 * 抽象数据类型描述。 * 算法的时间复杂度和空间复杂度。 1.1 为什么要学习数据结构   1. 数据结构的前世今生   数据结构作为一门独立的课程是从1968年开始在美国设立的。1968年,算法和程序设计技术的先驱,美国的唐·欧·克努特(Donald Ervin Knuth,中文名高德纳)教授开创了数据结构的最初体系,他所著的《计算机程序设计艺术》第一卷《基本算法》是第一本较系统地阐述数据的逻辑结构和存储结构及其操作的著作。从20 世纪60 年代末到20世纪70年代初,随着大型程序的出现,软件也相对独立,结构化程序设计成为程序设计方法学的主要内容,数据结构显得越来越重要。   从20世纪70年代中期到20世纪80年代,各种版本的数据结构著作相继出现。目前,数据结构的发展并未就此止步,随着大数据和人工智能时代的到来,数据结构开始在新的应用领域发挥重要作用。面对爆炸性增长的数据和计算机技术的发展,人工智能、大数据、机器学习等各应用领域中需要处理的大量多维数据就需要对数据进行组织和处理,数据结构的重要性不言而喻。   高德纳(Donald Ervin Knuth)写出了计算机科学理论与技术的经典巨著《计算机程序设计艺术》(The Art of Computer Programming)(共五卷),该著作被《美国科学家》杂志列为20世纪最重要的12本物理科学类专著之一,与爱因斯坦《相对论》、狄拉克《量子力学》、理查?费曼《量子电动力学》等经典比肩。高德纳因而在他36岁时就荣获1974年度的图灵奖。《计算机程序设计的艺术》推出之后,真正能读完读懂的人数并不多,据说比尔·盖茨花费了几个月才读完第一卷,然后说:“如果你觉得自己是一名优秀的程序员,那就去读《计算机程序设计艺术》吧。对我来说,读完这本书不仅花了好几个月,而且还要求我有极高的自律性。如果你能读完这本书,不妨给我发个简历。”   2. 数据结构的作用与地位   数据结构是介于数学、计算机硬件和计算机软件三者之间的一门核心课程。数据结构已经不仅是计算机相关专业的核心课程,还是其他非计算机专业的主要选修课程之一,其重要性不言而喻。数据结构与计算机软件的研究有着更密切的关系,开发计算机系统软件和应用软件都会用到各种类型的数据结构。例如,算术表达式求值问题、迷宫求解、机器学习中的决策树分类等分别利用了数据结构中的栈、树进行解决,因此,要想更好地运用计算机来解决实际问题,使编写出的程序更高效、具有通用性,仅掌握计算机程序设计语言是难以应付众多复杂问题的,还必须学习和掌握好数据结构方面的有关知识。数据结构也是学习操作系统、软件工程、人工智能、算法设计与分析、机器学习、大数据等众多后继课程的重要基础。 1.2 基本概念和术语   在学习数据结构的过程中,有一些基本概念和专业术语会经常出现,下面先来了解一下这些基本概念和术语。   1. 数据   数据(data)是描述客观事物的符号,能输入到计算机中并能被计算机程序处理的符号集合。它是计算机程序加工的“原料”。例如,一个文字处理程序(如Microsoft Word)的处理对象就是字符串,一个数值计算程序的处理对象就是整型和浮点型数据。因此,数据的含义非常广泛,如整型、浮点型等数值类型及字符、声音、图像、视频等非数值数据都属于数据范畴。   2. 数据元素   数据元素(data element)是数据的基本单位,在计算机程序中通常作为一个整体考虑和处理。一个数据元素可由若干个数据项(data item)组成,数据项是数据不可分割的最小单位。例如,一个学校的教职工基本情况表包括工号、姓名、性别、籍贯、所在院系、出生年月及职称等数据项。教职工基本情况如表1-1所示。表中的一行就是一个数据元素,也称为一条记录。 表1-1 教职工基本情况 工 号 姓 名 性 别 籍 贯 所 在 院 系 出 生 年 月 职 称 2006002 孙冬平 男 河南 计算机学院 1970.10 教 授 2019056 朱 琳 女 北京 文学院 1985.08 讲 师 2015028 刘晓光 男 陕西 软件学院 1981.11 副教授      3. 数据对象   数据对象(data object)是性质相同的数据元素的集合,是数据的一个子集。例如,对于正整数来说,数据对象是集合N={1,2,3,…};对于字母字符数据来说,数据对象是集合C={'A','B','C',…}。   4. 数据结构   数据结构(data structure)即数据的组织形式,它是数据元素之间存在的一种或多种特定关系的数据元素集合。在现实世界中,任何事物都是有内在联系的,而不是孤立存在的,同样在计算机中,数据元素不是孤立的、杂乱无序的,而是具有内在联系的数据集合。例如,表1-1的教职工基本情况表是一种表结构,学校的组织机构是一种层次结构,城市之间的交通路线属于图结构,如图1-1和图1-2所示。 图1-1 学校组织机构图 图1-2 城市之间交通路线图   5. 数据类型   数据类型(data type)用来刻画一组性质相同的数据及其上的操作。数据类型是按照值的不同进行划分的。在高级语言中,每个变量、常量和表达式都有各自的取值范围,该类型就说明了变量或表达式的取值范围和所能进行的操作。例如,C语言中的字符类型规定了所占空间是8位,也就决定了它的取值范围,同时也定义了在其范围内可以进行赋值运算、比较运算等。   在C语言中,按照取值的不同,数据类型还可以分为原子类型和结构类型两类。原子类型是不可以再分解的基本类型,包括整型、实型、字符型等。结构类型是由若干个类型组合而成,是可以再分解的。例如,整型数组是由若干整型数据组成的,结构类型的值也是由若干个类型范围的数据构成,它们的类型都是相同的。   随着计算机技术的飞速发展,计算机从最初仅能够处理数值信息,发展到现在能处理的对象包括数值、字符、文字、声音、图像及视频等信息。任何信息只要经过数字化处理,能够让计算机识别,都能够进行处理。当然,这需要对要处理的信息进行抽象描述,让计算机理解。 1.3 数据的逻辑结构与存储结构   数据结构的主要任务就是通过分析数据对象的结构特征,包括逻辑结构及数据对象之间的关系,并把逻辑结构表示成计算机可实现的物理结构,以便设计、实现算法。 1.3.1 逻辑结构   数据的逻辑结构(logical structure)是指在数据对象中数据元素之间的相互关系。数据元素之间存在不同的逻辑关系构成了以下4种结构类型。   (1)集合。结构中的数据元素除了同属于一个集合外,数据元素之间没有其他关系。这就像数学中的自然数集合,集合中的所有元素都属于该集合,除此之外,没有其他特性。例如,数学中的正整数集合{5,67,978,20,123,18},集合中的数除了属于正整数外,元素之间没有其他关系。数据结构中的集合关系就类似于数学中的集合。集合表示如图1-3所示。   (2)线性结构。结构中的数据元素之间是一对一的关系。线性结构如图1-4所示。数据元素之间有一种先后的次序关系,a、b、c……是一个线性表,其中,a是b的前驱,b是a的后继。 图1-3 集合结构 图1-4 线性结构   (3)树状结构。结构中的数据元素之间存在一种一对多的层次关系,树状结构如图1-5所示。这就像学校的组织结构图,学校下面是教学的院系、行政机构及一些研究所。   (4)图结构。结构中的数据元素是多对多的关系,图1-6就是一个图结构。城市之间的交通路线图就是多对多的关系,a、b、c、d、e、f、g是7个城市,城市a和城市b、e、f都存在一条直达路线,而城市b也和a、c、f存在一条直达路线。 图1-5 树状结构 图1-6 图结构 1.3.2 存储结构   存储结构(storage structure)也称为物理结构(physical structure),指的是数据的逻辑结构在计算机中的存储形式。数据的存储结构应能正确反映数据元素之间的逻辑关系。   数据元素的存储结构形式通常有顺序存储结构和链式存储结构两种。顺序存储是把数据元素存放在一组地址连续的存储单元里,其数据元素间的逻辑关系和物理关系是一致的。采用顺序存储的字符串“abcdef”的存储结构如图1-7所示。链式存储是把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的,数据元素的存储关系并不能反映其逻辑关系,因此需要借助指针来表示数据元素之间的逻辑关系。字符串“abcdef”的链式存储结构如图1-8所示。 图1-7 顺序存储结构 图1-8 链式存储结构   数据的逻辑结构和物理结构是密切相关的,在学习数据结构的过程中,将会发现,任何一个算法的设计取决于选定的数据逻辑结构,而算法的实现则依赖于所采用的存储结构。   如何描述存储结构呢?通常是借助C/C++/Java/Python等高级程序设计语言中提供的数据类型进行描述。例如,对于数据结构中的顺序表可以用C语言中的一维数组表示;对于链表,可用C语言中的结构体描述,其中用指针来表示元素之间的逻辑关系。 1.4 抽象数据类型及其描述   在数据结构中,把一组包含数据类型、数据关系及在该数据上的一组基本操作统称为抽象数据类型。 1.4.1 什么是抽象数据类型   抽象数据类型(Abstract Data Type,ADT)是描述具有某种逻辑关系的数学模型,并在该数学模型上进行的一组操作。这个抽象数据类型有点类似于C++和Java中的类,例如,Java中的Integer类是基本类型int所对应的封装类,它包含MAX_VALUE(整数最大值)、MIN_VALUE(整数最小值)等属性、toString(int i)、parseInt(String s)等方法。它们的区别在于,抽象数据类型描述的是一组逻辑上的特性,与在计算机内部如何表示无关;Java中的Integer类是依赖具体实现的,是抽象数据类型的具体化表现形式。   抽象数据类型不仅包括在计算机中已经定义了的数据类型,如整型、浮点型等,还包括用户自己定义的数据类型,如结构体类型、类等。   一个抽象数据类型定义了一个数据对象、数据对象中数据元素之间的关系及对数据元素的操作。抽象数据类型通常是指用来解决应用问题的数据模型,包括数据的定义和操作。   抽象数据类型体现了程序设计中的问题分解、抽象和信息隐藏特性。抽象数据类型把实际生活中的问题分解为多个规模小且容易处理的问题,然后建立起一个计算机能处理的数据模型,并把每个功能模块的实现细节作为一个独立的单元,从而使具体实现过程隐藏起来。这就类似人们日常生活中盖房子,把盖房子分成若干个小任务:地皮审批、图纸设计、施工、装修等,工程管理人员负责地皮的审批,地皮审批下来之后,工程技术人员根据用户需求设计图纸,建筑工人根据设计好的图纸进行施工(包括打地基、砌墙、安装门窗等),盖好房子后请装修工人装修。   盖房子的过程与抽象数据类型中的问题分解类似,工程管理人员不需要了解图纸如何设计,工程技术人员不需要了解打地基和砌墙的具体过程,装修工人不需要知道怎么画图纸和怎样盖房子,这就是抽象数据类型中的信息隐藏。 1.4.2 抽象数据类型的描述   对于初学者来说,抽象数据类型不太容易理解,用一大堆公式会让不少读者迷茫,因此,本书采用通俗的语言去讲解抽象数据类型。本书把抽象数据类型分为两部分来描述,即数据对象集合和基本操作集合。其中,数据对象集合包括数据对象的定义及数据对象中元素之间关系的描述,基本操作集合是对数据对象的运算的描述。数据对象和数据关系的定义可采用数学符号和自然语言描述,基本操作的定义格式如下。 基本操作名(参数表):初始条件和操作结果描述.   例如,集合Set的抽象数据类型描述如下。   1. 数据对象集合   集合Set的数据对象集合为{a1,a2,…,a n},每个元素的类型均为DataType。   2. 基本操作集合   (1)InitSet (&S):初始化操作,建立一个空的集合S。   (2)SetEmpty(S):若集合S为空,返回1,否则返回 0。   (3)GetSetElem (S,i,&e):返回集合S的第i个位置元素值给e。   (4)LocateElem (S,e):在集合S中查找与给定值e相等的元素,如果查找成功返回该元   素在表中的序号,否则返回 0。   (5)InsertSet (&S,e):在集合S中插入一个新元素e。   (6)DelSet (&S,i,&e):删除集合S中的第i个位置元素,并用e返回其值。   (7)SetLength(S):返回集合S中的元素个数。   (8)ClearSet(&L):将集合S清空。   (9)UnionSet(&S,T):合并集合S和T,即将T中的元素插入到S中,相同的元素只保留一个。   (10)DiffSet(&S,T):求两个集合的差集,即S-T,即删除S中与T中相同的元素。   (11)DispSet(S):输出集合S中的元素。   基本操作实现如下。 typedef struct myset/*集合的类型定义*/ { DataType list[MAXSIZE]; int length; }Set; void InitSet(Set *S) /*集合S的初始化*/ { S->length=0; } int SetEmpty(Set S) /*判断集合S是否为空,若为空,则返回1;否则,返回0*/ { if(S.length<=0) return 1; else return 0; } int SetLength(Set S) /*返回集合S中元素个数*/ { return S.length; } void ClearSet(Set *S) /*清空集合S*/ { S->length=0; } int InsertSet(Set *S, DataType e) /*在集合S中插入一个元素e*/ { if(S->length>=MAXSIZE-1) return -1; else { S->list[S->length]=e; S->length++; return 1; } } int DelSet(Set *S, int pos) /*删除集合S中的第pos个元素*/ { int i; if(S->length<=0) return -1; else { for(i=pos-1;ilength-1;i++) S->list[i]=S->list[i+1]; S->length--; return 1; } } int GetSetElem(Set S,int i,DataType *e) /*获取集合S中第i个元素赋给e*/ { if(S.length<=0) return -1; else if(i<1&&i>S.length) return -1; else { *e=S.list[i-1]; return 1; } } int LocateElem(Set S, DataType e) /*查找集合S中元素值为e的元素,返回其序号*/ { int i; for(i=1;i<=S.length;i++) { if(S.list[i-1]==e) return i; } return 0; } int UnionSet(Set *S, Set T) /*合并集合S和T*/ { DataType e; int i; if(S->length+T.length>=MAXSIZE-1) return -1; else { for(i=1;i<=T.length;i++) { GetSetElem(T,i,&e); if(!LocateElem(*S,e)) InsertSet(S,e); } } } int DiffSet(Set *S, Set T) /*求集合S和T的差集*/ { DataType e; int i,pos; if(S->length<=0) return -1; else { for(i=1;i<=T.length;i++) { GetSetElem(T,i,&e); if(pos=LocateElem(*S,e)) DelSet(S,pos); } return 1; } } void DispSet(Set S) /*输出集合S中的元素*/ { int i; for(i=1;i<=S.length;i++) printf("%4c",S.list[i-1]); printf("\n"); } 1.5 算法   在定义好了数据类型之后,就要在此基础上设计实现算法,即程序。 1.5.1 数据结构与算法的关系   算法与数据结构关系密切,两者既有联系又有区别。数据结构与算法的联系可用一个公式描述:   程序=算法+数据结构   数据结构是算法实现的基础,算法依赖于某种数据结构才能实现。算法的操作对象是数据结构。算法的设计和选择要同时结合数据结构,只有确定了数据的存储方式和描述方式,即数据结构确定了之后,算法才能确定。例如,在数组和链表中查找元素值的算法实现是不同的。算法设计的实质就是对实际问题要处理的数据选择一种恰当的存储结构,并在选定的存储结构上设计一个好的算法。   数据结构是算法设计的基础。比如你要装修房子,装修房子的设计就相当于算法设计,而如何装修房子是要看房子的结构设计,不同的房间结构,其装修设计是不同的,只有确定了房间结构,才能进行房间的装修设计。房间的结构就像数据结构。算法设计必须要考虑到数据结构的构造,算法设计是不可能独立于数据结构存在的。数据结构的设计和选择需要为算法服务,根据数据结构及特点,才能设计出好的算法。 1.5.2 什么是算法   算法(algorithm)是解决特定问题求解步骤的描述,在计算机中表现为有限的操作序列。操作序列包括一组操作,每一个操作都完成特定的功能。例如,求n个数中最大者的问题,其算法描述如下。   (1)定义一个变量max和一个数组a[],分别用来存放最大数和数组的元素,并假定第一个数最大,赋给max。 max=a[0];   (2)依次把数组a中其余的n-1个数与max进行比较,遇到较大的数时,将其赋给max。 for(i=1;ia[j+1]) /*判断,冒泡排序算法实现*/ { /*比较两个元素,如果它们的顺序错误就将它们交换过来*/ t=a[j]; a[j]=a[j+1]; a[j+1]=t; change=TRUE; /*变量change赋值为TRUE*/ } } }   交换相邻两个整数为该算法中的基本操作。当数组a中的初始序列为从小到大有序排列时,基本操作的执行次数为0;当数组中初始序列从大到小排列时,基本操作的执行次数为n(n-1)/2。对这类算法的分析,一种方法是计算所有情况的平均值,这种时间复杂的计算方法称为平均时间复杂度;另外一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。若数组a中初始输入数据可能出现n!种的排列情况的概率相等,则冒泡排序的平均时间复杂度为T(n)=O(n2)。   然而,在很多情况下,各种输入数据集出现的概率难以确定,算法的平均复杂度也就难以确定。因此,另一种更可行也更为常用的办法是讨论算法在最坏情况下的时间复杂度,即分析最坏情况以估算算法执行时间的上界。例如,上面冒泡排序的最坏时间复杂度为数组a中初始序列为从大到小有序,则冒泡排序算法在最坏情况下的时间复杂度为T(n)=O(n2)。一般情况下,本书以后讨论的时间复杂度,在没有特殊说明的情况下,都指的是最坏情况下的时间复杂度。   2. 算法时间复杂度分析举例   一般情况下,算法的时间复杂度只需要考虑算法中的基本操作,即算法中最深层循环体内的操作。   【例1.1】分析以下程序段的时间复杂度。 for(i=2;i<=n;i++) for(j=2;j<=i-1;j++) { x++; //基本操作 a[i][j]=x; //基本操作 }   该程序段中的基本操作是第二层for循环中的语句,即x++和a[i][j]=x,其语句频度为(n-1)(n-2)/2。因此,其时间复杂度为O(n2)。   【例1.2】分析以下算法的时间复杂度。 void Fun( ) { int i=1; while(i<=n) { i=i*2; //基本操作 } }   该函数Fun()的基本操作是i=i*2,设循环次数为f(n),则2f(n)≤n,则有f(n)≤log2n。因此,时间复杂度为O(log2n)。   【例1.3】分析以下算法的时间复杂度。 void Func( ) { i=s=0; while(s=0 && t=m?a[n-1]:m; } }   设FindMax(a,n)占用的临时空间为S(n),由以上算法可得到以下占用临时空间的递推式。   则有S(n)=S(n-1)+1=S(n-2)+1+1=…=S(1)+1+1+…+1=O(n)。因此,该算法的空间复杂度为O(n)。 1.7 学好数据结构的秘诀   作为计算机专业的一名“老兵”,笔者从事数据结构和算法的研究已经有二十余年了,在学习的过程中,也会遇到一些问题,但在解决问题时,积累了一些经验,为了让读者在学习数据结构的过程中少走弯路,本节将分享一些笔者个人在学习数据结构与算法时的经验,希望对读者的学习有所帮助。   1. 明确数据结构的重要性,树立学好数据结构的信心   数据结构是计算机、软件工程等相关专业的核心课程,是操作系统、数据库原理、编译原理、人工智能、算法设计与分析等课程的重要基础。当今最流行的人工智能、机器学习中的所有算法无不蕴含着数据结构与算法知识,数据结构也是计算机专业硕士研究生入学考试,计算机软件水平考试、等级考试的必考内容之一,其重要性不言而喻。   一定要树立学好数据结构与算法的信心。万事开头难,学习任何一样新东西,都有一个适应过程,对于初学者来说,数据结构有些枯燥、乏味,但当你将数据结构中的知识与日常生活结合起来时,就不会觉得那么枯燥和乏味了,你会觉得它很有用。在学习数据结构与算法的过程中,主要困难可能是出于以下原因:一个是数据结构的概念比较抽象,不容易理解;另一个是没有熟练掌握一门程序设计语言。数据结构中的概念其实就是对日常生活中的具体问题进行了抽象,因此,只要与日常生活多联系,这些抽象的概念就变得好理解了。另外,一定要熟练掌握C语言/Java语言工具,从代码中去领会算法思想。   2. 熟练掌握程序设计语言,变腐朽为神奇   程序语言是学习数据结构和算法设计的基础,很显然,没有良好的程序设计语言能力,就不能很好地把算法用程序设计语言描述出来,程序设计语言和数据结构、算法的关系就像是画笔和画家的思想关系一样,程序设计语言就是画笔,数据结构、算法就是画家的思想,即便画家的水平很高,如果不会使用画笔,再美的图画也无法展现出来。   可见,要想学好数据结构,必须至少熟练掌握一门程序设计语言,如C语言、C++语言、Java语言等。   3. 结合生活实际,变抽象为具体   数据结构是一项把实际问题抽象化和进行复杂程序设计的工程。它要求学生不仅具备C语言等高级程序设计语言的基础,而且还要学会掌握把复杂问题抽象成计算机能够解决的离散的数学模型的能力。在学习数据结构的过程中,要将各种结构与实际生活结合起来,把抽象的东西具体化,以便理解。例如,学到队列时,很自然就会联想到火车站售票窗口前面排起的长长的队伍,这支长长的队伍其实就是队列的具体化,这样就会很容易理解关于队列的概念,如队头、队尾、出队、入队等。   4. 多思考,多上机实践   数据结构既是一门理论性较强的学科,也是一门实践性很强的学科。特别是对于初学者而言,接触到的算法相对较少,编写算法还不够熟练,俗话说“熟能生巧,勤能补拙”,因此,只有多看有关算法和数据结构方面的图书,认真理解其中的算法思想。除了阅读算法之外,还要自己动手写算法,并在计算机上调试,这样才能知道编写的算法是否正确,存在哪些错误和缺陷,以避免今后再犯类似错误,长此以往,自己的算法和数据结构水平才能快速提高。   有的表面上看是正确的程序,在计算机上运行后才发现隐藏的错误,特别是很细微的错误,只有多试几组数据,才知道程序到底是不是正确。因此,对于一个程序或算法,除了仔细阅读程序或算法判断是否存在逻辑错误外,还需要上机调试,在可能出错的地方设置断点,单步跟踪调试程序,观察各变量的变化情况,才能找到具体哪个地方出了问题。有时,可能仅仅是误输入了一个符号或变量,就可能产生错误,这种错误往往不容易发现,只有上机调试才能发现错误。因此,在学习数据结构与算法的时候一定要多上机实践。   只要能做到以上几点,选择一本好的数据结构教材或参考书(最好算法完全用C语言实现,有完整代码),加上读者的勤奋,学好数据结构并不是什么难事。          第2章 数据结构与算法基础            “工欲善其事,必先利其器”。C语言是数据结构与算法的主要描述语言,要想真正掌握好数据结构与算法,读懂并写出逻辑清晰、高效优雅的算法,必须首先对C语言了如指掌。本章旨在引领读者复习C语言中的一些重点和难点,为今后的数据结构与算法学习扫清语言障碍。本章主要内容包括C语言开发环境、函数与递归、指针、参数传递、动态内存分配及结构体、联合体。   本章重点和难点: * 递归函数的实现和递归如何转换为非递归。 * 指针数组、数组指针、函数指针的定义及使用。 * 理解传地址调用中变量的变化情况。 * 链表的定义及其操作。 2.1 递归与非递归   递归是C语言学习过程中的重点和难点。在数据结构与算法实践过程中,经常会遇到利用递归实现算法的情况。递归是一种分而治之、将复杂问题转换为简单问题的求解方法。使用递归可以使编写出的程序简洁、结构清晰,程序的正确性很容易证明,不需要了解递归调用的具体细节。 2.1.1 函数的递归调用   简单来说,函数的递归调用就是自己调用自己,即一个函数在调用其他函数的过程中,又出现了对自身的调用,这种函数称为递归函数。函数的递归调用可分为直接递归调用和间接递归调用。其中,在函数中直接调用自己称为函数的直接递归调用,如图2-1所示;如果函数f1调用了函数f2,函数f2又调用了f1,这种调用方式称为间接递归调用,如图2-2所示。   函数的递归调用就是自己调用自己,可以直接调用自己也可以间接调用自己。 图2-1 直接递归调用过程 图2-2 间接递归调用过程   在用递归解决实际问题时,递归函数只需知道最基本问题的解。在递归函数中,遇到基本问题时仅返回一个值,在解决较为复杂的问题时,通过将复杂的问题化解为比原有问题更简单、规模更小的问题,最后把复杂问题变成一个基本问题,而基本问题的答案是已知的,基本问题解决后,比基本问题大一点的问题也得到解决,直到原有问题得到解决。 2.1.2 递归应用举例   【例2-1】利用递归求n!。   【分析】n的阶乘递归定义为n!=n×(n-1)!,当n=5时,则有   5!=5×4!   4!=4×3!   3!=3×2!   2!=2×1!   1!=1×0!   0!=1   递归计算5!的过程如图2-3所示。因为5!=5×(5-1)!,因此,如果能求出(5-1)!,也就求出了5!;又因为(5-1)!=(5-1)×(5-2)!,因此,如果能求出(5-2)!,则也就能求出(5-1)!;……最后一直递归到1! =1×0!。而0!的值为1是已知条件,当得到了0!的值后,就可以得到1!的值,按上述分析过程逆向推回去,从而得到2!、3!、4!和5!的值。   这样就把求解问题5!转换为5与4!相乘,4!的值是未知的,接着继续把求解4!转换为4与3!相乘,这样将问题规模不断缩小,直到把原问题转换为求解0!=1这个最基本的已知问题为止。   根据上述分析可知,求解5!可分成两个阶段:第一阶段是由未知逐步推得已知的过程,称为“回推”;第二阶段是与回推过程相反的过程,即由已知逐步推得最后结果的过程,称为“递推”。图中的左半部分是回推过程,回推过程在计算出0!=1时停止调用;右半部分是递推过程,直到计算出5!=120为止。   综上,递归求n!的过程分以下两个过程。   (1)当n=0(递归调用结束,即递归的出口)时,返回1。   (2)当n≠0时,需要把复杂问题分解成较为简单的问题,直到分解成最简单的问题0!=1为止。 图2-3 求5!递归调用过程   递归求n!的算法实现如下。 #include #include long factorial(int n); /*求阶乘函数声明*/ void main() /*主函数*/ { int num; /*定义循环变量num*/ for(num=0;num<10;num++) /*for循环处理*/ printf("%d!=%ld\n",num,factorial(num)); /*输出阶乘计算结果*/ system("pause"); } long factorial(int n) /*递归求n!函数实现*/ { if(n==0) /*当n=0时,递归调用出口*/ return 1; /*0!=1是最基本问题的解*/ else /*否则*/ return n*factorial(n-1); /*递归调用将问题分解成较为简单的子问题*/ }   程序运行结果如图2-4所示。 图2-4 程序运行结果   【例2-2】要求利用递归实现求n个数中的最大者。   【分析】假设元素序列存放在数组a[]中,数组a[]中n个元素的最大者可以通过将a[n-1]与前n-1个元素最大者比较之后得到,而前n-1个元素的最大者可通过将a[n-2]与前n-2个元素的最大者比较之后得到,依次类推。   也就是说,数组a[]中只有一个元素时,最大者是a[0];超过一个元素时,则要比较最后一个元素a[n-1]和前n-1个元素中的最大者,其中较大的一个即所求。而求前n-1个元素的最大者需要继续调用findmax()函数得到。   求n个数中的最大者的递归算法实现如下。 #include #include #define N 200 /*宏定义 N=200*/ int findmax(int a[],int n); /*求数组中最大者的函数声明*/ void main() { int a[N],n,i; /*定义变量*/ printf("请输入n的值:"); /*输出提示信息*/ scanf("%d",&n); /*从键盘输入n的值*/ printf("请依次输入%d个数:\n",n); /*输出提示信息*/ for(i=0;i=m?a[n-1]:m; /*若第n个数大于或等于m,则第n个数就是最大者;否则,m为最大者*/ } }   程序的运行结果如图2-5所示。 图2-5 递归实现求n个数的最大者程序运行结果 2.1.3 迭代与递归   大量的递归调用会耗费大量的时间和内存。每次递归调用都会建立函数的一个备份,会占用大量的内存空间。迭代则不需要反复调用函数和占用额外的内存。通过分析递归求n的阶乘n!的计算过程,可以转换为非递归实现,其非递归实现如下。 int NonRecFact(int n) /*非递归求前n的阶乘*/ { int i,s=1; for(i=1;i<=n;i++) /*通过迭代求n的阶乘*/ s*=i; return s; /*返回计算结果*/ }   对于大整数问题,考虑到n值非常大的情况,运算结果超出一般整数的位数,可以用一维数组存储长整数,数组中的每个元素只存储长整数的一位数字。如有m位长整数N用数组a[]存储,N=a[m]*10m-1+a[m-1]*10m-2+…+a[2]*101+a[1]*100,并用a[0]存储长整数N的位数m,即a[0]=m。按上述约定,数组的每个元素存储k的阶乘k!的每一位数字,并从低位到高位依次存储于数组的第2个元素、第3个元素……例如,6!=720在数组中的存储形式如图2-6所示。 图2-6 k!在数组中的存储情况   其中,第1个元素3表示长整数是一个3位数,接着是低位到高位的0、2、7,表示长整数720。   在计算阶乘k!时,可以采用对已求得的阶乘(k-1)!连续累加k-1次(即得到k×(k-1)!)后得到。例如,已知5!=120,计算6!,可对原来的120再累加5次120(即得到6×5!)得到720。具体程序实现如下。 #include #include /*包含该头文件的目的是使用了函数malloc*/ #include #define N 100 /*宏定义 N=100*/ void fact(int a[],int k) /*求阶乘的非递归实现*/ { int *b,m,i,j,r,carry; /*定义变量*/ m=a[0]; /*将正整数的位数赋给m*/ b=(int*)malloc(sizeof(int)*(m+1)); /*申请分配指定字节的内存空间并赋值给b*/ for(i=1;i<=m;i++) b[i]=a[i]; /*将数组a的数据保存到数组b中*/ for(j=1;j #include void main() { int q=12; int *qPtr; /*声明指针变量qPtr*/ qPtr=&q; /*指针变量qPtr指向变量q*/ /*打印变量q的地址和qPtr的内容*/ printf("q的地址是:%p\nqPtr中的内容是:%p\n",&q,qPtr); /*打印q的值和qPtr指向变量的内容*/ printf("q的值是:%d\n*qPtr的值是:%d\n",q,*qPtr); /*运算符'&'和'*'是互逆的*/ printf("&*qPtr=%p,*&qPtr=%p\n因此有&*qPtr=*&qPtr\n",&*qPtr,*&qPtr); system("pause"); }   程序运行结果如图2-10所示。   &和*作为单目运算符,结合性是从右到左,优先级别相同,因此对于表达式&*qPtr来说,先进行*运算,后进行&运算。因为qPtr是指向变量q的,所以*qPtr的值为q,&*qPtr就是对q取地址,即&q,q的地址。*&qPtr是先进行取地址运算即&qPtr,即qPtr的地址,然后进行*运算,那么*&qPtr就是qPtr本身,即q的地址。因此,&*qPtr和*&qPtr是等价的。 图2-10 利用指针变量进行存取操作的程序运行结果   注意:指针变量也是一种数据类型,用来存放变量的地址。指针变量的类型应和所指向的变量的类型一致。例如,整型指针只能指向整型变量,不能指向浮点型变量。指针变量只能用来存放地址,不能将一个整型值赋给一个指针变量。一般所说的变量指针指的是变量的地址。指针是指的地址,指针变量就是存储地址的变量。 2.2.3 指针与数组   指针可以与变量结合,也可以与数组结合使用。指针数组和数组指针是两个截然不同的概念,指针数组是一种数组,该数组存放的是一组变量的地址。数组指针是一个指针,表示该指针是指向数组的指针。   1. 指向数组元素的指针   指针可以指向变量,也可以指向数组及数组中的元素。   例如,定义一个整型数组和一个指针变量,语句如下。 int a[5]={10,20,30,40,50}; /*定义数组并赋值*/ int *aPtr; /*定义指针变量*/   这里的a是一个数组,它包含5个整型数据。变量名a就是数组a的首地址,它与&a[0]等价。如果令aPtr=&a[0]或者aPtr=a,则aPtr也指向了数组a的首地址,如图2-11所示。   也可以在定义指针变量时直接赋值,以下语句是等价的。 int *aPtr=&a[0]; /*定义并同时初始化指针变量,将数组a的首地址赋给aPtr*/ int *aPtr; /*先定义指针变量aPtr*/ aPtr =&a[0]; /*然后初始化,将数组a的首地址赋给aPtr*/   与整型、浮点型数据一样,指针也可以进行算术运算,但含义却不同。当一个指针加(或减)1并不是指针值增加(或减少)1,而是使指针指向的位置向后(或向前)移动了一个位置,即加上(或减去)该整数与指针指向对象的大小的乘积。例如,对于aPtr+=3,如果一个整数占用4B,则相加后aPtr=2000+4×3=2012(这里假设指针的初值是2000)。同样指针也可以进行自增(++)运算和自减(--)运算。   也可以用一个指针变量减去另一个指针变量。例如,指向数组元素的指针aPtr的地址是2008,另一个指向数组元素的指针bPtr的地址是2000,则a=aPtr-bPtr的运算结果就是把从aPtr到bPtr之间的元素个数赋给a,元素个数为(2008-2000)/4=2(假设整数占用4B)。   也可以通过指针来引用数组元素。例如: *(aPtr+2);   如果aPtr是指向a[0],即数组a的首地址,则aPtr+2就是数组a[2]的地址,*(aPtr+2)就是30。   注意:指向数组的指针可以进行自增或自减运算,但是数组名则不能进行自增或自减运算,这是因为数组名是一个常量指针,常量值是不能改变的。   【例2-4】用指针引用数组元素并打印输出。   【分析】主要考查指针与数组结合的运算,有指针对数组的引用及指针的加、减运算。 #include #include void main() { int a[5]={10,20,30,40,50}; /*定义数组并赋值*/ int *aPtr,i; /*指针变量声明*/ aPtr=&a[0]; /*指针变量指向变量a*/ for(i=0;i<5;i++) /*通过数组下标引用元素的方式输出数组元素*/ printf("a[%d]=%d\n",i,a[i]); for(i=0;i<5;i++) /*通过数组名引用元素的方式输出数组元素*/ printf("*(a+%d)=%d\n",i,*(a+i)); for(i=0;i<5;i++) /*通过指针变量下标引用元素的方式输出数组元素*/ printf("aPtr[%d]=%d\n",i,aPtr[i]); for(aPtr=a,i=0;aPtr #include void main() { /*定义指针数组*/ char *book[4]={"C Programming Language","Python Language"," Data Structure ","Machine Learning"}; int n=4; /*指针数组元素的个数*/ int i; char *aPtr; /*第1种方法输出:通过数组名输出字符串*/ printf("第1种方法输出:通过指针数组的数组名输出字符串:\n"); for(i=0;i #include void main() { int a[3][4]={{1,2,3,4},{5,6,7,8},{9,10,11,12}}; /*定义数组a并赋值*/ int (*p)[4]; /*声明数组指针变量p*/ int row,col; /*定义变量*/ p=a; /*指针p指向数组元素为4的数组*/ /*打印输出数组指针p指向的数组元素值*/ for(row=0;row<3;row++) { for(col=0;col<4;col++) printf("a[%d,%d]=%-4d",row,col,*(*(p+row)+col)); /*通过数组指针p逐个输出数组元素值*/ printf("\n"); } /*通过改变指针p指向a的行地址输出数组a中每个元素的地址*/ for(p=a,row=0;p #include int *FindAddress(int (*ptr)[4],int n); /*声明查找成绩地址函数*/ void Display(int a[][4],int n,int *p); /*声明输出成绩函数*/ void main() { int row,n=4; int *p; /*定义指针变量*/ int score[3][4]={{83,78,79,88},{71,88,92,63},{99,92,87,80}};/*定义数组并赋值*/ printf("请输入学生编号(1或2或3).输入0退出程序.\n"); scanf("%d",&row); /*输入要输出学生成绩的编号*/ while(row) /*若学生编号不为0*/ { if(row==1||row==2||row==3) { printf("第%d个学生的成绩4门课的成绩是:\n",row); p=FindAddress(score,row-1); /*调用指针函数*/ Display(score,n,p); /*调用输出成绩函数*/ printf("请输入学生编号(1或2或3).输入0退出程序.\n"); scanf("%d",&row); } else { printf("输入不合法,请重新输入(1或2或3),输入0退出程序.\n"); scanf("%d",&row); } } system("pause"); } int *FindAddress(int (*ptrScore)[4],int n) /*查找某条学生成绩记录地址函数.通过传递的行地址找到要查找学生成绩的地址,并返回行地址*/ { int *ptr; ptr=*(ptrScore+n); /*修改行地址,即找到学生的第一门课成绩的地址*/ return ptr; /*返回学生第一门课成绩的地址*/ } void Display(int a[][4],int n,int *p) /*输出学生成绩的实现函数.利用传递过来的指针输出每门课的成绩*/ { int col; for(col=0;col #include #define N 10 int Ascending(int a,int b); /*声明升序排列函数*/ int Descending(int a,int b); /*声明降序排列函数*/ void swap(int *,int *); /*声明交换数据函数*/ void SelectSort(int a[],int n,int (*compare)(int,int));/*选择排序,函数指针作为参数调用*/ void Display(int a[],int n); /*声明输出数组元素函数*/ void main() { int a[N]={22,55,12,7,19,65,81,3,30,52}; int flag; while(1) { printf("1:从小到大排序.\n2:从大到小排序.\n0:结束!\n"); printf("请输入:"); scanf("%d",&flag); switch(flag) { case 1: printf("排序前的数据为:"); Display(a,N); SelectSort(a,N,Ascending); /*从小到大排序,将函数作为参数传递*/ printf("从小到大排列后的元素序列为:"); Display(a,N); break; case 2: printf("排序前的数据为:"); Display(a,N); SelectSort(a,N,Descending); /*从大到小排序,将函数作为参数传递*/ printf("从大到小排列后的元素序列为:"); Display(a,N); break; case 0: printf("程序结束!\n"); break; default: printf("输入数据不合法,请重新输入.\n"); break; } } system("pause"); } /*选择排序,将函数作为参数传递,判断是从小到大还是从大到小排序*/ void SelectSort(int a[],int n,int(*compare)(int,int)) { int i,j,k; for(i=0;ib) /*若a>b*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ } /*判断相邻数据大小,如果前者小,降序排列需要交换*/ int Descending(int a,int b) { if(a /*包含输入输出函数*/ int GCD(int m,int n); /*求两个整数的最大公约数的函数声明*/ void main() /*主函数*/ { int a,b,v; /*定义变量*/ printf("请输入两个整数:"); /*输出提示信息*/ scanf("%d,%d",&a,&b); /*键盘输入两个数*/ v=GCD(a,b); /*调用求两个数中的较大者的函数*/ printf("%d和%d的最大公约数为:%d\n",a,b,v); /*输出提示信息*/ } int GCD(int m,int n) /*求两个整数的最大公约数,并返回公约数*/ { int r; /*定义变量*/ r=m; /*将参数m赋值给r*/ do { m=n; /*赋值*/ n=r; r=m%n; /*r是m除以n的模*/ }while(r); return n; /*返回最大公约数n*/ }   程序的输出结果如图2-21所示。 图2-21 求两个整数的最大公约数运行结果   假设输入两个数15和25,在主函数中,将15和25分别赋值给实际参数a和b,通过语句v=GCD(a,b)调用实现函数GCD(int m,int n),也就是所谓的被调用函数,将15和25分别传递给被调用函数的形式参数m和n。然后求m和n的最大公约数,通过语句return n;将最大公约数5返回给主函数,即被调用函数,因此输出结果为5。   上述函数参数传递属于参数的单向传递,即a和b可以把值分别传递给m和n,而不可以把m和n传递给a和b。在传值调用中,实际参数和形式参数分别占用不同的内存单元,形式参数是实际参数的一个副本,实际参数和形式参数的值的改变都不会相互受到影响,如图2-22所示。这就像有一张身份证原件,它的复印件就是个副本,复印件的丢失不会影响到身份证原件的存在,身份证原件的丢失也不会影响到复印件的存在。   在调用函数时,形式参数被分配存储单元,并把15和25传递给形式参数,在函数调用结束,形式参数被分配的存储单元被释放,形式参数不复存在,而主函数中的实际参数仍然存在,并且其值不会受到影响。在被调用函数中,如果改变形式参数的值,假设把m和n的值分别改变为20和35,a和b的值不会改变,如图2-23所示。 图2-22 参数传递过程 图2-23 形式参数改变后的情况 2.3.2 传地址调用   C语言通过指针(地址)实现传地址调用。在函数调用过程中,如果需要在被调用函数中修改参数值,则需要把实际参数的地址传递给形式参数,通过修改该地址的内容改变形式参数的值,以达到修改调用函数中实际参数的目的。   【例2-10】编写一个求两个整数较大者和较小者的函数,要求用传地址方式实现。   【分析】通过传地址调用的方式,把两个实际参数传递给形式参数。在被调用函数中,先比较两个形式参数值的大小,如果前者小于后者,则交换两个参数值,其中,前者为大,后者为小。传地址调用时,在调用函数和被调用函数中,对参数的操作其实都是在对同一块内存操作,实际参数和形式参数共用同一块内存。 #include #include void Swap(int *x,int *y); /*函数声明*/ void main() { int a,b; printf("请输入两个整数:\n"); scanf("%d,%d",&a,&b); if(a #include #define N 10 /*结构体类型及变量定义、初始化*/ struct student { char *no; char *name; char sex; int age; float score; }stu[3]={{"19001","Zhu Tong",'m',22,90.0}, {"19002","Li Hua",'f',21,82.0}, {"19003","Yang Yang",'m',22,95.0}}; void main() { struct student *p; /*定义结构体指针*/ printf("学生基本情况表:\n"); printf("学号 姓名 性别 年龄 成绩\n");/*输出表头*/ for(p=stu;pno,p->name,p->sex,p->age,p->score); system("pause"); }   程序运行结果如图2-28所示。   首先定义了一个指向结构体的指针变量p,在循环体中,指针指向结构体数组p=stu,即指针指向了结构体变量的起始地址。通过p->no、p->name等访问各个成员。如果p+1,表示数组中第2个元素stu[1]的起始地址,p+2表示数组中的第3个元素地址,如图2-29所示。 图2-28 通过结构体指针输出学生信息 图2-29 指向结构体数组的指针在内存的情况 2.4.3 用typedef定义数据类型   通常情况下,在定义结构体类型时,使用关键字typedef为新的数据类型起一个好记的名字。typedef是C语言中的关键字,它的主要作用是为类型重新命名,一般形式如下。 typedef 类型名1 类型名2   其中,类型名1是已经存在的类型,如int、float、char、long等;也可以是结构体类型,如struct student。类型名2是重新起的名字,命名规则与变量名的命名规则类似,必须是一个合法的标识符。   1. 使用typedef为基本数据类型重新命名   例如: typedef int COUNT; /*将int型重新命名为COUNT*/ typedef float SCORE; /*将float型重新命名为SCORE*/   经过以上重新定义变量,COUNT就代表了int,SCORE就表示了float。这样,如下两条语句等价。 int a,b,c; /*定义int型变量a、b、c*/ COUNT a,b,c; /*定义COUNT型变量a、b、c*/   2. 使用typedef为数组类型重新命名   例如,以下代码是将NUM定义为数组类型: typedef int NUM[20]; /*NUM被定义为新的数组类型*/   NUM被定义为数组类型,该数组的长度为20,类型为int。可以使用NUM定义int型数组,代码如下。 NUM a; /*使用NUM定义int型数组*/   a表示长度为20的int型数组,它与如下代码等价。 int a[20]; /*使用int定义数组*/   3. 使用typedef为指针类型重新命名   使用typedef为指针类型变量重新命名与重新命名数组类型的方法是类似的。例如: typedef float *POINTER; /*POINT被定义为指针类型*/   POINTER表示指向float类型的指针类型。如果要定义一个float类型的指针变量p,代码如下。 POINTER p; /*使用POINTER定义指针变量*/   p被定义为指向float类型的指针变量。同样,也可以使用typedef重新为指向函数的指针类型命名,例如,定义一个函数指针类型,代码如下。 typedef int (*PTR)(int,int); /*PTR被定义为函数指针类型*/   PTR被定义为函数指针类型,PTR是指向返回值为int且有两个int型参数的函数指针。以下语句使用PTR定义变量。 PTR pm; /*使用PTR定义一个函数指针变量pm*/   pm被定义为一个函数指针变量。   4. 使用typedef为用户自定义数据类型重新命名   用户自己定义的数据类型主要包括结构体、联合体、枚举类型,最为常用的是为结构体类型重新命名,联合体和枚举类型的命名方法与结构体的重新命名方法类似。例如,将一个结构体命名为DATE,代码如下。 typedef struct /*为结构体类型重新命名*/ { int year; /*年*/ int month; /*月*/ int day; /*日*/ }DATE;   从类型名DATE可以很容易看出,DATE是表示日期的类型。上面的类型重新定义是在定义结构体类型的同时为结构体命名;也可以先定义结构体类型,然后重新为结构体命名,代码如下。 struct date /*定义结构体类型*/ { int year; int month; int day; }; typedef date DATE; /*为结构体类型重新命名*/   以上两段代码是等价的。注意,date和DATE是两个不同的名字,C语言是区分大小写的。接下来,就可以使用DATE定义变量了,代码如下。 DATE d; /*定义变量d*/   上面的变量定义与如下变量定义等价。 struct date d; 2.5 小结   本章主要介绍了C语言的重点和难点部分,目的是为今后学习数据结构扫清障碍。首先围绕着C语言中的重点和难点——递归、指针、参数传递、结构体,结合典型案例进行了详细分析、讲解。   递归是C语言及算法设计中常常使用的技术,递归可以把复杂的问题变成与原问题类似且规模小的问题加以解决,使用递归使程序的结构很清晰,更具有层次性,写出的程序简洁易懂。使用递归只需要少量的程序就可以描述解决问题需要的重复计算过程,大大减少了程序的代码量。任何使用递归解决的问题都能使用迭代的方法解决。   指针是C语言的精髓所在。指针不仅可以与变量结合起来使用,还可以与数组、函数相结合,使用指针能很方便地操作字符串、动态分配内存。指针使用不当,也常常会出现一些致命错误,这种错误十分隐蔽,难以发现,这就需要读者能熟练使用指针操作,以避免或减少错误的发生,并能掌握程序调试技巧,以快速找出原因并解决问题。   在C语言中,函数的参数传递有两种:传值调用和传地址调用。其中,前者是一种单向值传递方式,实际参数和形式参数分别占用不同的内存空间。后者是一种双向的值传递方式,实际参数和形式参数占用同一块内存单元。   结构体属于用户自己定义的类型,它常常用于非数值程序设计中,特别是在今后学习数据结构的过程中,链表、栈、队列、树及图等都会用到结构体类型。    第二篇 线性数据结构                         第3章 线性表            线性表是一种最简单的线性结构。线性结构的特点是在非空的有限集合中存在唯一的一个被称为“第一个”的数据元素,存在唯一的一个被称为“最后一个”的数据元素。第一个元素没有直接前驱元素,最后一个元素没有直接后继元素,其他元素都有唯一的前驱元素和唯一的后继元素。线性表有两种存储结构,即顺序存储结构和链式存储结构。   本章重点和难点: * 顺序表的基本操作实现。 * 单链表与双向链表的存储表示与基本操作实现。 3.1 线性表的定义及抽象数据类型   线性表(Linear_List)是最简单且最常用的一种线性结构。 3.1.1 线性表的逻辑结构   线性表是由n个类型相同的数据元素组成的有限序列,记为(a1,a2,…,ai-1,ai,ai+1,…,an)。其中,这里的数据元素可以是原子类型,也可以是结构类型。线性表的数据元素存在着序偶关系,即数据元素之间具有一定的次序。在线性表中,数据元素ai-1在ai的前面,ai又在ai+1的前面,可以把ai-1称为ai的直接前驱元素,ai称为ai+1的直接前驱元素。ai称为ai-1的直接后继元素,ai+1称为ai的直接后继元素。   线性表的逻辑结构如图3-1所示。 图3-1 线性表的逻辑结构   英文单词就可看作是简单的线性表,例如China、Science、Structure。其中每一个英文字母就是一个数据元素,每个数据元素之间存在着唯一的顺序关系。如“China”中字母C后面是字母h,字母h后面是字母i。   在较为复杂的线性表中,一个数据元素可以由若干个数据项组成,在如表3-1所示的一所学校的教职工情况表中,一个数据元素由姓名、性别、出生年月、籍贯、学历、职称及任职时间7个数据项组成。数据元素也称为记录。   知识点:在线性表中,除了第一个元素a1,每个元素有且仅有一个直接前驱元素;除了最后一个元素an,每个元素有且只有一个直接后继元素。    表3-1 教职工情况表 姓 名 性 别 出 生 年 月 籍 贯 学 历 职 称 任 职 时 间 王 欢 女 1958年10月 河南 本科 教授 2000年10月 周启泰 男 1969年5月 陕西 研究生 副教授 2002年10月 刘 娜 女 1978年12月 四川 研究生 讲师 2006年11月 … … … … … … … 3.1.2 线性表的抽象数据类型   线性表的抽象数据类型包括数据对象集合和基本操作集合。   1. 数据对象集合   线性表的数据对象集合为{a1,a2,…,an},元素类型为DataType。   数据元素之间的关系是一对一的关系。除了第一个元素a1外,每个元素有且只有一个直接前驱元素,除了最后一个元素an外,每个元素有且只有一个直接后继元素。   2. 基本操作集合   (1)InitList(&L):初始化操作,建立一个空的线性表L。这就像是在日常生活中,一所院校为了方便管理,建立一个教职工基本情况表,准备登记教职工信息。   (2)ListEmpty(L):若线性表L为空,返回1,否则返回0。这就像是刚刚建立了教职工基本情况表,还没有登记教职工信息。   (3)GetElem(L,i,&e):返回线性表L的第i个位置元素值给e。这就像在教职工基本情况表中,根据给定序号查找某个教师信息。   (4)LocateElem(L,e):在线性表L中查找与给定值e相等的元素,如果查找成功返回该元素在表中的序号表示成功,否则返回0表示失败。这就像在教职工基本情况表中,根据给定的姓名查找教师信息。   (5)InsertList(&L,i,e):在线性表L中的第i个位置插入新元素e。这就类似于经过招聘考试,引进了一名教师,这个教师信息登记到教职工基本情况表中。   (6)DeleteList(&L,i,&e):删除线性表L中的第i个位置元素,并用e返回其值。这就像某个教职工到了退休年龄或者调入其他学校,需要将该教职工从教职工基本情况表中删除。   (7)ListLength(L):返回线性表L的元素个数。这就像查看教职工基本情况表中有多少个教职工。   (8)ClearList(&L):将线性表L清空。这就像学校被撤销,不需要再保留教职工基本信息,将这些教职工信息全部清空。 3.2 线性表的顺序表示与实现   在了解了线性表的基本概念和逻辑结构之后,接下来就需要将线性表的逻辑结构转换为计算机能识别的存储结构,以便实现线性表的操作。线性表的存储结构主要有顺序存储结构和链式存储结构两种。本节主要介绍线性表的顺序存储结构及操作实现。 3.2.1 线性表的顺序存储结构   线性表的顺序存储指的是将线性表中的各个元素依次存放在一组地址连续的存储单元中。   假设线性表的每个元素需占用m个存储单元,并以所占的第一个单元的存储地址作为数据元素的存储位置。则线性表中第i+1个元素的存储位置LOC(ai+1)和第i个元素的存储位置LOC(ai)之间满足关系LOC(ai+1)=LOC(ai)+m。   线性表中第i个元素的存储位置与第一个元素a1的存储位置满足以下关系。   LOC(ai)=LOC(a1)+(i-1)×m   其中,第一个元素的位置LOC(a1)称为起始地址或基地址。   线性表的这种机内表示称为线性表的顺序存储结构或顺序映像,通常将这种方法存储的线性表称为顺序表。顺序表逻辑上相邻的元素在物理上也是相邻的。每一个数据元素的存储位置都和线性表的起始位置相差一个和数据元素在线性表中的位序成正比的常数(见图3-2)。只要确定了第一个元素的起始位置,线性表中的任一元素都可以随机存取,因此,线性表的顺序存储结构是一种随机存取的存储结构。 图3-2 线性表存储结构   由于C语言的数组具有随机存取特点,因此可采用数组来描述顺序表。顺序表的存储结构描述如下。 #define LISTSIZE 100 /*宏定义LISTSIZE表示100*/ typedef struct /*定义结构体SeqList*/ { DataType list[LISTSIZE]; /*定义线性表*/ int length; /*定义变量length*/ }SeqList;   其中,DataType表示数据元素类型,list用于存储线性表中的数据元素,length用来表示线性表中数据元素的个数,SeqList是结构体类型名。   如果要定义一个顺序表,代码如下。 SeqList L;   如果要定义一个指向顺序表的指针,代码如下。 SeqList *L; 3.2.2 顺序表的基本运算   在顺序存储结构中,线性表的基本运算如下(以下算法的实现保存在文件SeqList.h中)。   (1)初始化线性表。 void InitList(SeqList *L) /*初始化线性表*/ { L->length=0; /*把线性表的长度置为0*/ }   (2)判断线性表是否为空。 int ListEmpty(SeqList L) /*判断线性表是否为空,线性表为空返回1,否则返回0*/ { if(L.length==0) /*若线性表的长度为0*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)按序号查找。先判断序号是否合法,如果合法,把对应位置的元素赋给e,并返回1表示查找成功;否则返回-1表示查找失败。按序号查找的算法实现如下。 int GetElem(SeqList L,int i,DataType *e) /*查找线性表中第i个元素。查找成功将该值返回给e,并返回1表示成功;否则返回-1表示失败*/ { if(i<1||i>L.length) /*在查找第i个元素之前,判断该序号是否合法*/ return -1; /*返回-1*/ *e=L.list[i-1]; /*将第i个元素的值赋值给e*/ return 1; /*返回1*/ }   (4)按内容查找。从线性表中的第一个元素开始,依次与e比较,如果相等,返回该序号表示成功;否则返回0表示查找失败。按内容查找的算法实现如下。 int LocateElem(SeqList L,DataType e) /*查找线性表中元素值为e的元素*/ { int i; for(i=0;iL->length+1) /*在插入元素前,判断插入位置是否合法*/ { printf("插入位置i不合法!\n"); /*输出错误提示信息*/ return -1; /*返回-1*/ } else if(L->length>=LISTSIZE) /*在插入元素前,判断顺序表是否已经满*/ { printf("顺序表已满,不能插入元素.\n"); /*输出错误提示信息*/ return 0; /*返回0*/ } else { for(j=L->length;j>=i;j--) /*将第i个位置以后的元素依次后移*/ L->list[j]=L->list[j-1]; L->list[i-1]=e; /*插入元素到第i个位置*/ L->length=L->length+1; /*将顺序表长增1*/ return 1; /*返回1*/ } }   插入元素的位置i的合法范围应该是1≤i≤L→length+1。当i=1时,插入位置是在第一个元素之前,对应C语言数组中的第0个元素;当i=L→length+1时,插入位置是最后一个元素之后,对应C语言数组中的最后一个元素之后的位置。当插入位置是i=L→length+1时,不需要移动元素;当插入位置是i=0时,则需要移动所有元素。   (6)删除第i个元素。删除第i个元素之后,线性表{a1, a2, …, ai-1, ai, ai+1, …, an}变为{a1, a2, …, ai-1, ai+1, …, an},线性表的长度由n变成n-1。   为了删除第i个元素,需要将第i+1后面的元素依次向前移动一个位置,将前面的元素覆盖。移动元素时要先将第i+1个元素移动到第i个位置,再将第i+2个元素移动到第i+1个位置,以此类推,直到最后一个元素移动到倒数第二个位置。最后将顺序表的长度减1。   例如,要删除线性表{9, 12, 6, 15, 28, 20, 10, 4, 22}的第4个元素,需要依次将序号为5、6、7、8、9的元素向前移动一个位置,并将表长减1,如图3-4所示。 图3-4 删除元素15的过程   在进行删除操作时,先判断顺序表是否为空,若不空,接着判断序号是否合法,若不空且合法,则将要删除的元素赋给e,并把该元素删除,将表长减1。删除第i个元素的算法实现如下。 int DeleteList(SeqList *L,int i,DataType *e) /*删除第i个元素的算法实现*/ { int j; if(L->length<=0) /*若顺序表的长度小于或等于0*/ { printf("顺序表已空不能进行删除!\n"); /*表示不能进行删除操作,输出提示信息*/ return 0; /*返回0*/ } else if(i<1||i>L->length) /*若删除位置不合法*/ { printf("删除位置不合适!\n"); /*则输出提示信息*/ return -1; /*返回-1*/ } else /*否则*/ { *e=L->list[i-1]; /*将要删除的元素赋给e*/ for(j=i;j<=L->length-1;j++) L->list[j-1]=L->list[j]; L->length=L->length-1; /*将表长减1*/ return 1; /*返回1*/ } }   删除元素的位置i的合法范围应该是1≤i≤L→length。当i=1时,表示要删除第一个元素,对应C语言数组中的第0个元素;当i=L→length时,要删除的是最后一个元素。   (7)求线性表的长度,代码如下。 int ListLength(SeqList L) /*求线性表的长度实现函数*/ { return L.length; /*返回线性表的长度*/ }   (8)清空顺序表,代码如下。 void ClearList(SeqList *L) /*清空顺序表实现函数*/ { L->length=0; /*清空顺序表*/ } 3.2.3 顺序表的实现算法分析   在顺序表的实现算法中,除了按内容查找、插入和删除操作外,算法的时间复杂度均为O(1)。   在按内容查找的算法中,若要查找的是第一个元素,则仅需要进行一次比较;若要查找的是最后一个元素,则需要比较n次才能找到该元素(设线性表的长度为n)。   设pi表示在第i个位置上找到与e相等的元素的概率,若在任何位置上找到元素的概率相等,即pi=1/n。则查找元素需要的平均比较次数为: ===   因此,按内容查找的平均时间复杂度为O(n)。   在顺序表中插入元素时,主要时间耗费在元素的移动上。如果要将元素插入到第一个位置,则需要移动元素的次数为n次;如果要在最后一个元素之前插入,则仅需把最后一个元素向后移动即可;如果要在最后一个元素之后插入,即第n+1个位置,则不需要移动元素。设pi表示在第i个位置上插入元素的概率,假设在任何位置上找到元素的概率相等,即pi=1/(n+1)。则在顺序表的第i个位置插入元素时,需要移动元素的平均次数为: ===   因此,插入操作的平均时间复杂度为O(n)。   在顺序表的删除算法中,时间主要耗费仍在元素的移动上。如果要删除的是第一个元素,则需要移动元素次数为n-1次;如果要删除的是最后一个元素,则需要移动0次。设pi表示删除第i个位置上的元素的概率,假设在任何位置上找到元素的概率相等,即pi=1/n。则在顺序表中删除第i个元素时,需要移动元素的平均次数为:   ===   因此,删除操作的平均时间复杂度为O(n)。 3.2.4 顺序表的优缺点   线性表的顺序存储结构的优缺点如下。   1. 优点   (1)无须为表示表中元素之间的关系而增加额外的存储空间。   (2)可以快速地存取表中任一位置的元素。   2. 缺点   (1)插入和删除操作需要移动大量的元素。   (2)使用前须事先分配好存储空间,当线性表长度变化较大时,难以确定存储空间的容量。分配空间过大会造成存储空间的巨大浪费;分配的空间过小,难以适应问题的需要。 3.2.5 顺序表应用举例   在掌握了顺序表的基本操作之后,通过几个具体实例来加强对顺序表知识点的掌握。   【例3-1】假设线性表LA和LB分别表示两个集合A和B,利用线性表的基本运算实现集合运算:A=A-B,即如果在顺序表LA中出现的元素,在顺序表LB中也出现,则删除A中该元素。   【分析】只有依次从线性表LB中取出每个数据元素,并依次在线性表LA中查找该元素,如果LA中也存在该元素,则将该元素从LA中删除。其实这是求两个表的差集,即A-B。依次检查顺序表LB中的每一元素,如果在顺序表LA中也出现,则在A中删除该元素。核心代码如下。 void DelElem(SeqList *LA,SeqList LB) /*从LA中删除LB也出现的元素*/ { int i,flag,pos; DataType e; for(i=0;i<=LB.length;i++) { flag=GetElem(LB,i,&e); /*依次把LB中每个元素取出给e*/ if(flag==1) { pos=LocateElem(*LA,e); /*在LA中查找和LB中取出的元素e相等的元素*/ if(pos>0) DeleteList(LA,pos,&e); /*如果找到该元素,将其从LA中删除*/ } } }   程序运行结果如图3-5所示。 图3-5 集合A-B运算的程序运行结果   说明:在设计程序时需要用到头文件“SeqList.h”,而在顺序表的类型定义中包含DataType数据类型和顺序表长度,所以在包含#include"SeqList.h"之前首先进行宏定义。宏定义、类型定义和包含文件语句的次序如下。 #define LISTSIZE 100 typedef int DataType; #include"SeqList.h"   【例3-2】编写一个算法,把一个顺序表分拆成两个部分,使顺序表中小于或等于0的元素位于左端,大于0的元素位于右端。要求不占用额外的存储空间。例如,顺序表(-21,8,-9,25,-31,3, -2, -36)经过分拆调整后变为(-21,-36,-9,-2,-31,3,25,8)。   【分析】设置两个指示器i和j,分别扫描顺序表中的元素,i和j分别从顺序表的左端和右端开始扫描。如果i遇到小于或等于0的元素,略过不处理,继续向前扫描;如果遇到大于0的元素,暂停扫描。如果j遇到大于0的元素,略过不处理,继续向前扫描;如果遇到小于或等于0的元素,暂停扫描。如果i和j都停下来,则交换i和j指向的元素。重复执行直到i≥j为止。   算法描述如下。 void SplitSeqList(SeqList *L) /*将顺序表L分成两个部分:左边是小于或等于0的元素,右边是大于0的元素*/ { int i,j; /*定义两个指示器i和j*/ DataType e; i=0,j=(*L).length-1; /*指示器i和j分别指示顺序表的左端和右端元素*/ while(ilist[i]<=0) /*i遇到小于或等于0的元素*/ i++; /*略过*/ while(L->list[j]>0) /*j遇到大于0的元素*/ j--; /*略过*/ if(ilist[i]; L->list[i]=L->list[j]; L->list[j]=e; } } }   程序运行结果如图3-6所示。 图3-6 程序运行结果   【考研真题】设将n(n>1)个整数存放到一维数组R中,试设计一个在时间和空间两方面都尽可能高效的算法,将R中保存的序列循环左移p(00 && pnext=NULL; /*将单链表的头结点指针域置为空*/ }   (2)判断单链表是否为空。若单链表为空,返回1;否则返回0。算法实现如下。 int ListEmpty(LinkList head) /*判断单链表是否为空*/ { if(head->next==NULL) /*如果单链表头结点的指针域为空*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)按序号查找操作。从单链表的头指针head出发,利用结点的指针域依次扫描链表的结点,并进行计数,直到计数为i,就找到了第i个结点。如果查找成功,返回该结点的指针,否则返回NULL表示查找失败。按序号查找的算法实现如下。 ListNode *Get(LinkList head,int i) /*按序号查找单链表中第i个结点。查找成功返回该结点的指针表示成功;否则返回NULL表示失败*/ { ListNode *p; /*定义指向单链表的指针*/ int j; /*定义计数器*/ if(ListEmpty(head)) /*如果链表为空*/ return NULL; /*返回NULL*/ if(i<1) /*如果序号不合法*/ return NULL; /*则返回NULL*/ j=0; /*将计数器初始化为0*/ p=head; /*head指针赋值给p*/ while(p->next!=NULL&&jnext; /*则令p指向下一个结点继续查找*/ j++; } if(j==i) /*找到第i个结点*/ return p; /*返回指针p*/ else /*否则*/ return NULL; /*返回NULL*/ }   查找元素时,要注意判断条件p→next!=NULL,保证p的下一个结点不为空,如果没有这个条件,就无法保证执行循环体中的p=p→next语句。   (4)按内容查找,查找元素值为e的结点。从单链表中的头指针开始,依次与e比较,如果找到返回该元素结点的指针;否则返回NULL。查找元素值为e的结点的算法实现如下。 ListNode *LocateElem(LinkList head,DataType e) /*按内容查找单链表中元素值为e的元素,若查找成功则返回对应元素的结点指针,否则返回NULL表示失败*/ { ListNode *p; /*定义指向单链表的指针*/ p=head->next; /*指针p指向第一个结点*/ while(p) { if(p->data!=e) /*没有找到与e相等的元素*/ p=p->next; /*继续找下一个元素*/ else /*找到与e相等的元素*/ break; /*退出循环*/ } return p; /*返回元素值为e的结点指针*/ }   (5)定位操作。定位操作与按内容查找类似,只是返回的是该结点的序号。从单链表的头指针出发,依次访问每个结点,并将结点的值与e比较,如果相等,返回该序号表示成功;如果没有与e值相等的元素,返回0表示失败。定位操作的算法实现如下。 int LocatePos(LinkList head,DataType e) /*查找线性表中元素值为e的元素,查找成功将对应元素的序号返回,否则返回0表示失败*/ { ListNode *p; /*定义指向单链表的指针*/ int i; /*定义指示器变量*/ if(ListEmpty(head)) /*在查找第i个元素之前,判断链表是否为空*/ return 0; p=head->next; /*指针p指向第一个结点*/ i=1; /*将指示器置为1*/ while(p) { if(p->data==e) /*若找到与e相等的元素*/ return i; /*返回该序号*/ else /*否则*/ { p=p->next; /*令p指向下一个结点继续查找*/ i++; /*指示器加1*/ } } if(!p) /*如果没有找到与e相等的元素*/ return 0; /*返回0*/ }   (6)在第i个位置插入元素e。插入成功返回1,否则返回0;如果没有与e值相等的元素,返回0表示失败。   假设存储元素e的结点为p,要将p指向的结点插入pre和pre→next之间,根本不需要移动其他结点,只需要让p指向结点的指针和pre指向结点的指针做一点改变即可。即先把*pre的直接后继结点变成*p的直接后继结点,然后把*p变成*pre的直接后继结点,如图3-13所示,代码如下。 p->next=pre->next; pre->next=p; 图3-13 在*pre结点之后插入新结点*p   注意:插入结点的两行代码不能颠倒顺序。如果先进行pre→next=p,后进行p→next=pre→next操作,则第一条代码就会覆盖pre→next的地址,pre→next的地址就变成了p的地址,执行p→next=pre→next就等于执行p→next=p,这样pre→next就与上级断开了链接,造成尴尬的局面,如图3-14所示。 图3-14 插入结点代码顺序颠倒后,*(pre→next)结点与上级断开链接   如果要在单链表的第i个位置插入一个新元素e,首先需要在链表中找到其直接前驱结点,即第i-1个结点,并由指针pre指向该结点,如图3-15所示。然后申请一个新结点空间,由p指向该结点,将值e赋值给p指向结点的数据域,最后修改*p和*pre结点的指针域,如图3-16所示。这样就完成了结点的插入操作。 图3-15 找到第i个结点的直接前驱结点 图3-16 将新结点插入第i个位置   在单链表的第i个位置插入新数据元素e的算法实现如下。 int InsertList(LinkList head,int i,DataType e) /*在单链表中第i个位置插入一个结点,结点的元素值为e。插入成功返回1,失败返回0*/ { ListNode *pre,*p; /*定义第i个元素的前驱结点指针pre,指针p指向新生成的结点*/ int j; /*定义计数器变量*/ pre=head; /*指针p指向头结点*/ j=0; /*将计数器置为0*/ while(pre->next!=NULL&&jnext; /*令pre指向下一个结点*/ j++; /*计数器加1*/ } if(j!=i-1) /*若不存在第i个结点的前驱结点,说明插入位置错误*/ { printf("插入位置错误!"); /*输出错误提示信息*/ return 0; /*返回0*/ } /*新生成一个结点,并将e赋值给该结点的数据域*/ if((p=(ListNode*)malloc(sizeof(ListNode)))==NULL) /*动态分配一个结点的内存空间*/ exit(-1); p->data=e; /*插入结点操作*/ p->next=pre->next; pre->next=p; return 1; /*返回1*/ }   (7)删除第i个结点。   假设p指向第i个结点,要将*p结点删除,只需要绕过它的直接前驱结点的指针,使其直接指向它的直接后继结点即可删除链表的第i个结点,如图3-17所示。 图3-17 删除*pre的直接后继结点   将单链表中第i个结点删除可分为3步:第一步找到第i个结点的直接前驱结点,即第i-1个结点,并用pre指向该结点,p指向其直接后继结点,即第i个结点,如图3-18所示;第二步将*p结点的数据域赋值给e;第三步删除第i个结点,即pre→next=p→next,并释放*p结点的内存空间。删除过程如图3-19所示。 图3-18 找到第i-1个结点和第i个结点 图3-19 删除第i个结点   删除第i个结点的算法实现如下。 int DeleteList(LinkList head,int i,DataType *e) /*删除单链表中的第i个位置的结点。删除成功返回1,失败返回0*/ { ListNode *pre,*p; /*定义第i个元素的前驱结点指针pre和指向新结点的指针p*/ int j; /*定义计数器变量*/ pre=head; /*指针p指向头结点*/ j=0; /*将计数器置为0*/ while(pre->next!=NULL&&pre->next->next!=NULL&&jnext; /*指向下一个结点*/ j++; /*计数器加1*/ } if(j!=i-1) /*如果没找到要删除的结点位置,说明删除位置有误*/ { printf("删除位置有误"); /*输出错误提示信息*/ return 0; /*返回0*/ } /*指针p指向单链表中的第i个结点,并将该结点的数据域值赋值给e*/ p=pre->next; *e=p->data; /*将前驱结点的指针域指向要删除结点的下一个结点,也就是将p指向的结点与单链表断开*/ pre->next=p->next; /*令pre指向p的下一个结点*/ free(p); /*释放p指向的结点*/ return 1; /*返回1*/ }   注意:在查找第i-1个结点时,要注意不可遗漏判断条件pre→next→next!=NULL,确保第i个结点非空。如果没有此判断条件,而pre指针指向了单链表的最后一个结点,在执行循环后的p=pre→next,*e=p→data操作时,p指针指向的就是NULL指针域,会产生致命错误。   (8)求表长操作。求表长操作即返回单链表的元素个数,求单链表的表长算法实现代码如下。 int ListLength(LinkList head) /*求表长操作*/ { ListNode *p; /*定义指向新生成的结点指针变量*/ int count=0; /*定义计数器变量count并初始化*/ p=head; /*指针p指向头结点*/ while(p->next!=NULL) /*如果指针p没有到达链表末尾*/ { p=p->next; /*令p指向下一个结点*/ count++; /*计数器加1*/ } return count; /*返回元素个数*/ }   (9)销毁链表操作,实现代码如下。 void DestroyList(LinkList head) /*销毁链表*/ { ListNode *p,*q; /*定义指向新生成的结点的指针变量*/ p=head; /*指针p指向头结点*/ while(p!=NULL) /*如果链表不为空*/ { q=p; /*q指向待销毁的结点*/ p=p->next; /*p指向下一个结点*/ free(q); /*释放q指向的结点空间*/ } } 3.3.3 单链表存储结构与顺序存储结构的优缺点   下面简单对单链表存储结构和顺序存储结构进行对比。   1. 存储分配方式   顺序存储结构用一组连续的存储单元依次存储线性表的数据元素。单链表采用链式存储结构,用一组任意的存储单元存放线性表的数据元素。   2. 时间性能   采用顺序存储结构时,查找操作时间复杂度为O(1),插入和删除操作需要移动平均一半的数据元素,时间复杂度为O(n)。采用单链表存储结构时,查找操作时间复杂度为O(n),插入和删除操作不需要大量移动元素,时间复杂度仅为O(1)。   3. 空间性能   采用顺序存储结构时,需要预先分配存储空间,分配的空间过大会造成浪费,分配的空间过小不能满足问题需要。采用单链表存储结构时,可根据需要临时分配,不需要估计问题的规模大小,只要内存够就可以分配,还可以用于一些特殊情况,如一元多项式的表示。 3.3.4 单链表应用举例   【例3-3】已知两个单链表A和B,其中的元素都是非递减排列,编写算法将单链表A和B合并得到一个递减有序的单链表C(值相同的元素只保留一个),并要求利用原链表结点空间。   【分析】此题为单链表合并问题。利用头插法建立单链表,使先插入元素值小的结点在链表末尾,后插入元素值大的结点在链表表头。初始时,单链表C为空(插入的是C的第一个结点),将单链表A和B中较小的元素值结点插入C中;单链表C不为空时,比较C和将插入结点的元素值大小,值不同时插入到C中,值相同时,释放该结点。当A和B中有一个链表为空时,将剩下的结点依次插入C中。核心算法实现代码如下。 void MergeList(LinkList A,LinkList B,LinkList *C) /*将非递减排列的单链表A和B中的元素合并到C中,使C中的元素按递减排列,相同值的元素只保留一个*/ { ListNode *pa,*pb,*qa,*qb; /*定义指向单链表A,B的指针*/ pa=A->next; /*pa指向单链表A*/ pb=B->next; /*pb指向单链表B*/ free(B); /*释放单链表B的头结点*/ *C=A; /*初始化单链表C,利用单链表A的头结点作为C的头结点*/ (*C)->next=NULL; /*单链表C初始时为空*/ /*利用头插法将单链表A和B中的结点插入到单链表C中(先插入元素值较小的结点)*/ while(pa&&pb) /*单链表A和B均不空时*/ { if(pa->datadata) /*pa指向结点元素值较小时,将pa指向的结点插入到C中*/ { qa=pa; /*qa指向待插入结点*/ pa=pa->next; /*pa指向下一个结点*/ if((*C)->next==NULL) /*单链表C为空时,直接将结点插入到C中*/ { qa->next=(*C)->next; (*C)->next=qa; } else if((*C)->next->datadata) /*pa指向的结点元素值不同于已有结点元素值时,才插入结点*/ { qa->next=(*C)->next; (*C)->next=qa; } else /*否则,释放元素值相同的结点*/ free(qa); } else /*pb指向结点元素值较小,将pb指向的结点插入到C中*/ { qb=pb; /*qb指向待插入结点*/ pb=pb->next; /*pb指向下一个结点*/ if((*C)->next==NULL) /*单链表C为空时,直接将结点插入到C中*/ { qb->next=(*C)->next; (*C)->next=qb; } else if((*C)->next->datadata) /*pb指向的结点元素值不同于已有结点元素时,才将结点插入*/ { qb->next=(*C)->next; (*C)->next=qb; } else /*否则,释放元素值相同的结点*/ free(qb); } } while(pa) /*如果pb为空、pa不为空,则将pa指向的后继结点插入到C中*/ { qa=pa; /*qa指向待插入结点*/ pa=pa->next; /*pa指向下一个结点*/ if((*C)->next&&(*C)->next->datadata) { /*pa指向的结点元素值不同于已有结点元素时,才将结点插入*/ qa->next=(*C)->next; (*C)->next=qa; } else /*否则,释放元素值相同的结点*/ free(qa); } while(pb) /*如果pa为空、pb不为空,则将pb指向的后继结点插入到C中*/ { qb=pb; /*qb指向待插入结点*/ pb=pb->next; /*pb指向下一个结点*/ if((*C)->next&&(*C)->next->datadata) { /*pb指向的结点元素值不同于已有结点元素时,才将结点插入*/ qb->next=(*C)->next; (*C)->next=qb; } else /*否则,释放元素值相同的结点*/ free(qb); } }   程序的运行结果如图3-20所示。 图3-20 合并单链表的程序运行结果   在将两个单链表A和B的合并算法MergeList中,需要特别注意的是,不要遗漏单链表为空时的处理。当单链表为空时,将结点插入C中,代码如下。 if((*C)->next==NULL) /*单链表C为空时,直接将结点插入C中*/ { qa->next=(*C)->next; (*C)->next=qa; }   针对这个题目,经常会遗漏单链表为空的情况,以下代码遗漏了单链表为空的情况。 if((*C)->next&&(*C)->next->datanext) /*错误代码:遗漏了单链表为空的情况*/ { qb->next=(*C)->next; (*C)->next=qb; }   所以,对于初学者而言,写完算法后,一定要上机调试下算法的正确性。   【例3-4】利用单链表的基本运算,求两个集合的交集。   【分析】假设A和B是两个带头结点的单链表,分别表示两个给定的集合A和B,求C=A∩B。先将单链表A和B分别从小到大排序,然后依次比较两个单链表中的元素值大小,pa指向A中当前比较的结点,pb指向B中当前比较的结点,如果pa→datapb→data,则pb指向B中下一个结点;如果pa→data==pb→data,则将当前结点插入C中。 void Interction(LinkList A,LinkList B,LinkList *C) /*求A和B的交集*/ { ListNode *pa,*pb,*pc; /*定义3个结点指针*/ Sort(A); /*对数组A进行排序*/ printf("排序后A中的元素:\n"); /*输出提示信息*/ DispList(A); /*输出排序后A中的元素*/ Sort(B); /*对数组B进行排序*/ printf("排序后B中的元素:\n"); /*输出提示信息*/ DispList(B); /*输出排序后B中的元素*/ pa=A->next; /*pa指向A的第一个结点*/ pb=B->next; /*pb指向B的第一个结点*/ *C=(LinkList)malloc(sizeof(ListNode)); /*为指针*C指向的新链表动态分配内存空间*/ (*C)->next=NULL; while(pa&&pb) /*若pa和pb指向的结点都不为空*/ { if(pa->datadata) /*如果pa指向的结点元素值小于pb指向的结点元素值*/ pa=pa->next; /*则略过该结点*/ else if(pa->data>pb->data) /*如果pa指向的结点元素值大于pb指向的结点元素值*/ pb=pb->next; /*则略过该结点*/ else /*否则*/ { /*即pa->data==pb->data,则将当前结点插入C中.*/ pc=(ListNode*)malloc(sizeof(ListNode)); pc->data=pa->data; pc->next=(*C)->next; (*C)->next=pc; pa=pa->next; /*则pa指向A中下一个结点*/ pb=pb->next; /*则pb指向B中下一个结点*/ } } }   程序的运行结果如图3-21所示。 图3-21 求A和B交集的程序运行结果   【考研真题】假设一个带有表头结点的单链表,结点结构如下。   假设该链表只给出了头指针list,在不改变链表的前提下,请设计一个尽可能高效的算法,查找链表中倒数第k个位置上的结点(k为正整数)。若查找成功,算法输出该结点数据域的值,并返回1;否则返回0。要求如下。   (1)描述算法的基本设计思想。   (2)描述算法的详细实现步骤。   (3)根据设计思想和实现步骤,采用程序设计语言描述算法。   【分析】这是一道考研试题,主要考查对链表的掌握程度,这个题目比较灵活,利用一般的思维方式不容易实现。   (1)算法的基本思想:定义两个指针p和q,初始时均指向头结点的下一个结点。p指针沿着链表移动,当p指针移动到第k个结点时,q指针与p指针同步移动,当p指针移动到链表表尾结点时,q指针所指向的结点即为倒数第k个结点。   (2)算法的详细步骤如下。   ① 令count=0,p和q指向链表的第一个结点。   ② 若p为空,则转向○5执行。   ③ 若count等于k,则q指向下一个结点;否则令count++。   ④ 令p指向下一个结点,转向○2执行。   ⑤ 若count等于k,则查找成功,输出结点的数据域的值,并返回1;否则,查找失败,返回0。   (3)算法实现代码如下。 typedef struct LNode /*定义结点*/ { int data; struct Lnode *link; }*LinkList; /*定义结点指针变量*/ int SearchNode(LinkList list,int k) /*查找结点*/ { LinkList p,q; /*定义两个指针p和q*/ int count=0; /*定义计数器变量并赋初值为0*/ p=q=list->link; /*p和q指向链表的第一个结点*/ while(p!=NULL) { if(countlink; /*当p移到第k个结点后,q开始与p同步移动下一个结点*/ p=p->link; /*p移动到下一个结点*/ } if(countdata); /*输出倒数第k个结点的元素值*/ return 1; /*返回1*/ } } 3.4 循环单链表   循环单链表是首尾相连的单链表,是另一种形式的单链表。将单链表的最后一个结点的指针域由空指针改为指向头结点或第一个结点,整个链表就形成一个环,这样的单链表称为循环单链表。从表中任何一个结点出发均可找到表中其他结点。   与单链表类似,循环单链表也可分为带头结点结构和不带头结点结构两种。对于不带头结点的循环单链表,当表不为空时,最后一个结点的指针域指向头结点,如图3-22所示。对于带头结点的循环单链表,当表为空时,头结点的指针域指向头结点本身,如图3-23所示。 图3-22 循环单链表 图3-23 结点为空的循环单链表   循环单链表与单链表在结构、类型定义及实现方法上都是一样的,唯一的区别仅在于判断链表是否为空的条件上。判断单链表为空的条件是head→next==NULL,判断循环单链表为空的条件是head->next==head。   在单链表中,访问第一个结点的时间复杂度为O(1),而访问最后一个结点则需要将整个单链表扫描一遍,故时间复杂度为O(n)。对于循环单链表,只需设置一个尾指针(利用rear指向循环单链表的最后一个结点)而不设置头指针,就可以直接访问最后一个结点,时间复杂度为O(1)。访问第一个结点即rear->next->next,时间复杂度也为O(1),如图3-24所示。 图3-24 仅设置尾指针的循环单链表   在循环单链表中设置尾指针,还可以使有些操作变得简单,例如,要将如图3-25所示的两个循环单链表(尾指针分别为LA和LB)合并成一个链表,只需要将一个表的表尾和另一个表的表头连接即可,如图3-26所示。 图3-25 两个设置尾指针的循环单链表 图3-26 合并两个设置尾指针的循环单链表   将循环单链表合并为一个循环单链表只需要4步操作:①保存LA的头指针,即p=LA->next。②将LA的表尾与LB的第一个结点相连,即LA->next=LB->next->next;③释放LB的头结点,即free(LB->next);④将LB的表尾与LA的表头相连,即LB->next=p。   对于设置了头指针的两个循环单链表(头指针分别是head1和head2),要将其合并成一个循环单链表,需要先找到两个链表的最后一个结点,分别增加一个尾指针,分别使其指向最后一个结点。然后将第一个链表的尾指针与第二个链表的第一个结点连接起来,第二个链表的尾指针与第一个链表的第一个结点连接起来,就形成了一个循环链表。   合并两个循环单链表的算法实现如下。 LinkList Link(LinkList head1,LinkList head2) /*将两个链表head1和head2连接在一起形成一个循环链表*/ { ListNode *p,*q; /*定义两个指针变量p和q*/ p=head1; /*p指向第一个链表*/ while(p->next!=head1) /*指针p指向链表的最后一个结点*/ p=p->next; q=head2; while(q->next!=head2) /*指针q指向链表的最后一个结点*/ q=q->next; /*指向下一个结点*/ p->next=head2->next; /*将第一个链表的尾端连接到第二个链表的第一个结点*/ q->next=head1; /*将第二个链表的尾端连接到第一个链表的第一个结点*/ return head1; /*返回第一个链表的头指针*/ } 3.5 双向链表   在单链表和循环单链表中,每个结点只有一个指向其后继结点的指针域,只能根据指针域查找后继结点,要查找指针p指向结点的直接前驱结点,必须从p指针出发,顺着指针域把整个链表访问一遍,才能找到该结点,其时间复杂度是O(n)。因此,要访问某个结点的前驱结点,效率太低,为了便于操作,可将单链表设计成双向链表。 3.5.1 双向链表的存储结构   顾名思义,双向链表就是链表中的每个结点有两个指针域:一个指向直接前驱结点,另一个指向直接后继结点。双向链表的每个结点有data域、prior域和next域3个域。双向链表的结点结构如图3-27所示。 图3-27 双向链表的结点结构   其中,data域为数据域,存放数据元素;prior域为前驱结点指针域,指向直接前驱结点;next域为后继结点指针域,指向直接后继结点。   与单链表类似,也可以为双向链表增加一个头结点,这样使某些操作更加方便。双向链表也有循环结构,称为双向循环链表。带头结点的双向循环链表如图3-28所示。双向循环链表为空的情况如图3-29所示,判断带头结点的双向循环链表为空的条件是head->prior==head或head->next==head。 图3-28 带头结点的双向循环链表 图3-29 带头结点的空双向循环链表   在双向链表中,因为每个结点既有前驱结点的指针域又有后继结点的指针域,所以查找结点非常方便。对于带头结点的双向链表,如果链表为空,则有p=p->prior->next=p->next->prior。   双向链表的结点存储结构描述如下。 typedef struct Node /*定义双向链表的结点存储结构*/ { DataType data; /*数据域*/ struct Node *prior; /*指向前驱结点的指针域*/ struct Node *next; /*指向后继结点的指针域*/ }DListNode,*DLinkList; 3.5.2 双向链表的插入和删除操作   在双向链表中,有些操作如求链表的长度、查找链表的第i个结点等,仅涉及一个方向的指针,与单链表中的算法实现基本没什么区别。但是对于双向循环链表的插入和删除操作,因为涉及前驱结点和后继结点的指针,所以需要修改两个方向上的指针。   1. 在第i个位置插入元素值为e的结点   首先找到第i个结点,用p指向该结点;再申请一个新结点,由s指向该结点,将e放入数据域;然后修改p和s指向的结点的指针域,修改s的prior域,使其指向p的直接前驱结点,即s->prior=p->prior;修改p的直接前驱结点的next域,使其指向s指向的结点,即p->prior-> next=s;修改s的next域,使其指向p指向的结点,即s->next=p;修改p的prior域,使其指向s指向的结点,即p->prior=s。插入操作指针修改情况如图3-30所示。 图3-30 双向循环链表的插入结点操作过程   插入操作算法实现如下。 int InsertDList(DListLink head,int i,DataType e) /*双向链表插入操作的算法实现*/ { DListNode *p,*s; /*定义双向链表的结点指针p和s*/ int j; p=head->next; /*p指向链表的第一个结点*/ j=0; /*计数器初始化为0*/ while(p!=head&&jnext; /*则继续查找下一个结点*/ j++; /*计数器加1*/ } if(j!=i) /*若不存在第i个结点*/ { printf("插入位置不正确"); /*则输出错误提示信息*/ return 0; /*返回0*/ } s=(DListNode*)malloc(sizeof(DListNode));/*动态分配一个结点内存空间,由s指向该结点*/ if(!s) return -1; s->data=e; /*将参数e存入数据域*/ s->prior=p->prior; /*修改s的prior域,使其指向p的直接前驱结点*/ p->prior->next=s; /*修改p的前驱结点的next域,使其指向s指向的结点*/ s->next =p; /*修改s的next域,使其指向p指向的结点*/ p->prior=s; /*修改p的prior域,使其指向s指向的结点*/ return 1; /*插入成功,返回1*/ }   2. 删除第i个结点   首先找到第i个结点,用p指向该结点;然后修改p指向的结点的直接前驱结点和直接后继结点的指针域,从而将p与链表断开。将p指向的结点与链表断开需要两步:第一步,修改p的前驱结点的next域,使其指向p的直接后继结点,即p->prior->next=p->next;第二步,修改p的直接后继结点的prior域,使其指向p的直接前驱结点,即p->next->prior=p->prior。删除操作指针修改情况如图3-31所示。 图3-31 双向循环链表的删除结点操作过程   删除操作算法实现如下。 int DeleteDList(DListLink head,int i,DataType *e) /*双向链表删除操作的算法实现*/ { DListNode *p; int j; p=head->next; /*p指向双向链表的第一个结点*/ j=0; /*计数器初始化为0*/ while(p!=head&&jnext; /*则令p指向下一个结点继续查找*/ j++; /*计数器加1*/ } if(j!=i) /*若不存在待删除的结点位置*/ { printf("删除位置不正确"); /*则输出错误提示信息*/ return 0; /*返回0*/ } p->prior->next=p->next; /*修改p的前驱结点的next域,使其指向p的直接后继结点*/ p->next->prior =p->prior; /*修改p的直接后继结点的prior域,使其指向p的直接前驱结点*/ free(p); /*释放p指向结点的空间*/ return 1; /*返回1*/ }   插入和删除操作的时间耗费主要在查找结点上,两者的时间复杂度都为O(n)。 3.5.3 双向链表应用举例   【例3-5】约瑟夫环问题。有n个小朋友,编号分别为1,2,…,n,按编号围成一个圆圈,他们按顺时针方向从编号为k的人由1开始报数,报数为m的人出列,他的下一个人重新从1开始报数,数到m的人出列,照这样重复下去,直到所有人都出列。编写一个算法,输入n、k和m,按照出列顺序输出编号。   【分析】解决约瑟夫环问题可以分为3个步骤:第一步创建一个具有n个结点的不带头结点的双向循环链表(模拟编号从1~n的圆圈可以利用循环单链表实现,这里采用双向循环链表实现),编号从1到n,代表n个小朋友;第二步找到第k个结点,即第一个开始报数的人;第三步,编号为k的人从1开始报数,并开始计数,报到m的人出列即将该结点删除。继续从下一个结点开始报数,直到最后一个结点被删除。 void Josephus(DLinkList head,int n,int m,int k) /*在长度为n的双向循环链表中,从第k个人开始报数,数到m的人出列*/ { DListNode *p,*q; /*定义结点指针变量*/ int i; p=head; /*p指向双向循环链表的第一个结点*/ for(i=1;inext; /*p指向下一个结点*/ } while(p->next!=p) { for(i=1;inext; /*p指向下一个结点*/ } q->next=p->next; /*将p指向的结点删除,即报数为m的人出列*/ p->next->prior=q; printf("%4d",p->data); /*输出被删除的结点*/ free(p); /*释放p指向的结点空间*/ p=q->next; /*p指向下一个结点,重新开始报数*/ } printf("%4d\n",p->data); /*输出最后出列的人*/ }   程序运行结果如图3-32所示。 图3-32 约瑟夫问题程序运行结果   在创建双向循环链表CreateDCList函数中,根据创建的是否为第一个结点分为两种情况处理。如果是第一个结点,则让该结点的前驱结点指针域和后继结点指针域都指向该结点,并让头指针指向该结点,代码如下。 head=s; s->prior=head; s->next=head;   切记不要漏掉s->next=head或s->prior=head,否则在程序运行时会出现错误。   如果不是第一个结点,则将新结点插入双向链表的尾部,代码如下。 s->next=q->next; q->next=s; s->prior=q; head->prior=s;   注意:语句s->next=q->next和q->next=s的顺序不能颠倒,另外不要忘记让头结点的prior域指向s。 3.6 综合案例:一元多项式的表示与相加   一元多项式的相加是线性表在生活中的一个实际应用,它涵盖了本节所学到的链表的各种操作。通过使用链表实现一元多项式的相加,巩固读者对链表基本操作的理解与掌握。 3.6.1 一元多项式的表示   假设一元多项式为Pn(x)=anxn+an-1xn-1+…+a1x+a0,一元多项式的每一项由系数和指数构成,因此要表示一元多项式,需要定义一个结构体。结构体由两个部分构成,分别为coef和exp,分别表示系数和指数。定义结构体的代码如下。 struct node /*定义结构体struct node*/ { float coef; /*系数*/ int exp; /*指数*/ };   如果用结构体数组表示多项式的每一项,则需要n+1个数组元素存放多项式(假设n为最高次数)。遇到指数不连续且指数之间跨越非常大时,例如,多项式2x500+1,则需要数组的长度为501。这显然会浪费很多内存单元。   为了有效利用内存空间,可以使用链表表示多项式,多项式的每一项使用结点表示。结点由系数、指数和指针域3个部分构成,结构如图3-33所示。 图3-33 多项式每一项的结点结构   结点用C语言描述如下。 struct node /*定义结构体struct node*/ { float coef; /*系数*/ int exp; /*指数*/ struct node *next; /*指针域*/ }; 3.6.2 一元多项式相加   为了操作方便,将链表按照指数从高到低进行排列,即降幂排列。一个最高次数为n的多项式构成的链表如图3-34所示。 图3-34 一元多项式的链表结构   例如,有两个一元多项式p(x)=3x2+2x+1和q(x)=5x3+3x+2,链表表示如图3-35所示。 图3-35 一元多项式的链表表示   如果要将两个多项式相加,需要比较两个多项式的指数项后决定。当两个多项式的两项中指数相同时,才将系数相加。如果两个多项式的指数不相等,则多项式该项和的系数是其中一个多项式的系数。实现代码如下。 if(s1->exp==s2->exp) /*如果两个指数相等,则将系数相加*/ { c=s1->coef+s2->coef; e=s1->exp; s1=s1->next; s2=s2->next; } else if(s1->exp>s2->exp) /*如果s1的指数大于s2的指数,则将s1的指数作为结果*/ { c=s1->coef; e=s1->exp; s1=s1->next; } else /*如果s1的指数小于或等于s2,则将s2的指数作为结果*/ { c=s2->coef; e=s2->exp; s2=s2->next; }   其中,s1和s2分别指向两个链表表示的表达式。因为表达式是按照指数从大到小排列的,所以在指数不等时,将指数大的作为结果。指数小的还要继续进行比较。例如,如果当前s1指向系数为3,指数为2的结点即(3,2),s2指向(3,1)的结点,因为s1->exp>s2->exp,所以将s1的结点作为结果。在s1指向(2,1)时,还要与s2的(3,1)相加,得到(5,1)。   如果相加后的系数不为0,则需要生成一个结点存放到链表中,代码如下。 if(c!=0) /*如果相加后的系数不为0*/ { p=(ListNode*)malloc(sizeof(ListNode)); /*动态生成一个结点p*/ p->coef=c; /*将系数存入coef域*/ p->exp=e; /*将指数存入exp域*/ p->next=NULL; /*结点的指针域为空*/ if(s==NULL) /*若新生成的链表为空*/ s=p; /*则s指向新生成的结点*/ else /*否则*/ r->next=p; /*p指向的结点成为r的下一个结点*/ r=p; /*使r指向新链表的最后一个结点* }   如果在一个链表已经到达末尾,而另一个链表还有结点时,需要将剩下的结点插入新链表中,代码如下。 while(s1!=NULL) /*如果s1还有结点*/ { c=s1->coef; /*s1结点的系数赋给c*/ e=s1->exp; /*s1结点的指数赋给e*/ s1=s1->next; /*s1结点指向下一个结点*/ if(c!=0) /*如果相加后的系数不为0,则生成一个结点存放到链表*/ { p=(ListNode*)malloc(sizeof(ListNode)); /*动态生成一个结点p*/ p->coef=c; p->exp=e; p->next=NULL; if(s==NULL) /*若新生成的链表为空*/ s=p; /*则将p指向的结点作为第一个结点*/ else /*否则*/ r->next=p; /*将新结点插入r指向的结点之后*/ r=p; /*使r指向链表的最后一个结点*/ } } while(s2!=NULL) /*如果s2还有剩余结点*/ { c=s2->coef; /*s2结点的系数赋给c*/ e=s2->exp; /*s2结点的指数赋给e*/ s2=s2->next; /*s2结点指向下一个结点*/ if(c!=0) /*如果相加后的系数不为0,则生成一个结点存放到链表*/ { p=(ListNode*)malloc(sizeof(ListNode)); /*动态生成一个结点p*/ p->coef=c; p->exp=e; p->next=NULL; if(s==NULL) /*若新链表为空*/ s=p; /*则将p指向的结点作为第一个结点*/ else /*否则*/ r->next=p; /*将新结点插入到r指向的结点之后*/ r=p; /*使r指向链表的最后一个结点*/ } }   最后,s指向的链表就是两个多项式的和。   【例3-6】依次输入两个多项式,编写程序求两个多项式的和。 ListNode *addpoly(ListNode *h1,ListNode *h2) /*将两个多项式相加*/ { ListNode *p,*r=NULL,*s1,*s2,*s=NULL; float c; /*定义系数变量c*/ int e; /*定义指数变量e*/ s1=h1; /*使s1指向第一个多项式*/ s2=h2; /*使s2指向第二个多项式*/ while(s1!=NULL&&s2!=NULL) /*如果两个多项式都不为空*/ { if(s1->exp==s2->exp) /*如果两个指数相等*/ { c=s1->coef+s2->coef; /*则对应系数相加后,将和赋给c*/ e=s1->exp; /*将指数赋给e*/ s1=s1->next; /*使s1指向下一个待处理结点*/ s2=s2->next; /*使s2指向下一个待处理结点*/ } else if(s1->exp>s2->exp) /*如果第一个多项式结点的指数大于第二个多项式结点的指数*/ { c=s1->coef; /*将第一个多项式结点的系数赋给c*/ e=s1->exp; /*将第一个多项式结点的指数赋给e*/ s1=s1->next; /*使s1指向下一个待处理结点*/ } else /*否则*/ { c=s2->coef; /*将第二个多项式结点的系数赋给c*/ e=s2->exp; /*将第二个多项式结点的指数赋给e*/ s2=s2->next; /*使s2指向下一个待处理结点*/ } if(c!=0) /*如果相加后的系数不为0,则生成一个结点存放到链表*/ { p=(ListNode*)malloc(sizeof(ListNode)); /*动态生成一个结点p*/ p->coef=c; /*将c赋给结点的系数*/ p->exp=e; /*将e赋给结点的指数*/ p->next=NULL; /*将结点的指针域置为空*/ if(s==NULL) /*如果s为空链表*/ s=p; /*则使新结点成为s的第一个结点*/ else /*否则*/ r->next=p; /*使新结点*p成为r的下一个结点*/ r=p; /*使r指向链表的最后一个结点*/ } } while(s1!=NULL) /*如果第一个多项式还有其他结点*/ { c=s1->coef; /*第一个多项式结点的系数赋给c*/ e=s1->exp; /*第一个多项式结点的指数赋给e*/ s1=s1->next; /*将s1指向下一个结点*/ if(c!=0) /*如果相加后的系数不为0,则生成一个结点存放到链表*/ { p=(ListNode*)malloc(sizeof(ListNode)); p->coef=c; p->exp=e; p->next=NULL; if(s==NULL) s=p; else r->next=p; r=p; } } while(s2!=NULL) /*如果第二个多项式还有其他结点*/ { c=s2->coef; /*第二个多项式结点的系数赋给c*/ e=s2->exp; /*第二个多项式结点的指数赋给e*/ s2=s2->next; /*将s2指向下一个结点*/ if(c!=0) /*如果相加后的系数不为0,则生成一个结点存放到链表*/ { p=(ListNode*)malloc(sizeof(ListNode)); p->coef=c; p->exp=e; p->next=NULL; if(s==NULL) s=p; else r->next=p; r=p; } } return s; /*返回新生成的链表指针s*/ }   程序运行结果如图3-36所示。 图3-36 程序运行结果 3.7 小结   线性表中的元素之间是一对一的关系,除了第一个元素外,其他元素只有唯一的直接前驱,除了最后一个元素外,其他元素只有唯一的直接后继。   线性表有顺序存储和链式存储两种存储方式。采用顺序存储结构的线性表称为顺序表,采用链式存储结构的线性表称为链表。   顺序表中数据元素的逻辑顺序与物理顺序一致,因此可以随机存取。链表是靠指针域表示元素之间的逻辑关系。   链表又分为单链表和双向链表,这两种链表又可构成单循环链表、双向循环链表。单链表只有一个指针域,指针域指向直接后继结点。双向链表的一个指针域指向直接前驱结点,另一个指针域指向直接后继结点。   顺序表的优点是可以随机存取任意一个元素,算法实现较为简单,存储空间利用率高;缺点是需要预先分配存储空间,存储规模不好确定,插入和删除操作需要移动大量元素。链表的优点是不需要事先确定存储空间的大小,插入和删除元素不需要移动大量元素;缺点是只能从第一个结点开始顺序存取元素,存储单元利用率不高,算法实现较为复杂,因涉及指针操作,操作不当,会产生无法预料的内存错误。       第4章 栈和队列            栈是一种操作受限的线性表。栈具有线性表的结构特点,即每一个元素只有一个前驱元素和后继元素(除了第一个元素和最后一个元素外),但它只允许在表的一端进行插入和删除操作。与线性表一样,栈也有两种存储结构,即顺序存储结构和链式存储结构。与栈一样,队列也是一种操作受限的线性表。在实际生活中,栈和队列的应用十分广泛,在表达式求值、括号匹配中常常用到栈的设计思想,键盘输入缓冲区问题就是利用队列的思想实现的。   本章重点和难点: * 栈和队列的顺序表示与算法实现。 * 栈和队列的链式表示与算法实现。 * 求算术表达式的值。 * 舞伴配对问题。 * 递归的消除。 4.1 栈的定义与抽象数据类型 4.1.1 什么是栈   栈(stack)也称为堆栈,它是限定仅在表尾进行插入和删除操作的线性表。对栈来说,表尾(允许操作的一端)称为栈顶(top),另一端称为栈底(bottom)。栈顶是动态变化的,它由一个称为栈顶指针(top)的变量指示。当表中没有元素时,称为空栈。   栈的插入操作称为入栈或进栈,删除操作称为出栈或退栈。   在栈S=(a1,a2,…,an)中,a1称为栈底元素,an称为栈顶元素,由栈顶指针top指示。栈中的元素按照a1,a2,…,an的顺序进栈,当前的栈顶元素为an,如图4-1所示。最先进栈的元素一定是栈底元素,最后进栈的元素一定是栈顶元素。每次删除的元素是栈顶元素,也就是最后进栈的元素。因此,栈是一种后进先出(Last In First Out,LIFO)的线性表。   在软件应用中,栈的后进先出特性应用非常广泛,例如,使用浏览器上网时,浏览器的左上角有一个“后退”按钮,单击后可以按访问顺序的逆序加载浏览过的网页。   把栈想象成一个桶,先放进去的东西在最下面,后放进去的东西在最上面,最先取出来的是最后放进去的,最后取出来的是最先放进去的。这也像在日常生活中有一摞盘子,放盘子时,一个一个往上堆放,取盘子时,只能从上往下取,最后放上的盘子最先取下来,最先放的盘子最后取下来。 4.1.2 栈的抽象数据类型   1. 数据对象集合   栈的数据对象集合为{a1,a2,…,an},每个元素都有相同的类型DataType。   栈中数据元素之间是一对一的关系。栈具有线性表的特点:除了第一个元素a1外,每一个元素有且只有一个直接前驱元素;除了最后一个元素an外,每一个元素有且只有一个直接后继元素。   2. 基本操作集合   InitStack(&S):初始化操作,建立一个空栈S。这就像日常生活中,准备好了一个箱子,准备往里面摞盘子。   StackEmpty(S):若栈S为空,返回1,否则返回0。栈空就像日常生活中,准备好了箱子,箱子还是空的,里面没有盘子;栈不空,说明箱子里已经有了盘子。   GetTop(S,&e):返回栈S的栈顶元素给e。栈顶元素就像箱子里面最上面的那个盘子。   PushStack(&S,e):在栈S中插入元素e,使其成为新的栈顶元素。这就像日常生活中,在箱子里新放入了一个盘子,这个盘子成为一摞盘子中最上面的一个。   PopStack(&S,&e):删除栈S的栈顶元素,并用e返回其值。这就像是把箱子里最上面的那个盘子取出来。   StackLength(S):返回栈S的元素个数。这就像放在箱子里的盘子总共有多少个。   ClearStack(S):清空栈S。这就像把箱子里的盘子全部取出来。 4.2 栈的顺序表示与实现   栈有两种存储结构,即顺序存储和链式存储。 4.2.1 栈的顺序存储结构   采用顺序存储结构的栈称为顺序栈。顺序栈是利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,可利用C语言中的数组作为顺序栈的存储结构,同时附设一个栈顶指针top,用于指向顺序栈的栈顶元素。当top=0时表示空栈。   栈的顺序存储结构类型描述如下。 #define StackSize 100 /*宏定义,表示栈中存放的最大元素个数*/ typedef struct /*定义栈结构*/ { DataType stack[StackSize]; /*定义栈存储空间,利用数组作为存储空间*/ int top; /*定义栈顶指针*/ }SeqStack; 其中,DataType为元素的数据类型,stack用于存储栈中的数据元素的数组,top为栈顶指针。   当栈中元素已经有StackSize个时,称为栈满。如果继续进栈操作则会产生溢出,称为上溢。对空栈进行删除操作,称为下溢。   顺序栈的结构如图4-2所示。元素a、b、c、d、e、f、g、h依次进栈后,a为栈底元素,h为栈顶元素。在实际操作中,栈顶指针指向栈顶元素的下一个位置。 图4-2 顺序栈结构   顺序栈涉及的一些基本操作如下。   (1)初始化栈,将栈顶指针置为0,即令S.top=0。   (2)判断栈空条件为S.top==0,栈满条件为S.top==StackSize-1。   (3)栈的长度(即栈中元素个数)为S.top。   (4)进栈操作,先判断栈是否已满,若未满,将元素压入栈中,即S.stack[S.top]=e,然后使栈顶指针加1,即S.top++。出栈操作,先判断栈是否为空,若不为空,使栈顶指针减1,即S.top--,然后元素出栈,即e=S.stack[S.top]。 4.2.2 顺序栈的基本运算   顺序栈的基本运算如下(以下算法的实现保存在文件SeqStack.h中)。   (1)初始化栈,代码如下。 void InitStack(SeqStack *S) /*初始化栈*/ { S->top=0; /*把栈顶指针置为0*/ }   (2)判断栈是否为空,代码如下。 int StackEmpty(SeqStack S) /*判断栈是否为空,栈为空返回1,否则返回0*/ { if(S.top==0) /*如果栈顶指针top为0*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)取栈顶元素。在取栈顶元素前,先判断栈是否为空,如果栈为空,则返回0表示取栈顶元素失败;否则,将栈顶元素赋值给e,并返回1表示取栈顶元素成功。取栈顶元素的算法实现如下。 int GetTop(SeqStack S, DataType *e) /*取栈顶元素。将栈顶元素值返回给e,返回1表示成功,返回0表示失败*/ { if(S.top<=0) /*如果栈为空*/ { printf("栈已经空!\n"); /*输出提示信息*/ return 0; /*返回0*/ } else /*否则*/ { *e=S.stack[S.top-1]; /*取栈顶元素*/ return 1; /*返回1*/ } }   (4)将元素e入栈。在将元素e进栈前,需要先判断栈是否已满,如果栈满,返回0表示进栈操作失败;否则将元素e压入栈中,然后将栈顶指针top增1,并返回1表示进栈操作成功。进栈操作的算法实现如下。 int PushStack(SeqStack *S,DataType e) /*将元素e进栈,元素进栈成功返回1,否则返回0*/ { if(S->top>=StackSize) /*如果栈已满*/ { printf("栈已满,不能将元素进栈!\n"); /*则输出提示信息*/ return 0; /*返回0*/ } else /*否则*/ { S->stack[S->top]=e; /*元素e进栈*/ S->top++; /*修改栈顶指针*/ return 1; /*返回1*/ } }   (5)将栈顶元素出栈。在将元素出栈前,需要先判断栈是否为空。如果栈为空,则返回0;如果栈不为空,则先使栈顶指针减1,然后将栈顶元素赋值给e,返回1,表示出栈成功。出栈操作的算法实现如下。 int PopStack(SeqStack *S,DataType *e) /*出栈操作。将栈顶元素出栈,并将其赋值给e。出栈成功返回1,否则返回0*/ { if(S->top==0) /*如果栈为空*/ { printf("栈中已经没有元素,不能进行出栈操作!\n"); /*则输出提示信息*/ return 0; /*返回0*/ } else /*否则*/ { S->top--; /*先修改栈顶指针,即出栈*/ *e=S->stack[S->top]; /*将出栈元素赋给e*/ return 1; /*返回1*/ } }   (6)求栈的长度,代码如下。 int StackLength(SeqStack S) /*求栈的长度*/ { return S.top; /*返回栈的长度*/ }   (7)清空栈,代码如下。 void ClearStack(SeqStack *S) /*清空栈*/ { S->top=0; /*将栈顶指针置为0*/ } 4.2.3 顺序栈应用举例   【例4-1】利用顺序栈的基本操作,将元素a、b、c、d、e依次进栈,然后将e和d出栈,再将f和g进栈,最后将元素全部出栈,并依次输出出栈元素。   【分析】主要考查栈的基本操作和栈的后进先出特性,实现代码如下。 #include #include typedef char DataType; #include "SeqStack.h" /*包含栈的基本类型定义和基本操作实现*/ void main() { SeqStack S; /*定义一个栈*/ int i; DataType a[]={'a','b','c','d','e'}; DataType e; InitStack(&S); /*初始化栈*/ for(i=0;itop[0]=0; S->top[1]=StackSize-1; }   (2)取栈顶元素。首先判断要取哪个栈的栈顶元素,接着还要判断栈是否为空,如果栈为空,则返回0表示取栈顶元素失败;如果栈不为空,则将栈顶元素返回给e,并返回1表示取栈顶元素成功。取栈顶元素的算法实现如下。 int GetTop(SSeqStack S, DataType*e,int flag) /*取栈顶元素。将栈顶元素值返回给e,并返回1表示成功;否则返回0表示失败*/ { switch(flag) { case 1: /*为1,表示要取左端栈的栈顶元素*/ if(S.top[0]==0) return 0; *e=S.stack[S.top[0]-1]; break; case 2: /*为2,表示要取右端栈的栈顶元素*/ if(S.top[1]==StackSize-1) return 0; *e=S.stack[S.top[1]+1]; break; default: return 0; } return 1; }   (3)将元素e入栈。在将元素入栈之前,需要先判断栈是否已满,如果栈已满,则返回0表示进栈操作失败;否则先通过标志变量flag判断哪个栈需要进栈操作,然后将元素e进栈,并修改栈顶指针,最后返回1表示进栈操作成功。将元素e入栈的算法实现如下。 int PushStack(SSeqStack *S,DataType e,int flag) /*将元素e入共享栈。进栈成功返回1,否则返回0*/ { if(S->top[0]==S->top[1]) /*如果共享栈已满*/ return 0; /*返回0,进栈失败*/ switch(flag) { case 1: /*当flag为1,表示将元素进左端的栈*/ S->stack[S->top[0]]=e; /*元素进栈*/ S->top[0]++; /*修改栈顶指针*/ break; case 2: /*当flag为2,表示将元素进右端的栈*/ S->stack[S->top[1]]=e; /*元素进栈*/ S->top[1]--; /*修改栈顶指针*/ break; default: return 0; } return 1; /*返回1,进栈成功*/ }   (4)将栈顶元素出栈。 int PopStack(SSeqStack *S,DataType *e,int flag) { switch(flag) /*在出栈操作之前,判断哪个栈要进行出栈操作*/ { case 1: /*为1,表示左端的栈需要出栈操作*/ if(S->top[0]==0) /*左端的栈为空*/ return 0; /*返回0,出栈操作失败*/ S->top[0]--; /*修改栈顶指针,元素出栈操作*/ *e=S->stack[S->top[0]]; /*将出栈的元素赋给e*/ break; case 2: /*为2,表示右端的栈需要出栈操作*/ if(S->top[1]==StackSize-1) /*右端的栈为空*/ return 0; /*返回0,出栈操作失败*/ S->top[1]++; /*修改栈顶指针,元素出栈操作*/ *e=S->stack[S->top[1]]; /*将出栈的元素赋给e*/ break; default: return 0; } return 1; /*返回1,出栈操作成功*/ }   (5)判断栈是否为空。 int StackEmpty(SSeqStack S,int flag) /*判断栈是否为空。如果栈为空,返回1;否则,返回0*/ { switch(flag) { case 1: /*为1,表示判断左端的栈是否为空*/ if(S.top[0]==0) return 1; break; case 2: /*为2,表示判断右端的栈是否为空*/ if(S.top[1]==StackSize-1) return 1; break; default: printf("输入的flag参数错误!"); return -1; } return 0; }   3. 测试代码   利用共享栈基本运算,将两个栈中元素{10,20,30,40,50,60}和{100,200,300,500}分别进行入栈、取栈顶元素、出栈等操作。 #include #include #define StackSize 100 typedef int DataType; #include "SSeqStack.h" /*包含共享栈的基本类型定义和基本操作实现*/ void main() { SSeqStack S; /*定义一个共享栈*/ int i; DataType a[]={10,20,30,40,50,60}; DataType b[]={100,200,300,500}; DataType e1,e2; InitStack(&S); /*初始化共享栈*/ for(i=0;itop[0]==S->top[1]。 4.3 栈的链式表示与实现   在顺序栈中,由于顺序存储结构需要事先静态分配,而存储规模往往又难以确定,如果栈空间分配过小,可能会造成溢出;如果栈空间分配过大,又造成存储空间浪费。因此,为了克服顺序存储的缺点,采用链式存储结构表示栈。 4.3.1 栈的链式存储结构   栈的链式存储结构是用一组不一定连续的存储单元来存放栈中数据元素的。一般来说,当栈中数据元素的数目变化较大或不确定时,使用链式存储结构作为栈的存储结构是比较合适的。人们将用链式存储结构表示的栈称为链栈或链式栈。   链栈通常用单链表表示。插入和删除操作都在栈顶指针的位置进行,这一端称为栈顶,通常由栈顶指针top指示。为了操作方便,通常在链栈中设置一个头结点,用栈顶指针top指向头结点,头结点的指针指向链栈的第一个结点。例如,元素a、b、c、d依次入栈的链栈如图4-6所示。   栈顶指针top始终指向头结点,最先入栈的元素在链栈的栈底,最后入栈的元素成为栈顶元素。由于链栈的操作都是在链表的表头位置进行,因而链栈的基本操作的时间复杂度均为O(1)。   链栈的结点类型描述如下。 typedef struct node { DataType data; struct node *next; }LStackNode,*LinkStack;   对于带头结点的链栈,初始化链栈时,有top->next =NULL,判断栈空的条件为top->next== NULL。对于不带头结点的链栈,初始化链栈时,有top=NULL,判断栈空的条件为top ==NULL。 4.3.2 链栈的基本运算   链栈的基本运算实现如下(以下算法的实现保存在文件LinkStack.h中)。   (1)初始化链栈。初始化链栈需要先为头结点分配存储单元,然后将头结点的指针域置为空。初始化链栈的算法实现如下。 void InitStack(LinkStack *top) /*链栈的初始化*/ { if((*top=(LinkStack)malloc(sizeof(LStackNode)))==NULL) /*为头结点分配一个存储空间*/ exit(-1); (*top)->next=NULL; /*将链栈的头结点指针域置为空*/ }   (2)判断链栈是否为空。如果头结点指针域为空,说明链栈为空,返回1;否则返回0。判断链栈是否为空的算法实现如下。 int StackEmpty(LinkStack top) /*判断链栈是否为空*/ { if(top->next==NULL) /*如果头结点的指针域为空*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)将元素e入栈。先动态生成一个结点,用p指向该结点,将元素e值赋给*p结点的数据域,然后将新结点插入链表的第一个结点之前。把新结点插入链表中分为两个步骤,第一步p->next=top->next,第二步top->next=p。进栈操作如图4-7所示。 图4-7 进栈操作   将元素e入栈的算法实现如下。 int PushStack(LinkStack top, DataType e) /*将元素e入栈,进栈成功返回1*/ { LStackNode *p; /*定义指针p,指向新生成的结点*/ if((p=(LStackNode*)malloc(sizeof(LStackNode)))==NULL) /*为新结点动态分配内存空间*/ { printf("内存分配失败!"); /*输出提示信息*/ exit(-1); /*退出*/ } p->data=e; /*将e赋给p指向的结点数据域*/ p->next=top->next; /*指针p指向头结点*/ top->next=p; /*栈顶结点的指针域指向新插入的结点*/ return 1; /*返回1*/ }   (4)将栈顶元素出栈。先判断栈是否为空,如果栈为空,返回0表示出栈操作失败;否则,将栈顶元素出栈,并将栈顶元素值赋给e,最后释放结点空间,返回1表示出栈操作成功。出栈操作如图4-8所示。 图4-8 出栈操作   将栈顶元素出栈的算法实现代码如下。 int PopStack(LinkStack top,DataType *e) /*将栈顶元素出栈。删除成功返回1,失败返回0*/ { LStackNode *p; /*定义栈结点指针变量*/ p=top->next; /*指针p指向栈顶结点*/ if(!p) /*判断链栈是否为空*/ { printf("栈已空"); /*输出提示信息*/ return 0; /*返回0*/ } top->next=p->next; /*将栈顶结点与链表断开,即出栈*/ *e=p->data; /*将出栈元素赋值给e*/ free(p); /*释放p指向的结点*/ return 1; /*返回1*/ }   (5)取栈顶元素。 int GetTop(LinkStack top,DataType *e) /*取栈顶元素。取栈顶元素成功返回1,否则返回0*/ { LStackNode *p; /*定义栈结点指针变量*/ p=top->next; /*指针p指向栈顶结点*/ if(!p) /*如果栈为空*/ { printf("栈已空"); /*输出提示信息*/ return 0; /*返回0*/ } *e=p->data; /*将p指向的结点元素赋值给e*/ return 1; /*返回1*/ }   (6)求栈的长度。 int StackLength(LinkStack top) /*求栈的长度操作*/ { LStackNode *p; /*定义栈结点指针变量*/ int count=0; /*定义一个计数器,并初始化为0*/ p=top; /*p指向栈顶指针*/ while(p->next!=NULL) /*如果栈中还有结点*/ { p=p->next; /*依次访问栈中的结点*/ count++; /*每次找到一个结点,计数器累加1*/ } return count; /*返回栈的长度*/ }   (7)销毁链栈。 void DestroyStack(LinkStack top) /*销毁链栈。通过一个指针指向栈顶指针,从栈顶开始,依次释放结点空间,直到最后一个结点*/ { LStackNode *p,*q; /*定义栈结点指针变量*/ p=top; /*指针p指向栈顶结点*/ while(!p) /*如果栈还有结点*/ { q=p; /*q就是要释放的结点*/ p=p->next; /*p指向下一个结点,即下一次要释放的结点*/ free(q); /*释放q指向的结点空间*/ } } 4.4 栈与递归   栈的后进先出的思想还体现在递归函数中。本节主要讲解栈与递归调用的关系、递归利用栈的实现过程、递归与非递归的转换。 4.4.1 递归   先来看一个经典的递归例子:斐波那契数列。   1. 斐波那契数列   如果兔子在出生两个月后就有繁殖能力,以后一对兔子每个月能生出一对兔子,假设所有兔子都不死,那么一年以后可以繁殖多少对兔子呢?   不妨拿新出生的一对小兔子来分析下。第一、二个月小兔子没有繁殖能力,所以还是一对;两个月后,生下一对小兔子,共有2对兔子;三个月后,老兔子又生下一对,因为小兔子还没有繁殖能力,所以一共是3对兔子;以此类推,可以得出表4-1每月兔子的对数。 表4-1 每月兔子的对数 经过的月数 1 2 3 4 5 6 7 8 9 10 11 12 兔子对数 1 1 2 3 5 8 13 21 34 55 89 144      从表4-1中不难看出,数字1,1,2,3,5,8,构成了一个数列,这个数列有个十分明显的特征,即前面相邻两项之和构成后一项,可用数学函数表示如下。      如果要打印出斐波那契数列的前40项,常规的迭代方法实现代码如下。 void main( ) { int i,a[40]; a[0]=0; a[1]=1; printf("%4d",a[0]); /*输出第一项*/ printf("%4d",a[1]); /*输出第二项*/ for(i=2;i<40;i++) /*通过不断迭代求解其他项*/ { a[i]=a[i-1]+a[i-2]; /*根据前两项求解第三项* printf("%4d",a[i]); /*输出得到的当前项的值* } }   以上代码比较简单,不用过多解释,如果用递归实现,代码会更加简洁。 int Fib (int n) /*使用递归方法计算斐波那契数列*/ { if(n==0) /*若是第0项*/ return 0; /*则返回0*/ else if(n==1) /*若是第一项*/ return 1; /*则返回1*/ else /*其他情况*/ return Fib(n-1)+Fib(n-2); /*第三项为前两项之和*/ } void main() { int i; for(i=0;i<40;i++) printf("%4d",Fib(i)); }   例如,当n=4时,代码执行过程如图4-9所示。   2. 什么是递归函数   递归是指在函数的定义中,在定义自己的同时又出现了对自身的调用。如果一个函数在函数体中直接调用自己,称为直接递归函数。如果经过一系列的中间调用间接调用自己称为间接递归函数。    图4-9 斐波那契数列的执行过程   例如,n的阶乘的递归函数定义如下。      n的阶乘递归函数C语言程序实现如下。 int fact(int n) /*n的阶乘递归算法实现*/ { if(n==1) return 1; else return n*fact(n-1); }   Ackerman函数定义如下。      Ackerman递归函数C语言程序实现如下。 int Ack(int m,int n) /*Ackerman递归算法实现*/ { if(m==0) return n+1; else if(n==0) return Ack(m-1,1); else return Ack(m-1,Ack(m,n-1)); }   递归的实现本质上就是把嵌套调用变成栈实现。在递归调用过程中,被调用函数在执行前系统要完成如下3件事情。   (1)将所有参数和返回地址传递给被调用函数保存。   (2)为被调用函数的局部变量分配存储空间。   (3)将控制转到被调用函数的入口。   当被调用函数执行完毕,返回给调用函数前,系统同样需要完成如下3个任务。   (1)保存被调用函数的执行结果。   (2)释放被调用函数的数据存储区。   (3)将控制转到调用函数的返回地址处。   在有多层嵌套调用时,后调用的先返回,刚好满足后进先出的特性,因此递归调用是通过栈实现的。函数递归调用过程中,在递归结束前,每调用一次,就进入下一层。当一层递归调用结束时,返回到上一层。   为了保证递归调用能正确执行,系统设置了一个工作栈作为递归函数运行期间使用的数据存储区。每一层递归包括实参、局部变量及上一层的返回地址等构成一个工作记录。每进入下一层,新的工作栈记录被压入栈顶。每返回到上一层,就从栈顶弹出一个工作记录。因此,当前层的工作记录是栈顶工作记录,被称为活动记录。递归过程产生的栈由系统自动管理,类似用户自己定义的栈。 4.4.2 消除递归   用递归编写的程序结构清晰,算法也容易实现,读算法的人也容易理解,但递归算法的执行效率比较低,这是因为递归需要反复入栈,时间和空间开销都比较大,为了避免这种开销,需要消除递归。消除递归的方法通常有两种:一种是对于简单的递归可以直接用迭代,通过循环结构就可以消除;另一种方法是利用栈的方式实现。例如,n的阶乘就是一个简单的递归,可以直接利用迭代就可以消除递归。n的阶乘的非递归算法如下。 int fact(int n) /*n的阶乘的非递归算法实现*/ { int f,i; f=1; for(i=1;i<=n;i++) /*直接用迭代,通过循环结构就可消除递归*/ f=f*i; return f; }   当然,n的阶乘的递归算法也可以转换为利用栈实现的非递归算法。   【例4-3】编写求n的阶乘的递归算法与利用栈实现的非递归算法。   【分析】利用栈模拟实现求n的阶乘。定义一个二维数组,数组的第一维用于存放本层参数n,第二维用于存放本层要返回的结果。   当n=3时,递归调用过程如图4-10所示。 图4-10 递归调用过程   在递归函数调用的过程中,各参数入栈情况如图4-11所示。为便于描述,用f代替fact表示函数。 图4-11 递归调用入栈过程   当n=1时,递归调用开始逐层返回,参数开始出栈,如图4-12所示。 图4-12 递归调用出栈过程   n的阶乘递归与非递归算法实现如下。 int fact1(int n) /*n的阶乘递归实现*/ { if(n==1) /*递归函数出口。当n=1时,开始返回到上一层*/ return 1; else return n*fact1(n-1); /*n的阶乘递归实现。把一个规模为n的问题转换为n-1的问题*/ } int fact2(int n) /*n的阶乘非递归实现*/ { int s[MaxSize][2],top=-1; /*定义一个二维数组,并将栈顶指针置为-1*/ /*栈顶指针加1,将工作记录入栈*/ top++; s[top][0]=n; /*记录每一层的参数*/ s[top][1]=0; /*记录每一层的结果返回值*/ do { if(s[top][0]==1) /*递归出口*/ { s[top][1]=1; printf("n=%4d, fact=%4d\n",s[top][0],s[top][1]); } if(s[top][0]>1&&s[top][1]==0) /*通过栈模拟递归的递推过程,将问题依次入栈*/ { top++; s[top][0]=s[top-1][0]-1; s[top][1]=0; /*将结果置为0,还没有返回结果*/ printf("n=%4d, fact=%4d\n",s[top][0],s[top][1]); } if(s[top][1]!=0) /*模拟递归的返回过程,将每一层调用的结果返回*/ { s[top-1][1]=s[top][1]*s[top-1][0]; printf("n=%4d, fact=%4d\n",s[top-1][0],s[top-1][1]); top--; } }while(top>0); return s[0][1]; /*返回计算的阶乘结果*/ }   程序运行结果如图4-13所示。 图4-13 n的阶乘程序运行结果   利用栈实现的非递归过程可分为以下几个步骤。   (1)设置一个工作栈,用于保存递归工作记录,包括实参、返回地址等。   (2)将调用函数传递过来的参数和返回地址入栈。   (3)利用循环模拟递归分解过程,逐层将递归过程的参数和返回地址入栈。当满足递归结束条件时,依次逐层退栈,并将结果返回给上一层,直到栈空为止。 4.5 队列的定义与抽象数据类型   队列只允许在表的一端进行插入操作,在另一端进行删除操作。 4.5.1 什么是队列   日常生活中,人们在医院排队挂号就是一个队列。新来挂号的排在最后,这是一个入队的过程;排在队列最前面的人挂完号离开,这是出队的过程。在程序设计中也经常会遇到排队等待服务的问题。一个典型的例子就是操作系统中的多任务处理。在计算机系统中,同时有几个任务等待输出,那么就要按照请求输出的先后顺序进行输出。   队列(queue)是一种先进先出(First In First Out,FIFO)的线性表,它只允许在表的一端进行插入,另一端删除元素。这与日常生活中的排队是一致的,最早进入队列的元素最早离开。在队列中,允许插入的一端称为队尾(rear),允许删除的一端称为队头(front)。   假设队列为q=(a1, a2, …, ai, …, an),那么a1为队头元素,an为队尾元素。进入队列时,是按照a1, a2, …, an的顺序进入的,退出队列时也是按照这个顺序退出的。也就是说,当先进入队列的元素都退出之后,后进入队列的元素才能退出。即只有当a1, a2, …, an-1都退出队列以后,an才能退出队列。图4-14是一个队列的示意图。 图4-14 队列 4.5.2 队列的抽象数据类型   1. 数据对象集合   队列的数据对象集合为{a1, a2, …, an},每个元素都具有相同的数据类型DataType。   队列中的数据元素之间也是一对一的关系。除第一个元素a1外,每一个元素有且只有一个直接前驱元素;除最后一个元素an外,每一个元素有且只有一个直接后继元素。   2. 基本操作集合   InitQueue(&Q):初始化操作,建立一个空队列Q。这就像日常生活中医院新增一个挂号窗口,前来看病的人就可以排队在这里挂号看病。   QueueEmpty(Q):若Q为空队列,返回1,否则返回0。这就类似于挂号窗口前是否还有人排队挂号。   EnQueue(&Q,e):插入元素e到队列Q的队尾。这就像前来挂号的人都要到队列的最后排队挂号。   DeQueue(&Q,&e):删除Q的队首元素,并用e返回其值。这就像排在最前面的人挂完号离开队列。   Gethead(Q,&e):用e返回Q的队首元素。这就像询问排队挂号的人是谁一样。   ClearQueue(&Q):将队列Q清空。这就像所有排队的人都挂完了号离开队列。 4.6 队列的顺序存储及实现   队列的存储表示有两种,分别为顺序存储和链式存储。采用顺序存储结构的队列被称为顺序队列,采用链式存储结构的队列被称为链式队列。 4.6.1 顺序循环队列──顺序队列的表示   1. 顺序队列的表示   顺序队列通常采用一维数组依次存放从队头到队尾的元素。同时,使用两个指针分别指示数组中存放的第一个元素和最后一个元素的位置。其中,指向第一个元素的指针被称为队头指针front,指向最后一个元素的指针被称为队尾指针rear。   初始时,队列为空,队头指针front和队尾指针rear都指向队列的第一个位置,即front=rear=0,如图4-15所示。 图4-15 顺序队列为空   元素a、b、c、d、e、f、g依次进入队列后的状态如图4-16所示。 图4-16 顺序队列   当一个元素出队列时,队头指针front增1,队头元素即a出队后,front向后移动一个位置,指向下一个位置,rear不变,如图4-17所示。 图4-17 删除队头元素a后的顺序队列   2. 顺序队列的“假溢出”   经过多次插入和删除操作后,实际上队列还有存储空间,但是又无法向队列中插入元素,这种溢出称为“假溢出”。   例如,在如图4-17所示的队列中插入3个元素h、i、j,然后删除元素b,就会出现如图4-18所示的情况。当插入元素j后,队尾指针rear将越出数组的下界而造成“假溢出”。 图4-18 插入元素h、i、j和删除元素a、b后的“假溢出”   3. 顺序循环队列的表示   为了充分利用存储空间,消除这种“假溢出”现象,当队尾指针rear和队头指针front到达存储空间的最大值(假定队列的存储空间为QueueSize)的时候,让队尾指针和队头指针转换为0,这样就可以将元素插入到队列还没有利用的存储单元中。例如,在图4-18中插入元素j之后,使rear变为0,可以继续将元素插入下标为0的存储单元中。这样就把顺序队列使用的存储空间构造成一个逻辑上首尾相连的循环队列。   当队尾指针rear达到最大值QueueSize-1时,前提是队列中还有存储空间,若要插入元素,就要把队尾指针rear变为0;当队头指针front达到最大值QueueSize-1时,若要将队头元素出队,要让队头指针front变为0。这可通过取余操作实现队列的首尾相连。例如,假设QueueSize=10,当队尾指针rear=9时,若要将新元素入队,则先令rear=(rear+1)%10=0,然后将元素存入队列的第0号单元,通过取余操作实现了队列逻辑上的首尾相连。   4. 顺序循环队列的队空和队满判断   在顺序循环队列队空和队满的情况下,队头指针front和队尾指针rear同时都会指向同一个位置,即front==rear,如图4-19所示。即队列为空时,有front=0,rear=0,因此front==rear;队满时也有front=0,rear=0,因此front==rear。 (a)队空 (b)队满 图4-19 顺序循环队列队空和队满状态   为了区分是队空还是队满,通常采用如下两个方法。   (1)增加一个标志位。设这个标志位为flag,初始时,有flag=0;当入队成功,则flag=1;当出队成功,有flag=0。则队列为空的判断条件为front==rear&&flag==0,队列满的判断条件为front==rear&&flag==1。   (2)少用一个存储单元。队空的判断条件为front==rear,队满的判断条件为front==(rear+ 1)%QueueSize。那么,入队的操作语句为rear=(rear+1)%QueueSize,queue[rear]='i'。出队的操作语句为front=(front+1)%QueueSize。少用一个存储单元的顺序循环队列队满情况如图4-20所示。 图4-20 少用一个存储单元的顺序循环队列队满状态   顺序循环队列类型描述如下。 #define QueueSize 60 /*队列的最大容量*/ typedef struct Squeue{ DataType queue[QueueSize]; int front,rear; /*队头指针和队尾指针*/ }SeqQueue;   其中,queue用来存储队列中的元素,front和rear分别表示队头指针和队尾指针,取值范围为0~QueueSize。   顺序循环队列的主要操作说明如下。   (1)初始化时,设置SQ.front=SQ.rear=0。   (2)循环队列队空的条件为SQ.front=SQ.rear,队满的条件为SQ.front=(SQ.rear+1)% QueueSize。   (3)入队操作时,先判断队列是否已满,若队列未满,则将元素值e存入队尾指针指向的存储单元,然后将队尾指针加1后取模。   (4)出队操作时,先判断队列是否为空,若队列不空,则先把队头指针指向的元素值赋给e,即取出队头元素,然后将队头指针加1后取模。   (5)循环队列的长度为(SQ.rear+QueueSize-SQ.front)%QueueSize。 4.6.2 顺序循环队列的基本运算   顺序循环队列的基本运算算法实现如下(以下算法的实现保存在文件SeqQueue.h中)。   (1)初始化队列。 void InitQueue(SeqQueue *SCQ) /*顺序循环队列的初始化*/ { SCQ ->front=SCQ ->rear=0; /*把队头指针和队尾指针同时置为0*/ }   (2)判断队列是否为空。 int QueueEmpty(SeqQueue SCQ) /*判断顺序循环队列是否为空,队列为空返回1,否则返回0*/ { if(SCQ.front== SCQ.rear) /*当顺序循环队列为空时*/ return 1; /*返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)将元素e入队。 int EnQueue(SeqQueue *SCQ,DataType e) /*将元素e插入到顺序循环队列SCQ中,插入成功返回1,否则返回0*/ { if(SCQ->front== (SCQ->rear+1)%QueueSize) /*在插入新的元素之前,判断队尾指针是否到达数组的最大值,即是否上溢*/ return 0; SCQ->queue[SCQ->rear]=e; /*在队尾插入元素e*/ SCQ->rear=(SCQ->rear+1)%QueueSize; /*将队尾指针向后移动一个位置*/ return 1; }   (4)将队头元素出队。 int DeQueue(SeqQueue *SCQ,DataType *e) /*将队头元素出队,并将该元素赋值给e,出队成功返回1,否则返回0*/ { if(SCQ->front==SCQ->rear) /*在删除元素之前,判断顺序循环队列是否为空*/ return 0; else { *e=SCQ->queue[SCQ->front]; /*将要删除的元素赋值给e*/ SCQ->front=(SCQ->front+1)%QueueSize; /*将队头指针向后移动一个位置,指向新的队头*/ return 1; } }   (5)取队头元素。 int GetHead (SeqQueue SCQ,DataType *e) /*取队头元素,并将该元素赋值给e,取元素成功返回1,否则返回0*/ { if(SCQ.front==SCQ.rear) /*若顺序循环队列为空*/ return 0; /*返回0*/ else { *e=SCQ.queue[SCQ.front]; /*将队头元素赋值给e,取出队头元素*/ return 1; /*返回1*/ } }   (6)清空队列,算法实现如下。 void ClearQueue(SeqQueue *SCQ) /*清空队列*/ { SCQ->front=SCQ->rear=0; /*将队头指针和队尾指针都置为0*/ } 4.7 队列的链式存储及实现   采用链式存储的队列称为链式队列或链队列。链式队列在插入和删除过程中,不需要移动大量的元素,只需要改变指针的位置即可。 4.7.1 链式队列的表示   顺序队列在插入和删除操作过程中需要移动大量元素,这样算法的效率会比较低,为了避免以上问题,可采用链式存储结构表示队列。   1. 链式队列   链式队列通常用链表实现。一个链队列显然需要两个分别指示队头和队尾的指针(分别称为队头指针和队尾指针)才能唯一确定。这里,与单链表一样,为了操作方便,给链队列添加一个头结点,并令队头指针front指向头结点,用队尾指针rear指向最后一个结点。一个不带头结点的链式队列和带头结点的链队列分别如图4-21和图4-22所示。   对于带头结点的链式队列,当队列为空时,队头指针front和队尾指针rear都指向头结点,如图4-23所示。 图4-21 不带头结点的链式队列 图4-22 带头结点的链式队列   链式队列中,插入和删除操作只需要移动队头指针和队尾指针,这两种操作的指针变化如图4-24~图4-26所示。图4-24表示在队列中插入元素a的情况,图4-25表示队列中插入了元素a、b、c之后的情况,图4-26表示元素a出队列的情况。 图4-23 带头结点的空链式队列 图4-24 在队列中插入元素a 图4-25 在链式队列中插入一个元素c 图4-26 在链式队列中删除一个元素a   链式队列的类型描述如下。 /*链式队列结点类型定义*/ typedef struct QNode { DataType data; struct QNode* next; }LQNode,*QueuePtr; /*链式队列类型定义*/ typedef struct { QueuePtr front; QueuePtr rear; }LinkQueue;   2. 链式循环队列   将链式队列的首尾相连就构成了链式循环队列。在链式循环队列中,可以只设置队尾指针,如图4-27所示。当队列为空时,如图4-28所示,队列LQ为空的判断条件为LQ.rear->next==LQ.rear。 图4-27 链式循环队列 图4-28 空链式循环队列 4.7.2 链式队列的基本运算   链式队列的基本运算算法实现如下(以下队列基本操作实现代码保存在文件LinkQueue.h中)。   (1)初始化队列。 void InitQueue(LinkQueue *LQ) /*初始化链式队列*/ { LQ->front=LQ->rear=(LQNode*)malloc(sizeof(LQNode)); /*动态分配结点空间*/ if(LQ->front==NULL) exit(-1); /*如果分配失败,则退出*/ LQ ->front->next=NULL; /*把头结点的指针域置为NULL*/ }   (2)判断队列是否为空。 int QueueEmpty(LinkQueue LQ) /*判断链式队列是否为空,队列为空返回1,否则返回0*/ { if(LQ.rear==LQ.front) /*若链式队列为空时*/ return 1; /*则返回1*/ else /*否则*/ return 0; /*返回0*/ }   (3)将元素e入队。先为新结点申请一个空间,然后将e赋给数据域,并使原队尾元素结点的指针域指向新结点,队尾指针指向新结点,从而将结点加入队列中。操作过程如图4-29所示。 图4-29 将元素e入队的操作过程   将元素e入队的算法实现如下。 int EnQueue(LinkQueue *LQ,DataType e) /*将元素e插入到链式队列LQ中,插入成功返回1*/ { LQNode *s; s=(LQNode*)malloc(sizeof(LQNode)); /*为将要入队的元素申请一个结点的空间*/ if(!s) exit(-1); /*如果申请空间失败,则退出并返回参数-1*/ s->data=e; /*将元素值赋值给结点的数据域*/ s->next=NULL; /*将结点的指针域置为空*/ LQ->rear->next=s; /*将原来队列的队尾指针指向s*/ LQ->rear=s; /*将队尾指针指向s*/ return 1; }   (4)将队头元素出队。 int DeQueue(LinkQueue *LQ,DataType *e) /*删除链式队列中的队头元素,并将该元素赋给e,删除成功返回1,否则返回0*/ { LQNode *s; if(LQ->front==LQ->rear) /*在删除元素之前,判断链式队列是否为空*/ return 0; else { s=LQ->front->next; /*使指针s指向队头元素的指针*/ *e=s->data; /*将要删除的队头元素赋给e*/ LQ->front->next=s->next; /*使头结点的指针指向指针s的下一个结点*/ if(LQ->rear==s) LQ->rear=LQ->front;/*如果要删除的结点是队尾,则使队尾指针指向队头指针*/ free(s); /*释放指针s指向的结点空间*/ return 1; } }   (5)取队头元素。 int GetHead (LinkQueue LQ,DataType *e) /*取链式队列中的队头元素,并将该元素赋给e,取元素成功返回1,否则返回0*/ { LQNode *s; if(LQ.front==LQ.rear) /*若链式队列为空*/ return 0; /*返回0*/ else /*若链式队列不为空*/ { s=LQ.front->next; /*将指针p指向队列的第一个元素即队头元素*/ *e=s->data; /*将队头元素赋给e,取出队头元素*/ return 1; /*返回1*/ } }   (6)清空队列。 void ClearQueue(LinkQueue *LQ) /*清空队列*/ { while(LQ->front!=NULL) { LQ->rear=LQ->front->next; /*将队尾指针指向队头指针指向的下一个结点*/ free(LQ->front); /*释放队头指针指向的结点*/ LQ->front=LQ->rear; /*将队头指针指向队尾指针*/ } } 4.8 双端队列   双端队列和栈、队列一样,也是一种操作受限的线性表。 4.8.1 什么是双端队列   双端队列是限定插入和删除操作在表两端进行的线性表。这两端分别称为端点1和端点2。双端队列可以在队列的任何一端进行插入和删除操作,而一般的队列要求在一端插入元素,在另一端删除元素。一个双端队列如图4-30所示。 图4-30 双端队列   在图4-30中,可以在队列的左端或右端插入元素,也可以在队列的左端或右端删除元素。其中,end1和end2分别是双端队列的指针。   在实际应用中,还有输入受限和输出受限的双端队列。输入受限的双端队列指的是只允许在队列的一端插入元素,而两端都能删除元素的队列。输出受限的双端队列指的是只允许在队列的一端删除元素,两端都能输入元素的队列。 4.8.2 双端队列的应用   采用一个一维数组作为双端队列的数据存储结构,试编写入队算法和出队算法。双端队列为空的状态如图4-31所示。 图4-31 双端队列的初始状态(队列为空)   在实际操作过程中,用循环队列实现双端队列的操作是比较恰当的。元素a、b、c依次进入右端的队列,元素d、e依次进入左端的队列,如图4-32所示。 图4-32 双端队列插入元素之后   注意:双端队列虽然是两个队列共享一个存储空间,但是每个队列只有一个指针,在上述实现过程中,一般情况下,要求仅在一端进行插入和删除操作,这是与一般队列操作上的差别。   【例4-4】编写算法,实现双端队列的入队和出队操作,要求如下。   (1)当队满时,最多只能有一个存储空间为空。   (2)在进行插入和删除元素时,队列中的其他元素不动。   【分析】设双端队列为Q,初始时,队列为空,有Q.end1==Q.end2,队满的条件为(Q.end1-1+ QueueSize)% QueueSize ==Q.end2 或(Q.end2+1+ QueueSize)% QueueSize ==Q.end1。对于左端的队列,当元素入队时,需要执行Q.end-1操作;当元素出队时,需要执行Q.end1+1操作。对于右端的队列,当元素入队时,需要执行Q.end2+1操作;当元素出队时,需要执行Q.end2-1操作。   双端队列的入队和出队算法实现如下。 #define QueueSize 10 /*定义双端队列的大小*/ typedef char DataType; /*定义数据类型为字符类型*/ typedef struct DQueue /*双端队列的类型定义*/ { DataType queue[QueueSize]; int end1,end2; /*双端队列的队尾指针*/ }DQueue; int EnQueue(DQueue *DQ,DataType e,int tag) /*将元素e插入到双端队列中.如果成功返回1,否则返回0*/ { switch(tag) { case 1: /*1表示左端的队列元素入队*/ if((DQ->end1-1+QueueSize)%QueueSize!=DQ->end2) /*判断队列是否已满*/ { DQ->queue[DQ->end1]=e; /*元素e入队*/ DQ->end1=(DQ->end1-1+ QueueSize)%QueueSize; /*移动队列指针*/ return 1; } else return 0; case 2: /*2表示右端队列的元素入队*/ if((DQ->end2+1+QueueSize)%QueueSize!=DQ->end1) /*判断队列是否已满*/ { DQ->queue[DQ->end2]=e; /*元素e入队*/ DQ->end2=(DQ->end2+1+QueueSize)%QueueSize; /*移动队列指针*/ return 1; } else return 0; } return 0; } int DeQueue(DQueue *DQ,DataType *e,int tag) /*将元素出队列,并将出队列的元素赋值给e。如果出队列成功返回1,否则返回0*/ { switch(tag) { case 1: /*1表示左端队列元素出队*/ if(DQ->end1!=DQ->end2) /*判断队列是否为空*/ { 1 DQ->end1=(DQ->end1+1+QueueSize)%QueueSize;/*元素出队列*/ *e=DQ->queue[DQ->end1]; /*将出队列的元素赋值给e*/ return 1; } else return 0; case 2: /*2表示右端队列元素出队*/ if(DQ->end2!=DQ->end1) /*判断队列是否为空*/ { DQ->end2=(DQ->end2-1+QueueSize)%QueueSize;/*元素出队列*/ *e=DQ->queue[DQ->end2]; /*将出队列的元素赋值给e*/ return 1; } else return 0; } return 0; } 4.9 栈与队列的典型应用   在软件开发过程中,栈的“后进先出”和队列的“先进先出”特性应用非常广泛。例如,在计算机程序的编译和运行过程中,需要利用栈的“后进先出”特性对程序的语法进行检查,如进制转换、括号的匹配、表达式求值、迷宫求解。而舞伴配对、排队买票、打印任务管理都利用了队列的“先进先出”思想。 4.9.1 求算术表达式的值   表达式求值是程序设计编译中的基本问题,它的实现是栈应用的一个典型例子。   一个算术表达式是由操作数、运算符和分界符组成的。为了简化问题,假设算术运算符仅由加、减、乘、除4种运算符和左、右圆括号组成。   例如,一个算术表达式为6+(7-1)×3+10/2,这种算术表达式中的运算符总是出现在两个操作数之间,这种算术表达式称为中缀表达式。计算机编译系统在计算一个算术表达式之前,要将中缀表达式转换为后缀表达式,然后对后缀表达式进行计算。后缀表达式就是算术运算符出现在操作数之后,并且不含括号。   计算机在求算术表达式的值时分为如下两个步骤。   (1)将中缀表达式转换为后缀表达式。   (2)求后缀表达式的值。   1. 将中缀表达式转换为后缀表达式   要将一个算术表达式的中缀形式转换为相应的后缀形式,首先要了解算术四则运算的规则。算术四则运算的规则如下。   (1)先乘除,后加减。   (2)同级别的运算从左到右进行计算。   (3)先括号内,后括号外。   举例:算术表达式6+(7-1)×3+10/2转换为后缀表达式为6 7 1 – 3 × + 10 2 / +。   不难看出,转换后的后缀表达式具有以下两个特点。   (1)后缀表达式与中缀表达式的操作数出现顺序相同,只是运算符先后顺序改变了。   (2)后缀表达式不出现括号。   正因为后缀表达式的以上特点,所以编译系统不必考虑运算符的优先关系。仅需要从左到右依次扫描后缀表达式中的各个字符,遇到运算符时,直接对运算符前面的两个操作数进行运算即可。   如何将中缀表达式转换为后缀表达式呢?根据中缀表达式与后缀表达式中的操作数次序相同,只是运算符次序不同的特点,设置一个栈,用于存放运算符。依次读入表达式中的每个字符,如果是操作数,则直接输出。如果是运算符,则比较栈顶元素符与当前运算符的优先级,然后根据优先级高低进行处理,直到整个表达式处理完毕。约定#作为后缀表达式的结束标志,假设θ1为栈顶运算符,θ2为当前扫描的运算符,则中缀表达式转换为后缀表达式的算法描述如下。   (1)初始化栈,并将#入栈。   (2)若当前读入的字符是操作数,则将该操作数输出,并读入下一字符。   (3)若当前字符是运算符,记作θ2,则将θ2与栈顶的运算符θ1比较。若θ1优先级低于θ2,则将θ2进栈;若θ1优先级高于θ2,则将θ1出栈并将其作为后缀表达式输出。然后继续比较新的栈顶运算符θ1与当前运算符θ2的优先级,若θ1的优先级与θ2相等,且θ1为(,θ2为),则将θ1出栈,继续读入下一个字符。   (4)如果θ2的优先级与θ1相等,且θ1和θ2都为#,将θ1出栈,栈为空,则完成中缀表达式转换为后缀表达式,算法结束。   运算符的优先关系如表4-2所示。 表4-2 运算符的优先关系 θ2 θ1 + - × / ( ) # + > > < < < > > - > > < < < > > × > > > > < > > / > > > > < > > ( < < < < < = ) > > > > > > # < < < < < =   初始化一个空栈,用来对运算符进行出栈和入栈操作。中缀表达式6+(7-1)×3+10/2#转换为后缀表达式的具体过程如图4-33所示(为了便于描述,可在要转换表达式的末尾加一个#作为结束标记)。 图4-33 中缀表达式6+(7-1)×3+10/2转换为后缀表达式的过程 图4-33 中缀表达式6+(7-1)×3+10/2转换为后缀表达式的过程(续)   中缀表达式6+(7-1)×3+10/2转换为后缀表达式的输出过程如表4-3所示。   2. 求后缀表达式的值   在得到后缀表达式后,借助于栈就可以计算出后缀表达式的值。需要设置一个操作数栈operand,用于存放中间运算结果。   后缀表达式的求值算法实现如下。   (1)初始化operand栈。   (2)如果当前读入的字符是操作数,则将该操作数进入operand栈。   (3)如果当前字符是运算符?,则将operand栈退栈两次,分别得到操作数x和y,对x和y进行?运算,即y?x,得到中间结果z,将z进operand栈。   (4)重复执行步骤(2)和(3),直到表达式处理完毕。此时栈中元素即为算术表达式的运行结果。 表4-3 中缀表达式6+(7-1)×3+10/2转换为后缀表达式的输出过程 步骤 中缀表达式 栈 输出 后缀表达式 步骤 中缀表达式 栈 输出 后缀表达式 1 6+(7-1)×3+10/2# # 11 +10/2# #+× 6 7 1 - 3 2 +(7-1)×3+10/2# # 6 12 +10/2# #+ 6 7 1 - 3 × 3 (7-1)×3+10/2# #+ 6 13 +10/2# # 6 7 1 - 3 × + 4 7-1)×3+10/2# #+( 6 14 10/2# #+ 6 7 1 - 3 × + 5 -1)×3+10/2# #+( 6 7 15 /2# #+ 6 7 1 - 3 × + 10 6 1)×3+10/2# #+(- 6 7 16 2# #+/ 6 7 1 - 3 × + 10 7 )×3+10/2# #+(- 6 7 1 17 # #+/ 6 7 1 - 3 × + 10 2 8 )×3+10/2# #+( 6 7 1 - 18 # #+ 6 7 1 - 3 × + 10 2 / 9 ×3+10/2# #+ 6 7 1 - 19 # # 6 7 1 - 3 × + 10 2 / + 10 3+10/2# #+× 6 7 1 - 20 6 7 1 - 3 × + 10 2 / +      利用上述规则,求后缀表达式6 7 1-3×+10 2 /+的运算过程如图4-34所示。   3. 算法实现   【例4-5】通过键盘输入一个表达式,如6+(7-1)×3+10/2,要求将其转换为后缀表达式,并计算该表达式的值。   【分析】设置两个字符数组str和exp,str用于存放中缀表达式的字符串,exp用于存放后缀表达式的字符串。利用栈将中缀表达式转换为后缀表达式的方法是依次扫描中缀表达式,如果遇到数字则将其直接存入数组exp中;如果遇到的是运算符,则将栈顶运算符与当前运算符比较,如果当前运算符的优先级高于栈顶运算符的优先级,则将当前运算符进栈;如果栈顶运算符的优先级高于当前运算符的优先级,则将栈顶运算符出栈,并保存到数组exp中。   求后缀表达式的值时,依次扫描后缀表达式中的每个字符,如果是数字字符,将其转换为数字(数值型数据),并将其入栈;如果是运算符,则将栈顶的两个数字出栈,进行加、减、乘、除运算,并将结果入栈。当后缀表达式对应的字符串处理完毕后,将栈中元素出栈,即为所求表达式的值。 图4-34 后缀表达式6 7 1-3× +10/+的运算过程 图4-34 后缀表达式6 7 1-3× +10/+的运算过程(续)   求算术表达式的值的核心程序如下。 float ComputeExpress(char a[]) /*计算后缀表达式的值*/ { OpStack S; /*定义一个操作数栈*/ int i=0,value; float x1,x2; float result; S.top=-1; /*初始化栈*/ while(a[i]!='\0') /*依次扫描后缀表达式中的每个字符*/ { if(a[i]!=' '&&a[i]>='0'&&a[i]<='9') /*如果当前字符是数字字符*/ { value=0; while(a[i]!=' ') /*如果不是空格,说明数字字符是两位数以上的数字字符*/ { value=10*value+a[i]-'0'; i++; } S.top++; S.data[S.top]=value; /*处理之后将数字进栈*/ } else /*如果当前字符是运算符*/ { switch(a[i]) /*将栈中的数字出栈两次,然后用当前的运算符进行运算,再将结果入栈*/ { case '+': /*相加运算处理过程*/ x1=S.data[S.top]; S.top--; x2=S.data[S.top]; S.top--; result=x1+x2; S.top++; S.data[S.top]=result; break; case '-': /*相减运算处理过程*/ x1=S.data[S.top]; S.top--; x2=S.data[S.top]; S.top--; result=x2-x1; S.top++; S.data[S.top]=result; break; case '*': /*相乘运算处理过程*/ x1=S.data[S.top]; S.top--; x2=S.data[S.top]; S.top--; result=x1*x2; S.top++; S.data[S.top]=result; break; case '/': /*相除运算处理过程*/ x1=S.data[S.top]; S.top--; x2=S.data[S.top]; S.top--; result=x2/x1; S.top++; S.data[S.top]=result; break; } i++; /*i自增1,进行下一个字符处理*/ } } if(!S.top!=-1) /*如果栈不空,将结果出栈,并返回*/ { result=S.data[S.top]; S.top--; if(S.top==-1) return result; /*返回运算结果*/ else { printf("表达式错误"); /*输出提示信息*/ exit(-1); /*退出*/ } } } void TranslateExpress(char str[],char exp[]) /*把中缀表达式转换为后缀表达式*/ { SeqStack S; /*定义一个栈,用于存放运算符*/ char ch; DataType e; int i=0,j=0; InitStack(&S); ch=str[i]; i++; while(ch!='\0') /*依次扫描中缀表达式中的每个字符*/ { switch(ch) { case'(': /*如果当前字符是左括号,则将其进栈*/ PushStack(&S,ch); break; case')': /*如果是右括号,将栈中的运算符出栈,并将其存入数组exp中*/ while(GetTop(S,&e)&&e!='(') { PopStack(&S,&e); exp[j]=e; j++; exp[j]=' '; /*加上空格*/ j++; } PopStack(&S,&e); /*将左括号出栈*/ break; case'+': case'-': /*如果遇到的是'+'和'-',因为其优先级低于栈顶运算符的优先 级,所以先将栈顶字符出栈,并将其存入数组exp中,然后将当前运算符进栈*/ while(!StackEmpty(S)&&GetTop(S,&e)&&e!='(') { PopStack(&S,&e); exp[j]=e; j++; exp[j]=' '; /*加上空格*/ j++; } PushStack(&S,ch); /*当前运算符进栈*/ break; case'*': /*如果遇到的是'*'和'/',先将同级运算符出栈,存入exp中,然后将当前运算符进栈*/ case'/': while(!StackEmpty(S)&&GetTop(S,&e)&&e=='/'||e=='*') { PopStack(&S,&e); exp[j]=e; j++; exp[j]=' '; /*加上空格*/ j++; } PushStack(&S,ch); /*当前运算符进栈*/ break; case' ': /*如果遇到空格,忽略*/ break; default: /*如果遇到的是操作数,则将其直接送入exp中,并在其后添加一个空格,以分隔数字字符*/ while(ch>='0'&&ch<='9') { exp[j]=ch; j++; ch=str[i]; i++; } i--; exp[j]=' '; j++; } ch=str[i]; /*读入下一个字符,准备处理*/ i++; } while(!StackEmpty(S)) /*将栈中所有剩余的运算符出栈,送入数组exp中*/ { PopStack(&S,&e); exp[j]=e; j++; exp[j]=' '; /*加上空格*/ j++; } exp[j]='\0'; }   程序运行结果如图4-35所示。 图4-35 算术表达式程序运行结果   注意:在将中缀表达式转换为后缀表达式的过程中,每输出一个数字字符,需要在其后补一个空格,与其他相邻数字字符隔开,否则一连串数字字符放在一起无法区分是一个数字还是两个数字。   在ComputeExpress()函数中,遇到-运算符时,先出栈的为减数,后出栈的为被减数;对于/运算也一样。 4.9.2 舞伴配对   【例4-6】假设在周末舞会上,男士们和女士们进入舞厅时,各自排成一队。跳舞开始时,依次从男队和女队的队头上各出一人配成舞伴。若两队初始人数不相同,则较长的那一队中未配对者等待下一轮舞曲。现要求写一算法模拟上述舞伴配对问题。   【分析】先入队的男士或女士先出队配成舞伴。因此该问题具有典型的先进先出特性,可用队列作为算法的数据结构。假设男士和女士的记录存放在一个数组中作为输入,然后依次扫描该数组的各元素,并根据性别来决定是进入男队还是女队。当这两个队列构造完成之后,依次将两队当前的队头元素出队来配成舞伴,直至某队列变空为止。此时,若某队仍有等待配对者,算法输出此队列中等待者的人数及排在队头的等待者的名字,他(或她)将是下一轮舞曲开始时第一个可获得舞伴的人。   舞伴问题实现代码如下。 #include typedef struct{ char name[20]; char sex; /*性别,'F'表示女性,'M'表示男性*/ }Person; /*定义队列中元素的数据类型*/ typedef Person DataType; /*将队列中元素的数据类型重新定义为Person*/ #include"SeqQueue.h" /*包含队列基本操作头文件*/ void DancePartner(DataType dancer[],int num) /*结构体数组dancer中存放舞池中的舞者,num是跳舞的人数*/ { int i; DataType p; /*定义队列中的数据类型变量*/ SeqQueue Mdancers,Fdancers; InitQueue(&Mdancers); /*男士队列初始化*/ InitQueue(&Fdancers); /*女士队列初始化*/ for(i=0;i #include #define MaxLen 60 /*宏定义,设置字符串最大长度*/ typedef struct /*定义字符串结构类型*/ { char str[MaxLen]; int length; }SeqString; int DelSubString(SeqString *S,int pos,int n); /*删除子串的函数声明*/ void DelAllString(SeqString *S1,SeqString *S2); /*在主串S1中删除所有子串S2的函数声明*/ void CreateString(SeqString *S,char str[]); /*通过字符数组创建串的函数声明*/ void StrPrint(SeqString S); /*串的输出函数声明*/ int StrLength(SeqString *S); /*得到字符串长度的函数声明*/ int Index(SeqString *S1,SeqString *S2) /*比较字符串,获取子串在主串中的位置*/ { int i=0,j,k; while(ilength) /*若i小于S1的长度,表明还未查找完毕*/ { j=0; if(S1->str[i]==S2->str[j]) /*如果两个串的字符相同*/ { k=i+1; /*则令k指向S1的下一个字符,准备比较下一个字符是否相同*/ j++; /*令j指向S2的下一个字符*/ while(klength && jlength && S1->str[k]==S2->str[j]) /*若两个串的字符相同*/ { k++; /*则令k指向S1的下一个待比较字符*/ j++; /*则令j指向S2的下一个待比较字符*/ } if(j==S2->length) /*若完成一次匹配*/ break; /*则跳出循环,表明已在主串中找到子串*/ else if(j==S1->length+1 && k==S2->length+1) /*若匹配发生在S1的末尾*/ break; /*则跳出循环,表明已找到子串位置*/ else /*否则*/ i++; /*从主串的下一个字符开始比较*/ } else /*若两个串中对应的字符不相同*/ i++; /*需要从主串的下一个字符开始比较*/ } if(j==S2->length+1 && k==S1->length+1 && S1->str[k-1]==’\0’) /*若在主串的末尾找到子串*/ return i+1; /*则返回子串在主串中的起始位置*/ if(i>=S1->length) /*若主串的下标超过S1的长度,表明主串中不存在子串*/ return -1; /*则返回-1表示查找子串失败*/ else /*否则,表明查找子串成功*/ return i+1; /*返回子串在主串中的起始位置*/ }   程序的运行结果如图5-6所示。 图5-6 在主串中删除所有子串的程序运行结果 5.3 串的模式匹配   串的模式匹配也称为子串的定位操作,即查找子串在主串中出现的位置。串的模式匹配主要有朴素模式匹配算法Brute-Force及改进算法KMP算法。 5.3.1 朴素模式匹配算法——Brute-Force   子串的定位操作串通常称为模式匹配,是各种串处理系统中最重要的操作之一。设有主串S和子串T,如果在主串S中找到一个与子串T相等的串,则返回串T的第一个字符在串S中的位置。其中,主串S又称为目标串,子串T又称为模式串。   Brute-Force算法的思想是从主串S="s0s1…sn-1"的第pos个字符开始与模式串T="t0t1…tm-1"的第一个字符比较,如果相等则继续逐个比较后续字符;否则从主串的下一个字符开始重新与模式串T的第一个字符比较,以此类推。如果在主串S中存在与模式串T相等的连续字符序列,则匹配成功,函数返回模式串T中第一个字符在主串S中的位置;否则函数返回-1表示匹配失败。   例如,主串S="abaababaddecab",子串T="abad",S的长度为n=14,T的长度为m=4。用变量i表示主串S中当前正在比较字符的下标,变量j表示子串T中当前正在比较字符的下标。模式匹配的过程如图5-7所示。 图5-7 经典的模式匹配过程   假设串采用顺序存储方式存储,则Brute-Force匹配算法如下。 int B_FIndex(SeqString S,int pos,SeqString T) /*在主串S中的第pos个位置开始查找模式串T,如果找到,返回子串在主串中的位置;否则,返回-1*/ { int i,j; i=pos-1; j=0; while(i=T.length) /*如果在S中找到串T,则返回子串T在主串S中的位置*/ return i-j+1; else return -1; }   Brute-Force匹配算法简单且容易理解,并且在进行某些文本处理时,效率也比较高,如检查"Welcome"是否存在于主串“Nanjing University is a comprehensive university with a long history. Welcome to Nanjing University.”中时,上述算法中while循环次数(即进行单个字符比较的次数)为79(70+1+8),除了遇到主串中呈黑体的'w'字符,需要比较两次外,其他每个字符均只和模式串比较了一次。在这种情况下,此算法的时间复杂度为O(n+m)。其中,n和m分别为主串和模式串的长度。   然而,在有些情况下,该算法的效率却很低。例如,设主串S="aaaaaaaaaaaaab",模式串T="aaab"。其中,n=14,m=4。因为模式串的前3个字符是"aaa",主串的前13个字符也是"aaa",每趟比较模式串的最后一个字符与主串中的字符不相等,所以均需要将主串的指针回退,从主串的下一个字符开始与模式串的第一个字符重新比较。在整个匹配过程中,主串的指针需要回退9次,匹配不成功的比较次数是10×4,成功匹配的比较次数是4次,因此总的比较次数是10×4+4=11×4即(n-m+1)×m。   可见,Brute-Force匹配算法在最好的情况下,即主串的前m个字符刚好与模式串相等,时间复杂度为O(m)。在最坏的情况下,Brute-Force匹配算法的时间复杂度是O(n×m)。   在Brute-Force算法中,即使主串与模式串已有多个字符经过比较相等,只要有一个字符不相等,就需要将主串的比较位置回退。 5.3.2 KMP算法   KMP算法是由D.E.Knuth、J.H.Morris、V.R.Pratt共同提出的,因此称为KMP算法(Knuth-Morris-Pratt算法)。KMP算法在Brute-Force算法的基础上有较大改进,可在O(n+m)时间数量级上完成串的模式匹配,主要是消除了主串指针的回退,使算法效率有了很大程度的提高。   1. KMP算法思想   KMP算法的基本思想是在每一趟匹配过程中出现字符不等时,不需要回退主串的指针,而是利用已经得到的前面“部分匹配”的结果,将模式串向右滑动若干字符后,继续与主串中的当前字符进行比较。   那到底向右滑动多少个字符呢?仍然假设主串S="abaababaddecab",子串T="abad"。KMP算法匹配过程如图5-8所示。 图5-8 KMP算法的匹配过程   从图5-8中可以看出,KMP算法的匹配次数由原来的6次减少为4次。在第一次匹配的过程中,当i=3、j=3,主串中的字符与子串中的字符不相等,Brute-Force算法从i=1、j=0开始比较。而这种将主串的指针回退的比较是没有必要的,在第一次比较遇到主串与子串中的字符不相等时,有S0=T0='a',S1=T1='b',S2=T2='a',S3≠T3。因为S1=T1且T0≠T1,所以S1≠T0,S1与T0不必比较。又因为S2=T2且T0=T2,有S2=T0,所以从S3与T1开始比较。   同理,在第三次比较主串中的字符与子串中的字符不相等时,只需要将子串向右滑动两个字符,进行i=5、j=0的字符比较。在整个KMP算法中,主串中的i指针没有回退。   下面来讨论一般情况。假设主串S="s0s1…sn-1",T="t0t1…tm-1"。在模式匹配过程中,如果出现字符不匹配的情况,即当Si≠Tj(0≤i<n,0≤j<m)时,有   "si-jsi-j+1…si-1"="t0t1…tj-1"   假设子串即模式串存在可重叠的真子串,即   "t0t1…tk-1"="tj-ktj-k+1…tj-1"   也就是说,子串中存在从t0开始到tk-1与从tj-k到tj-1的重叠子串,则存在主串"si-ksi-k+1…si-1"与子串"t0t1…tk-1"相等,如图5-9所示。因此,下一次可以直接从比较si和tk开始。 图5-9 在子串有重叠时主串与子串模式匹配   如果令next[j]=k,则next[j]表示:当子串中的第j个字符与主串中对应的字符不相等时,下一次子串需要与主串中该字符进行比较的字符的位置。子串即模式串中的next函数定义如下。      其中,第一种情况,next[j]函数是为了方便算法设计而定义的;第二种情况,如果子串(模式串)中存在重叠的真子串,则next[j]的取值就是k,即模式串的最长子串的长度;第三种情况,如果模式串中不存在重叠的子串,则从子串的第一个字符开始比较。   KMP算法的模式匹配过程:如果模式串T中存在真子串"t0t1…tk-1"="tj-ktj-k+1…tj-1",当模式串T与主串S的si不相等,则按照next[j]=k将模式串向右滑动,从主串中的si与模式串的tk开始比较。如果si=tk,则主串与子串的指针各自增1,继续比较下一个字符。如果si≠tk,则按照next[next[j]]将模式串继续向右滑动,将主串中的si与模式串中的next[next[j]]字符进行比较。如果仍然不相等,则按照以上方法,将模式串继续向右滑动,直到next[j]=-1为止。这时,模式串不再向右滑动,比较s+1与t0。利用next函数的模式匹配过程如图5-10所示。 图5-10 利用next函数的模式匹配过程   利用模式串T的next函数值求T在主串S中的第pos个字符之后的位置的KMP算法描述如下。 int KMP_Index(SeqString S,int pos,SeqString T,int next[]) /*KMP模式匹配算法。利用模式串T的next函数在主串S中的第pos个位置开始查找模式串T,如果找到返回模式串在主串中的位置;否则,返回-1*/ { int i,j; i=pos-1; j=0; while(i=T.length) /*匹配成功,返回子串在主串中的位置*/ return i-T.length+1; else /*否则返回-1*/ return -1; }   2. 求next函数值   KMP模式匹配算法是建立在模式串的next函数值已知的基础上的。下面来讨论如何求模式串的next函数值。   从上面的分析可以看出,模式串的next函数值的取值与主串无关,仅与模式串相关。根据模式串next函数定义,next函数值可用递推的方法得到。   设next[j]=k,表示在模式串T中存在以下关系:   "t0t1…tk-1"="tj-ktj-k+1…tj-1"   其中,0k满足以上等式。那么计算next[j+1]的值可能有如下两种情况出现。   (1)如果tj=tk,则表示在模式串T中满足关系"t0t1…tk"="tj-ktj-k+1…tj",并且不可能存在k' >k满足以上等式。因此,有next[j+1]=k+1,即next[j+1]=next[j]+1。   (2)如果tj≠tk,则表示在模式串T中满足关系"t0t1…tk"≠"tj-ktj-k+1…tj"。在这种情况下,可以把求next函数值的问题看成一个模式匹配的问题。目前已经有"t0t1…tk-1"="tj-ktj-k+1…tj-1",但是tj≠tk,把模式串T向右滑动到k'=next[k],如果有tj=tk',则表示模式串中有"t0t1…tk'"="tj-k'tj-k'+1…tj",因此有next[j+1]=k'+1,即next[j+1]=next[k]+1。   如果tj≠tk',则将模式串继续向右滑动到第next[k']个字符与tj比较。如果仍不相等,则将模式串继续向右滑动到下标为next[next[k']]字符与tj比较。以此类推,直到tj和模式串中某个字符匹配成功或不存在任何k'(1 #include #include #include"SeqString.h" int B_FIndex(SeqString S,int pos,SeqString T,int *count); int KMP_Index(SeqString S,int pos,SeqString T,int next[],int *count); void GetNext(SeqString T,int next[]); void GetNextVal(SeqString T,int nextval[]); void PrintArray(SeqString T,int next[],int nextval[],int length); void main() { SeqString S,T; int count1=0,count2=0,count3=0,find; int next[40],nextval[40]; /*第1个例子*/ StrAssign(&S,"cabaadcabaababaabacabababab"); /*给主串S赋值*/ StrAssign(&T,"abaabacababa"); /*给模式串T赋值*/ GetNext(T,next); /*求next函数值*/ GetNextVal(T,nextval); /*求改进后的next函数值*/ printf("模式串T的next和改进后的next值:\n"); PrintArray(T,next,nextval,StrLength(T)); /*输出模式串T的next值和nextval值*/ find=B_FIndex(S,1,T,&count1); /*朴素模式串匹配*/ if(find>0) printf("Brute-Force算法的比较次数为:%2d\n",count1); find=KMP_Index(S,1,T,next,&count2); if(find>0) 1 printf("利用next的KMP算法的比较次数为:%2d\n",count2); find=KMP_Index(S,1,T,nextval,&count3); if(find>0) printf("利用nextval的KMP匹配算法的比较次数为:%2d\n",count3); /*第2个例子*/ StrAssign(&S,"abccbaaaababcabcbccabcbcabccbcbcb"); /*给主串S赋值*/ StrAssign(&T,"abcabcbc"); /*给模式串T赋值*/ GetNext(T,next); /*求next函数值*/ GetNextVal(T,nextval); /*求改进后的next函数值*/ printf("模式串T的next和改进后的next值:\n"); PrintArray(T,next,nextval,StrLength(T)); /*输出模式串T的next值和nextval值*/ find=B_FIndex(S,1,T,&count1); /*朴素模式串匹配*/ if(find>0) printf("Brute-Force算法的比较次数为:%2d\n",count1); find=KMP_Index(S,1,T,next,&count2); if(find>0) printf("利用next的KMP算法的比较次数为:%2d\n",count2); find=KMP_Index(S,1,T,nextval,&count3); if(find>0) printf("利用nextval的KMP匹配算法的比较次数为:%2d\n",count3); } void PrintArray(SeqString T,int next[],int nextval[],int length) /*模式串T的next值与nextval值输出函数*/ { int j; printf("j:\t\t"); for(j=0;j0)称为数组的维数,ji=0,1,…,bi-1,其中,0≤i≤n。bi是数组的第i维长度,ji是数组的第i维下标}。在一个二维数组中,若把数组看成由列向量组成的线性表,那么元素aij的前驱元素是ai-1, j,后继元素是ai+1, j;若把数组看成由行向量组成的线性表,那么元素aij的前驱元素是ai, j-1,后继元素是ai, j+1。   数组是一个特殊的线性表。   2. 基本操作集合   (1)InitArray(&A,n,bound1,…,boundn):初始化操作。如果维数和各维的长度合法,则构造数组A,并返回1,表示成功。   (2)DestroyArray(&A):销毁数组操作。   (3)GetValue(A,&e,index1,…,indexn):返回数组的元素操作。如果下标合法,将数组A中对应的元素赋值给e,并返回1,表示成功。   (4)AssignValue(&A,e,index1,…,indexn):设置数组的元素值操作。如果下标合法,将数组A中由下标index1,…,indexn指定的元素值置为e。   (5)LocateArray(A,ap,&offset):数组的定位操作。根据数组的元素下标,求出该元素在数组中的相对地址。 5.4.3 数组的顺序存储结构   计算机中的存储器结构是一维(线性)结构,而数组是一个多维结构,如果要将一个多维结构存放在一个一维的存储单元里,就需要先将多维的数组转换成一个一维线性序列,才能将其存放在存储器中。   数组的存储方式有两种,一种是以行序为主序的存储方式,另一种是以列序为主序的存储方式,对于如图5-14所示的数组A来说,二维数组A以行序为主序的存储顺序为a0,0,a0,1,…, a0,n-1,a1,0,a1,1,…,a1,n-1,…,am-1,0,am-1,1,…,am-1,n-1,以列序为主序的存储顺序为a0,0,a1,0,…,am-1,0,a0,1, a1,1,…,am-1,1,…,a0,n-1,a1,n-1,…,am-1,n-1。   根据数组的维数和各维的长度就能为数组分配存储空间。因为数组中的元素连续存放,所以任意给定一个数组的下标,就可以求出相应数组元素的存储位置。 图5-14 数组在内存中的存放形式   下面说明以行序为主序的数组元素的存储地址与数组的下标之间的关系。设每个元素占m个存储单元,则二维数组A中的任何一个元素aij的存储位置可以由以下公式确定。   Loc(i, j)=Loc(0,0)+(i×n+j)×m   其中,Loc(i, j)表示元素aij的存储地址,Loc(0,0)表示元素a00的存储地址,即二维数组的起始地址(也称为基地址)。   推广到更一般的情况,可以得到n维数组中数据元素的存储地址与数组的下标之间的关系为Loc(j1, j2,…, jn)=Loc(0,0,…,0)+(b1×b2×…×bn-1×j0+b2×b3×…×bn-1×j1+…+bn-1×jn-2+jn-1)×L。   其中,bi(1≤i≤n-1)是第i维的长度,ji是数组的第i维下标。   数组的顺序存储结构类型定义如下。 #define MaxArraySize 3 #include /*标准头文件,包含va_start、va-arg、va_end宏定义*/ typedef struct { DataType *base; /*数组元素的基地址*/ int dim; /*数组的维数*/ int *bounds; /*数组的每一维之间的界限的地址*/ int *constants; /*数组存储映像常量基地址*/ }Array;   其中,base是数组元素的基地址,dim是数组的维数,bounds是数组每一维之间的界限的地址,constants是数组存储映像常量基地址。   数组的顺序存储结构如图5-15所示。 图5-15 数组的顺序存储结构 5.5 特殊矩阵的压缩存储   矩阵是科学计算、工程数学,尤其是数值分析经常研究的对象。在高级语言中,通常使用二维数组来存储矩阵。在有些高阶矩阵中,非零元素非常少,此时若使用二维数组将造成存储空间的浪费,这时可只存储部分元素,从而提高存储空间的利用率。这种存储方式称为矩阵的压缩存储。压缩存储指的是为多个相同值的元素只分配一个存储单元,对值为零的元素不分配存储单元。   非零元素非常少(远小于m×n)或元素分布呈一定规律的矩阵称为特殊矩阵。 5.5.1 对称矩阵的压缩存储   如果一个n阶的矩阵A中的元素满足aij=aji (0≤i, j≤n-1),则称这种矩阵为n阶对称矩阵。   对于对称矩阵,每一对对称元素值相同,只需要为每一对对称元素其中之一分配一个存储空间,这样就可以用n(n+1)/2个存储单元存储n2个元素。n阶对称矩阵A和下三角矩阵如图5-16所示。 图5-16 n阶对称矩阵与下三角矩阵   假设用一维数组s存储对称矩阵A的上三角或下三角元素,则一维数组s的下标k与n阶对称矩阵A的元素aij之间的对应关系为。   当i≥j时,矩阵A以下三角形式存储,为矩阵A中元素的线性序列编号;当im,&M->n,&M->len); if(M->len>MaxSize) return 0; for(i=0;ilen;i++) { do { printf("请按行序顺序输入第%d个非零元素所在的行(0~%d),列(0~%d),元素值:", i+1,M->m-1,M->n-1); scanf("%d,%d,%d",&m,&n,&e); flag=0; /*初始化标志位*/ if(m<0||m>M->m||n<0||n>M->n) /*如果行号或列号正确,标志位为1*/ flag=1; /*若输入的顺序正确,则标志位为1*/ if(i>0&&mdata[i-1].i||m==M->data[i-1].i&&n<=M->data[i-1].j) flag=1; }while(flag); M->data[i].i=m; M->data[i].j=n; M->data[i].e=e; } return 1; }   (2)复制稀疏矩阵。为了得到稀疏矩阵M的一个副本N,只需将稀疏矩阵M的非零元素的行号、列号及元素值依次赋给矩阵N的行号、列号及元素值。复制稀疏矩阵的算法实现如下。 void CopyMatrix(TriSeqMatrix M,TriSeqMatrix *N) /*由稀疏矩阵M复制得到另一个副本N*/ { int i; N->len=M.len; /*修改稀疏矩阵N的非零元素的个数*/ N->m=M.m; /*修改稀疏矩阵N的行数*/ N->n=M.n; /*修改稀疏矩阵N的列数*/ for(i=0;idata[i].i=M.data[i].i; N->data[i].j=M.data[i].j; N->data[i].e=M.data[i].e; } }   (3)转置稀疏矩阵。转置稀疏矩阵就是将矩阵中的元素由原来的存放位置(i, j)变为(j, i),也就是说,将元素的行列互换。例如,如图5-21所示的6×7矩阵,经过转置后变为7×6矩阵,并且矩阵中的元素也要以主对角线为准进行交换。   将稀疏矩阵转置的方法是将矩阵M的三元组中的行和列互换,就可以得到转置后的矩阵N,如图5-24所示。稀疏矩阵的三元组顺序表转置过程如图5-25所示。 图5-24 稀疏矩阵转置 图5-25 矩阵转置的三元组表示   行列下标互换后,还需要将行、列下标重新进行排序,才能保证转置后的矩阵也是以行序优先存放的。为了避免这种排序,以矩阵中列序顺序优先的元素进行转置,然后按照顺序依次存放到转置后的矩阵中,这样经过转置后得到的三元组顺序表正好是以行序为主序存放的。具体算法实现大致有两种。   (1)逐次扫描三元组顺序表M,第1次扫描M,找到j=0的元素,将行号和列号互换后存入到三元组顺序表N中,即找到(3,0,9),将行号和列号互换,把(3,0,9)直接存入N中,作为N的第一个元素。然后第2次扫描M,找到j=1的元素,将行号和列号互换后存入三元组顺序表N中;以此类推,直到所有元素都存放至N中,最后得到的三元组顺序表N如图5-26所示。 图5-26 稀疏矩阵转置的三元组顺序表表示   稀疏矩阵转置的算法实现如下。 void TransposeMatrix(TriSeqMatrix M,TriSeqMatrix *N) /*稀疏矩阵的转置*/ { int i,k,col; N->m=M.n; N->n=M.m; N->len=M.len; if(N->len) { k=0; for(col=0;coldata[k].i=M.data[i].j; N->data[k].j=M.data[i].i; N->data[k].e=M.data[i].e; k++; } } }   通过分析该转置算法,其时间主要耗费在for语句的两层循环上,故算法的时间复杂度是O(n×len),即与M的列数及非零元素的个数成正比。我们知道,一般矩阵的转置算法为: for(col=0;coln=M.m; N->m=M.n; N->len=M.len; if(N->len) { for(col=0;coldata[k].i=M.data[i].j; N->data[k].j=M.data[i].i; N->data[k].e=M.data[i].e; position[col]++; /*修改下一个非零元素应该存放的位置*/ } } free(num); free(position); }   先扫描M,得到M中每一列非零元素的个数,存放到num中。然后根据num[col]和position[col]的关系,求出N中每一行第一个非零元素的位置。初始时,position[col]是M的第col列第一个非零元素的位置,每个M中的第col列的非零元素存入N中,则将position[col]加1,使position[col]的值始终为下一个要转置的非零元素应存放的位置。   该算法中有4个并列的单循环,循环次数分别为n和M.len,因此总的时间复杂度为O(n+len)。当M的非零元素个数len与m×n处于同一个数量级时,算法的时间复杂度变为O(m×n),与经典的矩阵转置算法时间复杂度相同。   (3)销毁稀疏矩阵,代码如下。 void DestroyMatrix(TriSeqMatrix *M) /*销毁稀疏矩阵*/ { M->m=M->n=M->len=0; } 5.6.5 稀疏矩阵应用举例──三元组表示的稀疏矩阵相加   【例5-3】有两个稀疏矩阵A和B,相加得到C,如图5-27所示。请利用三元组顺序表实现两个稀疏矩阵的相加,并输出结果。 图5-27 三元组顺序表表示的稀疏矩阵的相加   提示:矩阵中两个元素相加可能会出现如下3种情况。   (1)A中的元素aij≠0且B中的元素bij≠0,但是结果可能为零,如果结果为零,则不保存元素值;如果结果不为零,则将结果保存在C中。   (2)A中的第(i, j)个位置存在非零元素aij,而B中不存在非零元素,则只需要将该值赋值给C。   (3)B中的第(i, j)个位置存在非零元素bij,而A中不存在非零元素,则只需要将bij赋值给C。   两个稀疏矩阵相加的算法实现如下。 int AddMatrix(TriSeqMatrix A,TriSeqMatrix B,TriSeqMatrix *C) /*将两个矩阵A和B对应的元素值相加,得到另一个稀疏矩阵C*/ { int m=0,n=0,k=-1; if(A.m!=B.m||A.n!=B.n) /*如果两个矩阵的行数与列数不相等,则不能够进行相加运算*/ return 0; C->m=A.m; C->n=A.n; while(mdata[++k]=A.data[m++]; /*将矩阵A,即行号小的元素赋值给C*/ break; case 0: /*如果矩阵A和B的行号相等,则比较列号*/ switch(CompareElement(A.data[m].j,B.data[n].j)) { case -1: /*如果A的列号小于B的列号,则将矩阵A的元素赋值给C*/ C->data[++k]=A.data[m++]; break; case 0: /*如果A和B的行号、列号均相等,则将两元素相加,存入C*/ C->data[++k]=A.data[m++]; C->data[k].e+=B.data[n++].e; if(C->data[k].e==0) /*如果两个元素的和为0,则不保存*/ k--; break; case 1: /*如果A的列号大于B的列号,则将矩阵B的元素赋值给C*/ C->data[++k]=B.data[n++]; } break; case 1: /*如果A的行号大于B的行号,则将矩阵B的元素赋值给C*/ C->data[++k]=B.data[n++]; } } while(mdata[++k]=A.data[m++]; while(ndata[++k]=B.data[n++]; C->len=k+1; /*修改非零元素的个数*/ if(k>MaxSize) return 0; return 1; }   m和n分别为矩阵A和B的当前处理的非零元素下标,初始时为0。需要特别注意的是,最后求得的非零元素个数为k+1,k为非零元素最后一个元素的下标。   程序运行结果如图5-28所示。 图5-28 两个稀疏矩阵相加程序运行结果   两个稀疏矩阵A和B相减的算法实现与相加算法实现类似,只需要将相加算法中的+改成-即可,也可以将第二个矩阵的元素值都乘上-1,然后调用矩阵相加的函数即可。稀疏矩阵相减的算法实现如下。 int SubMatrix(TriSeqMatrix A,TriSeqMatrix B,TriSeqMatrix *C) /*稀疏矩阵的相减*/ { int i; for(i=0;i1时,其余n-1个结点可以划分为m个有限集合T1,T2,…,Tm,且这m个有限集合不相交,其中,Ti(1≤i≤m)又是一棵树,称为根的子树。   当n=0时,称为空树;当n>0时,称为非空树。树的逻辑结构如图6-1所示。 (a)只有根结点的树 (b)一般的树 图6-1 树的逻辑结构   图6-1(a)是一棵只有根结点的树,图6-1(b)是一棵有13个结点的树,其中,A是根结点,其余结点分成3个互不相交的子集T1={B,E,F,K,L},T2={C,G,H,I,M}和T3={D,J}。其中,T1、T2和T3分别是一棵树,它们都是根结点A的子树。T1的根结点是B,其余的4个结点又分为两个不相交的子集T11={E,K,L}和T12={F}。其中,T11和T12都是T1的子树,E是T11的根结点,{K,L}是E的子树。   如图6-1(b)所示的树看上去像一棵颠倒过来的树,根结点就像是树根,子树像一棵树的枝杈。一棵树中只有一个根结点。树的最末端的结点称为叶子结点,即K、L、F、G、H、M和J都是叶子结点,类似树的叶子,这些结点没有子树。   一棵树的根与子树是一对多的关系,例如,结点C有3棵子树T21={G}、T22={H}和T23={I,M},而T21、T22和T23只有一个根结点C。 6.1.2 树的相关概念   树的结点:包含一个数据元素及指向其他结点的分支信息。   结点的度(degree):结点的子树的个数称为结点的度。例如,结点B有两棵子树,因此度为2。   树的度:树中各结点的度的最大值。例如,图6-1(b)中的树的度为3,因为结点A和C的度都为3,它们是树中拥有最大度的结点。   叶子结点:也称为终端结点,度为零的结点即没有子树的结点称为叶子结点。例如,结点K、L、F、G、H、M和J都是叶子结点。   非终端结点:度不为零的结点也称为分支结点。例如,A、B、C、D、E、I等都是非终端结点。   孩子(child)与双亲(parent):结点的子树的根称为孩子,相应地,该结点称为双亲。例如,{G,M}是根结点C的子树,而C又是这棵子树的根结点,因此,G是C的孩子。而C是G的双亲。   兄弟(sibling):同一个双亲的孩子之间互称为兄弟。例如,E和F是B的孩子,故E和F互为兄弟,同理,G、H和I互为兄弟。   祖先与子孙:从根结点到达一个结点所经分支上的所有结点称为该结点的祖先。反之,以某结点为根的子树中的任一结点都称为该结点的子孙。例如,I、C和A都为M的祖先,{E,F,K,L}是B的子树,故E、F、K和L都是B的子孙。   结点的层次:从根结点起,根结点为第1层,根结点的孩子结点为第2层,依此类推,如果某一个结点是第L层,则其孩子结点位于第L+1层。在图6-1(b)所示的树中,'A'的层次为1,'B'的层次为2,'G'的层次为3,'M'的层次为4。   树的深度(depth):树中所有结点的层次最大值称为树的深度,也称为树的高度。例如,图6-1(b)中树的深度为4。   有序树:如果树中各棵子树之间是有先后次序的,则称该树为有序树。   无序树:如果树中各棵子树之间没有先后次序,则称该树为无序树。   森林(forest):m棵互不相交的树构成一个森林。若把一棵非空的树的根结点删除,则该树就变成了一个森林,森林中的树由原来的根结点的各棵子树构成。反之,把一个森林加上一个根结点,则该森林就变成一棵树。 6.1.3 树的逻辑表示   树的逻辑表示方法可以分为树形表示法、文氏图表示法、广义表表示法和凹入表示法4种。   树形表示法如图6-1所示。树形表示法是最常见的表示法,它能直观、形象地表示出树的逻辑结构和结点之间的关系。   文氏图是一种集合表示法,对于其中任意两个集合,或者不相交,或者一个包含另一个。文氏图表示法如图6-2所示。   根作为由子树森林组成的表的名字写在表的左边,如图6-1(b)所示的树可用广义表表示如下。   (A(B(E(K,L),F),C(G,H,I(M)),D(J)))   如图6-1(b)所示的树采用凹入表示法如图6-3所示。 图6-2 树的文氏图表示法 图6-3 树的凹入法表示法   在这4种表示树的形式中,比较常见的是树形表示法和广义表表示法。 6.1.4 树的存储结构   通常情况下,树的存储结构有双亲表示法、孩子表示法和孩子兄弟表示法3种。   1. 双亲表示法   双亲表示法是利用一组连续的存储单元存储树的每个结点,并利用一个指示器表示结点的双亲结点在树中的相对位置。双亲表示法中结点结构如图6-4所示。   其中,data存放数据元素信息,parent存放该结点的双亲在数组中的下标。   在C语言中,通常采用数组存储树中的结点,这类似于静态链表的实现。一棵树结构及树的双亲表示法如图6-5所示。 图6-4 双亲表示法的结点结构 图6-5 树的双亲表示法   其中,树的根结点的双亲位置用-1表示。   在采用双亲表示法存储树结构时,根据给定结点查找其双亲结点非常容易,可通过反复调用求双亲结点,很快能找到树的根结点。采用双亲表示法的树类型定义如下。 #define MaxSize 200 typedef struct Pnode /*双亲表示法的结点定义*/ { DataType data; int parent; /*指示结点的双亲*/ }PNode; typedef struct /*双亲表示法的树结构类型定义*/ { PNode node[MaxSize]; /*存储PNode结点类型数据*/ int num; /*结点的个数*/ }PTree;   2. 孩子表示法   孩子表示法是将双亲结点的孩子结点构成一个链表,然后让双亲结点指向这个链表,这样的链表称为孩子链表。若树中有n个结点,就有n个孩子链表。n个结点的数据及头指针构成一个顺序表。如图6-5所示的树的孩子表示法如图6-6所示,其中,^表示空。   为此,需要设计两个结点结构,一个是孩子链表的孩子结点,如图6-6所示,child是数据域,存放结点在表头数组中的下标,next是指针域,存放指向下一个孩子结点的指针;另一个是表头数组的表头结点,如图6-7所示,data存储结点的数据信息,firstchild存储孩子链表的头指针。 图6-6 树的孩子表示法 图6-7 表头结点   树的孩子表示法的类型定义如下。 #define MaxSize 200 typedef struct CNode /*孩子结点结构*/ { int child; struct CNode*next; /*指向下一个结点*/ }ChildNode; typedef struct /*表头结点结构*/ { DataType data; ChildNode *firstchild; /*孩子链表的指针*/ }DataNode; typedef struct /*孩子表示法树的类型定义*/ { DataNode node[MaxSize]; int num,root; /*结点的个数,根结点在顺序表中的位置*/ }CTree;   树的孩子表示法使得已知一个结点查找其孩子结点变得非常容易。通过表头结点指向的链表,找到该结点的每个孩子结点。但是查找双亲结点并不方便,这可将双亲表示法与孩子表示法结合起来,即在结点的顺序表中增加一个表示双亲结点位置的域,这样无论是查找双亲结点还是孩子结点都非常方便,图6-8就是将两者结合起来的带双亲的孩子链表。   3. 孩子兄弟表示法   孩子兄弟表示法也称为树的二叉链表表示法。孩子兄弟表示法采用链表作为存储结构,结点包含一个数据域和两个指针域。数据域存放结点的数据信息,一个指针域用来指示结点的第一个孩子结点,另一个指针域用来指示结点的下一个兄弟结点。   如图6-5所示的树对应的孩子兄弟表示及结点结构如图6-9所示。 图6-8 带双亲的孩子链表 图6-9 树的孩子兄弟表示法   树的孩子兄弟表示法的类型定义如下。 typedef struct CSNode /*孩子兄弟表示法的类型*/ { DataType data; struct CSNode*firstchild,*nextsibling; /*指向第一个孩子和下一个兄弟*/ }CSNode,*CSTree;   其中,指针firstchild指向结点的第一个孩子结点,nextsibling指向结点的下一个兄弟结点。   孩子兄弟表示法是树的最常用的存储结构,利用树的孩子兄弟表示法可以实现树的各种操作。例如,要查找树中D的第3个孩子结点,则只需要从D的firstchild找到第一个孩子结点,然后顺着结点的nextsibling域走两步,就可以找到D的第3个孩子结点H。 6.2 二叉树的相关概念及抽象数据类型   在探讨一般的树之前,先研究一种比较特殊的树──二叉树。二叉树具有树的一般特性,与一般的树相比,它的结构简单,更有利于读者对树这个抽象概念的掌握。 6.2.1 什么是二叉树   二叉树(binary tree)是由n(n≥0)个结点构成的另一种树结构。它的特点是每个结点最多只有两棵子树(即二叉树中不存在度大于2的结点),并且二叉树的子树有左右之分(称为左孩子和右孩子),次序不能颠倒。若n=0,则称该二叉树为空二叉树。   在二叉树中,任何一个结点的度只可能是0、1和2。   二叉树有5种基本形态,如图6-10所示。   在如图6-11所示的二叉树中,D是B的左孩子结点,E是B的右孩子结点,H是E的左孩子结点,D既没有左孩子结点也没有右孩子结点。 图6-10 二叉树的5种基本形态 图6-11 二叉树 6.2.2 二叉树的性质   二叉树具有以下重要的性质。   性质1 二叉树的第k层上至多有2k-1个结点(k≥1)。   证明:利用数学归纳法证明此性质。   (1)当k=1时,只有一个根结点,显然有2k-1=21-1=20=1,命题成立。   (2)假设对于所有的j(11,则其双亲结点为。   (2)如果2i>n,则结点i无左孩子。否则,其左孩子是2i。   (3)如果2i+1>n,则结点i无右孩子。否则,其右孩子是2i+1。   证明:(1)只要先证明了性质(2)和性质(3),便可由(2)和(3)得到性质(1)。   当i=1时,该结点一定是根结点,根结点没有双亲结点。当i>1时,分为两种情况讨论。   设编号为m的结点是编号为i的双亲结点。如果编号为i的结点是序号为m的结点的左孩子结点,则根据性质(2)有2m=i,即m=i/2。   如果编号为i的结点是编号为m结点的右孩子结点,则根据性质(3)有2m+1=i,即m=(i-1)/2=i/2-1/2。综合以上两种情况,当i>1时,编号为i的结点的双亲结点编号为。结论得证。   (2)利用数学归纳法。当i=1时,有2i=2,如果2>n,则二叉树中不存在编号为2的结点,也就不存在编号为i的左孩子结点。如果2≤n,则该二叉树中存在两个结点,编号2是编号为i的结点的左孩子结点的编号。   假设编号i=k时,当2k≤n时,编号为k的结点的左孩子结点存在且编号为2k,当2k>n时,编号为k的结点的左孩子结点不存在。   当i=k+1时,在完全二叉树中,如果编号为k+1的结点的左孩子结点存在(2i≤n),则其左孩子结点的编号为k的结点的右孩子结点编号加1,即编号为k+1的结点的左孩子结点编号为(2k+1)+1=2(k+1)=2i。因此,当2i >n时,编号为i的结点的左孩子不存在。结论得证。   (3)利用数学归纳法。当i=1时,如果2i+1=3>n,则该二叉树中不存在序号为3的结点,即编号为i的结点的右孩子不存在。如果2i+1=3≤n,则该二叉树存在编号为3的结点,且序号为3的结点是编号为i的结点的右孩子结点。   假设编号i=k时,当2k+1≤n时,编号为k的结点的右孩子结点存在且编号为2k+1,当2k+1>n时,序号为k的结点的右孩子结点不存在。   当i=k+1时,在完全二叉树中,如果编号为k+1的结点的右孩子结点存在(2i+1≤n),则其右孩子结点的序号为编号为k的结点的右孩子结点序号加2,即编号为k+1的结点的右孩子结点编号为(2k+1)+2=2(k+1)+1=2i+1。因此,当2i+1>n时,编号为i的结点的右孩子不存在。结论得证。 6.2.3 二叉树的抽象数据类型   1. 数据对象集合   二叉树的数据对象集合为二叉树中的各个结点构成的集合。根结点没有双亲结点,其他结点只有一个双亲结点。每个结点的孩子可能是0个、1个和2个。   2. 基本操作集合   (1)InitBitTree(&T)。 操作结果:构造空二叉树T。   (2)CreateBitTree(&T)。 初始条件:二叉树T不存在。 操作结果:创建二叉树T。   (3)DestroyBitTree(&T)。 初始条件:二叉树T已存在。 操作结果:如果二叉树存在,则将该二叉树销毁。   (4)InsertChild(p,LR,c)。   初始条件:二叉树T存在,p指向T中某个结点,LR为0或1,c非空,与T不相交且右子树为空。   操作结果:根据LR为0或1,插入c为p所指结点的左子树或右子树,p所指结点的原有左子树或右子树成为c的右子树。插入成功返回1,否则返回0。   (5)LeftChild(&T,e)。   初始条件:二叉树T存在,e是T中的某个结点。   操作结果:如果结点e存在左孩子结点,则返回e的左孩子结点元素值,否则返回空。   (6)RigthChild(&T,e)。   初始条件:二叉树T存在,e是T中的某个结点。 操作结果:如果结点e存在右孩子结点,则返回e的右孩子结点元素值,否则返回空。   (7)DeleteChild(p,int LR)。   初始条件:二叉树T存在,p指向T中的某个结点,LR为0或1。   操作结果:根据LR为0或1,删除T中p所指向的左子树或右子树。如果删除成功,返回1,否则返回0。   (8)PreOrderTraverse(T)。   初始条件:二叉树T存在。   操作结果:先序遍历二叉树T。二叉树的先序遍历,就是按照先访问根结点,再访问左子树,最后访问右子树的顺序,对每个结点进行访问且仅访问一次的操作。   (9)InOrderTraverse(T)。   初始条件:二叉树T存在。   操作结果:中序遍历二叉树。二叉树的中序遍历,就是按照先访问左子树,再访问根结点,最后访问右子树的次序,对二叉树中的每个结点进行访问且仅访问一次的操作。   (10)PostOrderTraverse(T)。   初始条件:二叉树T存在。   操作结果:后序遍历二叉树T。二叉树的后序遍历,就是按照先访问左子树,再访问右子树,最后访问根结点的次序,对二叉树中的每个结点进行访问且仅访问一次的操作。   (11)LevelTraverse(T)。   初始条件:二叉树T存在。   操作结果:层次遍历二叉树T。二叉树的层次遍历,就是按照从上到下,从左到右,依次对二叉树中的每个结点进行访问。   (12)BitTreeDepth(T)。   初始条件:二叉树T存在。   操作结果:求二叉树T的深度。二叉树的深度即二叉树的结点层次的最大值。如果二叉树非空,返回二叉树的深度;如果二叉树为空,返回0。 6.2.4 二叉树的存储表示与实现   二叉树存储结构也有顺序存储和链式存储两种。其中,链式存储是二叉树最常用的存储结构。   1. 二叉树的顺序存储   完全二叉树的存储可以按照从上到下、从左到右的顺序依次存储在一维数组中。完全二叉树的顺序存储如图6-16所示。   如果按照从上到下、从左到右的顺序把非完全二叉树也进行同样的编号,将结点依次存放在一维数组中。为了能够正确反映二叉树中结点之间的逻辑关系,需要在一维数组中将二叉树中不存在的结点位置空出,并用“/\”填充。非完全二叉树的顺序存储结构如图6-17所示。    图6-16 完全二叉树的顺序存储表示 图6-17 非完全二叉树的顺序存储表示   顺序存储对于完全二叉树来说是比较适合的。但是,对于非完全二叉树来说,这种存储方式会浪费内存空间。在最坏的情况下,如果每个结点只有右孩子结点,而没有左孩子结点,则需要占用2k-1个存储单元,而实际上,该二叉树只有k个结点。   2. 二叉树的链式存储   在二叉树中,每个结点有一个双亲结点和两个孩子结点。从一棵二叉树的根结点开始,通过结点的左右孩子地址就可以找到二叉树的每一个结点。因此二叉树的链式存储结构包括三个域:数据域、左孩子指针域和右孩子指针域。其中,数据域存放结点的值,左孩子指针域指向左孩子结点,右孩子指针域指向右孩子的结点。这种链式存储结构称为二叉链表存储结构,如图6-18所示。 图6-18 二叉链表存储结构结点   如果二叉树采用二叉链表存储结构表示,其二叉树的存储表示如图6-19所示。 图6-19 二叉树的二叉链表存储表示   有时为了方便找到结点的双亲结点,在二叉链表的存储结构中增加一个指向双亲结点的指针域parent。该结点的存储结构如图6-20所示。这种存储结构称为三叉链表结点存储结构。 图6-20 三叉链表结点结构   通常情况下,二叉树采用二叉链表进行表示。二叉链表存储结构的类型定义描述如下。 typedef struct Node /*二叉链表的结点结构*/ { DataType data; /*数据域*/ struct Node *lchild; /*指向左孩子结点*/ struct Node *rchild; /*指向右孩子结点*/ }*BiTree,BitNode; 6.3 遍历二叉树   在二叉树的一些应用中,经常需要在树中查找具有某种特征的结点,这就是二叉树的遍历问题。 6.3.1 什么是遍历二叉树   遍历二叉树即按照某种规律对二叉树的每个结点进行访问,使得每个结点仅被访问一次的操作。这里的访问可以是统计结点的数据信息、输出结点信息等。   二叉树的遍历不同于线性表的遍历,对于二叉树来说,每个结点有两棵子树,因而需要寻找一种规律,使得二叉树的结点能排列在一个线性队列上,从而便于遍历。从这个意义上讲,二叉树的遍历过程其实也是将二叉树的非线性序列转换成一个线性序列的过程。   回顾二叉树的定义,二叉树是由根结点、左子树和右子树构成。二叉树的基本结构如图6-21所示。如果能依次遍历这3个部分,就是遍历了整棵二叉树。如果用D、L、R分别代表遍历根结点、遍历左子树和遍历右子树,根据组合原理,有6种遍历方案,分别是DLR、DRL、LDR、LRD、RDL和RLD。 图6-21 二叉树的结点的基本结构   如果限定先左后右的次序,则以上6种遍历方案只剩下3种方案,分别是DLR、LDR和LRD。其中,DLR称为先序(根)遍历,LDR称为中序(根)遍历,LRD称为后序(根)遍历。   如果限定先左后右的次序,则在以上6种遍历方案中,只剩下3种方案:DLR、LDR和LRD。其中,DLR称为先序遍历,LDR称为中序遍历,LRD称为后序遍历。 6.3.2 二叉树的先序遍历   二叉树的先序遍历的递归定义如下。   如果二叉树为空,则执行空操作。如果二叉树非空,则执行以下操作。   (1)访问根结点。   (2)先序遍历左子树。   (3)先序遍历右子树。   根据二叉树的先序递归定义,得到图6-22的二叉树的先序序列为:A、B、D、G、E、H、I、C、F、J。   在二叉树先序遍历过程中,对每一棵二叉树重复执行以上的递归遍历操作,就可以得到先序序列。例如,在遍历根结点A的左子树{B,D,E,G,H,I}时,根据先序遍历的递归定义,先访问根结点B,然后遍历B的左子树为{D,G},最后遍历B的右子树为{E,H,I}。访问过B之后,开始遍历B的左子树{D,G},在子树{D,G}中,先访问根结点D,因为D没有左子树,所以遍历其右子树,右子树只有一个结点G,所以访问G。B的左子树遍历完毕,按照以上方法遍历B的右子树。最后得到结点A的左子树先序序列:B、D、G、E、H、I。   依据二叉树的先序递归定义,可以得到二叉树的先序递归算法。 void PreOrderTraverse(BiTree T) /*先序遍历二叉树的递归实现*/ { if(T) /*如果二叉树不为空*/ { printf("%2c",T->data); /*访问根结点*/ PreOrderTraverse(T->lchild); /*先序遍历左子树*/ PreOrderTraverse(T->rchild); /*先序遍历右子树*/ } }   下面来介绍二叉树的非递归算法实现。   算法实现:从二叉树的根结点开始,访问根结点,然后将根结点的指针入栈,重复执行以下两个步骤:①如果该结点的左孩子结点存在,访问左孩子结点,并将左孩子结点的指针入栈,重复执行此操作,直到结点的左孩子不存在;②将栈顶的元素(指针)出栈,如果该指针指向的右孩子结点存在,则将当前指针指向右孩子结点。重复执行以上两个步骤,直到栈空为止。以上算法思想的执行流程如图6-23所示。 图6-22 二叉树 图6-23 二叉树的先序遍历非递归算法执行流程图   二叉树的先序遍历非递归算法实现如下。 void PreOrderTraverse(BiTree T) /*先序遍历二叉树的非递归实现*/ { BiTree stack[MAXSIZE]; /*定义一个栈,用于存放结点的指针*/ int top; /*定义栈顶指针*/ BitNode *p; /*定义一个结点的指针*/ top=0; /*初始化栈*/ p=T; while(p!=NULL||top>0) { while(p!=NULL) /*如果p不空,则遍历左子树*/ { printf("%2c",p->data); /*访问根结点*/ stack[top++]=p; /*将p入栈*/ p=p->lchild; /*遍历左子树*/ } if(top>0) /*如果栈不空*/ { p=stack[--top]; /*栈顶元素出栈*/ p=p->rchild; /*遍历右子树*/ } } } 6.3.3 二叉树的中序遍历   二叉树的中序遍历的递归定义如下。   如果二叉树为空,则执行空操作。如果二叉树非空,则执行以下操作。   (1)中序遍历左子树。   (2)访问根结点。   (3)中序遍历右子树。   根据二叉树的中序递归定义,图6-22的二叉树的中序序列为:D、G、B、H、E、I、A、F、J、C。   在二叉树中序的遍历过程中,对每一棵二叉树重复执行以上的递归遍历操作,就可以得到二叉树的中序序列。   如果要中序遍历A的左子树{B,D,E,G,H,I},根据中序遍历的递归定义,需要先中序遍历B的左子树{D,G},然后访问根结点B,最后中序遍历B的右子树为{E,H,I}。在子树{D,G}中,D是根结点,没有左子树,因此访问根结点D,接着遍历D的右子树,因为右子树只有一个结点G,所以直接访问G。   在左子树遍历完毕之后,访问根结点B。最后要遍历B的右子树{E,H,I},E是子树{E,H,I}的根结点,需要先遍历左子树{H},因为左子树只有一个H,所以直接访问H,然后访问根结点E,最后要遍历右子树{I},右子树也只有一个结点,所以直接访问I,B的右子树访问完毕。因此,A的右子树的中序序列为:D、G、B、H、E和I。   从中序遍历的序列可以看出,A左边的序列是A的左子树元素,右边是A的右子树序列。同样,B的左边是其左子树的元素序列,右边是其右子树序列。根结点把二叉树的中序序列分为左右两棵子树序列,左边为左子树序列,右边是右子树序列。   依据二叉树的中序递归定义,可以得到二叉树的中序递归算法。 void InOrderTraverse(BiTree T) /*中序遍历二叉树的递归实现*/ { if(T) /*如果二叉树不为空*/ { InOrderTraverse(T->lchild); /*中序遍历左子树*/ printf("%2c",T->data); /*访问根结点*/ InOrderTraverse(T->rchild); /*中序遍历右子树*/ } }   二叉树的中序遍历非递归算法实现:从二叉树的根结点开始,将根结点的指针入栈,执行以下两个步骤:①如果该结点的左孩子结点存在,将左孩子结点的指针入栈,重复执行此操作,直到结点的左孩子不存在;②将栈顶的元素(指针)出栈,并访问该指针指向的结点,如果该指针指向的右孩子结点存在,则将当前指针指向右孩子结点。重复执行以上①和②,直到栈空为止。以上算法思想的执行流程如图6-24所示。 图6-24 二叉树的中序遍历非递归算法执行流程图   二叉树的中序遍历非递归算法实现如下。 void InOrderTraverse(BiTree T) /*中序遍历二叉树的非递归实现*/ { BiTree stack[MAXSIZE]; /*定义一个栈,用于存放结点的指针*/ int top; /*定义栈顶指针*/ BitNode *p; /*定义一个结点的指针*/ top=0; /*初始化栈*/ p=T; while(p!=NULL||top>0) { while(p!=NULL) /*如果p不空,访问根结点,遍历左子树*/ { stack[top++]=p; /*将p入栈*/ p=p->lchild; /*遍历左子树*/ } if(top>0) /*如果栈不空*/ { p=stack[--top]; /*栈顶元素出栈*/ printf("%2c",p->data); /*访问根结点*/ p=p->rchild; /*遍历右子树*/ } } } 6.3.4 二叉树的后序遍历   二叉树的后序遍历的递归定义如下。   如果二叉树为空,则执行空操作。如果二叉树非空,则执行以下操作。   (1)后序遍历左子树。   (2)后序遍历右子树。   (3)访问根结点。   根据二叉树的后序递归定义,图6-22的二叉树的后序序列为:G、D、H、I、E、B、J、F、C、A。   在二叉树后序的遍历过程中,对每一棵二叉树重复执行以上的递归遍历操作,就可以得到二叉树的后序序列。   例如,如果要后序遍历A的左子树{B,D,E,G,H,I},根据后序遍历的递归定义,需要先后序遍历B的左子树{D,G},然后后序遍历B的右子树为{E,H,I},最后访问根结点B。在子树{D,G}中,D是根结点,没有左子树,因此遍历D的右子树,因为右子树只有一个结点G,所以直接访问G,接着访问根结点D。   在左子树遍历完毕之后,需要遍历B的右子树{E,H,I},E是子树{E,H,I}的根结点,需要先遍历左子树{H},因为左子树只有一个H,所以直接访问H,然后遍历右子树{I},右子树也只有一个结点,所以直接访问I,最后访问子树{E,H,I}的根结点E。此时,B的左、右子树均访问完毕。最后访问结点B。因此,A的右子树的后序序列为:G、D、H、I、E和B。   依据二叉树的后序递归定义,可以得到二叉树的后序递归算法。 void PostOrderTraverse(BiTree T) /*后序遍历二叉树的递归实现*/ { if(T) /*如果二叉树不为空*/ { PostOrderTraverse(T->lchild); /*后序遍历左子树*/ PostOrderTraverse(T->rchild); /*后序遍历右子树*/ printf("%2c",T->data); /*访问根结点*/ } }   二叉树的后序遍历非递归算法实现:从二叉树的根结点开始,将根结点的指针入栈,执行以下两个步骤:①如果该结点的左孩子结点存在,将左孩子结点的指针入栈,重复执行此操作,直到结点的左孩子不存在;②取栈顶元素(指针)并赋给p,如果p->rchild==NULL或p->rchild=q,即p没有右孩子或右孩子结点已经访问过,则访问根结点,即p指向的结点,并用q记录刚刚访问过的结点指针,将栈顶元素退栈。如果p有右孩子且右孩子结点没有被访问过,则执行p=p->rchild。重复执行以上①和②,直到栈空为止。以上算法思想的执行流程如图6-25所示。 图6-25 二叉树的后序遍历非递归算法执行流程图   二叉树的后序遍历非递归算法实现如下。 void PostOrderTraverse(BiTree T) /*后序遍历二叉树的非递归实现*/ { BiTree stack[MAXSIZE]; /*定义一个栈,用于存放结点的指针*/ int top; /*定义栈顶指针*/ BitNode *p,*q; /*定义结点的指针*/ top=0; /*初始化栈*/ p=T,q=NULL; /*初始化结点的指针*/ while(p!=NULL||top>0) { while(p!=NULL) /*如果p不空,则遍历左子树*/ { stack[top++]=p; /*将p入栈*/ p=p->lchild; /*遍历左子树*/ } if(top>0) /*如果栈不空*/ { p=stack[top-1]; /*取栈顶元素*/ if(p->rchild==NULL||p->rchild==q) /*若 p无右孩子,或右孩子已访问过*/ { printf("%2c",p->data); /*访问根结点*/ q=p; p=NULL; top--; /*出栈*/ } else p=p->rchild; /*遍历右子树*/ } } } 6.4 遍历二叉树的应用   二叉树的遍历应用非常广泛,本节主要介绍如何利用遍历二叉树的算法思想输出二叉树及计算二叉树的结点。 6.4.1 按层次输出二叉树   打印输出二叉树的方式有多种,可以按照先序、中序、后序的方式输出二叉树,还可以按层次输出二叉树。   按层次输出二叉树的结点可利用队列实现,先定义一个队列queue,用来存放结点信息。从根结点出发,依次把每一层的结点入队,当一层结点入队完毕之后,将队头元素出队,输出该结点,然后判断结点是否存在左、右孩子,如果存在,则将左、右孩子入队。重复执行以上操作,直到队空为止。最后得到的输出序列就是按二叉树层次的输出序列。   按层次输出二叉树的算法实现如下。 void LevelPrint(BiTree T) /*按层次输出二叉树*/ { BiTree queue[MaxSize]; /*定义一个队列,用于存放结点的指针*/ BitNode *p; int front,rear; /*定义队列的队头指针和队尾指针*/ front=rear=-1; /*队列初始化为空*/ rear++; /*队尾指针加1*/ queue[rear]=T; /*将根结点指针入队*/ while(front!=rear) /*如果队列不为空*/ { front=(front+1)%MaxSize; p=queue[front]; /*取出队头元素*/ printf("%c",p->data); /*输出根结点*/ if(p->lchild!=NULL) /*如果左孩子不为空,将左孩子结点指针入队*/ { rear=(rear+1)%MaxSize; queue[rear]=p->lchild; } if(p->rchild!=NULL) /*如果右孩子不为空,将右孩子结点指针入队*/ { rear=(rear+1)%MaxSize; queue[rear]=p->rchild; } } } 6.4.2 二叉树的计数   二叉树的计数也可以通过遍历二叉树来实现,关于二叉树计数的算法有求二叉树叶子结点的个数、非叶子结点的个数。   1. 计算二叉树叶子结点的个数   求二叉树叶子结点的个数递归定义如下。      含义为,当二叉树为空时,叶子结点个数为0;当二叉树只有一个根结点时,根结点就是叶子结点,叶子结点个数为1;其他情况下,计算左子树与右子树中叶子结点的和。由此可得到统计叶子结点个数的算法。   求二叉树叶子结点个数的算法实现如下。 int LeafNum(BiTree T) /*求二叉树中叶子结点的个数*/ { if(!T) /*如果是空二叉树,返回0*/ return 0; else if(!T->lchild&&!T->rchild) /*如果左子树和右子树都为空,返回1*/ return 1; else return LeafNum(T->lchild)+LeafNum(T->rchild); /*将左子树与右子树叶子结点个数相加*/ }   2. 求二叉树的非叶子结点个数   二叉树的非叶子结点个数的递归定义如下。      含义为,当二叉树为空时,非叶子结点个数为0;当二叉树只有根结点时,根结点为叶子结点,非叶子结点个数为0;在其他情况下,非叶子结点个数为左子树与右子树中非叶子结点的个数再加1(根结点)。   求二叉树中非叶子结点个数的算法实现如下。 int NotLeafNum(BiTree T) /*求二叉树中非叶子结点的个数*/ { if(!T) /*如果是空二叉树*/ return 0; /*则返回0*/ else if(!T->lchild&&!T->rchild) /*如果是叶子结点*/ return 0; /*则返回0*/ else /*如果是非叶子结点,也不是根结点*/ return NotLeafNum(T->lchild)+NotLeafNum(T->rchild)+1; /*左右子树非叶子结点个数与根结点个数之和*/ }   3. 计算二叉树的所有结点数   二叉树的所有结点数的递归定义如下。      若二叉树为空,则结点个数为0;在二叉树不空的情况下,若左、右子树为空,则结点数为1;否则,二叉树的结点数为左、右子树的结点数之和加1。   求二叉树中所有结点个数的算法实现如下。 int AllNodes(BiTree T) /*求二叉树中所有结点的个数*/ { if(!T) /*如果是空二叉树*/ return 0; /*则返回0*/ else if(!T->lchild&&!T->rchild) /*如果是叶子结点*/ return 1; /*则返回1*/ else /*如果是非叶子结点,也不是根结点*/ return AllNodes(T->lchild)+AllNodes(T->rchild)+1; /*左右子树结点个数与根结点个数之和*/ }   4. 计算二叉树的深度   二叉树的深度递归定义如下。      含义为,当二叉树为空时,其深度为0;当二叉树只有根结点时,即结点的左、右子树均为空,二叉树的深度为1;在其他情况下,求二叉树的左、右子树深度的最大值再加1(根结点)。由此,得到二叉树的深度的算法如下。 int BitTreeDepth(BiTree T) /*计算二叉树的深度*/ { if(T == NULL) /*若二叉树为空*/ return 0; /*则深度为0*/ return BitTreeDepth(T->lchild)>BitTreeDepth(T->rchild)?1+BitTreeDepth(T->lchild): 1+BitTreeDepth(T->rchild); /*深度为二叉树的左、右子树深度的最大值加1*/ } 6.4.3 求叶子结点的最大最小枝长   求二叉树的所有叶子结点的最大枝长的递归模型如下。   求二叉树的所有叶子结点的最小枝长的递归模型如下。   相应的算法如下。 void MaxMinLeaf(BiTree T,int *max,int *min) /*计算叶子结点的最大最小枝长*/ { int m1,m2,n1,n2; if(T==NULL) /*二叉树为空,最大最小枝长为0*/ { *max=0; *min=0; } else { /*通过递归的方式分别求左右子树的最大枝长*/ MaxMinLeaf(T->lchild,m1,n1); MaxMinLeaf(T->rchild,m2,n2); *max=(m1>m2?m1:m2)+1; *min=(m1lchild,t2->lchild)) /*若左子树相似*/ return (BiTree_Like(t1->rchild,t2->rchild)); /*则两棵是否相似取决于右子树*/ else return 0; } 6.4.5 交换二叉树的左右子树   同样,在遍历二叉树的过程中也可以交换各个结点的左右子树。 Void BiTree_Swap(BiTree T) /*交换二叉树的左右子树*/ { BiTree p; if(T!=NULL) if(T->lchild!=NULL || T->rchild!=NULL) /*若T的两棵子树不同时为空,则交换两棵子树*/ { p=T->lchild; T->lchild=T->rchild; T->rchild=p; } if(T->lchild!=NULL) /*若T的左子树不为空,则将左子树的左右子树交换*/ BiTree_Swap(T->lchild); if(T->rchild!=NULL) /*若T的右子树不为空,则将右子树的左右子树交换*/ BiTree_Swap(T->rchild); }   注意:也可用后序遍历的方式实现交换左右两棵子树,但不宜用中序遍历的方式实现,这是因为若用中序遍历的算法,则仅交换了根结点的左右孩子。 6.4.6 求根结点到r结点之间的路径   假设二叉树采用二叉链表方式存储,root指向根结点,r所指结点为任一结点,试编写算法,求出从根结点到r结点之间的路径。   【分析】由于后序遍历的过程中,访问到r所指结点时,栈中所有结点均为r所指的祖先,这些祖先便构成了一条从根结点到r所指结点之间的路径,故可采用后序遍历。 void path(BiTree root, BitNode *r) /*求根结点到r结点之间的路径*/ { BitNode *p,*q; int i,top=0; BitNode *s[StackSize]; q=NULL; p=root; while(p!=NULL || top!=0) /*若树或栈不为空*/ { while(p!=NULL) /*遍历左子树*/ { top++; if(top>=StackSize) exit(-1); s[top]=p; p=p->lchild; } if(top>0) /*若栈不为空*/ { p=s[top]; if(p->rchild == NULL || p->rchild==q)/*若p的右孩子为空或右孩子已经被访问过*/ { if(p==r) /*找到r所指结点,则输出从根结点到r所指结点之间的结点*/ { for(i=1;i<=top;i++) printf("%4d",s[i]->data); top=0; } else { q=p; /*用q保存刚刚遍历过的结点*/ top--; p=NULL; } } else p=p->rchild; /*遍历右子树*/ } } }   本算法与后序非递归遍历二叉树算法唯一不同的地方是增加了如下语句。 if(p==r) /*若找到r所指结点,则输出从根结点到r所指结点之间的结点*/ { for(i=1;i<=top;i++) printf("%4d",s[i]->data); top=0; }   意即,如果找到r所指结点,则输出从根结点到r所指结点的路径。 6.5 线索二叉树   采用二叉链表作为二叉树的存储结构,只能找到结点的左、右孩子结点,而不能直接找到该结点的直接前驱和后继结点信息,这种信息只能在对二叉树的遍历过程中才能找到,显然这并不是最直接、最简便的方法。为了能快速找到任何一个结点的直接前驱和直接后继信息,需要对二叉树进行线索化。 6.5.1 什么是线索化二叉树   为了在遍历二叉树的过程中能直接找到任何一个结点的直接前驱结点或者直接后继结点,可在二叉链表结点中增加两个指针域,一个用来指示结点的前驱,另一个用来指示结点的后继。如果这样做,需要为每个结点增加两个域的存储单元,也会使结点结构的利用率大大下降。   在二叉链表的存储结构中,n个结点的二叉链表具有n+1个空指针域(根据二叉树的分支特点,分支数目为B=n-1,即非空链域为n-1个,故空链域有2n-(n-1)=n+1个)。   因此,可以利用这些空指针域存放结点的直接前驱和直接后继的信息。假定,若结点存在左子树,则指针域lchild指示其左孩子结点,否则指针域lchild指示其直接前驱结点;若结点存在右子树,则指针域rchild指示其右孩子结点,否则指针域rchild指示其直接后继结点。   另外增加两个标志域ltag和rtag,分别用来区分指针域指向的是左孩子结点还是直接前驱结点,右孩子结点还是直接后继结点,这样的结点存储结构如图6-26所示。   当ltag=0时,lchild指示结点的左孩子;当ltag=1时,lchild指示结点的直接前驱结点。当rtag=0时,rchild指示结点的右孩子;当rtag=1时,rchild指示结点的直接后继结点。   由这种存储结构构成的二叉链表称为二叉树的线索二叉树。采用这种存储结构的二叉链表称为线索链表。其中,指向结点直接前驱和直接后继的指针称为线索。在二叉树的先序遍历过程中,加上线索之后,得到先序线索二叉树。同理,在二叉树的中序(后序)遍历过程中,加上线索之后,得到中序(后序)线索二叉树。二叉树按照某种遍历方式使二叉树变为线索二叉树的过程称为二叉树的线索化。如图6-27所示就是将二叉树进行先序、中序和后序遍历得到的线索二叉树。 图6-27 二叉树的线索化   线索二叉树的存储结构类型描述如下。 typedef enum {Link,Thread}PointerTag; /*Link=0表示指向孩子结点,Thread=1表示指向前驱结点或后继结点*/ typedef struct Node /*线索二叉树存储结构类型定义*/ { DataType data; /*数据域*/ struct Node *lchild,rchild; /*指向左孩子结点的指针和右孩子结点的指针*/ PointerTag ltag,rtag; /*标志域*/ }*BiThrTree,BiThrNode; 6.5.2 线索二叉树   在二叉树遍历的过程中,可得到结点的前驱信息和后继信息,同时将结点的空指针域修改为其直接前驱或直接后继信息。因此,二叉树的线索化就是对二叉树的遍历过程。这里以二叉树的中序线索化为例介绍二叉树的线索化。   为了便于算法操作,在二叉链表中增加一个头结点。头结点的数据域可以存放二叉树的结点信息,也可以为空。令头结点的指针域lchild指向二叉树的根结点,指针域rchild指向二叉树中序遍历时的最后一个结点,二叉树中的第一个结点的线索指针指向头结点。在初始化时,使二叉树的头结点指针域lchild和rchild均指向头结点,并将头结点的标志域ltag置为Link,标志域rtag置为Thread。   线索化后的二叉树像一个循环链表,既可以从线索二叉树中的第一个结点出发沿着结点的后继线索指针遍历整个二叉树,也可以从线索二叉树的最后一个结点出发沿着结点的前驱线索指针遍历整个二叉树。经过线索化的二叉树及存储结构如图6-28所示。 图6-28 中序线索二叉树   中序线索二叉树的算法实现如下。 BiThrTree pre; /*pre始终指向已经线索化的结点*/ int InOrderThreading(BiThrTree *Thrt,BiThrTree T) /*通过中序遍历二叉树T,使T中序线索化。Thrt是指向头结点的指针*/ { if(!(*Thrt=(BiThrTree)malloc(sizeof(BiThrNode)))) /*为头结点分配内存单元*/ exit(-1); /*将头结点线索化*/ (*Thrt)->ltag=Link; /*修改前驱线索标志*/ (*Thrt)->rtag=Thread; /*修改后继线索标志*/ (*Thrt)->rchild=*Thrt; /*将头结点的rchild指针指向自己*/ if(!T) /*如果二叉树为空,则将lchild指针指向自己*/ (*Thrt)->lchild=*Thrt; else { (*Thrt)->lchild=T; /*将头结点的左指针指向根结点*/ pre=*Thrt; /*将pre指向已经线索化的结点*/ InThreading(T); /*中序遍历进行中序线索化*/ /*将最后一个结点线索化*/ pre->rchild=*Thrt; /*将最后一个结点的右指针指向头结点*/ pre->rtag=Thread; /*修改最后一个结点的rtag标志域*/ (*Thrt)->rchild=pre; /*将头结点的rchild指针指向最后一个结点*/ } return 1; } void InThreading(BiThrTree p) /*二叉树的中序线索化*/ { if(p) { InThreading(p->lchild); /*左子树线索化*/ if(!p->lchild) /*前驱线索化*/ { p->ltag=Thread; p->lchild=pre; } if(!pre->rchild) /*后继线索化*/ { pre->rtag=Thread; pre->rchild=p; } pre=p; /*pre指向的结点线索化完毕,使p指向的结点成为前驱*/ InThreading(p->rchild); /*右子树线索化*/ } } 6.5.3 遍历线索二叉树   遍历线索二叉树,就是根据线索查找结点的前驱和后继。   1. 在中序线索二叉树中查找结点的直接前驱   在中序线索二叉树中,结点*p(即指针p指向的结点)的直接前驱就是其左子树的最右下端结点。若p->ltag=1,那么p->lchild指向的结点就是p的直接前驱结点。例如,如图6-28所示的二叉树中,结点I的前驱标志域为1,即Thread,则直接前驱为F,即lchild指向的结点。如果p->ltag=0,对于结点C,它的直接前驱为I,即结点C的左子树的最右下端结点。查找结点的直接前驱的算法实现如下。 BiThrNode *InOrderPre(BiThrNode *p) /*在中序线索树中找结点*p的直接前趋*/ { BiThrNode *pre; if (p->ltag==Thread) /*如果p的标志域ltag为线索,则p的左子树结点即为前驱*/ return p->lchild; else { pre=p->lchild; /*查找p的左孩子的最右下端结点*/ while (pre->rtag==Link) /*右子树非空时,沿右链往下查找*/ pre=pre->rchild; return pre; /*pre就是最右下端结点*/ } }   2. 在中序线索二叉树中查找结点的直接后继   在中序线索二叉树中,查找结点*p的中序直接后继与查找结点的直接前驱类似。若p->rtag=1,那么p->rchild指向的结点就是p的直接后继结点。例如,在图6-28中,结点E的后继标志域为1,即Thread,则其直接后继为A,即rchild指向的结点。若p->rtag=0,对于结点A,它的直接后继为F,即A的右子树的最左下端结点。 BiThrNode *InOrderPost(BiThrNode *p) /*在中序线索树中查找结点*p的直接后继*/ { BiThrNode *pre; if (p->rtag==Thread) /*如果p的标志域ltag为线索,则p的右子树结点即为后继*/ return p->rchild; else { pre=p->rchild; /*查找p的右孩子的最左下端结点*/ while (pre->ltag==Link) /*左子树非空时,沿左链往下查找*/ pre=pre->lchild; return pre; /*pre就是最左下端结点*/ } }   3. 中序遍历线索二叉树   中序遍历线索二叉树可分为3步:第1步,从根结点出发,找到二叉树的最左下端结点并访问;第2步,判断该结点的右标志域是否为线索指针,若为线索指针即p->rtag==Thread,表明p->rchild指向的是后继结点,则将指针移动到右孩子结点,并访问右孩子结点;第3步,将当前指针指向该右孩子结点。重复执行以上3步,就可访问完二叉树中的所有结点。中序遍历线索二叉树的过程,就是线索查找后继和查找右子树的最左下端结点的过程。 intInOrderTraverse(BiThrTreeT,int (* visit)(BiThrTree e)) /*中序遍历线索二叉树.其中visit是函数指针,指向访问结点的函数实现*/ { BiThrTree p; p=T->lchild; /*p指向根结点*/ while(p!=T) /*空树或遍历结束时,p==T*/ { while(p->ltag==Link) p=p->lchild; if(!visit(p)) /*若已遍历完所有结点*/ return 0; /*则返回0*/ while(p->rtag==Thread&&p->rchild!=T) /*若标志域为线索指针,则访问后继结点*/ { p=p->rchild; /*移动到右孩子结点*/ visit(p); /*访问右孩子结点*/ } p=p->rchild; /*指向该右孩子结点*/ } return 1; }   由此可得出结论:对于中序线索二叉树,若ltag =0,则直接前驱为左子树的最右下端结点;若rtag=0,则其直接后继为右子树的最左下端结点。 6.6 树、森林与二叉树   树、森林和二叉树作为树的类型,它们之间是可以相互转换的。 6.6.1 树转换为二叉树   树的孩子兄弟表示和二叉树的二叉链表在存储方式上是相同的,也就是说,从它们的相同的物理结构可以得到一棵树,也可以得到一棵二叉树。树与二叉树的存储结构如图6-29所示。 (a)树 (b)树与二叉树的孩子兄弟链表 (c)二叉树 图6-29 树与二叉树的存储结构   树如何转换为二叉树呢?我们知道,一棵树的结点没有左右之分,而二叉树的结点有左右孩子之分。为了表述方便,约定树中的每一个孩子结点按照从左至右的顺序编号。例如,图6-29中结点a有3个孩子结点b、c和d,约定b为a的第1个孩子结点,c是a的第2个孩子结点,d是a的第3个孩子结点。   将一棵树转换为二叉树的步骤如下。   (1)加线。在所有兄弟结点之间加一条连线。   (2)去线。对树中的每个结点,只保留每个结点与它的第一个孩子结点间的连线,删除它与其他孩子结点的连线。   (3)调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使其结构层次分明。第一个孩子为二叉树中结点的左孩子,兄弟转换过来的孩子为右孩子。   图6-30给出了树转换为二叉树的过程。 (a)树 (b)加线 (c)去线 (d)调整 图6-30 将树转换为二叉树的过程   树转换为对应的二叉树后,树中每个结点的第1个孩子变为二叉树的左孩子结点,第2个孩子结点变为第1个孩子结点的右孩子结点,第3个孩子结点变为第2个孩子结点的右孩子结点。 6.6.2 森林转换为二叉树   森林是若干棵树组成的集合。森林也可以转换为对应的二叉树,方法如下。   (1)把森林中的每棵树都转换为二叉树。   (2)第一棵二叉树保持不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树,最后进行相应的调整,使其层次分明。   森林转换为二叉树的过程如图6-31所示。 (a)森林 (b)森林中每棵树转换为二叉树 (c)将所有二叉树转换为一棵二叉树 图6-31 森林转换为二叉树的过程 6.6.3 二叉树转换为树和森林   二叉树转换为树或者森林,就是将树和森林转换为二叉树的逆过程。把一棵二叉树转换为树的方法如下。   (1)加线。若某结点的左孩子结点存在,则将该结点的左孩子的右孩子结点、右孩子的右孩子结点都与该结点用线条连接。   (2)去线。删除原二叉树中所有结点与右孩子结点的连线。   (3)调整。使结构层次分明。   一棵二叉树转换为树的过程如图6-32所示。 (a)二叉树 (b)加线 (c)去线 (d)调整 图6-32 二叉树转换为树的过程   与二叉树转换为树的方法类似,二叉树转换为森林的过程如图6-33所示。 (a)二叉树 (b)加线 (c)去线 (d)调整 图6-33 二叉树转换为森林的过程 6.6.4 树和森林的遍历   与二叉树的遍历类似,树和森林的遍历也是按照某种规律对树或者森林中的每个结点进行访问,且仅访问一次的操作。   1. 树的遍历   通常情况下,按照访问树中根结点的先后次序,树的遍历方式分为先根遍历和后根遍历两种。先根遍历的步骤如下。   (1)访问根结点。   (2)按照从左到右的顺序依次先根遍历每一棵子树。   例如,如图6-32所示树后根遍历后得到的结点序列是e、f、g、b、h、c、i、j、d、a。   后根遍历的步骤如下。   (1)按照从左到右的顺序依次后根遍历每一棵子树。   (2)访问根结点。   例如,如图6-32所示树后根遍历后得到的结点序列是h、i、e、b、c、j、f、g、d、a。   2. 森林的遍历   森林的遍历方法有先序遍历和中序遍历两种。   先序遍历森林的方法如下。   (1)访问森林中第一棵树的根结点。   (2)先序遍历第一棵树的根结点的子树。   (3)先序遍历森林中剩余的树。   例如,如图6-33所示森林先序遍历得到的结点序列是a、b、e、c、d、f、g、h、i、j。   中序遍历森林的方法如下。   (1)中序遍历第一棵树的根结点的子树。   (2)访问森林中第一棵树的根结点。   (3)中序遍历森林中剩余的树。   例如,如图6-33所示森林中序遍历得到的结点序列是e、b、c、d、a、g、h、f、j、i。   从森林与二叉树之间的转换规则可知,当森林转换为二叉树时,其第一棵树的子树森林转换为左子树,剩余的树的森林转换为右子树。上述森林的先序和中序遍历即为其对应二叉树的先序和中序遍历。 6.6.5 树与二叉树应用举例   任何一棵二叉树只有唯一的先序、中序和后序序列,反过来,已知先序和中序序列、中序和后序序列、先序和后序序列,能唯一确定一棵二叉树吗?下面就来探讨这个问题。   1. 由先序序列和中序序列唯一确定一棵二叉树   先序遍历二叉树时,需要先访问根结点,然后先序遍历左子树,最后先序遍历右子树。因此,在二叉树的先序遍历过程中,根结点一定是第一个访问的结点。在中序遍历二叉树时,先中序遍历左子树,然后是根结点,最后遍历右子树。因此,在二叉树的中序序列中,根结点位于左右子树序列的中间,把序列分为两部分,左边序列为左子树结点,右边序列是右子树结点。   根据先序序列的左子树部分和中序序列的左子树部分,左子树的根结点可继续将中序序列分为左子树和右子树两个部分,以此类推,就可以构造出二叉树。   设结点的先序序列为(A,B,C,D,E,F,G),中序序列为(C,B,A,E,F,D,G),图6-34给出了确定二叉树的过程。   在先序序列的第一个结点一定是根结点,故A为根结点,则A的左子树为{B,C},右子树为{D,E,F,G},再观察先序序列和中序序列,对于A的左子树来说,先序序列为B、C,中序序列为C、B,B一定是C的根结点,而C一定是B的左孩子,故现在就画出了A的左子树。   现在再观察A的右子树,右子树先序序列为D、E、F、G,中序序列为E、F、D、G,则D一定是这棵子树的根结点,那么E、F就是D的左子树,G为D的右子树。再观察D的左子树先序序列和中序序列,E为F的根,F为右子树,这样就构造出了整棵二叉树。 图6-34 由先序序列和中序序列确定的二叉树过程   由先序序列和中序序列构造二叉树的算法实现如下。 void CreateBiTree1(BiTree *T,char *pre,char *in,int len) /*由先序序列和中序序列构造二叉树*/ { int k; char *temp; if(len<=0) { *T=NULL; return; } *T=(BitNode*)malloc(sizeof(BitNode)); /*生成根结点*/ (*T)->data=*pre; for(temp=in;templchild),pre+1,in,k); /*建立左子树*/ CreateBiTree1(&((*T)->rchild),pre+1+k,temp+1,len-1-k); /*建立右子树*/ }   2. 由中序序列和后序序列唯一确定一棵二叉树   由中序序列和后序序列也可以唯一确定一棵二叉树。后序遍历二叉树的顺序是先后序遍历左子树,接着后序遍历右子树,最后是访问根结点。因此,在二叉树的后序序列中,最后一个结点元素一定是根结点。在中序遍历二叉树的过程中,先中序遍历左子树,然后是根结点,最后遍历右子树。因此,在二叉树的中序序列中,根结点将中序序列分为左子树序列和右子树序列两部分。由中序序列的左子树结点个数,通过扫描后序序列,可以将后序序列分为左子树序列和右子树序列。以此类推,就可以构造出二叉树。   设结点的中序序列为(C,E,B,D,A,H,G,I,F),后序序列为(E,C,D,B,H,I,G,F,A),图6-35给出了唯一确定一棵二叉树的过程。 图6-35 由中序序列和后序序列确定二叉树的过程   由后序序列可知,A为二叉树的根结点,左子树为{C,E,B,D},右子树为{H,G,I,F},然后再观察A的左子树的中序序列和后序序列,从后序序列可知B为该子树的根结点,再由中序序列,{C,E}为B的左子树,D为B的右孩子,还按照上述方法,继续由B的左子树的中序序列和后序序列可知,再根据中序序列得知C为E的根结点,再由中序序列可知E为C的右子树,这样就构造出了A的左子树。   下面来构造A的右子树,因A的右子树中序序列为H、G、I、F,后序序列为H、I、G、F,则A的右子树的根结点为F,即后序序列的最后一个结点,再根据其左子树序列,左子树为{H,G,I},F没有右子树。然后再根据F的左子树的后序序列H,G,I可知,F左子树根结点为G,再由中序序列可知H为G的左孩子,I为G的右孩子。这样就构造出了A的右子树。   由中序序列和后序序列构造二叉树的算法如下。 void CreateBiTree2(BiTree *T,char *in,char *post,int len) /*由中序序列和后序序列构造二叉树*/ { int k; char *temp; if(len<=0) { *T=NULL; return; } for(temp=in;tempdata =*temp; break; } CreateBiTree2(&((*T)->lchild),in,post,k); /*建立左子树*/ CreateBiTree2(&((*T)->rchild),in+k+1,post+k,len-k-1); /*建立右子树*/ }   3. 由先序序列和后序序列不能唯一确定二叉树   那么,给定先序序列和后序序列可以唯一确定一棵二叉树吗?答案是不能。假设有一个先序序列为(A,B,C),一个后序序列为(C,B,A),可以构造出两棵树,如图6-36所示。 图6-36 由先序序列和后序序列确定的两棵二叉树   由此可知,给定先序序列和后序序列不能唯一确定二叉树。   4. 程序举例   【例6-3】编写算法,已知先序序列(E,B,A,D,C,F,H,G,I,K,J)和中序序列(A,B,C,D,E,F,G,H,I,J,K)或给出中序序列(A,B,C,D,E,F,G,H,I,J,K)和后序序列(A,C,D,B,G,J,K,I,H,F,E),构造一棵二叉树。 void PrintLevel(BiTree T) /*按层次输出二叉树的结点*/ { BiTree Queue[MaxSize]; int front,rear; if(T==NULL) return; front=-1; /*初始化队列*/ rear=0; Queue[rear]=T; while(front!=rear) /*如果队列不空*/ { front++; /*将队头元素出队*/ printf("%4c",Queue[front]->data); /*输出队头元素*/ if(Queue[front]->lchild!=NULL) /*如果队头元素的左孩子结点不为空,则将左孩子入队*/ { rear++; Queue[rear]=Queue[front]->lchild; } if(Queue[front]->rchild!=NULL) /*如果队头元素的右孩子结点不为空,则将右孩子入队*/ { rear++; Queue[rear]=Queue[front]->rchild; } } } void PrintTLR(BiTree T) /*先序输出二叉树的结点*/ { if(T!=NULL) { printf("%4c",T->data); /*输出根结点*/ PrintTLR(T->lchild); /*先序遍历左子树*/ PrintTLR(T->rchild); /*先序遍历右子树*/ } } void PrintLRT(BiTree T) /*后序输出二叉树的结点*/ { if (T!=NULL) { PrintLRT(T->lchild); /*先序遍历左子树*/ PrintLRT(T->rchild); /*先序遍历右子树*/ printf("%4c",T->data); /*输出根结点*/ } } void Visit(BiTree T,BiTree pre,char e,int i) /*访问结点e*/ { if(T==NULL&&pre==NULL) { printf("\n对不起!你还没有建立二叉树,先建立再进行访问!\n"); return; } if(T==NULL) return; else if(T->data==e) /*如果找到结点e,则输出结点的双亲结点*/ { if(pre!=NULL) { printf("%2c的双亲结点是:%2c\n",e,pre->data); printf("%2c结点在%2d层上\n",e,i); } else printf("%2c位于第1层,无双亲结点!\n",e); } else { Visit(T->lchild,T,e,i+1); /*遍历左子树*/ Visit(T->rchild,T,e,i+1); /*遍历右子树*/ } }   程序的运行结果如图6-37所示。由先序序列和中序序列确定的二叉树如图6-38所示。 图6-37 由给定序列构造二叉树运行结果 图6-38 由先序序列和中序序列确定的二叉树 6.7 综合案例:哈夫曼树   哈夫曼(Huffman)树又称最优二叉树。它是一种带权路径长度最短的树,应用非常广泛。本节主要介绍什么是哈夫曼树、哈夫曼编码及哈夫曼编码算法的实现。 6.7.1 什么是哈夫曼树   在介绍什么是哈夫曼树之前,先来了解以下几个概念。   1. 路径和路径长度   路径是指在树中从一个结点到另一个结点所走过的路程。路径长度是一个结点到另一个结点之间的分支数目。树的路径长度是指从树的树根到每一个结点的路径长度的和。   2. 树的带权路径长度   结点的带权路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和,通常记作WPL=,其中,n是树中叶子结点的个数,wi是第i个叶子结点的权值,li是第i个叶子结点的路径长度。   例如,图6-39所示的二叉树的带权路径长度分别是WPL=7×2+5×2+2×2+4×2=36、WPL=4×2+7×3+5×3+2×1=46、WPL=7×1+5×2+2×3+4×3=35,因此,第3棵树的带权路径长度最小,即其带权路径长度在所有带权为7、5、2、4的4个叶子结点的二叉树中最小,它其实就是一棵哈夫曼树。 (a)带权路径长度为36 (b)带权路径长度为46 (c)带权路径长度为35 图6-39 二叉树的带权路径长度   3. 哈夫曼树   哈夫曼树就是带权路径长度最小的树,权值越小的结点越远离根结点,权值越大的结点越靠近根结点。   哈夫曼树的构造算法如下。   (1)由给定的n个权值{w1,w2,…,wn}构成n棵只有根结点的二叉树集合F={T1,T2,…,Tn},其中每棵二叉树Ti中只有一个权值为wi的根结点,其左右子树均为空。   (2)在二叉树集合F中选取两棵根结点的权值最小和次小的树作为左、右子树构造一棵新的二叉树,新二叉树的根结点的权重为这两棵子树根结点的权重之和。   (3)在二叉树集合F中删除这两棵二叉树,并将新得到的二叉树加入到集合F中。   (4)重复步骤(2)和(3),直到集合F中只剩下一棵二叉树为止。这棵二叉树就是哈夫曼树。   例如,假设给定一组权值{2,3,6,8},按照哈夫曼构造的算法对集合的权重构造哈夫曼树的过程如图6-40所示。 图6-40 哈夫曼树构造过程 6.7.2 哈夫曼编码   在电报的传输过程中,需将传送的文字转换成由二进制的字符组成的字符串。例如,假设需传送的电文为“ABACCDA”,它只有4种字符,只需使用两个字符构成的串就可表示该电文。假设A、B、C、D的编码分别为00、01、10、11,则上述7个字符的电文便为“00010010101100”,总长14位,对方接收后可按两位分隔进行译码。   当然,在传送电文时,希望电文的长度尽可能短。如果每个字符按照长度不等进行编码,将出现频率高的字符采用尽可能短的编码,则电文的代码长度就会减少。如果设计A、B、C、D的编码分别为0、00、1和01,则上述7个字符的电文可转换为总长为9的字符串“000011010”。但是这样的电文无法翻译,例如,传送过去的字符串中前4个字符子串“0000”就可能有多种译法,可能是“AAAA”,也可能是“ABA”还可能是“BB”,因此,所设计的长短不等的编码必须满足任一个字符的编码都不是另一个字符的编码的前缀的要求,这样的编码称为前缀编码。   二叉树可以用来设计二进制的前缀编码。假设如图6-41所示的二叉树,其4个叶子结点分别表示a、b、c、d这4个字符,且约定的左孩子分支为0,右孩子分支为1,从根结点到每个叶子结点经过的分支组成的0和1序列就是结点的前缀编码。字符a的编码为0,字符b的编码为110,字符c的编码为111,字符d的编码为10。   那又如何得到使电文长度最短的二进制前缀编码呢?具体构造方法如下。   假设需要编码的字符集合为{c1,c2,…,cn},相应地,字符在电文中的出现次数为{w1,w2,…,wn},以字符c1、c2、…、cn作为叶子结点,以w1、w2、…、wn为对应叶子结点的权值构造一棵二叉树,按照以上构造方法,假设字符集合为{a,b,c,d},其各个字符相应的出现次数为{4,1,1,2},则这些字符作为叶子结点构成的哈夫曼树如图6-41所示。   因此,可以得到电文abdaacda的哈夫曼编码为01101000111100,共13个二进制字符。这样就保证了电文的编码达到最短。       6.7.3 哈夫曼编码算法的实现   【例6-3】假设一个字符序列为{a,b,c,d},对应的权重为{2,3,6,8}。试构造一棵哈夫曼树,然后输出相应的哈夫曼编码。   【分析】哈夫曼树的类型定义如下。 typedef struct /*哈夫曼树类型定义*/ { unsigned int weight; unsigned int parent,lchild,rchild; }HTNode,*HuffmanTree; typedef char **HuffmanCode; /*存放哈夫曼编码*/   HuffmanCode为一个二级指针,相当于二维数组,用来存放每一个叶子结点的哈夫曼编码。初始时,将每一个叶子结点的双亲结点域、左孩子域和右孩子域初始化为0。若有n个叶子结点,则非叶子结点有n-1个,所以总共结点数目是2n-1个。同时也要将剩下的n-1个双亲结点域初始化为0,这主要是为了查找权值最小的结点方便。   依次选择两个权值最小的结点s1和s2分别作为左子树结点和右子树结点,并为其双亲结点赋予一个地址,双亲结点的权值为s1和s2的权值之和。修改它们的parent域,使它们指向同一个双亲结点,双亲结点的左子树为权值最小的结点,右子树为权值次小的结点。重复执行这种操作n-1次,即求出n-1个非叶子结点的权值。这样就构造出了一棵哈夫曼树。代码如下。 /*构造哈夫曼树HT*/ for(i=n+1;i<=m;i++) { Select(HT,i-1,&s1,&s2); (*HT)[s1].parent=(*HT)[s2].parent=i; (*HT)[i].lchild=s1; (*HT)[i].rchild=s2; (*HT)[i].weight=(*HT)[s1].weight+(*HT)[s2].weight; }   求哈夫曼编码的方式有两种,即从根结点开始到叶子结点正向求哈夫曼编码和从叶子结点到根结点逆向求哈夫曼编码,这里只给出从根结点出发到叶子结点求哈夫曼编码的算法,其算法思想为,从编号为2n-1的结点开始,即根结点开始,依次通过判断左孩子和右孩子是否存在进行编码,若左孩子存在则编码为0,若右孩子存在则编码为1;同时,利用weight域作为结点是否已经访问的标志位,若左孩子结点已经访问则将相应的weight域置为1,若右孩子结点也已经访问过则将相应的weight域置为2,若左孩子和右孩子都已经访问过则回退至双亲结点。按照这个思路,直到所有结点都已经访问过,并回退至根结点,算法结束。   从根结点到叶子结点求哈夫曼编码的算法实现如下。 void HuffmanCoding(HuffmanTree *HT,HuffmanCode *HC,int *w,int n) /*构造哈夫曼树HT,并从根结点到叶子结点求哈夫曼编码并保存在HC中*/ { int s1,s2,i,m; unsigned int r,cdlen; char *cd; HuffmanTree p; if(n<=1) return; m=2*n-1; *HT=(HuffmanTree)malloc((m+1)*sizeof(HTNode)); for(p=*HT+1,i=1;i<=n;i++,p++,w++) { (*p).weight=*w; (*p).parent=0; (*p).lchild=0; (*p).rchild=0; } for(;i<=m;++i,++p) (*p).parent=0; /*构造哈夫曼树HT*/ for(i=n+1;i<=m;i++) { Select(HT,i-1,&s1,&s2); (*HT)[s1].parent=(*HT)[s2].parent=i; (*HT)[i].lchild=s1; (*HT)[i].rchild=s2; (*HT)[i].weight=(*HT)[s1].weight+(*HT)[s2].weight; } /*从根结点到叶子结点求哈夫曼编码并保存在HC中*/ *HC=(HuffmanCode)malloc((n+1)*sizeof(char*)); cd=(char*)malloc(n*sizeof(char)); r=m; /*从根结点开始*/ cdlen=0; /*编码长度初始化为0*/ for(i=1;i<=m;i++) (*HT)[i].weight=0; /*将weight域作为状态标志*/ while(r) { if((*HT)[r].weight==0) /*如果weight域等于零,说明左孩子结点没有遍历*/ { (*HT)[r].weight=1; /*修改标志*/ if((*HT)[r].lchild!=0) /*如果存在左孩子结点,则将编码置为0*/ { r=(*HT)[r].lchild; cd[cdlen++]='0'; } else if((*HT)[r].rchild==0) /*如果是叶子结点,则将当前求出的编码保存到HC中*/ { (*HC)[r]=(char *)malloc((cdlen+1)*sizeof(char)); cd[cdlen]='\0'; strcpy((*HC)[r],cd); } } else if((*HT)[r].weight==1) /*如果已经访问过左孩子结点,则访问右孩子结点*/ { (*HT)[r].weight=2; /*修改标志*/ if((*HT)[r].rchild!=0) { r=(*HT)[r].rchild; cd[cdlen++]='1'; } } else /*如果左孩子结点和右孩子结点都已经访问过,则退回到双亲结点*/ { r=(*HT)[r].parent; --cdlen; /*编码长度减1*/ } } free(cd); }   在算法的实现过程中,数组HT在初始时和哈夫曼树生成后的状态如图6-42所示。 (a)HT数组初始化状态 (b)生成哈夫曼树后HT的状态 图6-42 数组HT在初始化和生成哈夫曼树后的状态变化情况   生成的哈夫曼树如图6-43所示,不难看出,权值为2、3、6和8的哈夫曼编码分别是100、101、11和0。   程序运行结果如图6-44所示。 图6-43 哈夫曼树 图6-44 哈夫曼编码程序运行结果 6.8 小结   树是学习数据结构课程的一个重点和难点部分,也是各种考试常考内容之一。树反映的是一种层次结构的关系。树中结点之间是一种一对多的关系。   树与二叉树的定义都是递归的。树中的子树没有次序之分,二叉树的左右子树是有次序的,分别叫作左子树和右子树。   在二叉树中有两种特殊的树,即满二叉树和完全二叉树。满二叉树中每个非叶子结点都存在左子树和右子树,所有的叶子结点都处在同一层次上。完全二叉树是指与满二叉树的前n个结点结构相同,满二叉树是一种特殊的完全二叉树。   二叉树的存储结构有顺序存储和链式存储两种。完全二叉树可以采用顺序存储,采用顺序存储结构可以实现随机存取,实现比较方便。一般来说,一棵二叉树并不一定是完全二叉树,采用顺序存储结构会浪费大量的存储空间。通常情况下,采用二叉链表表示二叉树。二叉链表中的结点包括一个数据域和两个指针域。数据域存放结点的值信息,两个指针域分别指向左孩子结点和右孩子结点。   二叉树的遍历是一种常用的操作。二叉树的遍历分为先序遍历、中序遍历和后序遍历。   采用二叉链表表示的二叉树,不能直接找到该结点的直接前驱和后继结点信息,为了能快速找到任何一个结点的直接前驱和直接后继信息,需要对二叉树进行线索化。   哈夫曼树是一种特殊的二叉树,树中只有叶子结点和度为2的结点。哈夫曼树是带权路径最小的二叉树,也称为最优二叉树。       第7章 图            图(graph)是一种比线性表、树更为复杂的数据结构。在线性表中,数据元素之间呈线性关系,即除第一元素外,其他元素只有一个直接前驱元素和除最后一个元素外,其他元素只有一个直接后继元素。在树结构中,数据元素之间有明显的层次关系,即每个结点只有一个直接前驱结点,但可有多个直接后继结点,而在图结构中,每个结点既可有多个直接前驱结点,也可有多个直接后继结点。图的最早应用可以追溯到18世纪数学家欧拉(Euler)利用图解决的著名的哥尼斯堡桥问题,为图在现代科学技术领域的应用奠定了基础。   图的应用领域十分广泛,如化学分析、工程设计、遗传学、人工智能等。   本章重点和难点: * 图的定义及性质。 * 图的邻接矩阵和邻接表表示。 * 图的各种遍历算法实现。 * 最小生成树。 * 关键路径。 * 最短路径。 7.1 图的定义与相关概念   图是一种非线性的数据结构,图中的数据元素之间的关系是多对多的关系。 7.1.1 什么是图   图是由数据元素集合V与边的集合E构成的。在图中,数据元素通常称为顶点(Vertex)。其中,顶点集合V不能为空,边表示顶点之间的关系。   (1)若∈E,则表示从顶点x到顶点y存在一条弧(Arc),x称为弧尾(tail)或起始点(initial node),y称为弧头(head)或终端点(terminal node)。这样的图称为有向图(digraph),如图7-1(a)所示。   (2)如果∈E且有∈E,即E是对称的,则用无序对(x,y)代替有序对,表示x与y之间存在一条边(edge),这样的图称为无向图(undigraph),如图7-1(b)所示。   图的形式化定义为G=(V,E),其中,V={x|x∈数据元素集合},E={|Path(x,y)/\(x∈V,y∈V)}。Path(x,y)表示的意义或信息。 (a)有向图G1 (b)无向图G2 图7-1 有向图G1与无向图G2   在图7-1中,有向图G1可以表示为G1=(V1,E1),其中,顶点的集合为V1={a,b,c,d},边的集合为E1={,,,,,}。无向图G2可以表示为G2=(V2,E2),其中,顶点的集合为V2={a,b,c,d},边的集合为E2={(a,b),(a,c),(a,d),(b,c),(c,d)}。 7.1.2 图的相关概念   下面介绍与图有关的一些概念。   1. 邻接点   对于无向图G=(V,E),若边(vi,vj)∈E,则称vi和vj互为邻接点,即vi和vj相邻接。边(vi,vj)依附于顶点vi和vj,或者说边(vi,vj)与顶点vi、vj相关联。对于有向图G=(V,A),若弧∈A,则称顶点vi邻接到顶点vj,顶点vj邻接自顶点vi,弧和顶点vi、vj相关联。   无向图G2的边的集合为E={(a,b),(a,c),(a,d),(b,c),(c,d)},顶点a和b互为邻接点,边(a,b)依附于顶点a和b。顶点c和d互为邻接点,边(c,d)依附于顶点c和d。有向图G1的弧的集合为A={,,,,,},顶点a邻接到顶点b,弧与顶点a和b相关联。顶点c邻接自顶点d,弧与顶点d和c相关联。   2. 顶点的度   对于无向图,顶点v的度是指与v相关联的边的数目,记作TD(v)。对于有向图,以顶点v为弧头的数目称为顶点v的入度(indegree),记作ID(v)。以顶点v为弧尾的数目称为v的出度(outdegree),记作OD(v)。顶点v的度(degree)为TD(v)=ID(v)+OD(v)。   无向图G2中顶点a的度为3,顶点b的度为2,顶点c的度为3,顶点d的度为2。有向图G1的弧的集合为A={,,,,,},顶点a、b、c和d的入度分别为1、2、2和1,顶点a、b、c和d的出度分别为2、1、2和1,顶点a、b、c和d的度分别为3、3、4和2。   若图的顶点的个数为n,边数或弧数为e,顶点vi的度记作TD(vi),则顶点的度与弧或者边数满足关系e=。   3. 路径   无向图G中,从顶点v到顶点v' 的路径(path)是从v出发,经过一系列的顶点序列到达顶点v'。如果G是有向图,则路径也是有向的,路径的长度是路径上弧或边的数目。第一个顶点和最后一个顶点相同的路径称为回路或环(cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点外,其他顶点不重复出现的回路,称为简单回路或简单环。   在如图7-1所示的有向图G1中,顶点序列a,d,C成了一个简单回路。在无向图G2中,从顶点a到顶点c所经过的路径为a、d、c(或a、b、c)。   4. 子图   假设存在两个图G={V,E}和G'={V',E'},若G'的顶点和关系都是G的子集,即有V'V,E'E,则G'为G的子图,如图7-2所示。 (a)有向图G1的子图 (b)有向图G2的子图 图7-2 有向图G1与无向图G2的子图   5. 连通图和强连通图   对于无向图G,如果从顶点vi到顶点vj存在路径,则称vi到vj是连通的。如果对于图中任意两个顶点vi、vj∈V,vi和vj都是连通的,则称G是连通图(connected graph)。无向图中的极大连通子图称为连通分量。无向图G3与连通分量如图7-3所示。 (a)无向图G3 (b)无向图G3的3个连通分量 图7-3 无向图G3的连通分量   对于有向图G,如果对每一对顶点vi和vj,且vi≠vj,从vi到vj和从vj到vi都存在路径,则G为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。有向图G4与强连通分量如图7-4所示。 (a)有向图G4 (b)有向图G4的两个强连通分量 图7-4 有向图G4及强连通分量   在如图7-4所示的强连通分量中,顶点集合分别为{a,b,c}和{d},a到任何一个顶点都有路径,b、c到任何一个顶点也存在路径。   6. 完全图   若图的顶点数目是n,图的边(弧)的数目是e。若不存在顶点到自身的边或弧,即若存在,则有vi≠vj。对于无向图,边数e的取值范围为0~n(n-1)/2。将具有n(n-1)/2条边的无向图称为完全图(completed graph)或无向完全图。对于有向图,弧数e的取值范围是0~n(n-1)。具有n(n-1)条弧的有向图称为有向完全图。   7. 稀疏图和稠密图   具有e。对于无向图,还要插入弧。   (10)DeleteArc(&G,v,w):图的弧删除操作。在图G中删除弧。对于无向图,还要删除弧。   (11)DFSTraverseGraph(G):图的深度优先遍历操作。从图G中的某个顶点出发,对图进行深度优先遍历。   (12)BFSTraverseGraph(G):图的广度优先遍历操作。从图G中的某个顶点出发,对图进行广度优先遍历。 7.2 图的存储结构   在前面几章讨论的数据结构中,除了广义表和树外,其他数据结构都有两类存储结构,图的存储方式主要有邻接矩阵表示法、邻接表表示法、十字链表表示法和邻接多重链表表示法4种。 7.2.1 邻接矩阵(数组表示法)   图的邻接矩阵可利用两个数组实现,一个是一维数组,用来存储图中的顶点信息;另一个是二维数组,用来存储图中顶点之间的关系,该二维数组称为邻接矩阵。如果图是一个无权图,则邻接矩阵表示为:      对于带权图,有:      其中,wij表示顶点i与顶点j构成的弧或边的权值,如果顶点之间不存在弧或边,则用∞表示。   例如图7-1中,两个图弧和边的集合分别为A={,,,,,}和E={(a,b),(a,c),(a,d),(b,c),(c,d)},它们的邻接矩阵表示如图7-7所示。 (a)有向图G1的邻接矩阵 (b)无向图G2的邻接矩阵 图7-7 图的邻接矩阵表示   在无向图的邻接矩阵中,如果有边(a,b)存在,则的对应位置都置为1。   带权图的邻接矩阵表示如图7-8所示。 图7-8 带权图的邻接矩阵表示   图的邻接矩阵存储结构描述如下。 #define INFINITY 65535 /*65535被认为是一个无穷大的值*/ #define MaxSize 50 /*顶点个数的最大值*/ typedef enum{DG,DN,UG,UN}GraphKind; /*图的类型:有向图、有向网、无向图和无向网*/ typedef struct { VRType adj; /*对于无权图,用1表示相邻,0表示不相邻;对于带权图,存储权值*/ InfoPtr *info; /*与弧或边的相关信息*/ }ArcNode,AdjMatrix[MaxSize][MaxSize]; typedef struct /*图的类型定义*/ { VertexType vex[MaxSize]; /*用于存储顶点*/ AdjMatrix arc; /*邻接矩阵,存储边或弧的信息*/ int vexnum,arcnum; /*顶点数和边(弧)的数目*/ GraphKind kind; /*图的类型*/ }MGraph;   其中,数组vex用于存储图中的顶点信息,如a、b、c、d;arcs用于存储图中边的信息。 7.2.2 邻接表   邻接表(adjacency list)是图的一种链式存储方式。采用邻接表表示图一般需要两个表结构:边表和表头结点表。   在邻接表中,对图中的每个顶点都建立一个单链表,第i个单链表中的结点表示依附于顶点vi的边(对有向图来说是以顶点vi为尾的弧),这种链表称为边表,其中,结点称为弧结点或边表结点。弧结点由3个域组成,分别为邻接点域(adjvex)、数据域(info)和指针域(nextarc),邻接点域表示与相应的表头顶点相邻接顶点的位置,数据域存储与边或弧的信息,指针域用来指示与表头相邻接的下一个顶点。   在每个链表前面设置一个头结点,除了设有存储各个顶点信息的数据域(data)外,还设有指向对应边表中第一个结点的链域(firstarc),这种表称为表头结点表。相应地,结点称为表头结点。通常情况下,表头结点采用顺序存储结构实现,这样可以随机地访问任意顶点。   边表结点和表头结点的结构如图7-9所示。 图7-9 边表结点和表头结点存储结构   如图7-1所示的图G1和G2用邻接表表示如图7-10所示。 图7-10 图的邻接表表示   如图7-8所示的带权图的邻接表如图7-11所示。 图7-11 带权图的邻接表表示   图的邻接表存储结构描述如下。 #define MaxSize 50 /*顶点个数的最大值*/ typedef enum{DG,DN,UG,UN}GraphKind; /*图的类型:有向图、有向网、无向图和无向网*/ typedef struct ArcNode /*边结点的类型定义*/ { int adjvex; /*弧指向的顶点的位置*/ InfoPtr *info; /*与弧相关的信息*/ struct ArcNode *nextarc; /*指示下一个与该顶点相邻接的顶点*/ }ArcNode; typedef struct VNode /*头结点的类型定义*/ { VertexType data; /*用于存储顶点*/ ArcNode *firstarc; /*指示第一个与该顶点邻接的顶点*/ }VNode,AdjList[MaxSize]; typedef struct /*图的类型定义*/ { AdjList vertex; int vexnum,arcnum; /*图的顶点数目与弧的数目*/ GraphKind kind; /*图的类型*/ }AdjGraph;   如果无向图G中有n个顶点和e条边,则图采用邻接表表示,需要n个头结点和2e个表结点。在e远小于n(n-1)/2时,采用邻接表存储表示显然要比采用邻接矩阵表示更能节省空间。   在图的邻接表存储结构中,某个顶点的度正好等于该顶点对应链表中的结点个数。对于有向图的邻接表来说,某顶点对应链表的结点个数等于某个顶点的出度。   有时为了便于求某个顶点的入度,需要建立一个有向图的逆邻接链表,也就是为每个顶点vi建立一个以vi为弧头的链表。在邻接表中,边表结点的邻接点域的值为i的个数,就是顶点vi的入度。因此如果要求某个顶点的入度,则需要对整个邻接表进行遍历。如图7-1所示的有向图G1的逆邻接链表如图7-12所示。 图7-12 有向图G1的逆邻接链表 7.2.3 十字链表   十字链表(orthogonal list)是有向图的另一种链式存储结构,它可以看作是将有向图的邻接表与逆邻接链表结合起来的一种链表。十字链表中结点的结构如图7-13所示。 (a)弧结点 (b)顶点结点 图7-13 十字链表中的结点结构   弧结点包含5个域,分别为尾域tailvex、头域headvex、info域和两个指针域hlink、tlink。尾域tailvex用于表示弧尾顶点在图中的位置,头域headvex表示弧头顶点在图中的位置,info域表示弧的相关信息,指针域hlink指向弧头相同的下一条弧,tlink指向弧尾相同的下一条弧。   顶点结点包含3个域,分别为data域和firstin域、firstout域。data域存储与顶点相关的信息,如顶点的名称;firstin域和firstout域是两个指针域,分别指向以该顶点为弧头和弧尾的第一个弧结点。   有向图G1的十字链表存储表示如图7-14所示。 图7-14 有向图G1的十字链表存储结构   有向图的十字链表存储结构描述如下。 #define MaxSize 50 /*顶点个数的最大值*/ typedef struct ArcNode /*弧结点的类型定义*/ { int headvex,tailvex; /*弧的头顶点和尾顶点位置*/ InfoPtr *info; /*与弧相关的信息*/ struct *hlink,*tlink; /*指示弧头和弧尾相同的结点*/ }ArcNode; typedef struct VNode /*顶点结点的类型定义*/ { VertexType data; /*用于存储顶点*/ ArcNode *firstin,*firstout; /*分别指向顶点的第一条入弧和出弧*/ }VNode; typedef struct /*图的类型定义*/ { VNode vertex[MaxSize]; int vexnum,arcnum; /*图的顶点数目与弧的数目*/ }OLGraph;   十字链表中的表头结点即顶点结点之间不是链接存储,而是顺序存储。在图的十字链表中,可以很容易找到以某个顶点为弧尾和弧头的弧。 7.2.4 邻接多重链表   邻接多重链表(adjacency multilist)是无向图的另一种链式存储结构。在无向图的邻接表存储表示中,虽然很容易求得顶点和边的各种信息,但是对于每一条边(vi,vj)都有两个结点,分别存储在第i个和第j个链表中,这给图的某些操作带来不便。例如,要删除一条边,此时需要找到表示同一条边的两个顶点。因此,在进行这一类操作时,采用邻接多重链表比较合适,邻接多重链表是将图的一条边用一个结点表示,它的结点结构如图7-15所示。 (a)顶点结点 (b)边结点 图7-15 邻接多重链表的结点结构   顶点结点包含两个域,分别为data域和firstedge域,data为数据域,存储顶点的数据信息;firstedge为指针域,指示依附于顶点的第一条边。边结点有6个域,分别为mark域、ivex域、ilink域、jvex域、jlink域和info域。mark域用来表示边是否被检索过,ivex域和jvex域表示依附于边的两个顶点在图中的位置,ilink域指向依附于顶点ivex的下一条边,jlink域指向依附于顶点jvex的下一条边,info域表示与边相关的信息。   无向图G2的多重链表如图7-16所示。 (a)无向图G2 (b)多重链表 图7-16 无向图G2的多重链表   无向图的多重链表存储结构描述如下。 #define MaxSize 50 /*顶点个数的最大值*/ typedef struct EdgeNode /*边结点的类型定义*/ { int mark,ivex,jvex; /*访问标志和边的两个顶点位置*/ InfoPtr *info; /*与边相关的信息*/ struct *ilink,*jlink; /*指示与边顶点相同的结点*/ }EdgeNode; typedef struct VNode /*顶点结点的类型定义*/ { VertexType data; /*用于存储顶点*/ EdgeNode *firstedge; /*指向依附于顶点的第一条边*/ }VexNode; typedef struct /*图的类型定义*/ { VexNode vertex[MaxSize]; int vexnum,edgenum; /*图的顶点数目与边的数目*/ }AdjMultiGraph; 7.3 图的遍历   与树的遍历类似,从图中某一顶点出发访问遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫作图的遍历(traversing graph)。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。图的遍历方式主要有两种:深度优先搜索和广度优先搜索。 7.3.1 图的深度优先搜索   1. 什么是图的深度优先搜索遍历   图的深度优先搜索(depth_first search)遍历类似于树的先根遍历,是树的先根遍历的推广。图的深度优先遍历的思想是:假设初始状态时,图中所有顶点未曾被访问,从图中某个顶点v0出发,访问顶点v0,然后依次从v0的未被访问的邻接点出发深度优先遍历图,直至图中所有和v0有路径相通的顶点都被访问到;若此时图中还有顶点未被访问,则另选图中一个未被访问的顶点作为起始点,重复执行上述过程,直到图中所有的顶点都被访问过。   图的深度优先搜索遍历过程如图7-17所示,实箭头表示访问顶点的方向,虚箭头表示回溯,数字表示访问或回溯的次序。 (a)无向图G6 (b)图G6的深度优先遍历过程 图7-17 图G6及深度优先遍历过程   图的深度优先搜索遍历过程如下。   (1)从顶点a出发,因a还未被访问过,首先访问a。   (2)因a的邻接点有b、c、d,先访问a的第1个邻接点b。   (3)顶点b还有一个邻接点e未被访问过,故访问顶点e。   (4)顶点e的邻接点f还未被访问过,故访问顶点f。   (5)顶点f的邻接点c还未被访问过,故访问顶点c。   (6)顶点c的邻接点都已经被访问过,此时回溯到前一个顶点f。   (7)同理,顶点f、e、b都已经被访问过,且没有其他未访问的邻接点,因此,回溯到顶点a。   (8)顶点a的邻接点d还没有被访问过,故访问顶点d。   (9)顶点d的邻接点有g和h两个,先访问第一个顶点g。   (10)顶点g的邻接点有h和i两个,先访问第一个顶点h。   (11)顶点h的邻接点都已经被访问过,回溯到前一个顶点g。   (12)顶点g的未被访问过的邻接点只有i,故访问顶点i。   (13)顶点i所有的邻接点都已经被访问过,回溯到顶点g。   (14)同理,顶点g、d都没有未被访问的邻接点,回溯到顶点a。   顶点a所有的邻接点都已经被访问过,得到图的深度优先搜索遍历的序列为a、b、e、f、c、d、g、h、i。   在图的深度优先搜索遍历过程中,图中可能存在回路,因此,在访问某个顶点之后,沿着某条路径遍历,有可能又回到该顶点。例如,在访问顶点a之后,接着访问顶点b、e、f、c,顶点c的邻接点是顶点a,因顶点c的邻接点是a,因此会继续沿着边(c,a)再次访问顶点a。为了避免再次访问已经访问过的顶点,需要设置一个数组visited[n],作为一个标志记录结点是否访问过,其初值为0,一旦某个顶点被访问,则其相应的分量被置为1。   2. 图的深度优先搜索遍历的算法实现   图的深度优先遍历(邻接表实现)的算法描述如下。 int visited[MaxSize]; /*访问标志数组*/ void DFSTraverse(AdjGraph G) /*从第1个顶点起,深度优先搜索遍历图G*/ { int v; for(v=0;v=0; w=NextAdjVertex(G,G.vertex[v].data, G.vertex[w].data)) if(!visited[w]) DFS(G,w); /*递归调用DFS对v的尚未访问的序号为w的邻接顶点*/ }   如果该图是一个无向连通图或者一个强连通图,则只需要调用一次DFS(G,v)就可以遍历整个图,否则需要多次调用DFS(G,v)。在遍历图时,对图中的每个顶点至多调用一次DFS(G,v)函数,因为一旦某个顶点被标志为已被访问,就不再从它出发进行搜索。因此,遍历图的过程实质上是对每个顶点查找其邻接点的过程。其时间耗费取决于所采用的存储结构。当用二维数组表示邻接矩阵作为图的存储结构时,查找每个顶点的邻接点所需时间为O(n2),其中,n为图中的顶点数。当以邻接表作为图的存储结构时,查找邻接点的时间为O(e),其中,e为无向图边的数目或有向图弧的数目。由此,当以邻接表作为存储结构时,深度优先搜索遍历图的时间复杂度为O(n+e)。   图的深度优先搜索遍历算法DFS的另外一种写法如下。 void DFS(AdjGraph G,int v) /*从顶点v出发递归深度优先搜索遍历图G*/ { ArcNode *p; visited[v]=1; /*访问标志设置为已访问*/ Visit(G.vertex[v].data); /*访问第v个顶点*/ p=G.vertex[v].firstarc; /*取v的边表头指针,p指向v的邻接点*/ while(p) /*依次搜索v的邻接点*/ { if(!visited[p->adjvex]) /*若v尚未被访问*/ DFS(G,p->adjvex); /*以v的邻接点纵深搜索*/ p=p->nextarc; /*找v的下一个邻接点*/ } }   以邻接表作为存储结构,查找v的第一个邻接点,算法实现如下。 int FirstAdjVertex(AdjGraph G,VertexType v) /*返回顶点v的第一个邻接顶点的序号*/ { ArcNode *p; int v1; v1=LocateVertex(G,v); /*v1为顶点v在图G中的序号*/ p=G.vertex[v1].firstarc; if(p) /*如果顶点v的第一个邻接点存在,返回邻接点的序号,否则返回-1*/ return p->adjvex; else return -1; }   以邻接表作为存储结构,查找v的相对于w的下一个邻接点,算法实现如下。 int NextAdjVertex(AdjGraph G,VertexType v,VertexType w) /*返回v的相对于w的下一个邻接顶点的序号*/ { ArcNode *p,*next; int v1,w1; v1=LocateVertex(G,v); /*v1为顶点v在图G中的序号*/ w1=LocateVertex(G,w); /*w1为顶点w在图G中的序号*/ for(next=G.vertex[v1].firstarc;next;) if(next->adjvex!=w1) next=next->nextarc; p=next; /*p指向顶点v的邻接顶点w的结点*/ if(!p||!p->nextarc) /*如果w不存在或w是最后一个邻接点,则返回-1*/ return -1; else return p->nextarc->adjvex; /*返回v的相对于w的下一个邻接点的序号*/ } 7.3.2 图的广度优先搜索   本节介绍图的广度优先搜索遍历的定义和算法实现。   1. 什么是图的广度优先搜索遍历   图的广度优先搜索(breadth_first search)遍历类似于树的层次遍历过程。图的广度优先搜索遍历的思想是:从图的某个顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问,直至图中所有已访问的顶点的邻接点都被访问到;若此时图中还有顶点未被访问,则另选图中一个未曾被访问的顶点作为起始点,重复上述过程,直至图中的所有顶点都被访问到为止。   例如,图G6的广度优先搜索遍历的过程如图7-18所示。其中,箭头表示广度遍历的方向,旁边的数字表示遍历的次序。图G6的广度优先搜索遍历的过程如下。   (1)首先从顶点a出发,因a还未被访问过,首先访问顶点a。   (2)顶点a的邻接点有b、c、d,先访问a的第一个邻接点b。   (3)顶点a的邻接点c还没有被访问,故访问邻接点c。   (4)顶点a的邻接点d还没有被访问,故访问邻接点d。   (5)顶点b邻接点e还没有被访问,故访问顶点e。   (6)顶点c的邻接点f还没有被访问过,故访问顶点f。   (7)顶点d的邻接点有g和h,且都未被访问过,先访问第一个顶点g。   (8)顶点d的邻接点h还没有被访问,故访问h。   (9)顶点e、f、h不存在未被访问的邻接点,顶点g未被访问的邻接点只有i,故访问顶点i。至此,图G6所有的顶点已经访问完毕。   因此,图G6的广度优先搜索遍历序列为a、b、c、d、e、f、g、h、i。   2. 图的广度优先搜索遍历的算法实现   与深度优先搜索遍历类似,在图的广度优先搜索遍历过程中也需要一个访问标志数组visited[MaxSize],用来表示顶点是否被访问过。初始时,将图中的所有顶点的标志数组visited[vi]都初始化为0,表示顶点未被访问。从第一个顶点v0出发,访问该顶点并将标志数组置为1;然后将v0入队,当队列不为空时,将队头元素(顶点)出队,依次访问该顶点的所有邻接点,同时将标志数组对应位置1,并将其邻接点依次入队。以此类推,直到图中的所有顶点都已被访问过。   图的广度优先搜索遍历的算法实现如下。 void BFSTraverse(AdjGraph G) /*从第一个顶点出发,按广度优先非递归遍历图G*/ { int v,front,rear; ArcNode *p; int queue[MaxSize]; /*定义一个队列*/ front=rear=-1; /*初始化队列*/ for(v=0;vadjvex]==0) /*如果该顶点未被访问过*/ { visited[p->adjvex]=1; Visit(G.vertex[p->adjvex].data); rear=(rear+1)%MaxSize; queue[rear]=p->adjvex; } p=p->nextarc; /*p指向下一个邻接点*/ } } }   设图的顶点个数为n,边(弧)的数目为e,则采用邻接表实现图的广度优先遍历的时间复杂度为O(n+e)。图的深度优先遍历和广度优先遍历的结果并不是唯一的,这主要与图的存储结点的位置有关。 7.4 图的连通性问题   前面介绍了连通图和连通分量的概念,如何判断一个图是否为连通图呢?如何求解一个连通图的连通分量呢? 7.4.1 无向图的连通分量与最小生成树   在对无向图进行遍历时,对于连通图,仅需从图的任何一个顶点出发进行深度优先搜索遍历或广度优先搜索遍历,就可访问到图中的所有顶点;对于非连通图,则需从多个顶点出发进行搜索,而且每一次从一个新的起始点出发进行搜索过程中得到的顶点访问序列恰为其各个连通分量中的顶点集。图7-3中的非连通图G3的邻接表如图7-19所示。图G3是非连通图且有3个连通分量,因此在对图G3进行深度优先遍历时,需要从图的至少3个顶点(顶点a、顶点g和顶点i)出发,才能完成对图中的每个顶点的访问。对图G3进行深度遍历,经过3次递归调用得到的3个序列,分别为a、b、c、d、m、e、f;g、h;i、j、k、l。这3个顶点集分别加上依附于这些顶点的边,就构成了非连通图G3的两个连通分量,如图7-19(b)所示。   设E(G)为连通图G中所有边的集合,则从图中任一顶点出发遍历图时,必定将E(G)分成两个集合T(G)和B(G),其中,T(G)是遍历图过程中经过的边的集合,B(G)是剩余边的集合。显然,T(G)和图G中所有顶点一起构成连通图G的极小连通子图,根据7.1节生成树的定义,它是连通图的一棵生成树。由深度优先搜索得到的为深度优先生成树,对于连通图,由广度优先搜索得到的为广度优先生成树。如图7-20所示就是对应图G6的深度优先生成树和广度优先生成树。 (a)无向图G3 (b)无向图G3的邻接表 图7-19 图G3的邻接表 (a)图G6的深度优先生成树 (b)图G6的广度优先生成树 图7-20 图G6的深度优先生成树和广度优先生成树   对于非连通图,从某一个顶点出发,对图进行深度优先搜索遍历或者广度优先搜索遍历,按照访问路径会得到若干棵生成树,这些生成树放在一起就构成了森林。对图G3进行深度优先搜索得到的森林如图7-21所示。 图7-21 图G3的深度优先生成森林   为了判断一个图是否为连通图,可通过对图进行深度优先搜索或广度优先搜索,若调用遍历图的函数不止一次,则说明该图是非连通的,否则该图为连通图。 7.4.2 最小生成树   许多应用问题都是一个求无向连通图的最小生成树问题。假设要在n个城市之间铺设光缆,主要目标是要使这n个城市的任意两个之间都可以通信,且使铺设光缆的总费用最低。   在每两个城市之间都可以铺设光缆,n个城市之间最多可能铺设n(n-1)/2条光缆,但铺设光缆的费用很高,且各个城市之间铺设光缆的费用不同,那么,如何在这些可能的线路中选择n-1条以使总的费用最少呢?   用连通网来表示n个城市及n个城市间可能铺设的光缆,其中,网的顶点表示城市,边表示两个城市之间的光缆线路,赋予边的权值表示相应的造价。对于n个顶点的连通网可以建立许多不同的生成树,每一棵生成树都可以是一个通信网。现在,要选择这样一棵生成树,也就是使总的造价最少。这个问题就是构造连通网的最小代价生成树(minimum cost spanning tree,简称为最小生成树)问题,其中一棵生成树的代价就是树上所有边的代价之和。代价在网中通过权值来表示,一棵生成树的代价就是生成树各边的代价之和。   最小生成树有多种算法,其中大多数算法都利用了最小生成树的MST性质,具体如下。   假设一个连通网N=(V,E),V是顶点的集合,E是边的集合,V有一个非空子集U。如果(u,v)是一条具有最小权值的边,其中,u∈U,v∈V-U,那么一定存在一棵包含边(u,v)的最小生成树。   下面用反证法证明以上MST性质。   假设所有最小生成树都不存在这样的一条边(u,v)。设T是连通网N中的一棵最小生成树,如果将边(u,v)加入T中,根据生成树的定义,T一定出现包含(u,v)的回路。另外,T中一定存在一条边(u’,v’)的权值大于或等于(u,v)的权值,如果删除边(u’,v’),则得到一棵代价小于或等于T的生成树T’。T’是包含边(u,v)的最小生成树,这与假设矛盾。由此,性质得证。   普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法就是利用MST性质构造的最小生成树算法。   1. Prim普里姆算法   普里姆算法描述如下。   假设N={V,E}是连通网,TE是N的最小生成树边的集合。执行以下操作:   (1)初始时,令U={u0}(u0∈V),TE=。   (2)对于所有的边u∈U,v∈V-U的边(u,v)∈E,将一条代价最小的边(u0,v0)放到集合TE中,同时将顶点v0放进集合U中。   (3)重复执行步骤(2),直到U=V为止。   这时,边集合TE一定有n-1条边,T={V,TE}就是连通网N的最小生成树。   例如,图7-22就是利用普里姆算法构造最小生成树的过程。   初始时,集合U={a},集合V-U={b,c,d,e,f},边集合为。只有一个元素a∈U,将a从U中取出,比较顶点a与集合V-U中顶点构成的代价最小边,在(a,b)、(a,c)、(a,d)中,(a,c)的权值最小,故将顶点c加入到集合U中,边(a,c)加入到TE中,此时有U={a,c},V-U={b,d,e,f},TE=={(a,c)}。目前集合U的元素与集合V-U的元素构成的所有边为(a,b)、(a,d)、(b,c)、(c,d)、(c,e) 和(c,f),其中代价最小的边为(c,f),故把顶点f加入到集合U中,边(c,f)加入到TE中,此时有U={a,c,f},V-U={b,d,e},TE=={(a,c), (c,f)}。以此类推,直到所有的顶点都加入到U中。   为实现这个算法需附设一个辅助数组closeedge[MaxSize],以记录U到V-U最小代价的边。对于每个顶点v∈V-U,在辅助数组中存在一个相应分量closeedge[v],它包括两个域adjvex和lowcost,其中,adjvex域用来表示该边中属于U中的顶点,lowcost域存储该边对应的权值。用公式描述为:   closeedge[v].lowcost=Min({cost(u,v)|u∈U}) (a)无向网N (b)边ac加入集合TE中 (c)边cf加入集合TE中 (d)边df加入集合TE中 (e)边bc加入集合TE中 (f)边bc加入集合TE中 图7-22 利用普里姆算法构造最小生成树的过程   根据普里姆算法构造最小生成树,其对应过程中各个参数的变化情况如表7-1所示。 表7-1 普里姆算法各个参数的变化 i closeedge[i] 0 1 2 3 4 5 U V-U k (u0,v0) adjvex lowcost 0 a 6 a 1 a 5 {a} {b,c,d,e,f} 2 (a,c) adjvex lowcost 0 c 5 0 a 5 c 6 c 4 {a,c} {b,d,e,f} 5 (c,f) adjvex lowcost 0 c 5 0 f 2 c 6 0 {a,c,f} {b,d,e} 3 (d,f) adjvex lowcost 0 c 5 0 0 c 6 0 {a,c,d,f} {b,e} 1 (b,c) adjvex lowcost 0 0 0 0 b 3 0 {a,b,c,d,f} {e} 4 (b,e) adjvex lowcost 0 0 0 0 0 0 {a,b,c,d,e,f} {}   普里姆算法描述如下。 /*记录从顶点集合U到V-U的代价最小的边的数组定义*/ typedef struct { VertexType adjvex; VRType lowcost; }closeedge[MaxSize]; void MiniSpanTree_PRIM (MGraph G,VertexType u) /*利用普里姆算法求从第u个顶点出发构造网G的最小生成树T*/ { int i,j,k; closeedge closedge; k=LocateVertex(G,u); /*k为顶点u对应的序号*/ for(j=0;j #define MAX_VERTEX_NUM 20 /*顶点个数的最大值*/ #define MAX_NAME 3 /*顶点字符串的最大长度+1*/ #define INFINITY 65535 /*65535代表无限大的值*/ typedef int VRType; typedef char InfoType; typedef char VertexType[MAX_NAME]; /*邻接矩阵的数据结构*/ typedef struct { VRType adj; /*顶点关系类型。对无权图,用1(是)或0(否)表示相邻否*/ /*对带权图,则为权值类型*/ InfoType *info; /*该弧相关信息的指针(可无)*/ }ArcCell, AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM]; /*图的数据结构*/ typedef struct { VertexType vexs[MAX_VERTEX_NUM]; /*顶点向量*/ AdjMatrix arcs; /*邻接矩阵*/ int vexnum, /*图的当前顶点数*/ arcnum; /*图的当前弧数*/ } MGraph; /*记录从顶点集U到V-U的代价最小的边的辅助数组定义*/ typedef struct { VertexType adjvex; VRType lowcost; }Closedge[MAX_VERTEX_NUM]; /*采用数组(邻接矩阵)表示法,构造无向网G*/ //求closedge.lowcost的最小正值 int MiniNum(Closedge edge,MGraph G) { int i=0,j,k,min; while(!edge[i].lowcost) i++; min=edge[i].lowcost; /*第一个不为0的值*/ k=i; for(j=i+1;j0) if(min>edge[j].lowcost) { min=edge[j].lowcost; k=j; } return k; } void main() { MGraph N; printf("创建一个无向网:\n"); CreateGraph(&N); DisplayGraph(N); Prim(N,"A"); DestroyGraph(&N); system("pause"); }   程序运行结果如图7-23所示。 图7-23 普里姆算法运行结果   数组closedge的adjvex域和lowcost域的变化情况如图7-24所示。 图7-24 数组closedge的变化情况   2. Kruska(克鲁斯卡尔)算法   克鲁斯卡尔算法从另一途径求网的最小生成树,连通网为N={V,E},则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量中,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。以此类推,直至T中所有顶点都在同一连通分量上为止。   例如,如图7-24所示的无向图N利用克鲁斯卡尔算法构造最小生成树的过程如图7-25所示。   初始时,边的集合E为空集,顶点a、b、c、d、e、f分别属于不同的集合,假设U1={a},U2={b},U3={c},U4={d},U5={e},U6={f}。图中含有10条边,将这10条边按照权值从小到大排列,依次取出权值最小的边且依附于边的两个顶点属于不同的集合,则将该边加入集合E中,并将这两个顶点合并为一个集合,重复执行类似操作直到所有顶点都属于一个集合为止。   在这10条边中,权值最小的是边(a,c),其权值cost(a,c)=1,并且a∈U1,c∈U3,U1和U3分别属于不同的集合,故将边(a,c)加入集合E中,并将两个顶点所在的集合归并为一个集合,E={(a,c)},U1=U3={a,c}。在剩下的边的集合中,边(d,f)权值最小,且d∈U4,f∈U5,U3≠U4,因此,将边(d,f)加入边的集合E中,合并顶点集合,有E={(a,c),(d,f)},U1=U3=U4=U6={a,c,d,f}。然后继续从剩下的边的集合中选择权值最小的边,依次加入E中,并合并顶点集合,直到所有的顶点都属于同一顶点集合。 (a)初始状态 (b)边ac加入集合E中 (c)边df加入集合E中 (d)边bc加入集合E中 (e)边cf加入集合E中 (f)边bc加入集合E中 图7-25 克鲁斯卡尔算法构造最小生成树的过程   克鲁斯卡尔算法描述如下。 void Kruskal(MGraph G) /*克鲁斯卡尔算法求最小生成树*/ { int set[MaxSize],i,j; int a=0,b=0,min=G.arc[a][b].adj,k=0; for(i=0;i是有向网的一条弧,则称顶点vi是顶点vj的直接前驱,顶点vj是顶点vi的直接后继。   例如,一个软件工程专业的学生必须修完一系列基本课程才能毕业,其中有些课程是基础课,它独立于其他课程,如“高等数学”,而另一些课程必须在学完它的基础先修课程之后才能开始,如“数据结构”是在学习完“程序设计基础”和“离散数学”之后才能开始学习。这些先决条件定义了课程之间的优先次序。例如,软件工程专业的课程及先决条件如表7-2所示。 表7-2 软件工程专业课程关系表 课 程 编 号 课 程 名 称 先修课程编号 C1 高等数学 无 C2 程序设计基础 无 C3 离散数学 C1,C2 C4 数据结构 C2,C3 C5 算法设计与分析 C2,C4 C6 普通物理 C1 C7 计算机组成原理 C6 C8 操作系统 C4,C7 C9 编译原理 C4,C5 C10 线性代数 C1      这些课程之间的关系利用有向图可以更清楚地表示,如图7-26所示。   在AOV网中,不应该出现有向环,因为存在环意味着某项活动以自己为先决条件,显然这是不可能的。若设计出这样的流程图,工程就无法进行;对于程序的流程图来说,就是一个死循环。因此,对给定的有向图,应首先判断网中是否存在环,检测的办法就要利用有向图的拓扑排序知识了。 图7-26 表示课程之间优先关系的有向图   2. 拓扑排序   拓扑排序的方法如下。   (1)在有向图中任意选择一个没有前驱的顶点即入度为零的顶点,将该顶点输出。   (2)从图中删除该顶点和所有以它为尾的弧。   (3)重复执行步骤(1)和(2),直至所有顶点均已被输出或者当前图中不存在无前驱的顶点为止(说明有向图中存在环)。   按照以上方法,可得到如图7-28所示的有向图的两个拓扑序列(当然还可构造其他拓扑序列)为(C1,C6,C10,C7,C2,C3,C4,C5,C8,C9)和(C2,C1,C3,C4,C5,C9,C10,C6,C7,C8)。图7-27展示了一个完整的拓扑序列的构造过程,其拓扑序列为V1、V2、V4、V3、V5、V6。 (a)有向图 (b)输出V1后 (c)输出V2后 (d)输出V4后 (e)输出V3后 (f)输出V5后 图7-27 AOV网构造拓扑序列的过程   对有向图拓扑排序后,如果图中的顶点全部输出,表示图中不存在回路,则表明该有向图为AOV网;如果图中还存在未输出的顶点,表示图中存在回路。   针对以上算法步骤,可采用邻接表作为有向图的存储结构,且在头结点中增加一个存放顶点入度的数组indegree。入度为零的顶点即为没有前驱的顶点,算法实现:遍历邻接表,将各个顶点的入度保存在数组indegree中;将入度为零的顶点入栈,依次将栈顶元素出栈并输出该顶点,对该顶点的邻接顶点的入度减1,如果邻接顶点的入度为零,则入栈,否则将下一个邻接顶点的入度减1并进行相同的处理;然后继续将栈中元素出栈;重复执行以上操作,直到栈空为止。   有向图的拓扑排序算法描述如下。 int TopologicalSort(AdjGraph G) /*有向图G的拓扑排序。如果图G没有回路,则输出G的一个拓扑序列并返回1,否则返回0*/ { int i,k,count=0; int indegree[MaxSize]; /*存放各顶点当前入度*/ SeqStack S; ArcNode *p; /*将图中各顶点的入度保存在数组indegree中*/ for(i=0;iadjvex; indegree[k]++; p=p->nextarc; } } /*对图G进行拓扑排序*/ InitStack(&S); /*初始化栈S*/ for(i=0;inextarc)/*处理编号为i的顶点的所有邻接顶点*/ { k=p->data.adjvex; if(!(--indegree[k])) /*如果编号为i的邻接顶点的入度减1后变为0,则将其入栈*/ PushStack(&S,k); } } if(count、…、表示11个活动,a1、a2、…、a11表示活动的执行时间。进入顶点的有向弧表示的活动已经完成,从顶点出发的有向弧表示的活动可以开始。顶点v1表示整个工程的开始,v9表示整个工程的结束。顶点v5表示活动a4、a5已经完成,活动a7和a8可以开始。完成活动a1和活动a2分别需要6天和4天。   在某事件(顶点表示)发生之后,活动(该顶点出发的有向弧)才能开始。活动完成之后,之后的事件才会发生。   由于整个工程只有一个开始点和一个完成点,对于AOE网来说,网中只有一个入度为零的点(称为源点)和一个出度为零的点(称为汇点)。   2. 关键路径   AOE网需要研究的问题是完成整个工程至少需要多少时间,以及哪些活动是影响工程进度的关键。   由于在AOE网中有些活动可以并行进行,所以完成工程的最短时间是从开始点到完成点的最长路径的长度,这里所说的路径长度是指路径上各个活动持续时间之和。最长的路径就是关键路径(critical path)。在AOE网中,关键路径其实就是完成工程的最短时间所经过的路径。关键路径表示了完成工程的最短工期。   下面是和关键路径有关的几个概念。   (1)事件vi的最早发生时间ve(i):从源点到顶点vi的最长路径长度,称为事件vi的最早发生时间,记作ve(i)。求解ve(i)可以从源点ve(0)=0开始,按照拓扑排序规则根据递推得到,即ve(i)=Max{ve(k)+dut()|∈T,1≤i≤n-1},其中,T是所有以第i个顶点为弧头的弧的集合,dut()表示弧对应的活动的持续时间。例如,已知v2的最早发生时间为ve(2)=6,v3的最早发生时间为ve(3)=4,活动a4和a5的持续时间为1,故v5的最早发生时间为ve(5)=Max(6+1,4+1)=7,如图7-29所示。 图7-28 一个AOE网 图7-29 v5的最早发生时间   (2)事件vi的最晚发生时间vl(i):在保证整个工程正常完成的前提下,活动的最迟开始时间,记作vl(i)。在求解事件vi的最早发生时间ve(i)的前提vl(n-1)=ve(n-1)下,从汇点开始,向源点推进得到vl(i)=Min{vl(k)-dut()|∈S,0≤i≤n-2},其中,S是所有以第i个顶点为弧尾的弧的集合,dut()表示弧对应的活动的持续时间。   (3)活动ai的最早开始时间e(i):如果弧表示活动ai,当事件vk发生之后,活动ai才开始。因此,事件vk的最早发生时间也就是活动ai的最早开始时间,即e(i)=ve(k)。   (4)活动ai的最晚开始时间l(i):在不推迟整个工程完成时间的基础上,活动ai最迟必须开始的时间。如果弧表示活动ai持续时间为dut(),则活动ai的最晚开始时间l(i)=vl(j)-dut()。例如,因事件v8的最晚开始时间为14,活动a8的持续时间为7,所以a8的最晚开始时间为14-7=7,如图7-30所示。 图7-30 活动a8的最晚开始时间   (5)活动ai的松弛时间:活动ai的最晚开始时间与最早开始时间之差,记作l(i)-e(i)。   在如图7-28所示的AOE网中,从源点v1到汇点v9的关键路径是(v1,v2,v5,v8,v9),路径长度为18,也就是说,v9的最早发生时间为18。活动a6的最早开始时间是5,最晚开始时间是8,这意味着,如果a6推迟3天开始或延迟3天完成,都不会影响到整个工程的进度。   当e(i)=l(i)时,对应的活动ai称为关键活动。在关键路径上的所有活动都称为关键活动,非关键活动提前完成或推迟完成并不会影响到整个工程的进度。例如,活动a6是非关键活动,a8是关键活动。   求关键路径的算法如下。   (1)对网中的顶点进行拓扑排序,如果得到的拓扑序列顶点个数小于网中顶点数,则说明网中有环存在,不能求关键路径,终止算法;否则从源点v0开始,求出各个顶点的最早发生时间ve(i)。   (2)从汇点vn出发vl(n-1)=ve(n-1),按照逆拓扑序列求其他顶点的最晚发生时间vl(i)。   (3)由各顶点的最早发生时间ve(i)和最晚发生时间vl(i),求出每个活动ai的最早开始时间e(i)和最晚开始时间l(i)。   (4)找出所有满足条件e(i)=l(i)的活动ai,ai即是关键活动。   如上所述,计算各顶点的ve值是在拓扑排序的过程中进行的,需对拓扑排序的算法做如下修改:①在拓扑排序之前设置初值,令ve[i]=0;②在算法中增加一个计算vj的直接后继vk的最早发生时间的操作:若ve[j]+ dut()>ve[k],则ve[k]= ve[j]+ dut(); ③为了能按逆拓扑排序序列计算各顶点的vl值,需记下在拓扑排序的过程中求得的拓扑有序序列,这需要在拓扑排序算法中,增加一个记录拓扑有序序列,则在计算求得各顶点的ve值后,从栈顶到栈底便是逆拓扑有序序列。   利用AOE网的关键路径算法,如图7-28所示的网中顶点对应事件最早发生时间ve、最晚发生时间vl及弧对应活动最早发生时间e、最晚发生时间e如图7-31所示。 图7-31 如图7-28所示AOE网顶点发生时间与活动的开始时间   显然,网的关键路径有(v1,v2,v5,v8,v9)和(v1,v2,v5,v7,v9)两条,对应的关键活动是a1、a4、a5、a11和a1、a4、a7和a10。   关键路径经过的顶点满足条件ve(i)==vl(i),即当事件的最早发生时间与最晚发生时间相等时,该顶点一定在关键路径之上。同样,关键活动的弧满足条件e(i)=l(i),即当活动的最早开始时间与最晚开始时间相等时,该活动一定是关键活动。   求每一个顶点的最早开始时间,首先要将网中的顶点进行拓扑排序。在对顶点进行拓扑排序过程中,同时计算顶点的最早发生时间ve(i)。从源点开始,由与源点相关联的弧的权值,可以得到该弧相关联顶点对应事件的最早发生时间。同时定义一个栈T,保存顶点的逆拓扑序列。利用拓扑排序求ve(i)的算法实现如下。 int TopologicalOrder(AdjGraph N,SeqStack *T) /*采用邻接表存储结构的有向网N的拓扑排序,并求各顶点对应事件的最早发生时间ve*/ /*如果N无回路,则用栈T返回N的一个拓扑序列,并返回1,否则为0*/ { int i,k,count=0; int indegree[MaxSize]; /*数组indegree存储各顶点的入度*/ SeqStack S; ArcNode *p; /*将图中各顶点的入度保存在数组indegree中*/ FindInDegree(N,indegree); InitStack(&S); /*初始栈S*/ for(i=0;inextarc) /*处理序号为i的顶点的每个邻接点*/ { k=p->adjvex; /*顶点序号为k*/ if(--indegree[k]==0) /*如果k的入度减1后变为0,则将k入栈S*/ PushStack(&S,k); if(ve[i]+*(p->info)>ve[k]) /*计算顶点k对应的事件的最早发生时间*/ ve[k]=ve[i]+*(p->info); } } if(countinfo)>ve[k]) ve[k]=ve[i]+*(p->info)就是求顶点k的对应事件的最早发生时间,域info保存的是对应弧的权值,在这里将图的邻接表类型定义做了简单的修改。   在求出事件的最早发生时间之后,按照逆拓扑序列就可以推出事件的最晚发生时间、活动的最早开始时间和最晚开始时间。在求出所有参数之后,如果ve(i)==vl(i),输出关键路径经过的顶点。如果e(i)=l(i),将与对应弧关联的两个顶点存入数组e,用来输出关键活动。关键路径算法实现如下。 int CriticalPath(AdjGraph N) /*输出N的关键路径*/ { int vl[MaxSize]; /*事件最晚发生时间*/ SeqStack T; int i,j,k,e,l,dut,value,count,e1[MaxSize],e2[MaxSize]; ArcNode *p; if(!TopologicalOrder(N,&T)) /*如果有环存在,则返回0*/ return 0; value=ve[0]; for(i=1;ivalue) value=ve[i]; /*value为事件的最早发生时间的最大值*/ for(i=0;inextarc) /*弹出栈T的元素,赋给j,p指向j的后继事件k*/ { k=p->adjvex; dut=*(p->info); /*dut为弧的权值*/ if(vl[k]-dutnextarc) { k=p->adjvex; dut=*(p->info); /*dut为弧的权值*/ e=ve[j]; /*e就是活动的最早开始时间*/ l=vl[k]-dut; /*l就是活动的最晚开始时间*/ printf("%s→%s %3d %3d %3d\n",N.vertex[j].data,N.vertex[k].data,e,l,l-e); if(e==l) /*将关键活动保存在数组中*/ { e1[count]=j; e2[count]=k; count++; } } printf("关键活动为:"); for(k=0;k的权值;否则,dist[i]的值为∞。   假设S表示求出的最短路径对应终点的集合。在按递增次序已经求出从顶点v0出发到顶点vj的最短路径之后,那么下一条最短路径,即从顶点v0到顶点vk的最短路径或者是弧,或者是经过集合S中某个顶点然后到达顶点vk的路径。从顶点v0出发到顶点vk的最短路径长度或者是弧的权值,或者是dist[j]与vj到vk的权值之和。   求最短路径长度满足:终点为vx的最短路径或者是弧,或者是中间经过集合S中某个顶点然后到达顶点vx所经过的路径。下面用反证法证明此结论。假设该最短路径有一个顶点vzS,则最短路径为(v0,…,vz,…,vx)。但是这种情况是不可能出现的,因为最短路径是按照路径长度的递增顺序产生的,所以长度更短的路径已经出现,其终点一定在集合S中。因此假设不成立,结论得证。   例如,从图7-32可以看出,(v0,v2)是从v0到v2的最短路径,(v0,v2,v3)是从v0到v3的最短路径,经过了顶点v2;(v0,v2,v3,v4)是从v0到v4的最短路径,经过了顶点v3。   一般情况下,下一条最短路径的长度一定是   dist[j]=Min{dist[i]|vi∈V-S}   其中,dist[i]或者是弧的权值,或者是dist[k](vk∈S)与弧的权值之和。V-S表示还没有求出的最短路径的终点集合。   迪杰斯特拉算法求解最短路径步骤如下(假设有向图用邻接矩阵存储)。   (1)初始时,S只包括源点v0,即S={v0},V-S包括除v0以外的图中的其他顶点。v0到其他顶点的路径初始化为dist[i]=G.arc[0][i].adj。   (2)选择距离顶点vi最短的顶点vj,使得dist[j]=Min{dist[i]|vi∈V-S},dist[j]表示从v0到vj最短路径长度,vj表示对应的终点。   (3)修改从v0到顶点vi的最短路径长度,其中,vi∈S。如果有dist[k]+G.arc[k][i]存在,则它就是从v0到vi的当前最短路径,令path[i]=0,表示该最短路径上顶点vi的前一个顶点是v0;若v0到vi没有路径,则令path[i]=-1)。   (2)从V-S集合中找到一个顶点,该顶点与S集合中的顶点构成的路径最短,即dist[]数组中值最小的顶点为v1,将其添加到S中,则S={ v0,v1},V-S={ v2,v3,v4,v5}。考查顶点v1,发现从v1到v2和v3存在边,则得到:   dist[2]=min{dist[2],dist[1]+40}=60   dist[3]=min{dist[3],dist[1]+100}=130(修改) 则dist[]=[0,30,60,130,150,40],同时修改v1到v3路径上的前驱顶点,path[]=[0,0,0,1,0,0]。   (3)从V-S中找到一个顶点v5,它与S中顶点构成的路径最短,即dist[]数组中值最小的顶点,将其添加到S中,则S={ v0,v1,v5},V-S={ v2,v3,v4 }。考查顶点v5,发现v5与其他顶点不存在边,则dist[]和path[]保持不变。   (4)从V-S中找到一个顶点v2,它与S中顶点构成的路径最短,即dist[]数组中值最小的顶点,将其加入到S中,则S= { v0,v1,v5,v2},V-S={ v3,v4 }。考查顶点v2,从v2到v3存在边,则得到:   dist[3]=min{dist[3],dist[2]+50}=110(修改) 则dist[]=[0,30,60,110,150,40],同时修改v1到v3路径上的前驱顶点,path[]=[0,0,0,2,0,0]。   (5)从V-S中找到一个顶点v3,它与S中顶点构成的路径最短,即dist[]数组中值最小的顶点,将其加入到S中,则S= { v0,v1,v5,v2,v3},V-S={ v4 }。考查顶点v3,从v3到v4存在边,则得到:   dist[4]=min{dist[4],dist[3]+30}=140(修改) 则dist[]=[0,30,60,110,140,40],同时修改v1到v4路径上的前驱顶点,path[]=[0,0,0,2,3,0]。   (6)从V-S中找到与S中顶点构成的路径最短的顶点v4,即dist[]数组中值最小的顶点,将其加入到S中,则S= { v0,v1,v5,v2,v3,v4},V-S={ }。考查顶点v4,从v4到v5存在边,则得到:   dist[5]=min{dist[5],dist[4]+10}=40 则dist[]和path[]保持不变,即dist[]=[0,30,60,110,140,40],path[]=[0,0,0,2,3,0]。存储最短路径前驱结点下标的数组path的值如表7-3所示。 表7-3 path[]的值 数组下标 0 1 2 3 4 5 数组的值 0 0 0 2 3 0   根据dist[]和path[]中的值输出从v0到其他各顶点的最短路径。例如,从v0到v4的最短路径可根据path[]获得:由path[4]=3得到v4的前驱顶点为v3,由path[3]=2得到v3的前驱顶点为v2,由path[2]=0得到v2的前驱顶点为v0,因此反推出从v0到v4的最短路径为v0→v2→v3→v4,最短路径长度为dist[4],即140。   2. 迪杰斯特拉算法实现   求最短路径的迪杰斯特拉算法描述如下。 typedef int PathMatrix[MaxSize]; /*定义一个保存最短路径的一维数组*/ typedef int ShortPathLength[MaxSize]; /*定义一个保存从顶点v0到顶点v的最短距离的数组*/ void Dijkstra(MGraph N,int v0,PathMatrix path,ShortPathLength dist,int final[]) /*用Dijkstra算法求有向网N的v0顶点到其余各顶点v的最短路径path[v]及带权长度dist[v]*/ /*final[v]为1表示v∈S,即已经求出从v0到v的最短路径*/ { int v,w,k,min; for(v=0;v v%d : ", v0, i); j = i; /*j用于遍历while循环*/ printf("%s ", N.vex[v0]); while (path[j] != 0) { apath[k]=path[j]; j=path[j]; k++; } for(j=k-1;j>=0;j--) { printf("%s ", N.vex[apath[j]]); } printf("%s ", N.vex[i]); printf("\n"); } printf("\n顶点v%d到各顶点的最短路径长度为:\n", v0); for (i = 1; i < N.vexnum; i++) printf("%s - %s : %d \n", N.vex[0], N.vex[i], dist[i]); /*dist数组中存放v0到各顶点的最短路径*/ } void main() { int i,vnum=6,arcnum=9,final[MaxSize]; MGraph N; GNode value[]={{0,1,30},{0,2,60},{0,4,150},{0,5,40}, {1,2,40},{1,3,100},{2,3,50},{3,4,30},{4,5,10}}; VertexType ch[]={"v0","v1","v2","v3","v4","v5"}; PathMatrix path; /*用二维数组存放最短路径所经过的顶点*/ ShortPathLength dist; /*用一维数组存放最短路径长度*/ CreateGraph(&N,value,vnum,arcnum,ch); /*创建有向网N*/ DisplayGraph(N); /*输出有向网N*/ Dijkstra(N,0,path,dist,final); PrintShortPath(N, 0, path, dist); /*打印最短路径*/ }   程序运行结果如图7-35所示。 图7-35 迪杰斯特拉算法求从v0到其他各顶点最短路径的程序运行结果 7.6.2 每一对顶点之间的最短路径   如果要计算每一对顶点之间的最短路径,需每次以一个顶点为出发点,将迪杰斯特拉算法重复执行n次,就可以得到每一对顶点的最短路径。总的时间复杂度为O(n3)。下面介绍由另一位伟大的计算机科学家弗洛伊德(Floyd)提出的另一个算法,其时间复杂度也是O(n3),但其形式简单些。   1. 各个顶点之间的最短路径算法思想   求解各个顶点之间最短路径的弗洛伊德算法的思想是:假设要求顶点vi到顶点vj的最短路径。如果从顶点vi到顶点vj存在弧,但是该弧所在的路径不一定是vi到vj的最短路径,需要进行n次比较。首先需要从顶点v0开始,如果有路径(vi,v0,vj)存在,则比较路径(vi,vj)和(vi,v0,vj),选择两者中最短的一个且中间顶点的序号不大于0。   然后在路径上再增加一个顶点v1,得到路径(vi,…,v1)和(v1,…,vj),如果两者都是中间顶点不大于0的最短路径,则将该路径(vi,…,v1,…,vj)与上面已经求出的中间顶点序号不大于0的最短路径比较,选中其中最小的作为从vi到vj的中间路径顶点序号不大于1的最短路径。   接着在路径上增加顶点v2,得到路径(vi,…,v2)和(v2,…,vj),按照以上方法进行比较,求出从vi到vj的中间路径顶点序号不大于2的最短路径。以此类推,经过n次比较,可以得到从vi到vj的中间顶点序号不大于n-1的最短路径。依照这种方法,可以得到各个顶点之间的最短路径。   假设采用邻接矩阵存储带权有向图G,则各个顶点之间的最短路径可以保存在一个n阶方阵D中,每次求出的最短路径可以用矩阵表示为:D-1,D0,D1,D2,…,Dn-1。其中,D-1[i][j]=G.arc[i][j].adj,Dk[i][j]=Min{Dk-1[i][j],Dk-1[i][k]+Dk-1[k][j]|,0≤k≤n-1}。其中,Dk[i][j]表示从顶点vi到顶点vj的中间顶点序号不大于k的最短路径长度,而Dn-1[i][j]即为从顶点vi到顶点vj的最短路径长度。   根据弗洛伊德算法,求解如图7-32所示的带权有向图G7的每一对顶点之间最短路径的过程如下(D存放每一对顶点之间的最短路径长度,P存放最短路径中到达某顶点的前驱顶点下标)。   (1)初始时,D中元素的值为顶点间弧的权值,若两个顶点间不存在弧,则其值为∞。顶点v2到v3存在弧,权值为50,故D-1[2][3]=50;路径(v2,v3)的前驱顶点为v2,故P-1[2][3]=2。顶点v4到v5存在弧,权值为10,故D-1[4][5]=10;路径(v4,v5)的前驱顶点为v4,故P-1[4][5]=4。若没有前驱顶点,则P中相应的元素值为-1。D和P的状态如图7-36所示。 图7-36 D和P的初始状态   (2)考察v0,经过比较,从顶点vi到vj经由顶点v0的最短路径无变化,因此,D0和P0如图7-37所示。 图7-37 经由顶点v0的D和P的存储状态   (3)考察顶点v1,从顶点v1到v2和v3存在路径,由顶点v0到v1的路径可得到v0到v2和v3的路径D1[0][2]=70(由于70>60,D1[0][2]的值保持不变)和D1[0][3]=130(由于130<∞,故需更新D1[0][3]的值为130,同时前驱顶点P1[0][3]的值为1),因此更新后的最短路径矩阵和前驱顶点矩阵如图7-38所示。 图7-38 经由顶点v1的D和P的存储状态   (4)考察顶点v2,从顶点v2到v3存在路径,由顶点v0到v2的路径可得到v0到v3的路径D2[0][3]=110(由于110<130,故需更新D2[0][3]的值为110,同时前驱顶点P1[0][3]的值为2)。同时,修改从顶点v1到v3路径(D2[1][3]=90<100)和P2[1][3]的值,因此,更新后的最短路径矩阵和前驱顶点矩阵如图7-39所示。 图7-39 经由顶点v2的D和P的存储状态   (5)考察顶点v3,从顶点v3到v4存在路径,由顶点v0到v3的路径可得到v0到v4的路径D3[0][4]=140(由于140<150,故需更新D3[0][4]的值为140,同时前驱顶点P3[0][4]的值为3)。同时,更新从v1、v2到v4的最短路径长度和前驱顶点,因此,更新后的最短路径矩阵和前驱顶点矩阵如图7-40所示。 图7-40 经由顶点v3的D和P的存储状态   (6)考察顶点v4,从顶点v4到v5存在路径,则按以上方法计算从各顶点经由v4到其他各顶点的路径长度和前驱顶点,更新后的最短路径矩阵和前驱顶点矩阵如图7-41所示。 图7-41 经由顶点v4的D和P的存储状态   (7)考察顶点v5,从顶点v5到其他各顶点不存在路径,故无须更新最短路径矩阵和前驱顶点矩阵。根据以上分析,图G7的各个顶点间的最短路径及长度如图7-42所示。 图7-42 带权有向图G7的各个顶点之间的最短路径及长度   2. 各个顶点之间的最短路径算法实现   根据以上弗洛伊德算法思想,各个顶点之间的最短路径算法实现如下。 void Floyd_Short_Path(MGraph N) /*用Floyd算法求有向网N任意顶点之间的最短路径,其中,D[u][v]表示从u到v当前得到的最短路径,P[u][v]存放的是u到v的前驱顶点*/ { int D[MaxSize][MaxSize],P[MaxSize][MaxSize]; int u,v,w; for (u=0;uD[u][w]+D[w][v]) /*从u经w到v的一条路径为当前最短的路径*/ { D[u][v]=D[u][w]+D[w][v]; /*更新u到v的最短路径长度*/ P[u][v]=P[w][v]; /*更新最短路径中u到v的前驱顶点*/ } } Print_Short_Path(N,D,P); /*输出最短路径*/ printf("最短路径中各顶点的前驱顶点:\n"); PrintMatrix(P,N.vexnum); /*输出前驱顶点*/ }   计算机科学家简介:   Robert W. Floyd(罗伯特·W·弗洛伊德),1978年图灵奖获得者、斯坦福大学计算机科学系教授。Floyd是一位“自学成才的计算机科学家”。Floyd于1936年6月8日生于纽约,17岁获得芝加哥大学文学学士学位。毕业后,由于没有任何专门技能,Floyd无奈之下到Westinghouse Electric Corporation当了两年计算机操作员,期间,Floyd很快对计算机产生了兴趣,于是他在值班空闲时间刻苦学习钻研,白天又回母校去听有关课程,在1958年获得了物理学学士学位,逐渐变成计算机的行家里手。1956年,他到芝加哥的装甲研究基金会从事操作员的工作,后来成为程序员。1962年,他被马萨诸塞州的Computer Associates公司聘为分析员,此时与Warsall合作发布了Floyd-Warshall算法。1965年,他成为卡内基·梅隆大学的副教授,3年后转至斯坦福大学,1970年任教授。Floyd一生取得了许多成就,包括Floyd算法、编译器的开发与规则制定,他还是《计算机程序设计艺术》的主要评审。他开发了Algol 60编译器,提出了优先文法、有限上下文文法,与J Williams于1964年共同发明了著名的堆排序算法。1978年,Robert W. Floyd被授予图灵奖。   Edsgar Wybe Dijkstra(狄杰斯特拉),1930年5月11日出生于荷兰鹿特丹的一个知识分子家庭。1948年,Dijkstra进入莱顿大学学习数学与物理。期间,Dijkstra开始学习计算机编程。1951年,他自费赴英国参加了剑桥大学举办的一个程序设计培训班,第二年,被阿姆斯特丹数学中心聘为兼职程序员。1956年,Dijkstra成功地设计并实现了最短路径的高效算法──Dijkstra算法,解决了运动路径规划问题。1960年8月, Dijkstra和数学中心的同事率先实现了世界上第一个Algol 60编译器,并因此奠定了他作为世界一流计算机学者在科学界的地位。1962年,Dijkstra担任艾恩德大学(Eindhoven Technical University)数学教授。Dijkstra对计算机科学的贡献并不仅限于程序设计技术,在算法和算法理论、编译器、操作系统诸多方面都有许多创造,做出了杰出贡献。 7.6.3 最短路径应用举例   带权图(权值非负,表示边连接的两个顶点间的距离)的最短路径问题是找出从初始顶点到目标顶点之间的一条最短路径。假设从初始顶点到目标顶点之间存在路径,解决问题的方法如下。   (1)设最短路径初始时仅包含初始顶点,令当前顶点u为初始顶点。   (2)选择离u最近且尚未在最短路径中的一个顶点v,加入最短路径中,修改当前顶点u=v。   (3)重复执行步骤(1)和(2),直到u是目标顶点为止。   请问上述方法能否求得最短路径?若该方法可行,请证明之;否则,举例说明。   【分析】该题目是某年的考研试题,主要考查最短路径的掌握情况。按上述方法不一定能求出最短路径。例如,一个带权图如图7-43所示。 (a) (b) 图7-43 求带权图   对于图7-43(a),设初始顶点为1,目标顶点为4,求从顶点1到顶点4之间的最短路径。显然这两个顶点之间的最短路径为2。而利用题中给出的方法求得的最短路径为1→2→3→4,长度为3,这条路径并不是顶点1到顶点4之间的最短路径。   对于图7-43(b),设初始顶点为1,目标顶点为3,求从顶点1到顶点3之间的最短路径,利用题目给出的方法,求出1→2之后,无法求出顶点1到顶点3的路径。 7.7 图的应用举例   本节将通过两个具体实例来说明图的具体应用。   【例7-3】有一个邻接表存储的图G,分别设计实现如下要求的算法。   (1)求出图G中每个顶点的出度。   (2)求出图G中出度最大的一个顶点,输出该顶点的编号。   (3)计算图G中出度为零的顶点数。   (4)判断图G中是否存在边。   【分析】主要考查对图的邻接表存储特点和基本操作掌握情况。从图的表头结点出发,依次访问边表结点,并进行计数,就可得到相应每个顶点的出度。问题(1)、(2)、(3)可归结为一个问题,其中,求某个顶点的出度可以写成一个函数OutDegree(AdjGraph G,int v),在求(1)、(2)、(3)的问题时,可调用该函数实现。   对于问题(4),可令p=G.vertex[i].firstarc,然后依次遍历p指向链表中的每个结点,看该结点的序号是否为j,如果为j,则说明图中存在弧;若p==NULL,则说明图中不存在弧。代码如下。 while(p!=NULL && p->adjvex!=j) p=p->nextarc;   算法实现如下。 void main() { AdjGraph G; CreateGraph(&G); /*采用邻接表存储结构创建有向图G*/ DisplayGraph(G); /*输出有向图G*/ AllOutDegree(G); /*有向图G各顶点的出度*/ MaxOutDegree(G); /*求有向图G出度最大的顶点*/ ExistArc(G); /*判断有向图G中是否存在弧*/ DestroyGraph(&G); /*销毁图G*/ } int OutDegree(AdjGraph G,int v) { ArcNode *p; int n=0; p=G.vertex[v].firstarc; while(p!=NULL) { n++; p=p->nextarc; } return n; } void AllOutDegree(AdjGraph G) { int i; printf("(1)各顶点的出度:\n"); for(i=0;imaxds) { maxds=x; maxv=i; } } printf("(2)最大出度的顶点是%s,出度为%d\n",G.vertex[maxv].data,maxds); } void ZeroOutDegree(AdjGraph G) { int i,x; printf("(3)出度为零的顶点:"); for(i=0;i\n"); printf("请输入弧的弧头和弧尾:"); scanf("%s%s%*c",v1,v2); i=LocateVertex(G,v1); j=LocateVertex(G,v2); p=G.vertex[i].firstarc; while(p!=NULL && p->adjvex!=j) p=p->nextarc; if(p==NULL) printf("不存在弧<%s,%s>\n",v1,v2); else printf("存在弧<%s,%s>\n",v1,v2); }   程序运行结果如图7-44所示。   【例7-4】设计一个算法,判断无向图G是否为一棵树。   【分析】一个无向图G是一棵树的条件是G必须是无回路的连通图或是有n-1条边的连通图,这里采用后者作为判断条件。   对连通的判定,可通过判断能否遍历全部顶点来实现,算法如下。 int IsTree(AdjGraph *G) { int vNum=0,eNum=0,i; for(i=0;ivexnum;i++) visited[i]=0; DFS(G,0,&vNum,&eNum); if(vNum==G->vexnum && eNum==2*(G->vexnum-1)) return 1; else return 0; } void DFS(AdjGraph *G,int v,int *vNum,int *eNum) { ArcNode *p; visited[v]=1; (*vNum)++; p=G->vertex[v].firstarc; while(p!=NULL) { (*eNum)++; if(visited[p->adjvex]==0) DFS(G,p->adjvex,vNum,eNum); p=p->nextarc; } }   在深度搜索遍历的过程中,同时对遍历过的顶点和边数计数,当全部顶点都遍历过且边数为2×(n-1)时,这个图就是一棵树,否则不是一棵树。   程序运行结果如图7-45所示。 图7-44 程序运行结果 图7-45 判断无向图G是否为一棵树的程序运行结果 7.8 小结   图中元素之间是一种多对多的关系。   图由顶点和边(弧)构成,根据边的有向和无向可以将图分为两种:有向图和无向图。将带权的有向图称为有向网,带权的无向图称为无向网。   图的存储结构有邻接矩阵存储结构、邻接表存储结构、十字链表存储结构和邻接多重表存储结构4种。其中,最常用的是邻接矩阵存储和邻接表存储。   图的遍历分为广度优先搜索和深度优先搜索两种。图的广度优先搜索遍历类似于树的层次遍历,图的深度优先搜索遍历类似于树的先根遍历。   一个连通图的生成树是指一个极小连通子图,假设图中有n个顶点,则它包含图中n个顶点和构成一棵树的n-1条边。   构造最小生成树的算法主要有两个,即普里姆算法和克鲁斯卡尔算法。   关键路径是指路径最长的路径,关键路径表示了完成工程的最短工期。关键路径上的活动称为关键活动,关键活动可以决定整个工程完成任务的日期。非关键活动不能决定工程的进度。   最短路径是指从一个顶点到另一个顶点路径长度最小的一条路径。求最短路径的算法主要有两个,即迪杰斯特拉算法和弗洛伊德算法。    第四篇 常用算法                         第8章 查找            在计算机处理非数值问题时,查找是一种经常使用和非常重要的操作。根据查找的策略,可分为静态查找和动态查找。哈希查找是一种区别于关键字匹配的查找方式。   本章重点和难点: * 折半查找算法。 * 索引顺序表的查找。 * 二叉排序树和平衡二叉树。 * B-树和B+树。 * 哈希表的构造与查找。 8.1 基本概念   在介绍有关查找的算法之前,先介绍与查找相关的基本概念。   (1)关键字(key)与主关键字(primary key):数据元素中某个数据项的值。如果该关键字可以将所有的数据元素区别开来,也就是说,可以唯一标识一个数据元素,则该关键字称为主关键字,否则称为次关键字(secondary key)。特别地,如果数据元素只有一个数据项,则数据元素的值即关键字。   (2)查找表(search table):是由同一种类型的数据元素构成的集合。查找表中的数据元素是完全松散的,数据元素之间没有直接联系。   (3)查找(searching):根据关键字在特定的查找表中找到一个与给定关键字相同的数据元素的操作。如果在表中找到相应的数据元素,则称查找是成功的,否则称查找是失败的。例如,表8-1为教师基本情况信息表,如果要查找职称为“教授”并且性别是“男”的教师,则可以先利用职称将记录定位,然后在性别中查找值为“男”的记录。 表8-1 教师基本情况信息表 工 号 姓 名 性 别 出 生 年 月 所 在 院 系 职 称 研究兴趣 2001001 张宝华 男 1970.09 软件学院 教授 软件工程 2006002 刘 刚 男 1978.12 软件学院 教授 软件工程 2017107 吴艳丽 女 1988.01 软件学院 讲师 人工智能 2013021 杨彩玉 女 1986.11 电气工程学院 副教授 图像处理 2008008 郭东义 男 1980.07 计算机学院 副教授 网络安全   对查找表经常进行的操作有查询某个“特定的”数据元素是否在查找表中、检索某个“特定的”数据元素的各种属性、在查找表中插入一个数据元素、从查找表中删除某个数据元素。若对查找表只进行前两种查找操作,则称此类查找表为静态查找表,相应的查找方法称为静态查找。若在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已存在的某个数据元素,则称此类查找表为动态查找表,相应的查找方法为动态查找。   例如,在电话号码簿中查找某人的电话号码,在字典中查找某个字的读音和含义,电话号码簿和字典就可看作一张查找表。   通常为了方便讨论查找,要查找的数据元素中仅包含关键字。   平均查找长度(Average Search Length,ASL)是指在查找过程中,需要比较关键字的平均次数,它是衡量查找算法的效率标准。平均查找长度的数学定义式为ASL=。其中,Pi表示查找表中第i个数据元素的概率,Ci表示在找到第i个数据元素时与关键字比较的次数。 8.2 静态查找   静态查找可分为顺序表的查找、有序顺序表的查找和索引顺序表的查找。 8.2.1 顺序表的查找   顺序表的查找过程为从表的一端开始,逐个与关键字进行比较,若某个数据元素的关键字与给定的关键字相等,则查找成功,函数返回该数据元素所在的顺序表的位置;否则查找失败,返回0。   顺序表的存储结构如下。 #define MaxSize 100 typedef struct { KeyType key; }DataType; typedef struct { DataType list[MaxSize]; int length; }SSTable;   顺序表的查找算法描述如下。 int SeqSearch(SSTable S,DataType x) /*在顺序表中查找关键字为x的元素,如果找到返回该元素在表中的位置,否则返回0*/ { int i=0; while(ix.key) /*从有序顺序表的最后一个元素开始向前比较*/ i--; if(S.list[i].key==x.key) return i; return 0; }   假设表中有n个元素且要查找的数据元素在数据元素集合中出现的概率相等即为,则有序顺序表在查找成功时的平均查找长度为ASL成功===,即查找成功时平均比较次数约为表长的一半。在查找失败时,即要查找的元素没有在表中,则有序顺序表在查找失败时的平均查找长度为ASL失败===。即查找失败时平均比较次数也同样约为表长的一半。   2. 折半查找   折半查找(binary search)又称为二分查找,这种查找算法要求待查找的元素序列必须是从小到大排列的有序序列。   折半查找即将待查找元素与表中间的元素进行比较,如果两者相等,则说明查找成功,否则利用中间位置将表分成两部分;如果待查找元素小于中间位置的元素值,则继续与前一个子表的中间位置元素进行比较,否则与后一个子表的中间位置元素进行比较;不断重复以上操作,直到找到与待查找元素相等的元素,表明查找成功;如果子表变为空表,表明查找失败。   例如,一个有序顺序表为(7,15,22,29,41,55,67,78,81,99),如果要查找元素67,利用折半查找算法思想,折半查找的过程如图8-1所示。 图8-1 折半查找过程   其中,low和high表示两个指针,分别指向待查找元素的下界和上界,指针mid指向low和high的中间位置,即mid=(low+high)/2。   初始时,low=0,high=9,mid=(0+9)/2=4,因为list[mid]x,所以需要在左半区间继续查找x。此时有low=5,high=6,mid=5,因为list[mid]x.key) /*如果mid所指示的元素大于关键字,则修改high指针*/ high=mid-1; } return 0; } 图8-2 折半查找元素67的判定树   折半查找过程可以用一个判定树来描述。从图8-1中可以看出,查找元素41需要比较1次,查找元素78需要比较2次,查找元素55需要比较3次,查找元素67需要比较4次。整个查找过程可以用二叉判定树来表示,如图8-2所示。   其中,结点旁边的序号为该元素在序列中的下标。从图8-2中的判定树不难看出,查找元素67的过程正好是从根结点到元素值为67的结点的路径。查找元素67的比较次数正好是该元素在判定树中的所在层次。因此,如果表中有n个元素,折半查找成功时,至多需要比较的次数为+1。   对于具有n个结点的有序表刚好能够构成一个深度为h的满二叉树,则有h=。二叉树中第i层的结点个数是2i-1,假设表中每个元素的查找概率相等,即Pi=,则有序表的折半查找成功时的平均查找长度为ASL成功===。在查找失败时,即要查找的元素没有在表中,则有序顺序表的折半查找失败时的平均查找长度为ASL失败= ==。 8.2.3 索引顺序表的查找   当顺序表中的数据量非常大时,无论使用前述哪种查找算法都需要很长的时间,此时提高查找效率的一个常用方法就是在顺序表中建立索引表。建立索引表的方法是将顺序表分为几个单元,然后分别为这几个单元建立一个索引,原来的顺序表称为主表,提供索引的表称为索引表。索引表中只存放主表中要查找的数据元素的主关键字和索引信息。   图8-3是一个主表和一个按关键字建立的索引表结构图,其中,索引表包括两部分,即顺序表中每个单元的最大关键字和顺序表中每个单元的第一个元素的下标(即每个单元的起始地址)。 图8-3 索引顺序表   这样的表称为索引顺序表,要使查找效率高,索引表必须有序,但主表中的元素不一定要按关键字有序排列。索引顺序表的查找也称为分块查找。   从图8-3可以看出,索引表将主表分为4个单元,每个单元包含5个元素。要查找主表中的某个元素,需要分为两步查找,第一步需要确定要查找元素所在的单元,第二步在该单元查找指定的元素。例如,要查找元素62,首先需要将62与索引表中的元素进行比较,因为46<62<77,所以需要在第3个单元查找,该单元的起始下标是10,因此从主表中的下标为10的位置开始查找62,直到找到该元素为止。如果在该单元中没有找到62,则说明主表中不存在该元素,查找失败。   因索引表中的元素的关键字是有序的,故在确定元素所在主表的单元时,既可采用顺序查找法也可采用折半查找法,但对于主表,只能采用顺序查找法查找。索引顺序表的平均查找长度可以表示为ASL=Lindex+Lunit,Lindex是索引表的平均查找长度,Lunit是单元中元素的平均查找长度。   假设主表中的元素个数为n,并将该主表平均分为b个单元,且每个单元有s个元素,即b=n/s。如果表中的元素查找概率相等,则每个单元中元素的查找概率就是1/s,主表中每个单元的查找概率是1/b。如果用顺序查找法查找索引表中的元素,则索引顺序表查找成功时的平均查找长度为ASL成功= Lindex+Lunit =+=+=+1。如果用折半查找法查找索引表中的元素,则有Lindex=+1≈log2(b+1)-1,将其代入ASL成功= Lindex+Lunit中,则索引顺序表查找成功时的平均查找长度为ASL成功= Lindex+Lunit = log2(b+1)-1+= log2 (b+1)-1+≈log2(n/s+1)+。   当然,如果主表中每个单元中的元素个数不相等,就需要在索引表中增加一项,即用来存储主表中每个单元元素的个数,将这种利用索引表示的顺序表称为不等长索引顺序表。例如,一个不等长的索引顺序表如图8-4所示。 图8-4 不等长索引顺序表 int SeqIndexSearch(SSTable S,IndexTable T,int m,DataType x) /*在主表S中查找关键字为x的元素,T为索引表。如果找到返回该元素在表中的位置,否则返回0*/ { int i,j,bl; for(i=0;i=x.key) break; if(i>=m) /*如果要查找的元素不在索引顺序表中,则返回0*/ return 0; j=T[i].index; /*要查找的元素在主表的第j单元*/ if(idata.key==x.key) /*若找到,则返回指向该结点的指针*/ return p; else if(x.keydata.key) /*若x小于p指向的结点的值,则在左子树中查找*/ p=p->lchild; else p=p->rchild; /*若x大于p指向的结点的值,则在右子树中查找*/ } } return NULL; }   利用二叉排序树的查找算法思想,如果要查找关键字为x.key=62的元素。从根结点开始,依次将该关键字与二叉树的根结点比较。因为有62>57,所以需要在结点为57的右子树中进行查找。因为有62<71,所以需要在以71为结点的左子树中继续查找。因为有62<67,所以需要在结点为67的左子树中查找。因为该关键字与结点为67的左孩子结点对应的关键字相等,所以查找成功,返回结点62对应的指针。如果要查找关键字为23的元素,当比较到结点为12的元素时,因为关键字12对应的结点不存在右子树,所以查找失败,返回NULL。   在二叉排序树的查找过程中,查找某个结点的过程正好是走了从根结点到要查找结点的路径,其比较的次数正好是路径长度+1,这类似于折半查找,与折半查找不同的是,由n个结点构成的判定树是唯一的,而由n个结点构成的二叉排序树则不唯一。例如,图8-6为两棵二叉排序树,其元素的关键字序列分别是{57,21,71,12,51,67,76}和{12,21,51,57,67,71,76}。 图8-6 两种不同形态的二叉排序树示意图   在图8-6中,假设每个元素的查找概率都相等,则图8-6(a)的平均查找长度为ASL成功=×(1+2×2+4×3)=,图8-6(b)的平均查找长度为ASL成功=×(1+2+3+4+5+6+7)=。因此,树的平均查找长度与树的形态有关。如果二叉排序树有n个结点,则在最坏的情况下,平均查找长度为n,在最好的情况下,平均查找长度为log2n。   2. 二叉排序树的插入操作   二叉排序树的插入操作过程其实就是二叉排序树的建立过程。二叉树的插入操作从根结点开始,首先要检查当前结点是否是要查找的元素,如果是则不进行插入操作;否则,将结点插入到查找失败时结点的左指针或右指针处。在算法的实现过程中,需要设置一个指向下一个要访问结点的双亲结点指针parent,就是需要记下前驱结点的位置,以便在查找失败时进行插入操作。   假设当前结点指针cur为空,则说明查找失败,需要插入结点。如果parent->data.key小于要插入的结点x,则需要将parent的左指针指向x,使x成为parent的左孩子结点。如果parent->data.key大于要插入的结点x,则需要将parent的右指针指向x,使x成为parent的右孩子结点。如果二叉排序树为空树,则使当前结点成为根结点。在整个二叉排序树的插入过程中,其插入操作都是在叶子结点处进行的。   二叉排序树的插入操作算法描述如下。 int BSTInsert(BiTree *T,DataType x) /*二叉排序树的插入操作,如果树中不存在元素x,则将x插入到正确的位置并返回1,否则返回0*/ { BiTreeNode *p,*cur,*parent=NULL; cur=*T; while(cur!=NULL) { if(cur->data.key==x.key) /*如果二叉树中存在元素为x的结点,则返回0*/ return 0; parent=cur; /*parent指向cur的前驱结点*/ if(x.keydata.key) /*如果关键字小于p指向的结点的值,则在左子树中查找*/ cur=cur->lchild; else cur=cur->rchild; /*如果关键字大于p指向的结点的值,则在右子树中查找*/ } p=(BiTreeNode*)malloc(sizeof(BiTreeNode)); /*生成结点*/ if(!p) exit(-1); p->data=x; p->lchild=NULL; p->rchild=NULL; if(!parent) /*如果二叉树为空,则第一结点成为根结点*/ *T=p; else if(x.keydata.key)/*如果关键字小于parent指向的结点,则x成为parent的左孩子*/ parent->lchild=p; else /*如果关键字大于parent指向的结点,则x成为parent的右孩子*/ parent->rchild=p; return 1; }   对于一个关键字序列{37,32,35,62,82,95,73,12,5},根据二叉排序树的插入算法思想,对应的二叉排序树插入过程如图8-7所示。   从图8-7可以看出,通过中序遍历二叉排序树,可以得到一个关键字有序的序列{5,12,32,35,37,62,73,82,95}。因此,构造二叉排序树的过程就是对一个无序的序列排序的过程,且每次插入结点都是叶子结点,在二叉排序树的插入操作过程中,不需要移动结点,仅需要移动结点指针,实现较为容易。 图8-7 二叉排序树的插入操作过程   3. 二叉排序树的删除操作   在二叉排序树中删除一个结点后,剩下的结点仍然构成一棵二叉排序树,即保持原来的特性。删除二叉排序树中的一个结点可以分为三种情况讨论。假设要删除的结点由指针s指示,指针p指向s的双亲结点,设s为p的左孩子结点。二叉排序树的各种删除情形如图8-8所示。   (1)如果s指向的结点为叶子结点,其左子树和右子树为空,删除叶子结点不会影响到树的结构特性,因此只需要修改p的指针即可。   (2)如果s指向的结点只有左子树或只有右子树,在删除了结点*s后,只需要将s的左子树sL或右子树sR作为p的左孩子即p->lchild=s->lchild或p->lchid=s->rchild。   (3)如果s的左子树和右子树都存在,在删除结点S之前,二叉排序树的中序序列为{…QLQ…XLXYLYSSRP…},因此,在删除了结点S之后,有两种方法调整可使该二叉树仍然保持原来的性质不变。第一种方法是使结点S的左子树作为结点P的左子树,结点S的右子树成为结点Y的右子树。第二种方法是使结点S的直接前驱取代结点S,并删除S的直接前驱结点Y,然后令结点Y原来的左子树作为结点X的右子树。通过这两种方法均可以使二叉排序树的性质不变。 图8-8 二叉排序树的删除操作的各种情形   二叉排序树的删除操作算法描述如下。 int BSTDelete(BiTree *T,DataType x) /*在二叉排序树T中存在值为x的数据元素时,删除该数据元素结点,并返回1,否则返回0*/ { if(!*T) /*如果不存在值为x的数据元素,则返回0*/ return 0; else { if(x.key==(*T)->data.key) /*如果找到值为x的数据元素,则删除该结点*/ DeleteNode(T); else if((*T)->data.key>x.key)/*如果当前元素值大于x的值,则在该结点的左子树中查找并删除之*/ BSTDelete(&(*T)->lchild,x); else /*如果当前元素值小于x的值,则在该结点的右子树中查找并删除之*/ BSTDelete(&(*T)->rchild,x); return 1; } } void DeleteNode(BiTree *s) /*从二叉排序树中删除结点s,并使该二叉排序树性质不变*/ { BiTree q,x,y; if(!(*s)->rchild) /*若s的右子树为空,则使s的左子树成为被删结点双亲结点的右子树*/ { q=*s; *s=(*s)->lchild; free(q); } else if(!(*s)->lchild) /*若s的左子树为空,使s的右子树成为被删结点双亲结点的右子树*/ { q=*s; *s=(*s)->rchild; free(q); } else /*若s的左、右子树都存在,则使s的直接前驱结点代替s,并使其直接前驱结点的左子树成为其双亲结点的右子树结点*/ { x=*s; y=(*s)->lchild; while(y->rchild) /*查找s的直接前驱结点,y为s的直接前驱结点,x为y的双亲结点*/ { x=y; y=y->rchild; } (*s)->data=y->data; /*结点s被y取代*/ if(x!=*s) /*如果结点s的左孩子结点存在右子树*/ x->rchild=y->lchild; /*使y的左子树成为x的右子树*/ else /*如果结点s的左孩子结点不存在右子树*/ x->lchild=y->lchild; /*使y的左子树成为x的左子树*/ free(y); } }   在算法的实现过程中,通过调用Delete(T)来完成删除当前结点的操作,而函数BSTDelete (&(*T)->lchild,x)和BSTDelete(&(*T)->rchild,x)则是实现在删除结点后,利用参数T->lchild和T->rchild完成连接左子树和右子树,使二叉排序树性质保持不变。 8.3.2 平衡二叉树   二叉排序树查找在最坏的情况下,二叉排序树的深度为n,其平均查找长度为n。因此,为了减小二叉排序树的查找次数,需要进行平衡化处理,平衡化处理得到的二叉树称为平衡二叉树。   1. 平衡二叉树的定义   平衡二叉树或者是一棵空二叉树,或者是具有以下性质的二叉树:平衡二叉树的左子树和右子树的深度之差的绝对值小于或等于1,且左子树和右子树也是平衡二叉树。平衡二叉树也称为AVL树。   如果将二叉树中结点的平衡因子定义为结点的左子树与右子树之差,则平衡二叉树中每个结点的平衡因子的值只有三种可能:-1、0和1。例如,如图8-9所示即为平衡二叉树,结点的右边表示平衡因子,因为该二叉树既是二叉排序树又是平衡树,因此,该二叉树称为平衡二叉排序树。如果在二叉树中有一个结点的平衡因子的绝对值大于1,则该二叉树是不平衡的。例如,如图8-10所示为不平衡的二叉树。 图8-9 平衡二叉树 图8-10 不平衡二叉树   如果二叉排序树是平衡二叉树,则其平均查找长度与log2n是同数量级的,就可以尽量减少与关键字比较的次数。   2. 二叉排序树的平衡处理   在二叉排序树中插入一个新结点后,如何保证该二叉树是平衡二叉排序树呢?假设有一个关键字序列{5,34,45,76,65},依照此关键字序列建立二叉排序树,且使该二叉排序树是平衡二叉排序树。构造平衡二叉排序树的过程如图8-11所示。   初始时,二叉树是空树,因此是平衡二叉树。在空二叉树中插入结点5,该二叉树依然是平衡的。当插入结点34后,该二叉树仍然是平衡的,结点5的平衡因子变为-1。当插入结点45后,结点5的平衡因子变为-2,二叉树不平衡,需要进行调整。只需要以结点34为轴进行逆时针旋转,将二叉树变为以34为根,这时各个结点的平衡因子都为0,二叉树转换为平衡二叉树。 图8-11 平衡二叉树的调整过程   继续插入结点76,二叉树仍然是平衡的。当插入结点65时,该二叉树失去了平衡,如果仍然按照上述方法仅以结点45为轴进行旋转,就会失去二叉排序树的性质。为了保持二叉排序树的性质,又要保证该二叉树是平衡的,需要进行两次调整:先以结点76为轴进行顺时针旋转,然后以结点65为轴进行逆时针旋转。   一般情况下,新插入结点可能使二叉排序树失去平衡,通过使插入点最近的祖先结点恢复平衡,从而使上一层祖先结点恢复平衡。因此,为了使二叉排序树恢复平衡,需要从离插入点最近的结点开始调整。失去平衡的二叉排序树类型及调整方法可以归纳为以下四种情形。   (1)LL型。LL型是指在离插入点最近的失衡结点的左子树的左子树中插入结点,导致二叉排序树失去平衡。如图8-12所示,距离插入点最近的失衡结点为A,插入新结点X后,结点A的平衡因子由1变为2,该二叉排序树失去平衡。为了使二叉树恢复平衡且保持二叉排序树的性质不变,可以使结点A作为结点B的右子树,结点B的右子树作为结点A的左子树。这样就恢复了该二叉排序树的平衡,这相当于以结点B为轴,对结点A进行顺时针旋转。 图8-12 LL型二叉排序树的调整示意图   为平衡二叉排序树的每个结点增加一个域bf,用来表示对应结点的平衡因子,则平衡二叉排序树的类型定义用C语言描述如下。 typedef struct BSTNode /*平衡二叉排序树的类型定义*/ { DataType data; int bf; /*结点的平衡因子*/ struct BSTNode *lchild,*rchild; /*左、右孩子指针*/ }BSTNode,*BSTree;   当二叉树失去平衡时,对LL型二叉排序树的调整用以下语句实现。 BSTree b; b=p->lchild; /*b指向p的左子树的根结点*/ p->lchild=b->rchild; /*将b的右子树作为p的左子树*/ b->rchild=p; p->bf=b->bf=0; /*修改平衡因子*/   (2)LR型。LR型是指在离插入点最近的失衡结点的左子树的右子树中插入结点,导致二叉排序树失去平衡。如图8-13所示,距离插入点最近的失衡结点为A,在C的左子树CL下插入新结点X后,结点A的平衡因子由1变为2,该二叉排序树失去平衡。为了使二叉树恢复平衡且保持二叉排序树的性质不变,可以使结点B作为结点C的左子树,结点C的左子树作为结点B的右子树。将结点C作为新的根结点,结点A作为C的右子树的根结点,结点C的右子树作为A的左子树。这样就恢复了该二叉排序树的平衡。这相当于以结点B为轴,对结点C先做了一次逆时针旋转;然后以结点C为轴对结点A做了一次顺时针旋转。 图8-13 LR型二叉排序树的调整   相应地,对于LR型的二叉排序树的调整可以用以下语句实现。 BSTree b,c; b=p->lchild,c=b->rchild; b->rchild=c->lchild; /*将结点C的左子树作为结点B的右子树*/ p->lchild=c->rchild; /*将结点C的右子树作为结点A的左子树*/ c->lchild=b; /*将B作为结点C的左子树*/ c->rchild=p; /*将A作为结点C的右子树*/ /*修改平衡因子*/ p->bf=-1; b->bf=0; c->bf=0;   (3)RL型。RL型是指在离插入点最近的失衡结点的右子树的左子树中插入结点,导致二叉排序树失去平衡。如图8-14所示,距离插入点最近的失衡结点为A,在C的右子树CR下插入新结点X后,结点A的平衡因子由-1变为-2,该二叉排序树失去平衡。为了使二叉树恢复平衡且保持二叉排序树的性质不变,可以使结点B作为结点C的右子树,结点C的右子树作为结点B的左子树。将结点C作为新的根结点,结点A作为C的右子树的根结点,结点C的左子树作为A的右子树。这样就恢复了该二叉排序树的平衡。这相当于以结点B为轴对结点C先做了一次顺时针旋转;然后以结点C为轴对结点A做了一次逆时针旋转。 图8-14 RL型二叉排序树的调整   相应地,对于RL型的二叉排序树的调整可以用以下语句实现。 BSTree b,c; b=p->rchild,c=b->lchild; b->lchild=c->rchild; /*将结点C的右子树作为结点B的左子树*/ p->rchild=c->lchild; /*将结点C的左子树作为结点A的右子树*/ c->lchild=p; /*将A作为结点C的左子树*/ c->rchild=b; /*将B作为结点C的右子树*/ /*修改平衡因子*/ p->bf=1; b->bf=0; c->bf=0;   (4)RR型。RR型是指在离插入点最近的失衡结点的右子树的右子树中插入结点,导致二叉排序树失去平衡。如图8-15所示,距离插入点最近的失衡结点为A,在结点B的右子树BR下插入新结点X后,结点A的平衡因子由-1变为-2,该二叉排序树失去平衡。为了使二叉树恢复平衡且保持二叉排序树的性质不变,可以使结点A作为B的左子树的根结点,结点B的左子树作为A的右子树。这样就恢复了该二叉排序树的平衡。这相当于以结点B为轴,对结点A做了一次逆时针旋转。 图8-15 RR型二叉排序树的调整   相应地,对于RL型的二叉排序树的调整可以用以下语句实现。 BSTree b,c; b=p->rchild; p->rchild=b->lchild; /*将结点B的左子树作为结点A的右子树*/ b->lchild=p; /*将A作为结点B的左子树*/ /*修改平衡因子*/ p->bf=0; b->bf=0;   综合以上四种情况,在平衡二叉排序树中插入一个新结点e的算法描述如下。   (1)如果平衡二叉排序树是空树,则插入的新结点作为根结点,同时将该树的深度增1。   (2)如果二叉树中已经存在与结点e的关键字相等的结点,则不进行插入。   (3)如果结点e的关键字小于要插入位置的结点的关键字,则将e插入到该结点的左子树位置,并将该结点的左子树高度增1,同时修改该结点的平衡因子;如果该结点的平衡因子绝对值大于1,则需要进行平衡化处理。   (4)如果结点e的关键字大于要插入位置的结点的关键字,则将e插入到该结点的右子树位置,并将该结点的右子树高度增1,同时修改该结点的平衡因子;如果该结点的平衡因子绝对值大于1,则需要进行平衡化处理。 8.4 B-树与B+树   B-树与B+树是特殊的动态查找树。 8.4.1 B-树   B-树与二叉排序树类似,它是一种特殊的动态查找树,它是一种m叉排序树。   1. B-树的定义   B-树是一种平衡的排序树,也称为m路(阶)查找树。一棵m阶B-树或者是一棵空树,或者是满足以下性质的m叉树。   (1)树中的任何一个结点最多有m棵子树。   (2)如果根结点或者是叶子结点,或者至少有两棵子树。   (3)除了根结点之外,所有的非叶子结点至少应有棵子树。   (4)所有的叶子结点处于同一层次上,且不包括任何关键字信息。   (5)所有的非叶子结点的结构如下:   其中,n表示对应结点中的关键字的个数,Pi表示指向子树的根结点的指针,并且Pi指向的子树中每一个结点的关键字都小于Ki+1(i=0,1,…,n-1)。   例如,一棵深度为4的4阶B-树如图8-16所示。 图8-16 一棵深度为4的4阶B-树   在B-树中,查找某个关键字的过程与二叉排序树的查找过程类似。例如,要查找关键字为41的元素,首先从根结点开始,将41与A结点的关键字29比较,因为41>29,所以应该在P1所指向的子树内查找。指针P1指向结点C,因此需要将41与结点C中的关键字逐个比较,因为有41<42,所以应该在P0指向的子树内查找。指针P0指向结点F,因此需要将41与结点F中的关键字逐个进行比较,在结点F中存在关键字为41的元素,因此查找成功。   2. B-树的查找   在B-树中的查找过程其实就是对二叉排序树中查找的扩展,与二叉排序树不同的是,在B-树中,每个结点有不止一个子树。在B-树中进行查找需要顺着指针Pi找到对应的结点,然后在结点中顺序查找。   B-树的类型定义用C语言描述如下。 #define m 4 /*B-树的阶数*/ typedef struct BTNode /*B-树类型定义*/ { int keynum; /*每个结点中的关键字个数*/ struct BTNode *parent; /*指向双亲结点*/ KeyType data[m+1]; /*结点中关键字信息*/ struct BTNode *ptr[m+1]; /*指针向量*/ }BTNode,*BTree;   B-树的查找算法用C语言描述如下。 typedef struct /*返回结果类型定义*/ { BTNode *pt; /*指向找到的结点*/ int pos; /*关键字在结点中的序号*/ int flag; /*查找成功与否标志*/ }result;   3. B-树的插入操作   B-树的插入操作与二叉排序树的插入操作类似,都是使插入后,结点左边子树中每一个结点关键字小于根结点的关键字,右边子树的结点关键字大于根结点的关键字。而与二叉排序树不同的是,插入的关键字不是树的叶子结点,而是树中处于最低层的非叶子结点,同时该结点的关键字个数最少应该是-1,最大应该是m-1,否则需要对该结点进行分裂。   例如,图8-17为一棵3阶的B-树(省略了叶子结点),在该B-树中依次插入关键字35、25、78和43。 图8-17 一棵3阶的B-树   插入关键字35:首先需要从根结点开始,确定关键字35应插入的位置应该是结点E。因为插入后结点E中的关键字个数大于1(-1)且小于2(m-1),所以插入成功。插入后B-树如图8-18所示。   插入关键字25:从根结点开始确定关键字25应插入的位置为结点D。因为插入后结点D中的关键字个数大于2,需要将结点D分裂为两个结点,关键字24被插入到双亲结点B中,关键字12被保留在结点D中,关键字25被插入到新生成的结点D’中,并使关键字24的右指针指向结点D’。插入关键字25的过程如图8-19所示。 图8-18 插入关键字35的过程 图8-19 插入关键字25的过程   插入关键字78:从根结点开始确定关键字78应插入的位置为结点G。因为插入后结点G中的关键字个数大于2,所以需要将结点G分裂为两个结点,其中关键字73被插入到结点C中,关键字69被保留在结点F中,关键字78被插入到新的结点G’中,并使关键字73的右指针指向结点G’。插入关键字78的过程及结点C分裂过程如图8-20所示。 图8-20 插入关键字78及结点C的分裂过程   此时,结点C的关键字个数大于2,因此,需要将结点C进行分裂为两个结点。将中间的关键字73插入到双亲结点A中,关键字83保留在C中,关键字67被插入到新结点C’中,并使关键字56的右指针指向结点C’,关键字73的右指针指向结点C。结点C的分裂过程如图8-21所示。 图8-21 结点C分裂为结点C和C’的过程   插入关键字43:从根结点开始确定关键字43应插入的位置为结点E。如图8-22所示。因为插入后结点E中的关键字个数大于2,所以需要将结点E分裂为两个结点,其中中间关键字38被插入到双亲结点B中,关键字43被保留在结点E中,关键字35被插入到新的结点E’中,并使关键字32的右指针指向结点E’,关键字38的右指针指向结点E。结点E被分裂的过程如图8-23所示。 图8-22 插入关键字43后 图8-23 结点E被分裂过程   此时,结点B中的关键字个数大于2,需要进一步分解结点B,其中关键字32被插入到双亲结点A中,关键字24被保留在结点B中,关键字38被插入到新结点B’中,关键字24的左、右指针分别指向结点D和D’,关键字38的左、右指针分别指向结点E和E’。结点B被分裂的过程如图8-24所示。    图8-24 结点B被分裂的过程   关键字32被插入到结点A中后,结点A的关键字个数大于2,因此,需要对结点A分裂为两个结点,因为结点A是根结点,所以需要生成一个新结点R作为根结点,将结点A中的中间的关键字56插入到R中,关键字32被保留在结点A中,关键字73被插入到新结点A’中,关键字56的左、右指针分别指向结点A和A’。关键字32的左、右指针分别指向结点B和B’, 关键字73的左、右指针分别指向结点C和C’。结点A被分裂的过程如图8-25所示。 图8-25 结点A被分裂的过程   4. B-树的删除操作   对于要在B-树中删除一个关键字的操作,首先利用B-树的查找算法,找到关键字所在的结点,然后将该关键字从该结点删除。如果删除该关键字后,该结点中的关键字个数仍然大于或等于-1,则删除完成;否则,需要进行合并结点。   B-树的删除操作有以下三种可能。   (1)要删除的关键字所在结点的关键字个数大于或等于,则只需要将关键字Ki和对应的指针Pi从该结点中删除即可。因为删除该关键字后,该结点的关键字个数仍然不小于-1。例如,图8-26显示了从结点E中删除关键字35的情形。 图8-26 删除关键字35的过程   (2)要删除的关键字所在结点的关键字个数等于-1,而与该结点相邻的兄弟结点(左兄弟或右兄弟)中的关键字个数大于-1,则删除关键字后,需要将其兄弟结点中最小(或最大)的关键字移动到双亲结点中,将小于(或大于)并且离移动的关键字最近的关键字移动到被删关键字所在的结点中。例如,将关键字89删除后,需要将关键字73向上移动到双亲结点C中,并将关键字83下移到结点H中,得到如图8-27所示的B-树。   (3)要删除的关键字所在结点的关键字个数等于-1,而与该结点相邻的兄弟结点(左兄弟或右兄弟)中的关键字个数也等于-1,则删除关键字(假设该关键字由指针Pi指示)后,需要将剩余关键字与其双亲结点中的关键字Ki与兄弟结点(左兄弟或右兄弟)中的关键字进行合并,同时将与其双亲结点的指针Pi一块合并。例如,将关键字83删除后,需要将关键字83的左兄弟结点的关键字69与其双亲结点中的关键字73合并到一起,得到如图8-28所示的B-树。 图8-27 删除关键字89的过程 图8-28 删除关键字83的过程 8.4.2 B+树   B+树是B-树的一种变型。它与B-树的主要区别在于:   (1)如果一个结点有n棵子树,则该结点也必有n个关键字,即关键字个数与结点的子树个数相等。   (2)所有的非叶子结点包含子树的根结点的最大或者最小的关键字信息,因此所有的非叶子结点可以作为索引。   (3)叶子结点包含所有关键字信息和关键字记录的指针,所有叶子结点中的关键字按照从小到大的顺序依次通过指针链接。   由此可以看出,B+树的存储方式类似于索引顺序表的存储结构,所有的记录存储在叶子结点中,非叶子结点作为一个索引表。图8-29为一棵3阶的B+树。 图8-29 一棵3阶的B+树   在图8-29中,B+树有两个指针:一个指向根结点的指针,一个指向叶子结点的指针。因此,对B+树的查找可以从根结点开始也可以从指向叶子结点的指针开始。从根结点开始的查找是一种索引方式的查找,而从叶子结点开始的查找是顺序查找,类似于链表的访问。   从根结点对B+树进行查找给定的关键字,是从根结点开始经过非叶子结点到叶子结点。查找每一个结点,无论查找是否成功,都是走了一条从根结点到叶子结点的路径。在B+树上插入一个关键字和删除一个关键字都是在叶子结点中进行,在插入关键字时,要保证每个结点中的关键字个数不能大于m,否则需要对该结点进行分裂。在删除关键字时,要保证每个结点中的关键字个数不能小于,否则需要与兄弟结点合并。 8.5 哈希表   前面介绍过的有关查找的算法都经过了一系列比较过程,查找算法效率的高低取决于比较的次数。如果不经过比较就能确定要查找元素的位置,那么查找效率就会大大提高,这就需要建立一种数据元素的关键字与数据元素存放地址之间的对应关系,通过数据元素的关键字直接确定其存放的位置。 8.5.1 什么是哈希表   如何在查找元素的过程中不与给定的关键字进行比较,就能确定所查找元素的存放位置,这就需要在元素的关键字与元素的存储位置之间建立起一种对应关系,使得元素的关键字与唯一的存储位置对应。有了这种对应关系,在查找某个元素时,只需要利用这种确定的对应关系,由给定的关键字就可以直接找到该元素。key表示元素的关键字,f表示对应关系,则f(key)表示元素的存储地址,这种对应关系f称为哈希函数,利用哈希函数可以建立哈希表。哈希函数也称为散列函数。   例如,一个班级有30名学生,将这些学生按各自姓氏的拼音排序,姓氏首字母相同的学生放在一起。根据学生姓氏的拼音首字母建立的哈希表如表8-2所示。 表8-2 哈希表示例 序 号 姓 氏 拼 音 学 生 姓 名 1 A 安紫衣 2 B 白小翼 3 C 陈立本、陈冲 4 D 邓华 5 E 6 F 冯峰 7 G 耿敏、弓宁 8 H 何山、郝建华 … … …      例如,在查找姓名为“冯峰”的学生时,就可以从序号为6的一行直接找到该学生。这种方法要比在一堆杂乱无章的姓名中查找要方便得多,但是,如果要查找姓名为“郝建华”的学生,拼音首字母为“H”的学生有多个,这就需要在该行中顺序查找。像这种不同的关键字key出现在同一地址上,即有key1≠key2,f (key1)=f (key2)的情况称为哈希冲突。   在一般情况下,元素的关键字越多,越容易发生冲突,在设计哈希表时,应尽可能避免冲突的发生。只有少发生冲突,才能尽可能快地利用关键字找到对应的元素。因此,为了更加高效地查找集合中的某个元素,不仅需要建立一个哈希函数,还需要一个解决哈希函数冲突的方法。所谓哈希表,就是根据哈希函数和解决冲突的方法将元素的关键字映射在一个有限的且连续的地址,并将元素存储在该地址上的表中。 8.5.2 哈希函数的构造方法   构造哈希函数的目的主要是使哈希地址尽可能地均匀分布以减少或避免产生冲突,使计算方法尽可能简便以提高运算效率。哈希函数的构造方法主要有以下几种。   1. 直接定址法   直接定址法就是直接取关键字的线性函数值作为哈希函数的地址。直接定址法可以表示如下。   h(key)=x×key+y   其中,x和y是常数。直接定址法的计算比较简单且不会发生冲突。但是,由于这种方法会使产生的哈希函数地址比较分散,造成内存的大量浪费。例如,如果任给一组关键字{230,125,456,46,320,760,610,109},令x=1,y=0,则需要714(最大的关键字减去最小的关键字即760-46)个内存单元存储这8个关键字。   2. 平方取中法   平方取中法就是将关键字的平方得到的值的其中几位作为哈希函数的地址。由于一个数经过平方后,每一位数字都与该数的每一位相关,因此,采用平方取中法得到的哈希地址与关键字的每一位都相关,达到了哈希地址有了较好的分散性,从而避免冲突的发生。   例如,如果给定关键字key=3456,则关键字取平方后即key2=11 943 936,取中间的四位得到哈希函数的地址,即h(key)=9439。在得到关键字的平方后,具体取哪几位作为哈希函数的地址根据具体情况决定。   3. 折叠法   折叠法是将关键字平均分割为若干等份,最后一个部分如果不够可以空缺,然后将这几个等份叠加求和作为哈希地址。这种方法主要用在关键字的位数特别多且每一个关键字的位数分布大体相当的情况。例如,给定一个关键字23478245983,可以按照3位将该关键字分割为几个部分,其折叠计算方法如下:   然后去掉进位,将558作为关键字key的哈希地址。   4. 除留余数法   除留余数法主要是通过对关键字取余,将得到的余数作为哈希地址。其主要方法为:设哈希表长为m,p为小于或等于m的数,则哈希函数为h(key)=key%p。除留余数法是一种常用的求哈希函数的方法。   例如,给定一组关键字{75,150,123,183,230,56,37,91},设哈希表长m为14,取p=13,则这组关键字的哈希地址存储情况如图8-30所示。 图8-30 哈希表   在求解关键字的哈希地址时,一般情况下,p取值为小于或等于表长的最大质数。   由于一个数经过平方后,每一位数字都与该数的每一位相关,因此,采用平方取中法得到的哈希地址与关键字的每一位都相关,使哈希地址有了较好的分散性,从而避免冲突的发生。   例如,如果给定关键字key=3456,则关键字取平方后即key2=11 943 936,取中间的四位得到哈希函数的地址,即h(key)=9439。在得到关键字的平方后,具体取哪几位作为哈希函数的地址根据具体情况决定。 8.5.3 处理冲突的方法   在构造哈希函数的过程中,不可避免地会出现冲突的情况。所谓处理冲突就是在有冲突发生时,为产生冲突的关键字找到另一个地址存放该关键字。在解决冲突的过程中,可能会得到一系列哈希地址hi(i=1,2,…,n),也就是发生第一次冲突时,经过处理后得到第一个新地址记作h1,如果h1仍然会冲突,则处理后得到第二个地址h2,…,以此类推,直到hn不产生冲突,将hn作为关键字的存储地址。   处理冲突的方法比较常用的主要有开放定址法、再哈希法和链地址法。   1. 开放定址法   开放定址法是解决冲突比较常用的方法。开放定址法就是利用哈希表中的空地址存储产生冲突的关键字。当冲突发生时,按照以下公式处理冲突:   hi=(h(key)+di)%m , i=1,2,…,m-1   其中,h(key)为哈希函数,m为哈希表长,di为地址增量。地址增量di可以通过以下三种方法获得。   (1)线性探测再散列:在冲突发生时,地址增量di依次取1,2,…,m-1自然数列,即di=1,2,…,m-1。   (2)二次探测再散列:在冲突发生时,地址增量di依次取自然数的平方,即di=12,-12, 22,-22,…,k2,-k2。   (3)伪随机数再散列:在冲突发生时,地址增量di依次取随机数序列。   例如,在长度为14的哈希表中,将关键字183,123,230,91存放在哈希表中的情况如图8-31所示。 图8-31 哈希表冲突发生前   当要插入关键字149时,哈希函数h(149)=149%13=6,而单元6已经存在关键字,产生冲突,利用线性探测再散列法解决冲突,即h1=(6+1)%14=7,将149存储在单元7中,如图8-32所示。 图8-32 插入关键字149后   当要插入关键字227时,哈希函数h(227)=227%13=6,而单元6已经存在关键字,产生冲突,利用线性探测再散列法解决冲突,即h1=(6+1)%14=7,仍然冲突,继续利用线性探测法,即h2=(6+2)%14=8,单元8空闲,因此将227存储在单元8中,如图8-33所示。 图8-33 插入关键字227后   当然,在冲突发生时,也可以利用二次探测再散列解决冲突。在图8-33中,如果要插入关键字227,因为产生冲突,利用二次探测再散列法解决冲突,即h1=(6+12)%14=7,再次产生冲突时,有h2=(6-12)%14=5,将227存储在单元5中,如图8-34所示。 图8-34 利用二次探测再散列解决冲突   2. 再哈希法   再哈希法就是在冲突发生时,利用另外一个哈希函数再次求哈希函数的地址,直到冲突不再发生为止,即   hi=rehash(key),i=1,2,…,n   其中,rehash表示不同的哈希函数。这种再哈希法一般不容易再次发生冲突,但是需要事先构造多个哈希函数,这是一件不太容易也不现实的事情。   3. 链地址法   链地址法就是将具有相同散列地址的关键字用一个线性链表存储起来。每个线性链表设置一个头指针指向该链表。链地址法的存储表示类似于图的邻接表表示。在每一个链表中,所有的元素都是按照关键字有序排列。链地址法的主要优点是在哈希表中增加元素和删除元素方便。   例如,一组关键字序列{23,35,12,56,123,39,342,90,78,110},按照哈希函数h(key)=key%13和链地址法处理冲突,其哈希表如图8-35所示。 图8-35 链地址法处理冲突的哈希表 8.5.4 哈希表应用举例   【例8-1】给定一组元素的关键字hash[]={23,35,12,56,123,39,342,90},假设哈希表的长度m为11,p为11,利用除留余数法和线性探测再散列法将元素存储在哈希表中,并查找给定的关键字,求解平均查找长度,最后编程实现。   【分析】主要考察哈希函数的构造方法、冲突解决的办法。算法实现主要包括几个部分:构建哈希表、在哈希表中查找给定的关键字、输出哈希表及求平均查找长度。关键字的个数是8个,利用除留余数法求哈希函数即h(key)=key%p,利用线性探测再散列解决冲突即hi=(h(key)+di),哈希表如图8-36所示。 图8-36 哈希表   哈希表的查找过程就是利用哈希函数和处理冲突创建哈希表的过程。例如,要查找key=12,由哈希函数h(12)=12%11=1,此时与第1号单元中的关键字23比较,因为23≠12,又h1=(1+1)%11=2,所以将第2号单元的关键字35与12比较,因为35≠12,又h2=(1+2)%11=3,所以将第3号单元中关键字12与key比较,因为key=12,所以查找成功,返回序号3。   尽管使用哈希函数可以利用关键字直接找到对应的元素,但是不可避免地仍然会有冲突产生,在查找的过程中,比较仍会是不可避免的,因此,仍然以平均查找长度衡量哈希表查找的效率高低。假设每个关键字的查找概率都是相等的,则在图8-36的哈希表中,查找某个元素成功时的平均查找长度ASL成功=×(1×3+3+4×2+7×2)=3.5。   程序实现可分为两个部分:哈希表的操作和测试代码部分。   1. 哈希表的操作   这部分主要包括哈希表的创建、查找与求哈希表平均查找长度。其实现代码如下。 void CreateHashTable(HashTable *H,int m,int p,int hash[],int n) /*构造一个空的哈希表,并处理冲突*/ { int i,sum,addr,di,k=1; (*H).data=(DataType*)malloc(m*sizeof(DataType)); /*为哈希表分配存储空间*/ if(!(*H).data) exit(-1); for(i=0;i #include #include typedef int KeyType; typedef struct /*元素类型定义*/ { KeyType key; /*关键字*/ int hi; /*冲突次数*/ }DataType; typedef struct /*哈希表类型定义*/ { DataType *data; int tableSize; /*哈希表的长度*/ int curSize; /*表中关键字个数*/ }HashTable; void CreateHashTable(HashTable *H,int m,int p,int hash[],int n); int SearchHash(HashTable H,KeyType k); void DisplayHash(HashTable H,int m); void HashASL(HashTable H,int m); void DisplayHash(HashTable H,int m) /*输出哈希表*/ { int i; printf("哈希表地址:"); for(i=0;i12,所以需要先将35向右移动一个位置,然后将12插入到有序集合中的第1个位置,如图9-2所示。其中,阴影部分表示无序集,白色部分表示有序集。 图9-2 第1趟排序过程   第2趟排序:将无序集的第2个元素5依次与有序集中的元素从右到左比较,即先与35比较,因为5<35,所以先将35向右移动一个位置,然后将5与第1个元素12比较,因为5<12,所以将12向右移动一个位置,将5放在第1个位置,如图9-3所示。 图9-3 第2趟排序过程   第3趟排序:将无序集中的元素21与有序集中的元素从右到左依次比较,先与35比较。因为21<35,所以需将35向右移动一个位置并与前一个元素12比较。由于21>12,故需将21放置在12与35之间,即插入到第3个位置,如图9-4所示。 图9-4 第3趟排序过程   经过以上排序之后,有序集有4个元素,无序集为空集。此时直接插入排序完毕,整个序列变成一个有序序列。   相应地,直接插入排序算法描述如下。 void InsertSort(SqList *L) /*直接插入排序*/ { int i,j; DataType t; for(i=1;ilength;i++) /*前i个元素已经有序,从第i+1个元素开始与前i个有序的关键字比较*/ { t=L->data[i+1]; /*取出第i+1个元素,即待排序的元素*/ j=i; while(j>0&&t.keydata[j].key) /*寻找当前元素的合适位置*/ { L->data[j+1]=L->data[j]; j--; } L->data[j+1]=t; /*将当前元素插入合适的位置*/ } }   从上面的算法可以看出,直接插入排序算法简单且容易实现。直接插入排序算法的时间复杂度在最好的情况下是所有的元素的关键字都已经有序,此时外层的for循环的循环次数是n-1,而内层的while循环的语句执行次数为0,因此直接插入排序算法在最好的情况下的时间复杂度为O(n)。在最坏的情况下,即所有元素的关键字都是按照逆序排列,则内层while循环的比较次数均为i+1,则整个比较次数为=,移动次数为=,即在最坏情况下时间复杂度为O(n2)。如果元素的关键字是随机排列的,其比较次数和移动次数约为n2/4,此时直接插入排序的时间复杂度为O(n2)。   直接插入排序算法只利用了一个临时变量,因此其空间复杂度为O(1)。 9.2.2 折半插入排序   折半插入排序算法是直接插入排序的改进。它的主要改进在于在已经有序的集合中使用折半查找法确定待排序元素的插入位置,找到要插入的位置后,将待排序元素插入相应的位置。   假设有7个待排序元素:75,61,82,36,99,26,41。使用折半插入排序算法对该元素序列进行第一趟排序过程如图9-5所示。 图9-5 折半插入排序第1趟排序过程   其中,i=1表示第1趟排序,待排序元素为a[1],t存放的是待排序元素。当low>high时,low指向元素要插入的位置。依次将low~i-1的元素依次向后移动一个位置,然后将t的值插入到a[low]中。   第2趟折半插入排序过程如图9-6所示。 图9-6 第2趟折半插入排序过程   从以上两趟排序过程可以看出,折半插入排序与直接插入排序的区别仅在于查找插入的位置的方法不同。一般情况下,折半查找的效率要高于顺序查找的效率。   通过对直接插入排序算法简单修改,得到折半插入排序算法,实现代码如下。 void BinInsertSort(SqList *L) /*折半插入排序*/ { int i,j,mid,low,high; DataType t; for(i=1;ilength;i++)/*前i个元素已经有序,从第i+1个元素开始与前i个有序的关键字比较*/ { t=L->data[i+1]; /*取出第i+1个元素,即待排序的元素*/ low=1,high=i; while(low<=high) /*利用折半查找思想寻找当前元素的合适位置*/ { mid=(low+high)/2; if(L->data[mid].key>t.key) high=mid-1; else low=mid+1; } for(j=i;j>=low;j--) /*移动元素,空出要插入的位置*/ L->data[j+1]=L->data[j]; L->data[low]=t; /*将当前元素插入合适的位置*/ } }   从时间上比较,折半插入排序仅减少了关键字的比较次数,而记录的移动次数不变,因此,折半插入排序的时间复杂度为O(n2)。 9.2.3 希尔排序   希尔排序(Shell’s sort)也称为缩小增量排序,也属于插入排序类的算法,但时间效率比前几种排序有较大改进。   从对直接插入排序的分析可知,其算法时间复杂度为O(n2),但是若待排记录序列为“正序”,其时间复杂度为O(n)。由此可设想,若待排序记录序列按关键字基本有序,直接插入排序的效率就可大大提高。从另一个方面来看,由于直接插入排序算法简单,则在n值很小时效率也比较高。希尔排序正是综合考虑这两点对直接插入排序进行改进得到的一种插入排序方法。   希尔排序算法的基本思想是先将整个待排序记录分割成若干子序列,利用直接插入排序对子序列进行排序,待整个序列中的记录基本有序时,再对全部记录进行一次直接插入排序。   假设待排序的元素有n个,对应的关键字分别是a1,a2,…,an,设距离(增量)为c1=4的元素为同一个子序列,则元素的关键字a1,a5,…,ai,ai+5,…,an-5为一个子序列,同理,关键字a2,a6,…,ai+1,ai+6,…,an-4为一个子序列。然后分别对同一个子序列的关键字利用直接插入排序进行排序。之后,缩小增量令c2=2,分别对同一个子序列的关键字进行插入排序。以此类推,最后令增量为1,这时只有一个子序列,对整个元素进行排序,完成希尔排序的具体过程。   设待排序元素为48,26,66,57,32,85,55,19,使用希尔排序算法对该元素序列的排序过程如图9-7所示。 图9-7 希尔排序过程   增量依次为4、2、1,当增量为4时,第1个元素与第5个元素为一组,第2个元素与第6个元素为一组,第3个元素与第7个元素为一组,第4个元素与第8个元素为一组,本组内的元素进行直接插入排序,即完成第1趟希尔排序。当增量为2时,第1、3、5、7个元素构成一组,第2、4、6、8个元素构成一组,各组中的元素进行直接插入排序,即完成第2趟直接插入排序。当增量为1时,将所有的元素进行直接插入排序,此时所有的元素都按照从小到大排列,希尔排序算法结束。   相应地,希尔排序的算法可描述如下。 void ShellInsert(SqList *L,int c) /*对顺序表L进行一趟希尔排序,c是增量*/ { int i,j; DataType t; for(i=c+1;i<=L->length;i++) /*将距离为c的元素作为一个子序列进行排序*/ { if(L->data[i].keydata[i-c].key) /*如果后者小于前者,则需要移动元素*/ { t=L->data[i]; for(j=i-c;j>0&&t.keydata[j].key;j=j-c) L->data[j+c]=L->data[j]; L->data[j+c]=t; /*依次将元素插入正确的位置*/ } } } void ShellInsertSort(SqList *L,int delta[],int m) /*希尔排序,每次调用算法ShellInsert,delta是存放增量的数组*/ { int i; for(i=0;inext=NULL。指针p指向待排序的链表,若有序序列为空,将p指向的第一个结点插入空链表L中。然后将有序链表即L指向的链表的每一个结点与p指向的结点比较,并将结点*p插入L指向的链表的恰当位置。重复执行上述操作,直到待排序链表为空。此时,L就是一个有序链表。   插入排序程序的实现如下。 /*头文件*/ #include #include #include typedef int DataType; /*元素类型定义为整型*/ typedef struct Node /*单链表类型定义*/ { DataType data; struct Node *next; }ListNode,*LinkList; #include"LinkList.h" void InsertSort(LinkList L); void CreateList(LinkList L,DataType a[],int n); void CreateList(LinkList L,DataType a[],int n) /*创建单链表*/ { int i; for(i=1;i<=n;i++) InsertList(L,i,a[i-1]); } void main() { LinkList L,p; int n=8; DataType a[]={76,55,10,21,65,90,5,38}; InitList(&L); CreateList(L,a,n); printf("排序前的元素序列:\n"); for(p=L->next;p!=NULL;p=p->next) printf("%4d ",p->data); printf("\n"); InsertSort(L); printf("排序后的元素序列:\n"); for(p=L->next;p!=NULL;p=p->next) printf("%4d ",p->data); printf("\n"); } void InsertSort(LinkList L) /*链式存储结构下的插入排序*/ { ListNode *p=L->next,*pre,*q; L->next=NULL; /*初始时,已排序链表为空*/ while(p!=NULL) /*p是指向待排序的结点*/ { if(L->next==NULL)/*如果*p是第一个结点,则插入L,并令已排序的最后一个结点的指针域为空*/ { L->next=p; p=p->next; L->next->next=NULL; } else /*p指向待排序的结点,在L指向的已经排好序的链表中查找插入位置*/ { pre=L; q=L->next; while(q!=NULL&&q->datadata) /*在q指向的有序表中寻找插入位置*/ { pre=q; q=q->next; } q=p->next; /*q指向p的下一个结点,保存待排序的指针位置*/ p->next=pre->next; /*将结点*p插入结点*pre的后面*/ pre->next=p; p=q; /*p指向下一个待排序的结点*/ } } }   程序运行结果如图9-8所示。 图9-8 采用链式存储结构的插入排序程序运行结果 9.3 交换排序   交换排序的基本思想是通过依次交换逆序的元素实现排序。 9.3.1 冒泡排序   冒泡排序(bubble sort)是一种简单的交换类排序算法,它是通过交换相邻的两个数据元素,逐步将待排序序列变成有序序列。它的基本算法思想如下。   假设待排序元素有n个,从第1个元素开始,依次交换相邻的两个逆序元素,直到最后一个元素为止。当第1趟排序结束,就会将最大的元素移动到序列的末尾。然后按照以上方法进行第2趟排序,次大的元素将会被移动到序列的倒数第2个位置。以此类推,经过n-1趟排序后,整个元素序列就成了一个有序的序列。每趟排序过程中,值小的元素向前移动,值大的元素向后移动,就像气泡一样向上升,因此将这种排序方法称为冒泡排序。   例如,一组元素序列为56,22,67,32,59,12,89,26,对该元素序列进行冒泡排序,第1趟排序过程如图9-9所示。 图9-9 第1趟排序过程   经过第1趟冒泡排序后,值最大的元素89跑到了序列的最后。按以上方法,将第一个元素到倒数第一个元素重复以上过程,倒数第二大的元素将排在倒数第二个位置。以此类推,直到所有的元素均有序,冒泡排序结束。   对元素序列56,22,67,32,59,12,89,26的排序全过程如图9-10所示。设待排序元素为56,72,44, 31,99,21,69,80,使用冒泡排序对该元素序列排序的过程如图9-11所示。   在冒泡排序中,如果待排序元素的个数为n,则需要n-1趟排序;对于第i趟排序,需要比较的次数为i-1。    图9-10 冒泡排序的全过程 图9-11 冒泡排序全过程   冒泡排序的算法描述如下。 void BubbleSort(SqList *L,int n) /*冒泡排序*/ { int i,j,flag; DataType t; for(i=1;i<=n-1&&flag;i++) /*需要进行n-1趟排序*/ { flag=0; for(j=1;j<=n-i;j++) /*每一趟排序需要比较n-i次*/ if(L->data[j].key>L->data[j+1].key) { t=L->data[j]; L->data[j]=L->data[j+1]; L->data[j+1]=t; flag=1; } } }   容易看出,若初始序列为正序,则只需要进行一趟排序,在排序过程中进行n-1次关键字的比较,且不需要移动记录;反之,若初始序列为逆序,则需要进行n-1趟排序,需进行=次比较,并进行等数量级的移动操作。因此,总的时间复杂度为O(n2)。冒泡排序是一种稳定的排序算法。 9.3.2 快速排序   快速排序(quick sort)算法是冒泡排序的一种改进,与冒泡排序类似,快速排序也是通过逐渐消除待排序元素序列中逆序元素来实现排序的;不同的是,快速排序一趟排序仅需要交换一次元素就消除了多个逆序元素,这些逆序元素可能是不相邻的。   快速排序的算法思想是从待排序记录序列中选取一个记录(通常是第一个记录)作为枢轴,其关键字设为key,然后将其余关键字小于key的记录移至前面,而将关键字大于key的记录移至后面,结果将待排序记录序列分为两个子表,最后将关键字key的记录插入其分界线的位置。这个过程称为一趟快速排序。通过这一趟划分后,就可以关键字为key的记录为界将待排序序列分为两个子表,前面的子表所有记录的关键字均不大于key,后面子表的所有记录的关键字均不小于key。继续对分割后的子表进行上述划分,直至所有子表的表长不超过1为止,此时待排序的记录就成了一个有序序列。   设待排序序列存放在数组a[n]中,n为元素个数,设置两个指针i和j,初值分别为1和n,令a[1]作为枢轴元素赋给pivot,a[1]相当于空单元,然后执行以下操作。   (1)j从右往左扫描,若a[j].keypivot.key,将a[i]移至a[j]中,并执行一次j--操作。   (3)重复执行(1)和(2),直到出现i≥j,则将元素pivot移动到a[i]中。此时整个元素序列在位置i被划分成两个部分,前一部分的元素关键字都小于或等于a[i].key,后一部分元素的关键字都大于或等于a[i].key。至此,即完成了一趟快速排序。   按照以上方法对a[i]左边的子表和a[i]右边的子表也可继续进行以上划分操作。   例如,一组元素序列为37,19,43,22,22,89,26,92,根据快速排序算法思想,第一次划分过程如图9-12所示。 图9-12 第1趟快速排序过程   从图9-12容易看出,当一趟快速排序完毕之后,整个元素序列被枢轴的关键字37划分为两个子表,左边子表的元素值都小于37,右边子表的元素值大于或等于37。使用快速排序对前面的元素序列进行排序的整个过程如图9-13所示。 图9-13 快速排序过程   通过上面的排序过程不难看出,快速排序算法可以通过递归调用实现,排序的过程其实就是不断地对元素序列进行划分,直到每一个部分不能划分时即完成快速排序。   进行一趟快速排序,即将元素序列进行一次划分,算法描述如下。 int Partition(SqList *L,int low,int high) /*对顺序表L.r[low..high]的元素进行一趟排序,使枢轴前面的元素关键字小于枢轴元素的关键字,枢轴后面的 元素关键字大于或等于枢轴元素的关键字,并返回枢轴位置*/ { DataType t; KeyType pivotkey; pivotkey=(*L).data[low].key; /*将表的第一个元素作为枢轴元素*/ t=(*L).data[low]; while(low=pivotkey) /*从表的末端向前扫描*/ high--; if(low #include #define MaxSize 50 typedef int KeyType; typedef struct /*数据元素类型定义*/ { KeyType key;/*关键字*/ }DataType; typedef struct /*顺序表类型定义*/ { DataType data[MaxSize]; int length; }SqList; void InitSeqList(SqList *L,DataType a[],int n); void DispList(SqList L); void DispList2(SqList L,int count); void DispList3(SqList L,int pivot,int count); void HeapSort(SqList *H); void BubbleSort(SqList *L,int n); void QuickSort(SqList *L); int Partition(SqList *L,int low,int high); void DispList(SqList L) /*输出表中的元素*/ { int i; for(i=1;i<=L.length;i++) printf("%4d",L.data[i].key); printf("\n"); } void DispList2(SqList L,int count) /*输出表中的元素(用于冒泡排序算法调用)*/ { int i; printf("第%d趟排序结果:",count); for(i=1;i<=L.length;i++) printf("%4d",L.data[i].key); printf("\n"); } void DispList3(SqList L,int pivot,int count) /*输出每一趟排序后的元素序列(用于快速排序算法调用)*/ { int i; printf("第%d趟排序结果:[",count); for(i=1;idata[i]=a[i-1]; } L->length=n; } void main() { DataType a[]={37,19,43,22,22,89,26,92}; SqList L; int n=sizeof(a)/sizeof(a[0]); /*冒泡排序*/ InitSeqList(&L,a,n); printf("冒泡排序前:"); DispList(L); BubbleSort(&L,n); printf("冒泡排序结果:"); DispList(L); /*快速排序*/ InitSeqList(&L,a,n); printf("快速排序前:"); DispList(L); QuickSort(&L); printf("快速排序结果:"); DispList(L); system("pause"); } /*冒泡排序算法部分*/ void BubbleSort(SqList *L,int n) /*冒泡排序*/ { int i,j,flag=1; DataType t; static int count=1; for(i=1;i<=n-1&&flag;i++) /*需要进行n-1趟排序*/ { flag=0; for(j=1;j<=n-i;j++) /*每一趟排序需要比较n-i次*/ if(L->data[j].key>L->data[j+1].key) { t=L->data[j]; L->data[j]=L->data[j+1]; L->data[j+1]=t; flag=1; } DispList2(*L,count); count++; } } /*快速排序算法部分*/ void QSort(SqList *L,int low,int high) /*对顺序表L进行快速排序*/ { int pivot; static int count=1; if(low=pivotkey)/*从表的末端向前扫描*/ high--; if(lowdata[k].keydata[j].key) j=k; if(j!=i) /*如果序号i不等于j,则需要将序号i和序号j的元素交换*/ { t=L->data[i]; L->data[i]=L->data[j]; L->data[j]=t; } } }   假设待排序元素有8个,分别是65,32,71,28,83,7,53,49。使用简单选择排序对该元素序列的排序过程如图9-15所示。 图9-15 简单选择排序全过程   简单选择排序是一种不稳定的排序算法,在最好的情况下,待排序元素序列按照非递减排列,则不需要移动元素;在最坏的情况下,待排序元素按照非递增排列,则在每一趟排序时都需要移动元素,移动元素的次数为3(n-1)。在任何情况下,简单选择排序算法都需要进行n(n-1)/2次比较。综上所述,简单选择排序算法的时间复杂度是O(n2)。   简单选择排序的空间复杂度是O(1)。 9.4.2 堆排序   堆排序的算法思想主要是利用了二叉树的性质进行排序。   1. 什么是堆和堆排序   堆排序(heap sort)利用二叉树的树形结构进行排序。堆中的每一个结点都大于(或小于)其孩子结点。堆的数学形式定义为:假设存在n个元素,其关键字序列为(k1,k2,…,ki,…,kn),如果有: 其中,i=1,2…,,则称此元素序列构成一个堆。如果将这些元素的关键字存放在一维数组中,将此一维数组中的元素与完全二叉树一一对应,则完全二叉树中的每个非叶子结点的值都不小于(或不大于)孩子结点的值。   在堆中,堆的根结点元素值一定是所有结点元素值的最大值或最小值。例如,序列(89,77,65,62,32,55,60,48)和(18,37,29,48,50,43,33,69,77,60)都是堆,相应的完全二叉树表示如图9-16所示。   在如图9-16所示的堆中,一个是非叶子结点的元素值不小于其孩子结点的值,这样的堆称为大顶堆。另一个是非叶子结点的元素值不大于其孩子结点的元素值,这样的堆称为小顶堆。 大顶堆 小顶堆 图9-16 堆   按照完全二叉树的编号次序,将元素序列的关键字依次存放在相应的结点。然后从叶子结点开始,从互为兄弟的两个结点中(没有兄弟结点除外)选择一个较大(或较小)者与其双亲结点比较,如果该结点大于(或小于)双亲结点,则将两者进行交换,使较大(或较小)者成为双亲结点。对所有的结点都做类似操作,直到根结点为止。这时,根结点的元素值的关键字最大(或最小)。   如果将堆中的根结点(堆顶)输出之后将剩余的n-1个结点的元素值重新建立一个堆,则新堆的堆顶元素值是次大(或次小)值,将该堆顶元素输出,然后将剩余的n-2个结点的元素值重新建立一个堆。反复执行以上操作,直到堆中没有结点,就构成了一个有序序列,这样的重复建堆并输出堆顶元素过程称为堆排序。   2. 建堆   堆排序的过程就是建立堆和不断调整使剩余结点构成新堆的过程。假设将待排序的元素的关键字存放在数组a中,第1个元素的关键字a[1]表示二叉树的根结点,剩下元素的关键字 a[n]分别与二叉树中的结点按照层次从左到右一一对应。例如,a[1]的左孩子结点存放在a[2]中,右孩子结点存放在a[3]中,a[i]的左孩子结点存放在a[2i]中,右孩子结点存放在a[2i+1]中。   如果是大顶堆,则有a[i].key≥a[2i].key且a[i].key≥a[2i+1].key(i=1,2,…,)。如果是小顶堆,则有a[i].key≤a[2i].key且a[i].key≤a[2i+1].key(i=1,2,…,)。   建立一个大顶堆就是将一个无序的关键字序列构建为一个满足条件a[i]≥a[2i]且a[i]≥a[2i+1](i=1,2,…,)的序列。   建立大顶堆的算法思想:从位于元素序列中的最后一个非叶子结点(即第个元素)开始,逐层比较,直到根结点为止。假设当前结点的序号为i,则当前元素为a[i],其左、右孩子结点元素分别为a[2i]和a[2i+1]。将a[2i].key和a[2i+1].key之中的较大者与a[i]比较,如果孩子结点元素值大于当前结点值,则交换两者;否则不进行交换。逐层向上执行此操作,直到根结点,这样就建立了一个大顶堆。建立小顶堆的算法与此类似。   例如,给定一组元素序列(27,58,42,53,42,69,50,62),建立大顶堆的过程如图9-17所示。   从图9-17容易看出,建立后的大顶堆中的孩子结点元素值都小于或等于双亲结点元素值,其中,根结点的元素值69是最大的元素。创建后的堆的元素序列为69,62,50,58,42,42,27,53。   相应地,建立大顶堆的算法描述如下。 图9-17 建立大顶堆的过程 void CreateHeap(SqList *H,int n) /*建立大顶堆*/ { int i; for(i=n/2;i>=1;i--) /*从序号n/2开始建立大顶堆*/ AdjustHeap(H,i,n); } void AdjustHeap(SqList *H,int s,int m) /*调整H.data[s...m]的关键字,使其成为一个大顶堆*/ { DataType t; int j; t=(*H).data[s]; /*将根结点暂时保存在t中*/ for(j=2*s;j<=m;j*=2) { if(j(*H).data[j].key) /*如果孩子结点的值小于根结点的值,则不进行交换*/ break; (*H).data[s]=(*H).data[j]; s=j; } (*H).data[s]=t; /*将根结点插入正确位置*/ }   3. 调整堆   建立好一个大顶堆后,当输出堆顶元素后,如何调整剩下的元素,使其构成一个新的大顶堆呢?其实,这也是一个建堆的过程,由于除了堆顶元素外,剩下的元素本身就具有a[i].key≥a[2i].key且a[i].key≥a[2i+1].key(i=1,2,…,)的性质,关键字按照由大到小逐层排列,因此,调整剩下的元素构成新的大顶堆只需要从上往下进行比较找出最大的关键字,并将其放在根结点的位置就又构成了新的堆。   具体实现:当堆顶元素输出后,可以将堆顶元素放在堆的最后,即将第1个元素与最后1个元素交换a[1]<->a[n],则需要调整的元素序列就是a[1…n-1]。从根结点开始,如果其左、右子树结点元素值大于根结点元素值,选择较大的一个进行交换。即如果a[2]>a[3],则将a[1]与a[2]比较;如果a[1]<a[2],则将a[1]与a[2]交换,否则不交换。如果a[2]length); /*创建堆*/ for(i=(*H).length;i>1;i--) /*将堆顶元素与最后一个元素交换,重新调整堆*/ { t=(*H).data[1]; (*H).data[1]=(*H).data[i]; (*H).data[i]=t; AdjustHeap(H,1,i-1); /*将(*H).data[1..i-1]调整为大顶堆*/ } }   例如,一个大顶堆的元素的关键字序列为(69,62,50,58,42,42,27,53),其相应的完整的堆排序过程如图9-19所示。   从上面的例子不难看出,堆排序属于不稳定的排序算法。   堆排序的时间耗费主要是在建立堆和调整堆时。一个深度为h,元素个数为n的堆,其调整算法的比较次数最多为2(h-1)次,而建立一个堆,其比较次数最多为4n。一个完整的堆排序过程总共的比较次数为2(++…+)< 2nlog2n,因此,堆排序平均时间复杂度和最坏情况下的时间复杂度都是O(nlog2n)。   堆排序的空间复杂度为O(1)。 图9-19 一个完整的堆排序过程    9.4.3 选择排序应用举例   【例9-3】编写算法,利用简单选择排序和堆排序算法对一组关键字序列 (69,62,50,58,42,42, 27,53)进行排序,要求输出每趟排序的结果。   程序代码如下: #include #include #define MaxSize 50 typedef int KeyType; typedef struct /*数据元素类型定义*/ { KeyType key; /*关键字*/ }DataType; typedef struct /*顺序表类型定义*/ { DataType data[MaxSize]; int length; }SqList; void InitSeqList(SqList *L,DataType a[],int n); void DispList(SqList L,int n); void AdjustHeap(SqList *H,int s,int m); void CreateHeap(SqList *H,int n); void HeapSort(SqList *H); void SelectSort(SqList *L,int n); void main() { DataType a[]={69,62,50,58,42,42,27,53}; SqList L; int n=sizeof(a)/sizeof(a[0]); /*简单选择排序*/ InitSeqList(&L,a,n); printf("[排序前] "); DispList(L,n); SelectSort(&L,n); printf("[简单选择排序结果]"); DispList(L,n); /*堆排序*/ InitSeqList(&L,a,n); printf("[排序前] "); DispList(L,n); HeapSort(&L); printf("[堆排序结果] "); DispList(L,n); system("pause"); } void InitSeqList(SqList *L,DataType a[],int n) /*顺序表的初始化*/ { int i; for(i=1;i<=n;i++) { L->data[i]=a[i-1]; } L->length=n; } void DispList(SqList L,int n) /*输出表中的元素*/ { int i; for(i=1;i<=n;i++) printf("%4d",L.data[i].key); printf("\n"); } void HeapSort(SqList *H) /*调整后的堆排序算法,使其能输出每趟的排序结果*/ { DataType t; int i; CreateHeap(H,H->length); /*创建堆*/ for(i=(*H).length;i>1;i--) /*将堆顶元素与最后一个元素交换,重新调整堆*/ { t=(*H).data[1]; (*H).data[1]=(*H).data[i]; (*H).data[i]=t; AdjustHeap(H,1,i-1); /*将(*H).data[1..i-1]调整为大顶堆*/ printf("[第%d趟排序后结果] ",H->length-i+1); DispList(*H,H->length); } }   【分析】简单选择排序和堆排序都是不稳定的排序方法。它们的主要思想是每次从待排序元素中选择关键字最小(或最大)的元素,经过不断交换,重复执行以上操作,最后形成一个有序的序列。   程序运行结果如图9-20所示。 图9-20 选择排序程序运行结果   【例9-4】编写算法,对关键字序列(69,62,50,58,42,42,27,53)进行选择排序,要求使用链表实现。   【分析】主要考查选择排序的算法思想和链表的操作。具体实现时,设置两个指针p和q,分别指向已排序链表和未排序链表。初始时,先创建一个链表,q指向该链表,p指向的链表为空。然后从q指向的链表中找到一个元素值最小的结点,将其取出并插入p指向的链表中。重复执行以上操作直到q指向的链表为空,此时p指向的链表就是一个有序链表。 void SelectSort(LinkList L) /*用链表实现选择排序。将链表分为两段,p指向已经排序的链表部分,q指向未排序的链表部分*/ { ListNode *p,*q,*t,*s; p=L; while(p->next->next!=NULL) { for(s=p,q=p->next;q->next!=NULL;q=q->next) /*用q指针进行遍历链表*/ if(q->next->datanext->data) /*如果q指针指向的元素值小于s指向的元素值,则s=q*/ s=q; if(s!=q) /*如果*s不是最后一个结点,则将s指向的结点链接到p指向的链表后面*/ { t=s->next; /*将结点*t从q指向的链表中取出*/ s->next=t->next; t->next=p->next; /*将结点*t插入p指向的链表中*/ p->next=t; } p=p->next; } }   程序运行结果如图9-21所示。 图9-21 采用链式存储结构的选择排序程序运行结果 9.5 归并排序   归并排序(merging sort)的算法思想是将两个或两个以上的元素有序序列合并为一个有序序列,也就是说,待排序元素序列被划分为若干个子序列,每个子序列都是有序的,通过将有序子序列合并为整体有序的序列就是归并排序。其中,最常见的是2路归并排序。   2路归并排序的主要思想是假设元素的个数是n,将每个元素作为一个有序的子序列,然后将相邻的两个子序列两两归并,得到个长度为2的有序子序列;再将相邻的两个有序子序列两两归并,得到个长度为4的有序子序列;如此重复,直至得到一个长度为n的有序序列为止。   一组元素的关键字序列为(50,22,61,35,87,12,19,75),2路归并排序的过程如图9-22所示。 图9-22 2路归并排序过程   2路归并排序的核心操作是将一维数组中前后相邻的两个有序序列归并为一个有序序列,其算法描述如下。 void Merge(DataType s[],DataType t[],int low,int mid,int high) /*将有序的s[low...mid]和s[mid+1..high]归并为有序的t[low..high]*/ { int i,j,k; i=low,j=mid+1,k=low; while(i<=mid&&j<=high) /*将s中元素由小到大地合并到t*/ { if(s[i].key<=s[j].key) { t[k]=s[i++]; } else { t[k]=s[j++]; } k++; } while(i<=mid) /*将剩余的s[i..mid]复制到t*/ t[k++]=s[i++]; while(j<=high) /*将剩余的s[j..high]复制到t*/ t[k++]=s[j++]; }   以上是归并两个子表的算法,可通过递归调用以上算法归并所有子表从而实现2路归并排序。其2路归并算法描述如下。 void MergeSort(DataType s[],DataType t[],int low, int high) /*2路归并排序,将s[low...high]归并排序并存储到t[low...high]中*/ { int mid; DataType t2[MaxSize]; if(low==high) t[low]=s[low]; else { mid=(low+high)/2; /*将s[low...high]分为s[low...mid]和s[mid+1...high]*/ MergeSort(s,t2,low,mid); /*将s[low...mid]归并为有序的t2[low...mid]*/ MergeSort(s,t2,mid+1,high); /*将s[mid+1...high]归并为有序的t2[mid+1...high]*/ Merge(t2,t,low,mid,high); /*将t2[low...mid]和t2[mid+1...high]归并到t[low... high]*/ } }   容易看出,归并排序需要与元素个数相等的空间作为辅助空间,因此归并排序的空间复杂度为O(n)。由于2路归并排序过程中所使用的空间过大,因此,它主要被用在外部排序中。2路归并排序算法需要多次递归调用自己,其递归调用的过程可以构成一个二叉树的结构,它的时间复杂度为T(n)≤n+2T(n/2)≤n+2×(n/2+2×T(n/4))=2n+4T(n/4)≤3n+8T(n/8)≤…≤nlog2n+nT(1),即O(nlog2n)。   2路归并排序是一种稳定的排序算法。   【例9-5】编写算法,请使用2路归并排序对一组关键字(50,22,61,35,87,12,19,75)进行排序。   程序代码如下: #include #include #define MaxSize 100 typedef int KeyType; typedef struct /*数据元素类型定义*/ { KeyType key; /*关键字*/ }DataType; typedef struct /*顺序表类型定义*/ { DataType data[MaxSize]; int length; }SqList; void InitSeqList(SqList *L,DataType a[],int start,int n); void DispList(SqList L); void DispArray(DataType a[],int low,int high); void MergeSort(DataType s[],DataType t[],int low, int high); void Merge(DataType s[],DataType t[],int low,int mid,int high); int N=0; void main() { DataType a[]={50,22,61,35,87,12,19,75}; DataType b[MaxSize]; int n=sizeof(a)/sizeof(a[0]); SqList L,L2; /*归并排序*/ InitSeqList(&L,a,0,n); /*将数组a[0...n-1]初始化为顺序表L*/ printf("归并排序前: "); DispList(L); MergeSort(L.data,b,1,n); InitSeqList(&L2,b,1,n); /*将数组b[1...n]初始化为顺序表L2*/ printf("归并排序结果:"); DispList(L2); system("pause"); } void InitSeqList(SqList *L,DataType a[],int start,int n) /*顺序表的初始化*/ { int i,k; for(k=1,i=start;idata[k]=a[i]; } L->length=n; } void DispList(SqList L) /*输出表中的元素*/ { int i; for(i=1;i<=L.length;i++) printf("%4d",L.data[i].key); printf("\n"); } void DispArray(DataType a[],int low,int high) { int i; for(i=low;i<=high;i++) printf("%4d",a[i]); printf("\n"); } void Merge(DataType s[],DataType t[],int low,int mid,int high) /*将有序的s[low...mid]和s[mid+1..high]归并为有序的t[low..high]*/ { int i,j,k; i=low,j=mid+1,k=low; while(i<=mid&&j<=high) /*将s中元素由小到大地合并到t*/ { if(s[i].key<=s[j].key) { t[k]=s[i++]; } else { t[k]=s[j++]; } k++; } while(i<=mid) /*将剩余的s[i...mid]复制到t*/ t[k++]=s[i++]; while(j<=high) /*将剩余的s[j...high]复制到t*/ t[k++]=s[j++]; printf("第%d次归并后:",++N); DispArray(t,low,high); }   程序运行结果如图9-23所示。 图9-23 归并排序程序运行结果 9.6 基数排序   基数排序是一种与前面所述各种排序方法完全不同的方法,前面的排序主要通过对元素的关键字进行比较和移动记录这两种操作,而实现基数排序则不需要进行对关键字比较。 9.6.1 基数排序算法   基数排序主要是利用多个关键字进行排序,在日常生活中,扑克牌就是一种多关键字的排序问题。扑克牌有4种花色即红桃、方块、梅花和黑桃,每种花色从A到K共13张牌。   将一副扑克牌的排序过程看成由花色和面值两个关键字进行排序的问题,若规定花色和面值的顺序如下。   花色:黑桃<梅花<方块<红桃。   面值:A<2<3<4<5<6<7<8<9<10 #include #include #define MaxNumKey 6 /*关键字项数的最大值*/ #define Radix 10 /*关键字基数,此时是十进制整数的基数*/ #define MaxSize 1 000 #define N 6 typedef int KeyType; /*定义关键字类型为字符型*/ typedef struct { KeyType key[MaxNumKey]; /*关键字*/ int next; }SListCell; /*静态链表的结点类型*/ typedef struct { SListCell data[MaxSize]; /*存储元素,data[0]为头结点*/ int keynum; /*每个元素的当前关键字个数*/ int length; /*静态链表的当前长度*/ }SList; /*静态链表类型*/ typedef int addr[Radix]; /*指针数组类型*/ typedef struct { KeyType key; /*关键字*/ }DataType; void PrintList(SList L); void PrintList2(SList L); void InitList(SList *L,DataType d[],int n); int trans(char c); void Distribute(SListCell data[],int i,addr f,addr r); void Collect(SListCell data[],addr f,addr r); void RadixSort(SList *L); int trans(char c) /*将字符c转化为对应的整数*/ { return c-'0'; } void main() { DataType d[N]={268,126,63,730,587,184}; SList L; int *adr; InitList(&L,d,N); printf("待排序元素个数是%d个,关键字个数为%d个\n",L.length,L.keynum); printf("排序前的元素:\n"); PrintList2(L); printf("排序前的元素的存放位置:\n"); PrintList(L); RadixSort(&L); printf("排序后元素的存放位置:\n"); PrintList(L); system("pause"); } void PrintList(SList L) /*按数组序号形式输出静态链表*/ { int i,j; printf("序号 关键字 地址\n"); for(i=1;i<=L.length;i++) { printf("%2d ",i); for(j=L.keynum-1;j>=0;j--) printf("%c",L.data[i].key[j]); printf(" %d\n",L.data[i].next); } } void PrintList2(SList L) /*按链表形式输出静态链表*/ { int i=L.data[0].next,j; while(i) { for(j=L.keynum-1;j>=0;j--) printf("%c",L.data[i].key[j]); printf(" "); i=L.data[i].next; } printf("\n"); }   【分析】主要考查基数排序的算法思想。基数排序就是利用多个关键字先进行分配,然后再对每趟排序结果进行收集,多趟分配和收集后得到最终的排序结果。十进制数有0~9共10个数字,利用10个链表分别存放每个关键字各个位为0~9的元素,然后通过收集将每个链表连接在一起,构成一个链表,通过3次分配和收集完成排序。   程序运行结果如图9-27所示。 图9-27 基数排序运行结果 9.7 小结   排序可分为插入排序、选择排序、交换排序、归并排序和基数排序。   直接插入排序算法实现最为简单,时间复杂度在最好、最坏和平均情况下都为O(n2)。   简单选择排序算法的时间复杂度在最好、最坏和平均情况下都是O(n2),而堆排序的时间复杂度在最好、最坏和平均情况下都是O(nlog2n)。   冒泡排序的平均时间复杂度为O(n2),快速排序在最好和平均情况下时间复杂度为O(nlog2n),最坏情况下时间复杂度为O(n2)。   归并排序时间复杂度在最好、最坏和平均情况下都为O(nlog2n)。   基数排序是一种不需要对关键字进行比较的排序算法。在任何情况下,基数排序的时间复杂度均为O(d(n+rd))。   从稳定性来看,直接插入排序、冒泡排序、简单选择排序、归并排序和基数排序属于稳定的排序算法,希尔排序、快速排序、堆排序属于不稳定的排序算法。          第10章 回溯算法            回溯法,也称为试探法,是一种选优搜索法,该方法首先暂时放弃关于问题规模大小的限制,并将问题的候选解按照某种顺序逐一枚举和检验。当发现当前的候选解不可能是解时,就选择下一个候选解;倘若当前候选解除了还不满足问题的规模要求外,满足所有其他要求时,继续扩大当前候选解的规模,并继续向前试探。如果当前的候选解满足包括问题规模在内的所有要求时,该候选解就是问题的一个解。在寻找解的过程中,放弃当前候选解,退回上一步重新选择候选解的过程就称为回溯。 10.1 和式分解   编写非递归算法,要求输入一个正整数n,请输出和等于n且不增的所有序列。例如,n=4时,输出结果为: 4=4 4=3+1 4=2+2 4=2+1+1 4=1+1+1+1   【分析】利用数组a[]存放分解出来的和数,r[]存放待分解的余数,其中,a[k+1]存放第k+1 步分解出来的和数,r[k+1]用于存放分解出和数a[k+1]后,还未分解的余数。初始时,为保证上述要求能对第一步(k=0)分解也成立,将a[0]和r[0]的值设置为n,表示第一个分解出来的和数为n。第k+1步要继续分解的数是前一步分解后的余数,即r[k]。在分解过程中,当某步欲分解的数r[k]为0时,表明已完成一个完整的和式分解,将该和式输出;然后在前提条件a[k]>1时,调整原来所分解的和数a[k]和余数r[k],进行新的和式分解,即令a[k]-1,作为新的待分解和数,r[k]+1就成为新的余数。若a[k]==1,表明当前和数不能继续分解,需要进行回溯,回退到上一步,即令k-1,直至a[k]>1停止回溯,调整新的和数和余数。为了保证分解出的和数依次构成不增的正整数序列,要求从r[k]分解出来的最大和数不能超过a[k]。当k==0时,表明完成所有的和式分解。   算法实现如下。 01 #include 02 #include 03 #include 04 #define MAXN 100 05 int a[MAXN]; 06 int r[MAXN]; 07 void Sum_Depcompose(int n) //非递归实现和式分解 08 { 09 int i = 0; 10 int k = 0; 11 r[0] = n; //r[0]存放余数 12 do 13 { 14 if (r[k] == 0) //表明已完成一次和式分解,输出和式分解 15 { 16 printf("%d = %d", a[0], a[1]); 17 for (i = 2; i <= k; i++) 18 { 19 printf("+%d", a[i]); 20 } 21 printf("\n"); 22 while (k>0 && a[k]==1) //若当前待分解的和数为1,则回溯 23 { 24 k--; 25 } 26 if (k > 0) //调整和数和余数 27 { 28 a[k]--; 29 r[k]++; 30 } 31 } 32 else //继续和式分解 33 { 34 a[k+1] = a[k] 0); 39 } 40 void main() 41 { 42 int i,test_data[] = {4,5,6}; 43 for (i =0; i 02 #define N 12 03 int b[N+1]; 04 int a[10];/*存放方格填入的整数*/ 05 int total=0;/*共有多少种填法*/ 06 int checkmatrix[][3]={ {-1},{0,-1},{1,-1}, 07 {0,-1},{1,3,-1},{2,4,-1}, 08 {3,-1},{4,6,-1},{5,7,-1}}; 09 void write(int a[]) 10 /*输出方格中的数字*/ 11 { 12 int i,j; 13 for (i=0;i<3;i++) 14 { 15 for (j=0;j<3;j++) 16 printf("%3d",a[3*i+j]); 17 printf("\n"); 18 } 19 } 20 int isprime(int m) 21 /*判断m是否是质数*/ 22 { 23 int i; 24 int primes[]={2,3,5,7,11,17,19,23,29,-1}; 25 if(m==1||m%2==0) 26 return 0; 27 for(i=0;primes[i]>0;i++) 28 if (m==primes[i]) 29 return 1; 30 for (i=3;i*i<=m;) 31 { 32 if (m%i==0) 33 return 0; 34 i+=2; 35 } 36 return 1; 37 } 38 int selectnum(int start) 39 /*从start开始选择没有使用过的数字*/ 40 { 41 int j; 42 for (j=start;j<=N;j++) 43 if (b[j]) 44 return j; 45 return 0; 46 } 47 int check(int pos) 48 /*检查填入的pos位置是否合理*/ 49 { 50 int i,j; 51 if(pos<0) 52 return 0; 53 /*判断相邻的两个数是否是质数*/ 54 for(i=0;(j=checkmatrix[pos][i])>=0;i++) 55 if(!isprime(a[pos]+a[j])) 56 return 0; 57 return 1; 58 } 59 int extend(int pos) 60 /*为下一个方格找一个还没有使用过的数字*/ 61 { 62 a[++pos]=selectnum(1); 63 b[a[pos]]=0; 64 return pos; 65 } 66 int change(int pos) 67 /*调整填入的数,为当前方格寻找下一个还没有用到的数*/ 68 { 69 int j; 70 /*找到第一个没有使用过的数*/ 71 while (pos>=0&&(j=selectnum(a[pos]+1))==0) 72 b[a[pos--]]=1; 73 if (pos<0) 74 return -1; 75 b[a[pos]]=1; 76 a[pos]=j; 77 b[j]=0; 78 return pos; 79 } 80 void find() 81 /*查找*/ 82 { 83 int ok=0,pos=0; 84 a[pos]=1; 85 b[a[pos]]=0; 86 do 87 { 88 if (ok) 89 if (pos==8) 90 { 91 total++; 92 printf("第%d种填法\n",total); 93 write(a); 94 pos=change(pos); /*调整*/ 95 } 96 else 97 pos=extend(pos); /*扩展*/ 98 else 99 pos=change(pos); /*调整*/ 100 ok=check(pos); /*检查*/ 101 } while (pos>=0); 102 } 103 void main() 104 { 105 int i; 106 for (i=1;i<=N;i++) 107 b[i]=1; 108 find(); 109 printf("共有%d种填法\n",total); 110 system("pause"); 111 }   程序运行结果如图10-2所示。   第6和第8行:数组checkmatrix是一个二维数组,用来作为检测两个相邻数是否是质数的辅助数组。   第9~19行:输出方格中填入的整数。 图10-2 程序运行结果   第20~31行:判断m是否是质数。   第38~46行:选择一个还没有使用过的数字。   第47~58行:检测在第pos个位置填入的数字是否合适。   第59~65行:为下一个方格填入还没有使用过的数字,并将该数的使用标志置为0。   第66~79行:调整填入的数字,为当前方格寻找还没有使用过的数字。   第84~85行:初始时将方格中的第一个位置设置为1。   第89~95行:如果填满该方格,则输出方格中的数字,并调整最后一个方格中的数字。   第97行:扩展第pos个位置中的数字。   第99行:从第pos个位置开始调整填入的数字,试探求其他位置填入的数字。   第100行:测试填入的数字是否正确。 10.3 装载问题   有n个集装箱要装到两艘船上,每艘船的容载量分别为c1,c2,第i个集装箱的重量为w[i],同时满足w[1]+w[2]+…+w[n]≤c1+c2;求确定一个最佳的方案把这些集装箱装入这两艘船上。   【分析】最佳方案的方法:首先将第一艘船尽量装满,再把剩下的装在第二艘船上。第一艘船尽量装满,等价于从n个集装箱选取一个子集,使得该子集的总重量与第一艘船的重量c1最接近,这样就类似于0-1背包问题。   问题解空间:(x1,x2,x3,…,xn),其中,xi为0表示不装在第一艘船上,为1表示装在第一艘船上。   约束条件:   (1)可行性约束条件:w1×x1+w2×x2+…wi×xi+…+wn×xn≤c1。   (2)最优解约束条件:remain+cw>bestw(remain表示剩余集装箱重量,cw表示当前已装上的集装箱的重量,bestw表示当前的最优装载量)。   例如,集装箱的个数为4,重量分别是10、20、35、40,第一艘船的最大装载量是50,则最优装载是将重量为10和40的集装箱装入。首先从第一个集装箱开始,将重量为10的集装箱装入第一艘船,然后将重量为20的集装箱装入,此时有10+20≤50,然后试探将重量为35的集装箱装入,但是10+20+35>50,所以不能装入35,紧接着试探装入重量为40的集装箱,因为10+20+40>50,所以也不能装入。因此30成为当前的最优装载量。   取出重量为20的集装箱(回溯,重新调整问题的解),如果将重量为35的集装箱装入第一艘船,因为10+35≤50,所以能够装入。因为45>bestw,所以45作为当前最优装载量。   继续取出重量为35的集装箱,如果将重量为40的集装箱装入第一艘船,因为10+40≤50,所以装入第一艘船。因为50>bestw,所以50作为当前最优装载量。   算法实现如下。 01 #include 02 #include 03 int *w; /*存放每个集装箱的重量*/ 04 int n; /*集装箱的数目*/ 05 int c; /*第一艘船的承载量*/ 06 int cw=0; /*当前载重量*/ 07 int remain; /*剩余载重量*/ 08 int *x; /*存放搜索时每个集装箱是否选取*/ 09 int bestw; /*存放最优的放在第一艘船的重量*/ 10 int *bestx; /*存放最优的集装箱选取方案*/ 11 void Backtrace(int k) 12 { 13 int i; 14 if(k>n) /*递归的出口,如果找到一个解*/ 15 { 16 for(i=1;i<=n;i++) /*则将装入船上的集装箱存入bestx中*/ 17 bestx[i]=x[i]; 18 bestw=cw; /*记下当前的最优装载量*/ 19 return; 20 } 21 else 22 { 23 remain-=w[k]; 24 if (cw+w[k]<=c) /*如果装入w[k],还小于c*/ 25 { 26 x[k]=1; /*则装入w[k]*/ 27 cw+=w[k]; 28 Backtrace(k+1); /*继续检查剩下的集装箱是否能装入*/ 29 cw-=w[k]; /*不装入w[k]*/ 30 } 31 if (remain+cw > bestw) /*如果剩余的集装箱不能完全装入*/ 32 { 33 x[k]=0; 34 Backtrace(k+1); /*继续从剩余的集装箱中检查是否能装入*/ 35 } 36 remain+=w[k]; /*w[k]重新成为待装入的集装箱*/ 37 } 38 } 39 int BestSoution(int *w,int n,int c) 40 /*搜索最优的装载方案:w存放每个集装箱的重量, 41 n表示集装箱数目,c表示第一艘船的装载量*/ 42 { 43 int i; 44 remain=0; /*第一艘船剩下的装载量*/ 45 for(i=1;i<=n;i++) 46 { 47 remain+=w[i]; 48 } 49 bestw=0; /*初始化第一艘船最优装载量*/ 50 Backtrace(1); 51 return bestw; 52 } 53 void main() 54 { 55 int i; 56 printf("请输入集装箱的数目="); 57 scanf("%d",&n); 58 w=(int*)malloc(sizeof(int)*(n+1)); 59 x=(int*)malloc(sizeof(int)*(n+1)); 60 bestx=(int*)malloc(sizeof(int)*(n+1)); 61 printf("请输入第一艘船的装载量="); 62 scanf("%d",&c); 63 printf("请输入每个集装箱的重量:\n"); 64 for (i=1;i<=n;i++) 65 { 66 printf("第%d的重量=",i); 67 scanf("%d",&w[i]); 68 } 69 bestw=BestSoution(w,n,c); 70 for (i=1;i<=n;i++) 71 { 72 printf("%4d",bestx[i]); 73 } 74 printf("\n"); 75 printf("存放在第一艘船上的重量:%d\n",bestw); 76 free(w); 77 free(x); 78 free(bestx); 79 system("pause"); 80 } 图10-3 程序运行结果   程序运行结果如图10-3所示。   第14~20行:是递归的出口,如果找到问题的一个解,则将解存放到bestx数组中,并将cw记作当前的最优装载量。   第23行:从剩余的集装箱中取出第k个集装箱(重量为w[k])。   第24行:如果将第k个集装箱装入第一艘船上,总重量小于c,则说明可以装入。   第26和第27行:将第k个集装箱装入第一艘船上。   第28行:继续检查剩下的集装箱,并选择合适的装入。   第29行:取出第k个集装箱,用来调整装入的货物。   第31行:如果剩下的集装箱不能同时装入。   第33和第34行:不装入第k个集装箱,并检查剩下的集装箱是否能装入。   第36行:第k个集装箱重新成为待装入的集装箱。   第45和第48行:初始时将所有的集装箱都作为即将装入第一艘船的货物。   第49行:初始化最优装载量。   第50行:调用Backtrace()函数从第一个集装箱开始试探装入第一艘船。 10.4 迷宫问题   求迷宫中从入口到出口的路径是经典的程序设计问题。通常采用穷举法,即从入口出发,顺着某一个方向向前探索,若能走通,则继续往前走;否则沿原路返回,换另一个方向继续探索,直到探索到出口为止。为了保证在任何位置都能原路返回,显然需要用一个后进先出的栈来保存从入口到当前位置的路径。   可以用如图10-4所示的方块迷宫,空白方块表示通道,带阴影的方块表示墙。   所求路径必须是简单路径,即求得的路径上不能重复出现同一通道块。求迷宫中一条路径的算法的基本思路是:如果当前位置“可通”,则纳入“当前路径”,并继续朝下一个位置探索,即切换下一个位置为当前位置,如此重复直至到达出口;如果当前位置不可通,则应沿“来向”退回到前一通道块,然后朝“来向”之外的其他方向继续探索;如果该通道块的四周4个方块均不可通,则应从当前路径上删除该通道块。   下一位置指的是当前位置四周(东、南、西、北)4个方向上相邻的方块。假设入口位置为(1,1),出口位置为(8,8),根据以上算法搜索出来的一条路径如图10-5所示。 图10-4 迷宫 图10-5 迷宫中的一条可通路径   定义墙元素值为0,可通过路径为1,不能通过路径为-1,求解迷宫程序如下。 #include #include #include typedef struct { int x; /*行值*/ int y; /*列值*/ }PosType; /*迷宫坐标位置类型*/ typedef struct { int ord; /*通道块在路径上的序号*/ PosType seat; /*通道块在迷宫中的坐标位置*/ int di; /*从此通道块走向下一通道块的方向(0~3表示东~北)*/ }DataType; /*栈的元素类型*/ #include "SeqStack.h" #define MAXLENGTH 40 /*设迷宫的最大行列为40*/ typedef int MazeType[MAXLENGTH][MAXLENGTH]; /*迷宫数组类型[行][列]*/ MazeType m; /*迷宫数组*/ int x,y; /*迷宫的行数,列数*/ PosType begin,end; /*迷宫的入口坐标,出口坐标*/ int curstep=1; /*当前足迹,初值(在入口处)为1*/ void Init(int k) /*设定迷宫布局(墙为值0,通道值为k)*/ { int i,j,x1,y1; printf("请输入迷宫的行数,列数(包括外墙):"); scanf("%d,%d",&x,&y); for(i=0;i 02 #include 03 #define N 60 04 int ExchageMoney(float n,float *a,int c,float *r); 05 void main() 06 { 07 float rmb[]={100,50,20,10,5,2,1,0.5,0.2,0.1}; 08 int n=sizeof(rmb)/sizeof(rmb[0]),k,i; 09 float change,r[N];; 10 printf("请输入要找的零钱数:"); 11 scanf("%f",&change); 12 for(i=0;i=rmb[i]) 14 break; 15 k=ExchageMoney(change,&rmb[i],n-i,r); 16 if(k<=0) 17 printf("找不开!\n"); 18 else 19 { 20 printf("找零钱的方案:%.2f=",change); 21 if(r[0]>=1.0) 22 printf("%.0f",r[0]); 23 else 24 printf("%.2f",r[0]); 25 for(i=1;i=1.0) 28 printf("+%.0f",r[i]); 29 else 30 printf("+%.2f",r[i]); 31 } 32 printf("\n"); 33 } 34 system("pause"); 35 } 36 int ExchageMoney(float n,float *a,int c,float *r) 37 { 38 int m; 39 if(n==0.0) /*能分解,分解完成*/ 40 return 0; 41 if(c==0) /*不能分解*/ 42 return -1; 43 if(n<*a) 44 return ExchageMoney(n,a+1,c-1,r); /*继续寻找合适的面值*/ 45 else 46 { 47 *r=*a; /*将零钱保存到r中*/ 48 m=ExchageMoney(n-*a,a,c,r+1); /*继续分解剩下的零钱*/ 49 if(m>=0) 50 return m+1; /*返回找零的零钱张数*/ 51 return -1; 52 } 53 }   程序运行结果如图11-1所示。 图11-1 程序运行结果   第7行:存放人民币的各种面额大小。   第12~14行:找到第1个小于change的人民币面值。   第15行:调用exchange()函数并返回找回零钱的张数。   第16~17行:如果返回小于或等于0的数,则表示找不开零钱。   第18~33行:输出找零钱的方案。   第39~40行:表示找零钱成功,返回0。   第41~42行:表示没有找到合适的找零钱方案,返回-1。   第43~44行:继续寻找较小的面额。   第47行:将零钱的面额存放到数组r中。   第48行:继续分解剩下的零钱。   第49~50行:返回找零钱的张数。 11.2 哈夫曼编码   利用给定的结点权值构造哈夫曼树,并输出每个结点的哈夫曼编码。   【分析】哈夫曼树:也称为最优二叉树,带权路径长度达到最小的二叉树。构造哈夫曼树的过程利用了贪心选择的性质,每次都是从结点集合中选择权值最小的两个结点构造一个新树。这就保证了贪心选择的局部最优的性质。   算法实现如下。 01 #include 02 #include 03 #include 04 typedef struct 05 { 06 unsigned int weight; /*权值*/ 07 unsigned int parent,LChild,RChild; /*双亲、左右孩子结点的指针*/ 08 } HTNode, *HuffmanTree; /*存储哈夫曼树*/ 09 typedef char *HuffmanCode; /*存储哈夫曼编码*/ 10 void CreateHuffmanTree(HuffmanTree *ht,int *w,int n); 11 void Select(HuffmanTree *ht,int n,int *s1,int *s2); 12 void CreateHuffmanCode(HuffmanTree *ht, HuffmanCode *hc, int n); 13 void main() 14 { 15 HuffmanTree HT; 16 HuffmanCode HC; 17 int *w,i,n,w1; 18 printf("***********哈夫曼编码***********\n" ); 19 printf("请输入结点个数:" ); 20 scanf("%d",&n); 21 w=(int *)malloc((n+1)*sizeof(int)); 22 printf("输入这%d个元素的权值:\n",n); 23 for(i=1; i<=n; i++) 24 { 25 printf("%d: ",i); 26 scanf("%d",&w1); 27 w[i]=w1; 28 } 29 CreateHuffmanTree(&HT,w,n); /*构造哈夫曼树*/ 30 CreateHuffmanCode(&HT,&HC,n); /*构造哈夫曼编码*/ 31 system("pause"); 32 } 33 void CreateHuffmanTree(HuffmanTree *ht,int *w,int n) 34 /*构造哈夫曼树ht,w存放已知的n个权值*/ 35 { 36 int m,i,s1,s2; 37 m=2*n-1; /*结点总数*/ 38 *ht=(HuffmanTree)malloc((m+1)*sizeof(HTNode)); 39 for(i=1; i<=n; i++) /*初始化叶子结点*/ 40 { 41 (*ht)[i].weight=w[i]; 42 (*ht)[i].LChild=0; 43 (*ht)[i].parent=0; 44 (*ht)[i].RChild=0; 45 } 46 for(i=n+1; i<=m; i++) /*初始化非叶子结点*/ 47 { 48 (*ht)[i].weight=0; 49 (*ht)[i].LChild=0; 50 (*ht)[i].parent=0; 51 (*ht)[i].RChild=0; 52 } 53 printf("\n哈夫曼树为: \n"); 54 for(i=n+1; i<=m; i++) /*创建非叶子结点,建哈夫曼树*/ 55 /*在(*ht)[1]~(*ht)[i-1]的范围内选择两个最小的结点*/ 56 { 57 Select(ht,i-1,&s1,&s2); 58 (*ht)[s1].parent=i; 59 (*ht)[s2].parent=i; 60 (*ht)[i].LChild=s1; 61 (*ht)[i].RChild=s2; 62 (*ht)[i].weight=(*ht)[s1].weight+(*ht)[s2].weight; 63 printf("%d (%d, %d)\n", 64 (*ht)[i].weight,(*ht)[s1].weight,(*ht)[s2].weight); 65 } 66 printf("\n"); 67 } 68 void CreateHuffmanCode(HuffmanTree *ht, HuffmanCode *hc, int n) 69 /*从叶子结点到根,逆向求每个叶子结点对应的哈夫曼编码*/ 70 { 71 char *cd; /*定义的存放编码的空间*/ 72 int a[100]; 73 int i,start,p,w=0; 74 unsigned int c; 75 /*分配n个编码的头指针*/ 76 hc=(HuffmanCode *)malloc((n+1)*sizeof(char *)); 77 cd=(char *)malloc(n*sizeof(char)); /*分配求当前编码的工作空间*/ 78 cd[n-1]='\0'; /*从右向左逐位存放编码,首先存放编码结束符*/ 79 for(i=1; i<=n; i++) 80 /*求n个叶子结点对应的哈夫曼编码*/ 81 { 82 a[i]=0; 83 start=n-1; /*起始指针位置在最右边*/ 84 for(c=i,p=(*ht)[i].parent; p!=0; c=p,p=(*ht)[p].parent) 85 /*从叶子到根结点求编码*/ 86 { 87 if( (*ht)[p].LChild==c) 88 { 89 cd[--start]='0'; /*左分支记作0*/ 90 a[i]++; 91 } 92 else 93 { 94 cd[--start]='1'; /*右分支记作1*/ 95 a[i]++; 96 } 97 } 98 /*为第i个编码分配空间*/ 99 hc[i]=(char *)malloc((n-start)*sizeof(char)); 100 strcpy(hc[i],&cd[start]); /*将cd复制编码到hc*/ 101 } 102 free(cd); 103 for(i=1; i<=n; i++) 104 printf("权值为%d的哈夫曼编码为:%s\n",(*ht)[i].weight,hc[i]); 105 for(i=1; i<=n; i++) 106 w+=(*ht)[i].weight*a[i]; 107 printf("带权路径为:%d\n",w); 108 } 109 void Select(HuffmanTree *ht,int n,int *s1,int *s2) 110 /*选择两个parent为0,且weight最小的结点s1和s2*/ 111 { 112 int i,min; 113 for(i=1; i<=n; i++) 114 { 115 if((*ht)[i].parent==0) 116 { 117 min=i; 118 break; 119 } 120 } 121 for(i=1; i<=n; i++) 122 { 123 if((*ht)[i].parent==0) 124 { 125 if((*ht)[i].weight<(*ht)[min].weight) 126 min=i; 127 } 128 } 129 *s1=min; 130 for(i=1; i<=n; i++) 131 { 132 if((*ht)[i].parent==0 && i!=(*s1)) 133 { 134 min=i; 135 break; 136 } 137 } 138 for(i=1; i<=n; i++) 139 { 140 if((*ht)[i].parent==0 && i!=(*s1)) 141 { 142 if((*ht)[i].weight<(*ht)[min].weight) 143 min=i; 144 } 145 } 146 *s2=min; 147 }   程序运行结果如图11-2所示。 图11-2 程序运行结果   第37行:求出哈夫曼树所有结点的个数。   第39~45行:初始化叶子结点,将每个结点看作一棵树。   第46~52行:初始化非叶子结点。   第54~65行:创建哈夫曼树,找出两个权值最小的结点,构造它们的根结点。   第57行:调用Select()函数选择权值最小的两个结点。   第58~59行:将第i个结点作为权值最小的结点s1和s2的根结点。   第60~61行:分别让第i个结点的左右孩子指针指向s1和s2。   第62行:将s1和s2的权值之和作为第i个结点的权值。   第63~64行:输出第i个结点,s1和s2结点的权值。   第84~97行:从第0个结点开始向上直到根结点,为每个叶子结点构造哈夫曼编码。   第87~91行:如果是左分支,则用'0'表示。   第92~96行:如果是右分支,则用'1'表示。   第100行:将每个叶子结点的编码存放到hc中。   第103~104行:输出每个叶子结点的哈夫曼编码。   第105~106行:求出每个叶子结点的带权路径长度。   第113~120行:先找出一个参考结点的权值编号。   第121~129行:找出权值最小的结点。   第130~137行:找出一个编号不是min的参考结点权值编号。   第138~146行:找出一个编号不是min且权值最小的结点,即权值次小的结点。 11.3 加油站问题   一辆汽车加满油后可以行驶nkm。旅途中有若干个加油站,为了使沿途加油次数最少,设计一个算法,输出最好的加油方案。   例如,假设沿途有9个加油站,总路程为100km,加满油后汽车行驶的最远距离为20km。汽车加油的位置如图11-3所示。 km 图11-3 行驶过程中的加油次数   【分析】为了使汽车在途中加油次数最少,需要让汽车加过一次油后行驶的路程尽可能的远,然后再加下一次油。按照这种设计思想,制定以下贪心选择策略。   (1)第1次汽车从起点出发,行驶到n=20km时,选择一个距离终点最近的加油站xi,应选择距离起点为20km的加油站即第2个加油站加油。   (2)加完一次油后,汽车处于满油状态,这与汽车出发前的状态一致,这样就将问题归结为求xi到终点汽车加油次数最少的一个规模更小的子问题。   按照以上策略不断地解决子问题,即每次找到从前一次选择的加油站开始往前nkm之间、距离终点最近的加油站加油。   在具体的程序设计中,设置一个数组x,存放加油站距离起点的距离。全程长度用S表示,用数组a存放选择的加油站,total表示已经行驶的最长路程。   算法实现如下。 01 #include 02 #include 03 #define S 100 /*S:全程长度*/ 04 void main() 05 { 06 int i,j,n,k=0,total,dist; 07 int x[]={10,20,35,40,50,65,75,85,100}; /*加油站距离起点的位置*/ 08 int a[10]; /*数组a:选择加油点的位置*/ 09 n=sizeof(x)/sizeof(x[0]); /*n:沿途加油站的个数*/ 10 printf("请输入最远行车距离(15<=n<100):"); 11 scanf("%d",&dist); 12 total=dist; /*total:总共行驶的里程*/ 13 j=1; /*j:选择的加油站个数*/ 14 while(totaltotal) /*如果距离下一个加油站太远*/ 19 { 20 a[j]=x[i-1]; /*则在当前加油点加油*/ 21 j++; 22 total=x[i-1]+dist; /*计算加完油能行驶的最远距离*/ 23 k=i; /*k:记录下一次加油的开始位置*/ 24 break; /*退出for循环*/ 25 } 26 } 27 } 28 for(i=1;i1时,可利用分治法求解该问题,令mid=(left+right)/2,最大子序列和可能出现在以下3个区间内。   (1)该子序列完全落在左半区间,即a[0…mid-1]中,可采用递归将问题缩小在左半区间,通过调用自身maxLeftSum = MaxSubSum(a,left,mid)求出最大连续子序列和maxLeftSum。   (2)该子序列完全落在右半区间,即[mid…n-1]中,类似地,可通过调用自身maxRightSum = MaxSubSum(a,mid,right)求出最大连续子序列和maxRightSum。   (3)该子序列落在两个区间之间,横跨左右两个区间,则需要从左半区间求出maxLeftSum1= max(0≤i≤mid-1),从右半区间求出maxRightSum1=max(mid≤i<n)。最大连续子序列和为maxLeftSum1+ maxRightSum1。   最后需要求出这3种情况连续子序列和的最大值,即maxLeftSum1+maxRightSum1, maxLeftSum, maxRightSum的最大值就是最大连续子序列和。   算法实现如下。 01 #include 02 int MaxSubSum(int data[], int left, int right); 03 int GetMaxNum(int a,int b,int c); 04 void main() 05 { 06 int a[]={6,3,-11, 5, 8, 15, -2, 9, 10, -5}, n,s,i; 07 n=sizeof(a)/sizeof(a[0]); 08 printf("元素序列:\n"); 09 for(i=0;i y&&x > z) 19 return x; 20 if (y > x&&y > z) 21 return y; 22 return z; 23 } 24 int MaxSubSum(int a[], int left, int right) 25 { 26 int mid, maxLeftSum, maxRightSum, i, tempLeftSum, tempRighSum; 27 int maxLeftSum1, maxRightSum1; 28 if (right - left == 1) //如果当前序列只有一个元素 29 { 30 return a[left]; 31 } 32 mid = (left + right) / 2; //计算当前序列的中间位置 33 maxLeftSum = MaxSubSum(a,left,mid); 34 maxRightSum = MaxSubSum(a,mid,right); 35 //计算左边界最大子序列和 36 tempLeftSum = 0; 37 maxLeftSum1 = a[mid-1]; 38 for (i = mid - 1; i >= left; i--) 39 { 40 tempLeftSum += a[i]; 41 if (maxLeftSum1 < tempLeftSum) 42 maxLeftSum1 = tempLeftSum; 43 } 44 //计算右边界最大子序列和 45 tempRighSum = 0; 46 maxRightSum1 = a[mid]; 47 for (i = mid; i < right; i++){ 48 tempRighSum += a[i]; 49 if (maxRightSum1 < tempRighSum) 50 maxRightSum1 = tempRighSum; 51 } 52 //返回当前序列最大子序列和 53 return GetMaxNum(maxLeftSum1 + maxRightSum1, maxLeftSum, maxRightSum); 54 }   程序运行结果如图12-1所示。 图12-1 程序运行结果   第28~31行:如果子序列中只有一个元素,则返回该元素。   第33行:递归调用自身求左半区间的最大连续子序列和maxLeftSum。   第34行:递归调用自身求右半区间的最大连续子序列和maxRightSum。   第36~43行:求左半区间中从mid-1到i的最大子序列和maxLeftSum1。   第45~51行:求右半区间从mid到i的最大子序列和maxRightSum1。   第53行:求以上3种情况的最大值,即最大连续子序列和。 12.2 求x的n次幂   x的n次幂可利用简单的迭代法实现,也可将xn看成是一个规模为n的x相乘问题,这样就可以将规模不断进行划分,直到规模为1为止。求x的n次幂问题可分为以下两种情况。 x ^ n = x^(n/2) *x(n/2) (n是偶数) = x^((n-1)/2)*x^((n-1)/2)*x (n是奇数)   根据以上分析,求x的n次幂问题可表示成以下递归模型。   当n=1时,如果程序要找全部解,则在将找到的解输出后,应继续调整最后位置上填放的整数,试图去找下一个解。相应的算法如下。      算法实现如下。 01 #include 02 #include 03 using namespace std; 04 float divide_pow ( float x, float n ) 05 { 06 float a; 07 if ( n == 1 ) 08 return x; 09 else if ( (int)n % 2 == 0 ) //n为偶数 10 { 11 a = divide_pow(x,n/2); 12 return a*a; 13 } 14 else //n为奇数 15 { 16 a = divide_pow(x,(n-1)/2); 17 return a*a*x; 18 } 19 } 20 float common_pow ( float x, float y ) 21 { 22 float result = 1,i; 23 for ( i = 1; i <= y; ++i ) 24 { 25 result *= x; 26 } 27 return result; 28 } 29 void main() 30 { 31 float x; //底数 32 float n; //幂 33 cout << "请输入底数:" ; 34 cin >> x; 35 cout << "请输入幂:"; 36 cin >> n; 37 cout<<"普通的迭代法:"<high结束查找。   算法实现如下。 *********************************************/ 01 #include 02 #include 03 #include 04 using namespace std; 05 void split(int a[],int l,int r,int *m, int *left,int *right) 06 /*按中位数a[m]将a[]划分成两部分*/ 07 { 08 *m=(l+r)/2; 09 for(*left=l;*left<=r;(*left)++) 10 if(a[*left]==a[*m]) 11 break; 12 for(*right=(*left)+1;*right<=r;(*right)++) 13 if(a[*right]!=a[*m]) 14 break; 15 (*right)--; 16 } 17 void GetMode(int *a,int low,int high,int *maxcnt,int *index) 18 /*分治求解众数*/ 19 { 20 int left,right,mid,cnt; 21 if(low>high) 22 return; 23 split(a,low,high,&mid,&left,&right); //将数组a划分为3部分 24 cnt=right-left+1; 25 if(cnt>*maxcnt){ //保存众数个数最大值,以及众数下标 26 *index=mid; 27 *maxcnt=cnt; 28 } 29 GetMode(a,low,left-1,maxcnt,index); 30 GetMode(a,right+1,high,maxcnt,index); 31 } 32 void main() 33 { 34 int a[]={6,3,3,3,2,5,5,9,9,9,9,8}; 35 int maxcnt=0,index=0; //maxcnt:重数,index:众数下标 36 int n=sizeof(a)/sizeof(a[0]),i; 37 cout<<"元素序列:"< 02 #include 03 void Max_Min_Comm(int a[], int n, int *max, int *min); 04 void Max_Min_Div(int a[],int start, int end,int *max,int *min); 05 void main() 06 { 07 int a[]={65, 32, 78, -16, 90, 55, 26, -5, 8, 41},n,i; 08 int m1, n1, m2, n2; 09 n=sizeof(a)/sizeof(a[0]); 10 Max_Min_Comm(a,n,&m1,&n1); 11 printf("元素序列:\n"); 12 for(i=0;i *max) 27 *max= a[i]; 28 if(a[i] < *min) 29 *min= a[i]; 30 } 31 } 32 void Max_Min_Div(int a[],int start, int end,int *max,int *min) 33 /*a[]存放输入的数据,start和end分别表示数据的下标,*max和*min用于存放最大值和最小值*/ 34 { 35 int m1,n1,m2,n2,mid; 36 if(start==end)/*若只有一个元素*/ 37 { 38 *max=*min=a[start]; 39 return; 40 } 41 if(end-1==start)/*若有两个元素*/ 42 { 43 if(a[start]1时,可分为n=n和小于n的情况,有DivideNum(n,n-1)+1种可能,例如,5可以划分为5和除小于5之外的情况,这就是将原来规模为n的问题缩小为规模为n-1的问题进行处理。   (3)当nm的情况时会遇到这种情况,直接将其转换为DivideNum(n,n)解决。   (4)当n>m时,可分为两种情况处理:包含m和不包含m,对于包含m的情况,有DivideNum(n-m,m);对于不包含m的情况,有DivideNum(n,m-1)。   算法实现如下。 01 #include 02 #include 03 int DivideNum(int n,int m) //n表示需要划分的整数,m表示最大加数 04 { 05 if(n==1||m==1) //若n或m为1,则只有一种划分方法,即使n个1相加 06 return 1; 07 else if(n==m&&n>1) 08 return DivideNum(n,n-1)+1; 09 else if(nn,则令m=n 10 return DivideNum(n,n); 11 else if(n>m) 12 return DivideNum(n,m-1)+DivideNum(n-m,m);//两种情况:没有m的情况和有m的情况 13 return 0; 14 } 15 void main() 16 { 17 int n,m,r; 18 printf("请输入需要划分的整数与最大加数:\n"); 19 scanf("%d %d",&n,&m); 20 r=DivideNum(n,m); 21 printf("共有%d种划分方式!\n",r); 22 system("pause"); 23 }   程序运行结果如图12-5所示。 图12-5 程序运行结果 12.6 大整数乘法   设X和Y都是n位十进制数,要求计算它们的乘积X×Y。当n很大时,利用传统的计算方法求X×Y时需要计算步骤很多,运算量较大,若使用分治法求解X×Y会更高效,现要求采用分治法编写一个求两个任意长度的整数相乘运算的算法。   【分析】设有两个大整数X、Y,求X×Y的乘积就是把X与Y中的每一项去乘,但是这样的乘法效率较低。若采用分治法,可将X拆分为A和B,Y拆分为C和D,如图12-6所示。 图12-6 大整数X和Y的分段   则有XY=。   XY=   而。   这里取的大整数X、Y是在理想状态下,即X与Y的位数一致,且n=2m,m=1,2,3,…。计算X×Y需要进行4次n/2位整数的乘法,即AC、AD、BC和BD,及3次不超过n位的整数加法运算,此外还要进行两次移位2n和2n/2运算,这些加法和移位运算的时间复杂度为O(n)。根据以上分析,分治法求解X×Y的时间复杂度为T(n)=4T(n/2)+O(n),因此时间复杂度为O(n2)。   算法实现如下。 01 #include 02 #include 03 #include 04 #include 05 using namespace std; 06 string ToStr(int iValue) 07 //将整数转换为string类型 08 { 09 string result; 10 stringstream stream; 11 stream << iValue;//将整数iValue输出到stream字符串流 12 stream >> result;//从stream流中读取字符串数据存入result 13 return result; 14 } 15 template 16 int ToInt(string n) 17 //将字符串转换为整数 18 { 19 int num; 20 stringstream intstream; 21 intstream<>num; 23 return num; 24 } 25 void AddZero(string &s, int n, bool pre = true) 26 //在字符串前或者字符串后补0 27 { 28 string temp(n, '0'); 29 s = pre ? temp + s : s + temp; 30 } 31 void RemoveZero(string &str) 32 { 33 int i = 0; 34 while (i < str.length() && str[i] == '0') 35 i++; 36 if (i < str.length()) 37 str = str.substr(i); 38 else 39 str = "0"; 40 } 41 string BigIntegerAdd(string x, string y) 42 { 43 string result; 44 int t,m,i,size,b; 45 RemoveZero(x); 46 RemoveZero(y); 47 reverse(x.begin(), x.end()); 48 reverse(y.begin(), y.end()); 49 m = max((int)x.size(), (int)y.size()); 50 for (i = 0, size = 0; size|| i < m; i++) 51 { 52 t = size; 53 if (i < x.size()) 54 t += ToInt(x[i]); 55 if (i < y.size()) 56 t += ToInt(y[i]); 57 b = t % 10; 58 result = char(b + '0') + result; 59 size = t / 10; 60 } 61 return result; 62 } 63 64 string BigIntergerSub(string x, string y) 65 { 66 int xi,yi,i,x_size,y_size,*p,count=0; 67 string result; 68 RemoveZero(x); 69 RemoveZero(y); 70 reverse(x.begin(), x.end()); 71 reverse(y.begin(), y.end()); 72 x_size = (int)x.size(); 73 y_size = (int)y.size(); 74 p=new int[x_size]; 75 for ( i = 0; i < x_size; i++) 76 { 77 xi = ToInt(x[i]); 78 yi = i < y_size ? ToInt(y[i]) : 0; 79 p[count++] = xi - yi; 80 } 81 for (i = 0; i < x_size; i++) 82 { 83 if (p[i] < 0) 84 { 85 p[i] += 10; 86 p[i + 1]--; 87 } 88 } 89 for (i = x_size - 1; i >= 0; i--) 90 { 91 result += ToStr(p[i]); 92 } 93 return result; 94 } 95 96 97 string BigIntegerMul(string X, string Y) 98 { 99 string result, A, B, C, D, v2, v1, v0; 100 int n = 2,iValue; 101 if (X.size() > 2 || Y.size() > 2) 102 { 103 n = 4; 104 while (n < X.size() || n < Y.size()) 105 n *=2; 106 AddZero(X, n - (int)X.size()); 107 AddZero(Y, n - (int)Y.size()); 108 } 109 if (X.size() == 1) 110 AddZero(X, 1); 111 if (Y.size() == 1) 112 AddZero(Y, 1); 113 if (n == 2)//递归出口 114 { 115 iValue = ToInt(X) * ToInt(Y); 116 result = ToStr(iValue); 117 } 118 else 119 { 120 A = X.substr(0, n / 2); 121 B = X.substr(n / 2); 122 C = Y.substr(0, n / 2); 123 D = Y.substr(n / 2); 124 v2 = BigIntegerMul(A, C); 125 v0 = BigIntegerMul(B, D); 126 v1 = BigIntergerSub(BigIntegerMul(BigIntegerAdd(B, A), 127 BigIntegerAdd(D, C)), BigIntegerAdd(v2, v0)); 128 AddZero(v2, n, false); 129 AddZero(v1, n / 2, false); 130 result = BigIntegerAdd(BigIntegerAdd(v2, v1), v0); 131 } 132 return result; 133 } 134 void main() 135 { 136 string a, b; 137 char ch; 138 cout<<"要计算两个大整数相乘吗(y/n)?"<>a; 145 cout<<"请输入第2个整数:"; 146 cin>>b; 147 cout< 02 #include 03 #include 04 #define N 30 05 void rmb_units(int k); 06 void big_write_num(int l); 07 void main() 08 { 09 char c[N],*p; 10 int a,i,j,len,len_integer=0,len_decimal=0; //len_integer为整数部分长度,len_decimal为小数部分长度 12 printf("***************************************\n"); 13 printf(" 本程序是将阿拉伯数字小写金额转换成中文大写金额!\n"); 14 printf("***************************************\n"); 15 printf("请输入阿拉伯数字小写金额: ¥"); 16 scanf("%s",c); 17 printf("\n"); 18 p=c; 19 len=strlen(p); 20 /*求出整数部分的长度*/ 21 for(i=0;i<=len-1 && *(p+i)<='9' && *(p+i)>='0';i++); 22 if(*(p+i)=='.' || *(p+i)=='\0')//*(p+i)=='\0'没小数点的情况 23 len_integer=i; 24 else 25 { 26 printf("\n输入有误,整数部分含有错误的字符!\n"); 27 exit(-1); 28 } 29 if(len_integer>13) 30 { 31 printf("超过范围,最大万亿!整数部分最多13位!\n"); 32 printf("注意:超过万亿部分只读出数字的中文大写!\n"); 33 } 34 printf("¥%s 的大写金额:",c); 35 /*转换整数部分*/ 36 for(i=0;i2) //只取两位小数 79 len_decimal=2; 80 p=c; 81 /*转换小数部分*/ 82 for(j=0;j9) 87 { 88 printf("\n输入有误,小数部分含有错误的字符!\n"); 89 system("pause"); 90 exit(-1); 91 } 92 if(a==0) 93 { 94 if(j+1 02 #include 03 using namespace std; 04 void main() 05 { 06 char strID[19]; 07 int weight[]={7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2},m=0,i; 08 char verifyCod[]={'1','0','X','9','8','7','6','5','4','3','2'}; 09 while(1) 10 { 11 m=0; 12 cout<<"请输入15位身份证号(输入-1退出):"<>strID; 14 if(strcmp(strID,"-1")==0) 15 break; 16 for(i=strlen(strID);i>5;i--) 17 strID[i+2]=strID[i]; 18 strID[6]='1'; 19 strID[7]='9'; 20 for(i=0; i 02 #include 03 #include 04 #include 05 #define N 100 06 void QiangHongbao() 07 { 08 int num,i; 09 double total,total1=0,a[N],min=0.01,average; 10 float per_max_hongbao=0; 11 srand(time(0)); 12 printf("请输入红包的总金额:"); 13 scanf("%lf",&total); 14 printf("请输入红包的个数:"); 15 scanf("%d",&num); 16 for(i=1;inext;若和等于k,则求出两个多项式系数的乘积,并将其存入新结点中。若和大于k,则pa=pa->next。以此类推,这样就可以得到多项式A(x)和B(x)的乘积C(x)。算法结束后重新将链表B逆置,将链表B恢复原样。   算法实现如下。 01 #include 02 #include 03 #include 04 /*一元多项式结点类型定义*/ 05 typedef struct polyn 06 { 07 float coef; /*存放一元多项式的系数*/ 08 int expn; /*存放一元多项式的指数*/ 09 struct polyn *next; 10 }PolyNode, *PLinkList; 11 PLinkList CreatePolyn() 12 /*创建一元多项式,使一元多项式呈指数递减*/ 13 { 14 PolyNode *p,*q,*s; 15 PolyNode *head=NULL; 16 int expn2; 17 float coef2; 18 head=(PLinkList)malloc(sizeof(PolyNode));/*动态生成一个头结点*/ 19 if(!head) 20 return NULL; 21 head->coef=0; 22 head->expn=0; 23 head->next=NULL; 24 do 25 { 26 printf("输入系数coef(系数和指数都为0结束)"); 27 scanf("%f",&coef2); 28 printf("输入指数exp(系数和指数都为0结束)"); 29 scanf("%d",&expn2); 30 if((long)coef2==0&&expn2==0) 31 break; 32 s=(PolyNode*)malloc(sizeof(PolyNode)); 33 if(!s) 34 return NULL; 35 s->expn=expn2; 36 s->coef=coef2; 37 q=head->next; /*q指向链表的第一个结点,即表尾*/ 38 p=head; /*p指向q的前驱结点*/ 39 while(q&&expn2expn) 40 /*将新输入的指数与q指向的结点指数比较*/ 41 { 42 p=q; 43 q=q->next; 44 } 45 if(q==NULL||expn2>q->expn) /*q指向要插入结点的位置,p指向要插入 46 结点的前驱*/ 47 { 48 p->next=s; /*将s结点插入到链表中*/ 49 s->next=q; 50 } 51 else 52 q->coef+=coef2; /*若指数与链表中结点指数相同,则将系数相加*/ 53 } while(1); 54 return head; 55 } 56 PolyNode *MultiplyPolyn(PLinkList A,PLinkList B) 57 /*多项式的乘积*/ 58 { 59 PolyNode *pa,*pb,*pc,*u,*head; 60 int k,maxExp; 61 float coef; 62 head=(PLinkList)malloc(sizeof(PolyNode));/*动态生成头结点*/ 63 if(!head) 64 return NULL; 65 head->coef=0.0; 66 head->expn=0; 67 head->next=NULL; 68 if(A->next!=NULL&&B->next!=NULL) 69 maxExp=A->next->expn+B->next->expn; /*maxExp为两个链表指数的和 70 的最大值*/ 71 else 72 return head; 73 pc=head; 74 B=Reverse(B); /*使多项式B(x)呈指数递增形式*/ 75 for(k=maxExp;k>=0;k--) /*多项式的乘积指数范围为0~maxExp*/ 76 { 77 pa=A->next; 78 while(pa!=NULL&&pa->expn>k) /*寻找pa的开始位置*/ 79 pa=pa->next; 80 pb=B->next; 81 while(pb!=NULL&&pa!=NULL&&pa->expn+pb->expnnext; 84 coef=0.0; 85 while(pa!=NULL&&pb!=NULL) 86 { 87 if(pa->expn+pb->expn==k) /*如果在链表中找到对应的结点,即 88 和等于k,求相应的系数*/ 89 { 90 coef+=pa->coef*pb->coef; 91 pa=pa->next; 92 pb=pb->next; 93 } 94 else if(pa->expn+pb->expn>k) /*如果和大于k,则使pa移到下一个 95 结点*/ 96 pa=pa->next; 97 else 98 pb=pb->next; /*如果和小于k,则使pb移到下一个结点*/ 99 } 100 if(coef!=0.0) 101 /*如果系数不为0,则生成新结点,并将系数和指数分别赋值给新结点,并将结 102 点插入到链表中*/ 103 { 104 u=(PolyNode*)malloc(sizeof(PolyNode)); 105 u->coef=coef; 106 u->expn=k; 107 u->next=pc->next; 108 pc->next=u; 109 pc=u; 110 } 111 } 112 B=Reverse(B); /*完成多项式乘积后,将B(x)呈指数递减形式*/ 113 return head; 114 } 115 void OutPut(PLinkList head) 116 /*输出一元多项式*/ 117 { 118 PolyNode *p=head->next; 119 while(p) 120 { 121 printf("%1.1f",p->coef); 122 if(p->expn) 123 printf("*x^%d",p->expn); 124 if(p->next&&p->next->coef>0) 125 printf("+"); 126 p=p->next; 127 } 128 } 129 PolyNode *Reverse(PLinkList head) 130 /*将生成的链表逆置,使一元多项式呈指数递增形式*/ 131 { 132 PolyNode *q,*r,*p=NULL; 133 q=head->next; 134 while(q) 135 { 136 r=q->next; /*r指向链表的待处理结点*/ 137 q->next=p; /*将链表结点逆置*/ 138 p=q; /*p指向刚逆置后链表结点*/ 139 q=r; /*q指向下一准备逆置的结点*/ 140 } 141 head->next=p; /*将头结点的指针指向已经逆置后的链表*/ 142 return head; 143 } 144 void main() 145 { 146 PLinkList A,B,C; 147 A=CreatePolyn(); 148 printf("A(x)="); 149 OutPut(A); 150 printf("\n"); 151 B=CreatePolyn(); 152 printf("B(x)="); 153 OutPut(B); 154 printf("\n"); 155 C=MultiplyPolyn(A,B); 156 printf("C(x)=A(x)*B(x)="); 157 OutPut(C); /*输出结果*/ 158 printf("\n"); 159 system("pause"); 160 }   程序运行结果如图13-7所示。 图13-7 程序运行结果   第05~10行:定义一元多项式的结点,包括两个域:系数和指数。   第18~23行:动态生成头结点,初始时链表为空。   第24~31行:输入系数和指数,当系数和指数都输入为0时,输入结束。   第37~44行:从链表的第一个结点开始寻找新结点的插入位置。   第45~50行:将新结点q插入到链表的相应位置,插入后使链表中每个结点按照指数从大到小排列,即降幂排列。   第65~72行:两个多项式的指数的最大值之和作为多项式相乘后的最高指数项,若多项式中有一个为空,则相乘后结果为空,直接返回一个空链表。   第73~74行:初始时,pc是一个空链表,将pb逆置,使其指数按降幂排列。   第77~83行:分别在pa和pb链表中寻找可能开始的位置,保证两个链表中结点的指数相加为k。   第87~93行:若指数之和为k,则将两个结点的系数相乘。   第94~96行:若指数之和大于k,则需要从pa的下一个结点开始查找。   第97~98行:若指数之和小于k,则需要从pb的下一个结点开始查找。   第100~110行:若两个系数相乘后不为0,则创建一个新结点,并将系数和指数存入其中,把该结点插入到链表pc中。   第112行:将pb逆置,恢复原样。 13.5 大整数乘法   利用数组解决计算两个大整数相乘。   【分析】一般情况下,求两个大整数相乘往往利用分治法解决,理解起来较为困难,这里使用的方法是模拟人类大脑计算两个整数相乘的方式进行求解大整数相乘,中间结果和最后结果仍然使用数组来存储。   假设A为被乘数,B为乘数,分别从A和B的最低位开始,将B的最低位分别与A的各位数依次相乘,乘积的最低位存放在数组元素a[i]中,高位(进位)存放在临时变量d中;再将B的次低位与A的各位数相乘,并加上得到的进位d和a[i],就是B中该位数字与A中对应位上数字的乘积,其中,a[i]是之前得到乘积的第i位数字。以此类推,就可得到两个整数的乘积。代码如下。 for(i1=0,k=n1-1;i10) { i++; a[i]=a[i]+d%10; d=d/10; }   算法实现如下。 01 #include 02 #include 03 #include 04 #define N 500 05 void main() 06 { 07 long b,d; 08 int i,i1,i2,j,k,n,n1,n2,a[N]; 09 char s1[N],s2[N]; 10 printf("输入一个整数:"); 11 scanf("%s",&s1); 12 printf("再输入一个整数:"); 13 scanf("%s",&s2); 14 for(i=0;i0) 29 { 30 i++; 31 a[i]=a[i]+d%10; 32 d=d/10; 33 } 34 n=i; 35 } 36 printf("%s * %s= ",s1,s2); 37 for(i=n;i>=0;i--) 38 printf("%d",a[i]); 39 printf("\n"); 40 }   程序运行结果如图13-8所示。 图13-8 程序运行结果   第14~17行:将大整数上的每一位都初始化为0,分别求出两个整数的位数。   第19~27行:分别将被乘数和乘数上的每一位上的数字相乘,并将当前值存入d中。然后,把当前位上的数字存入a[i]中,进位存入d中。   第28~33行:在乘数中的每一位与被乘数相乘结束后,若最高位上还有进位,则将进位加到对应位a[i+1]上。   第34行:记下当前结果的位数,存入n中。   第37~38行:从高位到低位依次输出大整数相乘后的结果。    参 考 文 献          [1] 严蔚敏. 数据结构[M]. 北京:清华大学出版社,2001. [2] 耿国华. 数据结构[M]. 北京:高等教育出版社,2005. [3] 陈明. 实用数据结构[M]. 2版. 北京:清华大学出版社,2010. [4] Robert S. 算法:C语言实现(第1~4部分)[M]. 霍红卫,译. 北京:机械工业出版社,2009. [5] 吴仁群. 数据结构简明教程[M]. 北京:机械工业出版社,2011. [6] 朱站立. 数据结构[M]. 西安:西安电子科技大学出版社,2003. [7] 徐塞红. 数据结构考研辅导[M]. 北京:北京邮电大学出版社,2002. [8] 陈锐. 零基础学数据结构 [M]. 北京:机械工业出版社,2014. [9] 冼镜光. C语言名题百则[M]. 北京:机械工业出版社,2005. [10] 夏宽理. C程序设计实例详解[M]. 上海:复旦大学出版社,1996. [11] 李春葆,曾慧,张植民. 数据结构程序设计题典[M]. 北京:清华大学出版社,2002. [12] 杨明,杨萍. 研究生入学考试要点、真题解析与模拟考卷[M]. 北京:电子工业出版社,2003. [13] 唐发根. 数据结构[M]. 2版. 北京:科学出版社,2004. [14] 杨峰. 妙趣横生的算法[M]. 北京:清华大学出版社,2010. [15] Ellis H,Sartaj S,Susan A-F. 数据结构(C语言版)[M]. 李建中,张岩,李治军,译. 北京:机械工业出版社,2006. [16] 陈守礼,胡潇琨,李玲. 算法与数据结构考研试题精析[M]. 北京:机械工业出版社,2007. [17] 李春葆,尹为民,蒋晶珏. 数据结构教程 [M]. 北京:清华大学出版社,2017. [18] Cormen T H. 算法导论(原书第2版)[M]. 潘金贵,译. 北京:机械工业出版社,2006. [19] Robert S. 算法:C语言实现(第1~4部分)基础知识、数据结构、排序及搜索[M]. 霍红卫,译. 北京:机械工业出版社,2009. [20] Donald E K. 计算机程序设计艺术 卷1:基本算法(英文版 第3版)[M]. 北京:人民邮电出版社,2010. [21] 周伟,刘泱,王征勇. 2013年计算机专业基础综合历年统考真题及思路分析. [M]. 北京:机械工业出版社,2012. [22] Robert S,Kevin W. 算法[M]. 谢路云,译. 4版. 北京: 人民邮电出版社,2012. 40 ( 深入浅出数据结构与算法(微课视频版)    第2章 数据结构与算法基础 ( 39                            70 ( 深入浅出数据结构与算法(微课视频版)    第3章 线性表 ( 71                            220 ( 深入浅出数据结构与算法(微课视频版)    第7章 图 ( 219                            326 ( 深入浅出数据结构与算法(微课视频版)    第13章 实用算法 ( 327