文章目录
前言
本博客对王道数据结构、《数据结构 严蔚敏》以及个人总结与扩展的知识点进行整理,便于
24
24
24考研复习时使用。
小小科憨,不足挂齿,希望有帮助。
第一章:绪论(考纲一览)
1.1数据结构的基本概念
1.2算法和算法评价
①时间复杂度的计算
a.时间复杂度的定义
一般情况下,算法中基本语句重复执行的次数使问题规模n
的某个函数f(n)
,算法的时间复杂度记为:
T(n)=O(f(n))
它表示随问题规模的增大,算法执行时间的增长率和f(n)
的增长率相同,故称为算法的渐进时间复杂度,简称时间复杂度,一般地,我们选取最内层的操作作为时间复杂度的判断依据。
对于数学符号O
,其严格定义为:
若
T(n)
和f(n)
是定义在整数集合上的两个函数,则T(n)=O(f(n))
表示存在正的常数C
和n0
,使得当n≥n0
时都满足:0≤T(n)≤Cf(n)
(即,大O就是数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度,这个数据量也就是常数项系数已经不起决定性作用的数据量。)。
该定义说明了函数T(n)
和f(n)
具有相同的增长趋势,且T(n)
最多趋向于f(n)
的增长,符号O
则用来描述增长率的上限,特别地,若算法的执行时间不随问题规模n
的增加而增长,则算法的时间复杂度为T(n)=O(1)
,称为常量阶。
对于
O
O
O,可以理解为用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界。
事实上,输入数据的形式对程序运算时间是有很大影响的,以快速排序为例,快速排序是平均时间复杂度是
O
(
n
l
o
g
n
)
O (nlogn)
O(nlogn) ,但是当数据逆序情况下,快速排序的时间复杂度是
O
(
n
2
)
O(n_2)
O(n2),所以严格从大O的定义来讲,快速排序的时间复杂度应该是
O
(
n
2
)
O(n_2)
O(n2)而非
O
(
n
l
o
g
n
)
O (nlogn)
O(nlogn) ,但我们依然说快速排序是
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)的时间复杂度,这个就是业内的一个默认规定,这里说的
O
O
O代表的就是一般情况,而不是严格的上界。
常用的渐进时间复杂度:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(na)<O(2n)<O(n!)<O(nn)
可以看出不同算法的时间复杂度在不同数据输入规模下的差异。在决定使用哪些算法的时候,不是时间复杂越低的越好(因为简化后的时间复杂度忽略了常数项等等),要考虑数据规模,如果数据规模很小甚至可以用
O
(
n
2
)
O(n^2)
O(n2)的算法比
O
(
n
)
O(n)
O(n)的更合适。
关于
O
(
l
o
g
n
)
O(logn)
O(logn)
对于以
2
2
2、
10
10
10等等为底的任意
l
o
g
log
log函数的时间复杂度,同一规定说是以
l
o
g
n
logn
logn,也就是忽略了底数的描述,这是因为:
即:以
2
2
2为底
n
n
n的对数 = 以
2
2
2为底
10
10
10的对数 * 以
10
10
10为底
n
n
n的对数,以
2
2
2为底
10
10
10的对数是一个常数,而计算时间复杂度时是忽略常数项系数的,故而在讨论时间复杂度时会忽略底数,直接说成是
l
o
g
n
logn
logn。
b.次方阶示例
注意,对于for
循环,如:
for(int i=1;i<=n;i++)
其实际执行次数是由i<=n
的执行次数决定的,在执行完n
次后,i=n+1
,此时还需进行一次i<=n
的判断以跳出循环,故该for
循环一共执行n+1
次。
示例
//求两个n阶矩阵乘积
for(int i=1;i<=n;i++){ //执行次数为(n+1)次,但内层一共执行n次
for(int j=1;j<=n;j++){ //执行次数为n*(n+1)次,因为同样需要进行跳出判断,但内层一共执行n次
c[i][j]=0; //执行次数为n*n次
for(int k=1;k<=n;k++){ //执行次数为n*n*(n+1)次
c[i][j]+=a[i][k]+b[k][j]; //执行次数为n^3次
}
}
}
上述所有语句的频度之和可用n
进行表示,从而得到有关n
的函数f(n)
:
f(n)=n+3n2+n3
故而时间复杂度可表示为:
T(n)=O(n3)
i.求和符号与连乘符号
在一些特殊的频度计算当中,常常需要用到求和符号: ∑ i = 0 n \sum_{i=0}^n ∑i=0n,对于单重求和符号,此处不再赘述,只讨论二重及以上的求和符号。
1)性质:
结合律:
∑
i
=
0
n
(
a
i
+
b
i
)
\sum_{i=0}^n(a_i+b_i)
∑i=0n(ai+bi)=
∑
i
=
0
n
(
a
i
)
\sum_{i=0}^n(a_i)
∑i=0n(ai)+
∑
i
=
0
n
(
b
i
)
\sum_{i=0}^n(b_i)
∑i=0n(bi)
分配律:
∑
j
=
0
n
∑
i
=
0
n
(
f
(
i
)
∗
f
(
j
)
)
\sum_{j=0}^n\sum_{i=0}^n(f(i)*f(j))
∑j=0n∑i=0n(f(i)∗f(j))=
∑
i
=
0
n
f
(
i
)
∗
∑
j
=
0
n
f
(
j
)
\sum_{i=0}^nf(i)*\sum_{j=0}^nf(j)
∑i=0nf(i)∗∑j=0nf(j),特殊地,有:
∑
i
=
0
n
(
r
∗
a
i
)
\sum_{i=0}^n(r*a_i)
∑i=0n(r∗ai)=
r
∗
∑
i
=
0
n
(
a
i
)
r*\sum_{i=0}^n(a_i)
r∗∑i=0n(ai),r是任意常数。
常见的连乘符号计算:
∑
i
=
1
n
i
=
n
(
n
+
1
)
/
2
\sum_{i=1}^ni=n(n+1)/2
∑i=1ni=n(n+1)/2
∑
i
=
1
n
i
2
=
n
(
n
+
1
)
(
2
n
+
1
)
/
6
\sum_{i=1}^n{i^2}=n(n+1)(2n+1)/6
∑i=1ni2=n(n+1)(2n+1)/6
2)计算:
事实上,二重求和符号本质上是矩阵求和,其中,
∑
i
=
1
n
∑
j
=
1
m
(
a
i
j
)
\sum_{i=1}^n\sum_{j=1}^m(a_{ij})
∑i=1n∑j=1m(aij)的本质是以下矩阵的求和:
而
∑
i
=
1
n
∑
j
=
1
m
(
a
i
j
)
\sum_{i=1}^n\sum_{j=1}^m(a_{ij})
∑i=1n∑j=1m(aij)与
∑
j
=
1
m
∑
i
=
1
n
(
a
i
j
)
\sum_{j=1}^m\sum_{i=1}^n(a_{ij})
∑j=1m∑i=1n(aij)的区别在于,前者是先求行和,后者是先求列和。
由此,对于
∑
i
=
1
4
∑
j
=
1
i
(
a
i
j
)
=
a
11
+
a
21
+
a
22
+
a
31
+
a
32
+
a
33
+
a
41
+
a
42
+
a
43
+
a
44
\sum_{i=1}^4\sum_{j=1}^i(a_{ij})=a_{11}+a_{21}+a_{22}+a_{31}+a_{32}+a_{33}+a_{41}+a_{42}+a_{43}+a_{44}
∑i=14∑j=1i(aij)=a11+a21+a22+a31+a32+a33+a41+a42+a43+a44,是矩阵中的一个三角,常见的,对于
∑
i
=
1
n
∑
j
=
i
n
(
a
i
j
)
\sum_{i=1}^n\sum_{j=i}^n(a_{ij})
∑i=1n∑j=in(aij)就是矩阵的右上三角求和,亦可写为:
∑
j
=
1
n
∑
i
=
j
n
(
a
i
j
)
\sum_{j=1}^n\sum_{i=j}^n(a_{ij})
∑j=1n∑i=jn(aij),只不过一个是先求行和,一个是先求列和。
同理,对于
∑
i
=
1
n
∑
j
=
1
n
(
i
∗
j
)
\sum_{i=1}^n\sum_{j=1}^n(i*j)
∑i=1n∑j=1n(i∗j),可看作是通项为
a
i
j
=
i
∗
j
a_{ij}=i*j
aij=i∗j的矩阵,其第一行求和结果为:
[
n
(
n
+
1
)
/
2
]
∗
1
[n(n+1)/2]*1
[n(n+1)/2]∗1,第二行求和结果为:
[
n
(
n
+
1
)
/
2
]
∗
2
[n(n+1)/2]*2
[n(n+1)/2]∗2,第n行求和结果为:
[
n
(
n
+
1
)
/
2
]
∗
n
[n(n+1)/2]*n
[n(n+1)/2]∗n,故整个矩阵的求和结果为:
∑
i
=
1
n
∑
j
=
1
n
(
a
i
j
)
\sum_{i=1}^n\sum_{j=1}^n(a_{ij})
∑i=1n∑j=1n(aij)=
[
n
(
n
+
1
)
/
2
]
∗
[
n
(
n
+
1
)
/
2
]
[n(n+1)/2]*[n(n+1)/2]
[n(n+1)/2]∗[n(n+1)/2]=
[
n
(
n
+
1
)
/
2
]
2
{[n(n+1)/2]}^2
[n(n+1)/2]2
事实上也可写为:
∑
i
=
1
n
∑
j
=
1
n
(
i
∗
j
)
=
∑
i
=
1
n
i
∗
∑
j
=
1
n
j
=
n
(
n
+
1
)
/
2
∗
∑
i
=
1
n
i
=
[
n
(
n
+
1
)
/
2
]
2
\sum_{i=1}^n\sum_{j=1}^n(i*j)=\sum_{i=1}^ni*\sum_{j=1}^nj=n(n+1)/2*\sum_{i=1}^ni={[n(n+1)/2]}^2
∑i=1n∑j=1n(i∗j)=∑i=1ni∗∑j=1nj=n(n+1)/2∗∑i=1ni=[n(n+1)/2]2。
对于连乘符号
∏
i
=
1
n
∏
j
=
1
n
1
\prod_{i=1}^n\prod_{j=1}^n1
∏i=1n∏j=1n1,它本质是矩阵所有元素相乘,而对于多重累加/累乘符号,它们相当于是多维矩阵累加/累乘。
ii.时间复杂度上的应用
int main() {
int n=0;
cin>>n;
int x=0;
for(int i=1;i<=n;i++){
for(int j=1;j<=i;j++){
for(int k=1;k<=j;k++){
x++;
}
}
}
return x;
}
其中,x++
的执行次数为:
∑
i
=
1
n
∑
j
=
1
i
∑
k
=
1
j
1
=
∑
i
=
1
n
∑
j
=
1
i
j
=
∑
i
=
1
n
i
(
i
+
1
)
/
2
=
[
n
(
n
+
1
)
(
2
n
+
1
)
/
6
+
n
(
n
+
1
)
/
2
]
/
2
\sum_{i=1}^n\sum_{j=1}^i\sum_{k=1}^j1=\sum_{i=1}^n\sum_{j=1}^ij=\sum_{i=1}^ni(i+1)/2=[n(n+1)(2n+1)/6+n(n+1)/2]/2
∑i=1n∑j=1i∑k=1j1=∑i=1n∑j=1ij=∑i=1ni(i+1)/2=[n(n+1)(2n+1)/6+n(n+1)/2]/2,故而,T(n)=O(n3)。
int main() {
int n=0;
cin>>n;
int x=0;
for(int i=1;i<n;i++){
for(int j=1;j<=n-1;j++){
x++;
}
}
return x;
}
其中,x++
的执行次数为:
∑
i
=
1
n
−
1
∑
j
=
1
n
−
1
1
\sum_{i=1}^{n-1}\sum_{j=1}^{n-1}1
∑i=1n−1∑j=1n−11相当于是
n
×
(
n
−
1
)
n×(n-1)
n×(n−1)矩阵左下三角之和,即为:
1
+
2
+
3
+
.
.
.
+
(
n
−
1
)
=
n
(
n
−
1
)
/
2
1+2+3+...+(n-1)=n(n-1)/2
1+2+3+...+(n−1)=n(n−1)/2,故而,T(n)=O(n2)。
c.对数阶示例
int main() {
int n=0;
cin>>n;
int x=0;
for(int i=1;i<=n;i=i*2){
x++;
}
return x;
}
可设循环体内两条基本语句的频度为f(n)
,则有:
2
f
(
n
)
≤
n
,
f
(
n
)
≤
log
2
n
2^{f(n)}≤n,f(n)≤\log_2n
2f(n)≤n,f(n)≤log2n,故而,时间复杂度
T
(
n
)
=
O
(
log
2
n
)
T(n)=O(\log_2n)
T(n)=O(log2n)。
d.递归函数
递归函数时间复杂度分析我采用了递归树的方法,这是通解,同时没有引入主定理,因为麻烦。
- 一次递归调用:若递归函数当中只进行了一次递归调用,且递归调用的深度为
depth
,每个递归函数中,除递归调用语句外的其他语句时间复杂度为T
,则总体的时间复杂度为 O ( T ∗ d e p t h ) O(T*depth) O(T∗depth)。
例:
//求n的阶乘
int fact(int n){
if(n==0)return 1;
else return n* fact(n-1);
}
递归调用语句return n* fact(n-1);
一共会执行n
次(实际上就是结点的个数),且在函数体内的其他语句的时间复杂度为
O
(
1
)
O(1)
O(1),故而总体时间复杂度为
O
(
n
)
O(n)
O(n)。
- 两次递归调用:若递归函数当中进行了两次递归调用,则总体的时间复杂度需要在递归调用语句的执行次数与非递归调用语句总体执行次数之间进行取舍。
int fib(int n){
//斐波那契数列求和
if(n==0)return 0;
else if(n==1)return 1;
else return fib(n-1)+ fib(n-2);
}
所得递归树是一棵高度为n
的二叉树,其结点的最大深度为n
,最小深度为n/2
,故而结点总数介于
2
n
/
2
−
1
2^{n/2}-1
2n/2−1与
2
n
−
1
2^{n}-1
2n−1之间,故而,递归调用语句的执行次数介于
2
n
/
2
−
1
2^{n/2}-1
2n/2−1与
2
n
−
1
2^{n}-1
2n−1之间,其时间复杂度为
O
(
1
∗
2
n
)
=
O
(
2
n
)
O(1*2^{n})=O(2^{n})
O(1∗2n)=O(2n)(且递归递归调用语句本身执行的是加法操作,时间复杂度为
O
(
1
)
O(1)
O(1),至于fib(n-1)
和fib(n-2)
,它们返回的是两个整数,不用去管)。
对于除递归调用语句外的其他语句,它们的时间复杂度为
O
(
1
)
O(1)
O(1),递归树高度是
O
(
n
)
O(n)
O(n)级别,故而它们的总时间复杂度为
O
(
n
)
O(n)
O(n)。
综上,阶乘函数的时间复杂度为
O
(
2
n
)
O(2^{n})
O(2n)。
- 复杂递归调用
对于复杂递归调用,不介绍主定理,仍是采用递归树的方法,以快排为例,当分区比例为1:9时,所得时间复杂度递推式为(最后n
表示非递归调用代码的时间复杂度):
T
(
n
)
=
T
(
9
n
/
10
)
+
T
(
n
/
10
)
+
n
T(n)=T(9n/10)+T(n/10)+n
T(n)=T(9n/10)+T(n/10)+n
画出递归树:
树的高度介于
log
10
/
9
n
\log_{10/9}n
log10/9n与
log
10
n
\log_{10}n
log10n之间,故而递归调用语句的执行从时间复杂度即为结点个数的数量级,为
O
(
log
n
)
O(\log n)
O(logn),而上图右侧的n
则表示非递归调用代码的时间复杂度之和,每一次递归调用语句所在函数中非递归调用代码时间复杂度随着n
的改变而改变,但同时被调用的递归函数它们非递归调用代码的次数之和仍是
O
(
n
)
O(n)
O(n)数量级,又因为树高度介于
log
10
/
9
n
\log_{10/9}n
log10/9n与
log
10
n
\log_{10}n
log10n之间,故而非递归调用代码的总时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。
综上,此时总时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。
②空间复杂度的计算
空间复杂度指的是运行过程中临时占用存储空间大小的度量,主要来源于函数运行过程中开辟的函数调用栈,仍用递归树分析法,需要注意的是,空间复杂度是函数运行过程中开辟的最大辅助空间大小,但函数调用栈在函数执行完会自动销毁,故而所求即是函数调用栈某一时刻的最多数量。
int fib(int n){
//斐波那契数列求和
if(n==0)return 0;
else if(n==1)return 1;
else return fib(n-1)+ fib(n-2);
}
以斐波那契数列求和函数为例,其递归树最大高度为
n
n
n,故而当到达最左侧叶子结点时(结点就是一个函数调用栈,由父结点/函数调用栈创建,在执行完后会回到父结点/函数调用栈中继续执行,故此时这些函数调用栈同时嵌套存在),函数调用栈存在数目最多,共有
n
n
n个,故空间复杂度为
O
(
n
)
O(n)
O(n)。
第二章:线性表(考纲一览)
2.1线性表的定义
线性表是具有相同数据元素类型的
n
(
n
>
0
)
n(n>0)
n(n>0)个数据元素的有限序列,
n
n
n为表长,当
n
=
0
n=0
n=0时是空表,一般用
L
L
L命名线性表,其表示为
L
=
(
a
1
,
a
2
,
a
3
,
.
.
.
,
a
n
)
L=(a_1,a_2,a_3,...,a_n)
L=(a1,a2,a3,...,an)
a
1
a_1
a1是第一个元素,称为表头元素,
a
n
a_n
an则是表尾元素,除第一个元素外,每一个元素都有唯一一个前驱,除最后一个元素外,每一个元素都有唯一一个后继(逻辑特性)。其特点如下:
- 元素个数优先。
- 元素有逻辑上的顺序性,即有先后次序。
- 元素都是数据元素,且数据类型相同,占用相同大小存储空间。
- 元素有抽象性,仅讨论元素逻辑关系,不考虑元素本身。
2.2线性表的顺序表示:顺序表
2.2.1顺序表的定义
线性表的顺序存储称为顺序表,是用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理上也相邻。
假设线性表
L
L
L存储的起始地址为
L
O
C
(
A
)
LOC(A)
LOC(A),
s
i
z
e
o
f
(
E
l
e
m
t
y
p
e
)
sizeof(Elemtype)
sizeof(Elemtype)是每个数据元素所占用存储空间的大小,则顺序存储如下所示:
每个数据元素的存储位置和线性表的起始地址相差一个和该数据元素位序成正比的常数,因此线性表中任何数据元素都能随机存取(线性表中元素位序从
1
1
1开始,而数组中元素下标从
0
0
0开始)。
2.2.2顺序表的实现
a.静态实现
#define MAXSIZE 100 //定义线性表的最大长度
typedef int ElemType;
typedef struct{
int length; //记录当前表长
ElemType data[MAXSIZE]; //用于存储元素的数组
}SqList; //定义顺序表元素结构体
//初始化操作
void InitList(SqList &L){
for(int i=0;i<MAXSIZE;i++){
L.data[i]=0;
}
L.length=0;
}
在静态分配时,由于数组的大小和空间已事先确定,一旦空间满后再加入元素就会溢出,导致程序崩溃,且由于不确定存储空间的大小,容易出现分配过多而浪费、分配过少而不够用的情况。
b.动态实现
前置知识:malloc
函数与new
函数
malloc
函数用于向系统申请分配指定size
个字节的内存空间,返回值类型是void*
类型,void*
表示未确定类型的指针,可用来强制转化为任意其它类型的指针。
new
函数用于返回指定类型的指针,与malloc
不同,new
可以自动计算所需的字节大小,且自动将空指针强制转换。
typedef int ElemType;
#define InitSize 100//定义线性表初始化长度
typedef struct{
int length,MaxSize; //定义顺序表当前长度、最大容量
ElemType *data;
}SeqList;
//初始化操作
void InitList(SeqList &L){
L.data=(ElemType*)malloc(InitSize*sizeof(ElemType));
L.length=0;
L.MaxSize=InitSize;
}
//动态扩容
void IncreaseSize(SeqList &L,int len){//len是所要扩充的长度
ElemType *p=L.data; //p指针指向数组首地址
L.data=(ElemType*)malloc((L.MaxSize+len)*sizeof(ElemType));
for(int i=0;i<L.length;i++){
L.data[i]=p[i]; //将数据赋值到新的存储空间
}
L.MaxSize=L.MaxSize+len;
free(p);
}
动态分配中,存储数组的空间在程序执行过程中动态分配,一旦空间占满,就可另外开辟更大的存储空间以替代原先存储空间,达到扩容目的。
2.2.3顺序表的基本操作
注意,以下默认使用的是静态分配方式,前置代码:
#include <iostream>
using namespace std;
#define MAXSIZE 100 //定义线性表的最大长度
typedef int ElemType;
typedef struct{
int length; //记录当前表长
ElemType data[MAXSIZE]; //用于存储元素的数组
}SqList; //定义顺序表元素结构体
a.初始化操作
//初始化操作
void InitList(SqList &L){
for(int i=0;i<MAXSIZE;i++){
L.data[i]=0;
}
L.length=0;
}
b.插入操作
思路:在第 i ( 1 ≤ i ≤ n ) i(1≤i≤n) i(1≤i≤n)个位置插入一个元素时,需要从最后一个元素即第 n n n个元素开始,依次向后移动一个位置,直到第 i i i个元素(共 ( n − i + 1 ) (n-i+1) (n−i+1)个元素)。
//插入操作:在第i个位置插入元素(i是位序)
bool InsertSqList(SqList &L,int i,ElemType e){
if(i<1||i>L.length+1)return false;
if(L.length>=MAXSIZE)return false;
for(int j=L.length-1;j>=i-1;j--){
L.data[j+1]=L.data[j];
}
L.data[i-1]=e;
L.length++;
return true;
}
假设顺序表表长为
n
n
n,通过分析最内层语句L.data[j]=L.data[j-1];
来分析时间复杂度:
- 最好时间复杂度:当在表尾插入时执行次数最少(
i
=
n
+
1
i=n+1
i=n+1),此时不执行
L.data[j]=L.data[j-1];
,故时间复杂度为 O ( 1 ) O(1) O(1)。 - 最坏时间复杂度:当在表头插入时执行次数最多,为 k = ∑ j = n − 1 0 1 = n k=\sum_{j=n-1}^01=n k=∑j=n−101=n次,此时时间复杂度为 O ( n ) O(n) O(n)。
- 平均时间复杂度:一共有 n + 1 n+1 n+1个位置可供插入(即 i i i的取值是 1 1 1到 n + 1 n+1 n+1),假设每个位置插入的概率相等,为 1 / ( n + 1 ) 1/(n+1) 1/(n+1),则执行次数为 k = 1 / ( n + 1 ) ∑ i = 1 n + 1 ∑ j = n − 1 i − 1 1 = n / 2 k=1/(n+1)\sum_{i=1}^{n+1}\sum_{j=n-1}^{i-1} 1=n/2 k=1/(n+1)∑i=1n+1∑j=n−1i−11=n/2次,故此时时间复杂度为 O ( n ) O(n) O(n)。
c.删除操作
思路:删除第 i i i个元素时需要将第 ( i + 1 ) (i+1) (i+1)个至第 n n n个元素(共 ( n − i ) (n-i) (n−i)个元素)依次向前移动一个位置( i = n i=n i=n时无需移动)。
//删除操作:删除第i个位置上的元素(i是位序)
bool DeleteSqList(SqList& L,int i){
if(L.length==0)return false;
if(i<1||i>L.length)return false;
for(int j=i;j<=L.length-1;j++){
L.data[j-1]=L.data[j];
}
L.length--;
return true;
}
假设顺序表表长为
n
n
n,通过分析最内层语句L.data[j]=L.data[j+1];
来分析时间复杂度:
- 最好时间复杂度:删除表尾元素时( i = n i=n i=n),此时无需移动元素,故时间复杂度为 O ( 1 ) O(1) O(1)。
- 最坏时间复杂度:当在表头删除元素时( i = 1 i=1 i=1),此时执行次数为 k = ∑ j = 1 n − 1 1 = n − 1 k=\sum_{j=1}^{n-1}1=n-1 k=∑j=1n−11=n−1次,故时间复杂度为 O ( n ) O(n) O(n)。
- 平均时间复杂度:一共有 n n n个位置可供删除(即 i i i的取值是 1 1 1到 n n n),假设每个位置删除的概率相等,为 1 / n 1/n 1/n,则执行次数为 k = 1 / n ∑ i = 1 n ∑ j = i n − 1 1 = ( n − 1 ) / 2 k=1/n\sum_{i=1}^{n}\sum_{j=i}^{n-1} 1=(n-1)/2 k=1/n∑i=1n∑j=in−11=(n−1)/2次,故此时时间复杂度为 O ( n ) O(n) O(n)。
d.按值查找
//按值查找:查找顺序表中第一个值为e的元素,并返回位序
ElemType LocateElem(SqList& L,ElemType e){
for(int i=0;i<L.length;i++){
if(L.data[i]==e)return i+1;
}
return 0; //位序为1~n,返回0则表示不存在
}
假设顺序表表长为
n
n
n,通过分析最内层语句if(L.data[i]==e)return i+1;
来分析时间复杂度:
- 最好时间复杂度:要查找的元素是表头元素,则执行次数为 k = ∑ i = 0 0 1 = 1 k=\sum_{i=0}^{0}1=1 k=∑i=001=1次,故时间复杂度为 O ( 1 ) O(1) O(1)。
- 最坏时间复杂度:要查找的元素是表尾元素,则执行次数为 k = ∑ i = 0 n − 1 1 = n k=\sum_{i=0}^{n-1}1=n k=∑i=0n−11=n次,故时间复杂度为 O ( n ) O(n) O(n)。
- 平均时间复杂度:一共有 n n n个位置可供查找(即 i i i的取值是 0 0 0到 n − 1 n-1 n−1),假设每个位置查找成功的概率相等,为 1 / n 1/n 1/n,则执行次数为 k = 1 / n ∑ i = 0 n − 1 ∑ j = 0 i 1 = ( n + 1 ) / 2 k=1/n\sum_{i=0}^{n-1}\sum_{j=0}^{i}1=(n+1)/2 k=1/n∑i=0n−1∑j=0i1=(n+1)/2次,故此时时间复杂度为 O ( n ) O(n) O(n)。
2.2.4顺序表的特点
- 随机访问 ,可以在O(1)时间内找到第i个元素。
- 存储密度高,每个节点只存储数据元素。
- 拓展容量不方便。
- 插入、删除操作不方便,需要移动大量元素。
2.3线性表的链式表示:链表
顺序表的插入、删除操作需要移动大量元素,非常不方便,且要求大片连续的存储空间,而使用链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素物理上也相邻,插入和删除也不需要移动元素,而只需要修改指针,但也失去了随机存取的优点。
2.3.1单链表
a.单链表的定义
线性表的链式存储称为单链表,其使用指向后继元素的指针来建立数据元素之间的线性关系,结点类型描述如下:
#include <iostream>
using namespace std;
typedef int ElemType;
typedef struct LNode{
ElemType data; //数据域
LNode* next; //指针域
}LNode,*LinkList;
b.单链表的实现
单链表有不带头结点和带头结点两种实现方式,对于前者,通常用头指针来标识一个单链表,如单链表
L
L
L,头指针为NULL
时表示是一个空表;而对于后者,在单链表第一个真正存储元素的结点(首元结点)之前附加了一个结点,称为头结点,头结点可不设任何信息,也可记录表长,其指针指向首元结点,此时,头指针指向的是头结点。
使用头结点的好处:
- 由于第一个数据结点的位置被存放在头结点的指针域中,因此在链表的第一个位置上的操作和在表的其他位置上的操作一致,无需特殊处理(如不带头结点的单链表在添加/删除结点时需要判断是否是第一个结点,若是,则需要改变头指针的指向)。
- 对于使用头结点的单链表,无论链表是否为空,其头指针是都指向头结点的非空指针,此时空表和非空表的处理就得到了统一(如不带头结点的单链表在查/该操作时需要额外判断头指针
L
L
L是否为
NULL
,而增加头结点之后只需判断next
指针即可)。
①不带头指针
//不带头指针的初始化
void InitLinkList(LinkList& L){
L=NULL;
}
//不带头指针的判断是否为空
bool IsEmpty(LinkList& L){
return L==NULL;
}
②带头指针
bool InitLinkList(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
if(L==NULL)return false; //分配存储空间失败
L->next=NULL;
return true;
}
//带头指针的判断是否为空
bool IsEmpty(LinkList& L){
return L->next==NULL;
}
c.单链表的基本操作
以下均采用带头指针的写法,至于不带头指针的写法一般不会考,且除增加了一些判断操作外,并无太大区别。
①头插法建立单链表
思路:每次都将生成的结点插入到链表的表头,生成的单链表元素存储顺序与实际输入顺序相反,可使用此特性实现单链表反转。
#include <iostream>
using namespace std;
typedef int ElemType;
typedef struct LNode{
ElemType data; //数据域
LNode* next; //指针域
}LNode,*LinkList;
//头插法建立单链表
LinkList List_HeadInsert(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL;
LNode *t; //使用中间变量
ElemType x=0;
cin>>x;
while(x!=999){
t=new LNode;
t->data=x;
t->next=L->next;
L->next=t;
cin>>x;
}
return L;
}
//遍历单链表
void TraverseList(LinkList L){
LNode *cur=L->next;
int i=1;
while(cur!=NULL){
cout<<"第"<<i<<"个元素是:"<<cur->data<<endl;
i++;
cur=cur->next;
}
}
int main() {
LinkList L;
List_HeadInsert(L);
TraverseList(L);
return 0;
}
时间复杂度:每个结点的插入时间为
O
(
1
)
O(1)
O(1),若单链表长为
n
n
n,则时间复杂度为
O
(
n
)
O(n)
O(n)。
②尾插法建立单链表
思路:增加尾指针指向链表尾结点,实现在尾部插入新结点,使得元素存储顺序与输入顺序相同。
#include <iostream>
using namespace std;
typedef int ElemType;
typedef struct LNode{
ElemType data; //数据域
LNode* next; //指针域
}LNode,*LinkList;
//初始化
bool InitLinkList(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
if(L==NULL)return false; //分配存储空间失败
L->next=NULL;
return true;
}
//判断是否为空
bool IsEmpty(LinkList& L){
return L->next==NULL;
}
//尾插法建立单链表
LinkList List_TailInsert(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL;
LNode *t; //使用中间变量
LNode *r=L; //设置尾部指针
ElemType x;
cin>>x;
while(x!=999){
t=new LNode;
t->data=x;
t->next=NULL;
r->next=t;
r=t;
cin>>x;
}
return L;
}
//遍历单链表
void TraverseList(LinkList L){
LNode *cur=L->next;
int i=1;
while(cur!=NULL){
cout<<"第"<<i<<"个元素是:"<<cur->data<<endl;
i++;
cur=cur->next;
}
}
int main() {
LinkList L;
List_TailInsert(L);
TraverseList(L);
return 0;
}
时间复杂度:每个结点的插入时间为
O
(
1
)
O(1)
O(1),若单链表长为
n
n
n,则时间复杂度为
O
(
n
)
O(n)
O(n)。
③按位查找
//按位查找:按位序查找并返回第i个位置的结点
LNode *GetElem(LinkList& L,int i){
int j=1;
LNode *cur=L->next;
if(i==0)return L; //若位序为0,则返回头结点
if(i<0)return NULL; //若i无效,则返回NULL
while(cur!=NULL&&j<i){
cur=cur->next;
j++;
}
return cur; //若i大于表长,则返回NULL
}
时间复杂度: O ( n ) O(n) O(n)。
④按值查找
//按值查找:返回值为e的结点的指针,若不存在则返回NULL
LNode *LocateElem(LinkList& L,ElemType e){
LNode *cur=L->next;
while(cur!=NULL&&cur->data!=e){ //注意,这两个判断是有先后顺序的
cur=cur->next;
}
return cur;
}
时间复杂度: O ( n ) O(n) O(n)。
⑤按位插入操作
思路:找到第 i − 1 i-1 i−1个结点,在其后面插入新元素结点。
//插入操作:给定位序i,插入元素e
bool ListInsert(LinkList& L,int i,ElemType e){
if(L->next==NULL||i<1)return false;
LNode* cur=L;
int j=0;
while(cur!=NULL&&j<i-1){
cur=cur->next;
j++;
}
if(cur==NULL)return false;
LNode* t=new LNode;
t->data=e;
t->next=cur->next;
cur->next=t;
return true;
}
平均时间复杂度: O ( n ) O(n) O(n)
⑥指定结点后插操作
//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p,ElemType e){
if(p==NULL)return false;
LNode* s=new LNode;
s->data=e;
s->next=p->next;
p->next=s;
return true;
}
平均时间复杂度: O ( 1 ) O(1) O(1)
⑦按位删除操作
思路:先找到第 i − 1 i-1 i−1个结点,再删除其后继结点。
//按位删除:删除指定位序结点,并用e返回被删除结点的值
bool ListDelete(LinkList& L,int i,ElemType& e){
if(L==NULL||i<1)return false;
//先找到第i-1个结点
int j=0;
LNode* p=L;
while(p!=NULL&&j<i-1){
p=p->next;
j++;
}
if(p==NULL||p->next==NULL)return false;
LNode* q=p->next;
p->next=q->next;
e=q->data;
free(q);
return true;
}
平均时间复杂度: O ( n ) O(n) O(n)
d.单链表的特点
- 解决顺序表需要大量连续存储空间的缺点。
- 单链表附加指针域,使得存储密度低,存在浪费存储空间的缺点。
- 单链表元素离散分布在存储空间,故是非随机存取的存储结构,即不能直接找到某个特定的结点,查找某个特定结点时,需要从表头开始依次遍历。
2.3.2双链表
单链表结点中只有一个指向后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点,只能从头开始遍历,即,访问后继结点的时间复杂度为 O ( 1 ) O(1) O(1),而访问前驱结点的时间复杂度为 O ( n ) O(n) O(n)。
a.双链表的定义
双链表在单链表的结点中加入了一个指向其前驱的
p
r
i
o
r
prior
prior指针,因此其按值查找和按位查找操作与单链表相同,但插入和删除操作的实现上与单链表有较大不同,它们的时间复杂度均为
O
(
1
)
O(1)
O(1),但是双链表仍不可随机存取,按位查找和按值查找都只能用依次遍历的方式实现。
typedef int ElemType;
typedef struct DNode{
ElemType data;
DNode *prior,*next;
}DNode,*DLinkList;
b.双链表的实现
注意,以下均是带头结点的写法。
//双链表初始化
bool InitDLinkList(DLinkList& L){
L=(DLinkList)malloc(sizeof(DNode));
if(L==NULL)return false;
L->next=NULL;
L->prior=NULL;
return true;
}
//判断是否为空
bool IsEmpty(DLinkList L){
return L->next==NULL;
}
c.双链表的基本操作
双链表的按位查找、按值查找、按位插入等操作与单链表相同,故此处不再赘述。
①后插操作
//后插操作:给定结点p,在其后面插入结点q
bool InsertNextDNode(DNode* p,DNode* q){
if(p==NULL||q==NULL)return false;
if(p->next!=NULL)q->next=p->next;
p->next=q;
q->prior=p;
return true;
}
平均时间复杂度: O ( 1 ) O(1) O(1)
②前插操作
//前插操作:给定结点p,在其前面插入结点q
bool InsertPriorDNode(DNode* p,DNode* q){
if(p==NULL||q==NULL)return false;
p->prior->next=q;
q->prior=p->prior;
q->next=p;
p->prior=q;
return true;
}
平均时间复杂度: O ( 1 ) O(1) O(1)
③删除后继结点
//删除后继结点:删除给定结点p的后继结点
bool DeleteNextDNode(DNode* p){
if(p==NULL||p->next==NULL)return false;
DNode* t=p->next;
p->next=t->next;
if(t->next!=NULL)t->next->prior=p;
free(t);
return true;
}
平均时间复杂度: O ( 1 ) O(1) O(1)
④前向遍历
//前向遍历:对给定结点p进行前向遍历
//前向遍历:对给定结点p进行前向遍历
bool PriorTraverseDLinkList(DLinkList L,DNode* p){
if(p==NULL||L==NULL)return false;
DNode* cur=p;
while(cur!=L){
cout<<cur->data<<endl;
cur=cur->prior;
}
return true;
}
⑤头插法
//头插法建立双链表
DLinkList DLinkList_HeadInsert(DLinkList& L){
L=(DLinkList)malloc(sizeof(DNode));
L->prior=NULL;
L->next=NULL;
DNode* t;
ElemType x;
cin>>x;
while(x!=999){
t=new DNode;
t->data=x;
t->next=L->next;
t->prior=L;
L->next=t;
cin>>x;
}
return L;
}
⑥尾插法
//尾插法建立双链表
DLinkList DLinkList_TailInsert(DLinkList& L){
L=(DLinkList)malloc(sizeof(DNode));
L->prior=NULL;
L->next=NULL;
DNode* t,*q;
q=L;
ElemType x;
cin>>x;
while(x!=999){
t=new DNode;
t->next=NULL;
t->data=x;
q->next=t;
t->prior=q;
q=t;
cin>>x;
}
return L;
}
2.3.3循环链表
循环链表,是另一种形式的链式存储结构。它的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。
a.循环单链表
循环单链表和单链表的区别在于,表中最后一个结点的指针不是
N
U
L
L
NULL
NULL,而改为指向头结点,从而整个链表形成一个环。
在循环单链表中,表尾结点
∗
r
*r
∗r的
n
e
x
t
next
next域指向
L
L
L,故表中没有指针域为
N
U
L
L
NULL
NULL的结点,因此循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针。
循环单链表的插入、删除算法与单链表几乎一致,且,正是因为循环单链表是一个环,因此在任何位置上的插入和删除操作都是等价的,无需判断是否是表尾。
#include <iostream>
using namespace std;
typedef int ElemType;
typedef struct LNode{
ElemType data;
LNode* next;
}LNode,*LinkList;
//初始化循环单链表
bool InitList(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
if(L==NULL)return false;
L->next=L;
return true;
}
//判断循环单链表是否为空
bool IsEmpty(LinkList L){
return L->next==L;
}
//判断结点p是否是循环单链表的表尾结点
bool IsTail(LinkList L,LNode* p){
return p->next==L;
}
//删除操作:删除给定结点p(与单链表不同,循环单链表从一个结点出发就可遍历其他所有结点,故不需要头结点就可找到前驱结点
bool DeleteLNode(LNode* p){
if(p==NULL)return false;
LNode* cur=p;
while(cur->next!=p)cur=cur->next;
cur->next=p->next;
free(p);
return true;
}
//统计表长
int Length(LinkList L){
int i=0;
LNode* cur=L;
while(cur->next!=L){
cur=cur->next;
i++;
}
return i;
}
//按值查找:找到数据域等于e的结点
LNode* LocateElem(LinkList L,ElemType e){
if(L->next==NULL)return NULL;
LNode* cur=L->next;
while(cur!=L){
if(cur->data==e)return cur;
cur=cur->next;
}
return NULL;
}
//按位查找:返回位序为i的结点
LNode* GetElem(LinkList L,int i){
if(L->next==L||i<1)return NULL;
LNode* cur=L->next;
int j=1;
while(cur!=L&&j<i){
cur=cur->next;
j++;
}
if(j==i)return cur;
else return NULL;
}
//循环单链表的初始化:头插法
LinkList List_HeadInsert(LinkList& L){
L=(LinkList)malloc(sizeof(LNode));
L->next=L;
LNode* s=NULL;
ElemType x;
cin>>x;
while(x!=999){
s=new LNode;
s->data=x;
s->next=L->next;
L->next=s;
cin>>x;
}
return L;
}
循环单链表与单链表的比较:
- 单链表:从一个结点出发只能遍历到其后继结点,且只能从表头结点向后遍历才能遍历整个链表。
- 循环单链表:从一个结点出发就可遍历整个链表,并找到其他所有结点,甚至可不设置头指针而设置尾指针,使得从对表头和表尾的操作都只需要 O ( 1 ) O(1) O(1)的时间复杂度(从表尾找到表头只要 O ( 1 ) O(1) O(1)的时间复杂度)。
b.循环双链表
对于循环双链表,头结点的
p
r
i
o
r
prior
prior指针还要指向表尾结点,如下图所示:
在循环双链表
L
L
L中,某结点
∗
p
*p
∗p为尾结点时,
p
−
>
n
e
x
t
=
=
L
p->next==L
p−>next==L;当循环双链表为空表时,其头结点的
p
r
i
o
r
prior
prior域和
n
e
x
t
next
next域都等于
L
L
L。
#include <iostream>
using namespace std;
typedef int ElemType;
typedef struct DNode{
ElemType data;
DNode* next,* prior;
}DNode,*DLinkList;
//初始化循环双链表
bool InitDLinkList(DLinkList& L){
L=(DLinkList)malloc(sizeof(DNode));
if(L==NULL)return false;
L->prior=L;
L->next=L;
return true;
}
//判断循环双链表是否为空
bool IsEmpty(DLinkList L){
return L->next==L;
}
//判断结点p是否是循环双链表的表尾结点
bool isTail(DLinkList L,DNode* p){
return p->next==L;
}
//插入操作:将结点s插入到结点p之后
bool InsertNextDNode(DNode* p,DNode* s){
if(p==NULL||s==NULL)return false;
p->next->prior=s;
s->next=p->next;
s->prior=p;
p->next=s;
return true;
}
//删除操作:删除结点p的后继结点
bool DeleteNextNode(DLinkList& L,DNode* p){
DNode* q=p->next;
//循环双链表不用担心q结点为空
p->next=q->next;
q->next->prior=p;
free(q);
return true;
}
2.3.4静态链表
静态链表借助数组来描述线性表的链式存储结构,它的结点由数据域
d
a
t
a
data
data和
n
e
x
t
next
next构成,但此处的指针是结点存储的数组下标,亦称游标,和顺序表一样,静态链表需要先预先分配一块连续的存储空间,但不要求逻辑上相邻物理上也相邻,也因此失去了随机存取的特性,只能从头结点开始依次向后查找。
静态链表以
n
e
x
t
=
−
1
next=-1
next=−1作为结束的标志。静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。
- 优点:增、删操作不需要移动大量元素。
- 缺点:不能随机存取,只能从头结点开始往后查找,且容量固定不变。
#include <iostream>
using namespace std;
typedef int ElemType;
#define MaxSize 100
typedef struct {
ElemType data;
int next; //下一个元素的数组下标
}SNode,SLinkList[MaxSize];//SLinkList a;等价于SNode a[MaxSize];
/*
* 两种定义方式:
* (1)SNode L[MAXSIZE];强调L是一个SNode型的数组
* (2)SLinkList[MAXSIZE];强调L是一个静态链表
* */
//初始化
void InitSLinkList(SLinkList L){
for(int i=0;i<MaxSize;i++){
L[i].next=-2; //使用-2标记空结点
}
L[0].next=-1; //使用-1标记尾部结点,初始化时头结点就是尾结点
}
//判断是否为空
bool IsEmpty(SLinkList L){
return L[0].next=-1;
}
//按序查找:获取第i个元素的值
int GetIndex(SLinkList L,int i){
if(i<1||i>MaxSize)return -1; //-1表示未找到第i个元素
int index=0; //index起游标作用,用于记录第j个元素的下标
int j=0; //j用于计数
while(j<i&&index!=-2){
index=L[index].next;
j++;
}
if(j==i)return L[j].data;
else return -1;
}
//得到第一个空结点的下标
int Get_First_Empty_Node(SLinkList L){
for(int i=0;i<MaxSize;i++){
if(L[i].next==-2)return i;
}
return -1; //表示链表已满,不存在空结点
}
/*
* 插入操作:在位序i上插入结点
* (1)找到第一个空结点的下标,并存入数据元素
* (2)找到第(i-1)个元素保存并修改next值
* (3)将插入结点的next值修改为保存的(i-1)个元素的next值
* */
bool InsertSNode(SLinkList& L,int i,ElemType e){
if(i<1||i>MaxSize-1)return false;
int Empty_Index=-1;
for(int k=1;k<MaxSize;i++){
if(L[k].next==-2){
Empty_Index=k;
break;
}
}
if(Empty_Index==-1)return false; //不存在空结点则返回false
L[Empty_Index].data=e; //存入数据元素的值
int index=0; //index用于记录第j个元素的下标
int j=0;
while(j<(i-1)&&index!=-2){ //找到第(i-1)个元素的下标
index=L[index].next;
j++;
}
L[Empty_Index].next=L[index].next; //即使第(i-1)个元素是表尾元素(以-1为标识),也无需特判
L[index].next=Empty_Index;
return true;
}
/*
* 删除操作:删除第i个结点,并将数据域保存至元素e
* (1)找到第(i-1)个结点的下标
* (2)更改第(i-1)个结点的next值,且需判断第i个结点是否为表尾结点,若是则需特判
* (3)将第i个结点next值置为-2
* */
bool DeleteSNode(SLinkList& L,int i,ElemType& e){
if(i<1||i>MaxSize)return false;
int index=0; //index用于记录第j个元素的下标
int j=0;
while(j<i-1&&index!=-2){ //找到第(i-1)个元素的下标
index=L[index].next;
j++;
}
if(L[index].next==-2||L[L[index].next].next==-2)return false; //若第(i-1)或第i个结点的下标为-2,则表示不存在,返回false
e=L[L[index].next].data;
L[index].next=L[L[index].next].next; //即使第(i-1)个元素是表尾元素(以-1为标识),也无需特判
L[L[index].next].next=-2;
return true;
}
注意:
- 初始化静态链表时,需要将
a[0]
的next
设为 − 1 -1 −1以标明尾部结点(刚开始时头结点即是尾部结点),并将空闲结点的next
值设为某个特殊值,如-2
表明是空闲结点。 - 按位序查找结点时,需要从头结点出发挨个往后遍历结点,时间复杂度为 O ( n ) O(n) O(n)。
2.4顺序表和链表的比较
存取(读写)方式:顺序表既可顺序存取,也可,随机存取,存储密度高,但需要分配大片连续空间改变容量不方便。
链表只需要离散的小空间,分配方便、改变容量方便。但不可随机存取,只能从表头顺序存取元素,如在第
i
i
i个位置上执行存活取的操作,顺序表只需访问一次,而链表则需从表头开始依次访问
i
i
i次,且存储密度低。
存取密度
=
数据元素本身占用的存储量
/
结点结构占用的存储量
存取密度=数据元素本身占用的存储量/结点结构占用的存储量
存取密度=数据元素本身占用的存储量/结点结构占用的存储量
逻辑结构与物理结构:
顺序表和链表都属于线性表,都是线性结构。
对于顺序存储,逻辑上相邻,物理上也相邻;而对于链式存储(静态链表也属于链式存储),逻辑上相邻,物理上不一定相邻,对应的逻辑关系通过指针链接来实现。
初始化操作:顺序表需要预分配大片连续空间。若分配空间过小,则之后不方便拓展容量;若分配空间过大,则浪费内存资源。
- 静态分配:静态数组,容量不可改变。
- 动态分配:动态数组,容量可以改变,但是需要移动大量元素,时间代价高(使用函数
malloc()
、free()
)。
而链表只需要分配一个头结点或者只声明一个头指针。
销毁操作:顺序表需要修改Length=0
,对于静态分配,由系统自动回收空间,对于动态分配,需要手动free()
;对于链表,需要使用free()
依次删除各个结点。
查找、插入和删除操作:
对于按值查找,顺序表无序时,两者的时间复杂度均为
O
(
n
)
O(n)
O(n),当顺序表有序时,可使用折半查找,此时时间复杂度为
O
(
l
o
g
2
n
)
O(log_2n)
O(log2n)。
对于按序查找,顺序支持随机存取,时间复杂度为
O
(
1
)
O(1)
O(1),而链表的平均时间复杂度为
O
(
n
)
O(n)
O(n)。
顺序表的插入、删除操作,平均需要移动半个表长的元素,时间复杂度为
O
(
n
)
O(n)
O(n),而链表的插入、删除操作只需要修改相关结点的指针域即可,但需要从表头顺序查找要删除的元素,故时间复杂度为
O
(
n
)
O(n)
O(n)。
空间分配:顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置;与分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续空间,则会导致分配失败,链式存储的结点空间只需要早需要时申请分配,只要内存有空间就可以分配,操作灵活、高效。
2.5补充:《严蔚敏 数据结构》算法应用
2.5.1线性表的合并
问题描述:给定
L
A
LA
LA和
L
B
LB
LB两个线性表,将存在于
L
B
LB
LB而不存在于
L
A
LA
LA的元素插入到
L
A
LA
LA当中,实现集合思想。
算法思路:
- 获取 L A LA LA和 L B LB LB的表长 m 、 n m、n m、n。
- 从
L
B
LB
LB的第
1
1
1个元素开始,循环
n
n
n次执行以下操作:
- 从 L B LB LB中查找第 i ( 1 ≤ i ≤ n ) i(1≤i≤n) i(1≤i≤n)个数据元素赋给 e e e。
- 在 L A LA LA中查找元素 e e e,若不存在,则将其插在 L A LA LA的最后。
void MergeList(List& A,List& B){
m=ListLength(LA);
n=ListLength(LB);
for(int i=1;i<=n;i++){
GetElem(LB,i,e);
if(!LocateElem(LA,e))ListInsert(LA,++m,e); //将e插在LA的最后
}
}
当采用顺序存储结构时,在每次循环的当中,GetElem()
和ListInsert()
操作执行时间与表长无关,而LocateElem()
执行时间和表长m
成正比,因此算法的时间复杂度为
O
(
m
×
n
)
O(m×n)
O(m×n)。
当采用链式结构时,在每次循环的当中,GetElem()
执行时间和表长n
成正比,而LocateElem()
和ListInsert()
操作执行时间与表长无关,因此,若假设
m
>
n
m>n
m>n,则算法的时间复杂度也为
O
(
m
×
n
)
O(m×n)
O(m×n)。
2.5.2有序表的合并
问题描述:给定 L A LA LA和 L B LB LB两个有序表,表长分别记为 m m m和 n n n,将二者合并为新有序表 L C LC LC。
a.顺序有序表的合并
void MergeList_Sq(SqList LA,SqList LB,SqList& LC){
LC.length=LA.length+LB.length;
LC.elem=new ElemType[LC.length];
pc=LC.elem;
pa=LA.elem;pb=LB.elem; //pa和pb分别指向LA和LB的第一个元素
pa_last=LA.elem+LA.length-1;
pb_last=LB.elem+LB.length-1;
//pa_last和pb_last分别指向两表最后一个元素
while((pa<=pa_last)&&(pb<=pb_last)){
if(*pa<=*pb)*pc++=*pa++;
else *pc++=*pb++;
}
while(pa<pa_last)*pc++=*pa++;
while(pb<pb_last)*pc++=*pb++;
}
算法最多执行 m + n m+n m+n次,故算法时间复杂度为 O ( m + n ) O(m+n) O(m+n),且,算法在归并时也开辟了新的辅助空间,故空间复杂度也为 O ( m + n ) O(m+n) O(m+n)。
b.链式有序表的合并
void MergeList_L(LinkList &LA,LinkList &LB,LinkList &LC){
pa=LA->next;
pb=LB->next;
LC=LA; //用LA的头结点作为LC的头结点
pc=LC;
while(pa&&pb){
if(pa->data<=pb->data){
pc->next=pa;
pc=pa;
pa=pa->next;
}
else{
pc->next=pb;
pc=pb;
pb=pb->next;
}
}
pc->next=pa?pa:pb;
delete LB;
}
算法时间复杂度为 O ( m + n ) O(m+n) O(m+n),但无需其他辅助空间。故空间复杂度也为 O ( 1 ) O(1) O(1)。
2.6补充:常用链表各类操作的对比
在课后题中经常出现各种天花烂坠的链表,此处作一个汇总。
注:在不说明的情况下,如,“仅设头指针的循环单链表”,意为只设头指针指向首元结点,不设头结点。
查找首元结点 | 查找表尾结点 | 寻找结点*p的前驱 | 表头插入元素 | 表尾插入元素 | 表头删除元素 | 表尾删除元素 | |
---|---|---|---|---|---|---|---|
不带头结点的单链表L | L,O(1) | 顺序遍历,O(n) | 顺序遍历,O(n) | O(1),需要改变头指针指向 | O(n) | O(1),需要改变头指针指向 | O(n) |
带头结点的单链表L | L->next,O(1) | 顺序遍历,O(n) | 顺序遍历,O(n) | O(1),不需要改变头指针指向 | O(n) | O(1),不需要改变头指针指向 | O(n) |
不带头结点仅设头指针L的循环单链表 | L,O(1) | 顺序遍历,O(n) | 顺序遍历,O(n) | O(n),需要改变头指针指向,且需保持循环特性 | O(n) | O(n),需要保持循环特性 | O(n) |
带头结点仅设头指针L的循环单链表 | L->next,O(1) | 顺序遍历,O(n) | 顺序遍历,O(n) | O(1),不需要改变头指针指向,不会破坏循环特性 | O(n) | O(1),不会破坏循环特性 | O(n) |
不带头结点仅设尾指针R的循环单链表 | R->next,O(1) | R,O(1) | 顺序遍历,O(n) | O(1),不会破坏循环特性 | O(1),需要改变表尾指针的指向 | O(1),不会破坏循环特性 | O(n) |
带头结点仅设尾指针R的循环单链表 | L->next->next,O(1) | R,O(1) | 顺序遍历,O(n) | O(1),不会破坏循环特性 | O(1),需要改变表尾指针的指向 | O(1),不会破坏循环特性 | O(n) |
仅设头指针L的循环双链表 | L,O(1) | O(1) | O(1) | O(1),需要改变表头指针的指向,不会破坏循环特性 | O(1),需要改变表尾指针的指向 | O(1),不会破坏循环特性 | O(1) |
仅设尾指针R的循环双链表 | L->next,O(1) | O(1) | O(1) | O(1),不会破坏循环特性 | O(1),需要改变表尾指针的指向 | O(1),不会破坏循环特性 | O(1) |
第三章:栈、队列和数组(考纲一览)
3.1栈
3.1.1栈的定义
栈(
S
t
a
c
k
Stack
Stack)是一种操作受限的线性表,只允许在一端进行插入或删除操作,假设某个栈
S
=
(
a
1
,
a
2
,
a
3
,
a
4
,
a
5
)
S=(a_1,a_2,a_3,a_4,a_5)
S=(a1,a2,a3,a4,a5),则
a
1
a_1
a1是栈底元素,
a
5
a_5
a5是栈顶元素,且由于栈只能在栈顶进行插入或删除操作,当进栈的次序为
a
1
,
a
2
,
a
3
,
a
4
,
a
5
a_1,a_2,a_3,a_4,a_5
a1,a2,a3,a4,a5时,出栈次序则为
a
5
,
a
4
,
a
3
,
a
2
,
a
1
a_5,a_4,a_3,a_2,a_1
a5,a4,a3,a2,a1,此特性称为后进先出(
L
a
s
t
Last
Last
I
n
In
In
F
i
r
s
t
First
First
O
u
t
Out
Out,
L
I
F
O
LIFO
LIFO)。
数学特性:
n
n
n个不同元素进栈时,出栈元素不同排列的个数为
1
/
(
n
+
1
)
C
2
n
n
1/(n+1)C_{2n}^n
1/(n+1)C2nn
常见概念:
- 栈顶:允许进行插入和删除的一端 (最上面的为栈顶元素)。
- 栈底:不允许进行插入和删除的一端 (最下面的为栈底元素)。
- 空栈:不含任何元素的空表。
常用操作:
- 进栈操作:栈不满时,在栈顶压入元素并修改栈顶指针。
- 出栈操作:栈不空时,从栈顶弹出元素并修改栈顶指针。
- 判空操作:根据栈顶指针的状态判空。
- 判满操作:根据栈顶指针的状态判满。
3.1.2栈的顺序存储
①顺序栈
采用顺序存储的栈称为顺序栈,它利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针(
t
o
p
top
top)指示当前栈顶元素的位置。
缺点:栈的大小固定,当分配过大时,容易出现资源浪费,当分配过小时,容易出现元素上溢。
//顺序栈的定义
typedef int ElemType;
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int top;
}SqStack;
事实上,上图中 t o p top top指向的是栈顶元素,当栈顶指针 t o p top top不同时,操作及判断条件也会因此而改变。
a. t o p top top指向栈顶元素
//top指向栈顶元素
void InitSqStack(SqStack& S){
S.top=-1;
};
//判空
bool IsEmpty(SqStack S){
return S.top==-1;
}
//判满
bool IsFull(SqStack S){
return S.top==MaxSize-1;
}
//入栈
bool Push(SqStack& S,ElemType e){
if(S.top==MaxSize-1)return false;
S.data[++S.top]=e;
return true;
}
//出栈
bool Pop(SqStack& S,ElemType& e){
if(S.top==-1)return false;
e=S.data[S.top--];
return true;
}
//获取栈顶元素
bool GetTop(SqStack& S,ElemType& e){
if(S.top==-1)return false;
e=S.data[S.top];
return true;
}
b. t o p top top指向栈顶元素后一位
//top指向栈顶元素后一位
void InitSqStack(SqStack& S){
S.top=0;
}
//判空
bool IsEmpty(SqStack S){
return S.top==0;
}
//判满
bool IsFull(SqStack S){
return S.top==MaxSize;
}
//入栈
bool Push(SqStack& S,ElemType e){
if(S.top==MaxSize)return false;
S.data[S.top++]=e;
return true;
}
//出栈
bool Pop(SqStack& S,ElemType& e){
if(S.top==0)return false;
e=S.data[--S.top];
return true;
}
//获取栈顶元素
bool GetTop(SqStack& S,ElemType& e){
if(S.top==0)return false;
e=S.data[S.top-1];
return true;
}
②共享栈
利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间两端,两个栈向共享空间的中间延伸。
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢,其存储数据的时间复杂度均为
O
(
1
)
O(1)
O(1),在判空、判满时略有不同,其中栈满时是两个共享栈共同占用整个共享存储空间。
#include <iostream>
using namespace std;
//共享栈的定义
typedef int ElemType;
#define MaxSize 50
typedef struct {
ElemType data[MaxSize];
int top1;
int top2;
}ShStack;
//初始化
void InitShStack(ShStack& S){
S.top1=-1;
S.top2=MaxSize;
}
//判断栈是否为空
bool IsEmpty(ShStack S){
return S.top1==-1&&S.top2==MaxSize;
}
//判断栈1是否为空
bool IsEmptyStack1(ShStack S){
return S.top1==-1;
}
//判断栈2是否为空
bool IsEmptyStack2(ShStack S){
return S.top2==MaxSize;
}
//判断是否为满
bool IsFull(ShStack S){
return S.top1+1==S.top2;
}
//栈1元素入栈
bool Push_1(ShStack& S,ElemType e){
if(S.top1+1==S.top2)return false;
S.data[++S.top1]=e;
return true;
}
//栈2元素入栈
bool Push_2(ShStack& S,ElemType e){
if(S.top1+1==S.top2)return false;
S.data[--S.top2]=e;
return true;
}
//栈1元素出栈
bool Pop_1(ShStack& S,ElemType& e){
if(S.top1==-1)return false;
e=S.data[S.top1--];
return true;
}
//栈2元素出栈
bool Pop_2(ShStack& S,ElemType& e){
if(S.top2==MaxSize)return false;
e=S.data[S.top2++];
return true;
}
//获取栈1栈顶元素
bool GetTop_1(ShStack S,ElemType& e){
if(S.top1==-1)return false;
e=S.data[S.top1];
return true;
}
//获取栈2栈顶元素
bool GetTop_2(ShStack S,ElemType& e){
if(S.top2==MaxSize)return false;
e=S.data[S.top2];
return true;
}
int main() {
return 0;
}
3.1.3链栈
栈的链式存储又称链栈,优点是便于多个栈共享存储空间和提高效率,且不存在栈满上溢的情况,通常使用单链表实现,且规定所有操作都在单链表的表头执行,实现进栈时,需要将数据从链表的头部插入,而实现出栈时,需要删除链表头部的首元结点,即,链栈实际上就是一个只能采用头插法插入或删除数据的链表。
此处采用带头结点的写法。
#include<iostream>
using namespace std;
//链栈的定义
typedef int ElemType;
typedef struct LNode{
ElemType data;
LNode* next;
}LNode,*ListStack;
//初始化
bool InitListStack(ListStack& S){
S=(ListStack)malloc(sizeof(LNode));
if(S==NULL)return false;
S->next=NULL;
return true;
}
//判断是否为空
bool IsEmpty(ListStack S){
return S->next==NULL;
}
//入链栈
bool Push(ListStack& S,ElemType e){
LNode* t=new LNode;
if(t==NULL)return false;
t->data=e;
t->next=S->next;
S->next=t;
return true;
}
//出链栈
bool Pop(ListStack& S,ElemType& e){
if(S->next==NULL)return false;
LNode* p=S->next;
S->next=p->next;
e=p->data;
free(p);
return true;
}
//获取栈顶元素
bool GetTop(ListStack S,ElemType& e){
if(S->next==NULL)return false;
e=S->next->data;
return true;
}
3.2队列
3.2.1队列的定义
队列,也是一种操作受限的线性表,仅允许在表的一端进行插入,而在表的另一端进行删除,将向队列中插入元素称为入队,将向队列中删除元素称为出队,最早入队的元素也是最早出队的元素,此特性称为先进先出(
F
i
r
s
t
First
First
I
n
In
In
F
i
r
s
t
First
First
O
u
t
Out
Out,
F
I
F
O
FIFO
FIFO)。
3.2.2队列的顺序存储
①顺序队列
队列的顺序存储实质分配一块连续的存储单元存放队列中的元素,并设置队头指针
f
r
o
n
t
front
front和队尾指针
r
e
a
r
rear
rear分别指向队头元素和队尾元素(可以有不同的指向方式),这里采用的是
《王道数据结构》
《王道 数据结构》
《王道数据结构》上,队头指针
f
r
o
n
t
front
front指向队头元素,队尾指针
r
e
a
r
rear
rear指向队尾元素后一位的方式。
如上图所示,采用Q.front==Q.rear
作为判空的方式,采用Q.rear==MaxSize
作为判满的方式(因为此时新元素不能入队列),此时会出现
假溢出
假溢出
假溢出的情形,即并非真正的溢出,
d
a
t
a
data
data仍存在可存放元素的位置,故是一种
假溢出
假溢出
假溢出。
#include<iostream>
using namespace std;
//顺序队列的定义
typedef int ElemType;
#define MaxSize 100
typedef struct{
ElemType data[MaxSize];
int front;
int rear;
}SqQueue;
//初始化顺序队列
void InitSqQueue(SqQueue& Q){
Q.front=Q.rear=0;
}
//判断顺序队列是否为空
bool IsEmpty(SqQueue Q){
return Q.front==Q.rear;
}
//判断顺序队列是否满
bool IsFull(SqQueue Q){
return Q.rear==MaxSize;
//满并不是rear与front之差为MAXSIZE,而是队列不能再新增元素(此即为顺序队列的缺点).
}
//入队列
bool EnQueue(SqQueue& Q,ElemType e){
if(Q.rear==MaxSize)return false;
Q.data[Q.rear++]=e;
return true;
}
//出队
bool DeQueue(SqQueue& Q,ElemType& e){
if(Q.rear==Q.front)return false;
e=Q.data[Q.front++];
return true;
}
//获取队头元素
bool GetHead(SqQueue Q,ElemType& e){
if(Q.front==Q.rear)return false;
e=Q.data[Q.front];
return true;
}
//获取队列长度
int GetSize(SqQueue& Q){
return Q.rear-Q.front;
}
②循环队列
循环队列将顺序队列臆想为一个环状的空间,即将存储队列元素的表从逻辑上视为一个环,称为循环队列,队头指针
f
r
o
n
t
front
front指向队头元素,队尾指针
r
e
a
r
rear
rear指向队尾元素后一位,当队首/队尾指针等于MaxSize-1
后,在前进一位就自动到0,这一操作利用取余运算%
来实现。
初始时:Q.front=Q.rear=0
队首指针后移1位:Q.front=(Q.front+1)%MaxSize;
队头指针后移1位:Q.rear=(Q.rear+1)%MaxSize;
求队列长度:(Q.rear+MaxSize-Q.front)%MaxSize;
此时用Q.front==Q.rear
来判断是否队空,但是当元素入队速度大于出队速度时,队尾指针会因为求余操作而反向追上队首指针,使得队满时也有Q.front==Q.rear
,为此,提出了三种解决方案。
a.牺牲一个单元区分队空和队满
约定队头指针在队尾指针下一个位置时就视作队满。
初始时:Q.front=Q.rear=0
队空:Q.front==Q.rear;
队满:(Q.rear+1)%MaxSize==Q.front;
队首指针后移1位:Q.front=(Q.front+1)%MaxSize;
队头指针后移1位:Q.rear=(Q.rear+1)%MaxSize;
求队列长度:(Q.rear+MaxSize-Q.front)%MaxSize;
代码实现:
#include<iostream>
using namespace std;
//循环队列的定义
typedef int ElemType;
#define MaxSize 100
typedef struct{
ElemType data[MaxSize];
int front;
int rear;
}SqQueue;
//循环队列初始化
void InitSqQueue(SqQueue& Q){
Q.front=Q.rear=0;
}
//判空
bool IsEmpty(SqQueue Q){
return Q.rear==Q.front;
}
//判满
bool IsFull(SqQueue Q){
return (Q.rear+1)%MaxSize==Q.front;
}
//入队
bool Push(SqQueue& Q,ElemType e){
if((Q.rear+1)%MaxSize==Q.front)return false;
Q.data[Q.rear]=e;
Q.rear=(Q.rear+1)%MaxSize;
return true;
}
//出队
bool Pop(SqQueue& Q,ElemType& e){
if(Q.rear==Q.front)return false;
e=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
return true;
}
//获取队头元素
bool GetHead(SqQueue Q,ElemType& e){
if(Q.front==Q.rear)return false;
e=Q.data[Q.front];
return true;
}
//获取队列长度
int GetSize(SqQueue& Q){
return (Q.rear-Q.front+MaxSize)%MaxSize;
}
b.增设元素个数成员
增设元素个数成员后,队空的条件变为Q.size==0
,队满的条件变为Q.size==MaxSize
,从而避免了两种情况下都满足Q.rear==Q.front
的情况,也不会浪费存储空间。
#include<iostream>
using namespace std;
//循环队列的定义
typedef int ElemType;
#define MaxSize 100
typedef struct{
ElemType data[MaxSize];
int front;
int rear;
int size;
}SqQueue;
//循环队列初始化
void InitSqQueue(SqQueue& Q){
Q.front=Q.rear=Q.size=0;
}
//判空
bool IsEmpty(SqQueue Q){
return Q.size==0;
}
//判满
bool IsFull(SqQueue Q){
return Q.size==MaxSize;
}
//入队
bool Push(SqQueue& Q,ElemType e){
if(Q.size==MaxSize)return false;
Q.data[Q.rear]=e;
Q.rear=(Q.rear+1)%MaxSize;
Q.size++;
return true;
}
//出队
bool Pop(SqQueue& Q,ElemType& e){
if(Q.size==0)return false;
e=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
Q.size--;
return true;
}
//获取队头元素
bool GetHead(SqQueue Q,ElemType& e){
if(Q.size==0)return false;
e=Q.data[Q.front];
return true;
}
//获取队列长度
int GetSize(SqQueue& Q){
return Q.size;
}
c.增设tag记录
可使用tag
来记录上一次操作是入队还是出队操作,用0
表示出队,用1
表示入队,则此时有:
队空:Q.front==Q.rear&&tag==0
//Q.front==Q.rear且上一次是出队操作,则一定是队空
队满:Q.front==Q.rear&&tag==1
//Q.front==Q.rear且上一次是入队操作,则一定是队满
代码实现:
#include<iostream>
using namespace std;
//循环队列的定义
typedef int ElemType;
#define MaxSize 100
typedef struct{
ElemType data[MaxSize];
int front;
int rear;
int tag; //0表示上一次是出队操作,1表示上一次是入队操作
}SqQueue;
//初始化
void InitSqQueue(SqQueue& Q){
Q.front=Q.rear=Q.tag=0;
}
//判空
bool IsEmpty(SqQueue Q){
return Q.rear==Q.front&&!Q.tag;
}
//判满
bool IsFull(SqQueue Q){
return Q.rear==Q.front&&Q.tag;
}
//元素入队
bool Push(SqQueue& Q,ElemType e){
if(Q.rear==Q.front&&Q.tag)return false;
Q.data[Q.rear]=e;
Q.rear=(Q.rear+1)%MaxSize;
Q.tag=1;
return true;
}
//元素出队
bool Pop(SqQueue& Q,ElemType& e){
if(Q.front==Q.rear&&!Q.tag)return false;
e=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
return true;
}
//获取队列大小
int GetSize(SqQueue Q){
return (Q.rear-Q.front+MaxSize)%MaxSize;
}
3.2.3队列的链式存储
队列的链式存储称为链式队列,它实际上是一个同时带有队头指针和队尾指针的单链表,其中队头指针指向队头结点,而队尾指针指向队尾结点。
链式队列特别适用于数据元素变动较大的情况,且不存在队列满或空的问题,另外,当程序中要使用多个队列与多个栈的情形时,最好使用链式队列,这样就不会出现存储分配不合理和溢出的问题。
链式队列也有带头结点和不带头结点的两种写法,对于不带头结点的写法,每次出队都需要更改队头指针指向,且第一次插入结点时需要同时修改队头、队尾指针,使得插入和删除操作并不统一,故此处采用带头结点的写法。
typedef struct{
int data;
LNode* next;
}LNode;
typedef struct{
LNode* front;
LNode* rear;
}LinkQueue;
//初始化
bool InitLinkQueue(LinkQueue& Q){
Q.rear=Q.front=new LNode;
if(Q.front==NULL)return false;
Q.front->next=NULL;
//需要注意这一步
return true;
}
//判断是否为空
bool EmptyLinkQueue(LinkQueue& Q){
return Q.front==Q.rear;
}
//入队列
bool EnLinkQueue(LinkQueue& Q,int e){
LNode* t=new LNode;
if(t==NULL)return false;
t->data=e;
t->next=NULL;
Q.rear->next=t;
Q.rear=t;
return true;
}
//出队列
bool DelLinkQueue(LinkQueue& Q,int& e){
if(Q.front==Q.rear)return false;
LNode* t=Q.front->next;
e=t->data;
Q.front->next=t->next;
if(Q.rear==t)Q.rear=Q.front;
//此次是最后一个结点出队
free(t);
return true;
}
3.2.4双端队列
双端队列是指允许两端都能进行入队和出队操作的队列,其元素的逻辑结构仍是线性结构,将队列的两端分别称为前端和后端。
由此衍生出输出受限的双端队列和输入受限的双端队列。
- 输出受限的双端队列:允许在一端进行插入和删除操作,但在另一端只允许插入操作。
- 输入受限的双端队列:允许在一端进行插入和删除操作,但在另一端只允许删除操作。
这里,采用的是LeetCode
第641题:
设计循环双端队列
设计循环双端队列
设计循环双端队列的代码,且是顺序存储方式实现:
class MyCircularDeque {
private:
vector<int>nums;
int front;
int rear;
int maxsize;
public:
MyCircularDeque(int k) {
nums=vector<int>(k+1);
front=0;
rear=0;
maxsize=k+1;
}
bool insertFront(int value) {
if((rear+1)%maxsize==front)return false;
front=(front-1+maxsize)%maxsize;
nums[front]=value;
return true;
}
bool insertLast(int value) {
if((rear+1)%maxsize==front)return false;
nums[rear]=value;
rear=(rear+1)%maxsize;
return true;
}
bool deleteFront() {
if(rear==front)return false;
front=(front+1)%maxsize;
return true;
}
bool deleteLast() {
if(rear==front)return false;
rear=(rear-1+maxsize)%maxsize;
return true;
}
int getFront() {
if(rear==front)return -1;
return nums[front];
}
int getRear() {
if(rear==front)return -1;
return nums[(rear-1+maxsize)%maxsize];
}
bool isEmpty() {
return rear==front;
}
bool isFull() {
return (rear+1)%maxsize==front;
}
};
3.3栈的应用
3.3.1括号匹配
括号匹配是借助一个栈来检测括号正确性。
流程:
①初始设置一个空栈,顺序读入括号。
②若是右括号,则或者使置于栈顶的最急迫期待得以消解,或者是不合法的情况(括号序列不匹配,退出程序)。
③若是左括号,则作为一个新的更急迫的期待压入栈中,自然使原有的在栈中的所有未消解的期待的急迫性降了一级。
④算法结束结束时,栈为空,否则括号序列不匹配。
以下采用的是 L e e t C o d e LeetCode LeetCode第 20 20 20题—— 有效地括号 有效地括号 有效地括号代码:
class Solution_有效的括号_20 {
public:
bool isValid(string s) {
if(s.size()%2==1)return false;
stack<char>S;
for(int i=0;i<s.size();i++){
if(s[i]=='('||s[i]=='{'||s[i]=='[')S.push(s[i]);
else if(S.empty())return false;
else if(s[i]==')'&&S.top()=='(')S.pop();
else if(s[i]=='}'&&S.top()=='{')S.pop();
else if(s[i]==']'&&S.top()=='[')S.pop();
else return false;
}
return S.empty();
}
};
3.3.2表达式求值
算术表达式是由操作数(运算数)、运算符(操作符)、和界线符(括号)三部分组成,在计算机当中,算术表达式的计算通过堆栈来实现,根据运算符和操作数的相对位置,可将表达式分为三类:
- 中缀表达式:运算符处于两个操作数之间,需要使用界定符(括号)来划分运算优先级,日常使用的就是中缀表达式。
- 前缀表达式(波兰表达式):运算符处于两个操作数之前,不需要使用界定符(括号)来划分运算优先级。
- 后缀表达式(逆波兰表达式):运算符处于两个操作数之后,不需要使用界定符(括号)来划分运算优先级。
对于中缀表达式,它不仅依赖运算符的优先级,还要处理括号,在计算机中计算时要考虑其优先级和括号的关系,实现起来比较麻烦,而对于前/后缀表达式,它们的运算符在操作数之前/后,已经考虑了运算符的优先级,没有括号,只有操作数和运算符,故适合计算机进行运算。
例:
中缀表达式:A+B*(C-D)-E/F
前缀表达式:+A-*B-CD/EF
后缀表达式:
表达式树:可用树的递归结构来表示表达式,以运算符作为非叶结点中的数据,用它的两棵子树作为它的运算对象,如:
对应中缀表达式:
A
+
B
∗
(
C
−
D
)
−
E
/
F
A+B*(C-D)-E/F
A+B∗(C−D)−E/F,其后序遍历序列对应后缀表达式:
A
B
C
D
−
∗
+
E
F
/
ABCD-*+EF/
ABCD−∗+EF/,其先序遍历序列对应前缀表达式:
−
+
A
∗
B
−
C
D
/
E
F
-+A*B-CD/EF
−+A∗B−CD/EF。
a.相互转换
①中缀表达式转后缀表达式
i.手算
步骤:
- 确定中缀表达式中各个运算符的运算顺序。
- 选择下一运算符,按照 [ 左操作数 [左操作数 [左操作数 右操作数 右操作数 右操作数 运算符 ] 运算符] 运算符]的方式组合成一个新的操作数。
- 若还有运算符没被处理,继续步骤2.
注:因为一个中缀表达式的后缀表达式并不唯一,为使手算转换结果与机算一致,需采用
左优先
左优先
左优先原则,即,只要左边的运算符能先计算,就先计算左边的(这里指的是中缀表达式中确定的运算符的次序,这决定了手算后缀表达式时的转换次序),但在转换为后缀表达式时操作数的相对顺序并不能变,如
C
∗
D
/
E
C*D/E
C∗D/E操作转换后
(
C
,
D
,
E
)
(C,D,E)
(C,D,E)的相对顺序不能改变。
例:
中缀表达式: A + B - C * D / E + F
运算符运算顺序: ① ④ ② ③ ⑤
后缀表达式: A B + C D * E / - F +
ii.机算
算法思想:
流程:
(1)首先构造一个运算符栈S1(用于存储暂时不能确定运算顺序的运算符)和中间结果的栈S2。
(2)从左至右扫描中缀表达式,如果是操作数时,将其压入S2。
(3)若是运算符,则依次弹出S1栈中优先级大于等于当前运算符的所有运算符并压入S2,若遇到左括号或栈空或遇到一个比它优先级低的运算符则停止,之后再将当前运算符入S1。
(4)若是括号,则:
a.若是左括号,则直接压入S1。
b.若是右括号,则依次弹出S1栈顶的运算符,并压入S2,直到遇到左括号为止,此时将这一对括号丢弃;
(5)重复步骤(2)至(4),直到表达式的最右边。
(6)若表达式扫描完,将S1中剩余的运算符依次出栈并压入S2,此时从左往右依次读取S2中的元素,即为对应的后缀表达式。
例:
②中缀表达式转前缀表达式
i.手算
步骤:
- 确定中缀表达式中各个运算符的运算顺序。
- 选择下一运算符,按照 [ 运算符 [运算符 [运算符 左操作数 左操作数 左操作数 右操作数 ] 右操作数] 右操作数] 的方式组合成一个新的操作数。
- 若还有运算符没被处理,就继续执行步骤2。
注:因为一个中缀表达式的前缀表达式并不唯一,为使手算转换结果与机算一致,需采用 右优先 右优先 右优先原则,即,只要右边的运算符能先计算,就先计算右边的(这里指的是中缀表达式中确定的运算符的次序),但在转换为前缀表达式时操作数的相对顺序并不能变,如 B ∗ ( C − D ) B*(C-D) B∗(C−D)中,操作数 B B B需要在 ( C − D ) (C-D) (C−D)的运算结果之前。
中缀表达式: A + B * ( C - D ) - E / F
⑤ ③ ② ④ ①
前缀表达式: + A - * B - C D / E F
ii.机算
算法思想:
流程:
(1)首先构造一个运算符栈S1(用于存储暂时不能确定运算顺序的运算符)和中间结果的栈S2。
(2)从右至左扫描中缀表达式,如果是操作数时,将其压入S2。
(3)若是运算符,则依次弹出S1栈中优先级大于等于当前运算符的所有运算符并压入S2,若遇到右括号或栈空或遇到一个比它优先级低的运算符则停止,之后再将当前运算符入S1。
(4)若是括号,则:
a.若是右括号,则直接压入S1。
b.若是左括号,则依次弹出S1栈顶的运算符,并压入S2,直到遇到左括号为止,此时将这一对括号丢弃;
(5)重复步骤(2)至(4),直到表达式的最右边。
(6)若表达式扫描完,将S1中剩余的运算符依次出栈并压入S2,此时从左往右依次读取S2中的元素,即为对应的后缀表达式。
b.求值
①后表达式机算
算法步骤:
- 从左往右扫描表达式,遇到操作数时直接压栈;遇到运算符时弹出栈顶的两个操作数,并使用运算符进行运算。
- 将运算结果压栈,重复步骤1直到到达表达式最左端,最后运算得出的结果即为表达式的结果。
例:计算前缀表达式 123 + 4 × + 5 − 1 2 3 + 4 × + 5 - 123+4×+5−
扫描到的元素 | 栈S(栈底–>栈顶) | 说明 |
---|---|---|
1 | 1 | 直接压栈 |
2 | 1 2 | 直接压栈 |
3 | 1 2 3 | 直接压栈 |
+ | 1 5 | 弹出两个栈顶元素进行运算(2+3) |
4 | 1 5 4 | 直接压栈 |
x | 1 20 | 弹出两个栈顶元素进行运算(5x4) |
+ | 21 | 弹出两个栈顶元素进行运算(1+20) |
5 | 21 5 | 直接压栈 |
- | 16 | 弹出两个栈顶元素进行运算(21-5) |
16即为所得结果。
注:先出栈的是右操作数,故所得结果是16而非-16。
②前缀表达式机算
算法步骤:
- 从右至左扫描表达式,遇到操作数时直接压栈;遇到运算符时弹出栈顶的两个操作数,并使用运算符进行运算。
- 将运算结果压栈,重复步骤1直到到达表达式最左端,最后运算得出的结果即为表达式的结果。
例:计算前缀表达式 − + 1 × + 2345 - + 1 × + 2 3 4 5 −+1×+2345
扫描到的元素 | 栈S(栈底–>栈顶) | 说明 |
---|---|---|
5 | 5 | 直接压栈 |
4 | 5 4 | 直接压栈 |
3 | 5 4 3 | 直接压栈 |
2 | 5 4 3 2 | 直接压栈 |
+ | 5 4 5 | 弹出两个栈顶元素进行运算(2+3) |
x | 5 20 | 弹出两个栈顶元素进行运算(5 x 4) |
1 | 5 20 1 | 直接压栈 |
+ | 5 21 | 弹出两个栈顶元素进行运算(1+20) |
- | 16 | 弹出两个栈顶元素进行运算(21-5) |
16即为所得结果。
注:先出栈的是左操作数,故所得结果是16而非-16。
③中缀表达式机算
中缀表达式求值实际上是中缀转后缀与后缀表达式求值两个算法的结合,需要初始化两个栈,分别存储操作数和运算符,遇到操作数时执行压栈,若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈 (期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈项元素并执行相应运算,运算结果再压回操作数栈)。
3.3.3递归工作栈
前置知识:函数调用栈
函数调用栈简称栈,在程序运行过程中,不管是函数执行还是函数调用,栈都非常关键,它的主要作用:
- 保存函数的局部变量。
- 向被调用函数传递参数。
- 返回函数的返回值。
- 保存函数的返回地址,返回地址是指从被调用函数返回后调用者应该继续执行的指令地址。
每个函数在执行过程中都需要使用一块栈内存用来保存上述这些值,称这块栈内存为函数的栈帧(stack frame
),当发生函数调用时,因为调用者还没有执行完成,其栈内存中保存的数据还有用,所以被调用函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧push
到栈上,等被调用函数执行完成后再将其栈帧从栈上pop
出去。这样,栈大小随着函数调用层级的增加而生长,随函数的返回而缩小。
总结:
- 栈帧是一块因函数运行而临时开辟的空间。
- 每调用一次函数便会创建一个独立栈帧,存放是函数中的必要信息,如局部变量、函数传参、返回值等。
- 当函数运行完毕栈帧将会销毁。
一个递归工作函数在函数执行过程中进行自我调用时,在运行被调用函数之前,系统需要完成三件事:
- 将所有的实参、返回地址等信息传递给被调用函数保存。
- 为被调用函数的局部变量分配存储区。
- 将控制转移到被调函数的入口。
而从被调用函数返回到调用函数之前,系统也需要完成三件事:
- 保存被调函数的计算结果。
- 释放被掉函数的数据区。
- 依照被调函数保存的返回地址将控制转移到调用函数。
当有多个函数构成嵌套调用时,按照
后调用先返回
后调用先返回
后调用先返回的原则,上述函数之间的信息传递和控制转移必须通过
栈
栈
栈来实现,即将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就在它的栈顶分配一个存储区,每当从一个函数返回时就会释放它的存储区,使得当前正运行的函数的数据区必在栈顶。
对于递归调用函数,它的运行过程类似于多个函数的嵌套,只是调用函数和被调函数是同一个函数,下面以阶乘函数为例:
对于递归函数的时间复杂度,在第一章就已讨论,此处不再赘述。
对于递归函数的空间复杂度,由于递归函数在执行过程中,系统需设立一个
递归工作栈
递归工作栈
递归工作栈存储每一层递归所需的信息,次工作栈是递归函数执行的辅助空间,因此分析递归算法的空间复杂度需要分析工作栈的大小,即:
S
(
n
)
=
O
(
f
(
n
)
)
S(n)=O(f(n))
S(n)=O(f(n))
其中,
f
(
n
)
f(n)
f(n)为递归工作栈中工作记录与问题规模
n
n
n的函数关系。
3.4队列的应用
3.4.1队列在层次遍历中的应用
以二叉树的层序遍历为例:
流程:
①根结点入队。
②若队空(所有结点都已处理完毕),则结束遍历,否则重复③操作。
③队列中第一个结点出队,并访问之。若其有左孩子,则将左孩子入队;若有右孩子,则将右孩子入队,返回②。
代码:
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector <vector <int>> ret;
if (!root) {
return ret;
}
queue <TreeNode*> q;
q.push(root);
while (!q.empty()) {
int currentLevelSize = q.size();
ret.push_back(vector <int> ());
for (int i = 1; i <= currentLevelSize; ++i) {
auto node = q.front(); q.pop();
ret.back().push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return ret;
}
};
3.4.2队列在计算机系统中的应用
队列在计算机中主要用于解决主机与外部设备之间速度不匹配和由多用户引起的资源竞争问题。
a.主机与外部设备之间速度不匹配问题:
以主机和打印机之间速度不匹配的问题为例做简要说明。主机输出数据给打印机打印,输出数据的速度比打印数据的速度要快得多,由于速度不匹配,若直接把输出的数据送给打印机打印显然是不行的。解决的方法是设置一个打印数据缓冲区,主机把要打印输出的数据依次写入这个缓冲区,写满后就暂停输出,转去做其他的事墙。打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求。主机接到请求后再向缓冲区写入打印数据。这样做既保证了打印数据的正确,又使主机提高了效率。由此可见,打印数据缓冲区中所存储的数据就是一个队列。
b.多用户引起的资源竞争问题:
C
P
U
CPU
CPU(即中央处理器,它包括运算器和控制器)资源的竞争就是一个典型的例子。在一个带有多终端的计算机系统上,有多个用户需要
C
P
U
CPU
CPU各自运行自己的程序,它们分别通过各自的终端向操作系统提出占用
C
P
U
CPU
CPU的请求。操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把
C
P
U
CPU
CPU分配给队首请求的用户使用。当相应的程序运行结束或用完规定的时间间隔后,令其出队,再把
C
P
U
CPU
CPU分配给新的队首请求的用户使用。这样既能满足每个用户的请求,又使
C
P
U
CPU
CPU能够正常运行。
3.5数组和特殊矩阵
3.5.1数组的定义
数组,是由
n
(
n
≥
1
)
n(n≥1)
n(n≥1)个相同类型的数据元素构成的有限序列,每个数据元素称为一个数组元素,每个元素在
n
n
n个线性关系中的序号称为该元素的下标,下标的取值称为数组的维界,下标的组成个数称为维数。
数组和线性表的关系:数组时线性表的推广,一维数组可视为一个线性表,二维数组可视为其元素也是定长线性表的线性表。
3.5.2数组的存储结构
一个数组的所有元素在内存中占用一段连续的存储空间,由于数组可能是多维的结构,则用一组连续存储单元存放数组元素就次序约定问题。
如上图
(
a
)
(a)
(a)所示的二维数组,它既可看成
(
b
)
(b)
(b)所示的一维数组,也可看成如
(
c
)
(c)
(c)所示的一维数组,响应的,对二维数组就有两种存储方式,一种是以列为主序的存储方式,如下图中
(
a
)
(a)
(a)所示,另一种是以行尾主序的存储方式,如下图中
(
b
)
(b)
(b)所示:
在
C
、
C
+
+
、
J
a
v
a
C、C++、Java
C、C++、Java等语言当中采用的都是以行序为主序的存储方式,而在
F
O
R
T
R
A
N
FORTRAN
FORTRAN等语言中采用的是以列序为主序的存储结构。
按行优先存储的基本思想是:先行后列,先存储行号较小的元素,行号相等则优先存储列号较小的元素,设二维数组行下标和列下标的范围分别为
[
0
,
h
1
]
[0,h_1]
[0,h1]与
[
0
,
h
2
]
[0,h_2]
[0,h2],则存储结构关系式为:
L
O
C
(
a
i
,
j
)
=
L
O
C
(
a
0
,
0
)
+
[
i
∗
(
h
2
+
1
)
+
j
]
∗
L
LOC(a_{i,j})=LOC(a_{0,0})+[i*(h_2+1)+j]*L
LOC(ai,j)=LOC(a0,0)+[i∗(h2+1)+j]∗L
按列优先存储的基本思想是:先列后行,先存储列号较小的元素,列号相等则优先存储行号较小的元素,设二维数组行下标和列下标的范围分别为
[
0
,
h
1
]
[0,h_1]
[0,h1]与
[
0
,
h
2
]
[0,h_2]
[0,h2],则存储结构关系式为:
L
O
C
(
a
i
,
j
)
=
L
O
C
(
a
0
,
0
)
+
[
j
∗
(
h
1
+
1
)
+
i
]
∗
L
LOC(a_{i,j})=LOC(a_{0,0})+[j*(h_1+1)+i]*L
LOC(ai,j)=LOC(a0,0)+[j∗(h1+1)+i]∗L
注:
L
L
L是每个数组元素所占的存储单元大小。
3.5.3特殊矩阵的压缩存储
压缩存储:为多个值相同的元素只分配一个存储空间,对零元素不分配存储空间,目的是为节省存储空间。
特殊矩阵:指具有许多相同矩阵元素或零元素,且这些相同矩阵元素或零元素分布有一定规律性的矩阵。
特殊矩阵的压缩存储:找出特殊矩阵中值相同矩阵元素的分布规律,将呈现规律性分布的、值相同的多个矩阵元素存储到一个存储空间中。
注意:描述矩阵元素时,行、列号通常从
1
1
1开始,而描述数组时的行、列行从
0
0
0开始。
a.对称矩阵
若对于一个
n
n
n阶方阵
A
[
1...
n
]
[
1...
n
]
A[1...n][1...n]
A[1...n][1...n]中的任意一个元素
a
i
,
j
=
a
j
,
i
(
1
≤
i
,
j
≤
n
)
a_{i,j}=a_{j,i}(1≤i,j≤n)
ai,j=aj,i(1≤i,j≤n),则称其为对称矩阵,对于一个
n
n
n阶方阵,其中的元素可划分为
3
3
3个部分:上三角区、主对角线和下三角区。
存储思路:利用下标的映射关系只存储上三角 ( 1 ≤ i < j ≤ n ) (1≤i<j≤n) (1≤i<j≤n)和主对角线元素 ( i = j ) (i=j) (i=j),若是 n n n阶方阵,则数组的大小为 n ( n + 1 ) / 2 n(n+1)/2 n(n+1)/2。
存储下三角区域+行优先:
当
i
≥
j
i≥j
i≥j时,矩阵
a
i
,
j
a_{i,j}
ai,j的
1
1
1到
(
i
−
1
)
(i-1)
(i−1)行一共有
i
(
i
−
1
)
/
2
i(i-1)/2
i(i−1)/2个元素,第
i
i
i行中,
a
i
,
j
a_{i,j}
ai,j是第
j
j
j个元素,又因数组下标从
0
0
0开始,故有:
下标
k
=
i
(
i
−
1
)
/
2
+
(
j
−
1
)
,
(
i
≥
j
)
k=i(i-1)/2+(j-1),(i≥j)
k=i(i−1)/2+(j−1),(i≥j)
故元素下标之间的对应关系为:
存储上三角区域+行优先:
当
j
≥
i
j≥i
j≥i时,矩阵
a
i
,
j
a_{i,j}
ai,j的
1
1
1到
(
i
−
1
)
(i-1)
(i−1)行一共有
(
i
−
1
)
(
2
n
−
i
+
2
)
/
2
(i-1)(2n-i+2)/2
(i−1)(2n−i+2)/2个元素,第
i
i
i行中,
a
i
,
j
a_{i,j}
ai,j是第
(
j
−
i
+
1
)
(j-i+1)
(j−i+1)个元素(从对角线开始数),又因数组下标从
0
0
0开始,故有:
下标
k
=
(
i
−
1
)
(
2
n
−
i
+
2
)
/
2
+
(
j
−
i
)
,
(
j
≥
i
)
k=(i-1)(2n-i+2)/2+(j-i),(j≥i)
k=(i−1)(2n−i+2)/2+(j−i),(j≥i)
故元素下标之间的对应关系为:
k
=
{
(
j
−
1
)
(
2
n
−
j
+
2
)
+
(
i
−
j
)
,
j
>
i
(
i
−
1
)
(
2
n
−
i
+
2
)
/
2
+
(
j
−
i
)
,
j
≤
i
k=\left\{\right.^{(i-1)(2n-i+2)/2+(j-i),j≤i}_{(j-1)(2n-j+2)+(i-j),j>i}
k={(j−1)(2n−j+2)+(i−j),j>i(i−1)(2n−i+2)/2+(j−i),j≤i
存储下三角区域+列优先:
当
i
≥
j
i≥j
i≥j时,矩阵
a
i
,
j
a_{i,j}
ai,j的
1
1
1到
(
j
−
1
)
(j-1)
(j−1)列一共有
(
j
−
1
)
(
2
n
−
j
+
2
)
/
2
(j-1)(2n-j+2)/2
(j−1)(2n−j+2)/2个元素,第
j
j
j列中,
a
i
,
j
a_{i,j}
ai,j是第
(
i
−
j
+
1
)
(i-j+1)
(i−j+1)个元素(从对角线开始数),又因数组下标从
0
0
0开始,故有:
下标
k
=
(
j
−
1
)
(
2
n
−
j
+
2
)
/
2
+
(
i
−
j
)
,
(
i
≥
j
)
k=(j-1)(2n-j+2)/2+(i-j),(i≥j)
k=(j−1)(2n−j+2)/2+(i−j),(i≥j)
故元素下标之间的对应关系为:
k
=
{
(
i
−
1
)
(
2
n
−
i
+
2
)
+
(
j
−
i
)
,
j
>
i
(
j
−
1
)
(
2
n
−
j
+
2
)
/
2
+
(
i
−
j
)
,
i
≥
j
k=\left\{\right.^{(j-1)(2n-j+2)/2+(i-j),i≥j}_{(i-1)(2n-i+2)+(j-i),j>i}
k={(i−1)(2n−i+2)+(j−i),j>i(j−1)(2n−j+2)/2+(i−j),i≥j
存储上三角区域+列优先:
当
j
≥
i
j≥i
j≥i时,矩阵
a
i
,
j
a_{i,j}
ai,j的
1
1
1到
(
j
−
1
)
(j-1)
(j−1)列一共有
j
(
j
−
1
)
/
2
j(j-1)/2
j(j−1)/2个元素,第
j
j
j列中,
a
i
,
j
a_{i,j}
ai,j是第
i
i
i个元素,又因数组下标从
0
0
0开始,故有:
下标
k
=
j
(
j
−
1
)
/
2
+
(
i
−
1
)
,
(
j
≥
i
)
k=j(j-1)/2+(i-1),(j≥i)
k=j(j−1)/2+(i−1),(j≥i)
故元素下标之间的对应关系为:
k
=
{
i
(
i
−
1
)
+
(
j
−
1
)
,
i
>
j
j
(
j
−
1
)
/
2
+
(
i
−
1
)
,
j
≥
i
k=\left\{\right.^{j(j-1)/2+(i-1),j≥i}_{i(i-1)+(j-1),i>j}
k={i(i−1)+(j−1),i>jj(j−1)/2+(i−1),j≥i
b.三角矩阵
三角矩阵包含下三角矩阵和上三角矩阵两种,下三角矩阵中上三角区所有元素都是同一常量,上三角矩阵中下三角区所有元素都是同一常量。
存储思想:存储思想与对称矩阵类似,不同之处在于存储完下/上三角区和主对角线上的元素之后,紧接着存储对角线上方的常量一次,故,
n
n
n阶三角矩阵可压缩存储在数组
B
[
n
(
n
+
1
)
/
2
+
1
]
B[n(n+1)/2+1]
B[n(n+1)/2+1]中。
由于存储思想与对称矩阵类似,故此处不再作过多推导,只给出公式(均是行优先):
下三角矩阵压缩存储:
上三角矩阵压缩存储:
c.三对角矩阵
三对角矩阵,亦称带状矩阵,对于
n
n
n阶三对角矩阵
A
A
A中的任意元素
a
i
,
j
a_{i,j}
ai,j,当
∣
i
−
j
∣
>
1
|i-j|>1
∣i−j∣>1时,有:
a
i
,
j
=
0
,
(
1
≤
i
,
j
≤
n
)
a_{i,j}=0,(1≤i,j≤n)
ai,j=0,(1≤i,j≤n)
对于三对角矩阵,可利用上述特性,只存储主对角线及其上、下两侧次对角线的元素,其他零元素不进行存储,此时,一个
n
n
n阶的三对角矩阵总共需要
3
n
−
2
3n-2
3n−2的存储空间。
行优先存储
可将
3
3
3条对角线上的元素;
a
i
,
j
=
0
,
(
1
≤
i
,
j
≤
n
,
∣
i
−
j
∣
≤
1
)
a_{i,j}=0,(1≤i,j≤n,|i-j|≤1)
ai,j=0,(1≤i,j≤n,∣i−j∣≤1)按行优先的方式存储在一维数组
B
B
B中,且
a
1
,
1
a_{1,1}
a1,1存储在
B
[
0
]
B[0]
B[0]中。
当
a
i
,
j
a_{i,j}
ai,j处于第
2
2
2到
(
n
−
1
)
(n-1)
(n−1)行之间时,由于从第二行开始的元素个数均为
3
3
3个,故此时前
(
i
−
1
)
(i-1)
(i−1)行的元素个数为
3
(
i
−
2
)
+
2
3(i-2)+2
3(i−2)+2,这是因为第
1
1
1行元素的个数为
2
2
2个,而
a
i
,
j
a_{i,j}
ai,j属于第
i
i
i行第
(
j
−
i
+
2
)
(j-i+2)
(j−i+2)个非零元素,又因数组的下标从
0
0
0开始,故而数组下标
k
k
k与行标
i
i
i、列标
j
j
j的关系为:
k
=
2
i
+
j
−
3
,
2
≤
i
≤
n
,
1
≤
j
≤
n
,
∣
i
−
j
∣
≤
1
k=2i+j-3,2≤i≤n,1≤j≤n,|i-j|≤1
k=2i+j−3,2≤i≤n,1≤j≤n,∣i−j∣≤1
,经验证,当
i
=
1
i=1
i=1时该式依旧成立,故可得关系式:
k
=
2
i
+
j
−
3
,
1
≤
i
,
j
≤
n
,
∣
i
−
j
∣
≤
1
k=2i+j-3,1≤i,j≤n,|i-j|≤1
k=2i+j−3,1≤i,j≤n,∣i−j∣≤1
若已知元素
a
i
,
j
a_{i,j}
ai,j在数组中存储的下标为
k
k
k,则可得
i
=
(
k
−
j
+
3
)
/
2
i=(k-j+3)/2
i=(k−j+3)/2,此时有如下三种情况:
①
①
①当
a
i
,
j
a_{i,j}
ai,j位于主对角线时,就有:
i
=
j
i=j
i=j,则此时
i
=
j
=
(
k
+
3
)
/
3
i=j=(k+3)/3
i=j=(k+3)/3。
②
②
②当
a
i
,
j
a_{i,j}
ai,j位于下侧次对角线时,就有:
i
=
j
+
1
i=j+1
i=j+1,则此时
i
=
j
+
1
=
(
k
+
4
)
/
3
i=j+1=(k+4)/3
i=j+1=(k+4)/3。
③
③
③当
a
i
,
j
a_{i,j}
ai,j位于上侧次对角线时,就有:
i
=
j
−
1
i=j-1
i=j−1,则此时
i
=
j
−
1
=
(
k
+
2
)
/
3
i=j-1=(k+2)/3
i=j−1=(k+2)/3。
例:若已知下标
k
=
4
k=4
k=4(
k
k
k从
0
0
0开始存储元素),则有:
①
①
①当
a
i
,
j
a_{i,j}
ai,j位于主对角线时,就有:
i
=
j
i=j
i=j,则此时
i
=
j
=
7
/
3
i=j=7/3
i=j=7/3,此时并非整数,故舍去。
②
②
②当
a
i
,
j
a_{i,j}
ai,j位于下侧次对角线时,就有:
i
=
j
+
1
i=j+1
i=j+1,则此时
i
=
j
+
1
=
8
/
3
i=j+1=8/3
i=j+1=8/3,此时并非整数,故舍去。
③
③
③当
a
i
,
j
a_{i,j}
ai,j位于上侧次对角线时,就有:
i
=
j
−
1
i=j-1
i=j−1,则此时
i
=
j
−
1
=
6
/
3
=
2
i=j-1=6/3=2
i=j−1=6/3=2,此时是整数,故即为答案。
故而下标
k
=
4
k=4
k=4所对应的矩阵元素是
a
2
,
3
a_{2,3}
a2,3。
d.稀疏矩阵
设在
m
n
mn
mn的矩阵中有
t
t
t个非零元素,令
c
=
t
/
(
m
n
)
c=t/(mn)
c=t/(mn),当
c
<
=
0.05
c<=0.05
c<=0.05时称为稀疏矩阵。
存储思想:存各非零元的值、行号与列号以形成三元组,而三元组可用数组、十字链表表示,但也失去了随机存取的特性。
顺序存储三元组:数组存储
链式存储三元组:十字链表
结点结构:
- right:链接同一行中的下一个非零元素。
- left:链接同一列中的下一个非零元素。
存储结构:
3.6补充:判断栈输出序列合法性
在
408
408
408考试当中,直接写出所有可能出栈的概率不大,大多是判断出栈顺序是否正确的题型,这一类题型中,可以利用到的技巧栈的
后进先出
后进先出
后进先出特性,事实上,这一问题的根源是栈是在一端进行输入、输出的,且需要注意的是,题目隐含的情况下是在入栈的过程中仍可能有元素出栈。
①以三个元素为例:
当输出序列第一个为
c
c
c时:
当输出序列第一个为
c
c
c时,此时
(
a
,
b
)
(a,b)
(a,b)一定在栈内部,因为出栈后就不会再入栈(否则违反入栈序列为
(
a
,
b
,
c
)
(a,b,c)
(a,b,c)的条件),故而在
c
c
c入栈之前一定不会发生:
a
a
a入栈,
a
a
a出栈,
b
b
b入栈,
a
a
a入栈,这一情况,故而,输出序列只能为:
(
c
,
b
,
a
)
(c,b,a)
(c,b,a)。
事实上,可以发现一些规律(
3
3
3个元素有点少,可能没那么明显)。当输出序列第一个为
c
c
c时,则在
(
a
,
b
)
(a,b)
(a,b)入栈的整个过程中,都不能出现出栈的操作,即,此时
(
a
,
b
)
(a,b)
(a,b)之后出栈的相对只有
(
b
,
a
)
(b,a)
(b,a)这一种情况。
当输出序列第一个为
b
b
b时:
当输出序列第一个为
b
b
b时,此时
(
a
,
c
)
(a,c)
(a,c)一定在栈内部,因为出栈后就不会再入栈,且,
c
c
c在
b
b
b之后入栈,此时就有
a
a
a入栈,
b
b
b入栈,
b
b
b出栈,
c
c
c入栈,
c
c
c出栈,
a
a
a出栈与
a
a
a入栈,
b
b
b入栈,
b
b
b出栈,
a
a
a出栈,
c
c
c入栈,
c
c
c出栈两种情况,此时,输出序列为
(
b
,
c
,
a
)
(b,c,a)
(b,c,a)和
(
b
,
a
,
c
)
(b,a,c)
(b,a,c)两种。
其中的规则同上,在
b
b
b出栈之前
b
b
b之前的元素都不能出栈,它们是被卡死的,而
c
c
c是在
b
b
b之后入栈的元素,故它可随意穿插在
b
b
b之前入栈的元素的整个出栈过程中。
当输出序列第一个为
a
a
a时:
当输出序列第一个为
a
a
a时,此时
(
b
,
c
)
(b,c)
(b,c)一定在栈内部,因为出栈后就不会再入栈,且,在
c
c
c在入栈之前
b
b
b是可以先出栈的,此时就有
a
a
a入栈,
a
a
a出栈,
b
b
b入栈,
c
c
c入栈,
c
c
c出栈,
b
b
b出栈与
a
a
a入栈,
a
a
a出栈,
b
b
b入栈,
b
b
b出栈,
c
c
c入栈,
c
c
c出栈两种情况,此时,输出序列为
(
a
,
c
,
b
)
(a,c,b)
(a,c,b)和
(
a
,
b
,
c
)
(a,b,c)
(a,b,c)两种。
从中也可以发现,由于
a
a
a是输出序列第一个,故而
a
a
a入栈后必须立即出栈,否则不会是第一个,而对于
(
b
,
c
)
(b,c)
(b,c),二者的顺序是随意的,并无相对顺序可言(这是因为
(
b
,
c
)
(b,c)
(b,c)都在
a
a
a之后,可随意穿插)。
②题目:
一个栈的入栈序列是a,b,c,d,e则栈的不可能的输出序列是:()
A edcba B decba C dceab D abcde
对于
A
A
A:第一个输出元素是
e
e
e,则
e
e
e输出时
(
a
,
b
,
c
,
d
)
(a,b,c,d)
(a,b,c,d)一定在栈内,且从未出栈,故此时输出序列只能为
(
e
,
d
,
c
,
b
,
a
)
(e,d,c,b,a)
(e,d,c,b,a),故正确。
对于
B
B
B:第一个输出元素是
d
d
d,则
d
d
d输出时
(
a
,
b
,
c
)
(a,b,c)
(a,b,c)一定在栈内,且从未出栈,而之后的
e
e
e可在
(
a
,
b
,
c
)
(a,b,c)
(a,b,c)输出的过程中随意入栈、出栈,即,
(
a
,
b
,
c
)
(a,b,c)
(a,b,c)输出的相对顺序一定是
(
c
,
b
,
a
)
(c,b,a)
(c,b,a),而
e
e
e可随意穿插,故正确。
对于
C
C
C:理由同上,故错误。
对于
D
D
D:理由同上,故正确。
3.7补充:判断队列输出序列合法性
对于普通队列,由于只能在队尾输入元素,在队头输出元素,故而输出序列并无争议性。
对于双端队列及其变体(输入/输出受限)队列,若一端同时能插入/删除,则就能模拟为一个栈使用,此时上文栈中输入/输出的所有序列都是合法的,之后只需要在栈不合法的序列当中进行判断即可。
对于双端队列:能输出序列的所有可能排列,故共
A
n
n
=
n
!
A_n^n=n!
Ann=n!种。
对于输入受限的双端队列(假设只能右侧输入):以输出序列为
(
4
,
2
,
1
,
3
)
(4,2,1,3)
(4,2,1,3)为例,不符合栈的合法序列,先输出
4
4
4,则说明
(
1
,
2
,
3
)
(1,2,3)
(1,2,3)都在队列当中,因为只能在右侧输入,故此时队列中存放顺序应为
(
1
,
2
,
3
,
4
)
(1,2,3,4)
(1,2,3,4),但两个方向进行输出的所有可能序列都不可能先输出
2
2
2,故不合法。
对于输出受限的双端队列(假设只能右侧输出):以输出序列为
(
4
,
1
,
3
,
2
)
(4,1,3,2)
(4,1,3,2)为例,不符合栈的合法序列,先输出
4
4
4,则说明
(
1
,
2
,
3
)
(1,2,3)
(1,2,3)都在队列当中,因为只能在右侧输出,故此时队列中存放顺序应为
(
2
,
3
,
1
)
(2,3,1)
(2,3,1),但两个方向进行输入的所有可能序列中都不会是
(
2
,
3
,
1
)
(2,3,1)
(2,3,1),故不合法。
注:对于受限双端队列输出序列种类数的判断,只需用 A n n − ( 1 / n + 1 ) C 2 n n A_n^n-(1/n+1)C^n_{2n} Ann−(1/n+1)C2nn计算得到所有不能用栈模拟的序列个数,并将这些序列进行输入/输出模拟,来进行判断是否合法即可。
3.8补充:关于Q.front与Q.rear初始化值的说明
关于
Q
.
f
r
o
n
t
Q.front
Q.front与
Q
.
r
e
a
r
Q.rear
Q.rear初始化值的设置在课后习题中反复出现,故此处加以说明。
a.Q.front指向队头元素,Q.rear指向队尾元素
Q.front=0;
Q.rear=n-1;
//此时入队一个元素后,Q.front=Q.rear=0,符合定义
//判空:(Q.rear+1)%MaxSize==Q.front;
//判满:(Q.rear+2)%MaxSize==Q.front;
//求队列大小:(Q.rear-Q.front+1+MaxSize)%MaxSize;
b.Q.front指向队头元素前一位,Q.rear指向队尾元素
Q.front=n-1;
Q.rear=n-1;
//此时入队一个元素后,Q.front=n-1,Q.rear=0,符合定义
//判空:Q.rear=Q.front;
//判满:(Q.rear+1)%MaxSize==Q.front;
//求队列大小:(Q.rear-Q.front+MaxSize)%MaxSize;
c.Q.front指向队头元素,Q.rear指向队尾元素后一位
Q.front=0;
Q.rear=0;
//此时入队一个元素后,Q.front=0,Q.rear=1,符合定义
//判空:Q.rear=Q.front;
//判满:(Q.rear+1)%MaxSize==Q.front;
//求队列大小:(Q.rear-Q.front+MaxSize)%MaxSize;
d.Q.front指向队头元素前一位,Q.rear指向队尾元素后一位
Q.front=n-1;
Q.rear=0;
//此时入队一个元素后,Q.front=n-1,Q.rear=1,符合定义
//判空:(Q.rear-1+MaxSize)%MaxSize=Q.front;
//判满:Q.rear==Q.front;
//求队列大小:(Q.rear-Q.front-1+MaxSize)%MaxSize;
总结:四种指向中,实际是假设入队一个元素后再判断初始化值的,这是因为入队操作改变的是 Q . r e a r Q.rear Q.rear的值,而不影响 Q . f r o n t Q.front Q.front, Q . f r o n t Q.front Q.front只需要在队列中有元素时指向正确即可。需要注意的是,这四种不同的写法会改变判空、判满与求队列大小的操作。
3.9总结
第四章:串(考纲一览)
4.1基本概念
- 串(字符串String):由零个或多个字符组成的有限序列,一般记为 S = " a 1 , a 2 , a 3 , . . . , a n " , ( n ≥ 0 ) S="a_1,a_2,a_3,...,a_n",(n≥0) S="a1,a2,a3,...,an",(n≥0)。
- 子串:串中任意个连续的字符组成的序列称为子串。
- 主串:包含子串的串相应地称为主串,通常子串在主串中的位置以子串的第一个字符在主串中的位置来表示。
- 空串: n = 0 n=0 n=0时的串,记为 " " "" ""。
- 空格串:由一个或多个空格组成的串,空格也是字符,故空格串 ≠ ≠ =空串。
- 模式匹配:子串的定位操作称为串的模式,它求的是子串(常称模式串)在主串中的位置(从 1 1 1开始)。
串和线性表的关系:串是特殊的线性表,数据元素之间呈线性关系(逻辑结构相似),且串的数据对象限定为字符集。
4.2朴素模式匹配算法
算法思想:分别用
i
i
i和
j
j
j指向主串
S
S
S和模式串
T
T
T中当前正在比较字符的位置,从主串
S
S
S的第一个字符开始,与模式串
T
T
T中第一个字符比较,若相等则逐个向后比较,否则从主串的下一个字符起重新与模式串的字符比较,以此类推,直至模式串
T
T
T中每个字符依次与主串
S
S
S中的一个连续的字符序列相等,则称匹配成功。函数返回值为与模式串
T
T
T中第一个字符相等的字符在主串
S
S
S中的序号,当匹配不成功时,返回
0
0
0。
int Index_BF(SString S,SString T){
int i=1;int j=1;
while(i<=S.length&&j<=T.length){
if(S.ch[i]==T.ch[j]){
i++;
j++;
}
else{
i=i-j+2; //指针回退至下一个字符的位置以重新开始匹配,i=(i-j+1)+1
j=1;
}
if(j>T.length)return i-T.length;//匹配成功
else return 0; //主串中不包含子串,返回0
}
}
a.最好时间复杂度
在最好的情况下,每趟不成功的匹配都发生在模式串的第一个字符与主串中相应字符的比较,以
S
=
"
a
a
a
a
a
b
c
"
,
T
=
"
b
a
"
S="aaaaabc",T="ba"
S="aaaaabc",T="ba"为例,第
1
1
1到
5
5
5趟,每一趟只需比较一次:
设主串的长度为
n
n
n,子串的长度为
m
m
m,假设从主串的第
i
i
i个位置开始与模式串匹配成功,则在前
(
i
−
1
)
(i-1)
(i−1)趟匹配中字符总共比较了
(
i
−
1
)
(i-1)
(i−1)次,总共的字符比较次数为
(
i
+
m
−
1
)
(i+m-1)
(i+m−1)次,而对于主串,其起始位置由
1
1
1到
(
n
−
m
+
1
)
(n-m+1)
(n−m+1),若这
(
n
−
m
+
1
)
(n-m+1)
(n−m+1)个位置上匹配成功的概率相等,则最好情况下匹配成功的平均比较次数为:
1
/
(
n
−
m
+
1
)
∑
i
=
1
n
−
m
+
1
(
i
+
m
−
1
)
=
(
n
+
m
)
/
2
1/(n-m+1)\sum_{i=1}^{n-m+1}(i+m-1)=(n+m)/2
1/(n−m+1)∑i=1n−m+1(i+m−1)=(n+m)/2
故而,最好情况下平均时间复杂度为
O
(
n
+
m
)
O(n+m)
O(n+m)。
b.最好时间复杂度
在最坏的情况下,每趟不成功的匹配都发生在模式串的最后一个字符与主串中相应字符的比较,如
S
=
"
a
a
a
a
a
b
c
"
,
T
=
"
b
a
"
S="aaaaabc",T="ba"
S="aaaaabc",T="ba"。
设主串的长度为
n
n
n,子串的长度为
m
m
m,假设从主串的第
i
i
i个位置开始与模式串匹配成功,则在前
(
i
−
1
)
(i-1)
(i−1)趟匹配中字符总共比较了
(
i
−
1
)
∗
m
(i-1)*m
(i−1)∗m次,总共的字符比较次数为
(
i
m
−
m
+
m
)
=
i
m
(im-m+m)=im
(im−m+m)=im次,而对于主串,其起始位置由
1
1
1到
(
n
−
m
+
1
)
(n-m+1)
(n−m+1),若这
(
n
−
m
+
1
)
(n-m+1)
(n−m+1)个位置上匹配成功的概率相等,则最坏情况下匹配成功的平均比较次数为:
1
/
(
n
−
m
+
1
)
∑
i
=
1
n
−
m
+
1
(
i
m
)
=
m
(
n
−
m
+
2
)
/
2
1/(n-m+1)\sum_{i=1}^{n-m+1}(im)=m(n-m+2)/2
1/(n−m+1)∑i=1n−m+1(im)=m(n−m+2)/2
故而,最坏情况下平均时间复杂度为
O
(
n
m
)
O(nm)
O(nm)。
4.3KMP算法
前置概念
- 字符串的前缀:除最后一个字符外,字符串的所有头部子串。
- 字符串的后缀:除第一个字符外,字符串的所有尾部子串。
- 部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
举例说明:
- ′ a ′ 'a' ′a′的前缀后后缀均为空集,故最长相等前后缀长度为 0 0 0。
- ′ a b ′ 'ab' ′ab′的前缀集合为{ ′ a ′ {'a'} ′a′},后缀集合为{ ′ b ′ {'b'} ′b′},因为{ ′ a ′ {'a'} ′a′} ∩ ∩ ∩{ ′ b ′ {'b'} ′b′} = = = ∅ ∅ ∅,故最长相等前后缀长度为 0 0 0。
- ′ a b a ′ 'aba' ′aba′的前缀集合为{ ′ a ′ , ′ a b ′ {'a','ab'} ′a′,′ab′},后缀集合为{ ′ a ′ , ′ b a ′ {'a','ba'} ′a′,′ba′},因为{ ′ a ′ , ′ a b ′ {'a','ab'} ′a′,′ab′}∩{ ′ a ′ , ′ b a ′ {'a','ba'} ′a′,′ba′}={ ′ a ′ {'a'} ′a′},故最长相等前后缀长度为 1 1 1。
- ′ a b a b ′ 'abab' ′abab′的前缀集合为{ ′ a ′ , ′ a b ′ , ′ a b a ′ {'a','ab','aba'} ′a′,′ab′,′aba′},后缀集合为{ ′ b ′ , ′ a b ′ , ′ b a b ′ {'b','ab','bab'} ′b′,′ab′,′bab′},因为{ ′ a ′ , ′ a b ′ , ′ a b a ′ {'a','ab','aba'} ′a′,′ab′,′aba′}∩{ ′ b ′ , ′ a b ′ , ′ b a b ′ {'b','ab','bab'} ′b′,′ab′,′bab′}={ ′ a b ′ {'ab'} ′ab′},故最长相等前后缀长度为 2 2 2。
- ′ a b a b a ′ 'ababa' ′ababa′的前缀集合为{ ′ a ′ , ′ a b ′ , ′ a b a ′ , ′ a b a b ′ {'a','ab','aba','abab'} ′a′,′ab′,′aba′,′abab′},后缀集合为{ ′ a ′ , ′ b a ′ , ′ a b a ′ , ′ b a b a ′ {'a','ba','aba','baba'} ′a′,′ba′,′aba′,′baba′},因为{ ′ a ′ , ′ a b ′ , ′ a b a ′ , ′ a b a b ′ {'a','ab','aba','abab'} ′a′,′ab′,′aba′,′abab′}∩{ ′ a ′ , ′ b a ′ , ′ a b a ′ , ′ b a b a ′ {'a','ba','aba','baba'} ′a′,′ba′,′aba′,′baba′}={ ′ a ′ , ′ a b a ′ {'a','aba'} ′a′,′aba′},故最长相等前后缀长度为 3 3 3。
算法思想:每一趟匹配过程中出现字符比较不等时,不需要回溯指针
i
i
i,而是利用已经得到的部分匹配的结果,向右滑动一段尽可能较远的距离(移动位数=已匹配的字符数-前一个匹配字符对应的部分匹配值)后,再进行比较。
例子:
以主串
S
S
S为
′
a
b
a
b
c
a
b
c
a
c
b
a
b
′
'ababcabcacbab'
′ababcabcacbab′,模式串
T
T
T为
‘
a
b
c
a
c
‘
`abcac`
‘abcac‘为例,子串的部分匹配值数组为:
T | a | b | c | a | c |
---|---|---|---|---|---|
部分匹配值 | 0 | 0 | 0 | 1 | 0 |
匹配过程:
①第一趟匹配
发现
c
c
c与
a
a
a不匹配,但前面
2
2
2个字符
′
a
b
′
'ab'
′ab′是匹配的,由于最后一个匹配的字符
′
b
′
'b'
′b′的部分匹配数为
0
0
0,故而移动位数
=
2
−
0
=
2
=2-0=2
=2−0=2,故将子串向后移动
2
2
2位,进行第二趟匹配。
②第二趟匹配
发现
b
b
b与
c
c
c不匹配,但前面
4
4
4个字符
′
a
b
c
a
′
'abca'
′abca′是匹配的,由于最后一个匹配的字符
′
a
′
'a'
′a′的部分匹配数为
1
1
1,故而移动位数
=
4
−
1
=
3
=4-1=3
=4−1=3,故将子串向后移动
3
3
3位,进行第三趟匹配。
③第三趟匹配
子串全部比较完成,匹配成功。
时间复杂度:整个匹配过程中指针
i
i
i始终没有回溯,故
K
M
P
KMP
KMP算法可在
O
(
n
+
m
)
O(n+m)
O(n+m)的数量级上完成串的模式匹配操作。
对于以上操作,每次匹配失败时都要去找它前一个元素的部分匹配值,这样并不方便,可将 P M PM PM表右移一位,并将第一个元素设置为 − 1 -1 −1(因为若是第一个元素匹配失败,则需将子串右移 0 − ( − 1 ) = 1 0-(-1)=1 0−(−1)=1位),从而得到 n e x t next next数组:
T | a | b | c | a | c |
---|---|---|---|---|---|
next | -1 | 0 | 0 | 0 | 1 |
由于模式串的比较指针
j
j
j所会倒退到的位置的计算公式为:
j
=
j
−
(
(
j
−
1
)
−
n
e
x
t
[
j
]
)
=
n
e
x
t
[
j
]
+
1
j=j-((j-1)-next[j])=next[j]+1
j=j−((j−1)−next[j])=next[j]+1
故将
n
e
x
t
next
next数组的值全部
+
1
+1
+1,从而得到子串指针的变换公式
j
=
n
e
x
t
[
j
]
j=next[j]
j=next[j],以及最终的
n
e
x
t
next
next数组:
T | a | b | c | a | c |
---|---|---|---|---|---|
next | 0 | 1 | 1 | 1 | 2 |
此时, n e x t [ j ] next[j] next[j]的含义为,当子串的第 j j j个字符与主串发生失配时,则跳到子串的 n e x t [ j ] next[j] next[j]位置重新与主串当前位置作比较。
4.3改进的KMP算法
4.4next数组与nextval数组求法及匹配过程分析
前面写了这么多真的很累,下面介绍两种数组的快速求法,毕竟考试还是要以拿分为主。
4.4.1next数组求法及匹配过程分析
- 求出模式串的部分匹配值数组。
- 将部分匹配值数组整体右移一位,第一位赋为 − 1 -1 −1再全部 + 1 +1 +1。
例,主串 T = ′ a b a a b a a b c a b a a b c ′ T='abaabaabcabaabc' T=′abaabaabcabaabc′,模式串串 S = ‘ a b a a b c ’ S= ‘abaabc’ S=‘abaabc’的部分匹配值数组为:
位序 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
S | a | b | a | a | b | c |
部分匹配值 | 0 | 0 | 1 | 2 | 3 | 1 |
整体右移一位,第一位赋为 − 1 -1 −1再全部 + 1 +1 +1,得到 n e x t next next数组:
位序 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
S | a | b | a | a | b | c |
next值 | 0 | 1 | 1 | 2 | 2 | 3 |
注,此处的 + 1 +1 +1是因为位序从 1 1 1开始,若从 0 0 0开始,则不需要 + 1 +1 +1,则 n e x t next next数组的值为: − 1 , 0 , 0 , 1 , 1 , 2 -1,0,0,1,1,2 −1,0,0,1,1,2,即:
位序 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
S | a | b | a | a | b | c |
next值 | -1 | 0 | 0 | 1 | 1 | 2 |
n e x t [ j ] next[j] next[j]的含义为,当子串的第 j j j个字符与主串发生失配时,则子串指针 j j j跳到子串的 n e x t [ j ] next[j] next[j]位置重新与主串当前位置作比较(需要注意位序是从 0 0 0开始还是从 1 1 1开始,以上 n e x t next next数组是位序从 1 1 1开始,若是从 0 0 0开始,则需全部再 − 1 -1 −1)( n e x t next next数组中第一位为 0 0 0或 − 1 -1 −1,表示当子串第一位就不匹配时,需将模式串整体后移1位,主串指针 i i i进行 + 1 +1 +1,以重新开始比较)。
4.4.2nextval数组求法及匹配过程分析
- 求出模式串的 n e x t next next数组。
- n e x t next next数组第 1 1 1位与 n e x t next next数组相同,从第二位开始,依次判断该位置的 n e x t next next值对应的元素与该位置元素是否相同,若相同,该位置的 n e x t v a l nextval nextval的值就是该位置 n e x t next next值对应元素的 n e x t v a l nextval nextval的值;若不同,该位置的 n e x t v a l nextval nextval的值就是该位置的 n e x t next next值。
例:
当位序从
1
1
1开始时,将
n
e
x
t
next
next数组、
n
e
x
t
v
a
l
nextval
nextval数组的所有值都
−
1
-1
−1即可: