【数据结构】第二章 线性表
线性表的逻辑结构(定义)
线性表是具有相同数据类型的\(n\left( n\geqslant 0 \right)\)个数据元素的有限序列,其中\(n\)为表长,当\(n = 0\)时线性表是一个空表。若用\(L\)命名线性表,则其一般表示为 \[ L=\left( a_1, a_2, \dots , a_i, a_{i+1}, \dots , a_n \right). \]
【注意】
相同数据类型:线性表中元素的类型必须一致
有限序列:必须是有限的、区分先后顺序的序列
e.g.:所有的整数按递增次序排列就不是线性表
几个概念:
\(i\)是线性表中的“第\(i\)个”元素在线性表中的位序
\(a_1\)是表头元素;\(a_n\)是表尾元素
除第一个元素外,每个元素有且仅有一个直接前驱
除最后一个元素外,每个元素有且仅有一个直接后继
【注意】
位序从1开始,数组下标从0开始。
线性表的基本操作
为什么要实现对数据结构的基本操作?
- 团队合作编程,你定义的数据结构要让别人能够很方便的使用(封装)
- 将常用的操作/运算封装成函数,避免重复工作,降低出错风险
- 创建与销毁
InitList(&L)
:初始化表。构造一个空的线性表L,分配内存空间。DestroyList(&L)
:销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
- 增、删
ListInsert(&L,i,e)
:插入操作。在表L中的第i个位置上插入指定元素e。ListDelete(&L,i,&e)
:删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
- 查
LocateElem(L,e)
:按值查找操作。在表L中查找具有给定关键字值的元素。GetElem(L,i)
:按位查找操作。获取表L中第i个位置的元素的值。
- 其他常用操作
Length(L)
:求表长。返回线性表L的长度,即L中数据元素的个数。PrintList(L)
:输出操作。按前后顺序输出线性表L的所有元素值。Empty(L)
:判空操作。若L为空表,则返回true
,否则返回false
。
说明:
对数据的操作——创销、增删查
C语言函数的定义:
返回值类型 函数名(参数1类型 参数1, 参数2类型 参数2, ...)
为什么这里没有说明各个参数的具体类型?
这是因为这里的类型可以根据实际需要来确定,这里仅给出抽象的数据结构。
实际开发中,可根据实际需求定义其他的基本操作
函数名和参数的形式、命名都可改变,但需要具有可读性(Ref:严蔚敏版《数据结构》)
什么时候要传入引用
&
?——对参数的修改结果需要“带回来”在线实验工具:C++ 在线工具 | 菜鸟工具 (runoob.com)
小结

线性表的物理结构
顺序表:线性表的顺序表示
在顺序表中,把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

由于线性表中的元素具有相同数据类型,各个元素在内存中的占用空间大小相同,而在顺序表中各个元素又按逻辑顺序依次连续存放,故可以采用访问LOC(L)+(i-1)*sizeof(ElemType)
(首元素地址+第i
个元素的位置偏移量)地址的方式来访问顺序表中的任意的第i
个元素。
实现方式
静态分配实现
#define MaxSize 10 // 定义最大长度
typedef struct{
ElemType data[MaxSize]; // 用静态的“数组”存放数据元素
int length; // 顺序表的当前长度
} SqList; // 顺序表的类型定义(静态分配方式)
// Sq: Sequence,顺序,序列
声明SqList
时,系统会给各个数据元素分配连续的存储空间,大小为MaxSize*sizeof(ElemType)
。
Q:如果“数组”存满了怎么办?
A:可以放弃治疗,顺序表的表长刚开始确定后就无法更改(存储空间是静态的)
动态分配实现
#define InitSize 10 // 顺序表的初始长度
typedef struct{
ElemType *data; // 指示动态分配数组的指针
int MaxSize; // 顺序表的最大容量
int length; // 顺序表的当前长度
} SeqList; // 顺序表的类型定义(动态分配方式)
动态申请和释放内存空间
C语言:
malloc
、free
函数malloc
函数用于申请一整片连续的存储空间,返回空间的首地址(返回void *
类型,需要转换为ElemType *
)。e.g.:
L.data = (ElemType *) malloc (sizeof(ElemType) * InitSize);
注意:malloc前必须加上强制转换符(ElemType *),这是因为具体的类型为编译器标示了如何确定数组各个元素的大小,下标运算符
[]
才能通过这一大小确定元素的位置。C++语言:
new
、delete
关键字
顺序表的特点
随机访问,即可以在
O(1)
时间内找到第i
个元素无论是静态分配还是动态分配都可使用
L.data[i-1]
来访问第i
个元素。存储密度高,每个节点只存储数据元素
拓展容量不方便
即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高(需要逐一复制原有的元素到新地址)。
插入、删除操作不方便,需要移动大量元素
基本操作
注:在未说明的情况下,本节代码建立在顺序表的“静态分配”实现方式之上,“动态分配” 实现类似。
#define MaxSize 10 // 定义最大长度 typedef struct{ int data[MaxSize]; // 用静态的“数组”存放整型元素 int length; // 顺序表的当前长度 }SqList; // 顺序表的类型定义
顺序表的插入
ListInsert(&L,i,e)
:插入操作。在表L中的第i
个位置(位序,从1开始计数)上插入指定元素e
。
bool ListInsert(SqList &L, int i, int e) {
if(i < 1 || i > L.length + 1) // 判断i的范围是否有效
return false;
if(L.length >= MaxSize) // 判断存储空间是否已满
return false;
for(int j = L.length; j >= i; j--) // 将第i个以及之后的元素后移1位
L.data[j] = L.data[j-1];
L.data[i-1] = e; // 在位置i处放入e
L.length++; // 长度加1
return true;
}
插入操作的时间复杂度
问题规模
n = L.length
(表长)
最好情况:新元素插入到表尾,不需要移动元素
i = n+1
,循环 0 次;最好时间复杂度O(1)
最坏情况:新元素插入到表头,需要将原有的 n 个元素全都向后移动
i = 1
,循环 n 次;最坏时间复杂度O(n)
平均情况:假设新元素插入到任何一个位置的概率相同,即\(i=1,2,3,\dots ,n+1\)的概率\(p=\frac{1}{n+1}\)。
i = 1
,循环 n 次;i = 2
,循环 n-1 次;i = 3
,循环 n-2 次,……,i = n+1
,循环 0 次。故循环次数的期望为 \[ np+(n-1)p+(n-2)p+\cdots +1\cdot p=\frac{n(n+1)}{2}\cdot \frac{1}{n+1}=\frac{n}{2}=O(n) \]
顺序表的删除
ListDelete(&L,i,&e)
:删除操作。删除表L中第i
个位置(位序,从1开始计数)的元素, 并用e返回删除元素的值。
bool ListDelete(SqList &L, int i, int &e) {
if(i < 1 || i > L.length + 1) // 判断i的范围是否有效
return false;
e = L.data[i-1]; // 将被删除的元素赋值给e
for(int j = i; j < L.length; j++) // 将第i个以及之后的元素后移1位
L.data[j-1] = L.data[j];
L.length--; // 长度减1
return true;
}
删除操作的时间复杂度
问题规模
n = L.length
(表长)
最好情况:删除表尾元素,不需要移动其他元素
i = n
,循环 0 次;最好时间复杂度O(1)
最坏情况:删除表头元素,需要将后续的 n-1 个元素全都向前移动
i = 1
,循环 n-1 次;最坏时间复杂度O(n)
平均情况:假设新元素插入到任何一个位置的概率相同,即\(i=1,2,3,\dots ,n\)的概率\(p=\frac{1}{n}\)。
i = 1
,循环 n-1 次;i = 2
,循环 n-2 次;i = 3
,循环 n-3 次,……,i = n
,循环 0 次。故循环次数的期望为 \[ (n-1)p+(n-2)p+\cdots +1\cdot p=\frac{n(n-1)}{2}\cdot \frac{1}{n}=\frac{n-1}{2}=O(n) \]
顺序表的按位查找
GetElem(L,i)
:按位查找操作。获取表L中第i
个位置的元素的值。
// 静态分配实现
#define MaxSize 10 // 定义最大长度
typedef struct{
ElemType data[MaxSize]; // 用静态的“数组”存放数据元素
int length; // 顺序表的当前长度
} SqList; // 顺序表的类型定义(静态分配方式)
ElemType GetElem(SqList L, int i){
return L.data[i-1];
}
// 动态分配实现
#define InitSize 10 // 顺序表的初始长度
typedef struct{
ElemType *data; // 指示动态分配数组的指针
int MaxSize; // 顺序表的最大容量
int length; // 顺序表的当前长度
} SeqList; // 顺序表的类型定义(动态分配方式)
ElemType GetElem(SeqList L, int i){
return L.data[i-1];
}
按位查找操作的时间复杂度
由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i
个元素(“随机存取”特性),故按位查找操作的时间复杂度为\(O(1)\)。
顺序表的按值查找
LocateElem(L,e)
:按值查找操作。在表L中查找具有给定关键字值的元素。
// 动态分配实现
#define InitSize 10 // 顺序表的初始长度
typedef struct{
ElemType *data; // 指示动态分配数组的指针
int MaxSize; // 顺序表的最大容量
int length; // 顺序表的当前长度
} SeqList; // 顺序表的类型定义(动态分配方式)
// 在顺序表L中查找第一个元素值等于e的元素,并返回其位序
int LocateElem(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; // 退出循环,说明查找失败
}
手写代码与机试的区别
《数据结构》考研初试中,手写代码可以直接用==
,无论ElemType
是基本数据类型还是 结构类型,手写代码主要考察学生是否能理解算法思想,不会严格要求代码完全可运行。
- 基本数据类型(
int
、char
、double
、float
等)可以直接用运算符==
比较 - 结构体数据类型是不能直接用运算符
==
比较的,需要- 依次对比各个分量来判断两个结构体是否相等;
- 或者要求使用者定义一个判断相等的函数,再在
LocateElem
代码中调用这一函数
删除操作的时间复杂度
问题规模
n = L.length
(表长)
最好情况:目标元素在表头,循环 0 次,时间复杂度
O(1)
最坏情况:目标元素在表尾,循环 n 次,时间复杂度
O(n)
平均情况:假设目标元素出现在任何一个位置的概率相同,都为\(p=\frac{1}{n}\)。
目标元素在第 1 位,循环 1 次;在第 2 位,循环 2 次;……;在第 n 位,循环 n 次。
故循环次数的期望为 \[ 1\cdot p+2\cdot p+\cdots +n\cdot p=\frac{n(n+1)}{2}\cdot \frac{1}{n}=\frac{n+1}{2}=O(n) \]
小结



