【数据结构与算法】Part02——线性表
零、知识回顾
一、线性表
1.1 线性表的概念
线性表: 通常定义为
n
(
n
≥
0
)
n(n\geq 0)
n(n≥0) 个具有相同特性的数据元素的一个有限序列. 记为:
L
=
(
a
1
,
a
2
,
.
.
.
,
a
i
,
a
i
+
1
,
.
.
.
,
a
n
)
L=(a_{1},a_{2},...,a_{i},a_{i+1},...,a_{n})
L=(a1,a2,...,ai,ai+1,...,an)其中,
L
L
L 是表名,
a
i
a_{i}
ai 是表中的数据元素,是不可再分割的原子数据,亦称为结点或表项.
n
n
n 是表中表项的个数,也称为表的长度. 若
n
=
0
n=0
n=0,则称为空表.
线性表存在唯一的第一个表项(称为表头或起始结点) 和 最后一个表项(称为表尾或终端结点). 除表头外,其他表项有且仅有一个直接前驱,表头没有前驱;除表尾外,其他表项有且仅有一个直接后继,表尾没有后继.
👉 线性表的例子
- 26个英文字母组成的英文表:
( A , B , . . . , Y , Z ) (A, B, ... , Y, Z) (A,B,...,Y,Z) - 某单位历年拥有计算机的数量:
( 26 , 35 , 42 , 46 , 52 , 64 , 70 , 75 ) (26, 35, 42, 46, 52, 64, 70, 75) (26,35,42,46,52,64,70,75)
1.2 案例引入
👉 案例 01:一元多项式的运算:实现两个多项式的加、减、乘运算.
对于一个
n
n
n 阶多项式
P
n
(
x
)
P_{n}(x)
Pn(x):
P
n
(
x
)
=
p
0
+
p
1
x
+
p
2
x
2
+
.
.
.
+
p
n
x
n
P_{n}(x)=p_{0}+p_{1}x+p_{2}x^2+...+p_{n}x^n
Pn(x)=p0+p1x+p2x2+...+pnxn我们可以用线性表
P
P
P 来表示:
P
=
(
p
0
,
p
1
,
p
2
,
.
.
.
,
p
n
)
P=(p_{0},p_{1},p_{2},...,p_{n})
P=(p0,p1,p2,...,pn)每一项的次数
i
i
i 隐含在其系数
p
i
p_{i}
pi 的序号中:
对应下标 | 0 | 1 | 2 | … | n |
---|---|---|---|---|---|
系数 | p 0 p_{0} p0 | p 1 p_{1} p1 | p 2 p_{2} p2 | … | p n p_{n} pn |
次数 | 0 | 1 | 2 | … | n |
进而对于多项式的加法:
R
n
(
x
)
=
P
n
(
x
)
+
Q
m
(
x
)
(
n
>
m
)
R_{n}(x)=P_{n}(x)+Q_{m}(x)(n>m)
Rn(x)=Pn(x)+Qm(x)(n>m)我们可以用线性表表示为:
R
=
(
p
0
+
q
0
,
p
1
+
q
1
,
p
2
+
q
2
,
.
.
.
,
p
m
+
q
m
,
p
m
+
1
,
p
n
)
R=(p_{0}+q_{0},p_{1}+q_{1},p_{2}+q_{2},...,p_{m}+q_{m},p_{m+1},p_{n})
R=(p0+q0,p1+q1,p2+q2,...,pm+qm,pm+1,pn)
👉 案例 02:稀疏多项式的运算:
S
(
x
)
=
2
+
5
x
10000
+
7
x
20000
S(x)=2+5x^{10000}+7x^{20000}
S(x)=2+5x10000+7x20000若按照上述方法保存在线性表中,必然造成巨大的空间浪费,因此我们可以按照如下表格去建立线性表:
对应下标 | 0 | 1 | 2 |
---|---|---|---|
系数 | 2 | 5 | 7 |
次数 | 0 | 10000 | 20000 |
即只存系数不为零的项对应的系数,此时对应的线性表
S
S
S 为:
S
=
(
(
2
,
0
)
,
(
5
,
10000
)
,
(
7
,
20000
)
)
S=((2,0),(5,10000),(7,20000))
S=((2,0),(5,10000),(7,20000))即对于稀疏多项式:
P
n
(
x
)
=
p
1
x
e
1
+
p
2
x
e
2
+
.
.
.
+
p
m
x
e
m
P_{n}(x)=p_{1}x^{e_{1}}+p_{2}x^{e_{2}}+...+p_{m}x^{e_{m}}
Pn(x)=p1xe1+p2xe2+...+pmxem我们可以用如下形式的线性表表示:
P
=
(
(
p
1
,
e
1
)
,
(
p
2
,
e
2
)
,
.
.
.
,
(
p
m
,
e
m
)
)
P=((p_{1},e_{1}),(p_{2},e_{2}),...,(p_{m},e_{m}))
P=((p1,e1),(p2,e2),...,(pm,em))
对于稀疏多项式的加法运算:
若存在两个稀疏多项式对应的线性表
A
,
B
A,B
A,B:
A
=
(
(
7
,
0
)
,
(
3
,
1
)
,
(
9
,
8
)
,
(
5
,
17
)
)
B
=
(
(
8
,
10
)
,
(
22
,
7
)
,
(
−
9
,
8
)
)
A=((7,0),(3,1),(9,8),(5,17)) \\ B=((8,10),(22,7),(-9,8))
A=((7,0),(3,1),(9,8),(5,17))B=((8,10),(22,7),(−9,8))求二者相加结果的步骤为:
- 创建一个新数组 C C C(对于 C C C 的大小);
- 分别从头遍历比较
A
A
A 和
B
B
B 的每一项
若指数相同 ,对应系数相加,若其和不为零,则在 C C C 中增加一个新项;
若指数不相同 ,则将指数较小的项复制到 C C C 中; - 若一个多项式已遍历完毕,将另一个剩余项依次复制到 C C C 中即可.
此时,我们有这样的疑问:数组 C C C 应该设置为多大?
若采用 顺序存储结构 ,则有如下问题:
- 存储空间分配不灵活
- 运算的空间复杂度高
因此我们可以使用 链式存储结构 :
(贴图)
此时便不需要额外的空间
👉 案例 03:图书信息管理系统:
ISBN | 书名 | 定价 |
---|---|---|
123456 | 程序设计基础 | 35 |
123457 | 数据库原理 | 32 |
123458 | 机器学习 | 60 |
123459 | 多元统计分析 | 42 |
123460 | 数据结构与算法 | 53 |
… | … | … |
需要的功能:
\quad
查找、插入、删除、修改、排序、计数
图书表抽象为线性表
表中每本图书抽象为线性表中的数据元素
该系统可以用线性表或者链表.
根据实际情况:
- 选择适当的存储结构
- 实现此存储结构上的基本操作
- 利用基本操作完成功能
👉 总结:
- 线性表中数据元素的类型可以为 简单类型 ,也可以为 复杂类型 .
- 许多实际应用所涉及的基本操作有很大相似性,不应为每个具体应用单独编写一个程序.
- 从具体应用中抽象出共性的 逻辑结构和基本操作(抽象数据类型) ,然后实现其 存储结构和基本操作
1.3 线性表的类型定义
👉 抽象数据类型线性表的定义:
ADT List{
数据对象:D = {a_i | a_i属于Elemset, (i=1,2,...,n,n>=0)}
数据关系:R = {<a_{i-1},a_i> | a_{i-1},a_i属于D, (i=2,3,...,n)}
基本操作:
InitList(&L); // 构造一个空的线性表L
DestroyList(&L); // 线性表L已存在;销毁线性表L
ClearList(&L); // 线性表L已存在;将线性表L重置为空表
ListEmpty(L);
// 线性表L已存在;若L为空,返回True,否则返回False
ListLength(L); // 线性表L已存在;返回线性表L中的数据元素个数
GetElem(L,i,&e);
// 线性表L已存在,1<=i<=ListLength(L);用e返回L中第i个数据元素的值
LocateElem(L,e,compare());
// 线性表L已存在,compare()是数据元素的判定函数;
// 返回与e满足compare()的数据元素的位序,若不存在则返回0
PriorElem(L,cur_e,&pre_e);
// 线性表L已存在;
// 若cur_e是L的数据元素且不为表头,则用pre_e返回其前驱,否则操作失败
NextElem(L,cur_e,&next_e);
// 线性表L已存在;
// 若cur_e是L的数据元素且不为表尾,则用next_e返回其后继,否则操作失败
ListInsert(&L,i,e);
// 线性表L已存在,1<=i<=ListLength(L)+1;
// 在L的第i个位置前插入新的数据元素e,L的长度加1
ListDelete(&L,i,&e);
// 线性表L已存在,1<=i<=ListLength(L);
// 删除L的第i个数据元素,并用e返回其值,L的长度减1
ListTraverse(&L,visited())
// 线性表L已存在;依次对线性表中每个元素调用visited()
...
}ADT List
上述所提及的运算都是 逻辑结构 上定义的运算. 只需要给出这些运算是“做什么”,至于“如何做”等实现细节,只有待确定了存储结构之后才考虑.
二、顺序存储(顺序表)
2.0 线性表的存储结构
在计算机内,线性表有两种基本的存储结构: 顺序存储结构 和 链式存储结构
2.1 线性表的顺序存储表示
线性表的顺序表示又称为 顺序存储结构 或 顺序映像
👉 顺序存储定义:把逻辑上相邻的数据元素存储在物理上相邻的存储单元中的存储结构.
即,逻辑上相邻,物理上也相邻.
线性表顺序存储结构 占用一片连续的存储空间 . 知道某个元素的存储位置就可以计算其他元素的存储位置.
👉 顺序表的特点:以物理位置相邻表示逻辑关系. 任一元素均可随机存取.
顺序表
=
{
地址连续
依次存放
随机存取
类型相同
顺序表=\begin{cases} 地址连续 \\ 依次存放 \\ 随机存取 \\ 类型相同 \\ \end{cases}
顺序表=⎩
⎨
⎧地址连续依次存放随机存取类型相同由此,我们联想到 数组 ,即我们可以用一维数组来表示顺序表.
但是线性表可以调整表长(比如:插入、删除),但是数组长度不可以动态定义.
一维数组的定义方式:
类型说明符 数组名[常量表达式]
说明:常量表达式中可以包含常量和符号常量,不能包含变量. 即C++语言中不允许对数组的大小作动态定义.
基于上述情况,我们可以 用一个变量来表示顺序表的长度属性 .
# define LIST INIT SIZE 100
typedef struct{
ElemType elem[LIST INIT SIZE];
int length; // 当前长度
}SqList;
👉 多项式的顺序存储结构类型定义:
对于多项式:
P
n
(
x
)
=
p
1
x
e
1
+
p
2
x
e
2
+
.
.
.
+
p
m
x
e
m
P_{n}(x)=p_{1}x^{e_{1}}+p_{2}x^{e_{2}}+...+p_{m}x^{e_{m}}
Pn(x)=p1xe1+p2xe2+...+pmxem我们可以用如下形式的线性表表示:
P
=
(
(
p
1
,
e
1
)
,
(
p
2
,
e
2
)
,
.
.
.
,
(
p
m
,
e
m
)
)
P=((p_{1},e_{1}),(p_{2},e_{2}),...,(p_{m},e_{m}))
P=((p1,e1),(p2,e2),...,(pm,em))那么我们可以用如下方式来定义该线性表:
# define MAXSIZE 1000 // 多项式可能达到的最大长度
typedef struct{ // 多项式非零项的定义
float p; // 系数
int e; // 指数
}Polynomial;
typedf struct{
Polynomial *elem; // 存储空间的基地址
int length; // 多项式中当前项的个数
}SqList; // 多项式的顺序存储结构类型为SqList
👉 顺序表示意图:
对于顺序表的定义:
#define MAXSIZE 100
typedef struct{
ElemType *elem;
int length;
}SqList; // 定义顺序表类型
其中:
SqList L; // 定义变量L,L是SqList这种类型的,L是个顺序表
2.2 顺序表基本操作的实现
线性表的基本操作:
InitList(&L); // 构造一个空的线性表L
DestroyList(&L); // 线性表L已存在;销毁线性表L
ClearList(&L); // 线性表L已存在;将线性表L重置为空表
ListEmpty(L);
// 线性表L已存在;若L为空,返回True,否则返回False
ListLength(L); // 线性表L已存在;返回线性表L中的数据元素个数
GetElem(L,i,&e);
// 线性表L已存在,1<=i<=ListLength(L);用e返回L中第i个数据元素的值
LocateElem(L,e,compare());
// 线性表L已存在,compare()是数据元素的判定函数;
// 返回与e满足compare()的数据元素的位序,若不存在则返回0
PriorElem(L,cur_e,&pre_e);
// 线性表L已存在;
// 若cur_e是L的数据元素且不为表头,则用pre_e返回其前驱,否则操作失败
NextElem(L,cur_e,&next_e);
// 线性表L已存在;
// 若cur_e是L的数据元素且不为表尾,则用next_e返回其后继,否则操作失败
ListInsert(&L,i,e);
// 线性表L已存在,1<=i<=ListLength(L)+1;
// 在L的第i个位置前插入新的数据元素e,L的长度加1
ListDelete(&L,i,&e);
// 线性表L已存在,1<=i<=ListLength(L);
// 删除L的第i个数据元素,并用e返回其值,L的长度减1
ListTraverse(&L,visited())
// 线性表L已存在;依次对线性表中每个元素调用visited()
补充:操作算法中用到的预定义常量和类型
// 函数结果状态代码
#define TRUE 1
#define FALSE 0
#define OK 1
#define ERROR 0
#define INFEASIBLE -1
#define OVERFLOW -2
// 是函数的类型,其值是函数结果是状态代码
typedef int Status
typedef char ElemType
👉 算法01:线性表L的初始化(参数用引用)
Status InitList Sq(SqList &L){ // 构造一个空的顺序表L
L.elem = new ElemType[MAXSIZE]; // 为顺序表分配空间
if(!L.elem) exit(OVERFLOW); // 存储分配失败
L.length = 0; // 空表长度为0
return OK;
}
👉 算法02:销毁线性表L
void DestroyList(SqList &L){
if(L.elem) delete L.elem; // 释放存储空间
}
👉 算法03:清空线性表L
void ClearList(SqList &L){
L.length = 0; // 将线性表的长度置为0
}
👉 算法04:求线性表L的长度
int GetLength(SqList L){
return (L.length);
}
👉 算法05:判断线性表L是否为空
int IsEmpty(SqList L){
if(L.length == 0) return 1;
else return 0;
}
👉 算法06:顺序表的取值(根据位置 i 获取相应位置数据元素的内容)
int GetElem(SqList L, int i, ElemType &e){
if(i <1 || i > L.length) return ERROR; // 判断i值是否合理,若不合理,返回ERROR
e = L.elem[i-1]; // 第 i-1 的单元存储着第 i 个元素
return OK;
}
👉 算法07:顺序表的查找
⋅
\cdot
⋅ 从线性表 L 中查找与指定值 e 相同的数据元素的位置
⋅
\cdot
⋅ 从表的一端开始,逐个进行记录的关键字和给定值的比较. 若找到,返回该元素的位置序号,未找到,返回 0.
int LocateElem(SqList L, ElemType e){
// 在线性表L中查找值为e的数据元素,返回其序号
for(i = 0; i < L.length; i++)
if(L.elem[i] == e) return i+1; // 查找成功,返回序号
return 0; // 查找失败,返回0
}
顺序表的查找算法分析
平均查找长度 ASL(Average Search Length):
为确定记录在表中的位置,需要与给定值进行比较的关键字的个数的期望值叫做查找算法的平均查找长度.
对含有 n n n 个记录的表,查找成功时:
A S L = ∑ i = 1 n P i C i ASL=\sum_{i=1}^nP_{i}C_{i} ASL=i=1∑nPiCi其中, P i P_{i} Pi 是第 i i i 个记录被查找的概率, C i C_{i} Ci 是找到第 i i i 个记录需要比较的次数.
👉 算法08:顺序表的插入
顺序表的插入运算是指在表的第
i
(
1
≤
i
≤
n
+
1
)
i(1\leq i \leq n+1)
i(1≤i≤n+1) 个位置上,插入一个新结点
e
e
e,使长度为
n
n
n 的线性表
(
a
1
,
.
.
.
,
a
i
−
1
,
a
i
,
.
.
.
,
a
n
)
(a_{1},...,a_{i-1},a_{i},...,a_{n})
(a1,...,ai−1,ai,...,an) 变成长度为
n
+
1
n+1
n+1 的线性表
(
a
1
,
.
.
.
,
a
i
−
1
,
e
,
a
i
,
.
.
.
a
n
)
(a_{1},...,a_{i-1},e,a_{i},...a_{n})
(a1,...,ai−1,e,ai,...an).
算法思想:
- 判断插入位置 i i i 是否合法;
- 判断顺序表的存储空间是否已满,若已满,返回 ERROR;
- 将第 n n n 至第 i i i 位的元素依次向后移动一个位置,空出第 i i i 个位置;
- 将要插入的新元素 e e e 放入第 i i i 个位置;
- 表长加 1,插入成功返回 OK.
算法实现:
Status ListInsert_Sq(SqList &L, int i, ElemType e){
if(i < 1 || i > L.length+1) return ERROR;
if(L.length == MAXSIZE) return ERROR;
for(j = L.length-1; j >= i-1; j--)
L.elem[j+1] = L.elem[j];
L.elem[i-1] = e;
L.length++;
return OK;
}
顺序表的插入算法分析
算法时间主要耗费在 移动元素 的操作上:
- 若插入在尾结点之后,则根本无需移动 (特别快).
- 若插入在首结点之前,则表中元素全部后移 (特别慢).
- 若要考虑在各种位置插入(共 n + 1 n+1 n+1 种可能) 的平均移动次数,则:
E i n s = 1 n + 1 ∑ i = 1 n + 1 ( n − i + 1 ) = 1 n + 1 n ( n + 1 ) 2 = n 2 E_{ins}=\frac{1}{n+1}\sum_{i=1}^{n+1}(n-i+1)=\frac{1}{n+1}\frac{n(n+1)}{2}=\frac{n}{2} Eins=n+11i=1∑n+1(n−i+1)=n+112n(n+1)=2n顺序表插入算法的平均时间复杂度为 O ( n ) O(n) O(n).
👉 算法09:顺序表的删除
顺序表的删除运算是指将表的第
i
(
1
≤
i
≤
n
)
i(1\leq i \leq n)
i(1≤i≤n) 个结点删除,使长度为
n
n
n 的线性表
(
a
1
,
.
.
.
,
a
i
−
1
,
a
i
,
a
i
+
1
,
.
.
.
,
a
n
)
(a_{1},...,a_{i-1},a_{i},a_{i+1},...,a_{n})
(a1,...,ai−1,ai,ai+1,...,an) 变成长度为
n
−
1
n-1
n−1 的线性表
(
a
1
,
.
.
.
,
a
i
−
1
,
a
i
+
1
,
.
.
.
,
a
n
)
(a_{1},...,a_{i-1},a_{i+1},...,a_{n})
(a1,...,ai−1,ai+1,...,an).
算法思想:
- 判断删除位置 i i i 是否合法(合法值为 1 ≤ i ≤ n 1\leq i \leq n 1≤i≤n).
- 将欲删除的元素保留在 e e e 中.
- 将第 i + 1 i+1 i+1 至第 n n n 位的元素依次向前移动一个位置.
- 表长减 1,删除成功返回 OK.
算法实现:
Status ListDelete Sq(SqList &L, int i){
if((i < 1) || (i > L.length)) return ERROR; // i值不合法
for(j = 1; j <= L.length-1; j++)
L.elem[j-1] = L.elem[j]; // 被删除元素之后的元素前移
L.length--; // 表长减1
return OK;
}
顺序表的删除算法分析
算法时间主要耗费在 移动元素 的操作上:
- 若删除尾结点,则根本无需移动 (特别快).
- 若删除首结点,则表中 n − 1 n-1 n−1 个元素全部前移 (特别慢).
- 若要考虑在各种位置删除(共 n n n 种可能) 的平均移动次数,则:
E d e l = 1 n ∑ i = 1 n ( n − i ) = 1 n n ( n − 1 ) 2 = n − 1 2 E_{del}=\frac{1}{n}\sum_{i=1}^{n}(n-i)=\frac{1}{n}\frac{n(n-1)}{2}=\frac{n-1}{2} Edel=n1i=1∑n(n−i)=n12n(n−1)=2n−1顺序表删除算法的平均时间复杂度为 O ( n ) O(n) O(n).
2.3 顺序表总结
👉 顺序表的特点
- 利用数据元素的存储位置表示线性表中相邻元素之间的前后关系,即线性表的逻辑结构与存储结构一致.
- 在访问线性表时,可以快速地计算出任何一个数据元素的存储位置. 因此可以粗略地认为,访问每个元素所花费的时间相等
这种存取元素的方法被称为 随机存取法
👉 顺序表的操作算法分析
时间复杂度
查找、插入、删除算法的平均时间复杂度为
O
(
n
)
O(n)
O(n).
空间复杂度
顺序表操作算法的空间复杂度为
S
(
n
)
=
O
(
1
)
S(n)=O(1)
S(n)=O(1) (没有占用辅助空间).
👉 顺序表的优缺点
优点:
- 存储密度大.
- 可以随机存取表中任一元素.
缺点:
- 在插入、删除某一元素时,需要大量移动元素.
- 浪费存储空间.
- 属于静态存储,数据元素的个数不能自由扩充.
三、链式存储(链表)
3.1 线性表链式存储概述
线性表的链式表示又称为 非顺序映像 或 链式映像.
👉 链式存储定义:
结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相邻.
- 用一组 物理位置任意的存储单元 来存放线性表的数据元素.
- 这组存储单元既可以是 连续 的,也可以是 不连续 的,甚至是零散分布在内存中的任意位置上的.
- 链表中元素的 逻辑次序和物理次序不一定相同.
👉 顺序表与链表对比实例:
存在一个线性表:(赵,钱,孙,李,周,吴,郑,王),分别用顺序表和链表存储的结构如下图所示:
如图,在链表中,每个结点不仅要存储数据,也要存储后继结点的地址.
👉 与链式存储有关的术语:
- 结点:数据元素的存储映像. 由数据域和指针域两部分组成.
- 链表: n n n 个结点由指针链组成的一个链表. 它是线性表的链式存储映像,称为线性表的链式存储结构.
- 单链表、双链表、循环链表:
单链表:结点只有一个指针域的链表,称为单链表或线性链表.
双链表:结点有两个指针域的链表,称为双链表.
循环链表:首尾相接的链表称为循环链表.
- 头指针、头结点和首元结点:
头指针:是指向链表的第一个结点的指针.
首元结点:是指链表中存储第一个数据元素 a 1 a_{1} a1 的结点.
头结点:是在链表的首元结点之前附设的一个结点.
链表的存储结构有以下两种形式:
- 不带头结点;
- 带头结点.
讨论 01:如何表示空表?
无头结点时,头指针为空时表示空表;
有头结点时,当头结点的指针域为空时表示空表.
讨论 02:在链表中设置头结点有什么好处?
- 便于首元结点的处理
首元结点的地址保存在头结点的指针域中,所以在链表的第一个位置上的操作和其他位置一致,无须进行特殊操作;- 便于空表和非空表的统一处理
无论链表是否为空,头指针都是指向头结点的非空指针,因此空表和非空表的处理也就统一了
讨论 03:头结点的数据域内装的是什么?
头结点的数据域可以为空,也可存放线性表长度等附加信息,但此结点不能计入链表长度值.
👉 链表的特点:
- 结点在存储器中的位置是任意的,即逻辑上相邻的数据元素在物理上不一定相邻.
- 访问时只能通过头指针进入链表,并通过每个结点的指针域依次向后顺序扫描其余结点(顺序存取),所以寻找第一个结点和最后一个结点所花费的时间不等.
3.2 单链表
👉 单链表的定义和表示
带头结点的单链表:
非空表:
空表:
单链表由表头唯一确定,因此单链表可以用头指针的名字来命名,若头指针是L,则把链表称为表L.
👉 单链表的存储结构
Typedef struct Lnode{
ElemType data; // 结点的数据域
struct Lnode *next; // 结点的指针域
}Lnode, *LinkList; // LinkList为指向结构体Lnode的指针类型
定义链表:
定义结点指针p:
例如,存储学生学号、姓名、成绩的单链表结点类型定义如下:
Typedef Struct student{
Char num[8]; // 数据域
char name[8]; // 数据域
int score; // 数据域
struct student *next; // 指针域
}Lnode, *LinkList;
为了统一链表的定义操作
👉 单链表基本操作的实现
单链表的初始化(带头结点的单链表):即构造一个如图的空表
算法步骤:
- 生成新结点作为头结点,用头指针L指向头结点
- 将头结点的指针域置空
算法描述:
Status initList_L(LinkList &L){
L = new LNode;
L->next = NULL;
return OK;
}
👉 补充单链表几个常用简单算法
补充算法1——判断链表是否为空
空表:链表中无元素,称为空链表(头指针和头结点仍然在)
算法思路:判断头结点指针域是否为空
Int ListEmpty(LinkLisk L){
If(L->next)
return 0;
else
return 1;
}
补充算法2——单链表的销毁:
链表销毁后不存在
算法思路:从头指针开始,依次释放所有结点
Status DestroyList_L(LinkList &L){
Lnode *p;
while(L){
P = L;
L = L->next;
delete p;
}
return OK;
}
补充算法3——清空链表 :
链表仍存在,但链表中无元素,成为空链表(头指针和头结点仍然在)
算法思路:依次释放所有结点,并将头结点指针域设置为空
Status ClearList(LinkList & L){
Lnode *p, *q;
p = L->next;
while(p){
q = p->next;
delete p;
p = q;
}
L->next = NULL;
return OK;
}
补充算法4——求单链表的表长:
算法思路:从首元结点开始,依次计数所有结点
Int ListLength_L(LinkList L){
LinkList p;
p = L->next; // p指向第一个结点
i = 0;
while(p){ // 遍历单链表,统计结点数
i++;
p = p->next;
}
}
算法:取值——取单链表中第i个元素的内容
思路:
从链表的头指针出发,顺着链域next逐个结点往下搜索,直至搜索到第i个结点为止。因此,链表不是随机存储结构.
算法步骤:
- 从第 1 个结点 (L->next) 顺链扫描,用指针 p 指向当前扫描到的结点,p 初值 p=L->next;
- j 作计数器,累计当前扫描过的结点数,j 初值为1;
- 当 p 指向扫描到的下一结点时,计数器 j 加 1;
- 当 j==1 时,p 所指的结点就是要找到的第 i 个结点.
算法描述:
Status GetElem_L(LinkList L, int I, ElemType &e){
P = L->next;
j = 1;
while(p && j<1){
P = p->next;
++j;
}
if(!p || j>i) Return ERROR;
e = p->data;
return OK;
}
3.3 循环链表
循环链表:是一种头尾相接的链表(即:表中最后一个结点的指针域指向头结点,整个链表形成一个环).
👉 优点:
从表中任一结点出发均可找到表中其他结点.
👉 注意:
由于循环链表中没有 NULL 指针,故涉及遍历操作时,其终止条件就不再像非循环链表那样判断 p 或 p->next 是否为空,而是判断它们是否等于头指针.
循环条件:
单链表:p != NULL p->next != NULL
单循环链表:
p != L; p->next != L
头指针表示单循环链表:
找
a
1
a_{1}
a1 的时间复杂度为:O(1)
找
a
n
a_{n}
an 的时间复杂度为:O(n)
注意: 表的操作常常是在表的首尾位置上进行
因此我们可以用尾指针表示单循环链表:
尾指针表示单循环链表:
a
1
a_{1}
a1 的存储位置是:R->next->next;
a
n
a_{n}
an 的存储位置是:R.
找
a
1
a_{1}
a1 和
a
n
a_{n}
an的时间复杂度都为:O(1)
👉 两个链表合并(带尾指针循环链表的合并):
问题描述:
有两个带尾指针的非空循环链表 Ta 和 Tb,设计算法将两者合并(将 Tb 合并在 Ta 之后):
步骤分析:
- p 存表头结点
- Tb 表头连接到 Ta 表尾
- 释放Tb表头结点
- 修改指针
算法描述:
LinkList Connect(LinkList Ta, LinkList Tb){
// Ta, Tb为非空循环链表
p = Ta->next; // 存头指针
Ta->next = Tb->next->next;
// Tb表头连接Ta表尾
delete Tb->next; // 释放Tb表头结点
Tb->next = p; // 修改指针
return Tb;
}
此算法时间复杂度为:O(1).
3.4 双向链表
👉 为什么讨论双向链表?
对于单链表:
单链表结点
⇒
\Rightarrow
⇒ 有指示后继的指针域
⇒
\Rightarrow
⇒ 找后继结点方便;
即:查找某结点的后继节点的执行时间为 O(1).
但是:
无指示前驱的指针域
⇒
\Rightarrow
⇒ 找前驱结点难:从表头出发查找;
即:查找某结点的前驱结点的执行时间为 O(n).
因此,我们可以用双向链表来克服单链表的这种缺点.
双向链表:
在单链表的每个结点里再 增加一个指向其直接前驱的指针域prior,这样链表中就形成了有两个方向不同的链,故称为 双向链表.
双向链表的结构定义:
根据图示,我们可以定义双向链表的结构如下:
typedef struct DuLNode{
Elemtype data;
struct DuLNode *prior, *next;
}DuLNode, *DuLinkList;
双向循环链表:
和单链的循环表类似,双向链表也可以有循环表:
- 让头结点的前驱指针指向链表的最后一个结点;
- 让最后一个结点的后继指针指向头结点
双向链表结构的对称性:
p->priot->next = p = p->next->prior
在双向链表中有些操作(如:ListLength, GetElem等),因仅涉及一个方向的指针,故它们的算法与线性链表的相同. 但在插入、删除时,则需要 同时修改两个方向上的指针,两者的操作时间复杂度均为 O(n).
👉 双向链表的插入操作:
算法描述:
void ListInsert_DuL(DuLinkList &L, int i, ElemType e){
// 在带头结点的双向循环链表L中第i个位置之前插入元素e
if(!(p=GetElemP_DuL(L,i))) return ERROR;
s = new DuLNode;
s-> date = e;
s->prior = p->prior;
p->prior->next = s;
s->next = p;
p->prior = s;
return OK;
}
👉 双向链表的删除操作:
算法描述:
void ListDelete_DuL(DuLink &L, inti, ElemType &e){
// 删除带头结点的双向循环链表L的第i个元素,并用e返回
if(!(p=GetElemP_DuL(L,i))) return ERROR;
e = p->data;
p->prior->next = p->next;
p->next->prior = p->prior;
free(p);
return OK;
}
3.5 链表总结
-
链式存储结构的 优点:
-
- 结点空间 可以 动态申请和释放;
-
- 数据元素的逻辑次序靠结点的指针来指示,插入和删除时不需要移动数据元素.
-
链式存储结构的 缺点:
-
- 存储密度小,每个结点的 指针域需额外占用存储空间. 当每个结点的数据域所占字节不多时,指针域所占存储空间的比重显得很大;
-
- 链式存储结构是 非随机存取 结构. 对任一结点的操作都要从头指针依指针连查找到该结点,这增加了算法的复杂度.
-
单链表、循环链表和双向链表的 时间效率 比较:
查找表头结点 | 查找表尾结点 | 查找结点 ∗ p *p ∗p 的前驱结点 | |
---|---|---|---|
带头结点的单链表 | L->next时间复杂度为O(1) | 从L->next依次向后遍历时间复杂度为O(n) | 通过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->prior时间复杂度为O(1) | p->prior时间复杂度为O(1) |
四、顺序表和链表的比较
比较项目 | 顺序表 | 链表 |
---|---|---|
存储空间 | 预先分配,会导致空间闲置或溢出现象 | 动态分配,不会出现存储空间闲置或溢出现象 |
存储密度 | 不用为表示结点间的逻辑关系而增加额外的存储开销,存储密度等于1 | 需要借助指针来体现元素间的逻辑关系,存储密度小于1 |
存取元素的时间 | 随机存取,按位置访问元素的时间复杂度为O(1) | 顺序存取,按位置访问元素时间复杂度为O(n) |
插入、删除的时间 | 平均移动约表中一般元素,时间复杂度为O(n) | 不需移动元素,确定插入、删除位置后,时间复杂度为O(1) |
适用情况 | 1. 表长变化不大,且能事先确定变化的范围;2. 很少进行插入和删除操作,经常按元素位置序号访问数据元素 | 1. 长度变化较大;2. 频繁进行插入或删除操作 |
五、线性表的应用
5.1 线性表的合并
👉 问题描述:
假设利用两个线性表 La 和 Lb 分别表示两个集合 A 和 B,现要求一个新的集合 A=A
∪
\cup
∪B:
👉 算法步骤:
依次取出 Lb 中的每个元素,执行以下操作:
- 在 La 中查找该元素;
- 如果找不到,则将其插入 La 最后.
👉 算法实现:
void union(List &La, List Lb){
La_len = ListLength(La); // 取长度
Lb_len = ListLength(Lb);
for(i=1;i<=Lb_len;i++){
GetElem(Lb, i, e); // 取出Lb中第i个元素
if(!LocateElem(La, e)) // 查找
ListInsert(&La, ++La_len, e);
}
}
5.2 有序表的合并
👉 问题描述:
已知线性表 La 和 Lb 中的数据元素按值非递减有序排列,现要求将 La 和 Lb 归并为一个新的线性表 Lc,且 Lc 中的元素仍按值非递减有序排列.
👉 算法步骤:
- 创建空表 Lc;
- 依次从 La 或 Lb 中“摘取”元素值较小的结点插入到 Lc 表的最后,直至其中一个表变为空为止;
- 继续将 La 或 Lb 其中一个表的剩余结点插入在 Lc 表的最后.
👉 通过线性表实现:
void MergeList_Sq(SqList La, SqList Lb, SqList &Lc){
pa = La.elem;
pb = Lb.elem;
// pa, pb 分别指向两个表的第一个元素
Lc.length = La.length + Lb.length;
Lc.elem = new ElemType[Lc.length];
// 为新表分配数组空间
pc = Lc.elem;
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++;
// 某个表到达表尾,将另一个表剩余元素加入Lc
}
该算法的性能分析:
时间复杂度:O( ListLength(La) + ListLength(Lb) )
空间复杂度:O( ListLength(La) + ListLength(Lb) )
👉 通过链式表实现:
void MergeList_L(LinkList &La, LinkList &Lb, LinkList &Lc){
pa = La->next;
pb = Lb->next;
pc = Lc = La; // 用La的头结点作为Lc的头结点
while(pa && pb){
if(pa->data <= pb->data){
pc->next = pa;
pc = pa;
pa = pa->next;
}
else{
pc->next = pa ? pa:pb;
}
}
pc->next = pa ? pa:pb;
// 插入剩余段
delete Lb; // 释放Lb的头结点
}
该算法的性能分析:
时间复杂度:O( ListLength(La) + ListLength(Lb) )
空间复杂度:O( 1 )
六、案例分析与实现
6.1【案例01】一元多项式的运算:实现两个多项式的加、减、乘运算
例如:
P
a
(
x
)
=
10
+
5
x
−
4
x
2
+
3
x
3
+
2
x
4
P
b
(
x
)
=
−
3
+
8
x
+
4
x
2
−
5
x
4
+
7
x
5
−
2
x
6
Pa(x)=10+5x-4x^2+3x^3+2x^4 \\ Pb(x)=-3+8x+4x^2-5x^4+7x^5-2x^6
Pa(x)=10+5x−4x2+3x3+2x4Pb(x)=−3+8x+4x2−5x4+7x5−2x6
我们可以用顺序表来存储每一项的系数,进而实现运算,如表所示:
结点个数 | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
系数pa[i] | 10 | 5 | -4 | 3 | 2 | 5 | ||||||
系数pb[i] | -3 | 8 | 4 | 0 | -5 | 7 | -2 | 7 | ||||
系数pc[i] | 7 | 13 | 0 | 3 | -3 | 7 | -2 | 7 |
具体代码略
6.2【案例02】稀疏多项式的运算
例如: A ( x ) = 7 + 3 x + 9 x 8 + 5 x 1 7 B ( x ) = 8 x + 22 x 7 − 9 x 8 A(x)=7+3x+9x^8+5x^17 \\ B(x)=8x+22x^7-9x^8 A(x)=7+3x+9x8+5x17B(x)=8x+22x7−9x8我们可以利用线性表,在每个结点处存储非零系数对应的项的次数和系数,进而实现运算:
如图所示,具体步骤为:
- 创建一个新数组 c
- 分别从头遍历比较 a 和 b 的每一项
若指数相同,对应系数相加,若其和不为零,则在 c 中增加一个新项
若指数不相同,则将指数较小的项复制到 c 中 - 一个多项式已遍历完毕时,将另一个剩余项依次复制到 c 中即可
由于顺序存储结构存在以下两个问题:
- 存储空间分配不灵活
- 运算的空间复杂度高
因此,我们选用链式存储结构:
typedef struct PNode{ float coef; // 系数 int expn; // 指数 struct PNode *next; // 指针域 }PNode, *Polynomial;
👉 多项式创建——算法步骤
- 创建一个只有头结点的空链表;
- 根据多项式的项的个数n,循环n次执行以下操作:
⋅ \cdot ⋅ 生成一个新结点 *s;
⋅ \cdot ⋅ 输入多项式当前项的系数和指数,赋给新节点 *s 的数据域;
⋅ \cdot ⋅ 设置一个前驱指针 pre,用于指向待找到的第一个大于输入项指数的结点的前驱;
⋅ \cdot ⋅ 指针 q 初始化,指向首元结点;
⋅ \cdot ⋅ 循链向下逐个比较链表中当前结点与输入项指数,找到第一个大于输入项指数的结点 *q;
⋅ \cdot ⋅ 将输入项结点 *s 插入到结点 *q 之前.
👉 多项式创建——算法描述
void CreatePolyn(Polynomial &P, int n){
P = new PNode;
P->next = NULL;
for(i=1;i<=n;++i){
s = new PNode;
cin >> s->coef >> s->expn;
pre = P;
q = P->next;
while(q && q->expn<s->expn){
pre = q;
q = q->next;
}
s->next = q;
pre->next = s;
}
}
6.3【案例03】图书信息管理
例如:图书管理系统包含三项信息:ISBN、书名、价格.
👉 图示说明
对于该系统,我们可以用顺序表或者链表来实现,具体图示如下:
👉 图书管理系统定义
struct Book{
char id[20]; // ISBN
char name[50]; // 书名
int price; // 定价
};
typedef struct{ // 顺序表
Book *elem;
int length;
}SqList;
typedef struct LNode{ // 链表
Book data;
struct LNode *next;
}LNode, *LinkList;
// 具体算法依据前文实现
七、课后习题
👉 在一个长度为 n n n 的顺序表中删除第 i i i 个元素时,需要向前移动 n − i \mathbf{n-i} n−i 个元素
👉 在一个长度为 n n n 的顺序表中向第 i i i 个元素之前插入一个新元素时,需要向后移动 n − i + 1 \mathbf{n-i+1} n−i+1 个元素
👉 线性表的顺序存储结构是一种 随机存取 的存储结构
👉 在顺序表中,只要知道基地址和结点大小,就可在相同时间内求出任一结点的存储地址
👉 在等概率的情况下,顺序表的插入操作要移动表长的 一半 结点
👉 顺序存储结构的优点是:存储密度大
👉 顺序表所占字节数与 元素存放顺序 无关
👉 对于顺序表,算法的时间复杂度为O(1)的运算是:修改第 i i i 个元素
👉 顺序表中,如果有4个数据,要删除第2个位置(从1开始数起)的数据,需要向前移动 2 个数据
👉 顺序表的长度是表中 数据元素的个数
👉 设顺序表L是一个递减有序表,试着写一个算法,将x插入其后仍保持L的有序性:
# include<bits/stdc++.h>
# define false 0
# define true 1
# define LIST_INIT_SIZE 100
# define LISTINCREMENT 10
typedef structure{
int *elem;
int length; // 当前长度
int listsize // 当前分配的存储容量
}SqList;
bool InitList(SqList &L){
L.listsize =
}
👉 线性表采用链式存储时,其地址 连续与否都可以
👉 从一个具有 n n n 个结点的单链表中查找其值等于 x x x 的结点时,在查找成功的情况下,需平均比较 ( n + 1 ) / 2 \mathbf{(n+1)/2} (n+1)/2 个元素结点