数据结构是计算机软件的一门基础课程,计算机科学各个领域及有关的应用软件都要用到各种数据结构.语言编译要使用栈、散列表及语法树;操作系统中用队列、存储管理表及目录树等;数据库系统运用线性表、多链表及索引树等进行数据管理;而在人工智能领域,依求解问题性质的差异将涉及到各种不同的数据结构,如广义表、集合、搜索树及各种有向图等等。学习数据结构目的是要熟悉一些最常用的数据结构,明确数据结构内在的逻辑关系,知道它们在计算机中的存储表示,并结合各种典型应用说明它们在进行各种操作时的动态性质及实际的执行算法,进一步提高软件计和编程水平。通过对不同存储结构和相应算法的对比,增强我们根据求解问题的性质选择合理的数据结构,并将问题求解算法的空间、时间及复杂性控制在一定范围的能力。
软件设计师考试大纲对数据结构部分的要求是熟练掌握常用数据结构和常用算法,因此,本专题从数据结构的概述出发,对基本的概念引出常用的数据结构类型的介绍和讲解,同时在讲解各种数据结构中间采用算法与数据结构相结合的方式,在算法步骤中使用数据结构,对数据结构的重点、难点进行了分析,最后讲解了与数据结构紧密相关的排序和查找算法,以及一些以往考试题的分析。
1. 数据结构概述
数据结构研究了计算机需要处理的数据对象和对象之间的关系;刻画了应用中涉及到的数据的逻辑组织;也描述了数据在计算机中如何存储、传送、转换。
学习数据结构注意的问题:
n 系统掌握基本数据结构的特点及其不同实现。
n 了解并掌握各种数据结构上主要操作的实现及其性能(时间、空间)的分析。
n 掌握各种数据结构的使用特性,在算法设计中能够进行选择。
n 掌握常用的递归、回溯、迭代、递推等方法的设计
n 掌握自顶向下、逐步求精的程序设计方法。
n 掌握自顶向下、逐步求精的程序设计方法。
在学习数据结构的知识之前,我们要了解一下数据结构中的基本概念。
数据:对客观事物的符号表示,在计算机中就是指所有能输入到计算机中并被计算机程序所处理的符号的总称。
数据项: 是数据的不可分割的最小单位;
数据元素:是数据的基本单位,在计算机程序中通常作为一个整体进行处理;一个数据元素可由若干个数据项组成。
数据对象:是性质相同的数据元素的集合,是数据的一个子集。
数据结构上的基本操作:
◆插入操作
◆删除操作
◆更新操作
◆查找操作
◆排序操作
数据结构是指数据对象及相互关系和构造方法,一个数据结构B形式上可以用一个二元组表示为B=(A,R)。其中,A是数据结构中的数据(称为结点)的非空有限集合,R是定义在A上的关系的非空有限集合。
根据数据元素之间的关系的不同特性,通常有下列4类基本结构。
Ø 集合——结构中的数据元素除了“同属于一个集合”的关系外,别无其他关系。
Ø 线性结构——结构中的数据元素之间存在一个对一个的关系。
Ø 树形结构——结构中的元素之间存在一个对多个的关系。
Ø 图状结构或网状结构——结构中的元素之间存在多个对多个的关系。
数据结构中,结点与结点间的相互关系是数据的逻辑结构。数据结构在计算机中的表示(又称为映象)称为数据的物理结构,也称存储结构。
数据元素之间的关系在计算机中有两种不同的表示方式:顺序映象和非顺序映象,并由此得到两种不同的存储结构:顺序存储结构和链式存储结构。
任何一个算法的设计取决于选定的数据(逻辑)结构,而算法的实现依赖于采用的存储结构。
数据的逻辑结构分为两类:
线性结构:线性表、栈、队列和串
非线性结构:树、图
数据的存储方法有四类:
顺序存储方法
链接存储方法
索引存储方法
散列存储方法
2. 常用数据结构
2.1线性表
在数据结构中,线性结构常称为线性表,是最简单、最常用的一种数据结构,它是由n个相同数据类型的结点组成的有限序列。
其特点是:在数据元素的非空有限集合中,
◆存在唯一的一个被称做“第一个”的数据元素
◆存在唯一的一个被称做“最后一个”的元素数据元素
◆除第一个之外,集合中的每个数据元素均只有一个前驱
◆除最后一个之外,集合中每个数据元素均只有一个后继
一个由n个结点e0,e1…,en-1组成的线性表记为:(e0,e1…,en-1)。线性表的结点个数称为线性表的长度,长度为0的线性表称为空的线性表,简称空表。对于非空线性表,e0是线性表的第一个结点,en-1是线性表的最后一个结点。线性表的结点构成了一个序列,对序列中两个相邻结点ei和ei-1,称前者是后者的前驱结点,后者是前者的后继结点。
线性表最重要的性质是线性表中结点和相对位置是确定的。
线性表的结点也称为表元,或称为记录,要求线性表的结点一定是同一类型的数据。线性表的结点可由若干个成分组成,其中唯一标识表元的成分成为关键字,简称键。
线性表是一个相当灵活的数据结构,它的长度可以根据需要增长或缩短。对线性表的基本运算如下:
INITIATE(L)初始化操作
LENGTH(L) 求长度函数
GET(L,i) 取元素函数
PRIOR(L,elm)求前驱函数
NEXT(L,elm) 求后继函数
LOCATE(L,x) 定位函数
INSERT(L,i,b)插入操作
DELETE(L,i) 删除操作
有多种存储方式能将线性表存储在计算机内,其中最常用的是顺序存储和链接存储。根据存储方式的不同,其上述的运算实现也不一样。
◆ 顺序存储:是最简单的存储方式,其特点是逻辑关系上相邻的两个元素在物理位置上也相邻。通常使用一个足够大的数组,从数组的第一个元素开始,将线性表的结点依次存储在数组中。
顺序存储方式优点:能直接访问线性表中的任意结点。
线性表的第i个元素a[i]的存储位置可以使用以下公式求得:
LOC(ai)=LOC(a1)+(i-1)*l
式中LOC(a1)是线性表的第一个数据元素a1的存储位置,通常称做线性表的起始位置或基地址。
顺序存储的缺点:
1) 线性表的大小固定,浪费大量的存储空间,不利于节点的增加和减少;
2) 执行线性表的插入和删除操作要移动其他元素,不够方便;
◆链式存储
线性表链接存储是用链表来存储线性表,。
单链表(线性链表):
从链表的第一个表元开始,将线性表的结点依次存储在链表的各表元中。链表的每个表元除要存储线性表结点的信息以外,还要有一个成分来存储其后继结点的指针。
线性链表的特点是:每个链表都有一个头指针,整个链表的存取必须从头指针开始,头指针指向第一个数据元素的位置,最后的节点指针为空。当链表为空时,头指针为空值;链表非空时,头指针指向第一个节点。
链式存储的缺点:
1) 由于要存储地址指针,所以浪费空间;
2) 直接访问节点不方便;
3)
4)
5)
6)
7)
循环链表:
循环链表是另一种形式的链式存储结构,是单链表的变形。它的特点就是表中最后一个结点的指针域指向头结点,整个链表形成一个环。因此,从表中任意一个结点出发都可以找到表中的其他结点。
循环链表和单向链表基本一致,差别仅在于算法中循环的条件不是结点的指针是否为空,而是他们的指针是否等于头指针,
循环链表最后一个结点的 link 指针不为 0 (NULL),而是指向了表的前端。
为简化操作,在循环链表中往往加入表头结点。
循环链表的特点是:只要知道表中某一结点的地址,就可搜寻到所有其他结点的地址。
循环链表的示例:
带表头结点的循环链表 :
双向链表:
双向链表是另一种形式的链式结构,双向链表的结点中有两个指针域,其一指向直接后继,另一指向直接前趋。双向链表克服了单链表的单向性的缺点。
前驱方向 后继方向
双向链表也可以有循环表,链表中存在两个环。一个结点的前趋的后继和该结点的后继的前趋都是指向该结点的。
p == p→lLink→rLink == p→rLink→lLink
2.2 栈
栈(Stack)是限定仅在表尾进行插入或删除操作的线性表。表尾端称栈顶(top),表头端称栈底(bottom)。
若有栈 S=(s0,s1,…,sn-1)则s0为栈底结点,sn-1为栈顶结点。通常称栈的结点插入为进栈,栈的结点删除为出栈。因为最后进栈的结点必定最先出栈,所以栈具有后进先出的特点。可以用一下一个图形来形象的表示:
栈有两种存储结构:顺序栈和链栈
顺序栈即栈的顺序存储结构是,利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,同时设指针top指示栈顶元素的当前位置。
栈也可以用链表实现,链式存储结构的栈简称链栈。若同时需两个以上的栈,则最好采用这种结构。对于栈上的操作,总结如下,大家可以仔细看一下这些程序,一个大的程序都是由一些对数据结构的小的操作组成的。
顺序存储的栈的基本操作如下:
判断栈满:
int stackfull(seqstack *s)
{
return (s->top= =stacksize-1);
}
进栈:
void push(seqstack *s,datatype x)
{
if (stackfull(s))
error(“stack verflow”);
s->data[++s->top]=x;
}
判断栈空:
int stackempty(seqstack *s)
{
return (s->top= = -1)
}
出栈:
datatype pop(seqstack *s)
{
if (stackempty(s))
error(“stack underflow”);
x=s->data[top];
s->top--;
return (x);
}
链接存储栈:用链表实现的栈,链表第一个元素是栈顶元素,链表的末尾是栈底节点,链表的头指针就是栈顶指针,栈顶指针为空则是空栈。若同时需要两个以上的栈,最好采用链表作存储结构
链接存储的栈的操作如下:
进栈:
Void push(linkstack *p,datatype x)
{
stacknode *q q=(stacknode*)malloc(sizeof(stacknode));
q–>data=x;
q–>next=p–>top;
p–>top=q;
}
出栈:
Datatype pop(linkstack *p)
{
datatype x;
stacknode *q=p–>top;
if(stackempty(p)
error(“stack underflow.”);
x=q–>data;
p–>top=q–>next;
free(q);
return x;
}
多栈处理 栈浮动技术:
n 个栈共享一个数组空间V[m]
n设立栈顶指针数组 t [n+1] 和 栈底指针数组 b [n+1]
,t[i]和b[i]分别指示第 i 个栈的栈顶与栈底,b[n]作为控制量,指到数组最高下标
各栈初始分配空间 s = m/n指针初始值 t[0] = b[0] = -1 b[n] = m-1
t[i] = b[i] = b[i-1] + s, i = 1, 2, …, n-1
2.3队列
队列是只允许在一端进行插入,另一端进行删除运算的线性表。允许删除的那一端称为队首(front),允许插入运算的另一端称为队尾(rear)。通常称队列的结点插入为进队,队列的结点删除为出队。若有队列
Q=(q0,q1,…,qn-1)则q0为队首结点,qn-1为队尾结点。因最先进入队列的结点将最先出队,所以队列具有先进先出的特性。
可以用顺序存储线性表来表示队列,也可以用链表实现,用链表实现的队列称为链队列。
队列操作:
①int push(PNODE *top,int e)是进栈函数,形参top是栈顶指针的指针,形参e是入栈元素。
②int pop(PNODE *top,int oe)是出栈函数,形参top是栈顶指针的指针,形参e作为返回出栈元素使用。
③int enQueue(PNODE *tail,int e)是入队函数,形参tail是队尾指针的指针,形参e是入队元素。
④int deQueue(PNODE *tail,int *e)是出队函数,形参tail是队尾指针的指针,形参e作为返回出队元素使用。
定义结点的结构如下:
typedef struct node{
int value;
struct node *next;
}NODE,*PNODE;
[函数①]
int push(PNODE *top,int e)
{
PNODE p = (PNODE)malloc(sizeof(NODE));
if(!p) return –1;
p->value = e;
p->next = *top; //指向栈顶指针
*top = p;
return 0;
}
[函数②]
int pop(PNODE *top,int *e)
{
PNODE p = *top;
if(p == NULL) return –1;
*e = p->value;
*top = p->next; //栈顶指向取出的数的指针
free(p);
return 0;
}
[函数③]
int enQueue(PNODE *tail,int e)
{ PNODE p,t;
t = *tail;
p = (PNODE)malloc(sizeof(NODE));
if(!p) return –l;
p—>value = e;
p—>next = t—>next;
t->next = p; //将元素加在尾指针后
*tail = p;
return 0;
}
[函数④]
int deQueue(PNODE *tail,int e)
{
PNODE p,q;
if((*tail)->next == *tail) return –1;//队列已经空
p = (*tail)->next; //p获得尾指针
q = p->next;
e = q->value;
p->next = q->next;
if(*tail = q)
*tail = p ; //尾指针指向最后节点
free(q);
return 0;
)
循环队列 (Circular Queue):
存储队列的数组被当作首尾相接的表处理。
队头、队尾指针加1时从maxSize -1直接进到0,可用语言的取模(余数)运算实现。
队头指针进1: front = (front + 1) % maxSize;
队尾指针进1: rear = (rear + 1) % maxSize;
队列初始化:front = rear = 0;
队空条件:front == rear;
队满条件:(rear + 1) % maxSize == front
循环队列的进队和出队:
优先级队列:是不同于先进先出队列的另一种队列。每次从队列中取出的是具有最高优先权的元素
2.4 串
字符串是非数值处理应用中重要的处理对象。字符串是由某字符集上的字符所组成的任何有限字符序列。
当一个字符串不包含任何字符时,称它为空字符串。一个字符串所包含的有效字符个数称为这个字符串的长度。一个字符串中任一连续的子序列称为该字符串的子串。包含子串的字符串相应称为主串。通常称该字符在序列中的序号为该字符在串中的位置。
字符串的串值必须用单引号括, ‘c1c2c3…cn起来,但单引号本身不属于串,它的作用是为了避免于变量名和数的常量混淆。在C语言中,字符串常量是用一对双引号括住若干字符来表示,如“I am a student”
字符串通常存于字符数组中,每个字符串最后一个有效字符后跟一个字符串结束符“/0”.系统提供的库函数形成的字符串会自动加结束符号,而用户的应用程序中形成的字符串必须由程序自行负责添加字符串结束符号。
两个串相等当且仅当两个串的值相等,长度相等并且各个对应位置的字符都相等。
常用的字符串的基本操作有7种。
ASSING(s,t)和CREAT(s,ss)赋值操作
EQUAL(s,t)判等函数
LENGTH(s)求长度函数
CONCAT(s,t)联接函数
SUBSTR(s,start,len)求子串函数
INDEX(s,t)定位函数
REPLACE(s,t,v)置换函数
INSERT(s,pos,t)插入函数
DELETE(s,pos,t)删除函数
(1)求字符串长,int strlen(char s)
(2)串复制(copy)
char *strcpy(char to,char from);
该函数将串from复制到串to中,并且返回一个指向串to的开始处的指针。
(3)联接(concatenation)
char strcat(char to,char from)
该函数将串from复制到串to的末尾,并且返回一个指向串to的开始处的指针。
(4)串比较(compare)
int strcmp(chars1,char s2);
该函数比较串s1和串s2的大小,当返回值小于0,等于0或大于0时分别表示s1s2
例如:result=strcmp(“baker”,”Baker”) result>0
result=strcmp(“12”,”12”); result=0
result=strcmp(“Joe”,”Joseph”); result<0
(5)字符定位(index)
char strchr(char s,char c);
该函数是找c在字符串中第一次出现的位置,若找到则返回该位置,否则返回NULL。
字符串的静态存储:顺序存储,用一组地址连续的地址单元存储串的字符序列,特别是在PASCAL程序语言中还可以采用紧缩数组来实现。
字符串的动态存储:采用链表的方式存储字符串,节点的大小可以不同,即每个节点可以存放的字符数是不同的。对于节点大小大于1的链表,需要设置头指针和尾指针来定位和串连接。
存储密度:串值所站的存储位/实际分配的存储位
实际应用的串处理系统中采用的是动态存储结构,每个串的值各自存储在一组地址连续的存储单元中,存储地址在程序执行过程中动态分配。利用串名和串值之间的的对应关系来建立存储映象来访问串。
2.5 数组
数组是最常用的数据结构之一,在程序中,数组常用来实现顺序存储的线性表。数组由固定个数的元素组成,全部元素的类型相同,元素依次顺序存储。每个元素对应一个下标,数组元素按数组名和元素的下标引用,引用数组元素的下标个数称为数组的维数。
在C语言中,n个元素的数组中,第一个元素的下标为0,最后一个的下标为n-1;
数组可以分为一维、二维……N维数组,取决于引用数组元素的下标的个数;
一维数组:
二维数组和三维数组:
数组可以分为静态数组和动态数组两类,所谓静态就是指数组的空间存储分配是在使用之前还是在程序运行当中分配,静态数组就是在定义时必须进行空间分配,也就是固定数组的大小,这样就不利于数组的扩展。同样动态数组就是在程序运行过程中进行数组的赋值或者是空间的分配,动态数组一般采用链表的存储结构,而静态数组一般采用顺序存储结构。
数组元素可以是任何类型的,当元素本身又是数组时,就构成多维数组。多维数组是一维数组的推广,多维数组中最常用的是二维数组。多维数组的所有元素并未排在一个线性序列里,要顺序存储多维数组按需要按一定次序把所有的数组元素排在一个线性序列里,常用的排列次序有行优先顺序和列优先顺序。对于多维数组,C语言按行优先顺序存放。
对于数组,通常只有两种操作:
u 给定一个下标,存取相应的数据元素
u 给定一组下标,修改相应数据元素的某一个或几个数据项的值。
一般用多维数组表示矩阵,具体有以下几种类型:
对称矩阵:A[i,j]= =A[j,i]
三角矩阵:以主对角线划分,三角矩阵有上三角和下三角两种。
上三角矩阵中,它的下三角(不包括主对角线)中的元素均为常数。下三角矩阵正好相反,它的主对角线上方均为常数,在大多数情况下,三角矩阵常数为零。
三角矩阵可压缩存储到向量sa[0..n(n+1)/2]中,sa[k]和aij的对应关系是:
k =
3、对角矩阵
对角矩阵中,所有的非零元素集中在以主对角线为了中心的带状区域中,即除了主对角线和主对角线相邻两侧的若干条对角线上的元素之外,其余元素皆为零。
当∣i-j∣>1时,元素a[i,j]=0。
LOC(i,j)=LOC(0,0)+[3*i-1+(j-i+1)] =LOC(0,0)+(2i+j)
4. 稀疏矩阵
简单说,设矩阵A中有s个非零元素,若s远远小于矩阵元素的总数(即s≦m×n),并且分布没有一定规律。用顺序存储结构的三元组对稀疏矩阵进行存储,分别记录行、列和值
十字链表:
由于非零元的位置和个数变化,所以用链表存储更恰当;在这种情况下采用十字链表来表示,每个非零元用一个节点表示,节点中有行、列还有向下的域和线右的域;
此外还需要一个指向列链表的表头节点和指向行链表的表头节点。还可以设置一个指向整个十字链表的表头节点。还可以把列表头和行表头节点组成数组,便于操作;
各种广义表示意图
2.6 树
2.6.1概述
树型结构是一类重要的非线性数据结构。其中以树和二叉树最为常用,直观看来,树是以分支关系定义的层次结构。
树是由一个或多个结点组成的有限集T,它满足以下两个条件:
I. 有一个特定的结点,成为根结点
II. 其余的结点分成m(m>=0)个互不相交的有限集T0,T1,…,Tm-1。其中每个集合又都是一棵树,称T0,T1,…,Tm-1为根结点的子树。
这里可以看出树的定义是递归的,即一棵树由子树构成,子树又由更小的子树构成。一个结点的子树数目,称为结点的度。树中各结点的度的最大值则称为树的度。树中结点的最大层次称为树的深度。
如果将树中结点的各子树看成从左到右是有次序的(即不能交换),则称该树为有序树,否则为无序树。森林是m(m>=0)棵互不相交的树的集合。
存储结构
树是非线性的结构,不能简单地用结点的线性表来表示。树有多种形式地存储结构,最常用的是标准存储形式和带逆存储形式。在树的标准存储结构中,树中的结点可分成两部分:结点的数据和指向子结点的指针。当程序需从结点返回到其父结点时,需要在树的结点中存储其父结点的位置信息,这种存储形式就是带逆存储结构。
具体使用的链表结构有:
◆双亲表示法:利用每个节点只有一个双亲的特点;求节点的孩子时要遍历整个向量
◆孩子表示法:把每个节点的孩子都排列起来,一单链表存储,则n个节点有n个孩子链表,而n个头指针又组成了一个线性表。
◆孩子兄弟表示法(又称二叉树表示法,或二叉链表表示法):节点两个指针分别指向该节点的第一个孩子和下一个兄弟节点。
树的遍历
在应用树结构时,常要求按某种次序获得树中全部结点的信息,这可通过树的遍历操作来实现。常用的树的遍历方法有:
树的前序遍历:首先访问根结点,然后从左到右遍历根结点的各棵子树。
树的后序遍历:首先从左到右按后序遍历根结点的各棵子树,然后访问根结点。
树的层次遍历:首先访问处于0层上的根结点,然后从左到右依次访问处于1层、2层……上的结点,即自上而下从左到右逐层访问树各层上的结点。
2.6.2二叉树
概述
与一般的树的结构比较,二叉树在结构上更规范和更有确定性,应用也比树更为广泛。
二叉树的特点是每个结点至多只有二棵子树(即二叉树中不存在度大于2的结点),并且,二叉树的子树有左右之分,其次序不能任意颠倒。二叉树与树不同的地方在于,首先二叉树可以为空,空的二叉树没有结点;另外,在二叉树中,结点的子树是有序的,分左右两棵子二叉树。
二叉树采用类似树的标准存储形式来存储。
二叉树的性质:
二叉树具有下列重要特性。
◆在二叉树的第i层至多有个结点(i>=1)。
◆深度为k的二叉树至多有-1个结点(k>=1)。
◆对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。
◆具有n个结点的完全二叉树的深度为。
二叉树的遍历
树的所有遍历方法都适用于二叉树,常用的二叉树遍历方法有3种。
#i nclude
#i nclude
#define NULL 0
Typedef struct node{
char data;
struct node *lchild,*rchild;
}TREENODE;
TREENODE *root;
前序遍历:
u 访问根结点,
u 按前序遍历根结点的左子树,
u 按前序遍历根结点的右子树。
中序遍历:
u 按中序遍历根结点的左子树,
u 访问根结点,
u 按中序遍历根结点的右子树。
中序遍历算法:
Void inorder(TREENODE *p)
{
if(p!=NULL)
{ inorder(p–>lchild);
printf(“%c”,p–>data)
inorder(p–>rchild);
}
}
后序遍历:
u 按后序遍历根结点的左子树,
u 按后序遍历根结点的右子树,
u 访问根结点。
以上3种遍历方法都是递归定义的。
哈夫曼及其应用:又称为最优树,是一类带权路径长度最短的树
路径长度:从树中一个节点到另一个节点之间的分支构成的这两个节点之间的路径,路径上的分支树木就称为路径长度;
树的路径长度:从树根到每一节点的路径长度之和;
树的带权路径长度:树中所有叶子节点的带权路径长度之和;
哈夫曼树就是一棵n个叶子节点的二叉树,所有叶子节点的带权之和最小。
算法描述:
给定n个节点的集合,每个节点都带权值;
选两个权值最小的节点构造一棵新的二叉树,新二叉树的根节点的权值就是两个子节点权值之和;
从n个节点中删除刚才使用的两个节点,同时将新产生的二叉树根节点放在节点集合中。
重复2,3步,知道只有一棵树为止。
例题:已知节点的前序序列和中序序列分别为:
前序序列:A B C D E F G
中序序列:C B E D A F G
求出整个二叉树,以及构造过程
2.7图
基本概念:
图是一种较线性表和树更为复杂的数据结构。在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
一个图G由非空有限的顶点集合V和有限的边的集合E组成,记为G=(V,E)。图一般分为两种类型。
无向图
无向图的边是顶点的无序偶,用(i,j)来表示顶点i和j之间的边。
有向图
有向图的边是顶点的有序偶,有向图的边也成为弧,用来表示顶点i和j之间的弧。
其中,有条边的无向图称为完全图,而具有n(n-1)条弧的有向图成为有向完全图。
有时图的边或弧具有与它相关的树,这种与图的边或弧相关的数称作权。带权图也简称为网。
如果同为无向图或同为有向图的两个图G1=(V1,E1)和G2=(V2,E2)满足
V2V1 E2E1
则称图G2是图G1的子图。
顶点的度就是指和顶点相关联的边的数目。在有向图中,以顶点v为头的弧的数据成为v的入度;以v为尾的弧成为v的出度。这里有一个重要的公式反映了顶点和边的关系。
其中,e表示边的数目,n表示顶点个数,TD(Vi)表示顶点Vi的度。
在图G=(V,E)中,如果存在顶点序列(v0 ,v1,…,vk),其中v0 =p,vk =q,且(v0 ,v1),(v1 ,v2)…,(vk-1 ,vk)都在E中,则称顶点p到顶点q有一条路径,并用(v0 ,v1,…,vk)表示这条路径,路径的长度就是路径上的边或弧的数目,这条路径的长度为k。 如果第一个顶点和最后一个顶点相同的路径称为回路或环。序列中顶点不重复出现的路径称为简单路径。
对无向图而言,如果从任意两个不同顶点i和j之间都有路径,则该无向图是连通的。无向图中的极大连通子图为该图的连通分量。
对有向图而言,如果任意两个不同顶点i到j有路径,同时j到i也有路径,则该有向图是强连通的。同样,无向图中的极大连通强子图为该图的强连通分量。
存储结构:最常用的存储结构是有两种。
邻接矩阵:
这是反映顶点间邻接关系的矩阵。定义如下:
设G=(V,E)是具有n(n≥1)个顶点的图,G的邻接矩阵M是一个n行n列的矩阵,若(i,j)或∈E,则M[i][j]=1;否则M[i][j]=0。
邻接表:这是图的链式存储结构。
图的每个顶点都建立了一个链表,且第i个链表中的结点代表与顶点i相关联的一条边或由顶点i出发的一条弧。而这些链表的头指针则构成一个顺序线性表。
另外,还有其他的一些存储结构,如十字链表和邻接多重表,分别用来存储有向图和无向图。
图的遍历:
图的遍历是指从图中的某个顶点出发,沿着图中的边或弧访问图中的每个顶点,并且每个顶点只被访问一次。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。
通常有两种方法,它们对无向图和有向图都适用。
深度优先搜索:
类似于树的先根遍历。
广度优先搜索:
类似于树的层次遍历。
这两种算法的时间复杂度相同,不同之处仅仅在于对顶点访问的顺序不同。
n 图的有关算法
涉及到图的有关算法比较多,这里只简单归纳介绍一下,具体算法希望大家参考有关资料。
u求最小代价生成树
设G=(V,E)是一个连通的无向图,若G1是包含G中所有顶点的一个无回路的连通子图,则称G1为G的一棵生成树。其中代价最小(各条边的权值之和最小)的生成树就称为最小代价生成树(简称最小生成树)。
这里提供两种算法来求解这一问题:普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法。分别适用于求边稠密的网的最小生成树和边稀疏的网的最小生成树,其时间复杂度分别是O(n2)和O(eloge)(e为网中边的数目)。
其中prim算法基本思想:任选一个顶点v0开始,连接与v0最近的顶点v1,得子树T1,再连接与T1最近的顶点v2,得子树T2,如此进行下去,直到所有顶点都用到为止。
L(v):v 到子树T0的直接距离。E是边集合。
输入加权连通图的带权邻接矩阵C=(Cij)n*n.
(1) T0<-空集,C(T0)<-0,V1={v0}
(2) 对每一点v属于V-V1,L(v)<-C(v, v0);[如果(v, v0)不属于E,则C(v, v0)=无穷大]
(3) 若V1=V,则输出T0,C(T0),停机。否则转到下一步;
(4) 在V-V1中找一点u,使L(u)=min{L(v)|v属于(V-V1)},并记在V1中与u相邻的点为w,e=(w,u)
(5) T0 <-T0,C(T0)<- C(T0)+ C(e), V1 <-V1
(6) 对所有的v属于V-V1,若C(v, u)
(7) 转3
克鲁斯卡尔(Kruskal)算法基本思想:最初把图的n个顶点看作n个分离的部分树,每个树具有一个顶点,算法的每一步选择可连接两分离树的边中权最小的边连接两个部分树,合二为一,部分树逐步减少,直到只有一个部分树,便得到最小生成树。
克鲁斯卡尔(Kruskal)算法步骤:
T0 :存放生成树的边的集合,初态为空;
C(T0):最小生成树的权,初值为0;
VS:部分树的顶点集合,其初值为{{v0}{ v1}……{ vn}}
输入边的端点数组A(e),边的权值w(e);
(1) T0 为空,C(T0)<-0;VS为空,将E中的边按从小到大的顺序排列成队列Q;
(2) 对所有的v属于V,VS〈-{v};
(3) 若|VS|=1,输出T0 ,C(T0) ,停止,否则转下一步;
(4) 从Q中取出排头边(u,v),并从Q中删除(u,v);
(5) 如u,v杂VS的同一个元素集V1 中,则转4,否则分属于两个几个V1 V2 ,进行下一步;
(6) T0<- T0 ,V<-, C(T0)<- C(T0)+ C(u,v),转3
u求最短路径
在图中求最短路径问题有两种提法,一是求从某个源点到其他顶点的最短路径,二是求每一对顶点之间的最短路径。
对于前者,一般采用迪杰斯特拉(Dijkstra)算法,按路径长度递增的次序产生最短路径。时间复杂度为O(n2)。
迪杰斯特拉(Dijkstra)算法的基本思想:生长一棵以v0为根的最短路树,在这棵树上每一顶点与根之间的路径都是最短路径。由于网络不存在负权,最短路树的生长过程中各顶点将按照距v0的远近及顶点的相邻关系,逐次长入树中,先近后远,直至所有顶点都已经在树中。
解决后面一种问题的方法是:每次以一个顶点为源点,重复执行迪杰斯特拉算法n次。另外还可以使用一种弗洛伊德(Floyd)算法,其时间复杂度也是O(n3)。
弗洛伊德(Floyd)算法基本思想:直接在图的带权邻接矩阵中用插入顶点的方法依次构造出n个矩阵,D(1)D(2)….D(n),使最后得到的矩阵.D(n)成为图的距离矩阵,同时也求出插入点矩阵以便得到两点见的最短路径。
u拓扑排序
拓扑排序的算法原理实际很简单。
I. 在有向图中选一个没有前驱的顶点且输出之
II. 从图中删除该顶点和所有以它为尾的弧
重复执行以上两步,直至全部顶点均已输出,或者当前图中不存在无前驱的顶点为止。后一种情况则说明有向图中存在环。
u求关键路径
在AOE网络中的某些活动可以并行的进行,因此完成工程的最少时间是从开始顶点到结束顶点的最长路径长度,称从开始顶点到结束顶点的最长路径为关键路径,关键路径上的活动即为关键活动。
3. 数据结构相关算法
3.1排序算法
基本概念
排序(Sorting)是计算机程序设计中的一种重要操作,其功能是对一个数据元素集合或序列重新排列成一个按数据元素某个项值有序的序列。作为排序依据的数据项称为“排序码”,也即数据元素的关键码。为了便于查找,通常希望计算机中的数据表是按关键码有序的。如有序表的折半查找,查找效率较高。还有,二叉排序树、B-树和B+树的构造过程就是一个排序过程。若关键码是主关键码,则对于任意待排序序列,经排序后得到的结果是唯一的;若关键码是次关键码,排序结果可能不唯一,这是因为具有相同关键码的数据元素,这些元素在排序结果中,它们之间的的位置关系与排序前不能保持。
若对任意的数据元素序列,使用某个排序方法,对它按关键码进行排序:若相同关键码元素间的位置关系,排序前与排序后保持一致,称此排序方法是稳定的;而不能保持一致的排序方法则称为不稳定的。
排序分为两类:内排序和外排序。
内排序:指待排序列完全存放在内存中所进行的排序过程,适合不太大的元素序列。
外排序:指排序过程中还需访问外存储器,足够大的元素序列,因不能完全放入内存,只能使用外排序。
对于有n个结点的线性表(e0,e1,…,en-1),将结点中某些数据项的值按递增或递减的次序,重新排列线性表结点的过程,称为排序。排序时参照的数据项称为排序码,通常选择结点的键值作为排序码。
若线性表中排序码相等的结点经某种排序方法进行排序后,仍能保持它们在排序之前的相对次序,称这种排序方法是稳定的;否则,称这种排序方法是不稳定的。
在排序过程中,线性表的全部结点都在内存,并在内存中调整它们在线性表中的存储顺序,称为内排序。在排序过程中,线性表只有部分结点被调入内存,并借助内存调整结点在外存中的存放顺序的排序方法成为外排序。
下面通过一个表格简单介绍几种常见的内排序方法,以及比较一下它们之间的性能特点。
排序方法
简介
平均时间
最坏情况
辅助存储
是否稳定
简单排序
选择排序
反复从还未排好序的那部分线性表中选出键值最小的结点,并按从线性表中选出的顺序排列结点,重新组成线性表。直至未排序的那部分为空,则重新形成的线性表是一个有序的线性表。(单选择排序)
O()
O()
O(1)
不稳定
直接插入排序
假设线性表的前面I个结点序列e0,e1,…,eI-1是已排序的。对结点在这有序结点ei序列中找插入位置,并将ei插入,而使i+1个结点序列e0,e1,…,ei也变成排序的。依次对i=1,2,…,n-1分别执行这样的插入步骤,最终实现线性表的排序。
O()
O()
O(1)
稳定
冒泡排序
对当前还未排好序的范围内的全部结点,自上而下对相邻的两个结点依次进行比较和调整,让键值大的结点往下沉,键值小的结点往上冒。即,每当两相邻比较后发现它们的排列顺序与排序要求相反时,就将它们互换。
O()
O()
O(1)
稳定
希尔排序
对直接插入排序一种改进,又称“缩小增量排序”。先将整个待排序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。,如果待排序记录序列为“正序“时,复杂度可达到O(n)
kn ln n
O()
O(logn)
不稳定
快速排序
对冒泡排序的一种本质的改进。通过一趟扫视后,使待排序序列的长度能大幅度的减少。在一趟扫视后,使某个结点移到中间的正确位置,并使在它左边序列的结点的键值都比它的小,而它右边序列的结点的键值都不比它的小。称这样一次扫视为“划分”。每次划分使一个长序列变成两个新的较小子序列,对这两个小的子序列分别作同样的划分,直至新的子序列的长度为1使才不再划分。当所有子序列长度都为1时,序列已是排好序的了。
O(nlogn)
O()
O(logn)
不稳定
堆排序(有两种情况:堆顶元素是最大值和堆顶元素是最小值)
一种树形选择排序,是对直接选择排序的有效改进。一个堆是这样一棵顺序存储的二叉树,它的所有父结点(e[i])的键值均不小于它的左子结点(e[2*i+1])和右子结点(e[2*i+2])的键值。初始时,若把待排序序列的n个结点看作是一棵顺序存储的二叉树,调整它们的存储顺序,使之成为一个堆,这时堆的根结点键值是最大者。然后将根结点与堆的最后一个结点交换,并对少了一个结点后的n-1结点重新作调整,使之再次成为堆。这样,在根结点得到结点序列键值次最大值。依次类推,直到只有两个结点的堆,并对它们作交换,最后得到有序的n个结点序列。
O(nlogn)
O(nlogn)
O(1)
不稳定
归并排序
将两个或两个以上的有序子表合并成一个新的有序表。对于两个有序子表合并一个有序表的两路合并排序来说,初始时,把含n个结点的待排序序列看作有n个长度都为1的有序子表所组成,将它们依次两两合并得到长度为2的若干有序子表,再对它们作两两合并……直到得到长度为n的有序表,排序即告完成。
O(nlogn)
O(nlogn)
O(n)
稳定
后面根据各种排序算法,给出了C语言的实现,大家在复习的时候可以做下参考。
u 选择排序
void ss_sort(int e[], int n)
{ int i, j, k, t;
for(i=0; i< n-1; i++) {
for(k=i, j=i+1; j
if(e[k]>e[j]) k=j;
if(k!=i) {
t=e[i]; e[i]=e[k]; e[k]=t;
}
}
}
u 直接插入排序
void si_sort(int e[], int n)
{ int i, j, t;
for(i=0; i< n; i++) {
for(t=e[i], j=i-1; j>=0&&t
e[j+1]=e[j];
e[j+1]=t;
}
}
u 冒泡排序
void sb_sort(int e[], int n)
{ int j, p, h, t;
for(h=n-1; h>0; h=p) {
for(p=j=0; j
if(e[j]>e[j+1]) {
t=e[j]; e[j]=e[j+1]; e[j+1]=t;
p=j;
}
}
}
u 希尔排序
void shell(int e[], int n)
{ int j, k, h, y;
for(h=n/2; h>0; h=h/2)
for(j=h; j
y=e[j];
for(k=j-h; k>0&&y
e[k+h]=e[k];
e[k+h]=y;
}
}
u 堆排序
void sift(e, n, s)
int e[];
int n;
int s;
{ int t, k, j;
t=e[s];
k=s; j=2*k+1;
while(j
if(j
j++;
if(t
e[k]=e[j];
k=j;
j=2*k+1;
}else break;
}
e[k]=t;
}
void heapsorp (int e[], int n)
{ int i, k, t;
for(i=n/2-1; i>=0; i--)
sift(e, n, i);
for(k=n-1; k>=1; k--) {
t=e[0]; e[0]=e[k]; e[k]=t;
sift(e, k, 0);
}
}
u 快速排序
void r_quick(int e[], int low, int high)
{ int i, j, t;
if(low
i=low; j=high; t=e[low];
while(i
while (it) j--;
if(i
while (i
if(I
}
e[i]=t;
r_quick(e,low,i-1);
r_quick(w,i+1,high);
}
}
另外,外排序是对大型文件的排序,待排序的记录存储在外存中,在排序过程中,内存只存储文件的一部分记录,整个排序过程需进行多次的内外存间的交换。
直接插入排序算法分析:
设有n个记录,存放在数组r中,重新安排记录在数组中的存放顺序,使得按关键码有序。即r[1].key≤r[2].key≤……≤r[n].key
先来看看向有序表中插入一个记录的方法:
设1<j≤n,r[1].key≤r[2].key≤……≤r[j-1].key,将r[j]插入,重新安排存放顺序,使得r[1].key≤r[2].key≤……≤r[j].key,得到新的有序表,记录数增1。
【算法10.1】
① r[0]=r[j]; //r[j]送r[0]中,使r[j]为待插入记录空位
i=j-1; //从第i个记录向前测试插入位置,用r[0]为辅助单元, 可免去测试i<1。
② 若r[0].key≥r[i].key,转④。 //插入位置确定
③ 若r[0].key < r[i].key时,
r[i+1]=r[i];i=i-1;转②。 //调整待插入位置
④ r[i+1]=r[0];结束。 //存放待插入记录
直接插入排序方法:仅有一个记录的表总是有序的,因此,对n个记录的表,可从第二个记录开始直到第n个记录,逐个向有序表中进行插入操作,从而得到n个记录按关键码有序的表。
【算法10.2】
void InsertSort(S_TBL &p)
{ for(i=2;i<=p->length;i++)
if(p->elem[i].key < p->elem[i-1].key) /*小于时,需将elem[i]插入有序表*/
{ p->elem[0].key=p->elem[i].key; /*为统一算法设置监测*/
for(j=i-1;p->elem[0].key < p->elem[j].key;j--)
p->elem[j+1].key=p->elem[j].key; /*记录后移*/
p->elem[j+1].key=p->elem[0].key; /*插入到正确位置*/
}
}
【效率分析】
空间效率:仅用了一个辅助单元。
时间效率:向有序表中逐个插入记录的操作,进行了n-1趟,每趟操作分为比较关键码和移动记录,而比较的次数和移动记录的次数取决于待排序列按关键码的初始排列。
最好情况下:即待排序列已按关键码有序,每趟操作只需1次比较2次移动。
总比较次数=n-1次
总移动次数=2(n-1)次
最坏情况下:即第j趟操作,插入记录需要同前面的j个记录进行j次关键码比较,移动记录的次数为j+2次。
平均情况下:即第j趟操作,插入记录大约同前面的j/2个记录进行关键码比较,移动记录的次数为j/2+2次。
由此,直接插入排序的时间复杂度为O(n2)。是一个稳定的排序方法。
折半插入排序算法分析:
直接插入排序的基本操作是向有序表中插入一个记录,插入位置的确定通过对有序表中记录按关键码逐个比较得到的。平均情况下总比较次数约为n2/4。既然是在有序表中确定插入位置,可以不断二分有序表来确定插入位置,即一次比较,通过待插入记录与有序表居中的记录按关键码比较,将有序表一分为二,下次比较在其中一个有序子表中进行,将子表又一分为二。这样继续下去,直到要比较的子表中只有一个记录时,比较一次便确定了插入位置。
二分判定有序表插入位置方法:
① low=1;high=j-1;r[0]=r[j]; // 有序表长度为j-1,第j个记录为待插入记录
//设置有序表区间,待插入记录送辅助单元
② 若low>high,得到插入位置,转⑤
③ low≤high,m=(low+high)/2; // 取表的中点,并将表一分为二,确定待插入区间*/
④ 若r[0].key
否则,low=m+1; // 插入位置在高半区
转②
⑤ high+1即为待插入位置,从j-1到high+1的记录,逐个后移,r[high+1]=r[0];放置待插入记录。
【算法10.3】
void InsertSort(S_TBL *s)
{ /* 对顺序表s作折半插入排序 */
for(i=2;i<=s->length;i++)
{ s->elem[0]=s->elem[i]; /* 保存待插入元素 */
low=i;high=i-1; /* 设置初始区间 */
while(low<=high) /* 该循环语句完成确定插入位置 */
{ mid=(low+high)/2;
if(s->elem[0].key>s->elem[mid].key)
low=mid+1; /* 插入位置在高半区中 */
else high=mid-1; /* 插入位置在低半区中 */
}/* while */
for(j=i-1;j>=high+1;j--) /* high+1为插入位置 */
s->elem[j+1]=s->elem[j]; /* 后移元素,留出插入空位 */
s->elem[high+1]=s->elem[0]; /* 将元素插入 */
}/* for */
}/* InsertSort */
【时间效率】
确定插入位置所进行的折半查找,关键码的比较次数至多为 ,次,移动记录的次数和直接插入排序相同,故时间复杂度仍为O(n2)。是一个稳定的排序方法。
表插入排序算法分析:
直接插入排序、折半插入排序均要大量移动记录,时间开销大。若要不移动记录完成排序,需要改变存储结构,进行表插入排序。所谓表插入排序,就是通过链接指针,按关键码的大小,实现从小到大的链接过程,为此需增设一个指针项。操作方法与直接插入排序类似,所不同的是直接插入排序要移动记录,而表插入排序是修改链接指针。用静态链表来说明。
#define SIZE 200
typedef struct{
ElemType elem; /*元素类型*/
int next; /*指针项*/
}NodeType; /*表结点类型*/
typedef struct{
NodeType r[SIZE]; /*静态链表*/
int length; /*表长度*/
}L_TBL; /*静态链表类型*/
假设数据元素已存储在链表中,且0号单元作为头结点,不移动记录而只是改变链指针域,将记录按关键码建为一个有序链表。首先,设置空的循环链表,即头结点指针域置0,并在头结点数据域中存放比所有记录关键码都大的整数。接下来,逐个结点向链表中插入即可。
表插入排序得到一个有序的链表,查找则只能进行顺序查找,而不能进行随机查找,如折半查找。为此,还需要对记录进行重排。
重排记录方法:按链表顺序扫描各结点,将第i个结点中的数据元素调整到数组的第i个分量数据域。因为第i个结点可能是数组的第j个分量,数据元素调整仅需将两个数组分量中数据元素交换即可,但为了能对所有数据元素进行正常调整,指针域也需处理。
【算法10.3】
1. j=l->r[0].next;i=1; //指向第一个记录位置,从第一个记录开始调整
2. 若i=l->length时,调整结束;否则,
a. 若i=j,j=l->r[j].next;i++;转(2) //数据元素应在这分量中,不用调整,处理下一个结点
b. 若j>i,l->r[i].elem<-->l->r[j].elem; //交换数据元素
p=l->r[j].next; // 保存下一个结点地址
l->r[j].next=l->[i].next;l->[i].next=j; // 保持后续链表不被中断
j=p;i++;转(2) // 指向下一个处理的结点
c. 若jr[j].next;//j分量中原记录已移走,沿j的指针域找寻原记录的位置
转到(a)
【时效分析】
表插入排序的基本操作是将一个记录插入到已排好序的有序链表中,设有序表长度为i,则需要比较至多i+1次,修改指针两次。因此,总比较次数与直接插入排序相同,修改指针总次数为2n次。所以,时间复杂度仍为O(n2)
希尔排序(Shell’s Sort)算法分析:
希尔排序又称缩小增量排序,是1959年由D.L.Shell提出来的,较前述几种插入排序方法有较大的改进。
直接插入排序算法简单,在n值较小时,效率比较高,在n值很大时,若序列按关键码基本有序,效率依然较高,其时间效率可提高到O(n)。希尔排序即是从这两点出发,给出插入排序的改进方法。
希尔排序方法:
1. 选择一个步长序列t1,t2,…,tk,其中ti>tj,tk=1;
2. 按步长序列个数k,对序列进行k趟排序;
3. 每趟排序,根据对应的步长ti,将待排序列分割成若干长度为m的子序列,分别对各子表进行直接插入排序。仅步长因子为1时,整个序列作为一个表来处理,表长度即为整个序列的长度。
【例10.4】待排序列为 39,80,76,41,13,29,50,78,30,11,100,7,41,86。
步长因子分别取5、3、1,则排序过程如下:
p=5 39 80 76 41 13 29 50 78 30 11 100 7 41 86
└─────────┴─────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┘
子序列分别为{39,29,100},{80,50,7},{76,78,41},{41,30,86},{13,11}。
第一趟排序结果:
p=3 29 7 41 30 11 39 50 76 41 13 100 80 78 86
└─────┴─────┴─────┴──────┘
└─────┴─────┴─────┴──────┘
└─────┴─────┴──────┘
子序列分别为{29,30,50,13,78},{7,11,76,100,86},{41,39,41,80}。
第二趟排序结果:
p=1 13 7 39 29 11 41 30 76 41 50 86 80 78 100
此时,序列基本“有序”,对其进行直接插入排序,得到最终结果:
7 11 13 29 30 39 41 41 50 76 78 80 86 100
【算法10.5】
void ShellInsert(S_TBL &p,int dk)
{ /*一趟增量为dk的插入排序,dk为步长因子*/
for(i=dk+1;i<=p->length;i++)
if(p->elem[i].key < p->elem[i-dk].key) /*小于时,需elem[i]将插入有序表*/
{ p->elem[0]=p->elem[i]; /*为统一算法设置监测*/
for(j=i-dk;j>0&&p->elem[0].key < p->elem[j].key;j=j-dk)
p->elem[j+dk]=p->elem[j]; /*记录后移*/
p->elem[j+dk]=p->elem[0]; /*插入到正确位置*/
}
}
void ShellSort(S_TBL *p,int dlta[],int t)
{ /*按增量序列dlta[0,1…,t-1]对顺序表*p作希尔排序*/
for(k=0;k
ShellSort(p,dlta[k]); /*一趟增量为dlta[k]的插入排序*/
}
【时效分析】
希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于步长因子序列的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。目前还没有人给出选取最好的步长因子序列的方法。步长因子序列可以有各种取法,有取奇数的,也有取质数的,但需要注意:步长因子中除1外没有公因子,且最后一个步长因子必须为1。希尔排序方法是一个不稳定的排序方法。
冒泡排序(Bubble Sort)算法分析:
先来看看待排序列一趟冒泡的过程:设1
通过两两比较、交换,重新安排存放顺序,使得r[j]是序列中关键码最大的记录。一趟冒泡方法为:
① i=1; //设置从第一个记录开始进行两两比较
② 若i≥j,一趟冒泡结束。
③ 比较r[i].key与r[i+1].key,若r[i].key≤r[i+1].key,不交换,转⑤
④ 当r[i].key>r[i+1].key时, r[0]=r[i];r[i]=r[i+1];r[i+1]=r[0];
将r[i]与r[i+1]交换
⑤ i=i+1; 调整对下两个记录进行两两比较,转②
冒泡排序方法:对n个记录的表,第一趟冒泡得到一个关键码最大的记录r[n],第二趟冒泡对n-1个记录的表,再得到一个关键码最大的记录r[n-1],如此重复,直到n个记录按关键码有序的表。
【算法10.6】
① j=n; //从n记录的表开始
② 若j<2,排序结束
③ i=1; //一趟冒泡,设置从第一个记录开始进行两两比较,
④ 若i≥j,一趟冒泡结束,j=j-1;冒泡表的记录数-1,转②
⑤ 比较r[i].key与r[i+1].key,若r[i].key≤r[i+1].key,不交换,转⑤
⑥ 当r[i].key>r[i+1].key时, r[i]<-->r[i+1]; 将r[i]与r[i+1]交换
⑦ i=i+1; 调整对下两个记录进行两两比较,转④
【效率分析】
空间效率:仅用了一个辅助单元。
时间效率:总共要进行n-1趟冒泡,对j个记录的表进行一趟冒泡需要j-1次关键码比较。
移动次数:
最好情况下:待排序列已有序,不需移动。
快速排序:
快速排序是通过比较关键码、交换记录,以某个记录为界(该记录称为支点),将待排序列分成两部分。其中,一部分所有记录的关键码大于等于支点记录的关键码,另一部分所有记录的关键码小于支点记录的关键码。我们将待排序列按关键码以支点记录分成两部分的过程,称为一次划分。对各部分不断划分,直到整个序列按关键码有序。
【算法10.8】
void QSort(S_TBL *tbl,int low,int high) /*递归形式的快排序*/
{ /*对顺序表tbl中的子序列tbl->[low…high]作快排序*/
if(low
{ pivotloc=partition(tbl,low,high); /*将表一分为二*/
QSort(tbl,low,pivotloc-1); /*对低子表递归排序*/
QSort(tbl,pivotloc+1,high); /*对高子表递归排序*/
}
}
【效率分析】
空间效率:快速排序是递归的,每层递归调用时的指针和参数均要用栈来存放,递归调用层次数与上述二叉树的深度一致。因而,存储开销在理想情况下为O(log2n),即树的高度;在最坏情况下,即二叉树是一个单链,为O(n)。
时间效率:在n个记录的待排序列中,一次划分需要约n次关键码比较,时效为O(n),若设T(n)为对n个记录的待排序列进行快速排序所需时间。
理想情况下:每次划分,正好将分成两个等长的子序列,则
T(n)≤cn+2T(n/2) c是一个常数
≤cn+2(cn/2+2T(n/4))=2cn+4T(n/4)
≤2cn+4(cn/4+T(n/8))=3cn+8T(n/8)
······
≤cnlog2n+nT(1)=O(nlog2n)
最坏情况下:即每次划分,只得到一个子序列,时效为O(n2)。
快速排序是通常被认为在同数量级(O(nlog2n))的排序方法中平均性能最好的。但若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序。为改进之,通常以“三者取中法”来选取支点记录,即将排序区间的两个端点与中点三个记录关键码居中的调整为支点记录。快速排序是一个不稳定的排序方法。
选择排序
选择排序主要是每一趟从待排序列中选取一个关键码最小的记录,也即第一趟从n个记录中选取关键码最小的记录,第二趟从剩下的n-1个记录中选取关键码最小的记录,直到整个序列的记录选完。这样,由选取记录的顺序,便得到按关键码有序的序列。
简单选择排序:
操作方法:第一趟,从n个记录中找出关键码最小的记录与第一个记录交换;第二趟,从第二个记录开始的n-1个记录中再选出关键码最小的记录与第二个记录交换;如此,第i趟,则从第i个记录开始的n-i+1个记录中选出关键码最小的记录与第i个记录交换,直到整个序列按关键码有序。
【算法10.9】
void SelectSort(S_TBL *s)
{ for(i=1;ilength;i++)
{ /* 作length-1趟选取 */
for(j=i+1,t=i;j<=s->length;j++)
{ /* 在i开始的length-n+1个记录中选关键码最小的记录 */
if(s->elem[t].key>s->elem[j].key)
t=j; /* t中存放关键码最小记录的下标 */
}
s->elem[t]<-->s->elem[i]; /* 关键码最小的记录与第i个记录交换 */
}
}
从程序中可看出,简单选择排序移动记录的次数较少,但关键码的比较次数依然是
堆排序(Heap Sort):设有n个元素的序列 k1,k2,…,kn,当且仅当满足下述关系之一时,称之为堆。
若以一维数组存储一个堆,则堆对应一棵完全二叉树,且所有非叶结点的值均不大于(或不小于)其子女的值,根结点的值是最小(或最大)的。
设有n个元素,将其按关键码排序。首先将这n个元素按关键码建成堆,将堆顶元素输出,得到n个元素中关键码最小(或最大)的元素。然后,再对剩下的n-1个元素建成堆,输出堆顶元素,得到n个元素中关键码次小(或次大)的元素。如此反复,便得到一个按关键码有序的序列。称这个过程为堆排序。
因此,实现堆排序需解决两个问题:
1. 如何将n个元素的序列按关键码建成堆;
2. 输出堆顶元素后,怎样调整剩余n-1个元素,使其按关键码成为一个新堆。
首先,讨论输出堆顶元素后,对剩余元素重新建成堆的调整过程。
调整方法:设有m个元素的堆,输出堆顶元素后,剩下m-1个元素。将堆底元素送入堆顶,堆被破坏,其原因仅是根结点不满足堆的性质。将根结点与左、右子女中较小(或小大)的进行交换。若与左子女交换,则左子树堆被破坏,且仅左子树的根结点不满足堆的性质;若与右子女交换,则右子树堆被破坏,且仅右子树的根结点不满足堆的性质。继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。称这个自根结点到叶子结点的调整过程为筛选。
再讨论对n个元素初始建堆的过程。
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。n个结点的完全
子树成为堆,之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
堆排序:对n个元素的序列进行堆排序,先将其建成堆,以根结点与第n个结点交换;调整前n-1个结点成为堆,再以根结点与第n-1个结点交换;重复上述操作,直到整个序列有序。
【算法10.10】
void HeapAdjust(S_TBL *h,int s,int m)
{/*r[s…m]中的记录关键码除r[s]外均满足堆的定义,本函数将对第s个结点为根的子树筛选,使其成为大顶堆*/
rc=h->r[s];
for(j=2*s;j<=m;j=j*2) /* 沿关键码较大的子女结点向下筛选 */
{ if(jr[j].keyr[j+1].key)
j=j+1; /* 为关键码较大的元素下标*/
if(rc.keyr[j].key) break; /* rc应插入在位置s上*/
h->r[s]=h->r[j]; s=j; /* 使s结点满足堆定义 */
}
h->r[s]=rc; /* 插入 */
}
void HeapSort(S_TBL *h)
{ for(i=h->length/2;i>0;i--) /* 将r[1..length]建成堆 */
HeapAdjust(h,i,h->length);
for(i=h->length;i>1;i--)
{ h->r[1]<-->h->r[i]; /* 堆顶与堆低元素交换 */
HeapAdjust(h,1,i-1); /*将r[1..i-1]重新调整为堆*/
}
}
交换记录至多k次。所以,在建好堆后,排序过程中的筛选次数不超过下式:
( log2(n1) + log2(n2) + … + log22 ) < nlog2n
而建堆时的比较次数不超过4n次,因此堆排序最坏情况下,时间复杂度也为O(nlog2n)。
二路归并排序
二路归并排序的基本操作是将两个有序表合并为一个有序表。
设r[u…t]由两个有序子表r[u…v-1]和r[v…t]组成,两个子表长度分别为v-u、t-v+1。合并方法为:
⑴ i=u;j=v;k=u; //置两个子表的起始下标及辅助数组的起始下标
⑵ 若i>v 或 j>t,转⑷ //其中一个子表已合并完,比较选取结束
⑶ //选取r[i]和r[j]关键码较小的存入辅助数组rf
如果r[i].key
否则,rf[k]=r[j]; j++; k++; 转⑵
⑷ //将尚未处理完的子表中元素存入rf
如果i
如果j<=t,将r[i…v]存入rf[k…t] //后一子表非空
⑸ 合并结束。
【算法10.11】
void Merge(ElemType *r,ElemType *rf,int u,int v,int t)
{
for(i=u,j=v,k=u;i
{ if(r[i].key
{ rf[k]=r[i];i++;}
else
{ rf[k]=r[j];j++;}
}
if(i
if(j<=t) rf[k…t]=r[j…t];
}
两路归并的迭代算法:1个元素的表总是有序的。所以对n个元素的待排序列,每个元素可看成1个有序子表长度均为2。再进行两两合并,直到生成n个元素按关键码有序的表。
【算法10.12】
void MergeSort(S_TBL *p,ElemType *rf)
{ /*对*p表归并排序,*rf为与*p表等长的辅助数组*/
ElemType *q1,*q2;
q1=rf;q2=p->elem;
for(len=1;lenlength;len=2*len) /*从q2归并到q1*/
{ for(i=1;i+2*len-1<=p->length;i=i+2*len)
Merge(q2,q1,i,i+len,i+2*len-1); /*对等长的两个子表合并*/
if(i+len-1length)
Merge(q2,q1,i,i+len,p->length); /*对不等长的两个子表合并*/
else if(i<=p->length)
while(i<=p->length) /*若还剩下一个子表,则直接传入*/
q1[i]=q2[i];
q1<-->q2; /*交换,以保证下一趟归并时,仍从q2归并到q1*/
if(q1!=p->elem) /*若最终结果不在*p表中,则传入之*/
for(i=1;i<=p->length;i++)
p->elem[i]=q1[i];
}
}
两路归并的递归算法:
【算法10.13】
void MSort(ElemType *p,ElemType *p1,int s,int t)
{ /*将p[s…t]归并排序为p1[s…t]*/
if(s==t) p1[s]=p[s]
else
{ m=(s+t)/2; /*平分*p表*/
MSort(p,p2,s,m); /*递归地将p[s…m]归并为有序的p2[s…m]*/
MSort(p,p2,m+1,t); /*递归地将p[m+1…t]归并为有序的p2[m+1…t]*/
Merge(p2,p1,s,m+1,t); /*将p2[s…m]和p2[m+1…t]归并到p1[s…t]*/
}
}
void MergeSort(S_TBL *p)
{ /*对顺序表*p作归并排序*/
MSort(p->elem,p->elem,1,p->length);
}
【效率分析】
需要一个与表等长的辅助元素数组空间,所以空间复杂度为O(n)。
对n个元素的表,将这n个元素看作叶结点,若将两两归并生成的子表看作它们的父结点,则归并过程对应由叶向根生成一棵二叉树的过程。所以归并趟数约等于二叉树的高度-1,即log2n,每趟归并需移动记录n次,故时间复杂度为O(nlog2n)。
基数排序:
基数排序是一种借助于多关键码排序的思想,是将单关键码按基数分成“多关键码”进行排序的方法。
多关键码排序:
设n个元素的待排序列包含d个关键码{k1,k2,…,kd},则称序列对关键码{k1,k2,…,kd}有序是指:对于序列中任两个记录r[i]和r[j](1≤i≤j≤n)都满足下列有序关系:
其中k1称为最主位关键码,kd称为最次位关键码。
多关键码排序按照从最主位关键码到最次位关键码或从最次位到最主位关键码的顺序逐次排序,分两种方法:
最高位优先(Most Significant Digit first)法,简称MSD法:先按k1排序分组,同一组中记录,关键码k1相等,再对各组按k2排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码kd对各子组排序后。再将各组连接起来,便得到一个有序序列。扑克牌按花色、面值排序中介绍的方法一即是MSD法。
最低位优先(Least Significant Digit first)法,简称LSD法:先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便得到一个有序序列。扑克牌按花色、面值排序中介绍的方法二即是LSD法。
链式基数排序:
将关键码拆分为若干项,每项作为一个“关键码”,则对单关键码的排序可按多关键码排序方法进行。比如,关键码为4位的整数,可以每位对应一项,拆分成4项;又如,关键码由5个字符组成的字符串,可以每个字符作为一个关键码。由于这样拆分后,每个关键码都在相同的范围内(对数字是0~9,字符是'a'~'z'),称这样的关键码可能出现的符号个数为“基”,记作RADIX。上述取数字为关键码的“基”为10;取字符为关键码的“基”为26。基于这一特性,用LSD法排序较为方便。
基数排序:从最低位关键码起,按关键码的不同值将序列中的记录“分配”到RADIX个队列中,然后再“收集”之。如此重复d次即可。链式基数排序是用RADIX个链队列作为分配队列,关键码相同的记录存入同一个链队列中,收集则是将各链队列按关键码大小顺序链接起来。
【算法10.14】
#define MAX_KEY_NUM 8 /*关键码项数最大值*/
#define RADIX 10 /*关键码基数,此时为十进制整数的基数*/
#define MAX_SPACE 1000 /*分配的最大可利用存储空间*/
typedef struct{
KeyType keys[MAX_KEY_NUM]; /*关键码字段*/
InfoType otheritems; /*其它字段*/
int next; /*指针字段*/
}NodeType; /*表结点类型*/
typedef struct{
NodeType r[MAX_SPACE]; /*静态链表,r[0]为头结点*/
int keynum; /*关键码个数*/
int length; /*当前表中记录数*/
}L_TBL; /*链表类型*/
typedef int ArrayPtr[radix]; /*数组指针,分别指向各队列*/
void Distribute(NodeType *s,int i,ArrayPtr *f,ArrayPtr *e)
{ /*静态链表ltbl的r域中记录已按(kye[0],keys[1],…,keys[i-1])有序)*/
/*本算法按第i个关键码keys[i]建立RADIX个子表,使同一子表中的记录的keys[i]相同*/
/*f[0…RADIX-1]和e[0…RADIX-1]分别指向各子表的第一个和最后一个记录*/
for(j=0;j
for(p=r[0].next;p;p=r[p].next)
{ j=ord(r[p].keys[i]); /*ord将记录中第i个关键码映射到[0…RADIX-1]*/
if(!f[j]) f[j]=p;
else r[e[j]].next=p;
e[j]=p; /* 将p所指的结点插入到第j个子表中*/
}
}
void Collect(NodeType *r,int i,ArrayPtr f,ArrayPtr e)
{/*本算法按keys[i]自小到大地将f[0…RADIX-1]所指各子表依次链接成一个链表*e[0…RADIX-1]为各子表的尾指针*/
for(j=0;!f[j];j=succ(j)); /*找第一个非空子表,succ为求后继函数*/
r[0].next=f[j];t=e[j]; /*r[0].next指向第一个非空子表中第一个结点*/
while(j
{ for(j=succ(j);j
if(f[j]) {r[t].next=f[j];t=e[j];} /*链接两个非空子表*/
}
r[t].next=0; /*t指向最后一个非空子表中的最后一个结点*/
}
void RadixSort(L_TBL *ltbl)
{ /*对ltbl作基数排序,使其成为按关键码升序的静态链表,ltbl->r[0]为头结点*/
for(i=0;ilength;i++) ltbl->r[i].next=i+1;
ltbl->r[ltbl->length].next=0; /*将ltbl改为静态链表*/
for(i=0;ikeynum;i++) /*按最低位优先依次对各关键码进行分配和收集*/
{ Distribute(ltbl->r,i,f,e); /*第i趟分配*/
Collect(ltbl->r,i,f,e); /*第i趟收集*/
}
}
【效率分析】
时间效率:设待排序列为n个记录,d个关键码,关键码的取值范围为radix,则进行链式基数排序的时间复杂度为O(d(n+radix)),其中,一趟分配时间复杂度为O(n),一趟收集时间复杂度为O(radix),共进行d趟分配和收集。
空间效率:需要2*radix个指向队列的辅助空间,以及用于静态链表的n个指针。
外排序
外部排序的方法
外部排序基本上由两个相互独立的阶段组成。首先,按可用内存大小,将外存上含n个记录的文件分成若干长度为k的子文件或段(segment),依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存。通常称这些有序子文件为归并段或顺串;然后,对这些归并段进行逐趟归并,使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止。
显然,第一阶段的工作已经讨论过。以下主要讨论第二阶段即归并的过程。先从一个例子来看外排序中的归并是如何进行的?
假设有一个含 10000 个记录的文件,首先通过10次内部排序得到10个初始归并段 R1~R10 ,其中每一段都含1000个记录。然后对它们作如图10.11所示的两两归并,直至
得到一个有序文件为止。
将两个有序段归并成一个有序段的过程,若在内存中进行,则很简单,前面讨论的2-路归并排序中的Merge函数便可实现此归并。但是,在外部排序中实现两两归并时,不仅要调用Merge函数,而且要进行外存的读/写,这是由于我们不可能将两个有序段及归并结果同时放在内存中的缘故。对外存上信息的读/写是以“物理块”为单位。假设在上例中每个物理块可以容纳200个记录,则每一趟归并需进行50次“读”和50次“写”,四趟归并加上内部排序时所需进行的读/写,使得在外排序中总共需进行500次的读/写。
一般情况下,外部排序所需总时间=内部排序(产生初始归并段)所需时间 m*tis+外存信息读写的时间 d*tio +内部归并排序所需时间 s*utmg
其中:tis是为得到一个初始归并段进行的内部排序所需时间的均值;tio是进行一次外存读/写时间的均值;utmg是对u个记录进行内部归并所需时间;m为经过内部排序之后得到的初始归并段的个数;s为归并的趟数;d为总的读/写次数。由此,上例10000个记录利用2-路归并进行排序所需总的时间为:
10*tis+500*tio+4*10000tmg
其中tio取决于所用的外存设备,显然,tio较tmg要大的多。因此,提高排序效率应主要着眼于减少外存信息读写的次数d。
3.2查找算法
查找就是在按某种数据结构形式存储的数据集合中,找出满足指定条件的结点。
按查找的条件分类:
u 有按结点的关键码查找;
u 关键码以外的其他数据项查找;
u 其他数据项的组合查找;
按查找数据在内存或外存:分内存查找和外存查找。
按查找目的:
u 查找如果只是为了确定指定条件的结点存在与否,成为静态查找;
u 查找是为确定结点的插入位置或为了删除找到的结点,称为动态查找。
这里简单介绍几种常见的查找方法。
u 顺序存储线性表的查找
这是最常见的查找方式。结点集合按线性表组织,采用顺序存储方式,结点只含关键码,并且是整数。如果线性表无序,则采用顺序查找,即从线性表的一端开始逐一查找。而如果线性表有序,则可以使用顺序查找、二分法查找或插值查找。
u 分块查找
分块查找的过程分两步,先用二分法在索引表中查索引项,确定要查的结点在哪一块。然后,再在相应块内顺序查找。
u 链接存储线性表的查找
对于链接存储线性表的查找只能从链表的首结点开始顺序查找。同样对于无序的链表和有序的链表查找方法不同。
动态查找表的不同表示方法:
二叉排序树(二叉查找树):或者是一棵空树,或者是具有下列性质的一棵树:
u 若左子树不空,则左子树上的所有节点的值都小于根节点的值;
u 若右子树不空,则右子树上的所有节点的值都大于根节点的值;
u 它的左右子树也分别是二叉排序树;
二叉排序树的查找分析:在随机的情况下,其平均查找长度为1+4logn;
平衡二叉树:或者是棵空树,或者是具有下列性质的二叉树:
u 它的左子树和右子树都是平衡二叉树;
u 左子树和右子树的深度之差不会超过1;
平衡二叉树的查找分析:在查找过程中和给定值进行比较的关键字个数不超过树的深度,,因此其平均查找的时间复杂度是O(logn);
u 散列表的查找
散列表又称杂凑表,是一种非常实用的查找技术。它的原理是在结点的存储位置和它的关键码间建立一个确定的关系,从而让查找码直接利用这个关系确定结点的位置。其技术的关键在于解决两个问题。
I. 找一个好的散列函数
II. 设计有效解决冲突的方法
常见的散列函数有:
I. 质数除取余法
II. 基数转换法
III. 平方取中法
IV. 折叠法
V. 移位法
常见的解决冲突的方法有:
I. 线性探查法
II. 双散列函数法
III. 拉链法
假设HASH表的地址集为0-n-1,冲突是指由关键字得到的HASH地址的位置上已经存在记录,则处理冲突就是为该关键字的记录找到另一个空的HASH地址用于存放。
处理冲突的方法:
开放地址法(线性探查法):二次地址=(一次地址+增量序列) MOD 散列表长度M
再HASH法(双散列函数法):使用不同的散列函数计算,互相作为补偿;
二次地址=RH(key) R和H都是散列函数
拉链法(链地址法):设立一个指针数组,初始状态是空指针,HASH地址为i的记录都插入到指针数组中第i项所指向的链表中,保持关键字有序。
建立一个公共的溢出区:将所有冲突的关键字和记录都添入到溢出区。
HASH查找分析: HASH的查找长度与查找表的长度无关,只与装添因子有关
装添因子=表中添入的记录数/HASH的长度
4. 重点、难点解析
数据结构的相关知识点在软考中是经常出现的,无论是上午的客观题,还是下午的程序填空,都会涉及,而且考试的题型除了一般的概念和常识以外,还涉及各种算法,出题十分灵活。因此对这部分的复习一定要非常重视。这里我们总结了数据结构部分的一些比较重要的要点。
n 链式存储结构
不管是线性表、栈,还是队列,都会使用链式存储结构。对于链式存储结构的操作与顺序存储结构不一样,因此我们需要熟悉它们的相关算法。如:
u 对于链式存储的线性表,它的插入、删除和查找算法。
u 对于链接存储栈,它的进栈、出栈算法
u 对于链接存储队列,它的进队、出队算法
另外,对于栈和队列的一些实际应用的算法也需要关心,特别在下午的程序填空的考试中,经常会碰到类似的算法。
图
关于图的概念和相关算法很多,这对高程软考来说,也是一个重点。
除了我们前面罗列的一些关于的图的基本概念以外,首先我们必须对图的几种存储结构要十分的清楚,因为这实际上是研究图的相关问题和算法的基础。包括:
u 邻接矩阵
u 邻接表
u 十字链表
u 邻接多重表
对于图的两种遍历算法(深度优先和广度优先)实际可以参考树的有关遍历算法,这样理解起来相对简单。
而对于图的有关算法,如求最小代价生成树、求最短路径、拓扑排序和求关键路径等,这是数据结构中的难点。不过一般来说,软考中很难直接考到。因此,大家在复习的时候主要抓住涉及算法的相关概念、算法的基本原理以及算法的复杂度这几个方面。当然,有时间最好参看一下相关资料,对它的具体实现仔细看看,这对理解其算法的核心思想当然会事半功倍。
二叉树
二叉树永远都是数据结构中考查的重点,包括二叉树的基本概念、性质以及各种不同的遍历方法。而且,它的相关算法也是树和森林的算法的基础。因此对这部分知识要十分的重视。
另外,有时候还会涉及到二叉查找树的一些概念和算法。
查找树便于链式存储,还能实现快速查找。作为一种特殊的二叉树,它或者为空,或者满足以下3个条件:
I. 若该树根结点的左子树非空,其左子树所有结点的键值都小于该树根结点的键值
II. 若该树根结点的右子树非空,其右子树所有结点的键值都大于该树根结点的键值
III. 该树根的左子树和右子树均为查找树
根据以上定义可以知道,如果进行中序遍历,即可得到一个从小到大的结点序列。
排序
排序对于数据结构来说是一个特别重要的重点和难点,这也是体现一个高级程序员基本素质的地方。我们除了要掌握各种排序算法的基本思想,还必须要掌握其具体实现(用C语言或者Pascal语言均可),这反过来对我们更深刻领会其算法的本质也很有裨益。另外,对于它们之间的各种差异比较,如稳定性、时间复杂度、空间复杂度也要有所了解。这可以见前面知识要点部分的相关内容。
HASH表的处理过程和解决冲突的方法!!!