单链表:线性表的链式表示(1)

顺序表 | 单链表 | |
---|---|---|
优点 | 可随机存取,存储密度高 | 不要求大片连续空间,改变容量方便 |
缺点 | 要求大片连续空间,改变容量不方便 | 不可随机存取,要耗费一定空间存放指针 |
单链表的定义
struct LNode { // 定义单链表结点类型
ElemType data; // 数据域:每个节点存放一个数据元素
struct LNode *next; // 指针域:指针指向下一个节点
};
// 增加一个新的结点:在内存中申请一个结点所需空间,并用指针 p 保存这个结点的地址
struct LNode *p = (struct LNode *)malloc(sizeof(struct LNode));
typedef
关键字用于对数据类型重命名。
用法:typedef <数据类型> <别名>
利用typedef
关键字,可以将上面的增加结点代码改写为:
// 增加一个新的结点:在内存中申请一个结点所需空间,并用指针 p 保存这个结点的地址
typedef struct LNode LNode;
LNode *p = (LNode *)malloc(sizeof(LNode));
或者直接在定义结构体时就改写:
typedef struct LNode { // 定义单链表结点类型
ElemType data; // 数据域:每个节点存放一个数据元素
struct LNode *next; // 指针域:指针指向下一个节点
} LNode, *LinkList; // LinkList表示单链表,它是LNode的指针
今后我们都将采用这一改写形式。
要表示一个单链表时,只需声明一个头指针 L
,指向单链表的第一个结点:
LinkList L;
LinkList
强调这是一个单链表- 使用
LNode *
强调这是一个结点
不带头结点的单链表
// 初始化一个空的单链表
bool InitList(LinkList &L) {
L = NULL; // 空表,暂时还没有任何结点,置空防止脏数据
return true;
}
// 判断单链表是否为空
bool Empty(LinkList L) {
return L == NULL;
}
带头结点的单链表
// 初始化一个空的带头结点的单链表
bool InitList(LinkList &L) {
// 分配一个头结点
L = (LNode *)malloc(sizeof(LNode));
// 内存不足,分配失败
if (L == NULL)
return false;
// 头结点之后暂时还没有节点,置空
L->next = NULL;
return true:
}
// 判断带头结点的单链表是否为空
bool Empty(LinkList L) {
return L->next == NULL;
}
Tips:头结点不存储数据!
带/不带头结点的区别
- 带头结点,代码更方便
- 空表判断:
L == NULL
- 空表判断:
- 不带头结点,写代码更麻烦
- 对第一个数据结点和后续数据结点的处理需要用不同的代码逻辑
- 对空表和非空表的处理需要用不同的代码逻辑
- 空表判断:
L->next == NULL
小结

