绪论
- 数据的存储结构主要有顺序存储、链式存储、索引存储和散列存储
- 顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。其优点是可以实现随机关系来体现。其优点是可以实现随机存取,每个元素占用最少的存储空间;缺点是只能使用相邻的一整块存储单元,因此可能产生较多的外部碎片。
- 链式存储,不要求逻辑上相邻的元素在物理位置上也相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系。其优点是不会出现碎片现象,能充分利用所有存储单元;缺点是每个元素因存储指针而占用额外的存储空间,且只能实现顺序存取。
- 索引存储。在存储元素信息的同时,还建立附加的索引表。索引表中的每项称为索引项,索引项的一般形式是(关键字,地址)。其优点是检索速度快;缺点是附加的索引表额外占用存储空间。另外,增加和删除数据时也要修改索引表,因而会花费较多的时间。
- 散列存储。根据元素的关键字直接计算出该元素的存储地址,又称哈希存储。其优点是检索,增加核删除结点的操作都很快;缺点是若散列函数不好,则可能出现元素存储单元的冲突,而解决冲突会增加时间和空间开销。
选择题
- 抽象数据类型(ADT)描述了数据的逻辑结构和抽象运算,通常用(数据对象,数据关系,基本操作集)这样的三元组来表示,从而构成一个完整的数据结构定义。
- 树和图是典型的非线性数据结构
- 顺序表,哈希表和单链表是三种不同的数据结构,既描述逻辑结构,又描述存储结构和数据运算。而有序表是指关键字有序的线性表,仅描述元素之间的逻辑关系,它即可以链式存储,又可以顺序存储,故属于逻辑结构。
- 数据的逻辑结构是从面向实际问题的角度出发的,只采用抽象表达方法,独立于存储结构,数据的存储方法有多种不同的选择;而数据的存储结构是逻辑结构在计算机上的映射,它不能独立于逻辑结构而存在。数据结构包括三个要素
- 在存储数据时,不仅要存储数据元素的值,而且要存储数据元素之间的关系
- 链式存储设计时,各个不同结点的存储空间可以不连续,但结点内的存储单元地址必须连续。
应用题
- 对于两种不同的数据结构,逻辑结构或物理结构一定不相同吗?
对于两种不同的数据结构,它们的逻辑结构和物理结构完全有可能相同。比如二叉树和二叉排序树,二叉排序树可以采用二叉树的逻辑表示和存储方式,前者通常用于表示层次关系,而后者通常用于排序和查找。虽然它们的运算都有建立树,插入节点,删除节点和查找节点等功能,但对于二叉树和二叉排序树,这些运算的定义是不同的,以查找节点为例,二叉树的时间复杂度为O(n),而二叉排序树的时间复杂度为O(log2n) - 试举一例,说明对相同的逻辑结构,同一种运算在不同的存储方式下实现时,其运算效率不同。
线性表既可以用顺序存储方式实现,由可以用链式存储方式实现。在顺序存储方式下,在线性表中插入和删除元素,平均要移动近一半的元素,时间复杂度为O(n);而在链式存储方式下,插入和删除的时间复杂度都是O(1)
算法和算法评价
- 算法的5个重要特性:有穷性,确定性,可行性,输入,输出
- 好的算法:正确性,可读性,健壮性,效率与低存储量需求
- 循环计算复杂度时,分开计算
- 递归公式计算递归次数为时间复杂度
线性表
- 线性表分为顺序存储(顺序表)和链式存储(单链表,双链表,循环链表,静态链表)
线性表定义: 是具有相同数据类型的n(n>=0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为
L = ( a 1 , a 2 , . . . , a n ) L=(a_1,a_2,...,a_n) L=(a1,a2,...,an)
线性表的逻辑特性:除第一个元素外,每个元素有且仅有一个直接前驱,除最后一个元素外,每个元素有且仅有一个直接后继。
线性表的特点: - 表中元素个数有限
- 表中元素具有逻辑上的顺序性,表中元素有其先后次序。
- 表中元素都是数据元素,每个元素都是单个元素。
- 表中元素的数据类型都相同,这意味着每个元素占有相同大小的存储空间。
- 表中元素具有抽象性,即仅讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容。
**注:**线性表是一种逻辑结构,表示元素之间一对一的相邻关系。顺序表和链表是指存储结构,两者属于不同层面的概念。
线性表的基本操作
InitList(&L): 初始化表。构造一个空的线性表
Length(L): 求表长。返回线性表L的长度,即L中数据元素的个数。
LocateElem(L,e): 按值查找操作。在表L中查找具有给定关键字值的元素。
GetElem(L,i): 按位查找操作。获取表L中的第i个位置上插入指定元素e
ListInsert(&L,i,e): 插入操作。在表L中的第i个位置上插入指定元素e.
ListDelete(&L,i,&e): 删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值
PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
Empty(L):判空操作。若L为空表,则返回true,否则返回false。
DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间
注意区分逻辑结构和存储结构的区别,不要混为一谈,尤其是选择题,题目是逻辑结构时,选项不能选存储结构
线性表的顺序表示
顺序表的定义
定义: 一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。
注: 线性表中的任一数据元素都可以随机存取,所以线性表的顺序存储结构是一种随机存取的存储结构。通常用高级程序设计语言中的数组来描述线性表的顺序存储结构。
// 假定线性表的元素类型为ElemType, 则线性表的顺序存储类型描述为
# define MaxSize 50 //定义线性表的最大长度
typedef struct{
ElemType data[MaxSize]; //顺序表的元素
int length; //顺序表的当前长度
}SqList; // 顺序表的类型定义
动态分配
# define InitSize 100 //表长度的初始定义
typedef struct {
ElemType *data; //指示动态分配数组的指针
int MaxSize, length; //数组的最大容量和当前个数
} SeqList;
- 顺序表最主要的特点是随机访问,即通过首地址和元素序号可在实践O(1)内找到指定的元素。
- 顺序表的存储密度高,每个结点只存储数据元素。
- 顺序表逻辑上相邻的元素物理上也相邻,所以插入和删除操作需要移动大量元素。
顺序表上基本操作的实现
插入操作
bool ListInsert(SqList &L, int i, ElemType e){
if (i<1 || i>L.length+1) //判断i的范围是否有效
return false;
if (L.length >= MaxSize) //当前存储空间已满,不能插入
return false;
for (int j=L.length; j>=i; i--) //将第i个元素及之后的元素后移
L.data[j] = L.data[j-1];
L.data[i-1] = e; //在位置i处放入e
L。length++; //线性表长度增加
return true;
}
删除操作
bool ListDelete(SqList &L, int i, Elemtype &e){
if (i < 1 || i > L.length) // 判断i的范围是否有效
return false;
e = L.data[i-1];
for(int j=i; j<L.length; j++) //将第i个位置后的元素前移
L.data[j-1] = L.data[j];
L.length--; //线性表长度减1
return true;
}
按值查找
int LocateElem(SqList L, ElemType e){
int i;
for (i=0; i<L.length; i++)
if (L.data[i] == e)
return i + 1;
return 0;
}
选择题
- 若长度为n的非空线性表采用顺序存储结构,在表的第i个位置插入一个数据元素,则i的合法值应该是:1<=i<=(n+1),n+1表示在表尾添加。
应用题 - 从顺序表中删除具有最小值的元素(假设唯一)并由函数返回被删元素的值。空出的位置由最后一个元素填补,若顺序表为空,则显示出错信息并退出运行。
// 搜索整个顺序表,查找最小资元素并记住其位置,搜索结束后用最后一个元素填补空出的原最小值元素的位置。
bool Del_Min(sqList &L, ElemType &value){
if (L.length==0)
return false;
value = L.data[0]
int pos = 0;
for (int i=0; i<L.length; i++)
{
if (L.data[i] < value){
value = L.data[i];
pos = i;
}
}
L.data[pos] = L.data[L.length-1];
L.length--;
return ture;
}
- 设计一个高效算法,将顺序表L的所有元素逆转,要求算法的空间复杂度为O(1)
直接交换L.data[i]和L.data[L.length-i-1]
void Reverse(Sqlist &L){
Elemtype temp; //辅助变量
for (int i=0; i<L.length; i++){
temp = L.data[i]; // 交换L.data[i]与L.data[L.length-i-1]
L.data[i] = L.data[L.length-i-1];
L.data[L.length-i-1]=temp;
}
- 对长度为n的顺序表L,编写一个时间复杂度为O(n),空间复杂度为O(1)的算法,该算法删除线性表中所有值为x的数据元素。
解法1: 用k记录顺序表L中不等于x的元素个数(即需要保存的元素个数),扫描时将不等于x的元素移动到下标k的位置,并更新k值。扫描结束后修改L的长度
void del_x_l(Sqlist &L, Elemtype x){
// 本算法实现删除顺序表L中所有值为x的数据元素
int k=0, i; // 记录值不等于x的元素个数
for (i=0; i<L.length; i++){
if (L.data[i]!=x){
L.data[k] = L.data[i];
k++; //不等于x的元素增1
}
}
L.length = k; // 顺序表L的长度等于k
}
解法2: 用k记录顺序表L中等于x的元素个数,边扫描L边统计k,并将不等于x的元素前移k个位置,扫描结束后修改L的长度。
void del_x_2(Sqlist &L, Elemtype x){
int k=0, i=0; // k记录值等于x的元素个数
while (i<L.length){
if (L.data[i]==x)
k++;
else
L.data[i-k]=L.data[i]; //当前元素前移k个位置
i++;
}
L.length=L.length-k;
}
- 从有序顺序表中删除其值在给定值s与t之间(要求s<t)的所有元素,若s或t不合理或顺序表尾空,则显示出错信息并退出运行。
void delete_s_t(Sqlist &L, Elemtype s, Elemtype t){
int i,j;
if (s>=t || L.length=0)
return false;
for (i=0; i<L.length&&L.data[i]<s;i++); // 寻址值大于等于S的第一个元素
if (i>=L.length)
return false; //所有元素值均小于s
for (j=i; j<L.length&&L.data[j]<=t;j++); //寻址值大于t的第一个元素
for (; j<L.length; i++,j++)
L.data[i] = L.data[j]; // 前移,填补被删元素位置
L.length = i;
return true;
}
- 从顺序表中删除其值在给定值s与t之间(包括s和t,要求s<t)的所有元素,若s或t不合理或顺序表为空,则显示出错信息并退出运行。
void del_s_t(Sqlist L, ElemType s, ElemType t):
if (s>=t||L.length==0)
return false
int k=0, i;
for (i=0; i<L.length;i++) //检查不在s和t之间的元素
if (L.data[i]<s || L.data[i]>t)
{
L.data[k]=L.data[i];
k++;
}
L.length = k;
- 从有序顺序表中删除所有其值重复的元素,使表中所有元素的值均不同
(双指针)
bool Delete_Same(SeqList& L){
if (L.length==0)
return false;
int i, j; //i存储第一个不相同的元素,j为工作指针
for (i=0, j=1; j<L.length; j++){
if(L.data[i] != L.data[j]) // 查找下一个与上一个元素值不同的元素
L.data[++i]=L.data[j]; //找到后,将元素前移
L.length = i +1;
return true;
}
}
- 将两个有序顺序表合并为一个新的有序顺序表,并由函数返回结果顺序表
bool Merge(SeqList A, SeqList B, SeqList &C){
if (A.length+B.length>C.maxSize) //大于顺序表的最大长度
return false;
int i=0, j=0, k=0;
while (i<A.length&&j<B.length){ //循环,两两比较,小者存入结果表
if (A.data[i] <= B.data[j])
C.data[k++] = A.data[i++];
else
C.data[k++] = B.data[j++];
}
while (i<A.length) // 还剩一个没有比较完的顺序表
C.data[k++] = A.data[i++];
while (j<B.length)
C.data[k++]=B.data[j++];
C.length = k;
return true;
}
- 已知在一维数组A[m+n]中依次存放两个线性表(a1,a2,a3,…,am)和(b1,b2,b3,…,bn)。编写一个函数,将数组中两个顺序表的位置互换,即将(b1,b2,b3,…,bn)放在(a1,a2,a3,…,am)的前面
先整体逆置,再分别逆置
typedef int DataType;
void Reverse(DataType A[], int left, int right, int arraySize){
// 逆转
if (left >= right || right >=arraySize)
return;
int mid = (left + right)/2;
for (int i=0; i<=mid-left; i++){
DataType temp = A[left+i];
A[left + i] = A[right-i];
A[right-i]=temp;
}
}
void Exchange(DataType A[], int m, int n, int arraySize){
Reverse(A, 0, m+n-1, arraySize);
Reverse(A,0,n-1. arraySize);
Reverse(A,n,m+n-1,arraySize);
}
void SearchExchangeInsert(ElemType A[], ElemType x){
int low=0, high=n-1, mid; //low和high指向顺序表下界和上界的下标
while (low<=high){
mid = (low + high)/2; //找中间位置
if (A[mid]==x) break; //找到x,退出while循环
else if (A[mid]<x) low=mid+1;
else high=mid-1;
}
if (A[mid] == x&&mid!=n-1){ //若最后一个元素与x相等,则不存在与其后继交换的操作
t = A[mid];
A[mid]=A[mid+1];
A[mid+1]=t;
}
if (low > high) {
for (i=n-1; i>high; i--) A[i+1]=A[i];
A[i+1]=x;
}
}
可将这个问题视为把数组ab转换成数组ba(a代表数组的前p个元素,b代表数组中余下的n-p个元素),先将a逆置得到a-1b,再将b逆置得到a-1b^-1,最后将整个a-1b-1转置。
感觉这种问题都可以变为逆转的问题
typedef int DataType;
void Reverse(DataType A[], int left, int right, int arraySize){
// 逆转
if (left >= right || right >=arraySize)
return;
int mid = (left + right)/2;
for (int i=0; i<=mid-left; i++){
DataType temp = A[left+i];
A[left + i] = A[right-i];
A[right-i]=temp;
}
}
void Converse(DataType R[], int n, int p, int arraySize){
Reverse(R, 0, p-1);
Reverse(R, p, n-1);
Reverse(R, 0, n-1);
}
上述算法中三个Reverse函数的时间复杂度分别为O(p/2),O((n-p)/2)和O(n/2),故所设计的算法的时间复杂度为O(n), 空间复杂度为O(1)。
双指针比大小,比到一个序列的长度截止
设计思想:
分别求两个升序序列A,B的中位数,设为a和b,求序列A,B的中位数过程如下:
(1)若a=b,则a或b即为所求中位数,算法结束
(2)若a<b, 则舍弃序列A中较小的一般,同时舍弃序列B中较大的一般,要求两次舍弃的长度相等
(3)若a>b,则舍弃序列A中较大的一半,同时舍弃序列B中较小的一半,要求两次舍弃的长度相等。
在保留的两个升序序列中,重复过程(1),(2),(3),直到两个序列中均只含一个元素,较小者即为所求的中位数。
int M_Search(int A[], int B[], int n)
{
int s1=0, d1=n-1; m1, s2=0, d2=n-1, m2;
// 分别表示序列A和B的首位数,末位数和中位数
while(s1!=d1 || s2!=d2){
m1 = (s1+d1)/2;
m2 = (s2+d2) /2;
if (A[m1] == B[m2])
return A[m1];
if (A[m1]<B[m2]){
if ((s1+d1)%2==0){
s1=m1;
d2=m2;
}
else{
s1 = m1 +1;
d2 = m2;
}
}
else{
if((s2+d2)%2==0){
d1 = m1;
s2 = m2;
}
else{
d1 = m1;
s2 = m2 + 1;
}
}
}
return A[s1]<B[s2]?A[s1]:B[s2];
}
算法的时间复杂度为O(log2n),空间复杂度为O(1)。
int findMissMin(int A[], int n)
{
int i, *B; //标记数组
B = (int *)malloc(sizeof(int)*n); //分配空间
memset(B,0,sizeof(int)*n); //赋初值为0
for (i = 0; i<n;i++)
if (A[i]>0 && A[i]<=n) //若A[i]的值介于1~n,则标记数组B
B[A[i]-1]=1;
for (i=0;i<n;i++)
if (B[i]==0) break;
return i+1;
}
算法的基本设计思想
(1)使用D_min记录所有已处理的三元组的最小距离,初值为一个足够大的整数
(2)集合S1,S2和S3分别保存在数组A,B,C中。数组的下标变量i=j=k=0,当i<|S1|,j<|S2|且k<|S3|时(循环执行下面的a_c)
# define INT_MAX 0x7ffffff
int abs_(int a){//计算绝对值
if (a<0)
return -a;
else
return a;
}
bool_xls_min(int a, int b, int c) {
// a是否是三个数中的最小值
if (a<=b && a<=c)
return true;
return false;
}
int findMinoTrip(int A[], int n, int B[], int m, int C[], int p){
//D_min 用于记录三元组的最小距离,初值为INT——MAX
int i=0, j=0, k=0, D_min=INT_MAX, D;
while (i<n && j<m&&k<p&&D_min>0){
D=abs_(A[i]-B[j]) +abs_(B[j]-C[k])+abs_(C[k]-A[i]); //计算D
if (D<D_min) D_min=D;
if (xls_min(A[i],B[j],C[k])) i++; //更新a
else if (xls_min(B[J],C[k],A[i])) j++;
else k++
}
return D_min;
}
线性表的链式表示
单链表的定义(非随机存取)
线性表的链式存储又称单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的线性关系,对每个链表结点,除存放元素自身的信息外,还需要存放一个指向其后继的指针。
单链表结点类型描述
typedef struct LNode{ //定义单链表结点类型
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
通常用头指针来标识一个单链表,如单链表L,头指针为NULL时表示一个空表。此外,为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头节点。头结点的数据域可以不设任何信息,也可以记录表长等信息。头结点的指针域指向线性表的第一个元素结点,
头结点和头指针的区别:不管带不带头结点,头指针都始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,结点内通常不存储信息。
引入头结点后,可以带来两个优点:
- 由于第一个数据结点的位置被存放在头结点的指针域中,因此在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理
- 无论链表是否为空,其头指针都指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。
单链表上基本操作的实现
采用头插法建立单链表
该方法从一个空表开始,生成新结点,并将读取到的数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后。
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
LinkList List_HeadInsert(LinkList &L){
LNode *s; int x;
L = (LinkList)malloc(sizeof(LNode)); 创建头节点
L->next=NULL; //初始化为空链表
scanf("%d", &x); //输入结点的值
while(x!=9999){
s=(LNode*)malloc(sizeof(LNode));
s->data=x;
s->next=L->next;
L->next = s;
scanf("%d", &x);
}
return L;
}
尾插法建立单链表
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
LinkList List_TailInsert(LinkList &L){ //正向建立单链表
int x;
L = (LinkList)malloc(sizeof(LNode));
LNode *s, *r=L; //r为表尾指针
scanf("%d", &x);
while(x!=9999){ //输入9999表示结束
s = (LNode *)malloc(sizeof(LNode));
s->data=x;
r->next=s;
r=s; //r指向新的表尾结点
scanf("%d", &x); //尾结点指针置空
}
r->next=NULL;
return L;
}
按序号查找结点值
在单链表中从第一个结点触发,顺指针next域逐个往下搜索,直到找到第i个节点为止,否则返回最后一个结点指针域NULL
LNode *GetElem(LinkList L, int i){
int j=1; //计数,初始为1
LNode *p=L->next; //第一个结点指针赋给p
if (i==0)
return L; //若i等于0,则返回头结点
if (i<1)
return NULL; //若i无效,则返回NULL
while(p&&j<i){ //从第1个结点开始找,查找第i个结点
p=p->next;
j++;
}
return p; //返回第i个结点的指针,若i大于表长,则返回NULL
}
按值查找表结点
LNode *LocateElem(LinkList L, ElemType e){
LNode *p=L->next;
while(p!=NULL&&p->data!=e) //从第1个结点开始查找data域为e的结点
p=p->next;
return p; //找到后返回该结点指针,否则返回NULL
}
插入节点操作
将i-1节点的指针指向的位置换为s,并将s的指针指向i节点
p=GetElem(L,i-1); //查找插入位置的前驱结点
s->next = p->next; i节点位置,赋给s
p->next = s; 更新i-1结点的指针
p=GetElem(L,i-1); // 查找删除位置的前驱结点
q=p->next; //令q指向被删除结点
p->next = q->next; 将*q结点从链中断开
free(q);
双链表
单链表结点只有一个指向其后继的指针,使得单链表只能从头节点依次顺序地向后遍历。要访问某个结点的前驱结点,只能从头开始遍历,访问后继节点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。
双链表结点定义如下:
typedef struct DNode{
ElemType data; //数据域
struct DNode *prior, *next; // 前驱和后继指针
}DNode, *DLinklist;
双链表的插入操作
s->next = p->next;
p->next->prior = s;
p->next = s;
s->prior = p;
双链表的删除操作
p->next=q->next;
q->next-<prior=p;
free(q);
循环链表
循环单链表
尾结点指针指向头节点,因此循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针。
循环双链表
头节点的prior指针还要指向表尾结点,在循环双链表L中,某结点*p为尾结点时,p->next==L;当循环双链表为空表时,其头结点的prior域和next域都等于L。
静态链表
静态链表借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,前面所讲的链表中的指针不同的是,这里的指针是结点的相对地址(数组下标),和顺序表一样,静态链表也要预先分配一块连续的内存空间。
#define MaxSize 50 //静态链表的最大长度
typedef struct { //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; // 下一个元素的数组下标
} SLinkList[MaxSize];
记得要遍历尾结点,删除尾结点时,还需要找到其前驱结点
应用题
设计思想:双指针,定义两个指针变量p和q,初始时均指向头结点的下一个结点(链的第一个结点),p指针沿链表移动;当p指针移动到第k个结点时,q指针开始与p指针同时移动;当p指针移动到最后一个结点时,q指针所指示结点为倒数第k个结点。以上过程仅进行一遍扫描。
实现步骤:
- count=0,p和q指向链表表头结点的下一个节点
- 若p为空,转5
- 若count等于k,则q指向下一个节点;否则,count=count+1
- p指向下一个节点,转2
- 若count等于k,则查找成功,输出该结点的data域的值,返回1;否则说明k值超过了线性表的长度,查找失败,返回0.
typedef int ElemType; //链表数据的类型定义
typedef struct LNode{ //链表结点的结构定义
ElemType data;
struct LNode *link;
}LNode, *LinkList;
int Search_k(LinkList list, int k){
//查找链表list倒数第k个结点,并输出该结点data域的值
LNode *p=list->link, *q=list->link; //指针p,q指示第一个结点
int count = 0;
while (p!=NULL){ //遍历链表直到最后一个结点
if (count < k) count++; //计数,若count<k只移动p
else q=q->link;
p=p->link; //之后让p,q同步移动
}
if (count<k)
return 0; //查找失败返回0
else{ //否则打印并返回1
printf("%d",q->data);
return 1;
}
}
基本思想:
- 分别求出str1和str2所指的两个链表的长度m和n
- 将两个链表以表尾对其,令指针p,q分别指向str1和str2的头结点,若m>=n,则指针p先走,使p指向链表中的第m-n+1个结点;若m<n,则使q指向链表中的第n-m+1个结点,即使指针p和q所指的结点到表尾的长度相等。
- 反复将指针p和q同步向后移动,当p,q指向同一位置时停止,即为共同后缀的起始位置,算法结束
typedef struct Node{
char data;
struct Node *next;
}SNode;
// 求链表长度的函数
int listlen(SNode *head){
int len=0;
while(head->next!=NULL){
len++;
head = head->next;
}
return len;
}
// 找出共同后缀的起始地址
SNode * find_addr(SNode *str1, SNode *str2){
int m,n;
SNode *p, *q;
m = listlen(str1); //求str1的长度
n = listlen(str2); //求str2的长度
for (p=str1;m>n;m--) //若m>n,使p指向链表中的第m-n+1个结点
p=p->next;
for (q=str2; m<n; n--) //若m<n,使q指向链表中的第n-m+1个结点
q=q->next;
while(p->next!=NULL&&p->next!=q->next){//将指针p和q同步向后移动
p=p->next;
q=q->next;
}
return p->next;
}
在这里插入图片描述
算法的基本设计思想
将链表后面一半逆置,然后前面取一个后面再取一个
- 先找出链表L的中间节点,为此设置两个指针p和q,指针p每次走一步,指针q每次走两步,当指针q到达链尾时,指针p正好在链表的中间节点
- 然后将L的后半段结点原地逆置
- 从单链表前后两段中依次各取一个节点,按要求重排。
void change_list(NODE *h){
NODE *p, *q, *r, *s;
p=q=h;
while (q->next!=NULL){//寻找中间结点
p=p->next;
q=q->next;
if(q->next!=NULL) q=q->next;
}
q=p->next; //p所指结点为中间结点,q为后半段链表的首结点
p->next=NULL;
while(q!=NULL){ //将链表后半段逆置
r=q->next;
q->next=p->next;
p->next=q;
q=r;
s=h->next; //s指向前半段的第一个数据结点,即插入点
q=p->next; //q指向后半段的第一个数据结点
p->next=NULL;
while (q!=NULL){//将链表后半段的结点插入到指定位置
r=q->next;
q->next=s->next; //将q所指结点插入到s所指结点之后
s->next = q;
s = q->next; //s指向前半段的下一个插入点
q=r;
}
}
}
栈、队列和数组
栈(顺序栈,链栈,共享栈)、队列(循环队列,链式队列,双端队列)、数组(一维数组,多维数组,压缩存储,稀疏矩阵)
栈
**栈的定义:**栈是只允许在一端进行插入或删除操作的线性表。首先栈是一种线性表,但限定在这种线性表只能在某一端进行插入和删除操作。
栈顶(T)
栈的基本操作
InitStack(&S):初始化一个空栈S
StackEmpty(S):判断一个栈是否为空,若栈S为空则返回true,否则返回false
Push(&S,x): 进栈,若栈S未满,则将x加入使之成为新栈顶。
Pop(&S,&x): 出栈,若栈S非空,则弹出栈顶元素,并用x返回
GetTop(S,&x):读栈顶元素,若栈S非空,则用x返回栈顶元素
DestroyStack(&S):销毁栈,并释放栈S占用的存储空间。
栈的顺序存储结构
顺序栈
# define MaxSize 50 //定义栈中元素的最大个数
typedef struct {
Elemtype data[MaxSize]; //存放栈中元素
int top; //栈顶指针
}SqStack;
栈顶指针:S.top,初始时设置S.top=-1;栈顶元素:S.data[S.top]。
进栈操作:栈不满时,栈顶指针先加1,再送值到栈顶元素
出栈操作:栈非空时,先取栈顶元素值,再将栈顶指针减1
栈空条件:S.top==-1;栈满条件,S.top=MaxSize-1
顺序栈的基本运算
- 初始化
void InitStack(SqStack &S){
S.top=-1; //初始化栈顶指针
}
- 判栈空
bool StackEmpty(SqStack S){
if (S.top==-1)
return true; //栈空
else
return false;
}
- 进栈
bool Push(SqStack &S, ElemType x){
if (S.top==MaxSize-1) // 栈满,报错
return false;
S.data[++S.top]=x; //指针先加1,再入栈
return true;
}
- 出栈
bool Pop(SqStack &S, ElemType &x){
if (S.top==-1) //栈空,报错
return false;
x = S.data[S.top--]; //先出栈,指针再减1
return true;
}
- 读取栈顶元素
bool GetTop(SqStack S, ElemType &x){
if (S.top==-1) //栈空,报错
return false;
x = S.data[S.top]; //x记录栈顶元素
return true;
}
共享栈
利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸
其中1号栈,出栈与入栈的指针顺序相反
栈的链式存储结构
采用链式存储的栈称为链栈,链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。通常用单链表实现,并规定所有操作都是在单链表的表头进行的。
typedef struct Linknode{
ElemType data; //数据域
struct Linknode *next; //指针域
} *LiStack; // 栈类型定义
选择题
栈和队列的逻辑结构都相同,都属于线性结构
应用题
- 设单链表的表头指针为L,结点结构由data和next两个域构成,其中data域为字符型。试设计算法判断该链表的全部n个字符是否中心对称,例如xyx,xyyx都是中心对称
**算法思想:**使用栈来判断链表中的数据是否中心对称。让链表的前一半元素依次进栈。在处理链表的后一半元素时,当访问到链表的一个元素后,就从栈中弹出一个元素,两个元素比较,若相等,则将链表中的下一个元素与栈中再弹出的元素比较,直至链表到尾。这时若栈是空栈,则得出链表中心对称的结论;否则,当链表中的一个元素与栈中弹出的元素不相等时,结论为链表非中心对称。
int dc(LinkList L, int n){
// L是带头结点的n个元素单链表,本算法判断链表是否是中心对称
int i;
char s[n/2]; //s字符串
p=L->next; //p是链表的工作指针,指向待处理的当前元素
for (i=0;i<n/2;i++){//链表前一半元素进栈
s[i]=p->data;
p = p->next;
}
i--; //恢复最后的i值
if (n%2==1) //若n是奇数,后移过中心结点
p=p->next;
while (p!=NULL&&s[i]==p->data){//检测是否中心对称
i--; //i充当栈顶指针
p=p->next;
}
if(i==-1) //栈为空栈
return 1; //链表中心对称
else
return 0;
}
- 设有两个栈s1,s2都采用顺序栈方式,并共享一个存储区[0,…, maxsize -1],为了尽量利用空间,减少溢出的可能,可采用栈顶相向,迎面增长的存储方式。试设计s1,s2有关入栈和出栈的操作算法。
两个栈共享向量空间,将两个栈的栈底设在向量两端,初始时,s1栈顶指针为-1,s2栈顶指针为maxsize。两个栈顶指针相邻时为栈满。
# define maxsize 100 // 两个栈共享存储空间所能达到的最多元素数
# define elemtp int
typedef struct{
elemtp stack[maxsize]; //栈空间
int top[2]; //top为两个栈顶指针
}stk;
stk s; //s是如上定义的结构类型变量,为全局变量
入栈操作
int push(int i, elemtp x){
//入栈操作,i为栈号,i=0表示左边的s1栈,i=1表示右边的s2栈,x是入栈元素
//入栈成功返回1,否则返回0
if (i<0||i>1){
printf("栈号输入不对");
exit(0);
}
if (s.top[1]-s.top[0]==1){
printf("栈已满\n");
return 0;
}
switch(i){
case 0: s.stack[++s.top[0]]=x; return 1; break;
case 1: s.stack[--s.top[1]]=x; return 1;
}
}
退栈操作
elemtp pop(int i){
// 退栈算法。i代表栈号,i=0时为s1栈,i=1时为s2栈
// 退栈成功返回退栈元素,否则返回-1
if (i<0||i>1){
printf("栈号输入错误\n");
exit(0);
}
switch(i){
case 0:
if(s.top[0]==-1){
printf("栈空\n");
return -1;
}
else
return s.stack[s.top[0]--];
break;
case 1:
if (s.top[1]==maxsize){
printf("栈空\n");
return -1;
}
else
return s.stack[s.top[1]++];
break;
}
}
队列
**定义:**队列简称队,也是一种操作受限的线性表,只允许在表的一端进行插入,而在表的另一端进行删除,向队列中插入元素称为入队或进队;删除元素称为出队或者离队。
队列常见的基本操作
InitQueue(&Q): 初始化队列,构造一个空队列
QueueEmpty(Q): 判队列空,若队列Q为空返回true,否则返回false
EnQueue(&Q, x): 入队,若队列Q未满,将x加入,使之称为新的队尾。
DeQueue(&Q,&x): 出队,若队列Q非空,删除队头元素,并用x返回
GetHead(Q,&x):读队头元素,若队列Q非空,则将队头元素赋值给x.
队列的顺序存储结构
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:队头指针front指向队头元素,队尾指针rear指向队尾元素的下一个位置。
#define MaxSize 50 //定义队列中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //存放队列元素
int front, rear; //队列指针和队尾指针
}SqQueue;
初始状态(队空条件):Q.frontQ.rear=0
进队操作:队不满时,先送值到队尾元素,再将队尾指针加1
出队操作:队不空时,先取队头元素值,再将队头指针加1
循环队列
当队首指针Q.front = Maxsize-1后,再前进一个位置就自动到0, 这可以利用除法取余运算实现。
牺牲一个单元来区分队空和队满,入队时少用一个队列单元,这是一种较为普遍的做法,约定以“队头指针在队尾指针的下一位置作为队满的标志”
队满条件:(Q.rear+1)%MaxSizeQ.front
队空条件仍:Q.front == Q.rear
队列中元素的个数:(Q.rear-Q.front+MaxSize)%MaxSize
循环队列的操作
- 初始化
void InitQueue(SqQueue &Q){
Q.rear = Q.front=0; //初始化队首,队尾指针
}
- 判队空
bool isEmpty(SqQueue Q){
if (Q.rear==Q.front) return true; //队空条件
else return false;
}
- 入队
bool EnQueue(SqQueue &Q, ElemType x){
if ((Q.rear+1)%MaxSize==Q.front) return false;
Q.data[Q.rear]=x;
Q.rear=(Q.rear+1)%MaxSize;
return true;
}
- 出队
bool DeQueue(SqQueue &Q, ElemType &x){
if(Q.rear==Q.front) return false;
x=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
return true;
}
队列的链式存储结构
队列的链式表示称为链队列,它实际上是一个同时带有队头指针和队尾指针的单链表。头指针指向队头结点,尾指针指向队尾结点,即单链表的最后一个结点。
typedef struct LinkNode{ //链式队列结点
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{ //链式队列
LinkNode *front, *rear; //队列的队头和队尾指针
}LinkQueue;
用单链表表示的链式队列特别适合于数据元素变动比较大的情形,而且不存在队列满且产生溢出的问题。另外,假如程序中要使用多个队列,与多个栈的情形一样,最好使用链式队列,这样就不会出现存储分配不合理和“溢出”问题。
链式队列的基本操作
- 初始化
void InitQueue(LinkQueue &Q){
Q.front = Q.rear = (LinkNode*)malloc(sizeof(LinkNode)); //建立头结点
Q.front->next=NULL; //初始为空
}
- 判队空
bool IsEmpty(LinkQueue Q){
if (Q.front==Q.rear) return true;
else return false;
}
- 入队
void EnQueue(LinkQueue &Q, ElemType x){
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x; s->next=NULL; //创建新结点,插入到链尾
Q.rear->next=s;
Q.rear=s;
}
双端队列
选择题
队列的长度:(rear-front+maxsize)%maxsize
应用题
顺序存储无法满足要求(2)的队列占用空间随着入队操作而增加,根据要求来分析:要求(1)容易满足;链式存储方便开辟新空间,要求(2)容易满足;对于要求(3),出队后的节点并不真正释放,用队头指针指向新的队头结点,新元素入队时,有空余结点则无需开辟新空间,赋值到队尾后的第一个空结点即可,然后用队尾指针指向新的队尾结点,这就需要设计成一个首尾相接的循环单链表,类似于循环队列的思想。设置队头,队尾指针后,链式队列的入队操作和出队操作的时间复杂度均为O(1)
(2)该循环链式队列的实现可以参考循环队列,不同之处在于循环链式队列可以方便地增加空间,出队的结点可以循环利用,入队时空间不够也可以动态增加。同样,循环链式队列也要区分队满和队空的情况,这里参考循环队列牺牲一个单元来判断。初始时,创建只有一个空闲结点的循环单链表,头指针front和尾指针rear均指向空闲结点。
插入第一个元素后的状态如下图所示
操作的基本过程如下:
栈和队列的应用
栈在括号匹配中的应用
设一个栈,将括号往里压,判断右括号和栈顶相同则将栈顶弹出,遇到左括号就压入,右括号就比较。
栈在表达式求值中的应用
栈在递归中的应用
队列在层次遍历中的应用
在信息处理中有一大类问题需要逐层或逐行处理。 这类问题的解决方法往往是在处理当前层或当前行时就对下一层或下一行做预处理。
队列在计算机系统中的应用
第一个方面是解决主机与外部设备之间速度不匹配的问题,第二个方面是解决由多用户引起的资源竞争问题。
应用题
- 假设一个算术表达式中包含圆括号、方括号和花括号三种类型的括号,编写一个算法来判别表达式中的括号是否配对,以字符“\0”作为算术表达式的结束符
bool BracketsCheck(char *str){
InitStack(S); //初始化栈
int i=0;
while (str[i]!='\0'){
switch(str[i]){
//左括号入栈
case '(': Push(S,'('); break;
case '[': Push(S,'['); break;
case '{': Push(S,'{'); break;
//遇到右括号,检测栈顶
case ')': Pop(S,e);
if (e!='(') return false;
break;
case ']': Pop(S,e);
if (e!='[') return false;
break;
case '}':Pop(S,e);
if (e!='{') return false;
break;
default;
break;
}
i++;
}
if (!IsEmpty(S)){
printf("括号不匹配\n");
return false;
}
else{
printf("括号匹配\n");
return true;
}
}
检查一遍,将所有硬座压入栈,这样软座就都到了前部
void Train_Arrange(char *train){
//用字符串train表示火车,H表示硬座,S表示软座
char *p=train, *q=train,c;
stack s;
InitStack(s); //初始化栈结构
while(*p){
if (*p=='H')
Push(s, *p); //把H存入栈中
else
*(q++)=*p; //把S调到前部
p++;
}
while (!StackEmpty(s)){
Pop(s, c);
*(q++)=c; //把H接在后部
}
}
设置一个栈用于保存n和对应的Pn(x)值,栈中相邻元素的Pn(x)有题中关系。然后边出栈边计算Pn(x),栈空后该值就计算出来了
double p(int n, double x){
struct stack{
int no; //保存n
double val; //保存Pn(x)值
}st [MaxSize];
int top=-1, i;
double fv1=1, fv2=2*x; //n=0,n=1时的初值
for(i=n;i>=2;i--){
top++;
st[top].no=i;
}
while(top>=0){
st[top].val=2*x*fv2-2*(st[top].no-1)*fv1;
fv1=fv2;
fv2=st[top].val;
top--;
}
if (n==0){
return fv1;
}
return fv2;
}
假设数组q的最大下标为10,恰好是每次载渡的最大量。假设客车的队列为q1,货车的队列为q2.若q1充足,则每取4个q1元素后再去一个q2元素,直到q的长度为10.若q1不充足,则直接用q2补齐
Queue q; // 过江渡船载渡队列
Queue q1; // 客车队列
Queue q2; // 货车队列
void manager(){
int i=0, j=0; //j表示渡船上的总车辆数
while (j<10){
if(!QueueEmpty(q1)&&i<4){
DeQueue(q1, x);
EnQueue(q, x);
i++;
j++;
}
else if (i==4&&!QueueEmpty(q2)){
DeQueue(q2, x);
EnQueue(q, x);
j++;
i=0;
}
else{
while(j<10&&i<4&&!QueueEmpty(q2)){
DeQueue(q2, x);
EnQueue(q,x);
i++;
j++;
}
i=0;
}
if(QueueEmpty(q1)&&QueueEmpty(q2))
j=11;
}
}
数组和特殊矩阵
数组的定义
特殊矩阵的压缩存储
压缩存储:指对多个值相同的元素只分配一个存储空间,对零元素不分配存储空间,其目的是节省存储空间。
特殊矩阵:指具有许多相同矩阵元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵。常见的特殊矩阵有对称矩阵,上下三角矩阵,对角矩阵
串
串:基本概念:主串、子串、串长
存储结构:定长顺序存储,堆分配存储,块链存储
模式匹配算法:暴力匹配法,KMP算法(部分匹配值表,next数组,next函数的推理过程),KMP算法的进一步改进——nextval数组
串的定义和实现
串的定义
串是由零个或多个字符组成的有限序列。一般记为
S=‘a1a2…an(n>=0)
其中,S是串名,单引号括起来的字符序列是串的值;ai可以是字母,数字或其他字符;串中字符的个数n称为串的长度。n=0时的串称为空串。
串中任意多个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串。某个字符在串中的序号称为该字符在串中的位置。子串在主串中的位置以子串的第一个字符在主串中的位置来表示。当两个串的长度相等且每个对应位置的字符都相等时,称这两个串是相等的。
串的存储结构
定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。
# define MAXLEN 255 //预定义最大串长为255
typedef struct{
char ch[MAXLEN]; // 每个分量存储一个字符
int length; //串的实际长度
}SString;
堆分配存储表示
堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的。
typedef struct{
char *ch; // 按串长分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性,在具体实现时,每个结点既可以存放一个字符,也可以存放多个字符。
串的基本操作
StrAssign(&T, chars):赋值操作,把串T赋值为chars
StrCopy(&T,S): 复制操作。由S复制得到串T
StrEmpty(S):判空操作,若S为空串,则返回TRUE,否则返回FALSE
StrCompare(S,T):比较操作。若S>T,则返回值>0,若S=T,则返回值=0;若S<T,则返回值<0
StrLength(S): 求串长。返回串S的元素个数
SubString(&Sub, S, pos, len):求子串。用Sub返回串S的第pos个字符起长度为len的子串。
Concat(&T,S1,S2): 串联接。用T返回由S1和S2联接而成的新串
Index(S,T):定位操作,若主串S中存在串T值相同的子串,则返回它在主串S中第一次出现的位置;否则函数值为0
ClearString(&S):清空操作。将S清为空串
DestroyString(&S):销毁串,将串S销毁
串的模式匹配
简单的模式匹配算法
子串的定位操作通常称为串的模式匹配,它求的是子串在主串中的位置。这里采用的定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
int Index(SString S, SString T){
int i=1, j=1;
while(i<=S.length && j<=T.length){
if(S.ch[i]==T.ch[j]){
i++;
j++;
}
else{
i = i-j+2;
j=1;
}
if (j>T.length) return i=T.length;
else return 0;
}
}
串的模式匹配算法——KMP算法
就是主串和辅串已经匹配的部分不用再次匹配。回溯时,直接跳过已经匹配的部分
字符串的前缀,后缀和部分匹配值
前缀:除最后一个字符以外,字符串的所有头部子串;
后缀:后缀指除第一个字符外,字符串的所有尾部子串;
部分匹配值:字符串的前缀和后缀的最长相等前后缀长度
KMP算法步骤
- 计算出子串的部分匹配值
- 从头开始匹配,如果失败,得到已匹配的字符数
- 移动位数=已匹配字符数-对应的部分匹配值,则将子串向后移动2位,如下进行第二趟匹配
整个匹配过程中,主串始终没有回退,故KMP算法可以在O(n+m)的时间数量级完成串的模式匹配操作。
对算法的改进方法:
将PM表右移一位,得到next数组
再将next数组整体加1
next[j]的含义是:在子串的j个字符与主串发生失配时,则跳到子串的next[j]位置重新与主串当前位置进行比较。
代码求解next数组
void get_next(String T, int next[]){
int i = 0, j=0;
next[1] = 0;
while (i<T.length){
if (j==0||T.ch[i]==T.ch[j]){
++i; ++j;
next[i] = j; //若pi=pj,则next[j+1]=next[j]+1
}
else
j = next[j]; //否则令j=next[j]
}
}
KMP匹配算法
int Index_KMP(String S, String T, int next []){
int i=1, j=1;
while(i<=S.length&&j<=T.length){
if(j==0|| || S.ch[i]==T.ch[j]){
++i; ++j; //继续比较后继字符
}
else
j=next[j]; //模式串向右移动
}
if (j>T.length)
return i-T.length; //匹配成功
else
return 0;
}
KMP算法的进一步优化
消除相等值的匹配,如果出现相等值,则需要再次递归,将next[j]修正为next[next[j]],直至两者不相等为止,更新后的数组命名为nextval,
void get_nextval(String T, int nextval[]) {
int i = 1, j=0;
nextval[1] = 0;
while(i<T.length){
if (j==0||T.ch[i]==T.ch[j]){
++i; ++j;
if(T.ch[i]!=T.ch[j]) nextval[i]=j;
else nextval[i] = nextval[j];
}
else
j=nextval[j];
}
}
KMP算法失配时:,主串指针i不变,子串指针j回退到next[j]位置重新比较
应用题
- 当模式串中的第一个字符与主串的当前字符比较不相等时,next[1]=0,表示模式串应右移一位,主串当前指针后移一位,再和模式串的第一字符进行比较。
- 当主串的第i个字符与模式串的第j个字符失配时,主串i不回溯,则假定模式串的第k个字符与主串的第i个字符比较,k值应满足条件1<k<j,且‘p1…pk-1’=‘pj-k+1…pj-1’,即k为模式串的下次比较位置。K值可能有多个,为了不使向右移动丢失可能的匹配,右移距离应该取最小,由于j-k表示右移的距离,所以取max{k}
- 除上面两种情况外,发生失配时,主串指针i不回溯,在最坏情况下,模式串从第1个字符开始与主串的第i个字符比较。
树与二叉树
树的基本概念
树的定义
树是n(n>=0)个结点的有限集。当n=0时,称为空树。在任意一颗非空树中应满足:
- 有且仅有一个特定的称为根的节点
- 当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根的子树
显然,树的定义是递归的,即在树的定义中又用到了其自身,树是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
- 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱
- 树中所有结点可以有零个或多个后继。
基本术语
- 度大于0的结点称为分支结点;度为0的结点称为叶子结点。在分支结点中,每个结点的分支数就是该结点的度
- 结点的深度是从根结点开始自顶向下逐层累加的
- 结点的高度是从叶结点开始自底向上逐层累加的
- 树的高度是树中结点的最大层数。
- 有序树和无序树。树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数
- 森林,森林是m棵互不相交的树的集合。森林的概念与树的概念十分相似,因为只要把树作为该结点的子树,则森林就变成了树。
树的性质
- 树中的结点数等于所有结点的度数之和加1
- 度为m的树中第i层上至多有m^{i-1}个结点(i>=1)
- 高度为h的m叉树至多有(m^h-1)/(m-1)个结点。
- 具有n个结点的m叉树的最小高度为
选择题
- 树中所有结点的度数加1等于结点数
n0+n1+n2+n3+n4=0+n1+2n2+3n3+4n4+1
- 总结点数=n0+n1+n2+…+nm
- 总分支数=1n1+2n2+…+mnm
- 总结点数=总分支数+1
二叉树的概念
二叉树的定义及其主要特性
二叉树的定义
二叉树是另一种树形结构,其特点是每个结点至多只有两颗子树(即二叉树中不存在度大于2的结点),并且二叉树的子树有左右之分,其次序不能任意颠倒。
几个特殊的二叉树
- 满二叉树:一棵高度为h,且含有2^h-1个结点的二叉树称为满二叉树,即树中的每一层都含有最多的结点
- 完全二叉树:高度为h,有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号1—n的结点一一对应时,称为完全二叉树
- **二叉排序树:**左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字
- **平衡二叉树:**树上任一结点的左子树和右子树的深度之差不超过1
二叉树的性质
1) 非空二叉树上的叶子结点数等于度为2的结点数加1,即n0=n2+1
- 非空二叉树上第k层上至多有2^{k-1}个结点
3)高度为h的二叉树至多有2^h-1个结点
4) 对完全二叉树按从上到下,从左到右的顺序依次编号1,2,…,n,则有以下关系:
5)具有n个结点的完全二叉树的高度为
二叉树的存储结构
顺序存储结构
二叉树的顺序存储是指用一组地址连续的存储单元依次自上而下,自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为i的结点元素存储在一维数组下标为i-1的分量中。
适合于完全二叉树和满二叉树的存储。
链式存储结构
二叉树的链式存储结构
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左右孩子指针
}BiTNode, *BiTree;
选择题:由完全二叉树的性质,最后一个分支结点的序号为[1001/2]=500,故叶子节点个数为501
含有n个结点的二叉链表中,含有n+1个空链域
应用题
- 在一颗完全二叉树中,含有n0个叶子节点,当度为1的节点数为1时,该树的高度是多少?当度为1的节点数为0时,该树的高度是多少
- 一颗有n个结点的满二叉树有多少个分支结点和多少个叶子结点?该满二叉树的高度是多少?
- 已知完全二叉树的第九层有240个结点,则整个完全二叉树有多少个节点?
- 已知一颗二叉树按顺序存储结构进行存储,设计一个算法,求编号分别为i和j的两个结点的最近的公共祖先结点的值。
首先,必须明确二叉树中任意两个结点必须存在最近的公共祖先结点,最坏的情况下是根结点(两个结点分别在根结点的左右分支中),而且从最近的公共祖先结点到根结点的全部祖先结点都是公共的。由二叉树顺序存储的性质可知,任一结点i的双亲结点的编号为i/2.求解i和j最近公共祖先结点的算法为:
ElemType Comm_Ancestor(SqTree T, int i, int j){
// 本算法在二叉树中查找结点i和结点j的最近公共祖先结点
if (T[i]!='#'&&T[j]!='#'){ //结点存在
while(i!=j){ // 两个编号不同时循环
if (i > j)
i = i/2;
else
j = j/2;
}
return T[i];
}
}
二叉树的遍历和线索二叉树
二叉树的遍历
二叉树的遍历时按某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。由于二叉树是一种非线性结构,每个结点都可能有两棵子树,因而需要寻找一种规律,以便使二叉树上的结点能排列在一个线性队列上,进而便于遍历。
由二叉树的递归定义可知,遍历一颗二叉树便要决定对根节点N,左子树L和右子树R的访问顺序。按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR)、中序(LNR)和后序(LRN)三种遍历算法,其中序指的是根结点在何时被访问
先序遍历
void PreOrder(BiTree T){
if(T!=NULL){
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
中序遍历
void InOrder(BiTree T){
if (T!=NULL){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
后序遍历
void PostOrder(BiTree T){
if(T!=NULL){
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
递归算法和非递归算法的转换
- 中序遍历的非递归算法
void InOrder2(BiTree T){
InitStack(S);
BiTree p = T; // 初始化栈S; p是遍历指针
while(p||!IsEmpty(S)){ //栈不空或p不空时循环
if(p){
Push(S,p); //当前结点入栈
p=p->lchild; //左孩子不空,一直向左走
}
else{
Pop(S,p); visit(p); //栈顶元素出栈,访问出栈结点
p=p->rchild; // 向右子树走,p赋值为当前结点的右孩子
}
}
}
非递归先序遍历
void PreOrder2(BiTree T){
InitStack(S); BiTree p=T; //初始化栈S;p是遍历指针
while (p||!IsEmpty(S)){ //栈不空或p不空时循环
if (p){
visit(p);
Push(S,p);
}
else{
Pop(S,p);
p = p->rchild;
}
}
}
层次遍历
即广度优先搜索。要进行层次遍历,需要借助一个队列。先将二叉树树根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,
void LevelOrder(BiTree T){
InitQueue(Q); //初始化辅助队列
BiTree p;
EnQueue(Q,T); // 将根结点入队
while (!IsEmpty(Q)){ //队列不空则循环
DeQueue(Q,p); //队头结点出队
visit(p); //访问出队结点
if (p->lchild!=NULL)
EnQueue(Q,p->lchild); //左子树不空,则左子树根节点入队
if (p->rchild!=NULL)
EnQueue(Q,p->rchild); //右子树不空,则右子树根结点入队
}
}
由遍历序列构造二叉树
由二叉树的先序序列和中序序列可以唯一地确定一颗二叉树。
在先序遍历序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。
线索二叉树
遍历二叉树是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点都有一个直接前驱和直接后继。
在含n个结点的二叉树中,有n+1个空指针。这是因为每个叶结点有2个空指针,每个度为1的结点有1个空指针,如果利用这些空指针来存放指向前驱或后继的指针,这样就可以像遍历单链表那样方便地遍历二叉树。
规定:若无左子树,令lchild指向其前驱结点;若无右子树,令rchild指向其后继结点。还需增加两个标志域标识指针域标识指针域是指向左(右)孩子还是指向前驱(后继)
typedef struct ThreadNode{
ElemType data; //数据元素
struct ThreadNode *lchild, *rchild; //左右孩子指针
int ltag, rtag; //左右线索标志
}ThreadNode, *ThreadTree;
中序线索二叉树的构建
以中序线索二叉树的建立为例,附设指针pre指向刚刚访问过的结点,指针p指向正在访问的结点,即pre指向p的前驱,在中序遍历的过程中,检查p的左指针是否为空,若为空就将它指向pre;检查pre的右指针是否为空,若为空就将它指向p。
void InThread(ThreadTree &p, ThreedTree &pre){
if(p!=NULL){
InThread(p->lchild, pre); //递归,线索化左子树
if(p->lchild==NULL){ //左子树为空,建立前驱线索
p->lchild = pre;
p->ltag = 1;
}
if(pre!=NULL&&pre->rchild=NULL){
pre->rchild = p; //建立前驱结点的后继线索
pre->rtag = 1;
}
pre = p; //标记当前结点为刚刚访问过的结点
InThread(p->rchild, pre); //递归,线索化右子树
}
}
通过中序遍历建立中序线索二叉树的主过程算法如下:
void CreateInThread(ThreadTree T){
ThreadTree pre=NULL;
if (T!=NULL){
InThread(T, pre); //线索化二叉树
pre->rchild=NULL; //处理遍历的最后一个结点
pre->rtag = 1;
}
}
为了方便,可以在二叉树的线索链表上也添加一个头结点,令其lchild域的指针指向二叉树的根结点,其rchild域的指针指向中序遍历时访问的最后一个结点;令二叉树中序序列中的第一个结点的lchild域指针和最后一个结点的rchild域指针均指向头结点。
中序线索二叉树的遍历
- 求中序线索二叉树中中序序列下的第一个结点:
ThreadNode *Firstnode(ThreadNode *p){
while (p->ltag==0) p = p->lchild; //最左下结点
return p;
}
- 求中序线索二叉树中结点p在中序序列下的后继:
ThreadNode *Nextnode(ThreadNode *p){
if (p->rtag==0) return Firstnode (p->rchild);
else return p->rchild; //rtag==1直接返回后继搜素
}
- 利用上面两个算法,可以写出不含头结点的中序线索二叉树的中序遍历算法
void Inorder(ThreadNode *T){
for (ThreadNode *p=Firstnode(T); p!=NULL; p=Nextnode(p))
visit(p);
}
C,C,C,D,A,B,C,A,A,C
C,A,A,X,A,B,A,A,C,C,
X,B,D,D,X,B,D,A,X,X
A,X,X,X,B
选择题
-
在二叉树中有两个结点m和n,若m是n的祖先,则使用后序遍历可以找到从m到n的路径
-
一颗非空的二叉树的先序遍历序列与后续遍历序列正好相反,则该二叉树一定满足:只有一个叶节点
-
先序序列为NLR,后序序列为LRN,虽然可以唯一确定树的根节点,但无法划分左,右子树
-
- 已知二叉树的后续序列为DABEC,中序序列为DEBAC,求先序序列,与已知先序序列和中序序列差不多
- 已知二叉树的后续序列为DABEC,中序序列为DEBAC,求先序序列,与已知先序序列和中序序列差不多
-
二叉树是一种逻辑结构,但线索二叉树是加上线索后的链表结构,即它是二叉树在计算机内部的一种存储结构,所以是一种物理结构。
-
一颗左子树为空的二叉树在先序线索化后,其中空的链域的个数是2个
-
不是每个节点通过线索都可以直接找到它的前驱和后继。在先序线索二叉树中查找一个结点的先序后继很简单,而查找先序前驱必须知道该结点的双亲结点。同样,在后续线索二叉树中查找一个结点的后序前驱也很简单,而查找后续后继也必须知道该结点的双亲。
-
后序线索树的遍历仍需要栈的支持
-
前序序列和后序序列不能唯一确定一颗二叉树,但可以确定二叉树中结点的祖先关系:两个结点的前序序列为XY,后续序列为YX时,则X为Y的祖先。考虑前序序列a,e,b,d,c,后续序列b,c,d,e,a,可知a为根结点,e为a的孩子结点;此外,由a的孩子结点的前序序列e,b,d,c和后序序列b,c,d,e,可知e是bcd的祖先。
-
应用题 -
若某非空二叉树的先序序列和后序序列正好相反,则该二叉树的形态是什么?
二叉树的先序序列是NLR,后序序列是LRN,要使NLR=NRL成立,L或R应为空,这样的二叉树每层只有一个结点,即二叉树的形态是其高度等于结点个数。 -
若某非空二叉树的先序序列和后序序列正好相同,则该二叉树的形态是什么?
二叉树只有一个根节点 -
编写后序遍历二叉树的非递归算法
**算法思想:**后序非递归遍历二叉树是先访问左子树,再访问右子树,最后访问根结点。(1)沿着根的左孩子,依次入栈,直到左孩子为空(2)读栈顶元素:若其右孩子不空且未被访问过,将右子树转执行(1);否则,栈顶元素出栈并访问。栈顶D的右孩子为空,出栈并访问,它是后序序列的第一个结点;栈顶B的右孩子不空且未被访问过,E入栈,栈顶E的左右孩子均为空,出栈并访问。
void PostOrder(BiTree T){
InitStack(S);
p = T;
r = NULL;
while (p||IsEmpty(S)){
if(p){
push(S,p);
p = p->lchild;
}
else
{
GetTop(S,p);
if (p->rchild&&p->rchild!=r) //若右子树存在,且未被访问过
p = p->child;
else
pop(S,p); //将结点弹出
visit(p->data); //访问该结点
r = p; //记录最近访问过的结点
p = NULL; // 结点访问完后,重置p指针
}
}
}
- 试给出二叉树的自下而上,从右到左的层次遍历算法
一般的二叉树层次遍历是自上而下,从左到右,这里的遍历顺序恰好相反。算法思想:利用原有的层次遍历算法,出队的同时将各结点指针入栈,在所有结点入栈后从栈顶开始一次访问即为所求的算法:
(1)把根节点入队列
(2)把一个元素出队列,遍历这个元素
(3)依次把这个元素的左孩子,右孩子入队列
(4)若队列不空,则跳到2
void InvertLevel(BiTree bt){
Stack S; Queue Q;
if (bt!=NULL){
InitStack(S);
InitQueue(Q);
EnQueue(Q,bt);
while (IsEmpty(Q)==false){
DeQueue(Q,p);
Push(s,p); //出队,入栈
if(p->lchild)
EnQueue(Q,p->lchild);
if(p->rchild)
EnQueue(Q,p->rchild);
}
while(IsEmpty(s)==false){
Pop(s,p);
visit(p->data);
}
}
}
- 假设二叉树采用二叉链表存储结构,设计一个非递归算法求二叉树的高度
采用层次遍历的算法,设置变量level记录当前节点所在的层数,设置变量last指向当前层的最右结点,每次层次遍历出队时与last指针比较,若相等,则层数加1
int Btdepth(BiTree T){
//采用层次遍历的非递归方法求解二叉树的高度
if (!T)
return 0; //树空,高度为0
int front=-1. rear=-1;
int last=0, level = 0;
BiTree Q[MaxSize]; //设置队列Q,元素是二叉树结点指针且容量租后
Q[++rear] = T; //将根结点入队
BiTree p;
while (front<rear){
p = Q[++front]; // 队列元素出队,即正在访问的结点
if(p->lchild)
Q[++rear]=p->lchild; //左孩子入队
if(p->rchild)
Q[++rear]=p->rchild; //右孩子入队
if (front==last){
level++;
last=rear;
}
}
return level;
}
求某层的结点个数,每层的结点个数,树的最大宽度等,都采用与此题类似的思想。递归解法如下
int Btdepth2(BiTree T){
if(T==NULL)
return 0; //空树,高度为0
ldep = Btdepth2(T->lchild); //左子树高度
rdep = Btdepth2(T->rchild); //右子树高度
if(ldep>rdep)
return ldep+1; //树的高度为子树最大高度加根结点
else
return rdep+1;
}
-
设一颗二叉树中各节点的值互不相同,其先序遍历序列和中序遍历序列分别存于两个一维数组A和B中,编写算法建立该二叉树的二叉链表
-
二叉树按二叉链表存储结构存储,写一个判别给定二叉树是否是完全二叉树的算法
根据完全二叉树的定义,具有n个结点的完全二叉树与满二叉树中编号1~n的结点一一对应。算法思想:采用层次遍历算法,将所有结点加入队列。遇到空结点时,看其后是否有非空结点,若有,则二叉树不是完全二叉树。
bool IsComplete(BiTree T){
InitQueue(Q);
if(!T)
return 1;
EnQueue(Q,T);
while (!IsEmpty(Q)){
DeQueue(Q,p);
if(p){ //结点非空,将其左,右子树入队列
EnQueue(Q, p->lchild);
EnQueue(Q, p->rchild);
}
else
while(!IsEmpty(Q)){
DeQueue(Q,p);
if(p)
return 0;
}
}
return 1;
}
- 假设二叉树采用二叉链表存储结构存储,试设计一个算法,计算一颗给定二叉树的所有双分支结点个数
int DsonNodes(BiTree b){
if(b==NULL)
return 0;
else if (b->lchild!=NULL&&b->rchild!=NULL) //双分支结点
return DSoneNodes(b->lchild)+DsonNodes(b->rchild)+1;
else
return DSonNodes(b->lchild)+DsonNodes(b->rchild);
}
- 设树B是一颗采用链式结构存储的二叉树,编写一个把树B中所有结点的左、右子树进行交换的函数。
void swap(BiTree b){
if(b){
swap(b->lchild); //递归地交换左子树
swap(b->rchild); //递归地交换右子树
temp=b->lchild; //交换左,右孩子结点
b->lchild = b->rchild;
b->rchild = temp;
}
}
-
假设二叉树采用二叉链存储结构存储,设计一个算法,求先序遍历序列中第k(1<=k<=二叉树中结点个数)个结点的值
-
已知二叉树以二叉链表存储,编写算法完成:对于树中每个元素值为x的结点,删去以它为根的子树,并释放相应的空间。
删除以元素值x为根的子树,只要能删除其左,右子树,就可以释放值为x的根结点,因此宜采用后序遍历。算法思想,删除值为x的结点,意味着应将其父节点的左右子女指针置空,用层次遍历易于找到某结点的父节点
void DeleteXTree(BiTree &bt){ //删除以bt为根的子树
if(bt){
DeleteXTree(bt->lchild);
DeleteXTree(bt->rchild);
free(bt);
}
}
//在二叉树上查找所有以x为元素值的结点,并删除以其为根的子树
void Search(BiTree bt, ElemType x){
BiTree Q[]; //Q是存放二叉树结点指针的队列,容量足够大
if(bt){
if (bt->data==x){
DeleteXTree(bt);
exit(0);
}
InitQueue(Q);
EnQueue(Q,bt);
while(!IsEmpty(Q)){
DeQueue(Q,p);
if(p->lchild) //若左子女非空
if(p->lchild->data==x){ //左子树符合则删除左子树
DeleteXTree(p->lchild);
p->lchild=NULL;
}
else
EnQueue(Q,p->lchild); //左子树入队列
if (p->rchild)
if(p->rchild->data==x){ //右子女符合则删除右子树
DeleteXTree(p->rchild);
p->rchild=NULL;
}
else
EnQueue(Q,p->rchild); /
}
}
}
- 在二叉树中查找值为x的结点,试编写算法打印值为x的结点的所有祖先,假设值为x的结点不多于1个
**算法思想:**采用非递归后序遍历,最后访问根结点,访问到值为x的结点时,栈中所有元素均为该结点的祖先,依次出栈打印即可。
typedef struct{
BiTree t;
int tag;
}stack; //tag=0表示左子女被访问,tag=1表示右子女被访问
void Search(BiTree bt, ElemType x){
//在二叉树bt中,查找值为x的节点,并打印其所有祖先
stack s[];
top=0;
while(bt!=NULL||top>0){
while (bt!=NULL&&bt->data!=x){
s[++top].t=bt
}
}
}
-
设一颗二叉树的结点结构为(LLINK,INFO,RLINK),ROOT为指向该二叉树根结点的指针,p和q分别为指向该二叉树中任意两个结点的指针,试编写算法ANCESTOR(ROOT,p,q,r),找到p和q的最近公共祖先结点r.
-
假设二叉树采用二叉链表存储结构,设计一个算法,求非空二叉树b的宽度(即具有结点数最多的那一层的结点个数)
-
设有一颗满二叉树,已知其先序序列为pre,设计一个算法求其后序序列post.
-
设计一个算法将二叉树的叶结点按从左到右的顺序连成一个单链表,表头指针为head.二叉树按二叉链表方式存储,链接时用叶节点的右指针域来存放单链表指针。
-
试设计判读两颗二叉树是否相似的算法,所谓二叉树T1和T2相似,指的是T1和T2都是空的二叉树或都只有一个根结点;或T1的左子树和T2 的左子树是相似的,且T1的右子树和T2的右子树是相似的。
-
写出在中序线索二叉树里查找指定结点在后续的前驱结点的算法
-
二叉树的带权路径长度为每个叶结点的深度域权值之积的总和,
算法的基本设计思想
(1)基于先序遍历的算法思想是用static变量记录wpl,把每个结点的深度作为递归函数的一个参数传递,算法步骤如下:
若该结点是叶结点,则遍历wpl加上该结点的深度域权值之积。
若该结点是非叶结点,则左子树不为空时,对左子树调用递归算法,右子树不为空,对右子树调用递归算法,深度参数均为本结点的深度参数加1
最后返回计算出的wpl即可
typedef struct BiTNode{
int weight;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
int WPL(BiTree root){
return wpl_PreOrder(root, 0);
}
int wpl_PreOrder(BiTree root, int deep){
static int wpl=0; //定义变量存储wpl
if (root->lchild==NULL&&root->rchild==NULL) //若为叶节点,则累计wpl
wpl+=deep*root->weight
if(root->lchild!=NULL) //若左子树不空,则对左子树递归遍历
wpl_PreOrder(root->lchild, deep+1);
if (root->rchild!=NULL) //若右子树不空,则对右子树递归遍历
wpl_PreOrder(root->rchild, deep+1);
return wpl;
}
**设计思想:**表达式树的中序序列加上必要的括号即为等价的中缀表达式。可以基于二叉树的中序遍历策略得到所需的表达式。
表达式树中分支结点所对应的子表达式的计算次序,由该分支结点所处的位置决定,为得到正确的中缀表达式,需要在生成遍历序列的同时,在适当位置增加必要的括号。显然,表达式的最外层和操作数不需要添加括号。
算法实现
void BtreeToE(BTree *root){
BtreeToExp(root, 1); //根的高度为1
}
void BtreeToExp(BTree *root, int deep)
{
if(root==NULL) return; //空结点返回
else if (root->left==NULL&&root->right==NULL) //若为叶结点
printf("%s", root->data);
else{
if(deep>1) printf("("); //若有子表达式则加1层括号
B
}
}
树,森林
树的存储结构
树的存储方式有多种,即可以采用顺序存储结构,又可采用链式存储结构,但无论采用何种存储方式,都要求能唯一地反映树中各结点之间的逻辑关系
双亲表示法
这种存储方式采用一组连续空间来存储每个节点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置,
双亲表示法的存储结构描述如下:
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct{ //树的结点定义
ElemType data;
int parent; //双亲位置域
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
该存储结构利用了每个结点只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构。
**注意:**树的顺序存储结构中,数组下标代表结点的编号,下标中所存的内容指示了结点之间的关系。而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,又指示了二叉树中各结点之间的关系。
孩子表示法
孩子表示法是将每个结点的孩子结点都用单链表连接起来形成一个线性结构,此时n个结点就有n个孩子链表。这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。
孩子兄弟表示法
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值,指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode, *CSTree;
树、森林与二叉树的转换
树转换为二叉树的规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则又称“左孩子右兄弟”
**森林转换为二叉树:**先将森林中的每棵树转换为二叉树,由于任何一颗和树对应的二叉树的右子树必空,若把森林中第二课树视为第一课树根的右兄弟,即将第二课树对应的二叉树当做第一课二叉树根的右子树,将第三棵树对应的二叉树当做
二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一课树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为一个由除第一个树外的森林转换后的二叉树。
树和森林的遍历
- 树的遍历是指用某种方式访问树中的每个结点,且仅访问一次,主要有两种方式:
(1)先根遍历。若树非空,先访问根结点,再依次遍历根结点的每颗子树,遍历子树时仍遵循先根后子树的规则,
(2)后根遍历:与二叉树的中序序列相同。 - 森林的遍历
先序遍历森林:(1)访问森林中第一棵树的根节点
(2)先序遍历第一棵树中根结点的子树森林
(3)先序遍历除去第一棵树之后剩余的树构成的森林
中序遍历森林:(1)中序遍历森林中第一课树的根结点的子树森林;(2)访问第一课树的根结点;(3)中序遍历除去第一课树之后剩余的树构成的森林。
选择题
根据森林与二叉树转换规则“左孩子右兄弟”。二叉树B中右指针域为空代表该结点没有兄弟结点。森林中每棵树的根结点从第二个开始依次连接到前一颗树的根的右孩子,因此最后一颗树的根结点的右指针为空。另外,每个非终端结点,其所有孩子结点在转换之后,最后一个孩子的右指针也为空。
- 树有一个很重要的性质,即在n个结点的树中有n-1条边,“那么对于每棵树,其结点数比边数多”。题中森林中的结点数比边数多10(即25-15=10),显然共有10棵树。
左孩子右兄弟
应用题
- 给定一棵树的先根遍历序列和后根遍历序列,能否唯一确定一颗树?若能,请举例说明
能够唯一确定 - 将下面一个由3棵树组成的森林转换为二叉树
- 编程求以孩子兄弟表示法存储的森林的叶子结点数
当森林以孩子兄弟表示法存储时,若结点没有孩子,则它必是叶子,总的叶子结点个数是孩子子树上的叶子树和兄弟子树的叶结点个数之和
typedef strucr node
{
ElemType data;
struct node *fch, *nsib; //孩子与兄弟域
}*Tree;
int Leaves(Tree t){ //计算以孩子兄弟表示法存储的森林的叶子数
if (t==NULL)
return 0; //树空返回0
if(t->fch==NULL) //若结点无孩子,则该结点必是叶子
return 1+Leaves(t->nsib); //返回叶子节点和其兄弟子树中的叶子结点数
else
return Leaves(t->fch)+Leaves(t->nsib);
}
- 以孩子兄弟链表为存储结构,请设计递归算法求树的深度
int Height(CSTree bt){
// 递归求以孩子兄弟链表示的树的深度
int hc, hs;
if(bt==NULL)
return 0;
else{
hc = height(bt->firstchild); //第一子女树高
hs = height(bt->nextsibling); //兄弟树高
if(hc+1>hs)
return hc+1;
else
return hs;
}
}
- 已知一棵树的层次序列及每个结点的度,编写算法构造此树的孩子-兄弟链表
- 正则k叉树中仅含有两类结点:叶节点和度为k的分支结点。树T中的结点总数n=n0+nk=n0+m.树中所含的边数e=n-1。这些边均是从m个度为k的结点发出的,即e=mk,整理得到n0+m=mk+1,故n0=(k-1)m+1
- 高度为h的正则k叉树T中,含最多结点的树形为:满k叉树
树与二叉树的应用
哈夫曼树和哈夫曼编码
在许多应用中,树中结点常常被赋予一个表示某种意义的数值,称为该结点的权。从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度,即为
W
P
L
=
∑
i
=
1
n
w
i
l
i
WPL=\sum^n_{i=1}w_il_i
WPL=i=1∑nwili
式中,wi是第i个叶结点所带的权值,li是该叶结点到根结点的路径长度。
哈夫曼树的构造
给定n个权值分别为w1,w2,…,wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n课仅含一个结点的二叉树,构成森林F
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中
从上述构造过程中可以看出哈夫曼树具有如下特点:
哈夫曼编码
由哈夫曼树得到哈夫曼编码是很自然的过程。首先,将每个出现的字符当做一个独立的结点,其权值为它出现的频度,构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中,我们可将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”
利用哈夫曼树可以设计出总长度最短的二进制前缀编码
注意: 左,右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度WPL相同且为最优。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但WPL必然相同且是最优的。
并查集
并查集是一种简单的集合表示,它支持以下3中操作
1)Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合
2)Union(S,Root1,Root2):把集合S中的子集合Root2并入子集合Root1.要求两者不相交,否则不执行合并
3)Find(S,x):查找集合S中单元素x所在的子集合,并返回该子集合的根结点。
选择题
若某二叉树有5个叶结点,其权值分别为10,12,16,21,30,则其最小的带权路径长度(WPL)是
图
图的基本概念
图的定义
图G由顶点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。若V={v1, v2, …, vn},则用|V|表示图G中顶点的个数,E={(u,v)|u属于V,v属于V},用|E|表示图G中边的条数
**注意:**线性表可以是空表,树可以是空树,但图不可以是空图。就是说,图中不能一个顶点也没有,图的顶点集V一定非空,但边集E可以为空,此时图中只有顶点而没有边
有向图
无向图
简单图,多重图
一个图G如果满足:(1)不存在重复边(2)不存在顶点到自身的边,那么称图G为简单图。
完全图
子图
连通,连通图和连通分量
在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量
强连通图,强连通分量
在有向图中,如果有一对顶点v和w,从v到w和从w到v之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。
注意:在无向图中讨论连通性,在有向图中讨论强连通性
生成树,生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n-1条边。包含图中全部顶点的极小连通子图,只有生成树满足这个极小条件,对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。
注:非连通情况下边最多的情况:由n-1个顶点构成一个完全图,此时再任意加入一条边则变成连通图。
有向图强连通情况下边最少的情况:至少需要n条边,构成一个环路。
顶点的度,入度和出度
- 无向图的全部顶点的度的和等于边数的2倍
- 对于具有n个顶点,e条边的有向图,有向图的全部顶点的入度之和与出度之和相等,并且等于边数,这是因为每条有向边都有一个起点和终点。
边的权和网
稠密图、稀疏图
路径、路径长度和回路
简单路径、简单回路
连通分量是极大连通子图;
图的存储及基本操作
图的存储必须要完整,准确地反映顶点集和边集的信息。根据不同图的结构和算法,采用不同的存储方式将对程序效率产生相当大的影响。
邻接矩阵法
所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(即各顶点之间的邻接关系),
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; // 带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵
int vexnum, arcnum; // 图的当前顶点数和弧数
}
图的邻接矩阵存储:
- 无向图的邻接矩阵一定是一个对称矩阵。因此,在实际存储邻接矩阵时只需存储上三角矩阵的元素
- 对于无向图,邻接矩阵的第i行非零元素的个数正好是顶点i的度TD
- 对于有向图,邻接矩阵的行为出度,列为入度
- 用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连,但是,要确定图中有多少条边,则必须按行,按列对每个元素进行检测,所花费的时间代价很大。
- 稠密图适合使用邻接矩阵的存储表示
邻接表法
当图为稀疏图时,用邻接表法存储邻接关系
所谓邻接表,是指对图G中的每个顶点vi建立一个单链表,第i个单链表中的结点表示依附于顶点vi的边这个单链表就称为顶点vi的边表。边表的头指针和顶点的数据信息采用顺序存储,所以在邻接表中存在两种结点:顶点表结点和边表结点。
顶点表结点由顶点域和指向第一条邻接边的指针构成,边表结点由邻接点域和指向下一条邻接边的指针域构成。
图的邻接表存储结构
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ // 边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
// InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; // 顶点信息
ArcNode *first; //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];
typedef struct{
AdjList vertices; //邻接表
int vexnum, arcnum; //图的顶点数和弧数
}ALGraph; //ALGraph是以邻接表存储的图类型
十字链表
十字链表是有向图的一种链式存储结构。在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点。
在十字链表中,既容易找到Vi为尾的弧,又容易找到Vi为头的弧,因而容易求得顶点的出度和入度。图的十字链表表示不是唯一的,但一个十字链表表示确定一个图。
邻接多重表
邻接多重表是无向图的另一种链式存储结构
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除操作时,需要分别在两个顶点的边表中遍历,效率较低。
图的基本操作
- Adacent(G,x,y):判断图G是否存在边<x,y>或(x,y)
- Neighbors(G,x): 列出图G中与结点x邻接的边
- InsertVertex(G,x): 在图G中插入顶点x
- n个顶点的无向图最多有n(n-1)/2条边,每条边在邻接表中存储两次,所以边表结点最多为n(n-1)个。
- 对于邻接矩阵表示的无向图,边数等于矩阵中1的个数除以2;对于邻接表表示的无向图,边数等于边结点的个数除以2.对于邻接矩阵表示的有向图,边数等于矩阵中1的个数;对于邻接表表示的有向图,边数等于边结点的个数。
- 在邻接矩阵表示的无向图或有向图中,对于任意两个顶点i和j,邻接矩阵中arcs[i][j]或arcs[j][i]为1表示有边相连,否则表示无边相连。在邻接表示的无向图或有向图中,对于任意两个顶点i和j,若从顶点表结点i出发找到编号为j的边表结点
- 写出从图的邻接表表示转换成邻接矩阵表示的算法
算法思想设图的顶点分别存储在数组v[n]中,首先初始化邻接矩阵,遍历邻接表,在依次遍历顶点v[i]的边链表时,修改邻接矩阵的第i行的元素值。若链表边结点的值为j,则置arcs[i][j]=1。遍历完邻接表时,整个转换过程结束。此算法对于无向图,有向图均适用。
void Convert(ALGraph &G, int arcs[M][N]){
// 此算法将邻接表方式表示的图G转换为邻接矩阵arcs
for (i=0; i <n; i++){ //依次遍历各顶点表结点为头的边链表
p = (G->v[i]).firstarc; //取出顶点i的第一条出边
while(p!=NULL){
arcs[i][p->adjvex]=1;
p = p->nextarc; //取下一条出边
}
}
}
**算法思想:**对于采用邻接矩阵存储的无向图,在邻接矩阵的每一行中,非零元素的个数为本行对应顶点的度,可以依次计算连通图G中各顶点的度,并记录度为奇数的顶点个数,若个数为0
图的遍历
图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次且仅访问一次。注意到树是一种特殊的图,所以树的遍历实际上也可视为一种特殊的图的遍历。图的遍历算法是求解图的连通性问题,拓扑排序和求关键路径等算法的基础。
图的遍历比树的遍历要复杂得多,因为图的任一顶点都可能和其余的顶点相邻接,所以在访问某个顶点后,可能沿着某条路径搜索又回到该顶点上。为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问的顶点,为此可以设一个辅助数组visited[] 来标记顶点是否被访问过。
广度优先搜索
广度优先搜索(BFS)类似于二叉树的层序遍历算法。基本思想是:首先访问起始顶点v,接着由v出发,依次访问v的各个未访问过的邻接顶点w1,w2,wi,然后依次访问w1,w2,…,wi的所有未被访问过的邻接顶点;再从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点,直至图中所有顶点都被访问过为止。若此时图中尚有顶点未被访问,则另选图中一个未层被访问的顶点作起始点。
换句话说,广度优先搜索遍历图的过程是以v为起始点,由近至远依次访问和v有路径相通且路径长度为1,2,…的顶点。广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
for(i=0; i<G.vexnum;++i)
visited[i]=FALSE; //访问标记数组初始化
InitQueue(Q); //初始化辅助队列Q
for (i=0; i<G.vexnum;++i) //从0号顶点开始遍历
if(!visited[i]) //对每个连通分量调用一次BFS
BFS(G,i); //vi未访问过,从vi开始BFS
}
void BFS(Graph G, int v){ //从顶点v出发,广度优先遍历图G
visit(v);
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v);
for(w=FirstNeighbor(G,v); w>=0;w=NextNeighbor(G,v,w)) //检测v所有邻接点
if(!visited[w]){
visited(w);
visited[w] = TRUE;
EnQueue(Q,w); //顶点w入队列
}
}
}
BFS算法的性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q,n个顶点均需入队一次,在最坏的情况下,空间复杂度为O(|V|)
采用邻接表存储方式时,每个顶点均需搜索一次,故时间复杂度为O(|V|),在搜索任意顶点的邻接点时,每条边至少访问一次,故时间复杂度为O(|E|),算法总的时间复杂度为O(|V|+|E|).采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(|V|),故算法总的时间复杂度为O(|V|^2)
BFS算法求解单源最短路径问题
使用BFS,我们可以求解一个满足上述定义的非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
BFS算法求解单源最短路径问题的算法如下:
void BFS_MIN_Distance(Graph G, int u){
//d[i]表示从u到i结点的最短路径
for(i=0;i<G.vexnum;++i)
d[i]=INF; //初始化路径长度
visited[u]=TRUE; d[u]=0;
EnQueue(Q,u);
while(!isEmpty(Q)){
DeQueue(Q,u); //队头元素,u出队
for(w=FirstNeighbor(G,u);w>0;w=NextNeighbor(G,u,w))
if(!visited[w]){ //w为u的尚未访问的邻接顶点
visited[w]=TRUE;
d[w]=d[u]+1;
EnQueue(Q,w)
}
}
}
广度优先生成树
在广度遍历的过程中,我们可以得到一颗遍历树,称为广度优先
深度优先搜索
与广度优先搜索不同,深度优先搜索(DFS)类似于树的先序遍历。如其名称中所暗含的意思一样。
它的基本思想如下,首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一顶点,再访问w1邻接且未被访问的任一顶点w2.。。重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有领接顶点未被访问过,则从该点开始继续上述搜索过程。
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0; v<G.vexnum;++v)
visited[v]=FALSE; // 初始化已访问标记数据
for(v=0;v<G.vexnum;++v) //本代码中是从v=0开始遍历
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visit(v); //访问顶点v
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){ //w为v的尚未访问的邻接顶点
DFS(G,w);
}
}
图的遍历与图的连通性
对于无向图来说,若无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故在BFSTraverse()或DFSTraverse()中添加了第二个for循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。对于无向图,上述两个函数调用BFS和DFS的次数等于该图的连通分量数;而对于有向图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量。
- 试设计一个算法,判断一个无向图G是否是一颗树,若是一棵树,则算法返回true,否则返回false
一个无向图G是一棵树的条件是,G必须是无回路的连通图或有n-1条边的连通图,这里采用后者作为判断条件。对连通的判定,可用能否遍历全部顶点来实现。可以采用深度优先搜索算法在遍历图的过程中统计可能访问到的顶点个数和边的条数,若一次遍历就能访问到n个顶点和n-1条边,则可断定此图是一棵树
bool isTree(Graph& G){
for(i=1; i<=G.vexnum;i++)
visited[i]=FALSE;
int Vnum=0, Enum=0; //记录顶点数和边数
DFS(G,1, Vnum, Ennum, visited);
if(Vnum==G.vexnum&&Enum==2*(G.vexnum-1))
return true; //符合树的条件
else
return false; //不符合树的条件
}
void DFS(Graph& G, int v, int& Vnum, int& Enum, int visited[]){
//深度优先遍历图G,统计访问过的顶点数和边数,通过Vnum和Enum返回
visited[v]=TRUE; Vnum++; //作访问标记,顶点计数
int w = FirstNeighbor(G,v); //取v的第一个邻接顶点
while(w!=-1){ //当邻接顶点存在
Enum++; //边存在,边计数
if(!visited[w])
DFS(G,w,Vnum, Enum,visited);
w = NextNeighbor(G, v, w);
}
}
- 写出图的深度优先搜索DFS算法的非递归算法(图采用邻接表形式)
在深度优先搜索的非递归算法中使用了一个栈S来记忆下一步可能访问的顶点,同时使用了一个访问标记数组visited[i]来记忆第i个顶点是否在栈内或曾经在栈内,若是则它以后不能再进栈。图采用邻接表形式
void DFS_Non_RC(AGraph& G, int v){
//从顶点v开始进行深度优先搜索,一次遍历一个连通分量的所有顶点
int w;
InitStack(S);
for(i=0;i<G.vexnum;i++)
visited[i]=FALSE; //初始化visited
Push(S,v); visited[v]=TRUE; //v入栈并置visited[v]
while(!IsEmpty(S)){
k = Pop(S); //栈中退出一个顶点
visit(k); //先访问,再将其子结点入栈
for(w=FirstNeighbor(G,k); w>=0;w=NextNeighor(G,k,w)) //k所有邻接点
if(!visited[w]){ //未进过栈的顶点进栈
Push(S,w);
visited[w]=true; //作标记,以免再次入栈
}
}
}
- 分别采用基于深度优先遍历和广度优先遍历算法判别以邻接表方式存储的有向图中是否存在由顶点vi到顶点vj的路径。注意,算法中涉及的图的基本操作必须在此存储结构上实现
int visited[MAXSIZE]={0}; //访问标记数组
void DFS(ALGraph G, int i, int j, bool &can_reach){
// 深度优先判断有向图G中顶点vi到顶点vj是否有路径,用can_raech标识
if(i==j){
can_reach = true;
return;
}
visited[i]=1; //置访问标记
for(int p=FirstNeighbor(G,i);p>=0;p=NextNeighbor(G,i,p))
if(!visited[p]&&!can_reach) //递归检测邻接点
DFS(G,p,j,can_reach);
}
广度优先遍历算法的实现:
int visited[MAXSIZE]={0}; //访问标记数组
int BFS(ALGraph G, int i, int j){
InitQueue(Q); EnQueue(Q,i); //顶点i入队
while(!isEmpty(Q)){ //非空循环
DeQueue(Q,u); //队头顶点出队
visited[u]=1; //置访问标记
if(u==j) return 1;
for(int p=FirstNeighbor(G,i);p;p=NextNeighbor(G,u,p)){
if(p==j) //若p==j,则查找成功
return 1;
if(!visited[p]){ //否则,顶点p入队
EnQueue(Q,p);
visited[p]=1;
}
}
}
}
- 假设图用邻接表表示,设计一个算法,输出从顶点Vi到顶点Vj的所有简单路径。
本题采用基于递归的深度优先遍历算法,从结点u出发,递归深度优先遍历图中结点,若访问到结点v,则输出该搜索路径上的结点。为此,设置一个path数组来存放路径上的结点,d表示路径长度。
图的应用
图的应用主要包括:最小生成树,最短路径,拓扑排序和关键路径。
最小生成树
一个连通图的生成图包含图的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。
最小生成树具有如下性质:
- 最小生成树不是唯一的,即最小生成树的树形不唯一,R中可能有多个最小生成树。当图G中的各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边数比顶点数少1,即G本身是一颗树时,则G的最小生成树就是它本身。
- 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的
- 最小生成树的边数为顶点数减1
构造最小生成树,用以下性质:假设G=(V,E)是一个带权连通无向图,U是顶点集V的一个非空子集。若(u,v)是一条具有最小权值的边,其中u属于U,v属于v-u,则必存在一颗包含边(u,v)的最小生成树
基于该性质的最小生成树算法主要有Prim算法和Kruskal算法,它们都基于贪心算法的策略,对这两种算法应主要掌握算法的本质含义和基本思想
Prim算法
步骤即为不断地加入最小权值的边
Prim算法的时间复杂度为O(|V|^2),不依赖于|E|,因此它适用于求解边稠密的图的最小生成树。
Kruskal算法
是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
Kruskal算法的步骤如下:
假设G=(V,E)是连通图,其最小生成树T=(U,FT)
初始化:U=V, Et=空。及 每个顶点构成一颗独立的树,T此时是一个仅含|V|个顶点的森林。
循环(重复下列操作直至T是一颗树):按G的边的权值递增顺序依次从E-Et中选择一条边,若这条边加入T后不构成回路,则将其加入ET,否则舍弃,直到Et中含有n-1条边。
时间复杂度为O(|E|log|E|),适用于边稀疏而顶点较多的图。
最短路径
当图是带权图时,把从一个顶点v0到图中其余任意一个顶点vi的一条路径所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径称为最短路径。
求解最短路径的算法通常都依赖于一种性质,即两点之间的最短路径也包含了路径上其他顶点间的最短路径。带权有向图G的最短路径问题一般可分为两类:一是单源最短路径,即求图中某一顶点到其他各顶点的最短路径,可通过经典的Dijkstra(地杰斯特拉)算法求解;二是求每对顶点间的最短路径,可通过Floyd算法求解。
Dijkstra算法求单源最短路径问题
边权存在负值时,Dijkstra算法不适用
floyd算法求各顶点之间最短路径问题
求所有顶点之间的最短路径问题描述:已知一个各边权值均大于0的带权有向图,对任意两个顶点vi不等于vj,要求求出vi与vj之间的最短路径和最短路径长度。
Floyd算法是一个迭代的过程,每迭代一次,在从vi到vj的最短路径上就多考虑了一个顶点;经过n次迭代后,所得到的A{n-1}[i][j]就是vi到vj的最短路径长度,即方阵A{n-1}中就保存了任意一对顶点之间的最短路径长度。
Floyd算法的时间复杂度为O(|V|^3),不过由于其代码很紧凑,且并不包含其他复杂的数据结构,因此隐含的常数系数很小。
Floyd算法允许图中带负权值的边,但不允许有包含带负权值的边组成的回路。Floyd算法同样适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。
有向无环图描述表达式
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图
拓扑排序
AOV网:若用DAG图表示一个工程,其顶点表示活动,用有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行的这样一种关系,则将这种有向图称为顶点表示活动的网络,即为AOV网。
拓扑排序算法的实现
bool TopologicalSort(Graph G){
InitStack(S); //初始化栈,存储入度为0的顶点
for(int i=0; i<G.vexnum;i++)
if(indegree[i]==0)
Push(S,i); //将所有入度为0的顶点出栈
int count = 0; //计数,记录当前已经输出的顶点数
while(!IsEmpty(S)){ //栈不空,则存在入度为0的顶点
Pop(S,i);
print[count++]=i; //输出顶点i
for (p=G.vertices[i].firstarc;p;p=p->nextarc){
// 将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
v = p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0,则入栈
}
if (count<G.vexnum)
return false; //排序失败,有向图有回路
else
return true;
}
}
关键路径
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销,称之为用边表示活动的网络,简称AOE网。AOE网和AOV网都是有向无环图,不同之处在于他们的边和顶点所代表的含义是不同的,AOE网中的边有权值,而AOV网中的边无权值,仅表示顶点之间的前后关系
AOE网的性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始
- 只有在进入某顶点的个有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
在AOE网中,有些活动是可以并行进行的,从源点到汇点的有向路径可能有多条,并且这些路径长度可能不同。完成不同路径上的活动所需时间虽然不同,但是只有所有路径上的活动都完成,整个工程才能算结束。
事件vk的最早发生时间ve(k)
它是指从源点v1到顶点vk的最长路径长度,事件vk的最早发生时间决定了所有从vk开始的活动能够开工的最早时间。
事件vk的最迟发生时间vl(k)
它是指在不推迟整个工程完成的前提下,即保证它的后继事件vj在其最迟发生时间vl(j)能够发生时,该事件最迟必须发生的时间。
关键路径:(1)拓扑排序(2)逆拓扑排序,(3)该弧起点的ve()(4)该弧终点的vl()-持续时间(5)根据l(i)-e(i)=0的关键活动
选择题
- 任何一个无向连通图的最小生成树 :有一颗或多颗
- 从n个顶点的连通图中选取n-1条权值最小的边,即可构成最小生成树
- 最短路径一定是简单路径,Dijkstra算法适合求解有回路的带权图的最短路径,也可以求任意两个顶点的最短路径,不适合求带负权值的最短路径问题。在用Floyd算法求两个顶点的最短路径时,当最短路径发生更改时,pathk-1就不是pathk的子集。
- 深度优先遍历,拓扑排序和关键路径方法可以判断出一个有向图是否有环
- 若有向图的拓扑有序序列唯一,则图中每个顶点的入度和出度不一定最多为1
- 若有向无环图的拓扑序列唯一,也不一定确定该图
- 用DFS遍历一个无环有向图,并在DFS算法退栈返回时打印相应的顶点,则输出的顶点序列是逆拓扑有序。
关键路径求解: - 拓扑排序,每个点的最大路径
- 从后往前,每个点的最小路径
- 事件的初始点的值
- 事件的末端值减事件时间
应用题
- 下面是一种称为“破圈法”的求解最小生成树的方法:
所谓“破圈法”,是指“任取一圈,去掉圈上权最大的边”,反复执行这一步骤,直到没有圈为止。试判断这种方法是否正确。
(2)解题思路:当某个顶点只有出弧而没有入弧时,其他顶点无法到达这个顶点,不可能与其他顶点和边构成强连通分量。 - 顶点1无入弧构成第一个强连通分量,删除顶点1及所有以之为尾的弧
- ,,,
prim算法找最近的点,是与上个集合任何一个点最近即可。
- 邻接表,序号+权值
对于有向无环图G中的任意结点u,v,它们之间的关系必然是下列三种之一:
- 假设结点u是结点v的祖先,则在调用DFS访问u的过程中,必然会在这个过程结束之前递归地对v调用DFS访问,即v的DFS函数结束时间先于u的DFS结束时间。从而可以考虑在DFS调用过程中设定一个时间标记,在DFS调用结束时,对各个结点计时。因此,祖先的结束时间必然大于子孙的结束时间。
- 若u是结点v的子孙,则v为u的祖先,按上述思路,v的结束时间大于u的结束时间
- 若u和v没有关系,则u和v在拓扑序列的关系任意。
bool visited[MAX_VERTEX_NUM]; // 访问标记数组
void DFSTraverse(Graph G){
// 对图G进行遍历,访问函数为visit()
for(v=0;v<G.vexnum;++v)
visited[v]=FALSE;
time = 0;
for (v=0;v<G.vexnum;++v)
if (!visited[v]) DFS(G,v);
}
void DFS(Graph G, int v){
visited[v] = TRUE;
visit(v);
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){
DFS(G,w);
}
time = time+1;
finishTime[v] = time;
}
上三角矩阵排序—>按上三角一行一行的存储
- 当带权连通图的任意一个环中所包含的边的权值均不相同时,其MST是唯一的
例如,取x邻接顶点y的下一个邻接顶点的函数NextNeighbor(G,x,y)
用邻接矩阵作为存储结构
int NextNeighbor(MGraph& G, int x, int y){
if(x!=1 && y!=-1){
for (int col=y+1; col<G.vexnum; col++)
if (G.Edge[x][col]>0 && G.Edge[x][col]<maxWeight)
return col;
}
return -1;
}
图的最小生成树首先必须是带权连通图,其次要在n个顶点的图中选择n-1条边将其连通,使得其权值总和达到最小,且不出现回路。
查找
查找的基本概念
- 查找。在数据集合中寻找满足某种条件的数据元素的过程称为查找。查找的结果分为:查找成功和查找失败
- 静态查找表:若一个查找表的操作只涉及查找和检索属性,则称为静态查找表。与此对应,需要动态地插入或删除的查找表称为动态查找表。适合静态查找表的查找方法有顺序查找,折半查找,散列查找等;适合动态查找表的查找方法有二叉排序树的查找,散列查找等。二叉平衡树和B树都是二叉排序树的改进
- 关键字。数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的
- 平均查找长度。在查找过程中,一次查找的长度是指需要比较的关键字次数,而平均查找长度则是所有查找过程中进行关键字的比较次数的平均值,其数学定义为:
顺序查找和折半查找
顺序查找
顺序查找又称线性查找,他对顺序表和链表都是适用的。对于顺序表,可通过数组下标递增来顺序扫描每个元素;对于链表,可通过指针next来一次扫描每个元素。
引入哨兵:
顺序查找的缺点是当n较大时,平均查找长度较大,效率低;优点是对数据元素的存储没有要求,顺序存储或链式存储都可。对表中记录的有序性也没有要求,无论记录是否按关键字有序,均可应用。对线性的链表只能进行顺序查找。
有序表的顺序查找
若在查找之前就已经知道表示关键字有序的,则查找失败时可以不用再比较到表的另一端就能返回查找失败的信息,从而降低顺序查找失败的平均查找长度。
在有序线性表的顺序查找中,查找成功的平均查找长度和一般线性表的顺序查找一样。查找失败时,查找指针一定走到了某个失败结点。
折半查找
折半查找的基本思想:首先将给定值key与表中中间位置的元素比较,若相等,则查找成功,返回该元素的存储位置。
int Binary_Search(SeqList L, ElemType key){
int low=0, high=L.TableLen-1, mid;
while(low<=high){
mid = (low+high)/2; //取中间位置
if(L.elem[mid]==key)
return mid; //查找成功则返回所在位置
else if (L.elem[mid]>key)
high=mid-1; //从前半部分继续查找
else
low=mid+1;
}
return -1;
}
若有序序列有n个元素,则对应的判定树有n个圆形的非叶结点和n+1个方形的叶结点。显然,判定树是一颗平衡二叉树。
因为折半查找需要方便地定位查找区域,所以它要求线性表必须具有随机存取的特性。因此,该查找法仅适合于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。
分块查找
分块查找的过程分为两步:第一步是在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表,第二步是在块内顺序查找。
分块查找的平均查找长度为索引查找和块内查找的平均长度之和。设索引查找和块内查找的平均查找长度分别为L1,Ls,则分块查找的平均查找长度为
ASL=L1+Ls
A,B, A, A, D, C, B, A,D,B
A, X, X,B, A,B,C,B,X,B
- 在有序单链表上做顺序查找,查找成功的平均查找长度与在无序顺序表或有序顺序表上做顺序查找的平均查找长度相同,都是(n+1)/2
- 折半查找的性能分析可以用二叉判定树来衡量,平均查找长度和最大查找长度都是O(log2n);二叉排序树的查找性能与数据的输入顺序有关,最好情况下的平均查找长度与折半查找相同,但最坏情况即形成单支树时,其查找长度为O(n)
应用题- 若对有n个元素的有序顺表和无序顺序表进行顺序查找,