一、线性表
一)、顺序表
1. 非空线性结构的四个特征。
线性结构是最常用、最简单的一种数据结构。而线性表是一种典型的线性结构。其基本特点是线性表中的数据元素是有序且是有限的。在这种结构中:
①存在一个唯一的被称为“第一个”的数据元素;
②存在一个唯一的被称为“最后一个”的数据元素;
③除第一个元素外,每个元素均有唯一一个直接前驱;
④除最后一个元素外,每个元素均有唯一—个直接后继。
- 线性表是重要的线性结构
线性表(Linear List) :是由n(n≥O)个数据元素(结点)a1,a2,…an组成的有限序列。该序列中的所有结点具有相同的数据类型。其中数据元素的个数n称为线性表的长度。
当n=0时,称为空表。
当n>0时,将非空的线性表记作:
(a1,a2, …an)
a1称为线性表的第一个(首)结点,an称为线性表的最后一个(尾)结点;
a1, a2, ai-1都是ai(2≤i≤n)的前驱,其中ai-1是ai的直接前驱;
ai+1, ai+2,an都是ai (1≤i≤ n-1)的后继,其中ai+是ai的直接后继。
3. 线性表的操作在顺序表中的实现。
顺序存储:把线性表的结点按逻辑顺序依次存放在一组地址连续的存储单元里。用这种方法存储的线性表简称顺序表。
顺序存储的线性表的特点:
线性表的逻辑顺序与物理顺序一致;
数据元素之间的关系是以元素在计算机内“物理位置相邻”来体现。
二)、链表
-
从顺序存储结构的优缺点,引出链表的必要性。
顺序存储结构要求内存中可用存储单元的地址必须是连续的,当我们需要频繁的做插入或删除操作时,每次操作都平均需要移动近一半的元素,非常不方便,此时我们就需要运用到链式存储结构。 -
链表的类型定义。
链式存储:用一组任意的存储单元存储线性表中的数据元素。用这种方法存储的线性表简称线性链表。
存储链表中结点的一组任意的存储单元可以是连续的,也可以是不连续的,甚至是零散分布在内存中的任意位置上的。
链表中结点的逻辑顺序和物理顺序不一定相同。
为了正确表示结点间的逻辑关系,在存储每个结点值的同时,还必须存储指示其直接后继结点的地址(或位置),称为指针(pointer)或链(link),这两部分组成了链表中的结点结构。
链表是通过每个结点的指针域将线性表的n个结点按其逻辑次序链接在一起的。每一个结点只包含一个指针域的链表,称为单链表。为操作方便,总是在链表的第一个结点之前附设一个头结点(头指针)head指向第一个结点。头结点的数据域可以不存储任何信息(或链表长度等信息)。
单链表是由表头唯一确定的,因此单链表可以用头指针的名字来命名。
结点的描述与实现:
C语言中用带指针的结构体类型来描述
typedef struct LNode{
ElemType data; //数据域,保存结点的值
struct LNode *next; //指针域
}LNode; //结点的类型
- 线性表的操作在单链表中的实现。
结点是通过动态分配和释放来的实现,即需要时分配,不需要时释放。实现时是分别使用C语言提供的标准函数:malLoc() , xealloa.(),sieof.(),free()。
动态分配 p=(LNode*)malLoc(sizeaf(LNode));
函数malLoc分配了一个类型为LNode的结点变量的空间,并将其首地址放入指针变量p中。
动态释放 free§;
系统回收由指针变量p所指向的内存区。P必须是最近一次调用malLoc函数时的返回值。
- 单链表的建立方法,特别是头插法和尾插法。
动态的建立单链表的常用方法有头插法和尾插法。
1)头插入法建表
从一个空表开始,重复读入数据,生成新结点,将读入数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头上,直到读入结束标志为止。即每次插入的结点都作为链表的第一个结点。
(2)尾插入法建表
头插入法建立链表虽然算法简单,但生成的链表中结点的次序和输入的顺序相反。若希望二者次序一致,可采用尾插法建表。该方法是将新结点插入到当前链表的表尾,使其成为当前链表的尾结点。
5. 单链表的应用
单链表的查找:
(1)按序号查找
对于单链表,不能像顺序表中那样直接按序号i访问结点,而只能从链表的头结点出发,沿链域next逐个结点往下搜索,直到搜索到第i个结点为止。因此,链表不是随机存取结构。
(2)按值查找
按值查找是在链表中,查找是否有结点值等于给定值key的结点。若有,则返回首次找到的值为key的结点的存储位置;否则返回NULL。查找时从开始结点出发,沿链表逐个将结点的值和给定的值key作比较。
单链表的插入:
插入运算是将值为e的新结点插入到表的第i个结点的位置上,即插入到ai-1与ai之间。因此,必须首先找到ai-1所在的结点p,然后生成一个数据域为e的新结点q,q结点作为p的直接后继节点。
单链表的删除:
(1)按序号删除
删除单链表中的第i个结点。
为了删除第i个结点ai,必须找到结点的存储地址。该存储地址是在其直接前趋结点ai-1的next域中,因此,必须首先找到ai-1的存储位置p,然后令p ->next指向ai的直接后继结点,即把ai从链上摘下。最后释放结点ai的空间,将其归还给“存储池”。
设单链表长度为n,则删去第i个结点仅当1≤i≤n时是合法的。则当i=n+1时,虽然被删结点不存在,但其前趋结点却存在,是终端结点。故判断条件之一是p - >next!=NULL。显然此算法的时间复杂度也是O(n)。
(2)按值删除
删除单链表中值为key的第一个结点。
与按值查找相类似,首先要查找值为key的结点是否存在?若存在,则删除;否则返回NULL。
循环链表:
循环链表:是一种头尾相连的链表。其特点是最后一个节点的指针域指向链表的头结点,整个链表的指针域连接成一个环。从循环链表的任意一个结点出发都可以找到链表中的其它结点,值得表的处理更加方便灵活。
(1)循环链表判空:head->next==head;
(2)循环链表判断是否是表尾结点:p->next==head;
双向链表:
双向链表:指的是构成链表的每个结点中设立两个指针域,一个指向其直接前驱的指针域prior,一个指向其直接后继的指针域next。这样形成的链表中有两个方向不同的链,故称为双向链表。
和单链表类似,双向链表一般增加头指针也能使双链表上某些运算变得方便。
将头结点和尾结点链接起来也能构成循环链表,并称之为双向循环链表。
双向链表是为了克服单链表的单向性的缺陷而引入的。
双向链表的结点的类型定义如下:
typedef struct DuLNode{
ElemType data; //数据域
struct DuLNode *prior; //指向直接前继
struct DuLNode *next; //指向直接后继
}DuLNode;
双向链表结构具有对称性,设p指向双向链表中的某一结点,则其对称性可用下式描述:
(p->prior)->next=p=(p->next)->prior
结点p的存储位置存放在其直接前驱结点p->prior的直接后继指针域中,同时也存放在其直接后继结点p->next的直接前驱指针域中。
二、栈与队列
一)、栈与队列
1.栈和队列仍属于线性结构,其操作是线性表操作的子集,是操作受限的线性表。但从数据类型的角度看,它们是和线性表大不相同的重要抽象数据类型。
(1)栈
栈是一种特殊的线性表,其特殊性体现在元素插入和删除运算上,它的插入和删除运算仅限定在表的某一端进行,不能在表中间和另一端进行。
栈的插入操作称为进栈(或入栈),删除操作称为出栈(或退栈)。
允许进行插入和删除的一端称为栈顶,另一端称为栈底。
处于栈顶位置的数据元素称为栈顶元素。
不含任何数据元素的栈称为空栈。
(2)队列
队列(简称队)也是一种运算受限的线性表,在这种线性表上,插入限定在表的某一端进行,删除限定在表的另一端进行。
队列的插入操作称为进队,删除操作称为出队。
允许插入的一端称为队尾,允许删除的一端称为队头。
新插入的元素只能添加到队尾,被删除的只能是排在队头的元素。
- 栈的定义及操作。栈是只准在一端进行插入和删除操作的线性表,该端称为栈的顶端。
(1)栈的概念
栈(Stack):是限制在表的一端进行插入和删除操作的线性表。又称为后进先出 LIFO (LastIn First 0ut) 或先进后出FILO (First In LastOut)线性表。
栈顶(Top):允许进行插入、删除操作的一端,又称为表尾。用栈顶指针(top)来指示栈顶元素。
栈底(Bottom):是固定端,又称为表头。空栈:当表中没有元素时称为空栈。
(2)栈的抽象数据类型定义
ADT Stack {
-数据对象: D ={ a;|a; E ElemSet,i=1, 2, ....n, n≥0 }
-数据关系: R ={<a;-1, a;;>|a;-1,a;∈D,i=2, 3, ...n}
-基本操作:初始化、进栈、出栈、取栈顶元素等
} ADT Stack
(3)栈的基本运算
栈的基本运算主要包括以下6种:
初始化栈InitStack(st)。建立一个空栈st。
销毁栈DestroyStack(st)。释放栈st占用的内存空间。
进栈Push(st,x)。将元素x插入栈st中,使x成为栈st的栈顶元素。
出栈Pop(st,x)。当栈st不空时,将栈顶元素赋给x,并从栈中删除当前栈顶。
取栈顶元素GetTop(st)。若栈st不空,取栈顶元素x并返回1;否则返回0。
判断栈空StackEmpty(st)。判断栈st是否为空栈。
- 栈的顺序和链式存储结构,及在这两种结构下实现栈的操作。
(1)栈的顺序存储
栈的顺序存储结构简称为顺序栈,和线性表相类似,用一维数组来存储栈。根据数组是否可以根据需要增大,又可分为静态顺序栈和动态顺序栈。
静态顺序栈实现简单,但不能根据需要增大栈的存储空间;
动态顺序栈可以根据需要增大栈的存储空间,但实现稍为复杂。
顺序栈通常由一个一维数组data和一个记录栈顶元素位置的变量top组成。
习惯上将栈底放在数组下标小的那端,栈顶元素由栈顶指针top所指向。
1)顺序栈的声明如下:
#define MaxSize 100//顺序栈的初始分配空间大小
typedef struct
{ ElemType data[MaxSize];
//保存栈中元素,这里假设ElemType为char类型
int top;//栈顶指针
} SqStack;
归纳起来,对于顺序栈st,其初始时置st.top=-1,它4个要素如下:
栈空条件:st.top==-1
栈满条件:st.top==MaxSize-1
元素x进栈操作:st.top++;将元素x放在st.data[st.top]中
出栈元素x操作:取出栈元素x=st.data[st.top];st.top--
2)顺序栈的基本运算算法如下。
a.初始化栈运算算法
主要操作:设定栈顶指针top为-1。
void InitStack(SqStack &st)//st为引用型参数
{
st.top=-1;
}
b.销毁栈运算算法
这里顺序栈的内存空间是由系统自动分配的,在不再需要时由系统自动释放其空间。
void DestroyStack(SqStack st)
{ }
c.进栈运算算法
主要操作:栈顶指针加1,将进栈元素放进栈顶处。
int Push(SqStack &st,ElemType x)
{ if (st.top==MaxSize-1)//栈满上溢出返回0
return 0;
else
{ st.top++;
st.data[st.top]=x;
return 1;//成功进栈返回1
}
}
d.出栈运算算法
主要操作:先将栈顶元素取出,然后将栈顶指针减1。
int Pop(SqStack &st,ElemType &x)
{ if (st.top==-1)//栈空返回0
return 0;
else
{ x=st.data[st.top];
st.top--;
return 1;//成功出栈返回1
}
}
e.取栈顶元素运算算法
主要操作:将栈指针top处的元素取出赋给变量x。
int GetTop(SqStack st,ElemType &x)
{ if (st.top==-1)//栈空返回0
return 0;
else
{ x=st.data[st.top];
return 1;//成功取栈顶元素返回1
}
}
f.判断栈空运算算法
主要操作:若栈为空(top==-1)则返回值1,否则返回值0。
int StackEmpty(SqStack st)
{ if (st.top==-1)
return 1;//栈空返回1
else
return 0;//栈不空返回0
}
(2)栈的链式存储
栈的链式存储结构称为链栈,是运算受限的单链表。其插入和删除操作只能在表头位置上进行。
1)链栈的结点类型说明如下:
typedef struct Stack_Node
{ ElemType data;
Struct Stack_Node *next;//栈顶指针
}Stack_Node;
2)链栈基本操作的实现
a.栈的初始化
Stack_Node *Init_Link_Stack(void){
Stack_ Node *top ;
top (Stack_Node*)malloc(sizeof(Stack_Node));
top->next=NULL ;
return(top) ;
}
b.压栈(元素进栈)
Status push(Stack_Node *top ,ElemType e){
Stack_Node*p
p=(Stack_Node*)malloc(sizeof(Stack_Node));
if(!p) return ERROR;
/*申请新结点失败,返回错误标志*/
p->data=e ;
p->next=top->next;
top->next=p ;/*钩链*/
return 0K;
}
c. 弹栈(元素出栈)
Status pop(Stack_Node *top,ElemType *e)
/*将栈顶元素出栈*/
{
Stack_Node*p;
ElemType e;
if(top->next==NULL)
return ERROR ;/*栈空,返回错误标志*/
p=top->next ; e=p->data ; /* 取栈顶元素*/
top->next=p->next ; /*修改栈顶指针*/
free(p);
return 0K;
}
4. 栈的应用:表达式求值。
由于栈具有“后进先出‘’的固有特性,因此,栈成为程序设计中常用的工具和数据结构。
(1)逆波兰表达式简介
假定给定一个只 包含 加、减、乘、除,和括号的算术表达式,你怎么编写程序计算出其结果。问 题是:在表达式中,括号,以及括号的多层嵌套 的使用,运算符的优先级不同等因素,使得一个算术表达式在计算时,运算顺序往往因表达式的内容而定,不具规律性。 这样很难编写出统一的计算指令。
使用逆波兰算法可以轻松解决。他的核心思想是将普通的中缀表达式转换为后缀表达式。
转换为后缀表达式的好处是:
去除原来表达式中的括号,因为括号只指示运算顺序,不是完成计算必须的元素。
使得运算顺序有规律可寻,计算机能编写出代码完成计算。虽然后缀表达式不利于人阅读,但利于计算机处理。
(2)将中缀表达式转换成后缀式(逆波兰表达式)
1)从左到右读进中序表达式的每个字符。
2)如果读到的字符为操作数,则直接输出到后缀表达式中。
3)如果遇到“)”,则弹出栈内的运算符,直到弹出到一个“(”,两者相互抵消。
4)“(”的优先级在栈内比任何运算符都小,任何运算符都可以压过它,不过在栈外却是优先级 最高者。
5)当运算符准备进入栈内时,必须和栈顶的运算符比较,如果外面的运算符优先级高于栈顶的运算符的优先级,则压栈;如果优先级低于或等于栈顶的运算符的优先级,则弹栈。直到栈顶的运算符的优先级低于外面的运算符优先级或者栈为空时,再把外面的运算符压栈。
6)中缀表达式读完后,如果运算符栈不为空,则将其内的运算符逐一弹出,输出到后缀表达式中。
(3)后缀表达式求值
后缀表达式具有和前缀表达式类似的好处,没有优先级的问题。
1)直接读取表达式,如果遇到数字就压栈。
2)如果遇到运算符,就弹出两个数进行运算,随后再将运算结果压栈。
二)、栈及应用
- 栈的定义的本质:LIFO。栈是只准在一端进行插入和删除操作的线性表,该端称为栈的顶端。
栈(Stack):是限制在表的一端进行插入和删除操作的线性表。又称为后进先出 LIFO(LastIn First 0ut)或先进后出FILO (First In LastOut)线性表。
栈顶(Top):允许进行插入、删除操作的一端,又称为表尾。用栈顶指针(top)来指示栈顶元素。
栈底(Bottom):是固定端,又称为表头。空栈:当表中没有元素时称为空栈。
- 栈的应用:中缀表达式转为后缀表达式,并求值。
(1)中缀、后缀表达式区别
中缀表达式:(或中缀记法)是一个通用的算术或逻辑公式表示方法, 操作符是以中缀形式处于操作数的中间(例:3 + 4),中缀表达式是人们常用的算术表示方法。
后缀表达式:后缀表达式,指的是不包含括号,运算符放在两个运算对象的后面,所有的计算按运算符出现的顺序,严格从左向右进行(不再考虑运算符的优先规则)。
可以通过2个表达式计算的结果,证明是否转换正确。
(2)中缀转后缀规则
中缀表达式转后缀表达式规则:
从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;
若是符号,则判断其与栈顶符号的优先级,是右括号或者优先级低于栈顶符号(乘除优先加减,)
则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
(3)中缀转后缀规则分析
从头到尾读取中缀表达式的每个对象,对不同对象按不同的情况处理。
1)运算数:直接输出;
2)左括号:压入堆栈;
3)右括号:将栈顶的运算符弹出并输出,直到遇到左括号(出栈,不输出)
4)运算符:
①当优先级大于栈顶运算符时,则把它压栈;
②当优先级小于或等于栈顶运算符时,将栈顶运算符弹出并输出;再比较新的栈顶运算符,直到该运算符大于栈顶运算符优先级为止,然后将该运算符压栈;
5)若各对象处理完毕,则把堆栈中存留的运算符一并输出。
3.递归过程的应用。
(1)定义 (什么是递归?)
在数学与计算机科学中,递归(Recursion)是指在函数的定义中使用函数自身的方法。实际上,递归,顾名思义,其包含了两个意思:递 和 归,这正是递归思想的精华所在。
(2)递归思想的内涵(递归的精髓是什么?)
正如上面所描述的场景,递归就是有去(递去)有回(归来),如下图所示。“有去”是指:递归问题必须可以分解为若干个规模较小,与原问题形式相同的子问题,这些子问题可以用相同的解题思路来解决,就像上面例子中的钥匙可以打开后面所有门上的锁一样;“有回”是指 : 这些问题的演化过程是一个从大到小,由近及远的过程,并且会有一个明确的终点(临界点),一旦到达了这个临界点,就不用再往更小、更远的地方走下去。最后,从这个临界点开始,原路返回到原点,原问题解决。
(3)递归的三要素
在我们了解了递归的基本思想及其数学模型之后,我们如何才能写出一个漂亮的递归程序呢?笔者认为主要是把握好如下三个方面:
1)明确递归终止条件;
2)给出递归终止时的处理办法;
3)提取重复的逻辑,缩小问题规模。
(4)递归的应用场景
在我们实际学习工作中,递归算法一般用于解决三类问题:
1)问题的定义是按递归定义的(Fibonacci函数,阶乘,…);
2)问题的解法是递归的(有些问题只能使用递归方法来解决,例如,汉诺塔问题,…);
3)数据结构是递归的(链表、树等的操作,包括树的遍历,树的深度,…)。
三)、队列及应用
-
队列的基本概念和算法,其本质是:FIFO。
队列(Queue):也是运算受限的线性表。是一种先进先出(First ln First Out ,简称FIFO)的线性表。只允许在表的一端进行插入,而在另一端进行删除。
队首(front):允许进行删除的一端称为队首。
队尾(rear) :允许进行插入的一端称为队尾。
队列中没有元素时称为空队列。在空队列中依次加入元素a1, a2,…,an之后,a1是队首元素,an是队尾元素。显然退出队列的次序也只能是a1, a2,…,an,即队列的修改是依先进先出的原则进行的。
-
链队列空的条件是首尾指针相等,而循环队列满的条件的判定,则有牺牲一个单元和设标记等方法。
队列类型定义:
#define MAX_QUEUE_SIZE 100
typedef struct queue
{
ElementType Queue_array[MAX_QUEUE_SIZE];
int front;
int rear;
}SqQueue;
设立一个队首指针front,一个队尾指针rear,分别指向队首和队尾元素。
初始化:front=rear=0
入队:将新元素插入rear所指的位置,然后rear加1
出队:删去front所指的元素,然后加1并返回被删元素
队列为空:front=rear
队满:rear=MAX_QUEUE_SIZE-1或front=rear
假溢出:顺序队列中存在“假溢出”现象。因为在入队和出队操作中,头、尾指针只增加不减小,致使被删除元素的空间永远无法重新利用。因此,尽管队列中实际元素个数可能远远小于数组大小,但可能由于尾指针已超出向量空间的上界而不能做入队操作。该现象称为“假溢出”。
循环队列:为充分利用向量空间,克服上述“假溢出”现象的方法是:将为队列分配的向量空间看成为一个首尾相接的圆环,并称这种队列为循环队列。
在循环队列中进行入队和出队操作时,队首、队尾指针仍要加1,朝前移动。当队首、队尾指针指向向量上界(MAX_QUEUE_SIZE-1)时,其加1操作的结果是指向向量的下界0.
代码描述为:i=(i+1)%MAX_QUEUE_SIZE;
入队时尾指针向前追赶头指针,出队时头指针向前追赶尾指针,故队空和队满时头尾指针均相等。因此,无法通过front=rear来判断队列是空还是满。解决此问题的办法是:约定入队前,测试尾指针在循环意义下加1后是否等于头指针,若相等则认为队满。
rear所指的单元始终为空。
循环队列为空:front=rear
循环队列满:(rear+1)%MAX_QUEUE_SIZE=front;
三、串
一)、串
1.串是数据元素为字符的线性表,串的定义及操作。
(1)串的基本概念
串(字符串):是零个或多个字符组成的有限序列。记作:S=“a1a2a3”,其中S是串名, ai(1≤i≤n)是单个,可以是字母、数字或其它字符。
串值:双引号括起来的字符序列是串值。
串长:串中所包含的字符个数称为该串的长度。空串(空的字符串):长度为零的串称为空 串,它不包含任何字符。
空格串(空白串):构成串的所有字符都是空格的串称为空白串。
注意:空串和空白串的不同,例如“”和“ ”分别表示长度为的0空白串和长度为1的空白串。
子串:串中任意个连续字符组成的子序列称为该串的子串,包含子串的串相应地称为主串。
子串的序号:将子串在主串中首次出现时的该子串的首字符对应在主串中的序号(或位置)。
串相等:如果两个串的串值相等(相同),称这俩个串相等。换言之,只有当两个串的长度相等,且各个对应位置的字符都相同时才相等。通常在程序中使用的串可分为两种:串变量和串常量。
(2)串的抽象数据类型定义
ADT String{
-
数据对象: D = { a|a;∈CharacterSet,i=1,2…,n, n>=20 }
-
数据关系: R={<ai-1, ai>| ai-1, ai∈D,i=2,3…,n}
-
基本操作:
-StrAssign(t,chars)
-
初始条件:chars是一个字符串常量。
-
操作结果:生成一个值为chars的串t。
-
StrConcat(s, t)
-
初始条件:串s, t已存在。
-
操作结果:将串t联结到串s后形成新串存放到s中。
-
StrLength (t)
-
初始条件:字符串t已存在。
-
操作结果:返回串t中的元素个数,称为串长。
-
SubString (s, pos,len,sub)
-
初始条件:串s,已存在,
1≤pos≤StrLength(s)且
0≤len≤StrLength(s) - pos+1。
- 操作结果:用sub返回串s的第pos个字符起长度为len的子串。
} ADT String
2.串的基本操作,用串的基本操作来编写算法求串的其它操作。
串的基本运算如下:
串赋值Assign(s,str):将一个常字符串str赋给串s。
销毁串DestroyStr(s):释放串s占用的内存空间。
串复制StrCopy(s,t):将一个串t赋给串s。
求串长StrLength(s):返回串s的长度。
判断串相等StrEqual(s,t):两个串s和t相等时返回1;否则返回0。
串连接Concat(s,t):返回串s和串t连接的结果串。
求子串SubStr (s,i,j):返回串s的第i个位置开始的j个字符组成的串。
查找定位位置Index(s,t):返回子串t在主串s中的位置。
子串插入InsStr(s,i,t):将子串t插入到串s的第i个位置。
子串删除DelStr(s,i,j):删除串s中从第i个位置开始的j个字符。
子串替换RepStrAll(s,s1,s2):返回串s中所有出现的子串s1均替换成s2后得到的串。
输出串DispStr(s):显示串s的所有字符。
四、数组与广义表
一)、数组
1.数组的逻辑结构定义及存储。
(1)数组的定义
一维数组是n(n>1)个相同性质的数据元素a1,a2,…,an构成的有限序列,它本身就是一个线性表。
二维数组可以看成是这样的一个线性表,它的每个数据元素也是一个线性表。
例如,以下的二维数组A以m行n列的矩阵形式表示,它可以看成是一个线性表的线性表:
A=(A1,A2,…,Ai,…,Am)
其中每个数据元素Ai也是一个线性表:
Ai=(ai,1,ai,2,…,ai,n)
通常数组的基本运算主要有:
存元素值
取元素值
几乎所有的高级程序设计语言都实现了数组数据结构,称为数组类型。在C/C++语言中,数组类型具有如下特点:
一个数组中所有元素具有相同的数据类型。
数组一旦被定义,它的维数和每维大小就不再改变。
数组中每个元素对应唯一的下标,可以通过该下标对元素进行存取操作。
(2)数组的存储结构
以二维数组为例,在C/C++语言中,由于数组下标从0开始,所以除特别指出外,后面的数组表示均统一为下标从0开始。
二维数组的存储次序有按行优先和按列优先两种方式。
2.掌握数组的存储结构,稀疏矩阵(含特殊矩阵)的存储及运算。
(1)稀疏矩阵的存储:
一个阶数较大的矩阵中的非零元素个数s相对于矩阵元素的总个数t十分小时,即s<<t时,称该矩阵为稀疏矩阵。
例如一个100×100的矩阵,若其中只有100个非零元素,就可称其为稀疏矩阵。
稀疏矩阵的压缩存储方法是只存储非零元素,主要有三元组和十字链表两种方法。
稀疏矩阵的零元素非常多,且分布无规律,所以稀疏矩阵的压缩存储方法为:只存储矩阵中的非零元素,按照三元组的形式存储。三元组由非零元素,该元素行下标和该元素列下标三个数据构成,放在一个列数为3的数组中。
存储结构又分数组结构和链表结构两个大类,其中链表结构又有一般链表、行指针数组链表和行指针的十字链表存储结构,是链表的直接应用和组合应用。
稀疏矩阵的压缩存储,数据结构提供有 3 种具体实现方式:
1)三元组顺序表;
2)行逻辑链接的顺序表;
3)十字链表;
(2)矩阵运算
1)矩阵转置(带回溯)
由于稀疏矩阵中的元素的存储是按行优先存储的,因此三元组中数据的行下标递增有序,行下标相同时列下标递增有序。存储转置后的矩阵,只要将三元组a的第一行依次将a中的列值由小到大进行选择,将选中的三元组元素行列值互换后放入三元组b,直到三元组a中的元素全部放入b中为止。
2)不带回溯的转置
不带回溯的转置要解决的关键问题是要预先确定好矩阵的每一列第一个非零元素在三元组b中应有的位置。为了确定这些位置,转置前必须求得三元组a中每一列非零元的个数,从而得到每列第一个元素在三元组b中(行)的位置。用pot数组来记录。
pot[1]=1;
pot[col]=pot[col-1]+第col-1列非零元的个数(pot[col])
3)矩阵相加 C = A + B
稀疏矩阵相加采用的算法思想是:依次扫描 A和B的行号和列号,如果A的当前项的行号等于B的当前项的行号,则比较其列号,将较小的项存入C。若列号也相等,则将对应的元素相加后存入C;若A的当前项的行号小于B的,则将A的项存入C,否则将B的项存入C。
[注]:if a[i][0]= = b[i][0]&& a[i][1]< b[i][1] 表明该位置上B矩阵为零而A矩阵的元素不为零,相加后的C矩阵的该位置元素应为A矩阵的该元素,故将较小者存入C。
4)矩阵相乘 C = A * B
矩阵的乘法涉及到两矩阵不同行,列之间的运算,情况相对复杂。稀疏矩阵的乘法采用普通矩阵相乘的基本方法,关键是通过给顶的行号和列号找出原矩阵对应的元素值。这里设计一个函数value,当在三元组表示中找到时返回其元素值,找不到时,说明该位置为0,因此返回0。然后利用该函数计算出C的行号i和列号j 处的元素值,若该值不为0,则存入其三元组表示的矩阵 ,否则不存入。
二)、稀疏矩阵
1.稀疏矩阵的三元组表存储结构
稀疏矩阵的三元组表示:
由于稀疏矩阵中非零元素的分布没有任何规律,所以在存储非零元素时还必须同时存储该非零元素所 对应的行下标和列下标。
这样稀疏矩阵中的每一个非零元素需由一个三元组(i,j,ai,j)唯一确定,稀疏矩阵中的所有非零元素构成三元组线性表。
((0,2,1),(1,1,2),(2,0,3),(3,3,5),(4,4,6),(5,5,7),(5,6,4))
三元组顺序表的数据结构可定义如下:
#define MaxSize 100 //矩阵中非零元素的最多个数
typedef struct
{ int r; //行号
int c; //列号
ElemType d; //元素值为ElemType类型
} TupNode; //三元组定义
typedef struct
{ int rows; //行数
int cols; //列数
int nums; //非零元素个数
TupNode data[MaxSize];
} TSMatrix;
-
稀疏矩阵的两种转置方法
( 1)矩阵转置(带回溯)
由于稀疏矩阵中的元素的存储是按行优先存储的,因此三元组中数据的行下标递增有序,行下标相同时列下标递增有序。存储转置后的矩阵,只要将三元组a的第一行依次将a中的列值由小到大进行选择,将选中的三元组元素行列值互换后放入三元组b,直到三元组a中的元素全部放入b中为止。
(2)不带回溯的转置
不带回溯的转置要解决的关键问题是要预先确定好矩阵的每一列第一个非零元素在三元组b中应有的位置。为了确定这些位置,转置前必须求得三元组a中每一列非零元的个数,从而得到每列第一个元素在三元组b中(行)的位置。用pot数组来记录。
pot[1]=1;
pot[col]=pot[col-1]+第col-1列非零元的个数(pot[col])
- 十字链表
对于稀疏矩阵中每个非零元素创建一个结点存放它,包含元素的行号、列号和元素值。这里有4个非零元素,创建4个数据结点。
将同一行的所有结点构成一个带头结点的循环单链表,行号为i的单链表的头结点为hr[i]。这里有3行,对应有3个循环单链表,头结点分别为hr[0]~hr[2]。
hr[i](0≤i≤2)头结点的行指针指向行号为i的单链表的首结点。
将同一列的所有结点构成一个带头结点的循环单链表,列号为j的单链表的头结点为hd[j]。这里有4列,对应有4个循环单链表,头结点分别为hd[0]~hd[3]。
hd[j](0≤j≤3)头结点的列指针指向列号为j的单链表的首结点。
由此,创建了3+4=7个循环单链表,头结点个数也为7个。实际上,可以将hr[i]和hd[i]合起来变为h[i],即h[i]同时包含有行指针和列指针。
h[i](0≤i≤2)头结点的行指针指向行号为i的单链表的首结点,h[i](0≤i≤3)头结点的列指针指向列号为i的单链表的首结点,这样,头结点的个数为MAX{3,4}=4个。
再将所有头结点h[i](0≤i≤3)链起来构成一个带头结点的循环单链表,这样需要增加一个总头结点hm。
总头结点中存放稀疏矩阵的行数和列数等信息。
十字链表结点结构和头结点合起来声明的结点类型如下:
#define M 3 //矩阵行数
#define N 4 //矩阵列数
#define Max ((M)>(N)?(M):(N)) //矩阵行列中较大者
typedef struct mtxn
{ int i; //行号
int j; //列号
struct mtxn *right,*down; //向右和向下的指针
union
{ ElemType value; //存放非零元素值
struct mtxn *link;
} tag;
} MatNode;
三)、广义表
1.广义表的概念。
广义表(Lists,又称列表)是线性表的推广。线性表定义为n>=0个元素a1,a2,a3,…,an的有限序列。线性表的元素仅限于原子项,原子是作为结构上不可分割的成分,它可以是一个数或一个结构,若放松对表元素的这种限制,容许它们具有其自身结构,这样就产生了广义表的概念。
广义表是n (n>=0)个元素a1,a2,a3,…,an的有限序列,其中ai或者是原子项,或者是一个广义表。通常记作LS=(a1,a2,a3,…,an)。LS是广义表的名字,n为它的长度。若ai是广义表,则称它为LS的子表。
广义表的重要结论:
(1)广义表的元素可以是原子,也可以是子表,子表的元素又可以是子表,即广义表是一个多层次的结构。
(2)广义表可以被其它广义表所共享,也可以共享其它广义表。广义表共享其它广义表时通过表名引用。
(3)广义表本身可以是一个递归表。
(4)根据对表头、表尾的定义,任何一个非空广义表的表头可以是原子,也可以是子表,而表尾必定是广义表。
相应的数据结构定义如下:
typedef struct GLNode
{ int tag ; /标志域,为1:表结点;为0:原子结点/
union
{ elemtype value; /*原子结点的值域*/
struct
{ struct GLNode *hp ,*tp ;
}ptr; /* ptr和atom两成员共用*/
}Gdata ;
} GLNode; /* 广义表结点类型*/
2.广义表的取头和取尾操作。
若广义表LS非空时:
①a1(表中第一个元素)称为表头;
②其余元素组成的子表称为表尾;
(a2,a3, ……,an)
③广义表中所包含的元素(包括原子和子表)的个数称为表的长度。
④广义表中括号的最大层数称为表深(度)。
注意:表头是元素,表尾是广义表。
根据广义表对表头和表尾的定义可知:
(1)对任意一个非空的广义表,其表头可能是单元素,也可能是广义表,
(2)而其表尾一定是广义表。
(3)注意表尾的深度(即括号的嵌套层数)
(4)表尾是由除了表头以外的其余元素组成的广义表,所以,需要在表尾的直接元素外面再加一层括号。
广义表最基本的操作:取表头head(LS)与取表尾tail(LS)
例:LS=(a,(b,c,d))
head(LS)=a
tail(LS)=((b,c,d))
head(tail(LS))=(b,c,d)
tail(tail(head(tail(LS))))=(d)
head(tail(tail(head(tail(LS)))))=d
tail(tail(tail(head(tail(LS)))))=()
3.广义表运算的递归算法。
以广义表为例,讨论如何利用“分治法”(Divide and Conquer)进行递归算法设计的方法。
对这类问题设计递归算法时,通常可以先写出问题求解的递归定义。和第二数学归纳法类似,递归定义由基本项和归纳项两部分组成。
递归定义的基本项描述了一个或几个递归过程的终结状态。虽然一个有限的递归(且无明显的迭代)可以描述一个无限的计算过程,但任何实际应用的递归过程,除错误情况外,必定能经过有限层次的递归而终止。所谓终结状态指的是不需要继续递归而可直接求解的状态。例如3-3的n阶Hanio问题,在n=1时可以直接求得解,即将圆盘从X塔座移动到Z塔座上。一般情况下,若递归参数为n,则递归的终结状态为n=0或n=1等。
递归定义的归纳项描述了如何实现从当前状态到终结状态的转化。递归设计的实质是:当一个复杂的问题可以分解成若干子问题来处理时,其中某些子问题与原问题有相同的特征属性,则可利用和原问题相同的分析处理方法;反之,这些子问题解决了,原问题也就迎刃而解了。递归定义的归纳项就是描述这种原问题和子问题之间的转化关系。仍以Hanoi塔问题为例。原问题是将n个圆盘从X塔座移至Z塔座上,可以把它分解成3个子问题:
(1) 将编号为1至n-1的n-1个圆盘从X塔座移至Y塔座;
(2) 将编号为n的圆盘从X塔座移至Z塔座;
(3) 将编号为1至n-1的圆盘从Y塔座移至Z塔座。
其中(1)和(3)的子问题和原问题特征属性相同,只是参数(n-1和n)不同,由此实现了递归。
由于递归函数的设计用的是归纳思维的方法,则在设计递归函数时,应注意:
1) 首先因书写函数的首部和规格说明,严格定义函数的功能和接口(递归调用的界面),对求精函数中所得的和原问题性质相同的子问题,只要接口一致,便可进行递归调用;
2) 对函数中的每一个递归调用都看成只是一个简单的操作,只要接口一致,必能实现规格说明中定义的功能,切忌想的太深太远。正如用第二数学归纳法证明命题时,由归纳假设进行归纳证明时,决不能怀疑归纳假设是否正确。
4.算法设计举例
(1)求广义表深度的递归函数如下所示:
int GListDepth(GList L)
{
//采用头尾链表存储结构,求广义表L的深度
if(!L)
return 1; //空表深度为1
if(L->tag == ATOM) //原子深度为0
return 0;
for(max = 0, pp = L; pp; pp = pp->ptr.tp){
dep = GListDepth(pp->ptr.hp); //求以pp->ptr.hp为头指针的子表深度
if(dep > max)
max = dep;
}
return max+1; //非空表的深度是各元素的深度的最大值加1
}
上述算法的执行过程实质上是遍历广义表的过程,在遍历中首先求得各子表的深度,然后综合得到广义表的深度。例如,图5.13展示了求广义表D的深度的过程。图中用虚线示意遍历过程中指针L的变化状况,在指向节点的虚线旁标记的是将要遍历的子表,而在从结点射出的虚线旁标记的数字是刚求得的子表的深度,从图中可见广义表D=(A,B,C)=((), (e), (a, (b,c,d)))的深度为3。
(2)若按递归定义分析广义表D的深度,则有:
DEPTH(D) = 1 + Max{DEPTH(A), DEPTH(B), DEPTH©}
DEPTH(A) = 1;
DEPTH(B) = 1 + Max{DEPTH(e)} = 1+0 = 1
DEPTH© = 1 + Max{DEPTH(a), DEPTH((b,c,d))} = 2
DEPTH(a) = 0
DEPTH((b,c,d)) = 1 + Max{DEPTH(b), DEPTH©, DEPTH(d)}
= 1 + 0
= 1
由此,DEPTH(D) = 1 + Max{1, 1, 2} = 3。
五、树和二叉树
一)、树的概念
1.树是复杂的非线性数据结构,树,二叉树的递归定义,基本概念,术语。
树形结构是一类非常重要的非线性结构。直观的,树形结构是以分支关系定义的层次结构。树在计算机领域中也有着广泛的应用,例如在编译程序中,用树来表示源程序的语法结构;在数据库系统中,可用树来描述其执行过程等等。
1树的定义
树(Tree)是n(n≥0)个结点的有限集合T,若n=0时称为空树,否则:
(1)有且只有一个特殊的称为树的根(Root)结点;
(2)若n>1时,其余的结点被分为m(m>0)个互不相交的子集T,T2,T3—Tm,其中每个子集本身又是一
棵树,称其为根的子树(Subtree)。
这是树的递归定义,即用树来定义树,而只有一个结点的树必定仅由根组成。
2 树的基本术语
(1)结点:一个数据元素及其若干指向其子树的分支。
(2)结点的度、树的度:结点所拥有的子树的棵树称为结点的度。树中结点度的最大值称为树的度。
(3)叶子结点、非叶子结点:树中度为0的结点称为叶子结点。相对应的,度不为0的结点称为非叶子结点。
(4)孩子结点、双亲结点、兄弟结点:一个结点的子树的根称为该结点的孩子结点或子结点;相应的,该结点是其孩子结点的双亲结点或父结点。同一双亲结点的所有子结点互称为兄弟结点。
(5)层次、堂兄弟结点:规定树中根结点的层次为1,其余结点的层次等于其双亲结点的层次加1。双亲结点在同一层上的所有结点互称为堂兄弟结点。
(6)结点的层次路径、祖先、子孙:从根结点开始,到达某结点p所经过的所有结点称为结点p的层次路径(有且只有一条)。结点p的层次路径上的所有结点(p除外)称为p的祖先。以某一结点为根的子树中的任意结点称为该结点的子孙结点。
(7)树的深度:树中结点的最大层次值,又称为树的高度。
(8)有序树和无序树:对于一棵树,若其中每一个结点的子树具有一定的次序,则称该树为有序树,否则称无序树。
(9)森林:是m(m>=0)棵互不相交的树的集合。显然,若将一棵树的根结点删除,剩余的子树就构成了森林。
3 二叉树
二叉树(Binary Tree)是n(n≥0)个结点的有限集合T,若n=0时称为空树,否则:
(1)有且只有一个特殊的称为树的根(Root)结点;
(2)若n>1时,其余的结点被分为两个互不相交的子集T,T2,分别称之为左、右子树,并且左、右子树又都是二叉树。
2.二叉树的性质,存储结构
满二叉树:一颗深度为k且有2^k-1个结点的二叉树称为满二叉树。
基本特点是每一层上的结点数总是最大结点数。
满二叉树的所有的支结点都有左、右子树。
可对满二叉树的结点进行连续编号,若规定从根结点开始,按“自上而下、自左至右”的原则进行。
完全二叉树(Complete Binary Tree):如果深度为k,由n个结点的二叉树,当且仅当其每一个结点都与深度为k的满二叉树中编号从1到n的结点——对应,该二叉树称为完全二叉树。或深度为k的满二叉树中编号从1到n的前n个结点构成了一棵深度为k的完全二叉树。
性质1:在非空二叉树中,第i层上至多有2^(i-1)个结点(i>=1)。
性质2:深度为k的二叉树至多有2^k-1个结点(k>=1)。
性质3:对于任何一棵二叉树:若其叶子结点数为n0,度为2的结点数为n2,则n0=n2+1。
性质4:n个结点的完全二叉树深度为:。
性质5:若对一棵有n个结点的完全二叉树(深度为:)的结点按层序自左至右进行编号,则对于编号为i(1<=i<=n)的结点:
(1)若i=1:则结点i是二叉树的根,无双亲结点;否则,若i≥>1,则其双亲结点编号是Li/2」。
(2)如果2i>n:则结点i为叶子结点,无左孩子;否则,其左孩子结点编号是2i。
(3)如果2i+1>n:则结点i无右孩子;否则,其右孩子结点编号是2i+1。
1.顺序存储结构
二叉树存储结构的类型定义:
#define MAX_SIZE 100
typedef telemtype sqbitree[MAX_SIZE];
用一组地址连续的存储单元依次“自上而下、自左至右”存储完全二叉树的数据元素。
对于完全二叉树上编号为i的结点元素存储在一维数组的下标值为i-1的分量中。
对于一般的二叉树,将其每个结点与完全二叉树上的结点相对照,存储在一维数组中。
2.链式存储结构
①二叉链表结点:一个数据域,两个分别指向左右子结点的指针域。
typedef struct BTNode
{
elementtype data;
struct BTNode *lchild,*rchild;
}BTNode;
②三叉链表结点:除二叉链表的三个域之外,再增加一个指针域,用来指向结点的父结点。
typedef struct BTNode
{
elementtype data;
struct BTNode *lchild,*rchild,*parent;
}BTNode;
二)、二叉树的遍历
1.二叉树遍历的递归算法。教材中介绍了三种(先、中、后序)方法,另三种也应会用(如按降序输出二叉排序树中结点的值)。
遍历二叉树:是指按指定的规律对二叉树中的每一个结点访问一次且仅访问一次。
先序遍历:若二叉树为空,则遍历结束;否则:
(1)访问根节点;
(2)先序遍历左子树;
(3)先序遍历右子树;
先序遍历的递归算法
void PreorderTraverse(BTNode*T)
{
if(T!=NULL)
{
visit(T->data); /访问根结点/
PreorderTraverse(T->Lchild);
PreorderTraverse(T->Rchild);
}
}
中序遍历:若二叉树为空,则遍历结束;否则:
(1)中序遍历左子树;
(2)访问根节点;
(3)中序遍历右子树;
中序遍历的递归算法
void PreorderTraverse(BTNode*T)
{
if(T!=NULL)
{
PreorderTraverse(T->Lchild);
visit(T->data); /访问根结点/
PreorderTraverse(T->Rchild);
}
}
后序遍历:若二叉树为空,则遍历结束;否则:
(1)后序遍历左子树;
(2)后序遍历右子树;
(3)访问根节点;
后序遍历的递归算法
void PreorderTraverse(BTNode*T)
{
if(T!=NULL)
{
PreorderTraverse(T->Lchild);
PreorderTraverse(T->Rchild);
visit(T->data); /访问根结点/
}
}
- 前序和中序的非递归遍历(后序非递归遍历的特殊性)。
先序遍历非递归算法:
设T是指向二叉树根结点的指针变量,非递归算法是:
若二叉树为空,则返回;否则,令p=T;
(1)访问p所指向的结点;
(2)q=p->Rchild ,若q不为空,则q进栈;
(3)p=p->Lchild ,若p不为空,转(1),否则转(4);
(4)退栈到p ,转(1),直到栈空为止。
算法实现:
#define MAXNODE 50
void PreorderTraverse(BTNode*T)
{
BTNode *Stack[MAX_NODE],*p=T,*q ;
int top=0 ;
if (T=-NULL) printf(“Binary Tree is Empty!n");
else {do
{visit( p-> data ); q—p->Rchild ;
if ( q!=NULL) stack[+Htop]q;
pp->Lchild ;
if(p——NULL){ p=stack[top];top-- ;}
while(p!=NULL);
}
}
中序遍历非递归算法
设T是指向二叉树根结点的指针变量,非递归
算法是:
若二叉树为空,则返回;否则,令p=T
(1)若p不为空,p进栈,p=p->Lchild ;
(2)否则(即p为空),退栈到p,访问p所指向的结点;
(3) p=p->Rchild ,转(1);直到栈空为止。
算法实现:
#define MAX_NODE 50
void InorderTraverse(BTNode*T)
{BTNode *Stack[MAX_NODE],*p=T;
int top=0 , bool=1 ;
if (T=-NULL) printf(" Binary Tree is Empty!\n”);
else {do
{while(p!=NULL)
{stack[+Htop]=p;p-p->Lchild; }
if (top=-0) bool=O ;
else { p=stack[top];top-- ;
visit( p->data ) ; p=p->Rchild ; }
}while (bool!=O);
}
}
后序遍历非递归算法
在后序遍历中,根结点是最后被访问的。因此,在遍历过程中,当搜索指针指向某一根结点时,不能立即访问,而要先遍历其左子树,此时根结点进栈。当其左子树遍历完后再搜索到该根结点时,还是不能访问,还需遍历其右子树。所以,此根结点还需再次进栈,当其右子树遍历完后再退栈到到该根结点时,才能被访问。
因此,设立一个状态标志变量tag ,tag=0结点暂不能访问;tag=1.结点可以被访问。
其次,设两个堆栈S1、S2 ,S1保存结点,S2保存结点的状态标志变量tag 。S1和S2共用一个栈顶指针。
设T是指向根结点的指针变量,非递归算法是:
若二叉树为空,则返回;否则,令p=T;
⑴第一次经过根结点p,不访问:p进栈S1,tag赋值0,进栈S2,p=p->Lchild。
(2)若p不为空,转(1),否则,取状态标志值tag:
(3)若tag-0:对栈S1,不访问,不出栈;修改S2栈顶元素值(tag赋值1),取S1栈顶元素的右子树,即p=S1[top]->Rchild,转(1);
(4若tag=1: S1退栈,访问该结点;直到栈空为止。
算法实现:
#define MAX_NODE 50
void PostorderTraverse( BTNode *T)
{BTNode *S1[MAX_NODE],*p=T;
int S2[MAX_NODE], top=0 , bool=1 ;
if(T==NULL) printf("Binary Tree is Empty!\n’);
else {do
{while(p!=NULL)
{S1[H+Htop]p ; S2[top]=0 ;
P=p->Lchild ;
if (top==0) bool=0 ;
else if (S2[top]==0)
i p=S1[top]->Rchild ; S2[top]=1
;}
else
{ p=S1[top];top-- ;
visit( p->data ) ; p=NULL;
/*使循环继续进行而不至
于死循环*/
}
}while (bool!=0);
}
}
- 遍历算法是基础,由此导出许多实用的算法,如求二叉树的高度、各结点的层次数、度为0、1、2的结点数,二叉树的相似、全等、复制等。
4.由二叉树的遍历的前序和中序序列或后序和中序序列可以唯一构造一棵二叉树,要会手工模拟及编写算法。由前序和后序序列不能唯一确定一棵二叉树。 - 了解二叉树的层次遍历
层次遍历二叉树,是从根结点开始遍历,按层次次序“自上而下,从左至右”访问树中的各结点。
为保证是按层次遍历,必须设置一个队列,初始化时为空。
设T是指向根结点的指针变量,层次遍历非递归算法是:
若二叉树为空,则返回;否则,令p-T,p入队;
(1)队首元素出队到p;
(2访问p所指向的结点;
(3)将p所指向的结点的左、右子结点依次入队。直到队空为止。
#define MAX_NODE50
void LevelorderTraverse(BTNode*T)
{BTNode *Queue[MAX_NODE],*p=T;
int front=0 , rear—O ;
if (p!=NULL)
{Queue[HHrear]-p;/根结点入队/
while (front<rear)
{ p-Queue[H+±front]; visit( p->data );
if(p->Lchild!=NULL)
Queue[+Hrear]-p;/左结点入队/
if(p->Rchild!=NULL)
Queue[+Hrear]-p;/左结点入队/
}
}
}
三)、二叉树的应用
1.线索化二叉树。
二叉树的线索化指的是依照某种遍历次序使二叉树成为线索二叉树的过程。
线索化的过程就是在遍历过程中修改空指针使其指向直接前驱或直接后继的过程。
仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点head,头结点的指针域的安排是:
Lchild域:指向二叉树的根结点;
Rchild域:指向中序遍历时的最后一个结点;
二叉树中序序列中的第一个结点Lchild指针域和最后一个结点Rchild指针域均指向头结点head。
2.树的存储结构。
1.双亲表示法(顺序存储结构)
用一组连续的存储空间来存储树的结点,同时在每个结点中附加一个指示器(整数域),用以指示双亲结点的位置(下标值)。
2.孩子链表表示法
树中每个结点有多个指针域,每个指针指向其一棵子树的根结点。有两种结点结构。
⑴定长结点结构
指针域的数目就是树的度。
其特点是:链表结构简单,但指针域的浪费明显。在一棵有n个结点,度为k的树中必有n (k-1)+1空指针域。
(2不定长结点结构
树中每个结点的指针域数量不同,是该结点的度,没有多余的指针域,但操作不便。
⑶复合链表结构
对于树中的每个结点,其孩子结点用带头结点的单链表表示。n个结点的树有n个(孩子)单链表(叶子结点的孩子链表为空),而n个头结点又组成一个线性表且以顺序存储结构表示。
3孩子兄弟表示法(二叉树表示法)
以二叉链表作为树的存储结构。两个指针域:分别指向结点的第一个子结点和下一个兄弟结点。
3.树和二叉树的相互转换
由于二叉树和树都可用二叉链表作为存储结构,对比各自的结点结构可以看出,以二叉链表作为媒介可以导出树和二叉树之间的一个对应关系。
从物理结构来看,树和二叉树的二叉链表是相同的,只是对指针的逻辑解释不同而已。
从树的二叉链表表示的定义可知,任何一棵和树对应的二叉树,其右子树一定为空。
对于一般的树,可以方便地转换成一棵唯一的二叉树与之对应。将树转换成二叉树在“孩子兄弟表示法”中已给出,其详细步骤是:
⑴加虚线。在树的每层按从“左至右”的顺序在兄弟结点之间加虚线相连。
(2)去连线。除最左的第一个子结点外,父结点与所有其它子结点的连线都去掉。
(3旋转。将树顺时针旋转450,原有的实线左斜。
(4)整型。将旋转后树中的所有虚线改为实线,并向右斜。
2二叉树转换成树
对于一棵转换后的二叉树,如何还原成原来的树?其步骤是:
(1)加虚线。若某结点i是其父结点的左子树的根结点,则将该结点i的右子结点以及沿右子链不断地搜索所有的右子结点,将所有这些右子结点与i结点的父结点之间加虚线相连。
(2)去连线。去掉二叉树中所有父结点与其右子结点之间的连线。
(3)规整化。将图中各结点按层次排列且将所有的虚线变成实线。
4.掌握树和森林的遍历及与对应二叉树遍历的关系
树的遍历
由树结构的定义可知,树的遍历有二种方法。
(1)先序遍历:先访问根结点,然后依次先序遍历完每棵子树。
(2)后序遍历:先依次后序遍历完每棵子树,然后访问根结点。
树的先序遍历实质上与将树转换成二叉树后对二叉树的先序遍历相同。
树的后序遍历实质上与将树转换成二叉树后对二叉树的中序遍历相同。
2森林的遍历
设F={T1,T2,…,T}是森林,对F的遍历有二种方法。
(1)先序遍历:按先序遍历树的方式依次遍历F中的每棵树。
(2)中序遍历:按后序遍历树的方式依次遍历F中的每棵树。
四)、哈夫曼树
1.哈夫曼树的定义、构造及求哈夫曼编码。
哈夫曼树又称最优树,是一类带权路径长度最短的树。
最优二叉树(哈夫曼树)基本概念
结点路径:从树中一个结点到另一个结点的之间的分支构成这两个结点之间的路径。路径长度:结点路径上的分支数目称为路径长度。
树的路径长度:从树根到每一个结点的路径长度之和。
结点的带权路径长度:从该结点的到树的根结点之间的路径长度与结点的权(值)的乘积。
权(值):各种开销、代价、频度等的抽象称呼。
树的带权路径长度:树中所有叶子结点的带权路径长度之和,记做:
WPL=wi×l+w2×l十…十Wn×ln一>wi>l(i=1,2,…,n)
其中:n为叶子结点的个数;w;为第i个结点的权值;l;为第i个结点的路径长度。
Huffman树:具有n个叶子结点(每个结点的权值为w;)的二叉树不止一棵,但在所有的这些二叉树中,必定存在一棵WPL值最小的树,称这棵树为Huffman树(或称最优树)。
Huffman树的构造
①根据n个权值{w1,w2,…,wn},构造成n棵二叉树的集合F={T1,T2,…,Tn},其中每棵二叉树只有一个权值为wi的根结点,没有左、右子树;
②在F中选取两棵根结点权值最小的树作为左、右子树构造一棵新的二叉树,且新的二叉树根结点权值为其左、右子树根结点的权值之和;
③在F中删除这两棵树,同时将新得到的树加入F中;
④重复②、③,直到F只含一颗树为止。构造Huffman树时,为了规范,规定F一{T1,T2,…,Tn}中权值小的二叉树作为新构造的二叉树的左子树,权值大的二叉树作为新构造的二叉树的右子树;在取值相等时,深度小的二叉树作为新构造的二叉树的左子树,深度大的二叉树作为新构造的二叉树的右子树。
Huffman编码
在电报收发等数据通讯中,常需要将传送的文字转换成由二进制字符0、1组成的字符串来传输。为了使收发的速度提高,就要求电文编码要尽可能地短。此外,要设计长短不等的编码,还必须保证任意字符的编码都不是另一个字符编码的前缀,这种编码称为前缀编码。
Huffman树可以用来构造编码长度不等且译码不产生二义性的编码。
Huffman编码方法
以字符集C作为叶子结点,次数或频度集W作为结点的权值来构造Huffman树。规定Huffman树中左分支代表“0”,右分支代表“1”。从根结点到每个叶子结点所经历的路径分支上的“0”或“1"所组成的字符串,为该结点所对应的编码,称之为Huffman编码。由于每个字符都是叶子结点,不可能出现在根结点到其它字符结点的路径上,所以一个字符的Huffman编码不可能是另一个字符的Huffman编码的前缀。
六、图
一)、图的概念
1.图的基本概念
图是一种比线性表和树更为复杂的数据结构。
图结构:是研究数据元素之间的多对多的关系。在这种结构中,任意两个元素之间可能存在关系。即结点之间的关系可以是任意的,图中任意元素之间都可能相关。
图的应用极为广泛,已渗入到诸如语言学、逻辑学、物理、化学、电讯、计算机科学以及数学的其它分支。
(1)图的基本概念
图的定义和术语
一个图(G)定义为一个偶对(V,E),记为G=(V,E)。其中: V是顶点(Vertex)的非空有限集合,记为V(G); E是无序集V&V的一个子集,记为E(G),其元素是图的弧(Arc)。
将顶点集合为空的图称为空图。其形式化定义为:
G=(V, E)
V= {vIv∈data object}
E={<v,W>| v,w∈V ^p(v,w)}
P(v,w)表示从顶点v到顶点w有一条直接通路。
弧(Arc) :表示两个顶点v和w之间存在一个关系,用顶点偶对<v,W>表示。通常根据图的顶点偶对将图分为有向图和无向图。
1)无向图和有向图
有向图(Digraph):若图G的关系集合E(G)中,顶点偶对<v,w>的v和w之间是有序的,称图G是有向图。在有向图中,若<v,w>∈E(G),表示从顶点v到顶点w有一条弧。其中: v称为弧尾(tail)或始点(initialnode), w称为弧头(head)或终 点(terminal node)。
无向图(Undigraph):若图G的关系集合E(G)中,顶点偶对<v,w>的v和w之间是无序的,称图G是无向图。
2)端点和相邻点
在一个无向图中,若存在一条边(i,j),则称顶点i、j为该边的两个端点,并称它们互为相邻点(或者邻接点)。
3)度、入度和出度
顶点v的度记为D(v)。对于无向图,每个顶点的度定义为以该顶点为一个端点的边数。
对于有向图,顶点v的度分为入度和出度,入度是以该顶点为终点的入边数目;出度是以该顶点为起点的出边数目,该顶点的度等于其入度和出度之和。
4)子图
设有两个图G=(V,E)和G’=(V’,E’),若V’是V的子集,即V’ V,且E’是E的子集,即E’ E,则称G’是G的子图。
5)完全无向图和完全有向图
对于有向图,若具有n(n-1)条边,则称之为完全有向图。
6)稀疏图和稠密图
边数较少(边数e<<nlog2n,其中n为顶点数)的图称为稀疏图。边总较多的图称为稠密图。
7)路径和路径长度
在一个图G中,从顶点i到顶点j的一条路径是一个顶点序列i=i0、i1、…、im=j,若是无向图,则(ik-1,ik)∈E(G),(1≤k≤m),若该图是有向图,则<ik-1,ik>∈E(G),(1≤k≤m),其中顶点i称为该路径的开始点,顶点j称为该路径的结束点。
路径长度是指一条路径上经过的边的数目。
8)简单路径
若一条路径的顶点序列中顶点不重复出现,称该路径为简单路径。
9)回路(环)
若一条路径上的开始点和结束点为同一个顶点,则称该路径为回路(环)。除开始点与结束点相同外,其余顶点不重复出现的回路称为简单回路(简单环)。
10)连通、连通图和连通分量
在无向图G中,若从顶点i到顶点j有路径,则称顶点i和j是连通的。若图G中任意两个顶点都是连通的,则称G为连通图,否则为非连通图。无向图G中极大连通子图称为G的连通分量。
11)强连通图和强连通分量
在有向图G中,若任意两个顶点i和j都是连通的,即从顶点i到j和从顶点j到i都存在路径,则称该图是强连通图。
有向图G中极大强连通子图称为G的强连通分量。
12)权和网
在一个图中,每条边可以标上具有某种含义的数值,该数值称为该边的权。边上带权的图称为带权图,也称为网。
2.图的存储结构,重点是邻接矩阵和邻接表
(1)邻接矩阵
邻接矩阵是表示顶点之间相邻关系的矩阵。设G=(V,E)是具有n个顶点的图,顶点编号依次为0、1、…、n-1,则G的邻接矩阵是具有如下定义的n阶方阵A。
若G是不带权的图:
若G是带权图或网,则邻接矩阵可定义为(其中wij为边(i,j)或<i,j>的权):
图的邻接矩阵具有这样的特点:
对于n个顶点e条边的图采用邻接矩阵存储时占用存储空间为O(n2),与边数e无关(不考虑压缩存储),特别适合存储稠密图;任何图的邻接矩阵表示是唯一的;
图采用邻接矩阵存储时判断两个顶点i、j之间是否有边十分容易。
图的邻接矩阵类型声明:
#define MAXVEX 100 //图中最大顶点个数
typedef char VertexType[10];
//定义VertexType为字符串类型
typedef struct vertex
{ int adjvex; //顶点编号
VertexType data; //顶点的信息
} VType; //顶点类型
typedef struct graph
{ int n,e; //n为实际顶点数,e为实际边数
VType vexs[MAXVEX]; //顶点集合
int edges[MAXVEX][MAXVEX]; //边的集合
} MatGraph; //图的邻接矩阵类型
(2)邻接表
邻接表是图的一种链式存储结构。
在邻接表中,对图中每个顶点建立一个带头结点的单链表,把该顶点的所有相邻点串起来。
所有的头结点构成一个数组,称为头结点数组,用adjlist表示,第i个单链表adjlist[i]中的结点表示依附于顶点i的边,也就是说头结点数组元素的下标与顶点编号一致。
每个单链表中每个结点由3个域组成:
顶点域adjvex(用以指示该相邻点在头结点数组中的下标)
权值域weight(存放对应边的权值)
指针域nextarc(用以指向依附于顶点i的下一条边所对应的结点)
为了统一,对于不带权图,weight域均置为1;对于带权图,weight置为相应边的权值。
图的邻接表具有这样的特点:
对于n个顶点e条边的图采用邻接表存储时占用存储空间为O(n+e),与边数e有关,特别适合存储稀疏图;
图的邻接表表示不一定是唯一的,这是因为邻接表的每个单链表中,各结点的顺序是任意的;
图采用邻接表存储时查找一个顶点的所有相邻顶点十分容易。
一个图的邻接表存储结构的类型声明如下:
typedef char VertexType[10]; //VertexType为字符串类型
typedef struct edgenode
{ int adjvex; //相邻点序号
int weight; //边的权值
struct edgenode *nextarc; //下一条边的顶点
} ArcNode; //每个顶点建立的单链表中边结点的类型
typedef struct vexnode
{ VertexType data; //存放一个顶点的信息
ArcNode *firstarc; //指向第一条边结点
} VHeadNode; //单链表的头结点类型
typedef struct
{ int n,e; //n为实际顶点数,e为实际边数
VHeadNode adjlist[MAXVEX]; //单链表头结点数组
} AdjGraph; //图的邻接表类型
3.图的算法,特别是图的生成算法
图的生成算法,包括以邻接表和邻接矩阵两种存储结构的图的生成算法。
(1)图的以邻接表为存储结构的生成算法
基本思路:
先创建邻接表头结点数组,并置所有头结点的firstarc为NULL。
遍历邻接矩阵数组A,当A[i][j]≠0且A[i][j]≠∞时,说明有一条从顶点i到顶点j的边,建立一个边结点p,置其adjvex域为j,其weight域为A[i][j](aij),将p结点插入到顶点i的单链表头部。
void CreateGraph(AdjGraph *&G,int A[][MAXVEX],int n,int e)
{ int i,j;
ArcNode *p;
G=(AdjGraph *)malloc(sizeof(AdjGraph));
G->n=n; G->e=e;
for (i=0;in;i++) //邻接表中所有头结点的指针域置空
G->adjlist[i].firstarc=NULL;
for (i=0;in;i++) //检查A中每个元素
for (j=G->n-1;j>=0;j--)
if (A[i][j]>0 && A[i][j]<INF) //存在一条边
{ p=(ArcNode *)malloc(sizeof(ArcNode)); //创建结点p
p->adjvex=j;
p->weight=A[i][j];
p->nextarc=G->adjlist[i].firstarc; //头插法插入p
G->adjlist[i].firstarc=p;
}
}
(2)图的以邻接矩阵为存储结构的生成算法
由邻接矩阵数组A、顶点数n和边数e建立图G的邻接矩阵存储结构。
void CreateGraph(MatGraph &g,int A[][MAXVEX],int n,int e)
{ int i,j;
g.n=n; g.e=e;
for (i=0;i<n;i++)
for (j=0;j<n;j++)
g.edges[i][j]=A[i][j];
}
- 邻接矩阵和邻接表存储结构间的相互转换
(1)设计一个将邻接矩阵g转换为邻接表G的算法;
先分配G的内存空间并将所有头结点的firstarc域置为NULL。
遍历邻接矩阵g,查找元素值不为0且不为∞的元素g.edges[i][j],找到这样的元素后创建一个边结点p,将其插入到G->adjlist[i]单链表的首部。
void MatToAdj(MatGraph g,AdjGraph *&G)
//将邻接矩阵g转换成邻接表G
{ int i,j; ArcNode *p;
G=(AdjGraph *)malloc(sizeof(AdjGraph));
for (i=0;i<g.n;i++) //邻接表中所有头结点的指针域置初值
G->adjlist[i].firstarc=NULL;
for (i=0;i<g.n;i++) //检查邻接矩阵中每个元素
for (j=g.n-1;j>=0;j--)
if (g.edges[i][j]!=0 && g.edges[i][j]!=INF) //有一条边
{ p=(ArcNode *)malloc(sizeof(ArcNode)); //创建结点p
p->adjvex=j;
p->weight=g.edges[i][j];
p->nextarc=G->adjlist[i].firstarc; //头插法插入p
G->adjlist[i].firstarc=p;
}
G->n=g.n;G->e=g.e; //置顶点数和边数
}
(2)设计一个将邻接表G转换为邻接矩阵g的算法
先将邻接矩阵g中所有元素初始化:对角线元素置为0,其他元素置为∞。
然后遍历邻接表的每个单链表,当访问到G->adjlist[i]单链表的结点p时,将邻接矩阵g的元素g.edges[i][p->adjvex]修改为p->weight。
void AdjToMat(AdjGraph *G,MatGraph &g)
//将邻接表G转换成邻接矩阵g
{ int i,j; ArcNode *p;
for (i=0;in;i++)
for (j=0;j<G->n;j++)
if (i==j) g.edges[i][i]=0; //对角线置为0
else g.edges[i][j]=INF;
for (i=0;in;i++)
{ p=G->adjlist[i].firstarc;
while (p!=NULL)
{ g.edges[i][p->adjvex]=p->weight;
p=p->nextarc;
}
}
g.n=G->n; g.e=G->e; //置顶点数和边数
}
二)、图的遍历
1.图的遍历算法:深度优先遍历,宽度优先遍历。
给定一个图G=(V,E)和其中的任一顶点v,从顶点v出发,访问图G中的所有顶点而且每个顶点仅被访问一次,这一过程称为图的遍历。
为了避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点。
为此设一个辅助数组visited[],用以标记顶点是否被访问过,其初态应为0(false)。一旦一个顶点i被访问,则visited[i]=1(true)。
(1)深度优先遍历算法
深度优先遍历(Depth First Search,简称DFS):
① 访问顶点v;
② 选择一个与顶点v相邻且没被访问过的顶点w,从w出发深度优先遍历。
③ 直到图中与v相邻的所有顶点都被访问过为止。
实现深度优先遍历的递归算法如下:
visited[MAXVEX]={0}; //全局变量
void DFS(AdjGraph *G,int v)
{ int w; ArcNode *p;
printf("%d ",v); //访问v顶点
visited[v]=1;
p=G->adjlist[v].firstarc; //找v的第一个相邻点
while (p!=NULL) //找v的所有相邻点
{ w= p->adjvex; //顶点v的相邻点w
if (visited[w]==0) //顶点w未访问过
DFS(G,w); //从w出发深度优先遍历
p=p->nextarc; //找v的下一个相邻点
}
}
(2) 广度优先遍历算法
广度优先遍历(Breadth First Search,简称BFS):
①访问顶点v;
②访问顶点v的所有未被访问过的相邻点,假设访问次序是vi1,vi2,…,vit 。
③按 vi1,vi2,…,vit 的次序,访问每个顶点的所有未被访问过的相邻点,直到图中所有和初始点v有路径相通的顶点都被访问过为止。
实现广度优先搜索的算法如下:
void BFS(AdjGraph *G,int vi)
{ int i,v,visited[MAXVEX]; ArcNode *p;
int Qu[MAXVEX],front=0,rear=0; //定义一个循环队列Qu
for (i=0;in;i++) visited[i]=0; //visited数组置初值0
printf("%d ",vi); //访问初始顶点
visited[vi]=1;
rear=(rear=1)%MAXVEX;Qu[rear]=vi; //初始顶点进队
while (front!=rear) //队不为空时循环
{ front=(front+1) % MAXVEX;
v=Qu[front]; //出队顶点v
p=G->adjlist[v].firstarc; //查找v的第一个相邻点
while (p!=NULL) //查找v的所有相邻点
{ if (visited[p->adjvex]==0) //未访问过则访问之
{ printf("%d ",p->adjvex); //访问该点并进队
visited[p->adjvex]=1;
rear=(rear+1) % MAXVEX;
Qu[rear]=p->adjvex;
}
p=p->nextarc; //查找v的下一个相邻点
}
}
}
2.图的连通性,掌握连通分量的求法
(1)连通图与连通分量
1)连通图:无向图 G 中,若对任意两点,从顶点 Vi 到顶点 Vj 有路径,则称 Vi 和 Vj 是连通的,图 G 是 连通图
2)连通分量:无向图 G 的连通子图称为 G 的连通分量
任何连通图的连通分量只有一个,即其自身,而非连通的无向图有多个连通分量
(2)强连通图与强连通分量
1)强连通图:有向图 G 中,若对任意两点,从顶点 Vi 到顶点 Vj,都存在从 Vi 到 Vj 以及从 Vj 到 Vi 的路 径,则称 G 是强连通图
2)强连通分量:有向图 G 的强连通子图称为 G 的强连通分量
强连通图只有一个强连通分量,即其自身,非强连通的有向图有多个强连通分量。
(3)连通分量的求法
这里主要谈及强连通分量(以下简称SCC,strongly connected component)三种常见的求法(以下涉及的图均为有向图),即Kosaraju、Tarjan和Gabow。三种算法背后的基础思想都是DFS,只是它们通过DFS获得了不同的信息。
Kosaraju
Kosaraju的方法也就是导论第二版中文22章中讲的方法, 即广为人知的两遍DFS。Kosaraju算法说白了就是先对原图来一遍DFS, 再把所有边的方向都倒过来,按照刚才DFS求出的结点完成时间的逆序,再来一遍DFS。
可以简单证明一下:
如果我们在第一遍DFS的时候,A先于B被访问,且A中的第一个被访问到的结点是x, 那A和B中所有的结点显然都能在x之后被访问到。于是x的完成时间要晚于B中任何一个结点, 从而A的完成时间晚于B。
如果B先于A被访问,由于A和B是不同的两个SCC,而且只有A到B的边,于是B就不能到达A。 那么当B中的结点被访问完之后,A中的点仍然处于为访问状态,自然A的完成时间也就晚于B了。
所以在第二遍DFS时,第一次取到的那个完成时间最晚的结点u, 它所在的SCC在转置图中就不能有指向外的边。于是对转置图的第二遍DFS, 从u开始,便能轻易走遍所有处于同一SCC的结点。后续的遍历步骤也就类似了。
时间复杂度自然是算在两次DFS头上,O(V+E)。
Tarjan
Tarjan 算法是基于深度优先搜索的算法,用于求解图的连通性问题。Tarjan 算法可以在线性时间内求出无向图的割点与桥,进一步地可以求解无向图的双连通分量;同时,也可以求解有向图的强连通分量、必经点与必经边。
总的来说, Tarjan算法基于一个观察,即:同处于一个SCC中的结点必然构成DFS树的一棵子树。 我们要找SCC,就得找到它在DFS树上的根。
如果DFS访问到了某个结点u,又顺着u来到了结点v, 但从v发出了一条反向边,指向了u的前驱w,那根据DFS的性质, u->v->w->u构成了一个环。这一堆东西必然处于同一个SCC。 所以某个要找到SCC子树的根,就得找那个在DFS树中最早被发现的结点,且这个结点要与它的一堆后继结点形成环。
这时候DFS的特性就派上用场了。最早发现的结点可以通过记录发现时间来实现,而反向边的判断可以通过结点颜色,即访问状态来实现。 定义一个结点的low值为:从该节点的子树结点可达的,尚未求出属于哪个SCC的结点的最早访问时间。 由于SCC构成子树,所以求没求出某个结点所在的SCC用栈来刻画就可以了: 每次访问到一个结点u,记录发现时间visit,并将它推到栈里去。 如果从u可达的结点v没访问过,那么访问v,用v的low值更新u; 否则,如果v已访问过,那就看看它在不在栈中。如果在,说明还没确定v到底属于哪个SCC, 这时(u, v)就是一条反向边了,根据v的visit值,更新u的low值即可。 最后回到u结点时,如果u的low值和visit相等了,显然u就是我们要找的根节点了。 从栈里把u和其上所有结点弹出来,这一堆东西就在一个SCC里了。
Gabow
Gabow与Tarjan的思路是一致的。但Gabow使用了另一个栈来找出SCC子树的根。 Gabow使用的栈S与Tarjan一样,保存尚未决定属于哪个SCC的结点; 栈P保持如下性质:栈顶结点始终具有最小的visit值, 即保持栈顶元素的visit值小于等于当前发现的反向边指向的祖先结点的visit值。
栈S和P都随着DFS的进行增长。若当前正在访问结点u,从u可达点v, 先将u压入两个栈中。这一步骤相当于Tarjan中初始化一个结点的low值为当前visit值。 如果v没有访问过,则访问v;否则判断v是否在S栈中。 如果在,那么(u, v)为反向边,此时从P栈顶弹出那些晚于v被发现的结点。为啥? 因为此时v是u的后继结点,我们得找出以u为根的子树结点能到达的最早访问的结点, 类似于Tarjan算法中对low值的更新。
再一次回到u时,若P栈栈顶的元素就是u,表明u就是SCC子树的根。 与Tarjan类似,从S栈中弹出元素即找到了一个SCC
Gabow时间复杂度也为O(V+E)
3.生成树的构造方法
(1) 什么是图的生成树和最小生成树
在一个无向连通图G中,如果取它的全部顶点和一部分边构成一个子图G',即V(G')=V(G) 和 E(G') E(G)。
若边集E(G')中的边既将图G中的所有顶点连通又不形成回路,则称子图G'是原图G的一棵生成树。
(2)生成树的构造方法
1)可以通过遍历方式产生一个无向图的生成树:
通过深度优先遍历产生的生成树称为深度优先生成树。
通过广度优先遍历产生的生成树称为广度优先生成树。
2)可以通过遍历方式产生一个无向图的生成树。
通过深度优先遍历产生的生成树称为深度优先生成树。
通过广度优先遍历产生的生成树称为广度优先生成树。
(3)无向图进行遍历时:
连通图:仅需要从图中任一顶点出发,进行深度优先遍历或广度优先遍历便可以访问到图中所有顶点,因此连通图的一次遍历所经过的边的集合及图中所有顶点的集合就构成了该图的一棵生成树。
非连通图:它由多个连通分量构成的,则需要从每个连通分量的任一顶点出发进行遍历,每次从一个新起点出发进行遍历过程得到的顶点访问序列恰为各个连通分量中的顶点集。每个连通分量产生的生成树合起来构成整个非连通图的生成树。
由一个带权无向图可能产生多棵生成树, 把具有权之和最小的生成树称为图的最小生成树(Minimum Cost Spanning Tree,简称MCST)。
构造一个图的最小生成树主要有两个算法,即普里姆算法和克鲁斯卡尔算法。
- 构造生成树的算法
构造一个图的最小生成树主要有两个算法,即普里姆算法和克鲁斯卡尔算法。
(1)普里姆算法
普里姆(Prim)算法是一种构造性算法。
G=(V,E) T=(U,TE)是G的从顶点v出发构造的最小生成树,步骤如下:
1)初始化U={v}。以v到其他顶点的所有边为候选边;
2)重复以下步骤n-1次,使得其他n-1个顶点被加入到U中:
① 从候选边中挑选权值最小的边加入TE,设该边在V-U中的顶点是k,将k加入U中;
② 考察当前V-U中的所有顶点j,修改候选边:若(k,j)的权值小于原来和顶点j关联的候选边,则用(k,j)取代后者作为候选边。
为了方便,假设图G采用邻接矩阵g存储,对应的Prim(g,v)算法如下:
void Prim(MatGraph g,int v) //输出求得的最小生树的所有边
{ int lowcost[MAXVEX]; //建立数组lowcost
int closest[MAXVEX]; //建立数组closest
int min,i,j,k;
for (i=0;i<g.n;i++) //给lowcost[]和closest[]置初值
{ lowcost[i]=g.edges[v][i];
closest[i]=v;
}
for (i=1;i<g.n;i++) //构造n-1条边
{ min=INF; k=-1;
for (j=0;j<g.n;j++) //在(V-U)中找出离U最近的顶点k
if (lowcost[j]!=0 && lowcost[j]<min)
{ min=lowcost[j];
k=j; //k为最近顶点的编号
}
printf(" 边(%d,%d),权值为%d\n",closest[k],k,min);
lowcost[k]=0; //标记k已经加入U
for (j=0;j<g.n;j++) //修正数组lowcost和closest
if (lowcost[j]!=0 && g.edges[k][j]<lowcost[j])
{ lowcost[j]=g.edges[k][j];
closest[j]=k;
}
}
}
普里姆算法中有两重for循环,所以时间复杂度为O(n2),其中n为图的顶点个数。
由于与e无关,所以普里姆算法特别适合于稠密图求最小生成树。
(2)克鲁斯卡尔算法
克鲁斯卡尔(Kruskal)算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
G=(V,E) T=(U,TE),构造最小生成树T的步骤如下:
1)置U的初值等于V(即包含有G中的全部顶点),TE的初值为空集(即图T中每一个顶点都构成一个连通分量)。
2)将图G中的边按权值从小到大的顺序依次选取:若选取的边未使生成树T形成回路,则加入TE;否则舍弃,直到TE中包含n-1条边为止。
实现克鲁斯卡尔算法的关键是如何判断选取的边是否与生成树中已保留的边形成回路?
为此设置一个辅助数组vset[0…n-1],它用于判定两个顶点之间是否连通。
数组元素vset[i](初值为i)代表编号为i的顶点所属的连通子图的编号。
对于边(i,j),若vset[i]=vset[j]不选;否则选取。
一旦选取边(i,j),将两个连通分量的所有vset值改为vset[i]或者vset[j]。
首先需要对所有边按权值递增排序,为此定义一个具有如下类型的边数组E[]:
typedef struct
{ int u; //边的起始顶点
int v; //边的终止顶点
int w; //边的权值
} Edge; //边数组元素类型
从图的邻接矩阵g中提取出边数组E,然后按边权值递增排序。
实现克鲁斯卡尔算法:假设采用直接插入排序法对边集E按权值递增排序。
void SortEdge(Edge E[],int e)
//直接插入排序:对E数组按权值递增排序
{ int i,j,k=0;
Edge temp;
for (i=1;i<e;i++)
{ temp=E[i];
j=i-1; //从右向左在有序区E[0…i-1]中找E[i]的插入位置
while (j>=0 && temp.w<E[j].w)
{ E[j+1]=E[j]; //将权值大于E[i].w的记录后移
j–;
}
E[j+1]=temp; //在j+1处插入E[i]
}
}
void Kruskal(MatGraph g) //输出求得的最小生树的所有边
{ int i,j,u1,v1,sn1,sn2,k;
int vset[MAXVEX]; //建立数组vset
Edge E[MAXE]; //建立存放所有边的数组E
k=0; //k统计E数组中边数
for (i=0;i<g.n;i++) //由图的邻接矩阵g产生的边集数组E
for (j=0;j<=i;j++) //提取邻接矩阵中部分元素
if (g.edges[i][j]!=0 && g.edges[i][j]!=INF)
{ E[k].u=i; E[k].v=j;
E[k].w=g.edges[i][j];
k++; //累加边数
}
SortEdge(E,k); //对E数组按权值递增排序
for (i=0;i<g.n;i++)
vset[i]=i; //初始化辅助数组
k=1; //k表示当前构造生成树的第几条边,初值为1
j=0; //j为E数组下标,初值为0
while (k<g.n) //生成的边数小于n时循环
{ u1=E[j].u; v1=E[j].v; //取一条边的头尾顶点
sn1=vset[u1];
sn2=vset[v1]; //分别得到两顶点所属的集合编号
if (sn1!=sn2) //两顶点属不同的集合,取该边
{ printf(" 边(%d,%d),权值为%d\n",u1,v1,E[j].w);
k++; //生成的边数增1
for (i=0;i<g.n;i++) //两个集合统一编号
if (vset[i]==sn2) //集合编号为sn2的改为sn1
vset[i]=sn1;
}
j++; //扫描下一条边
}
}
上述克鲁斯卡尔算法不是最优算法,在改进后可达到O(elog2e)。一般认为克鲁斯卡尔算法的时间复杂度是O(elog2e)。
由于仅仅与e有关,所以克鲁斯卡尔算法特别适合于稀疏图求最小生成树。
三)、图的应用
1.有向无环图的概念及应用
(1)图相关概念
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V, E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
图按照边的有无方向性分为无向图和有向图。
图中某个节点与其他节点的直连边条数称为该节点的度。有向图中,指向其他节点的边成为出度,被其他节点指向的边称为入度。
如果在有向图中,无法从某个顶点出发经过若干条边回到该点,则这个图是一个有向无环图(DAG图)。
在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(DAG,directed acyclic graph)。
因为有向图中一个点经过两种路线到达另一个点未必形成环,因此有向无环图未必能转化成树,但任何有向树均为有向无环图。
(2)应用
拓扑排序
有向无环图的拓扑排序为所有边的起点都出现在其终点之前的排序。能构成拓扑排序的图一定没有环,因为环中的一条边必定从排序较后的顶点指向比其排序更前的顶点。基于此,拓扑排序可以被用来定义有向无环图:当且仅当一个有向图有拓扑排序,它是有向无环图。一般情况下,拓扑排序并非唯一。有向无环图仅仅在存在一条路径可以包含其所有顶点的情况下,有唯一的拓扑排序方式,这时,拓扑排序与它们在这条路径中出现的顺序相同。
有向无环图的拓扑排序族等同于其可达性的线性拓展族。 因此,偏序关系相同的任意两个图会有相同的拓扑排序集。
组合计数
罗宾逊 (1973)研究了有向无环图的图计数问题。
其他应用:调度、数据处理网络、 因果结构、系谱学和版本历史、 引用图、数据压缩等。
- 拓扑排序的方法和算法。
设G=(V,E)是一个具有n个顶点的有向图,图中用顶点表示活动,用边表示活动之间的优先关系,这样的有向图称为用顶点表示活动的网,简称AOV(Activity On vertex Network)网。
该网中顶点序列v1、v2、…、vn称为一个拓扑序列,当且仅当该顶点序列满足下列条件:若<vi,vj>是图中的边(即从顶点vi到顶点vj有一条路径),则在序列中顶点vi必须排在顶点vj之前。
在一个有向图中找一个拓扑序列的过程称为拓扑排序。
拓扑排序步骤如下:
(1)从AOV网中选择一个没有前驱(即入度为0)的顶点并且输出它。
(2)从AOV网中删去该顶点,并且删去从该顶点发出的全部有向边。
(3)重复上述两步,直到剩余的网中不再存在没有前驱的顶点为止。
对任一有向图进行拓扑排序有两种结果:
图中全部顶点都包含在拓扑序列中,这说明该图中不存在有向回路;
图中部分顶点未被包含在拓扑序列中,这说明该图中存在有向回路。所以可以采用拓扑排序判断一个有向图中是否存在回路。
典型实现算法
Kahn算法
基于DFS的算法
- 关键路径的手工模拟和算法。
用带权有向图(DAG)描述工程的预计进度,以顶点表示事件,有向边表示活动,边e的权c(e)表示完成活动e所需的时间(比如天数),或者说活动e持续时间。
图中入度为0的顶点表示工程的开始事件(如开工仪式),称为源点;出度为0的顶点表示工程结束事件,称为汇点。则称这样的有向图为AOE网(Activity On Edge)。
整个工程完成的时间为:从有向图的源点到汇点的最长路径,具有最大长度的路径叫关键路径。
“关键活动”指的是:该边上的权值增加将使有向图上的最长路径的长度增加。
注意:在一个AOE网中,可以有不止一条的关键路径。
关键路径是由关键活动构成的。
下面介绍求关键活动的步骤。
事件的最早开始和最迟开始时间
(1)事件v的最早开始时间:规定源点事件的最早开始时间为0。定义图中任一事件v的最早开始时间(early event) ee(v)等于x、y、z到v所有路径长度的最大值,即:
ee(v)=0 当v为源点时
ee(v)=MAX{ee(x)+a,ee(y)+b,ee(z)+c} 否则
(2)事件v的最迟开始时间:定义在不影响整个工程进度的前提下,事件v必须发生的时间称为v的最迟开始时间(late event) ,记作le(v)。le(v)应等于ee(y)与v到汇点的最长路径长度之差,即:
le(v)=ee(v) 当v为汇点时
le(v)=MIN{le(x)-a,le(y)-b,le(z)-c} 否则
活动的最早开始时间和最迟开始时间
(3)活动a的最早开始时间e(a):指该活动起点x事件的最早开始时间,即:
e(a)=ee(x)
(4)活动a的最迟开始时间l(a):指该活动终点y事件的最迟开始时间与该活动所需时间之差,即:
l(a)=le(y)-c
(5)关键活动:对于每个活动a,求出d(a)=l(a)-e(a),若d(a)为0,则称活动a为关键活动。
对关键活动来说,不存在富余时间。显然,关键路径上的活动都是关键活动。
找出关键活动的意义在于,可以适当地增加对关键活动的投资(人力、物力等),相应地减少对非关键活动的投资,从而减少关键活动的持续时间,缩短整个工程的工期。
1.单源点到其它各顶点的最短路径。
单源最短路径算法:
求单源最短路径算法是由狄克斯特拉(Dijkstra)提出的,称为狄克斯特拉算法。
给定一个图G和一个起始顶点即源点v,求v到其他顶点的最短路径长度及最短路径。
狄克斯特拉算法的具体步骤如下:
① 初始时,顶点集S只包含源点,即S={v},顶点v到自已的距离为0。顶点集U包含除v外的其他顶点,源点v到U中顶点i的距离为边上的权(若v与i有边<v,i>)或∞(若顶点i不是v的出边相邻点)。
②从U中选取一个顶点u,它是源点v到U中距离最小的一个顶点,然后把顶点u加入S中(该选定的距离就是源点v到顶点u的最短路径长度)。
③ 以顶点u为新考虑的中间点,修改源点v到U中各顶点j(j∈U)的距离。
④重复步骤②和③直到S包含所有的顶点即U为空。
实现狄克斯特拉算法:
设置一个数组dist[0…n-1],dist[i]用来保存从源点v到顶点i的目前最短路径长度。
path[j]保存源点到顶点j的最短路径,实际上为最短路径上的前一个顶点u,即path[j]=u。
当求出最短路径后由path[j]向前推出源点到顶点j的最短路径。
最后求出顶点0到1~6各顶点的最短距离分别为4、5、6、10、9和16。
path值为{0,0,1,0,5,2,4}。
以求顶点0到顶点4的最短路径为例说明通过path求最短路径的过程:path[4]=5,path[5]=2,path[2]=1,path[1]=0(源点),则顶点0到顶点4的最短路径逆为4、5、2、1、0,则正向最短路径为0→1→2→5→4。
对应的狄克斯特拉算法如下(v为源点编号):
void Dijkstra(MatGraph g,int v)
//求从v到其他顶点的最短路径
{ int dist[MAXVEX]; //建立dist数组
int path[MAXVEX]; //建立path数组
int S[MAXVEX]; //建立S数组
int mindis,i,j,u=0;
for (i=0;i<g.n;i++)
{ dist[i]=g.edges[v][i]; //距离初始化
S[i]=0; //S[]置空
if (g.edges[v][i]<INF) //路径初始化
path[i]=v; //v→i有边时,置i前一顶点为v
else //v→i没边时,置i前一顶点为-1
path[i]=-1;
}
S[v]=1; //源点编号v放入S中
for (i=0;i<g.n-1;i++) //循环向S中添加n-1个顶点
{ mindis=INF; //mindis置最小长度初值
for (j=0;j<g.n;j++) //选取不在S中且有最小距离顶点u
if (S[j]==0 && dist[j]<mindis)
{ u=j;
mindis=dist[j];
}
S[u]=1; //顶点u加入S中
for (j=0;j<g.n;j++) //修改不在s中的顶点的距离
if (S[j]==0)
if (g.edges[u][j]<INF
&& dist[u]+g.edges[u][j]<dist[j])
{ dist[j]=dist[u]+g.edges[u][j];
path[j]=u;
}
}
}
狄克斯特拉算法Dijkstra(g,v)的时间复杂度为O(n2)。
- 任意两顶点间的最短路径
多源最短路径算法:
求解每对顶点之间的最短路径的一个办法是:每次以一个顶点为源点,重复执行Dijkstra算法n次,这样便可以求得每一对顶点之间的最短路径。
解决该问题的另一种方法是弗洛伊德(Floyd)算法。
假设有向图G=(V,E)采用邻接矩阵g表示,另外设置一个二维数组A用于存放当前顶点之间的最短路径长度,即分量A[i][j]表示当前顶点i到顶点j的最短路径长度。
弗洛伊德算法的基本思想是递推产生一个矩阵序列A0、A1、…、Ak、…、An-1,其中Ak[i][j]表示从顶点i到顶点j的路径上所经过的顶点编号不大于k的最短路径长度。
归纳起来,弗洛伊德思想可用如下的表达式来描述:
A-1[i][j]=g.edges[i][j]
Ak[i][j]=MIN{Ak-1[i][j],Ak-1[i][k]+Ak-1[k][j]} 0≤k≤n-1
另外用二维数组path保存最短路径,它与当前迭代的次数有关,即当迭代完毕,path[i][j]存放从顶点i到顶点j的最短路径的前一个顶点的编号。
pathk-1[i][j]=b,pathk-1[k][j]=a
若Ak-1[i][j]>Ak-1[i][k]+Ak-1[k][j],选择经过顶点k的路径,即pathk[i][j]=a=pathk-1[k][j]。
否则不改变。
弗洛伊德算法如下:
void Floyd(MatGraph g) //求每对顶点之间的最短路径
{ int A[MAXVEX][MAXVEX]; //建立A数组
int path[MAXVEX][MAXVEX]; //建立path数组
int i,j,k;
for (i=0;i<g.n;i++) //给数组A和path置初值
for (j=0;j<g.n;j++)
{ A[i][j]=g.edges[i][j];
if (i!=j && g.edges[i][j]<INF)
path[i][j]=i; //i和j顶点之间有一条边时
else //i和j顶点之间没有一条边时
path[i][j]=-1;
}
for (k=0;k<g.n;k++) //求Ak[i][j]
{ for (i=0;i<g.n;i++)
for (j=0;j<g.n;j++)
if (A[i][j]>A[i][k]+A[k][j]) //找到更短路径
{ A[i][j]=A[i][k]+A[k][j]; //修改路径长度
path[i][j]=path[k][j]; //修改最短路径
}
}
}
弗洛伊德算法Floyd(g)中有三重循环,其时间复杂度为O(n3)。
七、查找
一)、顺序查找,二分查找
(1)顺序查找:从表的一端开始逐个将记录的关键字和给定k值进行比较,若某个记录的关键字和给定值k值相等,查找成功;否则,若扫描完整个表,仍然没有找到相应的记录,则查找失败。
顺序表的类型定义:
#define MAX_SIZE 100
typedef struct SSTable
{
RecType elem[MAX_SIZE];
int length;
}SSTable;
算法实现:
int Seq_Search(SSTable ST,KeyType key)
{
int p;
ST.elem[0].key=key;
for(p=ST.length;!EQ(ST.elem[p].key,key);p--)
return(p);
}
查找成功时的平均查找长度ASL=(n+1)/2
包含查找不成功时ASL=3(n+1)/4
2.折半查找
折半查找又称为二分查找,是一种效率较高的查找方法。
前提条件:查找表中的所有记录是按关键字有序(升序或降序)。
查找过程中,先确定待查找记录在表中的范围,然后逐步缩小范围(每次将待查记录所在区间缩小一半),直到找到或找不到记录为止。
算法思想:用low、high和mid表示待查找区间的下界、上界和中间位置指针,初值为low=1,high=n,取中间位置mid=;比较中间位置记录的关键字与给定的k值:
①相等:查找成功;
②大于:待查记录在区间的前半段,修改上界指针:high=mid-1,转①;
③小于:待查记录在区间的后半段,修改下界指针:low=mid+1,转①;
八、排序
一)、排序算法
常见的内部排序算法有:插入排序(insertion sorting)、希尔排序(Shell Sort)、选择排序(Selection sort)、堆排序(Heapsort)、冒泡排序(Bubble Sort)、快速排序(quick sort)、归并排序(Merge sort)、基数排序(Radix sort)。