基本操作:插入
按位序插入
ListInsert(&L, i, e)
:插入操作。在表L
中的第i
个位置上插入指定元素e
。
思路:遍历节点并计数,找到第i-1
个结点,将新结点插入其后。
带头结点
注意:头结点可以看作“第0个”结点,不需要对第1个结点特殊处理。
// 在第i个位置插插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ELlemType e){
if (i < 1)
return false;
// 指针p指向当前扫描到的结点
LNode *p;
// 当前p指向的是第几个结点
int j = 0;
// L指向头结点,头结点是第0个结点(不存数据)
p = L;
// 循环找到第i-1个结点
while (p != NULL && j < i-1){
p = p->next;
j++;
}
// i值不合法
if(p==NULL)
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data=e;
// 将结点s接入到p的下一个结点之前
s->next=p->next; // ▲
// 将结点s接入到p之后
p->next=s; // ▲
//插入成功
return true;
}
代码中▲部分的两个语句顺序不能颠倒,否则原p的下一个结点(以及原来链表里之后的节点)就找不到了。


不带头结点
注意:不存在“第0个”结点,因此i=1
时需要特殊处理。
bool ListInsert(LinkList &L, int i, ElemType e) {
if (i < 1)
return false;
if (i == 1) { // 插入第1个结点的操作与其他结点操作不同
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = L;
L = s; // 头指针指向新结点
return true;
}
LNode *p; // 指针p指向当前扫描到的结点
int j = 1; // 当前p指向的是第几个结点
p = L; // p指向第1个结点(注意:不是头结点)
while (p != NULL && j < i - 1) { // 循环找到第i-1个结点
p = p->next;
j++;
}
if (p == NULL) // i值不合法
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s;
return true; // 插入成功
}
不带头结点写代码更不方便,推荐用带头结点。
除非特别声明,之后的代码默认带头结点!
注意:考试中带头、不带头都有可能考察,注意审题
按位序插入操作的时间复杂度
问题规模
n = L.length
(表长)
最好情况:插在表头,时间复杂度
O(1)
最坏情况:插在表尾,时间复杂度
O(n)
平均情况:时间复杂度
O(n)
在给定节点前后插入
在给定节点之后插入
// 后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p, ElemType e) {
if (p == NULL)
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) // 内存分配失败(不写问题也不大)
return false;
s->data = e; // 用结点s保存数据元素e
s->next = p->next;
p->next = s; // 将结点s连到p之后
return true;
}
时间复杂度:O(1)
ListInsert
的简化版本
ListInsert
的简化版本利用InsertNextNode
函数,可以简化之前写的按位序插入操作ListInsert
(以带头结点版本为例):
// 在第i个位置插插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ELlemType e){
if (i < 1)
return false;
// 指针p指向当前扫描到的结点
LNode *p;
// 当前p指向的是第几个结点
int j = 0;
// L指向头结点,头结点是第0个结点(不存数据)
p = L;
// 循环找到第i-1个结点
while (p != NULL && j < i-1){
p = p->next;
j++;
}
return InsertNextNode(p, e);
}
在给定节点之前插入
思路1:如果能额外给出线性表的头指针,则可以通过遍历到给定节点的前一个节点来实现插入,但是这样的算法时间复杂度为O(n)
,不推荐;
思路2:虽然不能很快地找到原p结点的前驱,但是可以通过将数据域替换的方式“曲线救国”,将前插操作转化为后插操作,即将结点p的数据域替换为需要插入的节点的值,然后在节点p后插入一个节点,其数据域为原p结点的数据域:
// 前插操作:在p结点之前插入元素e
bool InsertPriorNode(LNode *p, ElemType e) {
if (p == NULL)
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) // 内存分配失败
return false;
s->next = p->next;
p->next = s; // 新结点s连到p之后
s->data = p->data; // 将p中元素复制到s中
p->data = e; // p中元素覆盖为e
return true;
}
这样的算法时间复杂度为O(1)
。
基本操作:删除
按位序删除
ListDelete(&L,i,&e)
:删除操作。删除表L
中第i
个位置的元素,并用e
返回删除元素的值。
思路:找到第i-1
个结点,将其指针指向第i+1
个结点,并释放第i
个结点。
后文都将以带头结点的版本为例!
注意:头结点可以看作“第0个”结点,不需要对第1个结点特殊处理。
bool ListDelete(LinkList &L, int i, ElemType &e) {
if (i < 1)
return false;
LNode *p; // 指针p指向当前扫描到的结点
int j = 0; // 当前p指向的是第几个结点
p = L; // L指向头结点
while (p != NULL && j < i - 1) { // 循环找到第i-1个结点
p = p->next;
j++;
}
if (p == NULL) // i值不合法
return false;
if (p->next == NULL) // 第i-1个结点之后已无其他结点
return false;
LNode *q = p->next; // 令q指向被删除结点
e = q->data; // 用e返回元素的值
p->next = q->next; // 将*q结点从链中“断开”
free(q); // 释放结点的存储空间
return true; // 删除成功
}
按位序删除操作的时间复杂度
问题规模
n = L.length
(表长)
最好情况:时间复杂度
O(1)
最坏情况:时间复杂度
O(n)
平均情况:时间复杂度
O(n)
删除给定结点(BUG版)
思路:类似于给定节点前插操作的思路2,将结点p的数据域替换为后继节点的数据域,然后删除后继节点:
// 删除指定结点p
bool DeleteNode(LNode *p) {
if (p == NULL)
return false;
LNode *q = p->next; // 令q指向*p的后继结点
p->data = p->next->data; // 和后继结点交换数据域
p->next = q->next; // 将*q结点从链中“断开”
free(q); // 释放后继结点的存储空间
return true;
}
类似于给定节点前插操作的思路2,时间复杂度为O(1)
。
这一算法实际上是错误的,因为它不能正确处理删除链表最后一个节点的情况(此时不存在后继节点,无法替换)。
因此,要在单链表中正确实现删除给定结点操作,只能对最后一个节点(通过判断指针域next
是不是空来确定是否为末节点)采用给定节点前插操作的思路1,即遍历链表,找到前驱的方式删除(此时,时间复杂度为O(n)
),考试也不会考。
可见,单链表具有【无法逆向检索】的局限性,有时候不太方便。
基本操作:查找
按位查找
GetElem(L,i)
:按位查找操作。获取表L中第i个位置的元素的值。
// 按位查找,返回第i个元素(带头结点)
LNode *GetElem(LinkList L, int i) {
if (i < 0)
return NULL;
LNode *p; // 指针p指向当前扫描到的结点
int j = 0; // 当前p指向的是第几个结点
p = L; // L指向头结点,头结点是第0个结点(不存数据)
while (p != NULL && j < i) { // 循环找到第i个结点
p = p->next;
j++;
}
return p;
}
平均/最坏时间复杂度:O(n)
ListInsert
的进一步简化
ListInsert
的进一步简化在定义GetElem
函数后,就可以将按位序插入函数ListInsert
(以带头结点版本为例)进一步简化为:
// 在第i个位置插插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ELlemType e){
if (i < 1)
return false;
// 找到第i-1个结点
LNode *p = GetElem(L, i - 1);
return InsertNextNode(p, e);
}
按值查找
LocateElem(L,e)
:按值查找操作。在表L中查找具有给定关键字值的元素。
// 按值查找,找到数据域为e的结点
LNode *LocateElem(LinkList L, ElemType e) {
LNode *p = L->next;
// 从第1个结点开始查找数据域为e的结点
while (p != NULL && p->data != e)
p = p->next;
return p; // 找到后返回该结点指针,否则返回NULL
}
平均/最坏时间复杂度:O(n)
再次提醒==
/!=
的使用注意事项
==
/!=
的使用注意事项- 基本数据类型(
int
、char
、double
、float
等)可以直接用运算符==
/!=
比较 - 结构体数据类型是不能直接用运算符
==
/!=
比较的,需要- 依次对比各个分量来判断两个结构体是否相等;
- 或者要求使用者定义一个判断相等的函数,再在
LocateElem
代码中调用这一函数
求表长
Length(LinkList L)
:求表长操作。返回表L的数据节点个数。
// 求表的长度
int Length(LinkList L) {
int len = 0; // 统计表长
LNode *p = L;
while (p->next != NULL) {
p = p->next;
len++;
}
return len;
}
平均/最坏时间复杂度:O(n)
基本操作:建表
“尾插法”
思路:为了避免每次从头查找链表的尾部节点(会导致时间复杂度增加到$O\left( n^2\right)$),可以设置一个指向表尾结点的指针用于辅助连续的在表尾插入节点。
#define STOP_SIGNAL 9999 // 输入这一信号表示停止输入
LinkList List_TailInsert(LinkList &L) { // 正向建立单链表
int x; // 设ElemType为整型
L = (LinkList)malloc(sizeof(LNode)); // 建立头结点
L->next = NULL; // 初始为空链表
LNode *s, *r = L; // r为表尾指针
scanf("%d", &x); // 输入结点的值
while (x != STOP_SIGNAL) { // 输入9999表示结束
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
r->next = s;
r = s; // r指向新的表尾结点
scanf("%d", &x);
}
r->next = NULL; // 尾结点指针置空
return L;
}
平均/最坏时间复杂度:O(n)
“头插法”
#define STOP_SIGNAL 9999 // 输入这一信号表示停止输入
LinkList List_HeadInsert(LinkList &L) { // 逆向建立单链表
LNode *s;
int x;
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
L->next = NULL; // 初始为空链表
scanf("%d", &x); // 输入结点的值
while (x != STOP_SIGNAL) { // 输入9999表示结束
s = (LNode *)malloc(sizeof(LNode)); // 创建新结点
s->data = x;
s->next = L->next;
L->next = s; // 将新结点插入表中,L为头指针
scanf("%d", &x);
}
return L;
}
平均/最坏时间复杂度:O(n)
小结



