赞
踩
相关术语:数据、数据元素、数据对象、数据类型和数据结构。
数据(data)是对客观事物的符号表示,它能被计算机识别、存储和加工处理,它是计算机程序加工的“原料”。例如,一个代数方程求解程序所用到的数据是整数和实数;一个编译程序处理的对象是字符串(源程序)。在计算机科学中,数据的含义相当广泛,如客观世界中的声音、图像等,经过某些处理也可被计算机识别、存储和处理,因而它们也属于数据的范畴。数据对象是具有相同性质的数据元素的集合,是数据的一个子集。
数据元素(data element)是数据的基本单位,有时也称为元素、结点、顶点或记录。一个数据元素可能由若干数据项(data item)组成。例如,人口信息查询系统中身份证号、姓名等数据项组成了一个数据元素,即一个数据元素包含了两个以上的数据项。数据项是最小标识单位,有时也称为字段、域或属性。数据元素也可以仅有一个数据项。
数据类型是一个值的集合和定义在此集合上的一组操作的总称。
1)原子类型。其值不可再分的数据类型。
2)结构类型。其值可以再分解为若干成分(分量)的数据类型。
3)抽象数据类型。抽象数据组织及与之相关的操作。
数据结构(data structure)是指数据元素之间的相互关系,即数据的组织形式。它一般包括以下三个方面的内容。
(1)数据元素之间的逻辑关系,也称为数据的逻辑结构(Logical Structure),它独立于计算机,是数据本身所固有的。
(2)数据元素及逻辑关系在计算机存储器内的表示方式,称为数据的存储结构(storage structure)。它是逻辑结构在计算机存储器中的映射,必须依赖于计算机。
(3)数据运算,即对数据施加的操作。运算的定义直接依赖于逻辑结构,但运算的实现必依赖于存储结构。
1.逻辑结构
数据的逻辑结构是从逻辑关系上描述数据,不涉及数据在计算机中的存储,是独立于计算机的。可以说,数据的逻辑结构是程序员根据具体问题抽象出来的数学模型。数据中元素通常有两大类划分关系。
第一种,划分线性结构和非线性结构
对于线性结构来说,有且仅有一个开始和一个终端结点,并且所有结点都最多只有一个直接前趋和一个直接后继。例如:线性表、栈、队列、串和数组。对于非线性结构来说,一个结点可能有多个直接前趋和直接后继。例如:树、图等。
第二种,划分四类基本逻辑结构,即线性结构,集合,树形结构以及图结构下面的这张图给出了数据的逻辑结构之间的层级关系。
每种最明显的特点如下。图中a,b,c,d给出示意图。
线性结构:一对一关系。
集合:结构中的数据元素之间除“同属一个集合”外,别无其他关系。
树形结构:一对多关系。这种结构像自然界中的倒长的“树”一样,呈分支层次状态。在这种结构中,元素之间的逻辑关系通常称做双亲与子女关系。例如,家谱、行政组织结构等都可用树形结构来表示。
图结构:多对多关系。就是说元素间的逻辑关系可以是任意的。在这种结构中,元素间的逻辑关系也称做邻接关系。
2.存储结构
顺序存储:其方法是把数据元素依次存储在一组地址连续的存储单元中,元素间的逻辑关系由存储单元的位置直接体现,由此得到的存储表示称为顺序存储结构(sequential storage structure)。高级语言中,常用一维数组来实现顺序存储结构。该方法主要用于线性结构,非线性结构也可通过某种线性化的处理,实现顺序存储。
链式存储:将数据元素存储在一组任意的存储单元当中,而用附加的指针域表示元素之间的逻辑关系,由此得到的存储表示称为链式存储(linked storage structure)。使用这种存储结构时,往往把一个数据元素及附加的指针放在一个结构体中作为一个结点。在高级语言中,常用指针变量来实现链式存储。
索引存储:该方法的特点是在存储数据元素的同时,还建立附加的索引表。关键字是指能唯一标识数据元素的数据项。若每个数据元素在索引表中均有一个索引项,则该索引表称为稠密索引(dense index)。若一个索引项对应一组数据元素,则该索引表称为稀疏索引(sparse index)。
散列存储:该方法是依据数据元素的关键字,用一个事先设计好的函数计算出该数据元素的存储地址,然后把它存人该地址中。这种函数称为散列函数,由散列函数计算出的地址称为散列地址。
3.数据运算
数据的运算包括定义和实现。运算的定义针对逻辑结构,指功能;运算的实现针对存储结构,指具体操作步骤。具体的指检索,排序,插入,删除,修改,等等。
算法是为了解决某一计算问题而设计的一个过程,并且每一步必须能在计算机上实现而且是在有限的时间内完成,算法含有的5个特性:
(1)有穷性:一个算法对于任何合法的输人必须在执行有限步骤之后结束,且每步都可在有限时间内完成。
(2)确定性:算法的每条指令必须有确切含义,不能有二义性。在任何条件下,算法.只有唯一的一条执行路径,即对相同的输入只能得出相同的结果。
(3)可行性:算法是可行的,即算法中描述的操作均可通过已经实现的基本运算的有限次执行来实现。
(4)输入:一个算法有零个或多个输入,这些输入取自算法加工对象的集合。
(5)输出:一个算法有一个或多个输出,这些输出应是算法对输入加工后合乎逻辑的结果。
评价算法的好坏有4个指标:正确性、可读性、健壮性以及时空效率。其中时空效率是研究的重点。
1.时间复杂度分析概述
算法的时间复杂度是它对一组输入数据执行一次运算并产生输出所需要的时间。那我们该如何度量呢?一种客观的方法就是数一数在执行一次算法时共需要多少次基本运算。
在数出基本运算执行了多少次之前,我们先了解一下算法的结构。
一个算法是由控制结构(循环、顺序和分支3种)和原操作(固有的数据类型的操作)构成的,算法运行取决于两者的综合效果。而这个原操作就是基本运算,指一条或者多条指令可以完成的操作,比如加法,减法,乘法,除法,赋值,比较,等等。
对于基本运算执行了多少次,如果面对稍微复杂的算法而言,数一数这个方法说起来容易做起来难,因此我们必须简化对复杂度的度量,这里的解决方法就是只统计算法中一个主要的基本运算被执行的次数,并把它最为算法的复杂度。主要的基本运算就是在算法中执行次数最多的一个运算。即在算法中和所有语句执行时间以及所有语句的执行次数(T(n))成正比。Time(次数)白话文:通常基本运算(语句)是算法中最深层循环内的语句。(这里的基本运算也是基本语句,考研里的一般就一句话。看例子。)
基于以上的分析我们的目标之一就是找到一个算法中基本运算!(还差一个数一数)
void function(int n) {
for(int i=0;i<n;i++){ //问题规模是n,循环次数为n
printf ("He11o,world!\n") ; //基本运算时间复杂度为O(1)
}
}
到目前为止,我们知道实现目标一。回到开头,我们该如何实现数一数?
由算法的定义可知,算法的基本功能是将一组输入数据进行处理和运算(处理包括格式转化,空置填充等等,不深究)。那么可以得出算法的复杂度与输入数据的规模(通常用n表示)有关。换句话说,也就是:算法的执行时间主要与问题规模(n)有关。
通常来说n越大,基本运算执行的次数越多。但是,我们希望的是算法中主要的基本运算的执行次数要少,并且要求复杂度随着n的增长而缓慢的增长。
所以,找出一个关于n的一个函数f(n)代表基本运算的执行次数。表示为T(n)=O(f(n))。
(这里就将问题规模n和基本运算的执行次数联系起来了)通常问题的规模是算法中控制循环结束的语句。
基于以上的分析我们的目标之二就是算出算法中基本运算的执行次数f(n)!
求出后找出f(n)中随n增大而增大最快的项,然后将其系数变为1。例: 时间复杂度为 。下图是项的增长快慢层级表。
O
(
1
)
<
O
(
l
o
g
2
n
)
<
O
(
n
)
<
O
(
n
l
o
g
2
n
)
<
O
(
n
2
)
<
O
(
n
3
)
<
O
(
2
n
)
<
O
(
n
!
)
<
O
(
n
n
)
O(1)<O(log_2n)<O(n)<O(n log_2n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)
O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
计算算法的时间复杂度就是给出相应的数量级,当f(n)与n无关时,时间复杂度为T(n)=O(1);当f(n)与n是线性关系时,T(n)=O(n);当f(n)与n是二次方关系时,T(n)=O(n2)…
计算大O的步骤总结:
1)找出基本语句和问题规模。
2)计算n的函数,并确定f(n)中随n增大而增大最快的项,然后将其系数变为1。
2.时间复杂度计算
算数运算规则:
1)加法规则
O
(
f
(
n
)
)
+
O
(
g
(
n
)
)
=
O
(
m
a
x
(
f
(
n
)
,
g
(
n
)
)
)
O(f(n))+O(g(n))=O(max(f(n),g(n)))
O(f(n))+O(g(n))=O(max(f(n),g(n)))
2)乘法规则
O
(
f
(
n
)
)
∗
O
(
g
(
n
)
)
=
O
(
m
a
x
(
f
(
n
)
∗
g
(
n
)
)
)
O(f(n))*O(g(n))=O(max(f(n)*g(n)))
O(f(n))∗O(g(n))=O(max(f(n)∗g(n)))
算法分析中常用到多项式求和公式:
∑
i
=
1
n
i
=
n
(
n
+
1
)
2
\sum_{i=1}^{n}i=\frac{n(n+1)}2
i=1∑ni=2n(n+1)
∑
i
=
1
n
i
2
=
n
(
n
+
1
)
(
2
n
+
1
)
6
\sum_{i=1}^{n}i^2=\frac{n(n+1)(2n+1)}6
i=1∑ni2=6n(n+1)(2n+1)
四个基础大O推导法:
1)对于一个循环(for, while),假设**循环体(基本语句)**的时间复杂度为O(n),循环次数为m,则这个循环的时间复杂度为O(n × m)。
void function(int n) {
for(int i=0;i<n;i++){ //问题规模是n,循环次数为n
printf ("He11o,world!\n") ; //基本运算时间复杂度为O(1)
}
}
此时时间复杂度为 O(n × 1),即O(n)。
2)对于多个循环,假设循环体的时间复杂度为 O(n),各个循环的循环次数分别是a, b, c…,则这个循环的时间复杂度为 O(n×a×b×c…)。分析的时候应该由里向外分析这些循环。for的循环条件相互不影响。
void function(int n) {
for(int i=0; i<n; i++){ //循环次数为n
for(int j=0; j<n; j++){ //循环次数为n
printf ("He11o,world!\n"); //基本运算时间复杂度为O(1)
}
}
}
此时时间复杂度为 O(n × n × 1),即 O(n2)。
本质是算出基本语句执行次数:
当i=0,内循环执行n次,
当i=1,内循环执行n次,
……
当i=n-1,内循环执行n次,故一共执行了n*n次。
3)对于顺序执行(顺序结构)的语句,总的时间复杂度等于其中最大的时间复杂度。
void function(int n) {
// 第一部分时间复杂度为 O(n^2)
for (int i = 0;i < n; i++) {
for (int j = 0; j < n; j++) {
printf("Hello, World!\n")
}
}
// 第二部分时间复杂度为 O(n)
for (int j = 0;j < n; j++) {
printf("Hello, World! n")
}
}
此时时间复杂度为 max(O(n2), O(n)),即 O(n2)。
4)对于条件判断(分支结构)语句,总的时间复杂度等于其中时间复杂度最大的路径的时间复杂度。
void function(int n) {
if (n >= 0) {
// 第一条路径时间复杂度为 o(n^2)
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
printf("输入数据大于等于零\n");
}
}
} else {
// 第二条路径时间复杂度为 o(n)
for (int j = 0; j < n; j++) {
printf("输入数据小于零\n");
}
}
}
此时时间复杂度为 max(O(n2), O(n)),即O(n2)。
时间复杂度分析的基本策略是:从内向外分析,从最深层开始分析。如果遇到函数调用,要深入函数进行分析(递归)。
最后,我们来练习一下。
例题1:
void function(int n) {
for (int i = 0;i < n; i++) {
for (int j = i; j < n; j++) {
printf("Hello World\n");
}
}
}
方法①
参考答案:
当 i = 0 时,内循环执行 n 次运算,
当 i = 1 时,内循环执行 n - 1 次运算
……
当 i = n - 1 时,内循环执行 1 次运算。
所以,执行次数
f
(
n
)
=
n
+
(
n
−
1
)
+
(
n
−
2
)
…
…
+
1
=
n
(
n
+
1
)
/
2
=
n
2
/
2
+
n
/
2
f(n) = n + (n - 1) + (n - 2)……+ 1 = n(n + 1) / 2 = n^2 / 2 + n / 2
f(n)=n+(n−1)+(n−2)……+1=n(n+1)/2=n2/2+n/2。
方法②
上述代码可变为:
void aFunc(int n) {
for(int i=0;i<n;i++){
for (int j = 0; j<n-i; j++){
printf("Hello World\n");
}
}
}
分析:
for的循环条件相互影响,无法直接得出每层循环的次数,可以改变为级数的方式(本质还是大O推导法的第2种)。
参考答案:
∑
i
=
0
n
−
1
∑
j
=
0
n
−
i
−
1
1
=
∑
i
=
0
n
−
1
(
n
−
i
−
1
−
0
+
1
)
=
∑
i
=
0
n
−
1
(
n
−
i
)
=
n
−
0
+
n
−
1
+
.
.
.
+
n
−
(
n
−
1
)
=
n
(
n
+
1
)
2
例题1进阶:
void fun(int n) {
int s=0,i,j,k;
for(i=0; i<=n; i++)
for (j=0; j<=i; j++)
for (k=0; k<j; k++)
s++;
}
这里使用级数:
∑
i
=
0
n
∑
j
=
0
i
∑
k
=
0
j
−
1
1
=
∑
i
=
0
n
∑
j
=
0
i
(
j
−
1
−
0
+
1
)
=
∑
i
=
0
n
∑
j
=
0
i
j
=
∑
i
=
0
n
i
(
i
+
1
)
2
=
1
2
(
∑
i
=
0
n
i
2
+
∑
i
=
0
n
i
)
=
2
n
3
+
6
n
2
+
4
n
12
=
O
(
n
3
)
例题2
int m=0,i,j;
for(i=1; i<=n; i++)
for(j=1; j<=2*i; j++)
m++;
当j=1时,s++循环1次
当j=2时,s++循环1次
当j=3时,s++循环1次
……
当j=2i时,s++循环1次
一共循环了2i次。
总结:当每次变量都是自增1时,既可以用级数公式,也可用推的方式计算基本运算的执行次数。
例题3:
void fun () {
int i=1,j=100;
while(i<n) {
++j;
i+=2 ;
}
}
参考答案:
当 i = 1 时,内循环为i=1+2,
当 i = 2 时,内循环为i=1+2+2
……
当 i = n - 1 时,内循环为i=1+2(n-1)。
i的初值为1,每次自增2,假设i自增t次以后循环结束,即当i=t时,内循环为1+2t,最后i的值为1+2t,因此有1+2t<n,解得t,故f(n) =(n-1)/2。
例题4:
void aFunc(int n){
for(int i = 2; i<n; ){
i *= 2;
printf("%d\n", i);
}
}
参考答案:
第一次循环 i=2*2
第二次循环 i=2*2*2
……
假设循环次数为 t,则循环条件满足 2t+1 < n。
可以得出,执行次数t+1 = log2n,即 f(n) = log2n,可见时间复杂度为 O(log2n),即O(log2n)。
总结:当每次变量不是自增1时,只能用推的方式设方程,计算基本运算的执行次数。
例题5:
递归算法的时间复杂度分析:
·递归算法时间复杂度的分析称为变长时间复杂度的分析。
(1)递归算法时间复杂度分析的关键:根据递归过程建立递推关系式。
(2)然后求解这个递推关系式,得到一个表示算法执行时间的表达式。
(3)最后用渐进符号来表示这个表达式即得到算法的时间复杂度。
既然能到语句二,必然经过语句一。
设fact(n)的运行时间复杂度函数是T(n)=O(f(n)),函数中语句(1)的运行时间是O(1),语句(2)的运行时间T(n-1)+O(1),其中O(1)为基本运行时间,因此:
如果n≤1. T(n)=O(1)
如果n>1 T(n)=T(n-1) +O(1)
则:
T(n) =T(n-1)+O(1)
=T(n-2) + O(1) +O(1) = T(n-2) + 2O(1)
=T(n-3)+3*O(1)
…
=T(1)+(n-1)*O(1)
=n*O(1)=O(n) 即fact(n)的时间复杂度为O(n)。
3.算法的最好、最坏和平均情况
有些情况下,算法中基本操作重复执行的次数还随问题的输入数据集不同而不同。
[例]顺序查找,在数组a[]中查找值等于e的元素,返回其所在位置。
for (i=0;i< n;i++)
if (a[i]==e)
return i+1; //找到, 则返回是第几个元素
return 0;
最好情况: 1次
最坏情况: n
平均时间复杂度为:O(n)
最坏时间复杂度:指在最坏的情况下,算法的时间复杂度。
最好时间复杂度:指在最好的情况下,算法的时间复杂度。
平均时间复杂度:指在所有可能输入实例在等概率出现的情况下,算法的期望运行时间。
平均时间复杂度:所有可能输入的实例在等概率出现的情况下,算法期望运行时间。
设一个算法的输入规模为n,Dn是所有输入的集合,输入任何一个I时,概率为P(I) = 1/Dn。T(I)是算法输入I下所执行的基本语句次数,则平均执行时间为
∑
I
∈
D
n
P
(
I
)
∗
T
(
I
)
\sum_{I \in D_n}P(I)*T(I)
∑I∈DnP(I)∗T(I)。
最好情况:T(I)最小值
最坏情况:T(I)最大值
在本例当中,假设e总是在数组a中可以找到,并且e等于任何一个a[i]的概率都是1/n,(1<=i<=n),那么平均时间复杂度为:
1
n
(
1
+
2
+
⋯
+
n
)
=
1
n
n
(
n
+
1
)
2
=
(
n
+
1
)
2
\frac1n (1+2+⋯+n)=\frac1n \frac{n(n+1)}2=\frac{(n+1)}2
n1(1+2+⋯+n)=n12n(n+1)=2(n+1)
空间复杂度是算法运行时所需的存储空间的度量,主要考虑算法运行过程中临时占用的存储空间的大小。
记作:
S
(
n
)
=
O
(
f
(
n
)
)
S(n)=O(f(n))
S(n)=O(f(n)),其中
n
n
n为问题规模;
算法本身要占据的空间,输入输出,指令,常数,变量等。
【例】将一个一维数组a中的n个数逆序存放到原数组中。
我们可以看到算法1所用的辅助空间为1,也就是变量t,故空间复杂度为
O
(
1
)
O(1)
O(1);而算法3用到了辅助数组b,b的大小和a相同,所以算法2在运行时临时占用的空间为n,故空间复杂度为
O
(
n
)
O(n)
O(n)。
空间效率为
O
(
1
)
O(1)
O(1)的算法是指算法所需要的辅助空间并不依赖于问题的规模,并不是不需要任何额外的辅助空间。
结构体可以理解为用已经存在的数据类型(int,float,char…)为原材料制作的数据类型。通俗来说就是组合成用户需要的复杂数据类型。如果想制作一个学生类型的变量,该怎么办呢?这时就用到结构体了。
struct student{ //struct定义的是结构体数据类型。
char name[9];
int age;
char gender;
};
那么定义一个学生类型的变量就如下表示:struct student stu1;结构体变量
那么定义一个班级学生类型的数组如下表示:struct student stu[56];结构体数组
typedef命令的使用。起别名。
例如,上面的结构体可以进行如下改造的普遍格式:
typedef struct student{ //struct表示定义的是结构体数据类型
int a;
char b;
float c;
}TypeA;
上面的语句制造了一个新的数据类型,即TypeA型(也称结构体类型)。接下来定义一个名字为b,大小为3的数组,int b[3],由3个整型分量组成。而语句TypeA a;同样可以认为定义了一个数组,名字为a,与b不同的是,组成a数组的3个分量是不同类型的。
对于数组b,我们想取得其中的值,是通过数组的下标获得的,比如b[0]、 b[1]、 b[2]分别代表数组中第一、第二、第三个元素的值。而对于结构体a,访问结构体变量的成员用运算符“.”来实现。
格式:结构体变量名.成员名
a.a、 a.b、 a.c 分别对应于结构体变量a中第一、第二、第三个元素的值,两者十分相似。
再看语句TypeA a[3];它定义了一个结构体数组,由3个TypeA型的元素组成。前边已经定义TypeA为结构型,它含有3个分量(其实应该叫作结构体的成员,这里为了类比,将它叫作分量),因此数组a中的每个元素都是结构体类型且每个元素都有3个分量,可以把它类比成一个二维数组。例如,int b[3][3];定义了一个名字为b的二维数组。二维数组可以看成其数组元素是一维数组的一维数组,如果把b看成一个维数组,其中的每个数组元素都有3个分量,与a数组不同的地方在于,b中每个元素的3个分量是相同类型的,而a数组中每个元素的3个分量是不同数据类型的。从b数组取第一个元素的第一个分量的值的写法为b[0][0],对应到a数组则为a[0].a。
结构体与数组类比关系可以通过下图来形象地说明。
对于普通的数据类型,变量里装的是数据元素的内容,而指针变量里装的数据的地址。
指针型变量定义:
指针型变量的定义就是在变量名之前多出一个*。
下面我们以这个int *a
和int b
为例:
这里的a是个指针型变量,且让它指向了一个变量b,表达式为a=&b,则a中存放的就是变量b所在的地址。*a就是取变量b的值(x=*a;等价于 x=b;),
int *a; int *a; int x; int b;
&
:取地址运算符
*
:取指针所指向变量的内容
指针型在考研中用得最多的就是和结构型结合起来构造结点(如链表的结点、二叉树的结点等)。下面具体讲解常用结点的构造,这里的“构造”可以理解成先定义一个结点的结构类型,然后用这个结构型制作一个结点。这样说虽不太严谨,但便于理解。
(1)结构体指针
要构造一种结点,必须先定义结点的结构类型。
链表的结点有两个域,一个是数据域,用来存放数据;另一个是指针域,用来存放下一个结点的位置。
typedef struct node{
DataType data; //存放数据
struct node* next; //用来存放下一个结点的位置
}LNode, *LinkList;
定义一个结构体类型的普通变量:struct node p
;由于结构体别名LNode
,所以可以改成LNode p
;
那么定义一个结构体类型指针的变量,也就是struct node* p
;由于结构体别名LNode
,所以可以改成LNode* p
;
内部要用自己的类型来定义这个指针所以写成struct node * next
;
如果把struct node *
起个别名LinkList,struct node* p
等价于LinkList p
。对于定义LNode *p
结点指针,这种写法是顺理成章的,指针定义的一般规律,使我们记忆起来非常方便,不必再加个LinkList p
来增加记忆。但是教材上用的都是指针类型。
通过以上的分析,我们得出定义一个结点有三种方式:
LNode p1; //普通的结构类型
LNode* p2; //结构体类型指针,类比一般指针数据类型int* a; char *b;
struct node * p2; //用的不多,只有在结构体内部定义与自己相同类型的变量才会用到
LinkList p3; //课本用的最多
LNode* p2; //等价于LinkList p3;
(2)变量空间的分配
执行过程:先定义一个结点的指针p,然后用函数malloc()
来申请一个结点的内存空间,最后让指针p指向这片内存空间,这样就完成了一个结点的制作。
上述是完成一个结点的制作,如下:
p=(LNode *)malloc(sizeof(LNode)) //方法一
p=( LinkList)malloc(sizeof(LNode)) //方法二
那么完成一组结点的申请语法如何做呢?
举例:
int *p;
p=(int *)malloc(n * sizeof(int));
只需要在sizeof()
前乘上倍数
n
n
n,这样就申请了一个由指针p所指的元素类型为int
,长度为n的动态数组。
(3)结构体指针取值
那么对于如下的结构体我们该如何取值呢?
typedef struct student{ //struct定义的是结构体数据类型。
char name[9];
int age;
char gender;
}Student;
typedef struct class{
Student stu[MAXSIZE]; //定义一个学生数组,表示一个班级的学生
int stuNum; //学生数量
}Cla, *Classroom;
一般来说,用结构体变量直接取得分量的值,其操作用“.”;如果是用指向结构体变量的指针来取分量,其操作用“->”。
① Cla p; 普通结构体变量
② Cla *p; 结构体指针类型变量
③ Classroom p; 结构体指针类型变量
对于①如果想取stu域中的某个学生值赋给变量x,则应该写成:
x=p.stu[i]
对于②如果想取stu域中的值赋给变量x,则可以写成:
x=p->stu[i] x=(*p).stu[i]
对于③如果想取stu域中的值赋给变量x,则应该写成:
x=p->stu[i]
对于②和③我想取stu域某个学生的年龄赋值给x,写成:
x=p->stu[i].age
参数类型:整形、实型、字符型
void swap(int m, int n) {
int temp;
temp = m;
m=n ;
n=temp ;
}
void main() {
int a b;
scanf("%d%d",&a, &b) ;
swap(a,b) ;
printf("%d,d",a,b) ;
}
此类传值方式非常常见,把实参的值传送给函数局部工作区相应的副本中,函数使用这个副本执行必要的功能。函数修改的是副本的值,实参的值是不变的。无法交换值,不做过多解释。
(1)指针变量作为参数
void main() {
int a b;
int *pl,*p2;
scanf ("%dd",&a,&b) ;
p1=&a;
p2=&b;
swap(pl,p2) ;
printf("%d,od",a,b) ;
}
void swap(int *m, int *n) {
int temp;
temp = *m;
*m=*n;
*n=temp;
}
(2)数组名作为参数
void sub(char b[]){
b[]="world";
}
void main(){
char a[10]="hello";
sub(a);
for(int i=0;i<10;i++)
printf("%c",*(a+i));
}
(3)引用类型
void main() {
int i =5;
int &j=i;
i=7 ;
printf("%d%d",i,j);
}
j是一个引用类型,代表i的一个替代名,当i的值改变时,j的值也跟着改变,所以i和j都会输出7。对于下面代码中的m,n和a,b公用一个存储空间。
void swap(int &m, int &n) {
int temp;
temp = m;
m=n ;
n=temp ;
}
void main(){
int a b;
scanf ("%d%d",&a,&b);
swap(p1,p2) ;
printf("%d,%d",a,b);
}
1.定义
线性表具有相同特性的数据元素组成的一个有限序列。该序列所含元素的个数叫做线性表的长度,用
n
(
n
≥
0
)
n (n≥0)
n(n≥0)表示。
L
i
s
t
=
(
e
1
,
e
2
,
…
,
e
i
−
1
,
e
i
,
e
i
+
1
,
…
,
e
n
)
(
n
≥
0
)
List = (e_1,e_2,…,e_{i-1},e_i,e_{i+1},…,e_n ) (n≥0)
List=(e1,e2,…,ei−1,ei,ei+1,…,en)(n≥0)
线性表
L
i
s
t
List
List中所含元素的个数
n
n
n,那么表长为
n
n
n,
n
=
0
n=0
n=0时称为空表。
L
i
s
t
List
List表中相邻元素之间存在着线性关系,将
e
i
−
1
e_{i-1}
ei−1称为
e
i
e_i
ei的直接前驱。
e
i
+
1
e_{i+1}
ei+1称为
e
i
e_i
ei的直接后继。其中,第一个元素
e
1
e_1
e1为首元素,无直接前驱;最后一个元素
e
n
e_n
en为尾元素,无直接后继。
L
i
s
t
List
List 表中的数据元素类型相同,元素之间存在前驱与后继关系。
线性表中的数据元素也称为结点或记录,可以是原子类型(整型等),也可以是聚合类型(结构体类型等)。
2.线性表逻辑特性
除了第一个元素外,每个元素有且仅有一个直接前驱;除了最后一个元素外,每个元素有且仅有一个后继。其余的内部结点都有且仅有一个直接前驱和一个直接后继。
3.线性表的基本操作概念
线性表上的基本操作有:
(1)线性表初始化: Init_List()。
初始条件:表L不存在。
操作结果:构造一个空的线性表.
(2)销毁线性表: Destroy_List(L)。
初始条件:表L存在。
操作结果:销毁线性表。
(3)求线性表的长度: Length_List(L)。
初始条件:表L存在。
操作结果:返回线性表中所含元素的个数。
(4)检索查找: Locate_List(L,x),x 是给定的一个数据元素。
初始条件:线性表L存在。
操作结果:在表L中查找值为x的数据元素,其结果返回在L中首次出现值为x的那个元素的序号或地址,称为查找成功;否则,在L中未找到值为x的数据元素,返回一特殊值表示查找失败。
(5)插人操作: Insert_List(L,i,x)。
初始条件:线性表L存在,插人位置正确(1≤i≤n+1,n 为插人前的表长)。
操作结果:在线性表L的第i个位置上插人一个值为x的新元素,这样使原序号为i, i+1,…, n的数据元素的序号变为i+1,i+2,…,n+1,插人后表长=原表长+1。
(6)删除操作: Delete_List(L,i)。
初始条件:线性表L存在,并且1≤i≤n。
操作结果:在线性表L中删除序号为i的数据元素(有的时候会用一个变量e返回其值),删除后使序号为i+1,i+2,…,n的元素变为序号为i,i+l,…,n-1,新表长=原表长-1。
4.线性表存储结构
以上的所提及的运算只是逻辑结构上定义的运算。只要给出这些运算的功能是“做什么”,但是真正实现“如何做”的细节时,就需要考虑其存储结构。以下讨论线性表在不同存储结构上的各种操作的实现。由框架图可知:
线性表分为顺序存储结构和链式存储结构,前者叫做顺序表,后者叫做链表。
线性表的顺序存储是线性表的一种最简单最直接的存储结构。它是用内存中的一段地址连续的存储空间顺序存放线性表的每一个元素,用这种存储形式存储的线性表我们称其为顺序表。
在顺序表中用内存中地址的线性关系,表示线性表中数据元素之间的关系。这种用物理上的相邻关系实现数据元素之间的逻辑相邻关系就简单明了。例如线性表(1,2,3,4)存储结构:
为一个典型的线性表顺序存储结构。依次存储,地址连续,中间没有空出的单元。而:
不是一个典型的线性表顺序存储结构。地址不连续,中间有空出的单元。
既然地址是连续的,那我们就可以计算顺序表中元素的存储位置:
如图2.1所示。设e1的存储地址为Loc(e1),每个数据元素占d个字节存储单元,则第i个数据元素的地址为:
L
o
c
(
e
i
)
=
L
o
c
(
e
1
)
+
(
i
−
1
)
∗
d
(
1
<
=
i
<
=
n
)
Loc(e_i)= Loc(e_1)+(i-1)*d(1<=i<=n)
Loc(ei)=Loc(e1)+(i−1)∗d(1<=i<=n)
应用①:已知
e
2
e_2
e2的地址为
L
o
c
(
e
2
)
Loc(e_2)
Loc(e2),那么
L
o
c
(
e
i
)
Loc(e_i)
Loc(ei)的地址为:
L
o
c
(
e
i
)
=
L
o
c
(
e
1
)
+
(
i
−
2
)
∗
d
Loc(e_i)= Loc(e_1)+(i-2)*d
Loc(ei)=Loc(e1)+(i−2)∗d
应用②:假设题目没给出基地址 L o c ( e 1 ) Loc(e_1) Loc(e1)也可以通过角标,或者公式计算地址。
当然,除了上面的用线性表的下标理解方式,也可以用数组的下标进行表示,以下是其他资料中给出的方案。
设
a
1
a_1
a1的存储地址为
L
o
c
(
A
)
Loc(A)
Loc(A),每个数据元素占sizeof(ElemType)
个字节存储单元,则可以通过数组下标计算第
i
i
i个数据元素的地址。
注意:顺序表所占存储空间为:表长sizeof
(元素的类型)
1) 线性表中的元素的序位是从1开始的,而数组中的元素下标是从0开始的。
2) 线性表发生改变时,要及时修改其中length
成员变量的值。(编程需要注意)
从上面我们可以看出线性表的顺序存储元素位置的计算方式,那么顺序表在代码中怎么表示呢?
我们通常用一维数组data[MAXSIZE]
表示顺序表,用一个变量length
记录当前线性表中元素的个数,即线性表的长度, length-1
表示最后一个元素的下标。
表长为length
,数据元素分别存放在data[0]
到data[length-1]
中。MAXSIZE
为最大使用内存空间,length MAXSIZE
。通常将data
和length
封装成一个结构作为顺序表类型:
typedef struct node{
DataType data[MAXSIZE];
int length;
}SeqList, *PSeqList;
定义一个顺序表:SeqList L;
但是C语言中函数的参数传递中,定义指针更方便实现信息的传送,所以我们定义一个指向SeqList类型的指针:PseqList PL;SeqList *L;
如上所示,我们定义的数组其长度不可动态的变化,也就是静态分配。但是线性表又是长度可变的,所以又可以进行数组的动态分配。
typedef struct node{
DataType * data;
int length, MAXSIZE;
}SeqList, *PSeqList;
定义一个顺序表:SeqList L
;
定定义一个指针类型:PSeqList PL
;
注意:静态分配中,data[0]
存放的是首地址,也叫做基地址,同样是存放地址,而且静态分配不方便,那么我们可以直接定义指针变量,也可以用来存储地址,那么该地址的大小是多少呢?这就引出了内存分配函数。
线性表开辟存储空间可以通过PL = (PSeqList)malloc(sizeof(SeqList))
或者使用PL = (SeqList *)malloc(sizeof(SeqList))
操作来获得,也可以通过PL = &L
实现。
malloc(m)
函数,开辟m字节长度的地址空间,并返回这段空间的首地址;sizeof(x)
运算,计算变量x的长度;free(p)
函数,释放指针p所指变量的存储空间,即彻底删除一个变量。PL
是顺序表的地址,这样的线性表在内存中如图2.2所示。
取表长:(*PL).length
或PL->length; L.length=n
存取元素:PL->data[i]
,(*PL).data[i]
;
注意:动态分配并不是链式存储,它同样属于顺序存储结构,物理结构没有变化,依然是随机存取方式,只是分配的空间大小可以在运行时动态决定。
顺序表最主要的特点:
(1)地址连续,依次存放,随机访问(存取),即通过首地址和元素序号可在时间O(1)内找到指定的元素。
(2)存储密度高,每个结点只存储数据元素。
(3)插入和删除操作需要移动大量元素,逻辑上相邻的元素物理上也相邻。
1.顺序表的初始化
顺序表的初始化即构造一个空表,要返回该线性表,所以将返回一个指向顺序表的指针。首先动态分配存储空间,然后将表中 length
置为 0,表示表中没有数据元素。具体算法描述如下所示。
PSeqList Init_SeqList(void){
PSeqList PL;
/*创建一顺序表,入口参数无,返回一个指向顺序表的指针,指针值为零表示分配空间失败 */
PL=(PSeqList)malloc(sizeof(SeqList));
if (PL) {
/*若PL=0表示分配失败*/
PL->length=0;
}
return (PL);
}
2.求顺序表的长度
求顺序表的长度是在顺序表存在的情况下,顺序表中元素的个数。具体算法描述如下所示。
int Length_SeqList (SeqList L) {
/* 求顺序表的长度,入口参数:为顺序表,返回表长*/
return (L.length);
}
3.顺序表的检索操作
顺序表的检索是在表存在的情况下,查找值为
x
x
x的数据元素,若成功,返回在表中首次出现的值为
x
x
x的那个元素的序号(不是下标);未找到值为
x
x
x的数据元素,返回0表示查找失败。在顺序表中完成该运算最简单的方法是:从第一个元素
e
i
e_i
ei起依次和
x
x
x比较直到找到一个与
x
x
x 相等的数据元素,返回它在顺序表中的 data
数组的下标加 1(第一个元素存放在 data[0]
);或者查遍整个表都没有找到与 相等的元素,返0。具体算法描述如下所示。(共两种写法。顺序表只有按值查找,按位查找没有很好的意义,顺序表通过下表就可以直接取值到元素了。)
//在顺序表L中查找第一个元素值等于e的元素,并返回其位序 int Location_SeqList(SeqList L, ElemType e){ for(int i=0; i<L.length; i++) if(L.data[i] == e) return i+1; //数组下标为i的元素值等于e,返回其位序i+1 return 0; //推出循环,说明查找失败 } int Location_SeqList(SeqList L,DataType x) { /*顺序表检索,口参数为顺序表检索元素,返回元素位置,0 表示查找失败*/ int i=0; while(i>=L.length && L.data[i]!=x) i++; if (i>=L.length) { return 0; } else { return (i+1); } }
最好情况:查找的元素就在表头,仅需比较一次,时间复杂度为 O(1)。
最坏情况:查找的元素在表尾(或不存在)时,需要比较
n
n
n次,时间复杂度为 O(n)。
平均情况:假设
p
i
(
p
i
=
1
/
n
)
p_i(p_i =1/n)
pi(pi=1/n)是查找的元素在第 i (1<=i=L.ength)
个位置上的概率,则在长度为
n
n
n的线性表中查找值为
e
e
e的元素所需比较的平均次数为:
∑
i
=
1
n
p
i
∗
i
=
∑
i
=
1
n
1
n
∗
i
=
1
n
n
(
n
+
1
)
2
=
n
+
1
2
\sum_{i=1}^np_i*i =\sum_{i=1}^n \frac1n*i =\frac1n\frac{n(n+1)}2 =\frac{n+1}2
i=1∑npi∗i=i=1∑nn1∗i=n12n(n+1)=2n+1
4.插入
顺序表的插入是指在表的第
i
i
i个位置上插入一个值为
x
x
x的新元素,即在第
i
i
i元素之前插人
x
x
x,使原表长为
n
n
n的表:
(
e
1
,
e
2
,
⋅
⋅
⋅
,
e
i
−
1
,
e
i
,
e
i
+
1
,
⋅
⋅
⋅
,
e
n
)
(e_1,e_2,···,e_{i-1},e_i,e_{i+1},···,e_n)
(e1,e2,⋅⋅⋅,ei−1,ei,ei+1,⋅⋅⋅,en)。
变为表长为
n
+
1
n+1
n+1表:
(
e
1
,
e
2
,
⋅
⋅
⋅
,
e
i
−
1
,
x
,
e
i
,
e
i
+
1
,
⋅
⋅
⋅
,
e
n
)
(e_1,e_2,···,e_{i-1},x,e_i,e_{i+1},···,e_n)
(e1,e2,⋅⋅⋅,ei−1,x,ei,ei+1,⋅⋅⋅,en),其中
1
≤
i
≤
n
+
1
1\le i \le n+1
1≤i≤n+1
在一个顺序表中插入一个元素的前后变化过程如下图所示。假设:原表长为 8在第5个位置(下标为4)上插入元素 Z,必须将第 5个第8个元素(下标位4~7)后移一位,空出第五个的位置,再将Z插入到第五个位置上。
int Insert_SeqList(SeqList &L, int i, int e){
//判断i的范围是否有效
if(i<1||i>L.length+1)
return false;
if(L.length>MaxSize) //当前存储空间已满,不能插入
return false;
for(int j=L.length; j>=i; j--){ //将第i个元素及其之后的元素后移
L.data[j]=L.data[j-1];
}
L.data[i-1]=e; //在位置i处放入e
L.length++; //长度加1
return true;
}
最好情况:在表尾插入(即i= n +1),素后移语将不执行,时间复杂度为 O(1)
最坏情况:在表头插入(即i=1),元素后移语句将执行 n 次,时间复杂度为 O(n)。
平均情况:假设
p
i
(
p
i
=
1
/
(
n
+
1
)
)
p_i(p_i =1/(n+1))
pi(pi=1/(n+1))是在第
i
i
i个位置上插入一个结点的概率,则在长度为
n
n
n的线性表中插入一个结点时,所需移动结点的平均次数为
∑
i
=
1
n
+
1
p
i
(
n
−
i
+
1
)
=
∑
i
=
1
n
+
1
1
n
+
1
(
n
−
i
+
1
)
=
1
n
+
1
∑
i
=
1
n
+
1
(
n
−
i
+
1
)
=
1
n
+
1
n
(
n
+
1
)
2
=
n
2
\sum_{i=1}^{n+1}p_i(n-i+1) =\sum_{i=1}^{n+1} \frac1{n+1}(n-i+1) =\frac1{n+1}\sum_{i=1}^{n+1}(n-i+1) =\frac1{n+1}\frac{n(n+1)}2 =\frac n2
i=1∑n+1pi(n−i+1)=i=1∑n+1n+11(n−i+1)=n+11i=1∑n+1(n−i+1)=n+112n(n+1)=2n
证明平均移动次数:
顺序表在第 i 个位置上插入一个值为 x 的新元素,即在第 i 个元素之前插入 x,使得原表长为 n的表变为表长为 n+1 的表。n=L.length=7。
当 i=n+1 时,移动次数为 0;当 i=8 时,移动次数为 0;
当 i=n 时,移动次数为 1; 当 i=7 时,移动次数为 1;
当 i=n-1 时,移动次数为 2;当 i=6 时,移动次数为 2;
…
当 i=i 时,移动次数为 x;
x+i=n+1,故x=n-i+1;
5.删除
和插入是相反的。
bool LisDelete(SqList &L, int i, int &e){ // e用引用型参数
//判断i的范围是否有效
if(i<1||i>L.length)
return false;
e = L.data[i-1] //将被删除的元素赋值给e
for(int j=L.length; j>=i; j--){ //将第i个后的元素前移
L.data[j-1]=L.data[j];
}
L.length--; //长度减1
return true;
}
最好情况:删除表尾元素 (即 i= n),无须移动元素,时间复杂度为 O(1)
最坏情况:删除表头元素(即 i=1),黑移动除表头元素外的所有元素,时间复杂度为 O(n)
平均情况:假设
p
i
(
p
i
=
1
/
n
)
p_i(p_i =1/n)
pi(pi=1/n)是删除第个位置上结点的概率,则在长度为 n 的线性表中删除一个结点时,所需移动结点的平均次数为
∑
i
=
1
n
p
i
(
n
−
i
)
=
∑
i
=
1
n
1
n
∗
(
n
−
i
)
=
1
n
∑
i
=
1
n
(
n
−
i
)
=
1
n
n
(
n
−
1
)
2
=
n
−
1
2
\sum_{i=1}^np_i(n-i) =\sum_{i=1}^n \frac1n*(n-i) =\frac1n \sum_{i=1}^n (n-i) =\frac1n\frac{n(n-1)}2 =\frac{n-1}2
i=1∑npi(n−i)=i=1∑nn1∗(n−i)=n1i=1∑n(n−i)=n12n(n−1)=2n−1
证明平均移动的次数
顺序表在第 i 个位置上删除元素,使得原表长为 n 的表变为表长为 n-1 的表。n=L.length,但是
1<=i<=n。
当 i=n 时,移动次数为 0;
当 i=n-1 时,移动次数为 1;
当 i=n-2 时,移动次数为 2;
…
当 i=i 时,移动次数为 x;
x+i=n,故 x=n-i;
顺序表的存储特点是利用物理上的相邻关系表达出逻辑上的前驱和后继关系,它要求用连续的存储单元顺序存储线性表中各元素。因此,对顺序表进行插人和删除时需要通过移动数据元素来实现线性表的逻辑上的相邻关系,从而影响其运行效率。本节介绍线性表的链式存储结构。
线性表的链式表示的三种特性:
1.单链表定义
链表是通过一组任意的存储单元(可以连续也可不连续)来存储线性表中的数据元素。根据线性表的逻辑定义,单链表的存储单元不仅能够存储元素,而且要求能表达元素与元素之间的线性关系。对于数据元素ei而言,除存放数据元素自身的信息
e
i
e_i
ei之外,还需要存放后继元素
e
i
+
1
e_{i+1}
ei+1所在存储单元的地址,这两部分信息组成一个“结点”,每个结点包括两个域:数据域——存放数据元素本身的信息;指针域——存放其后继结点的地址,结点的结构如图所示
如果有n个这样的结点由指针链相链接,那么就称为一个链表。为了访问单链表,我们只要知道第一个结点地址就能访问第一个元素,如下图中的指针H。通过第一个元素的指针域得到第二个结点的地址,以此类推可以访问所有元素。指向第一个结点(元素)的地址称为“头指针”。
注:作为线性表的一种存储结构,我们关心的是结点间的逻辑结构(线性关系),而对每个结点的实际地址并不关心,所以通常的单链表用上图的形式表示。
每个结点就是一个元素,结点的定义如下:
typedef struct node{
DataType data;
struct node* next;
}LNode, *LinkList;
定义头结点变量:LNode H
;
定义头结点指针变量:LNode* H, LinkList H
;
注:LNode是结点的类型,LinkList是指向LNode类型结点的指针类型。
既然定义出了Lnode类型结点的指针型变量H,我们用它指向单链表的第一个结点,如下图2.8(a)和(b)所示:
2.链表中设置头结点
为了方便对单链表的操作,一般在单链表的第一个结点(首元结点)前加一个头结点。头结点可以为空,也可以加一些单链表相关的信息,指针域中存放的是第一个数据结点的地址。加头结点的优点:
① 便于首元结点的处理
由于第一个数据结点的位置被存放在头结点的指针域中,在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
② 便于空表和非空表的统一处理
无论链表是否为空,其头指针是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。
注:如果不做特别说明,一般按带头结点考虑。
3.解惑:头指针、头结点和首元结点
头指针:指向第一个结点(元素)的地址称为“头指针”
首元结点:指链表中存储第一个数据元素
e
1
e_1
e1的结点
头结点:指链表在首元结点前附设的一个结点
4.判空条件
不带头结点的链表,头指针为空时表示空表,判空条件表示为:H == NULL
;
带头结点时,头结点的指针域为空表示为空表,判空条件表示为:H->next == NULL
。
5.链表(链式存储结构)特点
解决顺序表需要大量连续的存储空间;非随机存取,也就是查找慢,但是插入和删除比较方便。
1.创建空单链表
链表与顺序表不同,它是一种动态管理的存储结构,链表中的每个结点占用的存储空间不是预先分配,而是运行时系统根据需求生成的,因此建立空单链表就是建立一个带头结点的空表。该算法主要是为单链表申请头结点。具体算法如下所示。
Linklist Creat_Linklist(void) {
/* 创建空单链表,参数:无;返回值:单链表的头指针0代表创建失败非 0代表成功*/
LinkList H;
H=(LinkList)malloc(sizeof(LNode));
if(H)
/* 确认创建头结点建是否成功成功修改单链表头结点的指针域为0代表空表*/
H->nextNULL;
return H;
}
2.单链表的销毁
单链表被构造,使用完后,由于其结点均为动态分配的内存空间,所以必须要销毁,以释放空间,否则会造成空间的浪费。单链表的销毁操作是创建操作的逆运算。销毁后单链表的头指针发生变化(变为空指针),所以要将头指针的地址作为形参。具体算法如下所示。
void Destroy_LinkList(LinkList &H) {
/*销毁单链表,人口参数:单链表头指针的地址*/
LinkList p,q;
p=H;
while(p) {
/*释放单链表的所有结点*/
q=p;
p=p->next;
free(q);
}
/*while*/
H=NULL;
}
3.求表长
由于单链表采用离散的存储方式并且没有显示表长的存储信息,因此要求出单链表的表长,必须将单链表遍历一遍。
算法思路:设一个移动指针 p 和计数器 count;初始化后,p 指向头结点,p 后移一个,结点 count加 1 直至 p 为 NULL。具体算法如下所示。
int Length_LinkList(Linklist H) {
/* 求单链表表长,入口参数:单链表头指针,出口参数:表长- 表示单链表不存在*/
LinkList p=H;/* p指向头结点*/
int count=-1;/*H 带头结点所以从-1 开始 */
while (p) {/*p所指的是第 count+1个结点*/
p=p->next;
count++;
}/*while*/
return (count);
}
该算法的时间复杂度为 O(n),其中n 为单链表的结点数。
4.查找
(1)按序号查找
从单链表的第一个元素结点起,判断当前结点是否是第 个,若是,则返回该结点的指针,否则继续下一个结点的查找,直到表结束为止。若没有第个结点则返空。如果i==0返回头指针
具体算法如下所。
LinkList Locate_Linklist Pos(LinkList H,int i) { /*不正确或者链表不存在,则返回 NULL,==0返回头指针否则返回第个结点的指针*/ LinkListp; int j; p=H; j=0; while (p && j<i) { /*查找第 i个结点*/ p=p->next; j++; }/*while*/ if(j!=i ll !p) { printf("参数 i错或单链表不存在"); return (NULL); } /*第个结点不存在*/ return (p); }
(2)按值查找
单链表的按值查找是在线性表存在的情况下,查找值为的数据元素,若成功,返回首次出现的值为工的那个元索所在结点的指针;否则,未找到值为的数据元素,返回NULL表示查找失败。
算法思路:从链表的第一个元素结点起,判断当前结点其值是否等于
x
x
x,若等于,则返回该结点的指针,否则继续执行下一个,直到表结束为止。
具体算法如下所示。
LinkList Locate_LinkList_Value(LinkList H,DataTypeX) {
/*在单链表中查找值为X的结点,口参数:单链表指针检索元素*/
/*出口参数:找到后返回其指针,否则返回NULL*/
LinkList = H->next;
while (p & p->data!=x)
p=p->next;
return (p);
}
该算法的时间复杂度均为 O(n)
。
5.单链表的建立(头插,尾插,一般插入)
(1)头插法
头部插入表示该表从一个空表开始,重复读入数据;生成新的结点,并将读到的数据放到新结点的数据域中,最后插入到表头的后面。
将上图的过程抽象出头插法的一般操作可以表示为如下两步操作:开辟新结点并赋值;修改指针。
p->next=L->next;
//①插入到表头
L->next=p;
//②头结点的指针指向s
给到对应的代码为:
void CreateList_H(LinkList L,int n){
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL; //先建立一个带头结点的单链表
for(i=n; i>0; --i){
p=(LNode*)malloc(sizeof(LNode));
scanf(&p->data); //输入元素值
p->next=L->next; //插入到表头
L->next=p;
}
}
特点:插入的数据数据和读到的数据是相反的。链表的倒置算法会用到。而尾部插入和一般插入,是正序的。
(2)普通(中间)插入
这个普通插入和头插法很类似,不同的地方就是就是把头指针换成了普通的结点。我们看看他是怎么说的。
插入运算是指在单链表的第
i
i
i个位置前插人一个值为
x
x
x的新结点,即在第
i
−
1
i-1
i−1结点的后面插人值为
x
x
x的新结点,假设第
i
−
1
i-1
i−1结点的指针为
p
p
p,
q
q
q指向待插人的值为文的新结点,将
q
q
q插入到
p
p
p的后面,其插入操作如图所示。具体操作如下:
①q->next=p->next;
②p->next=q;
注:两个指针的操作顺序不能交换。
具体算法如下所示。
int Insert_LinkList(LinkList H,int i,DataType x) { /*在单链表H的第个位置前插入值为 的结点,参数:单链表,插入位置插人元素*/ /*返回参数:成功标志,0表示不成功,1表示成功*/ LinkList p, q; p=Locate_LinkList (H,i-1); /*找第i-1个结点地址*/ if (!p) { printf("有误"); return (0); } q=(LinkList)malloc(sizeof(LNode)); if(!q) { printf(”申请空间失败”); return (0); } /*申请空间失败,不能插入*/ q->data=x; q->next=p->next; /*新结点插人在第 -1个结点的后面*/ p->next=q; return l; /*插入成功,则返回*/ }
该算法的时间主要花费在查找第
i
−
1
i-1
i−1个元素结点上,所以其时间复杂度为O(n)
。
(3)尾部插入
如果希望插入的结点是顺序的,那么可以考虑尾插法。尾插法是在尾部插入新的数据,所以必须要有一个尾指针
r
r
r,始终指向链表的尾结点。
插入操作比上面多了一个移动尾指针的操作,尾指针始终指向尾结点。
r->next=s; r=s;
6.删除结点
与插入结点对应的是删除结点,也比较简单,要将单链表的第i个结点删去,必须先在单链表中找到它的前驱第i-1个结点,再删除其后继结点。若要删除结点b,则仅需要修改结点a中的指针城。假设p为指向a的指针,则只需将p的指针域next指向原来p的下一个结点的下一个结点即可,即
p->next=p->next->next;
示意图:
这里还需注意,在考试答卷中,删除操作除了修改指针外,还要释放所删除结点的内存空间,删除操作应该是:
q=p->next;
p->next=q->next;
free (q);//调用函数free()来释放q所指结点的内存空间。
具体算法如下:
int Del_LinkList(linkList H,int i) { /* 删除单链表H上的第个结点,人口参数:单链表,删除元素序号,返回参数:成功标志,0表示不成功,1 表示成功*/ LinkList p,q; if (H==NULL || H->next==NULL) { printf("链表不存在或者空表不能删除"); return (0); } p=Locate LinkList(H,i-1); /* 找第 i-1个结点地址,见算法 2.1*/ if (p==NULL || p->next==NULL) { printf("参数i错"); return (0);/*第个结点不存在*/ } q=p->next;/*q指向第i个结点*/ p->next=q->next;/*从链表中删除*/ free(q);/*释放*s*/ return (1); }
1.定义
知道了单链表的结构之后,循环单链表就显得比较简单了,只要将单链表的最后一个指针域(空指针)指向链表中的第一个结点即可(这里之所以说第一个结点而不说是头结点是因为:如果循环单链表是带头结点的,则最后一个结点的指针域要指向头结点;如果循环单链表不带头结点,则最后一个指针域要指向首元结点(开始结点))。如图2.11所示为带头结点的循环单链表。
特点:循环单链表可以实现从任一个结点出发访问链表中的任何结点,而单链表从任一结点出发后只能访问这个结点本身及其后边的所有结点。
注意:由于循环单链表中没有NULL指针,所以在遍历操作中,终止操作条件不再像单链表那样判断(p或者p->next为空),而是判断他们的是否等于头指针。
判空条件:带头结点的循环单链表,当(H)head->next==(H)head时,链表为空;不带头结点的循环单链表,当(H)head==NULL时,链表为空。
与单链表的查找比较以及对本身的优化:
由循环单链表的特点可以知道,对于单链表只能从头结点开始遍历整个链表,而对于循环单链表则可以从表中任意结点开始遍历整个链表。对图2.11,找
e
1
e_1
e1的时间复杂度为O(1),但是找en的时间复杂度为O(n),不方便(假设从头遍历,并没有因为是循环单链表而变的查找方便)。
而很多对链表的操作是在表尾、表头进行,此时可以改变一下链表的标识方法,不用头指针而用一个指向尾结点的指针R来标识,既可以找到尾结点,又可以找到头结点(R->next),可以使得操作效率得以提高。都变为了O(1)。
2.操作
综上所述,在加入尾指针的循环单链表的基本操作如下所示:
r->next=s;
s->next=L;
r=s;
带尾指针的循环单链表的合并:
完整代码实现:
LinkList Connect(LinkList R1,LinkList R2){
p=R1->next;
R1->next=R2->next->next;
free(R2->next) ;
R2->next=p;
return R2;
}
1.双链表定义
我们知道单链表的结点有指向后继的指针域,只能由开始结点走到终端结点(查找后继结点比较方便),但是缺点也很明显,也就是无指向前驱的指针域,查找某个结点的前一个结点困难,需要从表头重新开始遍历。
优点;查找某个结点的后继结点的时间复杂度为O(1);
缺点:查找某个结点的前驱结点时间复杂度为O(n)。
若要求输出终端结点到开始结点的序列,单链表操作非常的麻烦,为解决此问题,构造了双链表。
我们只需要在单链表的每个结点再加一个指向前驱的指针域结点的结构为如图下所示,用这种带前驱和后继指针结点组成的链表称为双向链表。
双向链表结点的定义如下:
typedef struct node{
DataType data;
struct node* prior;
struct node* next;
}DuNode, *DLinkList;
和单链表类似,双向链表也有几种变形形式:下图给出了带头结点的双向链表示意图,链表中存在从头到尾和从尾到头的两条链;
性质: 双向链表结构的对称性(设p指向某个结点):p->prior->next= p = p->next->prior
判空条件:
同样双链表也分带头结点和不带头结点。带头结点时,H->next==NULL
时链表为空;不带头结点时,H==NULL
链表为空(为啥是==而不是=,代码中=是赋值哈,不是逻辑运算符)。
2.双链表操作
由于双链表是对称的,所以在插入和删除操作中,必须要同时修改两个方向上的指针,两者的操作时间复杂度为O(n)。
(1)插入
设在双链表中p所在结点之后插入一个结点s,其操作语句如下:
s->next = p->next;
①
s->prior = p;
②
p->next = s;
③//断链
s->next->prior = s;
④
①②两个步骤是可以互换的,原因是什么呢,这就要说到一个重要的点,防止断链,也就是找不到指针。我们知道在单链表中防止断链的操作就是先执行第一步,后执行第二步。同理在双链表中也有同样的操作。大白话来说就是所有和断链有关的操作都要在断链之前完成!(也就是说①②是和断链有关的操作,那必然是在③之前,至于他们的顺序我们不关心。)
指针的变化如下图所示。
说明:按照图2-16所示的顺序来插入,可以看成一个插入“公式”。其特点是,先将要插入的结点的 两边链接好,这样就可以保证不会发生链断之后找不到结点的情况。(这句话在考试写代码特别有用,因为上面描述的结点插入的步骤不唯一,所以这个公式可以帮助我们更快速的把双链表的结点插入代码写出来并且不考虑“哪一步必须在哪一步之前”,减少写代码错误)
设p指向双向链表中某结点,s指向待插入的值为x的新结点,将*s
插入到*p
的前面,插人示意图如下图所示。
具体步骤如下。
s->prior=p->prior;
①
p->prior->next=s;
②
s->next=p;
③
p->prior=s;
④//断链
注意:对于双向链表由于有前驱和后继指针,插人时要修改四个指针,并且指针操作的顺序虽然不是唯一的,但也不是任意的,操作①必须要放到操作④的前面完成,否则*p
的前驱结点的指针就丢掉了。请读者理解清楚每条指针操作的含义。
牛刀小试
例题1:在一个双链表中,在p结点之后插入结点q的操作是()。
A. q->prior=p; p->next=q; p-> next-> prior=q; q->next=p-> next;
B. q->next=p->next; p->next->prior=q; p-> next=q; q->prior=p;
C. p->next=q; q->prior=p; q->next=p->next; p->next->prior=q;
D. q->prior=p; p->next=q; q->next=p->next; p->next->prior=q;
例题2:在一个双链表中,在p结点之前插入q结点的操作是()。
A. p->prior=q; q->next=p; p-> prior-> next=q; q->prior=p->prior;
B. q->prior->p->prior; p->prior->next=q; q-> next=p; p->prior=q->next;
C. q->next=p; p->next=q; q->prior->next=q; q->next=p;
D. p->prior->next=q; q->next=p; q->prior=p->prior; p->prior=q;
例题一答案B。本题考查双链表的插入操作。
A选项执行完第二句时,p的后继结点地址丢失,插入不能完成。
C选项执行完第一句时,p的后继结点地址丢失,插入不能完成。
D选项执行完第二句时,p的后继结点地址丢失,插入不能完成。
例题二答案D。本题考查双链表的插入操作。
A选项执行完第一句时,p的前驱结点地址丢失,插入不能完成。
B选项最后一句p->prior=q->next;应该改为p->prior=q;。
C选项破坏了p和其后继结点的联系。
(2)删除
设要删除双链表中的p结点的后继结点:
q=p-> next;
p->next=q->next;
q->next-> prior=p;
free (q) ;
指针变化过程如图2-17所示。
1.定义
和循环单链表类似,循环双链表的构造源自双链表,即将终端结点的next指针指向链表中的第一个结点,将链表中第一个结点的prior指针指向终端结点,如图所示。
下图给出了带头结点的双句循环链表示意图,链表中存在两个环。
循环双链表同样有带头结点和不带头结点之分。不带头结点的循环双链表为空时H==NULL
;带头结点的循环双链表中是没有空指针的,其空状态下,H->next
和H->prior
必然都等于H
。所以判断其是否为空,只需要检查H->next
和H-> prior
两个指针中的任意一个是否等于H
指针即可,因此,以下四句中的任意一句为真,都可以判断循环双链表为空。
①H->next==H
②H->prior==H
③H->next==H && H->prior==H
④H->next==H || H->prior==H
2.操作
和双链表类似,具体算法中实现
项目 | 查找表头结点 | 查找表尾结点 | 查找结点*p 的前驱结点 |
---|---|---|---|
带头结点的单链表L | L->next时间复杂度为O(1) | 从L->next依次向后遍历时间复杂度 O(n) | 无法通过 p->next 找到 |
带头结点且有尾指针 R 的单链表 | L->next 时间复杂度为O(1) | R 时间复杂度为 O(1) | 无法通过 p->next 找到 |
带头结点仅设头指针 L 的循环单链表 | L->next 时间复杂度为O(1) | 从L->next依次向后遍历时间复杂度 O(n) | 通过p->next可以找到其前驱时间复杂度为 O(n) |
带头结点仅设尾指针 R 的循环单链表 | R->next 时间复杂度为O(1) | R 时间复杂度为 O(1) | 通过 p->next 可以找到其前驱时间复杂度为 O(n) |
带头结点的双向链表 L | L->next 时间复杂度为O(1) | 从 L->next 依次向后遍历时间复杂度 O(n) | p->prior 时间复杂度为O(1) |
带头结点的双向循环链表 L | L->next 时间复杂度为O(1) | L->prior 时间复杂度为O(1) | p->prior 时间复杂度为 O(1) |
注意:增设尾指针只在(循环)单链表中出现过。
根据单链表的知识,用单链表表示线性表时,他的结点空间是在运行时根据需要动态分配的,主要利用指针实现线性表的线性关系。但在有些语言中不提供指针类型,这时我们就无法创建单链表,但我们可借助数组来模拟单链表。用数组下标相对地表示地址,这里的地址为下一个元素在数组中的位置,我们称为静态指针或索引,这种链表称之为静态链表,需要预先分配内存空间。首先定义一个结构体记录类型:
typedef struct{
ElemType data; //元素
int next; //相对地址
}SNode; //结点类型
再定义一个静态链表:
#define MAXSIZE 100
typedef strcut{
SNode sp[MAXSIZE];
int SL; //静态链表头指针
}StList, *PStList;
静态链表示意图:
将以上表述简化为一个简单的案例,如下表示:
静态链表以next==-1作为其结束的标志(图中用^表示)。静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。
链式存储结构的优点:
1.结点空间可以动态申请和释放;
2.数据元素的逻辑次序靠结点的指针来指示,插入和删除时不需要移动数据元素。
链式存储结构的缺点:
存储密度小,每个结点的指针域需额外占用存储空间。当每个结点的数据域所占字节不多时,指针域所占存储空间的比重显得很大。
顺序存储和链式存储的优缺点比较:
顺序存储 | 链式存储 | |
---|---|---|
优点 | 1.方法简单,各种高级语言中都有数组,容易实现 2.不用为表示结点间的逻辑关系而增加额外的存储开销,存储密度大 3.具有按元素序号随机访问的特点,查找速度快 | 1.插人、删除时,只要找到对应前驱结点,修改指针即可,无须移动元素 2.采用动态存储分配,不会造成内存浪费和溢出 |
缺点 | 1.插人删除操作时,需要移动元素,平均移动大约表中一半的元素,元素较多的顺序表效率低 2.采用静态空间分配,需要预先分配足够大的存储空间,会造成内存的浪费和溢出 | 1.在有些语言中,不支持指针,不容易实现 2.需要用额外空间存储线性表的关系,存储空间有点大 3.不能随机访问,查找时要从头指针开始遍历 |
存储密度是指结点数据本身所占的存储量和整个结点结构中所占的存储量之比,即:
案例,例如:
一般地,存储密度越大,存储空间的利用率就越高。显然,顺序表的存储密度为1 (100%) ,而链表的存储密度小于1。
1.栈定义
栈是一种只能在一端进行插入或删除操作的线性表。其中允许进行插入或删除操作的一端称为栈顶(Top)。栈和队列都是线性表的子集,插入和删除的位置受限的线性表。
栈顶由一个称为栈顶指针的位置指示器(其实就是一个变量,对于顺序栈,就是记录栈顶元素所在数组位置标号的一个整型变量;对于链式栈,就是记录栈项元素所在结点地址的指针)来指示,它是动态变化的。
表的另一端称为栈底,栈底是固定不变的。栈的插入和删除操作一般称为入栈和出栈。空栈不包含任何元素。
2.栈进出的数学性质
如图3.1所示,给出进栈入栈的过程,有三个元素
e
1
,
e
2
,
e
3
e_1,e_2,e_3
e1,e2,e3,入栈的顺序是
e
1
,
e
2
,
e
3
e_1,e_2,e_3
e1,e2,e3,出栈序列为
e
3
、
e
2
、
e
1
e3、e2、e1
e3、e2、e1。假设类比一般情况,进栈的序列为
a
,
b
,
c
a,b,c
a,b,c,则它们的出栈序列能有几种。基本情况如下表示:
c
b
a
,
a
b
c
,
a
c
b
,
b
a
c
,
b
c
a
cba,abc,acb,bac,bca
cba,abc,acb,bac,bca。但是
c
a
b
cab
cab该情况是不行的。
少量的元素通过枚举是可以得出答案的,但是对于大量的元素,我们采用数学公式计算。
栈的数学性质:n个不同元素进栈,出栈元素不同排列的个数为
N
=
1
n
+
1
C
2
n
n
N=\frac1{n+1}C_{2n}^n
N=n+11C2nn 。上述公式称为卡特兰(Catalan)数。
3.栈的特点
由上述进栈出栈的顺序可以看出,栈的主要特点就是先进后出(FILO)。栈中的元素就好比开进一个死胡同的车队,最先开进去的汽车只能等后进来的汽车都出去了,才能出来。
4.栈的存储结构
顺序和链式,但是以顺序栈更常见。栈的顺序存储就是顺序栈,链式存储就是链栈。
5.栈的基本操作(顺序和链式)
初始化、判空、入栈、出栈、取栈顶元素、销毁栈。
1.顺序栈定义
栈的存储与一般线性表的实现类似,也有两种存储方式:顺序存储和链式存储。利用顺序存储方式实现的栈称为顺序栈。类似于顺序表的定义,要分配一块连续的存储空间存放栈中的元素,用一个一维数组DataType data[MAXSIZE]来实现栈底到栈顶的数据元素。那么如何表述元素的位置呢?
附设栈底:栈底位置可以是固定在数组的任一端,如固定在下标为-1的位置。
附设栈顶:而栈顶指示当前实际的栈顶元素位置,它是随着插入和删除而变化的,用一个int top变量指明当前栈顶的位置,同样将data和top封装在一个结构中。顺序栈的类型描述如下:
结构体:
typedef struct{
int data[maxSize];
int top;
}SeqStack,* PSeqStack;
SeqStack s;
//普通类型的栈变量
定义一个指向顺序栈的指针:
PSeqStack s;
S=( PSeqStack)malloc(sizeof(SeqStack));
具体地示意图:
由于顺序栈是静态分配存储,而栈的操作是一个动态过程:随着入栈的进行,有可能栈中元素的个数超过给栈分配的最大空间的大小,这时产生栈的溢出现象:称为上溢;随着出栈的进行,也有可能栈中元素全部出栈,这时栈中再也没有元素出栈了,也会造成栈的溢出现象称为下溢。所以,在下面的操作实现中,请读者注意在出栈和人栈时要首先进行栈空和栈满的检测。
那么这些情况也就引出栈的两个状态:
1)栈空状态。
S->top==-1
。有的书上规定 S.top==0
为栈空条件,这样会浪费一个元素大小的空间。
2)栈满状态。
S->top == maxSize-1
。 maxSize
为栈中最大元素的个数,则maxSize - 1
为栈满时栈顶元素在数组中的位置,因为数组下标从0号开始。课本规定栈顶指针top
为-1时栈空,即S->top==0
的数组位置也可以存有数据元素。
2.顺序栈基本操作
(1)初始化
顺序栈的初始化即构造一个空栈,要返回一个指向顺序栈的指针。首先动态分配存储空间,然后,将栈中 top 置为-1,表示空栈。具体算法如下所示。
PSeqStack Init_SeqStack(void){
PSeqStack s;
S=( PSeqStack)malloc(sizeof(SeqStack));
if(s)
s->top = -1;
return s;
}
(2)判栈空
判断栈中是否有元素,算法思想:只需判断 top 是否等于-1 即可。具体算法如下所示。
int Empty_SegStack(PSegStack S) {
/* 判断栈是否为空,入口参数: 顺序栈,返回值:1表示为空0表示非空*/
if (S->top==-1)
return 1;
else
return 0;
}
(3)入栈
入栈操作是在栈的顶部进行插入操作,也即相当于在线性表的表尾进行插入,因而无须移动元素。算法思想是,首先判断栈是否已满,若满退出,否则,由于栈的 top
指向栈顶,只要将入栈元素赋到 top+1
的位置,同时执行top++
即可。具体算法如下所示。
int Push Seq_Stack (PSegStack S,DataType x) {
/* 在栈顶插入一新元素 ,入口参数: 顺序,返回值:1表示成功,表示失败。*/
if (S->top==MAXSIZE-1)
return 0; /*栈满不能人栈*/
else {
S->top++;
S->data[S->top]=x;
return 1;
}
}
(4)出栈
出栈操作是在栈的顶部进行删除操作,也即相当于在线性表的表尾进行删除,因而无须移动元素。算法思想:首先判断栈是否为空,若空退出,否则,由于栈的 top
指向栈顶,只要修改 top
为 top -1
即可。具体算法如下所示。
int Pop_SegStack(PSeqStack S,DataType *x) {
/* 删除栈顶元素并保存在 *,人口参数:顺序,返回值:1表示出成功,0表示失败。*/
if (Empty SegStack (S))
return 0; /*栈空不能出栈*/
else {
*x=S->data[S->top];
S->top--;
return 1;
}
}
注意:这里top指向的是栈顶元素,所以进栈操作为s->data[++s->top]=x
,出栈操作为x=s->data[s->top--]
。若栈顶指针初始化为s->top=0
,即top指向栈顶元素的下一位置,则入栈操作变为s->data[s->top++]=x
;出栈操作变为x=s->data[--s->top]
。相应的栈空、栈满条件也会发生变化。请读者仔细体会其中的不同之处,做题时要灵活应变。
(5)取栈顶元素
取栈顶元素操作是取出栈顶指针 top 所指的元素值。算法思想是,首先判断栈是否为空,若空退出,否则,由于栈的 top 指向栈顶,返回 top 所指单元的值,栈不发生变化具体算法如下所示。
int GetTop_SegStack(PSeqStack S,DataTypeX) {
/* 取出栈顶元素,口参数:顺序,被取出的元索指针,这里用指针带出栈顶值* //* 返回值:1表示成功,0 表示失败*/
if (Empty_SegStack (S))
return 0; /*给出栈空信息*/
else
*x=S->data[S->top];
return (1); /*栈顶元素存入*x中*/
}
(6)求顺序栈的长度
void Length_SeqStack(PseqStack *S){
return S->top+1; /*top指针指向的是栈顶元素所在的位置,即数组的下标*/
}
(7)销毁栈
顺序栈被构造,使用完后,必须要销毁,否则可能会造成申请的内存不能释放,顺序栈的销毁操作是初始化操作的逆运算。由于要修改栈的指针变量,所以要将指针地址传给该函数。首先判断要销毁的栈是否存在,然后在顺序表存在的情况下释放该顺序表所占用的空间,将顺序栈指针赋 0。具体算法如下所示。
void Destroy_SeqStack (PSeqStack* S) {
/* 销毁顺序栈,参数:为要销毁的顺序指针地址,无返值*/
if(*S)
free(*S);
*S=NULL;
return;
}
3.共享栈
利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸,如图3.3所示。
两个栈的栈顶指针都指向栈顶元素,top0=-1 时0号栈为空,top1=MaxSize时 1号栈为空;仅当两个栈顶指针相邻(top1-top0=1)时,判断为栈满。当0号栈进栈时top0先加1再赋值,1号栈进栈时top1先减1再赋值;出栈时则刚好相反。
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。其存取数据的时间复杂度均为O(1),所以对存取效率没有什么影响。
采用链式存储的栈称为链栈,链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。通常采用单链表实现,所以其结点结构与单链表的结点结构相同,即结点结构为:
typedef struct node{
DataType data;
struct node* next;
}StackNode, *PStackNode;
因为栈的主要操作是在栈顶插人、删除,显然以链表的头部做栈顶是最方便的,而且,没有必要像单链表那样为了操作方便附加一个头结点,同时为了方便操作和强调栈顶是栈的一个属性,我们定义一个栈,链栈的存储类型可描述为:
typedef struct{
PstackNode top;
} LinkStack, *PlinkStack;
PlinkStack S;
S= (PLinkStack)malloc(sizeof(LinkStack));
课本教材给出的链栈示意下图3.3:注意:后面和链栈的操作一定要依据这个图进行,不然代码会看不懂的,本质上就是对链表操作。
(1)初始化栈
PLinkStack Init_LinkStack (void){
/*初始化链栈,人口参数: 空,返回值:链栈指针,nul1表示初始化失败*/
PLinkStack S;
S= (PLinkStack)malloc(sizeof(LinkStack));
if(S)
S-> top = NULL;
return (S) ;
}
(2)链栈判空
int Empty_ LinkStack (PLinkStack S){
/*链栈指针,返回值: 1表示栈空,0表示栈非空*/
return (s->top == NULL) ;
}
(3)入栈
int Push_LinkStack(PLinkStack S, int x){
PStackNode p;
p=(PStackNode)malloc(sizeof(StackNode));//为插入的元素开辟空间
if(!p){
printf("内存溢出");
return 0;
}
p->data = x;
p->next = S->top;
S->top = p;
return 1;
}
(4)出栈
int Pop_LinkStack(PLinkStack S, int &x){
PStackNode p;
if(Empty_LinkStack(s)){
printf("栈空,不能出栈");
return 0;
}
*x = s->top->data;// 保留要弹出结点的值
p=s->top;//保留要弹出结点的地址
s->top=p->next;
free(p);
return 1;
}
(5)取栈顶元素
int GetTop_LinkStack (PLinkStack S,DataType* x) {
/* 得到栈顶元素,口参数:链栈指针,出栈元素存放空间地址*/
/* 返回值:1表示出栈成功,0表示失败*/
if (Empty_LinkStack(S)) {
printf(”栈空”);
return (0); /*栈空*/
}
*x=S->top->data; /*栈顶元素存人*x中*/
return (1);
}
栈的应用场景:括号匹配、表达式求值、递归中的应用。
表达式求值是程序设计语言编译中一个最基本的问题。它的实现也是栈的应用中的一个典型的例子。
在表达式求值时,一般表达式有以下三种表示形式:
(1)后缀表示:<操作数> <操作数> <运算符>。
(2)中缀表示:<操作数> <运算符> <操作数>。
(3)前綴表示:<运算符> <操作数> <操作数>。
平常所用的表达式都是中缀表达。如: 1+2* (8-5)- 4/2。
由于中缀表示中有算符的优先级问题,有时还采用括号改变运算顺序,因此一般在表达式求值中,较少采用中缀表示,在编译系统中更常见的是采用后缀表示。上述式子的计算顺序用后缀表示的计算顺序如下。
1.中缀转后缀表达式(逆波兰式)求值
后缀表达式的操作数总是在运算符之前,并且表达式中没有括号又没有优先权。
2.后缀转中缀
从数字那端开始碰到第一个运算符,向后找两个操作数,后面依次类推
3.中缀转前缀表达式
4.前缀转中缀
从数字那端开始碰到第一个运算符,向后找两个操作数,后面依次类推
1.定义
队列和栈一样也是一种特殊的线性表,是限制在表的一端进行插人和在另一端进行删除的线性表。表中允许插人的一端称为队尾(rear),允许删除的一端叫队头(front)。当表中没有元素时称为空队列。
队列的插人操作称为进队列或人队列,队列的删除操作称为退队列或出队列。下图是队列的入队和出队的过程,其特点是先进先出。
存储方式:顺序存储和链式存储
1.队列的顺序存储
顺序存储的队列称为顺序队列,要分配一块连续的存储空间来存放队列里的元素,并且由于队列的队头和队尾都是活动的,因此有队头、队尾两个指针。
这里约定队头指针指向实际(这里指向的是第一个元素的前一个位置)队头元素所在的位置,队尾指针指向实际队尾元素所在的位置。
顺序队定义:
#define MAXSIZ 100 /*队列的最大容量*/
typedef struct{
DataType data[MAXSIZE];
int front,rear;
}Sequence,*PSeqQueue
定义一个指向队列的指针:
教材(指针类型):PSeqQueue Q;
那么队列的数据区域如下表示
Q->data[i] (0<= i <= maxsize - 1)
对头和队尾指针如下表示:
Q->front; Q->rear; (0<= 范围 <= maxsize -1)
出队和入队、假溢出现象
顺序队列是静态分配存储空间,但是操作是一个动态的过程。入对操作在对尾加一个元素,即Q->rear+1,Q->rear==maxsize时队满。出队操作是在队头删除一个元素,有两种方法。
第一种将所有的队列元素向前移一位,Q->rear减1,Q->front始终指向0位置不变,就像排队时,队头总在一个位置不变,每出队一个人,其余人向前走一个位置。
第二种方法是不需要移动元素,修改队头指针Q->front加1,一般常用第二种方法。
对于第二种方法,我们在删除元素时为了避免移动元素,只是修改了队头指针。但是这会造成随着入队出队的进行,会使整个队列整体向后移动,出现了如图d所示的情况。队尾指针已经移到了最后,再有元素人队就会出现溢出,而事实,上此时队中并未真的“满员”,这种现象为“假溢出”。
假溢出的另一种理解方式
图(a)所示为队列的初始状态,有Q.front==Q.rear==0
成立,该条件可以作为队列判空的条件。但能否用Q.rear == MaxSize作为队列满的条件呢?显然不能,图(d)中,队列中仅有一个元素,但仍满足该条件。这时入队出现“上溢出”,但这种溢出并不是真正的溢出,在data数组中依然存在可以存放元素的空位置,所以也是一种“假溢出”。
2.循环队列
为了解决假溢出的问题,可以将队列的数据区data[0…maxsize-1]
看成一个头尾相连的循环结构,头尾指针的关系不变,我们称这个队列为循环队列。
循环结构中data[0]在是data[maxsize-1]之后,当rear==maxsize-1时,再进一个元素就到0了,怎么实现回头操作呢?可以用模除“%”实现。
对于非循环队列入队Q->rear+1
——>循环队列入队Q->rear = (Q->rear+1) % MAXSIZE
对于非循环队列出队Q->front+1
——>循环队列入队Q-> front = (Q->front+1) % MAXSIZE
注意:出队入队按顺时针方向进1
队列状态判断条件:队空:顺序非循环条件下Q.front==Q.rear
;如果入队的速度赶上出队的速度,那么队尾的指针很快就会赶上队头指针,如图3.7(d1)所示,此时可以看出队满时也有Q.front==Q.rear
。
因此队空的条件和队满的条件相同,无法判断。所以这是必须要解决的一个问题。
为了区分队空还是队满的情况。解决方案:
一种:增加一个存储队中元素个数的变量,如num,当num==0
时队空,当num==maxsize时为队满。
二种(常用):牺牲一个存储单元。入队时少用一个队列单元,即,Q.front所指的位置不用,让队尾指针永远赶不上队头,当队尾指针加1从后面赶上队头指针就表示队满。如图d2。
队空条件仍:Q.front == Q.rear
队满条件:(Q.rear+1) % MaxSize == Q.front
队列中元素的个数:(Q.rear-Q.front + MaxSize) % MaxSize
3.顺序队列的操作
(1)队列初始化
(2)判断队空
(3)入队
(4)出队
(5)读取队头元素
(6)销毁栈
队列的链式存储就是用一个线性链表来表示队列,称为链队。为了操作上的方便,我们需要一个头指针和尾指针分别表示队头和队尾。
链队的描述如下:
typedef struct node {
DataType data;
struct node *next;
} Qnode,* PQNode; /*链队结点的类型*/
typedef struct {
PQNnode front,rear;
}LinkQueue,*PLinkQueue; / *将对头对尾指针封装在一起的链队*/
定义一个指向链队的指针:
PLinkQueue Q;
Q= (PLinkQueue) malloc (sizeof (LinkQueue));
按这种思想建立的带头结点的链队存储结构如图3.16所示。Q->front指向链队的队头元素,Q->rear指向链队的队尾元素。出队时只要修改队头指针,入队时只要修改队尾指针即可。链队的基本操作实现如下所示。
链队的基本操作实现如下所示。
(1)初始化一个空队列
(2)判空条件
(3)入队操作
int In_LinkQueue(PLinkQueue Q, DataType x){ /*人队操作,人口参数:链队列和待入队元素x,返回值: 1表示成功,0表示系统内存溢出*/ PQNode p; p=(PQNode)malloc(sizeof(Qnode)); if(!p){ printf ("内存溢出"); return(0); } p->data=x; p->next=null; if(Empty_LinkQueue(Q)){ Q->rear = Q->front = p; }else{ Q->rear->next=p; Q->rear = p; } return 1; }
(4)出队操作
int Out_LinkQueue(PLinkQueue Q,DataType x){ /*出队操作,人口参数:链队列,返回值: 1表示成功,0表示队空,出队元素用x保存*/ PQNode p; if (Empty_LinkQueue(Q)) { printf ("队空"); return (0); /*队空不能出队*/ } x=Q->front->data; p=Q-> front; Q->front=p->next; free(p) ; if(!Q->front) //当出队了一个元素后队变空 Q->rear= NULL; return 1; /*出队完成*/ }
(5)读对头元素
双端队列是指允许两端都可以进行入队和出队操作,如图3.10所示。其元素的逻辑结构仍是线性结构。将队列的两端分别称为前端和后端,两端都可以入队和出队。
双端队列进队时,前端进的元素排列在队列中后端进的元素的前面,后端进的元素排列在队列中前端进的元素后面。
双端队列出队时,无论是前端还是后端出队,后出队的元素排列在先出队的元素后面。
输出受限的双端队列:允许在一端进行插入和删除,但在另一端只允许插入的双端队列称为输出受限的双端队列,如图3.11所示。
输入受限的双端队列:允许在一端进行插入和删除,但在另一端只允许删除的双端队列称为输入受限的双端队列,如图3.12所示。
若限定双端队列从某个端点插入的元素只能从该端点删除,则该双端队列就蜕变为两个栈底相邻接的栈。
串是内容受限的线性表。
1.定义
由零个或多个任意字符串组成的字符序列。一般表示为: ;
其中s是串名;用双引号作为串的定界符,引号引起来的字符序列为串值,引号本身不属于串的内容;
ai(1≤i≤n)是一个任意字符,它称为串的元素,是构成串的基本单位,i是它在整个串中的序号,n为串的长度,表示串中所包含的字符个数,当n=0时,称为空串。
例如,在程序设计语言中
2.相关概念
子串与主串:串中任意连续的字符组成的子序列称为该串的子串。包含子串的串相应地称为主串。
子串的位置:子串的第一个字符在主串中的序号称为子串的位置。
串相等:称两个串是相等的,是指两个串的长度相等且对应位置上字符都相等。
空格串:串中的字符全是空格。空格串不是空串。
例如,有下列4个串a,b,c,d:
a= "Welcome to China’
b= “Welcome”
c= "China "
d= “welcometo”
b和c是a的子串,d则不是a的子串;b在a中的位置是1,c在a中的位置是12。
1.定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。如:
#define MAXSIZE 256 //预定义最大串长为256
char s[MAXSIZE]
那么如何获取串的实际长度呢,这就要定义一个结构体,并在其中定义个长度的量。如:
typedef struct {
char data[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SeqString;
定义一个串变量: SeqString s;
串的实际长度只能小于等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。要克服这种弊端,只能不限定串长的最大长度,即采用动态分配的方式。串长有两种表示方法:一是如上述定义描述的那样,用一个额外的变量len来存放串的长度;二是在串值后面加一个不计入串长的结束标记字符“\0”,此时的串长为隐含值。
从以上我们可以看到,串的起始位置是从0开始的,应用起来不方便。所以设定长串存储空间:char s[MAXSIZE+1];用s[0]存放串的实际长度,此时注意串的长度不能超过一个字符单位字节(char)的表示范围,串值存放在s[1]~s[MAXSIZE],字符的序号和存储位置一致,应用更为方便。
讨论定长串联接、求子串、串比较算法。
1.求串长
int StrLength(char *s)
{
int i=0;
while(s[i]!='\0')
i++;
return i;
}
2.串联接
把两个串s1和s2首尾连接成一个新串s,即s<=s1+s2,算法如下。
int StrConcat(char* sl, char* s2, char* s) { /*新串存储在字符串指针s中*/ int i=0,j,len1,len2; len1= StrLength(s1); len2= StrLength(s2); if (len1+ len2>MAXSIZE-1)//大于新串的长度 return 0; j=0; while(s1[j]!='\0') { s[i]=s1[j]; i++ ; j++ ; } j=0;//将索引下标置为0 while(s2[j]!= '\0') { s[i]=s2[j]; i++ ; j++; } s[i]='\0'; return 1; }
3.求子串(在长度为10的串中取一个从第3个开始长度为3的子串)
int StrSub (char* t, char* s, int i, int len)
{
/*用t返回串s中第i个字符开始的长度为len的子串,1≤i≤串长*/
int slen;
slen= StrLength(s);
if(i<1 || i> slen || len < 0 || len > slen-i + 1)
{
printf ("参数不对");
return 0;
}
for (j=0; j<1en; j++)
t[j]=s[i+j-1];//从i开始,循环j个长度,将取出的串放到新的串中,j从0开始所以要减1
t[j]= '\0';//这个不是在for循环内
return 1;
}
4.串的比较
int StrCmp(char* s1, char* s2)
{
int i=0;
while(s1[i] == s2[i] && s1[i] != '\0')
i++;
return (s1[i] == s2[i])
}
查找子串t(也被称为模式串)在主串s中的位置,查找过程称为模式匹配算法。
如果在s中找到等于t的子串,则称匹配成功,函数返回t在s中的首次出现的存储位置(或序号),否则匹配失败,返回-1。t也称为模式。为了运算方便,设字符串的长度存放在0号单元,串值从1号单元存放。这样,字符序号与存储位置一致。
算法思想:①首先将s1与t1进行比较,若相等,继续逐个比较后序字符;②若不同,子串右移一位,从主串的下一个字符起,重新与模式串进行比较…直到s的某一个字符si和t1相同,再将它们之后的字符进行比较,如此继续往下比较。
计数指针:当s的某一个字符si与t的字符tj不同时,则s返回到本趟开始字符的下一个字符,即si-j+2。本趟的起始位置是i-j+1。t返回到t1,继续开始下一趟的比较,重复上述过程。
若t中的字符全部比完,则说明本趟匹配成功,否则,匹配失败。
设主串s= “ababcabcacbab”,模式t= “abcac”,匹配过程如下图所示。
根据这个思想写出BF算法:
int StrIndex_ BF(char* s, char* t) { /* 从串s的第一个字符开始找首次与串 t相等的子串*/ int i=1,j=1; while(i<=s[0] && j<=t[0])/*两个串都没有扫描完,s[0],t[0]的位置存放的是数组的长度*/ { if (s[i]==t[j]) { i++; j++; }else{ i=i-j+2; /*回溯*/ j=1; } } if(j>t[0]) return i-t[0]; /*匹配成功,返回存储位置*/ else return -1; }
假设在某趟si和tj匹配失败后,指针i不回溯,j也不必回到头,而是将模式串t向右“滑动”至某个位置上,使得某个tk对准si继续向右进行(看具体例子)。显然,匹配失败后模式串移动的距离和主串没有关系,只与模式串本身有关系。现在问题的关键是串t“滑动”到哪个位置上。也就是k是怎么变化的?对应到计数指针上就是j的变化。
第一趟:i=1开始,j=1;到i=3,j=3时不匹配。移动了2位到二趟。
第二趟:i=3开始,j=1;到i=7,j=5时不匹配。移动了3位到三趟。
第三趟:i=7开始,j=2。
1.字符串的前缀、后缀和部分匹配值
首先要了解子串的结构,就要弄清楚几个概念:前缀、后缀和部分匹配值。
前缀:除最后一个字符以外,字符串的所有头部子串;
后缀:除第一个字符外,字符串的所有尾部子串;
部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
下面以’ababa’为例进行说明:
‘a’的前缀和后缀都是空集,最长相等前后缀长度为0;
‘ab’的前缀为{a},后缀为{b},最长相等前后缀长度为0;
‘aba’的前缀为{a,ab},后缀为{a,ba},最长相等前后缀{a}长度为1;
‘abab’的前缀{a,ab,aba},后缀{b,ab,bab},最长相等前后缀长度为2;
‘ababa’前缀{a,ab,aba,abab},后缀{a,ba,aba,baba},最长相等前后缀{aba}长度为3;
故‘ababa’部分匹配值为00123
利用上述方法容易写出子串‘abcac’的部分匹配值为00010,将部分匹配值写成数组的形式,得到部分匹配值的表PM。
下面用PM表进行字符串匹配:
第一趟匹配过程:
第一次匹配到i = 3,j = 3出现不匹配,前面的已匹配的字符数为”ab”,查表可以知最后一个匹配的字符b对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数:
移动位数 = 已匹配的字符数 – 对应的部分匹配值
因为2-0=2,所以将子串向后移动2位,进行第二趟匹配:
发现c与b不匹配,前面4个字符’abca '是匹配的,最后一个匹配字符a对应的部分匹配值为1。4-1=3.将子串向后移动3位,如下进行第三趟匹配:
2.计算子串指针j变化数组next数组
由已知的移动公式进行改写:
移动位数 = 已匹配的字符数 – 对应的部分匹配值
move = (j-1) - PM[j-1];
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。
将上例中字符串’ abcac’的PM表右移一位,就得到了next数组:
1)第一个元素右移以后空缺的用-1来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,而不需要计算子串移动的位数。
2)最后一个元素在右移的过程中溢出,因为原来的子串中,如果用到最后一个元素的部分匹配值时,说明子串已经匹配成功了,故可以舍去。
这样,上式就改写为:
move = (j-1)- next[j];
已知子串移动的位数为move,在实际匹配过程中,子串在内存里是不会移动的,而是指针在变化,书中画图举例只是为了让问题描述得更加形象。注意,这里要转换一下思想,模式串向后移动等价于指针 j 前移,换句话说,模式串后移相当于对指针 j 重定位。那么j可以求出:
j = j-移动位数。
移动位数 = 已匹配的字符数(j-1) – 对应的部分匹配值(next[j])
所以 j = j – 移动位数
= j – (j-1) + next[j]
= next[j] + 1
为了使得公式更加简洁,我们将next的数组整体加1;
得到:
最终得到子串指针变化公式j=next[j]。next[j]的含义是:在子串的第j个字符与主串发生失配时,则跳到子串的next[j]位置重新与主串当前位置进行比较。当next[j]=0时,只需要将字串右移动一位。
简单地讲,数组是由n(n≥1)个相同类型数据元素a0,a1,…,an-1组成的有限序列,且该有限序列存储在一块地址连续的内存单元中,因而数组是顺序存储结构。
对于一个一维数组,一旦a0的存储地址Loc(a_0 )确定,每个数据元素占k个存储单元,则任一数据元素ai的存储地址Loc(ai)可由以下公式求出:
Loc(ai) = Loc(a0)+i*k (0≤i< n)
对于一个二维数组,可将其转化为一维数组来考虑。图5.1为一个m行n列的二维数组,可以看成一个线性表
二维数组中元素在内存中的存储可以按行或者列的次序用一组连续的存储单元存放数组中的数组元素。
在一个以行序为主序的计算机系统中,当二维数组(m*n)第一个数据元素a00的存储地址为Loc(a00),假定每个数据元素占k个存储单元,则该二维数组中任意一组数据元素的存储地址可由下式确定:
列序:j*m+i
Loc(aij) = Loc(a00) + (i * n + j) * k
王道:设二维数组的行下标和列下标的范围分别为[0, h1],[0, h2],则存储关系为:
显然,二维数组同样满足数组的定义。一个二维数组可以看做是每个数据元素都是相同类型的一维数组的一维数组。以此类推,一个三维数组可以看做是一个每个数据元素都是相同类型的二维数组的一维数组,等等。
因此,数组具有以下特点:
(1)数组中的数据元素具有相同的数据类型。
(2)数组是一种随机存储结构,可以根据给定的一组下标直接访问对应的数组元素。
(3)一旦建立了数组,则数组中的数据元素个数和元素之间的关系就不再发生变化。
[例5-1] 设二维数组A[6][10],每个数组元素占4个存储单元,若按行优先顺序存放的数组元素A[3][5]的存储地址是1000,求A[0][0]的存储地址。
分析: A[][]数组按行优先存储,则对于A[3][5], 行标3之前的所有元素都已经填满,每行元素有10个,一共有3行;行标3所指的行中元素个数由列标指示,有6个元素。因此,A[3][5]之前一共有103+5=35个元素,A[3][5]的地址为1000,则A[0][0]的地址为1000-354=860。
在一个n阶方阵A中,若所有元素满足下述性质:
aij=aji 0≤i, j≤n-1
则称A为对称矩阵。
由于对称矩阵中的元素关于主对角线对称,因而只要存储矩阵中上三角或下三角中的元素,让每两个对称的元素共享一个存储空间,这样就可将n2个元素压缩存储到n(n+1)/2个元素空间中,能节约一半的空间。按行优先存储主对角线(包括对角线)以下的元素。
假设以一维数组sa[n(n+ 1)/2]作为n阶对称矩阵A的存储结构,需要保存的元素为:
那么保存在一维数组中的元素可见为:
则矩阵A中任一元素aij和sa[k]之间存在如下对应关系:
上三角阵为矩阵下三角部分(不包括对角线)元素全为c (e 可为0)的矩阵。
下三角阵为矩阵上三角部分(不包括对角线)元素全为c (e 可为0)的矩阵。
三角矩阵的存储方式和对称矩阵类似,以下三角矩阵为例,只需存储对角线及其以下部分的元素和其上三角中的一个元素c即可。对称矩阵类似的压缩存储方法来存储。三角矩阵中的重复元素c可共享一个存储空间,其余的元素正好有n(n+1)/2个,可以用一维数组sa[n(n+1)/2+1]作为n阶下(上)三角矩阵A的存储结构,其中常量c存放在数组的最后一个单元中,当 A为下三角矩阵时,任一元素aij和sa[k]之间存在式(5.4)的对应关系。
什么是稀疏矩阵?简单说,设矩阵Amn中有s个非零元素,若s远远小于矩阵元素的总数(即s<<m×n),则称A为稀疏矩阵。令e=s / (m×n),称e为矩阵的稀疏因子。当用数组存储稀疏矩阵中元素时,仅有少部分的空间被利用,造成空间的浪费。
通俗来说0元素比较多的元素称为稀疏矩阵。为了减少不必要的存储空间的开销,要对矩阵进行压缩。如果只存储非零的元素很显然不合适,这里我们还要存储它所在的行和列。因此,将非零元素及其相应的行和列构成一个三元组(行标,列标,值)。
由于a00位于矩阵的第1行第1列,因此,稀疏矩阵A中的任一非零元素aij可由一个三元组(i+1,j+1,aij)唯一确定。实际记作(i,j,v)。
如下图:
注意:遇到具体题目的时候,除了记录三元组表示的稀疏矩阵的数据,还需要记录稀疏矩阵的行数、列数以及非零元素的个数。
例题:
1.有一个100*90的稀疏矩阵,非0元素有10个,设每个整型数占两个字节,则用三元组表示该矩阵时,所需的字节数是()。
A.60 B.66 C.18 000 D. 33
分析:本题所需要的字节数目应该为10*(1+1+1)2+32=66
2.设数组A[0…8,1…10],数组中任一元素A[i][j]均占内存48个bit,从首地址2000开始连续存放在主内存里,主内存字长为16bit,那么:
(1)存放该数组至少需要的单元数是_________
(2)存放数组的第8列的所有元素至少需要的单元数是__________
(3)数组按列优先存储时,元素A[5][8]的起始地址是_________
答案:(1)270,(2)27,(3)2204
分析:(1)数组A[0…8,1…10]的数组元素个数为910=90,因此存放该数组的单元个数为9048/16=270,其中每个单元占据48/16=3个字长。
(2)存放数组的第8列的所有元素应为948/16=27。
(3)若数组按列优先存储,元素A[5][8]的起始地址为2000 +(79+5)*3= 2204。
3.设数组A[1…50,1… 80]的基地址为2000,每个元素占两个存储单元,若以行序为主序顺序存储,则元素A[45,68]的存储地址为_________;若以列序为主序顺序存储,则元素A[45][68]的存储地址为_________。
答案:(1)9174、(2)8878
分析:本题主要考查计算某元素的存储地址,注意区分按行为主序和以列为主序的存储方式。
(1)A[45][68]的存储地址= 2000+ ((45- 1)*80+67)*2=9174
(2)A[45][68]的存储地址2000+((68-1)*50+44)*2=8788
在前面的所述中,线性表中数据元素的类型必须是相同的而且只能是原子项,如果我们允许表中的数据元素具有自身结构,即数据元素也可以是一个线性表,这就是广义表,有时我们也称之为列表(lists)。
广义表是n≥0个元素的有限序列,即:
Ls= (a1,a2,…,an)
其中,Ls是广义表的名称,n是它的长度。ai可以是单个元素,也可以是广义表,若ai是单个元素,则称它是广义表Ls的原子,若ai是广义表,则称它为Ls的子表。当Ls非空时,称第一个元素ai为Ls的表头(Head),其余元素组成的表(a2,a3,.,an)为表尾(Tail),请读者注意表头表尾的定义。
由于在广义表的定义中又用到了广义表的概念,因而广义表是一个递归定义。一般约定大写字母或嵌套小括号表示广义表(或子表),小写字母表示单个元素(或原子)。下面列举一些广义表的例子。
A=() A是一个空表,其长度为0。
B=(b, c) B是一个长度为2的列表。
C=(a,(d, e, f)) C是一个长度为2的列表,其中第一个元素是原子a,第二个元素是子表(d,e,f)。
D=(A, B, C) D是一个长度为 3的列表,其中三个元素都是子表。
E=(a, E) E是一个长度为2的列表,它是一个递归表。
由于广义表(a1,a2,…,an)中的元素不是同一类型,可以是原子元素,也可以是子表,因此很难用顺序结构存储,通常采用模拟线性链式存储方法来存储广义表。
广义表中的每个元素由一个结点表示。该结点既可表示原子元素又可表示子表,为区分二者,可用一个标志位tag来区分,结点的结构可设计为如下形式:
当tag=0时,原子结点由标志域、值域构成;当tag=1时,表结点由标志域、指向表头的指针域和指向表尾的指针域三部分构成。
由于广义表是对线性表的推广,因此广义表上的操作也有查找、取元素、插人和删除等基本操作,在此我们介绍广义表的3个特殊的基本操作:取表头、取表尾和求广义表的深度。下面我们具体介绍。
1.取广义表表头GetHead()和取广义表表尾GetTail()。
任何一个非空广义表的表头是表中第一个元素,它可能是原子,也可能是广义表,而其表尾必定是广义表。例如:
B=(b, c) B是一个长度为2的列表。
C=(a,(d, e, f)) C是一个长度为2的列表,其中第一个元素是原子a,第二个元素是子表(d,e,f)。
D=(A, B, C) D是一个长度为 3的列表,其中三个元素都是子表。
值得注意的是,广义表()和 (()) 是不同的。前者长度n为0,表示一个空表;后者长度n为1,表示一个有一个空表的广义表,它可以分解得到表头和表尾,均是空表0。
2.求广义表的深度
广义表的深度是指广义表中所含括号的重数。
广义表题型主要是画图以及求长度(子表的个数)、深度(括号最大重数)或者取某个元素。
(1)例如表A = (((a,b),(c,d,e))),取元素e。(表头是表中的第一个元素,表尾是取出第一个元素剩下的部分。如A中只有一个元素((a,b),(c,d,e)),故((a,b),(c,d,e))为表头,表尾为()。
GetHead(A) => ((a,b),(c,d,e))
GetTail(GetHead(A)) => ((c,d,e))
GetHead(GetTail(GetHead(A))) => (c,d,e)
GetTail(GetHead(GetTail(GetHead(A)))) => (d,e)
GetTail(GetTail(GetHead(GetTail(GetHead(A))))) => (e)
GetHead(GetTail(GetTail(GetHead(GetTail(GetHead(A)))))) => e
(2)如广义表((a),((b,e)),())有三个子表(a),((b,e)),(),所以长度为3,其中子表((b,e))有两层括号,再加上最外面一层共三层,所以深度为3。
E = (a,E)递归表的长度为2,深度无穷大。
()空表长度为0,深度为1.
(3)画出广义表 L=((x,a),(x,a,(b,e)))存储结构
(4)设广义表L=((),()),则GetHead(L)是_________,;GetTail(L)是_______;L的长度是_________,L的深度是_________。
答案: ()、(())、2、2
分析:对于广义表Ls= (a1,a2,…,an),各个运算如下:
表头: GetHead(LS)= a1
表尾: GetTail(LS)= (a2,…,an)
定义:树是一种非线性结构(数据结构分为线性结构和非线性结构)。通俗来说(看第一个图),就是有限个结点的集合(集合可以为0个,那样就是空树)。由唯一的根结点(结点A)和若干个互不相交的子树组成。子树又可以分为一颗树。
图6-1 树的结构和非树的结构示意
下面以图6-1(a)为例,给出树的有关概念。树的相关概念主要三大类,分别为结点本身属性、结点之间的关系以及结点所组成的树所产生的特性。下面依次讲解。
(1)结点本身属性:
结点:A,B,C都是结点,包含数据元素和指向子树的分支。
结点的度:结点拥有子树的个数或者分支的个数。
叶子结点(终端结点):度为0的结点。
非叶子结点(非终端结点,又叫分支结点):度不为0的结点。
(2)结点间关系:
层次:根为第一层,下面依次类推。
孩子:结点子树的根。
双亲:与孩子结点定义对应。
兄弟:同一个双亲的孩子互为兄弟。
祖先:从根到某结点为根的子树中的所有结点。
子孙:以某结点为根的子树中的所有结点。
堂兄弟:双亲在同一层的结点互为堂兄弟。
路径和路径长度:路径是由这两个结点之间所经过的结点序列构成;路径长度是路径上所经过的边的个数。
(3)结点所产生的树:
树的度:树中各结点度的最大值。
树高度(深度):理解树的高度或者树的深度首先理解结点的高度(或者深度)。结点的深度是从根结点到目的结点路径上的结点个数。
注意,从某个结点往下走可能走到不同的叶子结点,也就对应不同的路径,我们选择最长路径上的结点数目为该结点的在树中的高度(王道书上的结点高度解释是从叶子结点开始自底向上逐层累加的)。故树的高度或者深度是树中结点的最大层数。
树有如下最基本的性质:
理解:n=B+1;这里的n表示结点数,B就是分支(Branch),加1就是表示唯一的根,所以分支数与结点的度数等价。可以这样理解,除去根结点,每个结点对应一个分支,一个分支一个度。
2)度为m的树中第i层上至多有mi-1个结点。
3)高度为h的m叉树至多有(mh-1)/(m-1)个结点。
4)具有n个结点的m叉树的最小高度为logm(n(m-1)+1)取上界。
n=n0+n1+n2+…+nm;
n= (n0+n1+n2+…+nm)<= (mh-1)/(m-1)
n(m-1)<= (mh-1)
n(m-1)+1<= mh
logm(n(m-1)+1) <= h
①总结点数n=n0+n1+n2+…+nm;
②总分支数B(总的度)=0n0+1n1+2n2+…+mnm;
③总结点数 = 总分支数 + 1;
二叉树是树的某种特殊形态,树通常有三叉树,四叉树等等,这里我们研究二叉树。
定义:二叉树是n(n≥0)个结点的有限集合,当n=0时,为空二叉树;当n>0时,有且有一个根结点,其余结点被分为两个互不相交的子集,一个左子集,一个右子集,每个子集又是一个二叉树。
特点:区别与树的是(1)二叉树的每个结点最多有两颗子树(二叉树中不存在度大于2的结点)。(2)子树有左右之分,其次序是不可以颠倒的。(3)二叉树也可以用递归的形式。
注意:二叉树不是树的特殊情况,他们是两个不同的概念。
二叉树结点的子树要区分左子树和右子树,即使只有一颗子树也要进行区分,说明它是左子树还是右子树。
树当只有一个孩子时,就无需区分他是左还是右的次序。因此二者是不同的。也是两者的主要差别。
二叉树的5种形态如图6.4所示。
1.特殊的二叉树
在了解二叉树前先了解二叉树的种类,其中二叉树分为满二叉树、完全二叉树、二叉排序树和平衡二叉树(这两个放到二叉树的应用讲解)。
1)满二叉树,高度为h,并且含有2h-1个结点,所有分支结点都有左孩子和右孩子结点,并且叶子结点都在最下面一层,并且除了叶子结点之外的每个结点度数均为2。如果按顺序编号,一定是奇数。
2)完全二叉树
书上给的定义就是,一颗深度为k、有n个结点的二叉树进行编号后,各个结点的编号与深度为k的满二叉树中相同的位置上的结点编号均相同。
说人话就是完全二叉树一定是由一个满二叉树从右自左,从下往上,挨个删除结点所得到的,跳着删除不是完全二叉树。
2.二叉树的性质
**性质1:**非空二叉树上的叶子结点数等于双分支结点数加1。
人话:度为0的结点个数为n0,度为二的结点个数为n2,则n0=n2+1。(适用于任何二叉树)
证明:
**性质2:**二叉树的第i层上最多有2i-1(i ≥ 1)个结点。
证明:
**性质3:**高度为h的二叉树最多有2h-1(h ≥ 1)个结点。(换句话说满二叉树有2h-1个结点)。
证明:高度为h的m叉树至多有(mh-1)/(m-1)个结点。
**性质4:**具有n个结点的完全二叉树的深度为:
**性质5:**对于n个结点的完全二叉树中的所有结点按上到下,从左到右的新顺序经行编号对于任意一个结点i(1<= i <=n)的编号都有:
(1)若i=1,则结点i是这棵完全二叉树的根,没有双亲;否则它的双亲编号为i/2取下界。
(2)若2i>n,则结点没有左孩子;否则其左孩子的结点编号为2i;
(3)若2i+1>n,则结点没有右孩子,否则其右孩子的编号为2i+1;
(4)结点i所在的层次为log2i取下界+1
1.顺序存储
顺序存储适用于满二叉树和完全二叉树。用一组连续的存储单元按照完全二叉树的每个结点编号的顺序存放结点内容。图6.7是一颗二叉树及其相应的存储结构。
二叉树的顺序存储结构描述如下:
typedef struct {
DataType data[MaxSize]; //存储在下标为1的数组单元中;
int n; //当前完全二叉树的个数
}QBTree
满二叉树或者完全二叉树利用了性质5。其特点是空间利用率高,普通的二叉树含有的空缺太多,为了体现性质5的中孩子和双亲的关系,需要将空缺的位置用特定的符号填补,利用率下降;
**性质5:**对于n个结点的完全二叉树中的所有结点按上到下,从左到右的新顺序经行编号对于任意一个结点i(1<= i <=n)的编号都有:
(1)若i=1,则结点i是这棵完全二叉树的根,没有双亲;否则它的双亲编号为i/2取下界。
(2)若2i>n,则结点没有左孩子;否则其左孩子的结点编号为2i;
(3)若2i+1>n,则结点没有右孩子,否则其右孩子的编号为2i+1;
(4)结点i所在的层次为log2i取下界+1
但是如果二叉树的结点过多比如有2h-1个元素,数组也设计成这样的大小不合适,为了要求存储结构能依据实际结点数分配存储空间,故产生动态分配的链式存储方式。
2.链式存储
链式存储是二叉树最常用的存储结构。不同的应用中可以增加或者删除指针,如下图所示。特点是寻找孩子容易,寻找双亲比较困难,若要频繁的找双亲,可给每个结点加一个指向双亲的指针域。
可以由上图可知:
二叉树的链式存储结构代码描述如下:
typedef struct bnode{
DataType data;
struct bnode *lchild;
struct bnode *rchild;
}Bnode, *BTree
容易验证,和单链表类似,一个二叉链表由一个头指针唯一确定。若二叉树为空,头指针为空;若某个结点的孩子不存在,其相应的指针也指向空。故在一个具有N个结点的二叉树中,定是共有2N个指针,其中只有N-1个用来指示结点的左孩子和右孩子,其他的N+1指针为空。
证明1:
n=B+1;
B=n-1; B是分支数 = 总结点的度数 = 有结点、有孩子、非空指针
2n-(n-1) = n+1;
证明2:
每个叶子结点有2个空指针,每个度为1的结点有1个空指针,度为2的结点没有空指针,空指针总数为2n0 + 1n1 + 0n2,又n0 = n2 + 1,所以空指针的总数为n0+n1+n2+1 = n+1。
二叉树是非线性的一种数据结构,如何逐一的对数据元素进行访问,由此提出了二叉树的遍历问题。所谓遍历就是要求每个结点能被访问到。常见的遍历方式分为五种,分别为先序、中序、后序、非递归和层次遍历。
2.先序遍历的递归算法
3.利用栈的先序遍历的非递归算法
4.中序遍历
中序遍历和先序遍历的算法设计思想完全一致,仅仅是处理和加工结点的次序不同,对上述先序遍历算法稍加改动就得到中序遍历的递归和非递归算法。
递归算法:将Visit (t->data) 语句插人到PreOrder (t-> lchild) 和PreOrder (t->rchild)之间。
非递归算法:将Visit(t-> data) 语句放在出栈之后。
若二叉树为空,则结束遍历操作;否则中序遍历根结点的左子树;访问根结点;中序遍历根结点的右子树。
中序遍历的算法。
5.利用栈的中序遍历的非递归
6.后序遍历递归算法
若二叉树为空,则结束遍历操作;否则后序遍历根结点的左子树;后序遍历根结点的右子树;访问根结点。
7.利用栈的后序遍历的非递归
方法一:利用先序遍历非递归方法,对二叉树按照根.右孩子.左孩子的顺序进行访问,访问到的结点暂时不输出,而是保存到另外一个栈中,待访问结束后,将被保存在栈中的结点输出即可。显然,这个算法需要用两个栈。后序遍历的非递归算法如下所示。
8.遍历序列的求解
以上时遍历算法的实现,手动遍历序列的求解。
重要技巧提示:
图6-9所示为指针p沿着图中箭头所指示路线游历整个二叉树的过程,并且对于图中每个结点,p都将经过它3次(如B结点周围的标号1、2和3,指出了p 3次经过此结点的情况)。对应于p游历整棵树的过程的程序为图6-9中右边的程序模板。如果将对结点的访问操作写在(1)处,则是先序遍历,即对应于图中的每个结点,在p所走线路经过的标号1处(第一次经过这个结点时)对其进行访问;如果将对结点的访问操作写在(2)处,则是中序遍历,即对应于图中的每个结点,在标号2处(第二次经过这个结点时)对其进行访问;如果将对结点的访问操作写在(3)处,则是后序遍历,即对应于图中的每个结点,在标号3处(第三次经过这个结点时)对其进行访问。
先序遍历输出序列为: A, B, C, D, E, F, G, H.
中序遍历输出序列为: C, B, E, D, F, A, H, G.
后序遍历输出序列为: C, E, F, D, B, H, G, A.
9.层次遍历
要进行层次遍历,需要建立一个循环队列。先将二叉树头结点入队列,然后出队列,访问该结点,如果它有左子树,则将左子树的根结点入队;如果它有右子树,则将右子树的根结点入队。然后出队列,对出队结点访问。如此反复,直到队列为空为止。
算法的自然语言描述:
(1)访问根结点,并将根结点人队。
(2)当队列不空时,重复下列操作。
·从队列退出一个结点。
·若其有左孩子,则访问左孩子,并将其左孩子人队。
·若其有右孩子,则访问右孩子,并将其右孩子人队。
二叉树的层次遍历:
void LevelOrder(BiTree T){
InitQueue(Q);//初始化辅助队列
BiTree p;
EnQueue(Q,T);//将根结点入队
while(!IsEmpty(Q)){//队列不空则循环
DeQueue(Q,p);//队头结点出队
visit(p);//访问出队结点
if(p->1child != NULL)
EnQueue(Q,p->1chi1d); //左子树不空,则左子树根结点入队
if(p->rchild != NULL)
EnQueue(Q,p->rchild);//右子树不空,则右子树根结点入队
}
}
10.由遍历序列构造二叉树
先序和中序、中序和后序、层次和中序可以构造出二叉树。
中序是确定左右的,先序确定根。
1.基本概念
遍历二叉树得到的一个线性序列,使得该序列除了第一个和最后一个结点外,都有一个直接前驱和直接后继。我想把它连成一个线性表,如何做?前面提到的含n个结点的二叉树有n+1个空指针,我们利用这些空指针来存放指向其前驱或者后继的指针。
原来的话我们是靠遍历完成查找的,
比较麻烦。这里我们就添加两个指针,
一个左指针,一个右指针。
目的:引入线索二叉树的目的是为了加快其查找结点的前驱和后继。
规则:若无左孩子,左指针指向它的前驱,若无右孩子,右指针指向它的后继。
线索二叉树的存储结构描述如下:
typedef char DataType; /*设数据类型为字符型*/
typedef struct Threadnode{
int ltag,rtag;
DataType data;
struct Threadnode *lchild,*rchild;
}Threadnode,*ThreadTree;
线索二叉树的结点结构:
lchild | ltag | data | rtag | rchild |
---|
ltag和rtag是信号标志,方便计算机识别。
ltag=0: lchild指示该结点的左孩子;
ltag=1: lchild指针该结点的前驱。
rtag=0: rchild指示该结点的右孩子;
rtag=1: rchild指针该结点的后继。
*lchild, *rchild称为线索,加上线索的树为线索二叉树。
那么把上述组合起来的结点结构称为二叉链表,又叫做线索链表。
2.中序线索二叉树
(1)中序线索化(对一棵已经存储的二叉树加线索)
二叉树线索化的过程,实质上就是遍历一棵二叉树的过程。在中序遍历过程中,将访问结点的操作改成检查当前结点的左、右孩子指针域是否为空,如果为空,则将左右孩子指针域分别放人前驱结点或后继结点的地址。为实现这一过程,设指针pre始终指向刚刚已访问过的结点,即若指针p指向当前结点,则pre指向p的前驱,以便增设线索,具体算法如下所示。
将此算法和中序遍历的递归算法进行比较,读者能发现,只要将中序遍历算法中的Visit()函数改成对当前结点加线索的处理就可以了,因此二叉树线索化算法实质就是遍历算法。
3.先序和后序线索二叉树
题型就是遍历画图,先序和同中序一样的。
后序线索遍历需要栈的支持。
4.中序线索二叉树的遍历算法
对链式存储的二叉树进行遍历不需要栈。实现步骤是:先找到中序遍历到的第一个结点,然后找此结点的后继,再找后继的后继。以此类推,直到所有结点被遍历到。该算法的描述如下所示。
算法中函数InPostNode §是找p的后继(线索二叉树的运算中实现),操作简单,不需要栈。可见在这个算法中不需要借助任何栈就可以遍历二叉树每个结点。
1.双亲表示法
树的双亲表示法主要描述的是结点的双亲关系,采用顺序存储结构,如图所示。
用c类型定义
#define MaxNodeNum 100
typedef struct {
DataType data;
int parent;
} Parentlist;
typedef struct {
Parentlist elem[MaxNodeNum];
int n;
} ParentTree;
这种存储方法的特点是寻找结点的双亲很容易,但寻找结点的孩子比较困难。
2.孩子表示法
孩子表示法主要描述的是结点的孩子关系。由于每个结点的孩子个数不定,所以利用链式存储结构更加适宜。对每个结点建立一个链表,链表中的元素就是头结点的孩子。n个结点就有n个链表,如何管理这些链表呢?最好的方法是将这些链表的头结点放在一个维数组中,例如图6.19所示的树可存储成图6.20所示的结构。
这种存储结构的特点是,寻找某个结点的孩子比较容易,但寻找双亲比较麻烦。所以,在必要的时候,可以将双亲表示法和孩子表示法结合起来。即将一维数组元素增加一个表示双亲结点的域parent,用来指示结点的双亲在一维数组中的位置。
3.孩子兄弟表示法
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点),如图5.15(b)所示。
孩子兄弟表示法的存储结构描述如下:,
typedef struct CSNode{
ElemType data; //数据域
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode, *CSTree;
这种存储表示法比较灵活,其最大的优点是可以方便地实现树转二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便。
1.树转换为二叉树(兄弟相连留长子)
(1)树中所有相邻兄弟之间加一条连线。
(2)对树中的每个结点,只保留它与第一个孩子结点之间的连线,删除它与其他孩子结点之间的连线。
(3)以树的根结点为轴心,将整棵树顺时针转动一定的角度,使之结构层次分明。
2.森林转二叉树
由森林的概念可知,森林是若干棵树的集合,只要将森林中各棵树的根视为兄弟,每棵树又可以用二叉树表示,这样,森林也同样可以用二叉树表示。
森林转换为二叉树的方法如下:
(1)将森林中的每棵树转换成相应的二叉树。
(2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树根结点的右孩子,当所有二叉树连起来后,此时所得到的二叉树就是由森林转换得到的二叉树。
3.二叉树转为树和森林
树和森林都可以转换为二叉树,二者不同的是树转换成的二叉树,其根结点无右分支;森林转换后的二叉树,其根结点有右分支。显然这一转换过程是可逆的,即可以依据二叉树的根结点有无右分支,将一棵二叉树还原为树或森林,具体方法如下:
(1)先将根的右孩子、右孩子的右孩子…都与该结点依次断开,得到若干个二叉树。
(2)每个二叉树的左孩子的右孩子都与根结点相连接,删除二叉树中所有双亲结点与右孩子结点的连线。
(3)整理由(1)、(2)两步骤所得到的树或森林,使之结构层次分明。
总结:
树转二叉树是基础,森林转二叉树包含了树转二叉树。
二叉树转树的前提是二叉树没有右孩子,有右孩子的一般是由森林转成的二叉树。
二叉树转森林包含了二叉树转树。
二叉树 | 树 | 森林 |
---|---|---|
先序 | 先序 | 先序 |
中序 | 后序 | 中序 |
1.二叉排序树定义
二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:
1)若左子树非空,则左子树上所有结点的值均小于根结点的值。
2)若右子树非空,则右子树上所有结点的值均大于根结点的值。
3)左、右子树也分别是一棵二叉排序树。
根据二叉排序树的定义,左子树结点值<根结点值<右子树结点值,所以对二叉排序树进行中序遍历,可以得到一个递增的有序序列。
例如,图5.21所示二叉排序树的中序遍历序列为123468.
2.二叉排序树查找
二叉排序树的查找是从根结点开始,沿某个分支逐层向下比较的过程。若二叉排序树非空,先将给定值与根结点的关键字比较,若相等,则查找成功;若不等,如果小于根结点的关键字,则在根结点的左子树上查找,否则在根结点的右子树上查找。这显然是一个递归的过程。
例如,查找值为4的结点。首先4与根结点6比较。由于4小于6,所以在根结点6的左子树中继续查找。由于4大于2,所以在结点2的右子树中查找,查找成功。
二叉排序树的非递归查找算法:
BSTNode *BST_Search (BiTree T, ElemType key){
while(T != NULL && key != T->data){ //若树 空或等于根结点值,则结束循环
if(key<T->data)
T=T->lchi1d; //小于, 则在左子树上查找
else
T=T->rchild;
}
return T;
}
3.二叉排序树插入和生成
二叉排序树作为一种动态树表,其特点是树的结构通常不是一次生成的,而是在查找过程中,当树中不存在关键字值等于给定值的结点时再进行插入的。
插入结点的过程如下:若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。插入的结点一定是一个新添加的叶结点,且是查找失败时的查找路径上访问的最后一个结点的左孩子或右孩子。利用二叉排序树的插人操作,可以从一棵空树开始,将元素逐个插人到二叉排序树中,从而建立一棵二叉排序树。
给定一组结点的关键字序列为: {34,18,76,52,13,67 ,82,58},则
构造二叉排序树的过程如图所示。
二叉排序树插入操作算法如下:
二叉排序树通常采用二叉链表作为存储结构,其存储结构描述如下:
typedef struct BinSTreeNode { DataType elem;/*elem含有关键字域*/ struct BinSTreeNode * lchild; struct BinSTreeNode * rchild; } *BinSTree; void BSTreeInsert (BinSTree* t,KeyType k) { /*在二叉排序树中插人关键字为k的结点, * t指向二叉排序树的根结点*/ BinSTree r; if(*t== NULL){ r=(BinSTree)malloc(sizeof(struct BinSTreeNode)) ; r->elem.key=k; r->lchild = r->rchild=NULL; *t=r;/*若二叉排序树为空,被插结点作为树的根结点*/ return ; else if(k < ((* t)->elem.key)) BSTreeInsert(&((*t)->1child),k);/*插人到左子树中*/ else BSTreeInsert(&((*t)-> rchild),k);/*插人到右子树中*/ }
4.二叉排序树删除
在二叉排序树中删除一个结点时,需保证删除后的二叉树仍然是二叉排序树。为讨论方便,假定被删除结点为p,其双亲结点为f。删除的过程可按下述的两种情况分别处理:
(1)如果被删除的结点没有左子树,则只需把结点f指向p的指针改为指向p的右子树,例如在图8.6中,图(b)为图(a)中删除结点为13后的情形。
(2)如果被删除的结点p有左子树,则删除结点p时,从结点p的左子树中选择结点值最大的结点s,用结点s替换结点p(即把s的数据复制到p中),再将指向结点s的指针改为指向结点s的左子树即可,例如在图8.6中,图©为图(a)中删除结点为76后的图示。
5.二叉排序树效率
二叉排序树的查找效率,主要取决于树的高度。
在最坏情况下,即构造二叉排序树的输入序列是有序的,则会形成一个倾斜的单支树,此时二叉排序树的性能显著变坏,树的高度也增加为元素个数n,如图5.25(b)所示。
在等概率情况下,图5.25(a)查找成功的平均查找长度为:
ASLn =(1 + 2*2 + 3*4 + 4*3)/10= 2.9
而图5.25(b)查找成功的平均查找长度为:
ASLn =(1+2+3+4+5+6+7+8+9+ 10)/10=5.5
1.平衡二叉树的定义
为避免树的高度增长过快,降低二叉排序树的性能,规定在插入和删除二叉树结点时,要保证任意结点的左、右子树高度差的绝对值不超过1,将这样的二叉树称为平衡二叉树(Balanced Binary Tree),简称平衡树。定义结点左子树与右子树的高度差为该结点的平衡因子,则平衡二叉树结点的平衡因子的值只可能是-1、0或1。
因此,平衡二叉树可定义为是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过1。图(a)所示是平衡二叉树,图(b)所示是不平衡的二叉树。结点中的值为该结点的平衡因子。
如果一个二叉树既是平衡二叉树又是二叉排序树,则该树被称为平衡二叉排序树。
2.平衡二叉树的建立
建立平衡二叉树的过程和建立二叉排序树的过程基本一样,都是将关键字逐个插入空树中的过程。所不同的是,在建立平衡二叉树的过程中,每插入一个新的关键字都要进行检查,看是否新关键字的插入会使得原平衡二叉树失去平衡,即树中出现平衡因子绝对值大于1的结点。如果失去平衡则需要进行平衡调整。本节的重点就是平衡调整,这同样也是考研的重点。
3.平衡调整
假定向平衡二叉树中插入一个新结点后破坏了平衡二叉树的平衡性,则首先要找出插入新结点后失去平衡的最小子树,然后再调整这棵子树,使之成为平衡子树。
所谓失去平衡的最小子树是以距离插入结点最近,且以平衡因子绝对值大于1的结点作为根的子树,又称为最小不平衡子树。
值得注意的是,当失去平衡的最小子树被调整为平衡子树后,无须调整原有其他所有的不平衡子树,整个二叉排序树就会成为一棵平衡二叉树。
当然,平衡调整必须保持排序二叉树左小右大的性质,若造成查找路径上的某个结点不再平衡,那么平衡调整有4种情况,分别为LL型、RR型、LR型和RL型。
以下调整规律都是建立在最小不平衡子树的基础上。
1)LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。如图5.28所示。(H为子树的深度)
2)RR平衡旋转(左单旋转)。由于在结点A的右孩子®的右子树®上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树,如图5.29所示。
3)LR平衡旋转(先左后右双旋转)。由于在A的左孩子(L)的右子树®上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后把该C结点向右上旋转提升到A结点的位置,如图5.30所示。
4)RL平衡旋转(先右后左双旋转)。由于在A的右孩子®的左子0树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后把该C结点向左上旋转提升到A结点的位置,如图5.31所示。
注意:LR和RL旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程,而图5.30和图5.31中以插入C的左子树中为例。
下面通过一个例题来综合体会一下平衡二叉树的建立、平衡调整。
4.平衡二叉树的删除
与平衡二义树的插入操作类似,以删除结点w为例来说明平衡二叉树删除操作的步骤:
1)用二叉排序树的方法对结点w执行删除操作。
2)从结点w开始,向上回溯,找到第一个不平衡的结点z(即最小不平衡子树);y为结点z的高度最高的孩子结点;x是结点y的高度最高的孩子结点。
3)然后对以z为根的子树进行平衡调整,其中x、y和z可能的位置有4种情况:
·y是z的左孩子,x是y的左孩子(LL,右单旋转);
·y是z的左孩子,x是y的右孩子(LR,先左后右双旋转);
·y是z的右孩子,x是y的右孩子(RR,左单旋转);
·y是z的右孩子,x是y的左孩子(RL,先右后左双旋转)。
这四种情况与插入操作的调整方式一样。不同之处在于,插入操作仅需要对以z为根的子树进行平衡调整;而删除操作就不一样,先对以z为根的子树进行平衡调整,如果调整后子树的高度减1,则可能需要对z的祖先结点进行平衡调整,甚至回溯到根结点(导致树高减1)。
以删除图7.16(a)的结点32为例,由于32为叶结点,直接删除即可,向上回溯找到第一个不平衡结点44(即z),z的高度最高的孩子结点为78(y),y的高度最高的孩子结点为50(x),满足RL情况,先右后左双旋转,调整后的结果如图7.16(c)所示。
5.平衡二叉树的查找
在平衡二叉树上进行查找的过程与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。假设以nh表示深度为h的平衡树中含有的最少结点数。显然,有n_0=0,n_1=1,n_2=2,并且有nh=nh-1+nh-2+1。可以证明,含有n个结点的平衡二又树的最大深度为O(log2n),因此平衡二叉树的平均查找长度为O(log2n),如图5.33所示。
路径:树中一个结点到另一个结点之间的分支构成两个结点之间的路径。
路径长度:路径上的分支数目。
树的路径长度:根结点到每个叶子结点的路径长度之和。
树的带权路径长度:树中所有叶子结点的带权路径长度之和。记作WPL=∑_(i=1)^m▒〖w_i l_i 〗,其中w_i是第i个叶子结点的权值,l_i为从根到第i个叶子结点的路径长度,m为树的叶子结点的个数。
最优二叉树:设有m个权值{w_1,w_2,…,w_n }的结点,构造一棵有m个叶子结点的二叉树,第i个叶子结点的权值为w_i,则带权路径长度WPL最小的二叉树被称作最优二叉树,这种最优二叉树也被称为哈夫曼树。
给定n个权值分别为{w_1,w_2,…,w_n }的结点,构造哈夫曼树的算法描述如下:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
2)构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3),直至F中只剩下一棵树为止。
从上述构造过程中可以看出哈夫曼树具有如下特点:
1)每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
2)构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树的结点总数为2n-1。
3)每次构造都选择2棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点。
由问题引入可知,在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
在上例中,假设{3,4,5,6,8,10,12,18}分别是字符a,b,c,d,e,f,g,h在一个文本中出现的次数,则利用图6.34的二叉树可以得到字符的一种最佳编码:首先将二叉树向左的分支上标记0,向右的分支上标记1。然后从二叉树的根结点开始到某个叶子结点的路径上的“0”和“1”组成的二进制位串分别记录下来,即为该叶子结点对应的字符编码。
王道上的一个图:说明每个字符对应的编码。
前缀码问题:
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。举例:设计字符A,B和C对应的编码0,101和100是前缀编码。对前缀编码的解码很简单,因为没有一个编码是其他编码的前缀。所以识别出第一个编码,将它翻译为原码,再对余下的编码文件重复同样的解码操作。例如,码串00101100可被唯一地翻译为 0,0,101和100。另举反例:如果再将字符D的编码设计为00,此时0是00的前綴,那么这样的码串的前两位就无法唯一翻译。
无向图 | 有向图 | |
---|---|---|
定义 | 在一个图中,如果任意两个顶点构成的偶对(vi,vj)∈E是无序的,即顶点之间的连线是没有方向的,则称该图为无向图(undigrpah)。 | 在一个图中,如果任意两个顶点构成的偶对(vi,vj)∈E是有序的,即顶点之间的连线是有方向的,则称该图为有向图(digrpah)。 |
顶点 | 图中的数据元vi称为顶点(vertex),(vi,vj)表示两个顶点之间有一条直线连接 | |
边 | 无向图中,则称这条连线为边;边用顶点的无序偶对(v,w)来表示,称顶点v和顶点w互为邻接点,边(v,w)称为与顶点v和w相关联。 | 无 |
弧、弧头、弧尾 | 无 | 有向图中连线称为弧(arc);弧用顶点的有序偶对(vi,vj)来表示,有序偶对的第一个结点vi被称为始点(或弧尾tail),在图中就是不带箭头的一端;有序偶对的第二个结点vj;被称为终点(或弧头head),在图中就是带箭头的一端。若vi,vj是一条弧,则称顶点v邻接到w,顶点w邻接自v,vi,vj与顶点v和w相关联。 |
完全图 | 对无向图,称作无向完全图,任意两个顶点都有一条直接边相连。那么在一个含有n个顶点的无向图中,有n(n-1)/2条边。 | 对于有向图,称作有向完全图,任意两个顶点之间都方向相反的两条弧相连接。那么在一个含有n个顶点的有向图中,有n(n-1)条边。 |
稠密图、稀疏图 | 一个图接近完全图,称为稠密图; 边很少的图为稀疏图 | |
度、入度、出度 | 无向图:顶点的度是指依附在该顶点的边的条数。 有向图:分为出度和入度;入度是以顶点v为终点的有向边的数目; 出度是以顶点v为起点的有向边的数目。顶点v的度=出度+入度 | |
全部顶点的度=2*边数(e) | 全部顶点的入度=出度=边数(e) |
无向图 | 有向图 | |
---|---|---|
路径、路径长度和回路 | 路径可以理解为任意两个顶点有边可以走(有关联)。路径长度路径上边的个数。路径上的第一个和最后一个顶点是同一个,称回路。 若一个图有n个顶点,并且有大于n-1条边,此图一定有环。 | |
子图 | 对于图G=(V ,E),G’=(V’,E),若存在V’是V的子集,E’是E的子集,则称图G’是G的一个子图(subgraph)。 | |
连通、连通图和连通分量 | 两顶点之间有路径,称连通;任意两个顶点都是连通的,称连通图,否则为非连通图。极大连通子图为连通分量。 | 无 |
强连通图、强连通分量 | 两顶点互相有路径,称两顶点强连通;任何一对顶点都是强连通的,称此图为强连通图,否则为非强连通图。 | |
极大连通子图 | 极大:要求连通子图包含所有边 极小:连通子图即要保持图的连通又要使得边数最少的子图 | |
生成树、生成森林 | 连通图生成树包含全部顶点的极小连通子图。 图有n个顶点,那么生成树有n-1 条边。 对于生成树,砍去一条边,变为非连通;添加一条边,变为一个回路。 | 无 |
所谓邻接矩阵(adjacency matrix)的存储结构,就是用一维数组存储图中顶点的信息,用一个二维数组表示图中各顶点之间的邻接关系信息,这个二维数组称为邻接矩阵。
对于无权图:不论是无向图还是有向图,只要两结点之间含有边,那么对应的二维数组相应位置等于1,若没有边,那么相应的位置就为0;
对于王道上的结点表示是上面的序号(从1开始),而课本使用的是v0开始。(以课本为主)
对于带权图:若两顶点之间有边相连,则邻接矩阵中对应项存放着的是该边的权值,若没有边,那么对应的值就是无穷大。
图的邻接矩阵存储结构定义如下:
#define MaxVertexNum 30 /*最大顶点个数*/
typedef struct {
VertexType vertexs [MaxVertexNum]; /*顶点表*/
EdgeType arcs[MaxVertexNum][MaxVertexNum]; /*邻接矩阵,即边表*/
int vertexNum,edgeNum;
}MGragh; /*MGragh是以邻接矩阵存储的图类型*/
从图的邻接矩阵存储方法容易看出这种表示具有以下特点:
(1)无向图的邻接矩阵一定是一个对称矩阵。因此,在具体存放邻接矩阵时只需存放上(或下)三角矩阵的元素即可。有向图的邻接矩阵不一定是对称矩阵。
(2)对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是第i个顶点的度TD(vi)。
(3)对于有向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是第i个顶点的出度OD(vi)(或入度ID(vi))。(记忆:出行入列)
(4)用邻接矩阵方法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、列对每个元素进行检测,所花费的时间代价很大,这是用邻接矩阵存储图的局限性。
(5)在邻接矩阵表示法中,如果是顶点很多而边很少的图,将会表示成一个稀疏矩阵,这不仅浪费空间,而且使一些算法变得很慢。(王道:稠密图适合用邻接矩阵表示)
当一个图为稀疏图时,使用邻接矩阵法显然要浪费大量的存储空间,而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
如何建立邻接表呢?在图G中以每个顶点vi为头结点建立一个顶点表,在顶点vi表后面建立单链表,这个单链表的结点是一个依附于顶点vi的边(对于有向图则是以顶点vi为尾的弧)对应的另一头的结点,这个单链表就称为顶点vi的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如图7.11所示。
无向图和有向图的具体实例表示图如下,其中7.13给出无向图图7.9对应的邻接表:
有向图及其邻接表:
图的邻接表存储结构定义如下:
#define MaxVertexNum 100
typedef struct ArcNode{ //边表结点
int adjvertex; //该弧所指向 的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
}EdgeNode;
typedef struct vnode{ //顶点表结点
VertexType data;
EdgeNode *firstedge;
}VertexNode;
typedef struct{
VertexNode adjlist[MaxVertexNum]; //邻接表
int vexNum,arcNum; //图的顶点数和弧数
}ALGraph;
图的邻接表存储方法具有以下特点:
①若G为无向图,则所需的存储空间为O(V+2|E|);若G为有向图,则所需的存储空间为O(V+|E|)。前者的倍数2是由于无向图中,每条边在邻接表中出现了两次。
② 对于稀疏图,采用邻接表表示将极大地节省存储空间。
③在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为O(n)。 但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
④在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表。
⑤图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。
有向图的一种链式存储结构。
无向图的一种链式结构。
图的遍历是指从图中的任一顶点出发,对图中所有顶点访问一次而且仅访问一次。图的遍历是图的一种基本操作,图的许多其他操作都是建立在遍历操作的基础之上。
图的遍历操作较为复杂,主要表现在以下4个方面。
(1)在图结构中,没有一个“自然”的首结点,图中任意一个顶点都可作为第一个被访问的结点。
(2)在非连通图中,从一个顶点出发,只能够访问它所在的连通分量上的所有顶点,因此,还需考虑如何选取下一个出发点以访问图中其余的连通分量。
(3)在图结构中,如果有回路存在,那么一个顶点被访问之后,有可能沿回路又回到该顶点。
(4)在图结构中,一个顶点可以和其他多个顶点相连,当这样的顶点访问过后,存在如何选取下一个要访问的顶点的问题。
图的遍历通常有深度优先搜索和广度优先搜索两种方式,它们对无向图和有向图都适用。
以邻接矩阵为存储结构对整个图G进行广度优先遍历的C语言描述。
定义:一个连通图的生成树包含图的所有顶点,并且只含尽可能少的边。
对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。
对于一个带权连通无向图G=(V, E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的那棵生成树,则T称为G的最小生成树
示意图如下:
不难看出,最小生成树具有如下性质:
1)最小生成树不是唯一的,即最小生成树的树形不唯一,R中可能有多个最小生成树。当图G中的各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边数比顶点数少1,即G本身是一棵树时,则G的最小生成树就是它本身。
2)最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
3)最小生成树的边数为顶点数减1。
Prim算法构造最小生成树的过程如图所示。初始时从图中任取一顶点(如顶点v0)加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该项点和相应的边加入T,每次操作后T中的顶点数和边数都增1。以此类推,直至图中所有的顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。
每次找出候选边中权值最小的边,就将该边并入生成树中。重复此过程直到所有边都被检测完为止。
说明:克鲁斯卡尔算法思想较为简单,因此在考研中需要手工构造生成树时,一般多用此方法。图
下图所示为用克鲁斯卡尔算法求解最小生成树的过程。
典型用途:交通网络的问题一从甲地到乙地之间是否有公路连通?在有多条通路的情况下,哪一条路最短?交通网络用有向网来表示:
顶点——表示地点,
弧——表示两个地点有路连通,
弧上的权值——表示两地点之间的距离、交通费或途中所花费的时间等。
如何能够使一个地点到另一个地点的运输时间最短或运费最省?这就是一个求两个地点间的最短路径问题。
问题抽象:在有向网中A点(源点)到达B点(终点)的多条路径中,寻找一条各边权值之和最小的路径,即最短路径。
最短路径与最小生成树不同,路径上不一定包含n个顶点,也不一定包含n-1条边。
第一类问题:单源最短路径:从一个源点到其它各点的最短路径
第二类问题:所有顶点间的最短路径:每对顶点之间的最短路径
该算法的基本思想是:设置两个顶点的集合S和T=V-S,集合S中存放已找到最短路径的顶点,集合T存放当前还未找到最短路径的顶点。初始状态时,集合S中只包含源点心v0,然后不断从集合T中选取到顶点v0路径长度最短的顶点u加人到集合S中,集合S每加入一个新的顶点u,都要修改顶点v0到集合T中剩余顶点的最短路径长度值,集合T中各顶点新的最短路径长度值为原来的最短路径长度值与顶点u的最短路径长度值加上u到该顶点的路径长度值中的较小值。此过程不断重复,直到集合T的顶点全部加人到S中为止。
1.初始化:先找出从源点V0到各终点Vk的直达路径(V0,Vk),即通过一条弧到达的路径。
2.选择:从这些路径中找出一条长度最短的路径(V0,u).
3.更新:然后对其余各条路径进行适当调整:
若在图中存在弧(u, Vk),且(V0,u) + (u,Vk) < (V0,Vk).
则以路径(V0,u,V)代替(V0,Vk).
在调整后的各条路径中,再找长度最短的路径,依此类推。
所有顶点间的最短路径:每对顶点之间的最短路径。算法思想:
·逐个顶点试探(每次选个中间点k)
·从vi到vj的所有可能存在的路径中(i≠k≠j)
·选出一条长度最短的路径,公式(A[i][j]比较A[i][k]+A[k][j])
有向无环图常用来描述一个工程或系统的进行过程。(通常把计划、施工、生产、程序流程等当作一个工程)一个工程可以分为若干个子工程,只要完成了这些子工程(活动),就可以导致整个工程的完成。
下面给出在寻找关键活动时所用到的几个参量的定义:
1.事件vk最早发生时间
它是指从源点v1到顶点vk的最长路径长度。事件vk的最早发生时间决定了所有从vk开始的活动能够开工的最早时间。
2.事件vk以的最迟发生时间
它是指在不推迟整个工程完成的前提下,即保证它的后继事件vj在其最迟发生时间vl(j)能够发生时,该事件最迟必须发生的时间。
对于关键路径,需要注意以下几点:
1)关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。
2)网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。