双链表:线性表的链式表示(2)
由于单链表具有“无法逆向检索”的缺点,我们在单链表的基础上进行改进,进一步牺牲了存储密度,使其“可进可退”。
双链表的定义
typedef struct DNode { // 定义双链表结点类型
ElemType data; // 数据域
struct DNode *prior, *next; // 前驱和后继指针,prior表示先前的
} DNode, *DLinklist;
初始化双链表(带头结点)与判空
// 初始化一个空的带头结点的双链表
bool InitDLinkList(DLinklist &L) {
// 分配一个头结点
L = (DNode *)malloc(sizeof(DNode));
// 内存不足,分配失败
if (L == NULL)
return false;
// 头结点的prior永远指向NULL
L->prior = NULL;
// 头结点之后暂时还没有节点
L->next = NULL;
return true;
}
// 判断带头结点的双链表是否为空
bool Empty(DLinklist L) {
return L->next == NULL;
}
基本操作:后插
// 在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s) {
if(p==NULL || s == NULL) // 非法参数
return false;
s->next = p->next; // ▲ 将结点*s插入到结点*p之后
if(p->next != NULL) // ▲ 如果p结点后还有结点
p->next->prior = s; // ▲
s->prior = p; // ▲
p->next = s; // ▲
return true;
}
标▲的几行代码的顺序,不一定必须是这个顺序,但写的时候一定要注意功能的实现是否正常,最好画图验证一下。
基本操作:删除
删除结点
// 删除p结点的后继结点
bool DeleteNextDNode(DNode *p) {
if (p == NULL)
return false;
DNode *q = p->next; // 找到p的后继结点q
if (q == NULL)
return false; // p没有后继
p->next = q->next;
if (q->next != NULL) // ▲ q结点不是最后一个结点
q->next->prior = p;
free(q); // 释放结点空间
return true;
}
删除链表
bool DestroyList(DLinklist &L){
if (L == NULL)
return false;
// 循环释放各个数据结点
while(L->next){
if(!DeleteNextDNode(L))
return false;
}
// 删除头结点
free(L);
// 链表置空
L = NULL;
return true;
}
基本操作:遍历
后向遍历
while (p) {
// 对结点p做相应处理,如打印
p = p->next;
}
前向遍历
while (p) {
// 对结点p做相应处理
p = p->prior;
}
前向遍历(跳过头结点)
while (p->prior) {
// 对结点p做相应处理
p = p->prior;
}
双链表不可随机存取,按位查找、按值查找操作都只能用遍历的方式实现,故时间复杂度O(n)
小结

循环链表:线性表的链式表示(3)
循环单链表

- 普通单链表
- 表尾结点的
next
指针指向NULL
- 从一个结点出发只能找到后续的各个结点
- 表尾结点的
- 循环单链表
- 表尾结点的
next
指针指回头结点 - 从一个结点出发可以找到其他任何一个结点
- 表尾结点的
// 初始化一个空的带头结点的循环单链表
bool InitList(LinkList &L) {
// 分配一个头结点
L = (LNode *)malloc(sizeof(LNode));
// 内存不足,分配失败
if (L == NULL)
return false;
// 头结点之后暂时还没有节点,置空
L->next = L; // 设置循环
return true:
}
// 判断带头结点的单链表是否为空
bool Empty(LinkList L) {
return L->next == L;
}
// 判断是否为表尾结点
bool isTail(LinkList L, LNode *p) {
return p->next == L;
}
对于循环单链表来说,
- 从头结点找到尾结点,时间复杂度为
O(n)
- 从尾结点找到头结点,时间复杂度为
O(1)
但事实上,很多时候对链表的操作都是在头部或尾部,故可以让L
指针指向表尾元素以方便操作。
注意:这样做以后,插入、删除时可能需要修改L
指针。
循环双链表

普通双链表
- 表头结点的
prior
指向NULL
- 表尾结点的
next
指针指向NULL
- 表头结点的
循环双链表
表头结点的
prior
指向表尾结点相当于循环双链表中的
prior
指向构成了一个闭环表尾结点的
next
指针指回头结点相当于循环双链表中的
next
指向也构成了一个闭环
// 初始化一个空的带头结点的循环双链表
bool InitDLinkList(DLinklist &L) {
// 分配一个头结点
L = (DNode *)malloc(sizeof(DNode));
// 内存不足,分配失败
if (L == NULL)
return false;
// 头结点的prior指向表尾结点 循环
L->prior = L;
// 头结点的next指向头结点 循环
L->next = L;
return true;
}
// 判断带头结点的双链表是否为空
bool Empty(DLinklist L) {
return L->next == L;
}
// 判断是否为表尾结点
bool isTail(DLinklist L, DNode *p) {
return p->next == L;
}
对于循环双链表,后插结点操作不再需要判断是否为表尾结点:
// 在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s) {
if(p==NULL || s == NULL) // 非法参数
return false;
s->next = p->next; // ▲ 将结点*s插入到结点*p之后
// if(p->next != NULL) // ▲ 如果p结点后还有结点
p->next->prior = s; // ▲
s->prior = p; // ▲
p->next = s; // ▲
return true;
}
删除节点也不再需要判断是否为表尾结点:
// 删除p结点的后继结点
bool DeleteNextDNode(DNode *p) {
if (p == NULL)
return false;
DNode *q = p->next; // 找到p的后继结点q
// if (q == NULL)
// return false; // p不可能没有后继(循环)
p->next = q->next;
// if (q->next != NULL) // 无需判断q结点不是最后一个结点
q->next->prior = p;
free(q); // 释放结点空间
return true;
}
小结

静态链表:线性表的链式表示(4)
静态链表:用数组的方式实现的链表
- 链表:各个结点在内存中星罗棋布、散落天涯。
- 静态链表:分配一整片连续的内存空间,各个结点集中安置。

#define MaxSize 10 // 静态链表的最大长度
typedef struct { // 静态链表结构类型的定义
ElemType data; // 存储数据元素
int next; // 下一个元素的数组下标
} SLinkList[MaxSize]; // 表示SLinkList类型是结构体数组
// SLinkList M; // 这一行代码将会定义大小为MaxSize
// 的上面所定义的结构体的数组
【优点】
- 增、删操作不需要大量移动元素
【缺点】
- 不能随机存取,只能从头结点开始依次往后查找
- 容量固定不可变
【适用场景】
- 不支持指针的低级语言
- 数据元素数量固定不变的场景(如操作系统的文件分配表FAT)
基本操作
注:考研基本不考代码实现!
假设静态链表为SLinkList a
:
- 初始化静态链表
- 把
a[0]
的next
设为-1
- 把其他结点的
next
设为一个特殊值用来表示结点空闲,如-2
- 把
- 查找
- 从头结点出发挨个往后遍历结点
- 插入位序为
i
的结点- 找到一个空闲的结点,存入数据元素
- 空闲判断
next == -2
- 空闲判断
- 从头结点出发找到位序为
i-1
的结点 - 修改新结点的
next
- 修改
i-1
号结点的next
- 找到一个空闲的结点,存入数据元素
- 删除某个结点
- 从头结点出发找到前驱结点
- 修改前驱结点的游标
- 被删除结点
next
设为空闲标志-2
顺序表与链表的对比
逻辑结构
顺序表和链表都属于线性表,逻辑结构都是线性结构。
物理结构
- 顺序表:顺序存储
- 优点:支持随机存取、存储密度高
- 缺点:大片连续空间分配不方便,改变容量不方便
- 链表:链式存储
- 优点:离散的小空间分配方便,改变容量方便
- 缺点:不可随机存取,存储密度低(需要额外存储指针域)
基本操作
主要考察:创(销)、增删(改)查
创建
顺序表:需要预分配大片连续空间
若分配空间过小,则之后不方便拓展容量;
若分配空间过大,则浪费内存资源。
- 静态分配(静态数组):容量不可改变
- 动态分配(动态数组):容量可改变,但需要移动大量元素,时间代价高
链表:只需分配一个头结点(也可以不要头结点,只声明一个头指针),之后方便拓展
销毁
- 顺序表
- 静态分配(静态数组):系统自动回收空间,无需操作
- 动态分配(动态数组):需要手动
free
释放空间
- 链表:需要依次释放
free
各个结点 - 注意:
malloc
和free
必须成对出现
- 顺序表
插入/删除结点
顺序表:插入/删除元素要将后续元素都后移/前移
时间复杂度是
O(n)
,时间开销主要来自移动元素,若单个数据元素占用的空间较大,那么移动的时间代价会很高
链表:插入/删除元素只需修改指针即可
时间复杂度虽然也是
O(n)
,但时间开销主要来自查找目标元素查找元素的时间代价更低
查找
顺序表
按位查找:
O(1)
(随机存取)按值查找:
O(n)
注:若表内元素有序,可利用二分法在
O(log n)
时间内找到
链表
- 按位查找:
O(n)
- 按值查找:
O(n)
- 按位查找:
总结对比
顺序表 | 链表 | |
---|---|---|
弹性(可扩容性) | 😭 | 😀 |
增/删 | 😭 | 😀 |
查 | 😀 | 😭 |
【适用场景】
- 链表:表长难以预估、经常要增加/删除元素
- 顺序表:表长可预估、查询(搜索)操作较多
【答题模板】
Q:请描述顺序表和链表的……实现线性表时,用顺序表还是链表好?
答题套路
- 顺序表和链表的逻辑结构都是线性结构,都属于线性表。
- 但是二者的存储结构不同,顺序表采用顺序存储,(特点,带来的优点、缺点);而链表采用链式存储,(特点,带来的优点、缺点)。
- 由于采用不同的存储方式实现,因此基本操作的实现效率也不同。当初始化时,……;当插入数据元素时,……;当删除数据元素时,……;当查找一个数据元素时……。