线性表
逻辑结构
线性表(Linear List)是具有相同数据类型的 n(n≥0)个数据元素的有限序列,一般表示为 L= (
a
1
,
a
2
,
⋅
⋅
⋅
,
a
i
,
a
i
+
1
,
⋅
⋅
⋅
,
a
n
a_1,a_2,···,a_i,a_{i+1},···,a_n
a1,a2,⋅⋅⋅,ai,ai+1,⋅⋅⋅,an),其中,n为表长,
a
1
a_1
a1 是唯一的表头元素,
a
n
a_n
an是唯一的表尾元素,
a
i
a_i
ai是线性表中位序为 i 的元素。除第一个元素外,每个元素有且仅有一个直接前驱。除最后一个元素外,每个元素有且仅有一个直接后继。
当 n=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
实现对数据结构的基本操作,便于团队合作编程,方便使用(封装),而且将常用的操作/运算封装成函数,可以避免重复工作,降低出错风险
存储结构
线性表主要有顺序表和链表两种实现。
顺序表(顺序存储)
线性表的顺序存储又称顺序表,它是用一组地址连续的存储单元依次存储线性表中的数据元素,元素之间的关系由存储单元的邻接关系来体现,从而使得逻辑上相邻的两个元素在物理位置上也相邻。每个数据元素的存储位置都和顺序表的起始位置相差一个和该数据元素的位序成正比的常数,因此,顺序表中的任一数据元素都可以随机存取,即顺序表是一种随机存取的存储结构。
通常用高级程序设计语言中的数组来描述线性表的顺序存储结构
优点:
- 支持随机存取,即可以在O(1)时间内找到第 i 个元素
- 存储密度高,每个节点只存储数据元素
缺点:
- 需要预先分配大量空间,并且拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)
- 顺序表逻辑上相邻的元素物理上也相邻,插入、删除操作不方便,需要移动大量元素
静态分配
在静态分配时,由于数组的大小和空间事先已经固定,一旦空间占满,再加入新的数据就会产生溢出,进而导致程序崩溃。存在一定的局限性,线性表的大小容量是不可调的,无法更改
代码:
#define MaxSize 10 //定义最大长度
typedef struct {
/* data */
int data[MaxSize]; //用静态的“数组”存放数据元素
int length; //顺序表的当前长度
} SqList;
void InitList(SqList &L) {
//把各个数据元素的值设为默认值(可省略)
for (int i = 0; i < MaxSize; i++) {
L.data[i] = 0;
}
L.length = 0; //顺序表初始长度为0;
}
如果在初始化顺序表时,没有设置好数据元素的默认值,由于内存中会有遗留的脏数据,会出现奇怪的值。
动态分配
在动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满,可以重新开辟一块更大的存储空间,用以替换原来的存储空间,从而达到扩充存储数组空间的目的,而不需要为线性表一次性地划分所有空间。
代码:
#define InitSize 10 //顺序表的初始长度
typedef struct {
int *data; //用静态的数组存放数据元素
int MaxSize; //定义最大长度
int length; //顺序表的当前长度
} SqList;
void InitList(SqList &L) {
L.data = (int *) malloc(InitSize * sizeof(int)); //用malloc函数申请一片连续的存储空间
L.length = 0;
L.MaxSize = InitSize;
}
void IncreaseSize(SqList &L, int len) {
int *p = L.data;
L.data = (int *) malloc((L.MaxSize + len) * sizeof(int)); //用malloc函数重新申请一片连续的存储空间
for (int i = 0; i < L.length; i++) {
L.data[i] = p[i]; //将数据复制到新区域,时间开销大
}
L.MaxSize = L.MaxSize + len; //顺序表最大长度增加len
free(p); //释放原来的内存空间
}
重新分配空间可以采用realloc实现,但有坑
基本操作
插入
在顺序表L的第i个位置插入新元素e。若i的输入不合法,则返回false,表示插入失败;否则,将第i个元素及其后的所有元素依次往后移动一个位置,腾出一个空位置插入新元素e,顺序表长度增加1,插入成功,返回true。
时间复杂度:
- 最好情况: 新元素插入到表尾,不需要移动元素,最好时间复杂度为 O ( 1 ) O(1) O(1)
- 最坏情况: 新元素插入到表头,需要将原有的 n 个元素全都向后移动,最坏时间复杂度为 O ( n ) O(n) O(n)
- 平均情况: 新元素插入到任一位置概率 p = 1 ( n + 1 ) p = \frac{1}{(n+1)} p=(n+1)1,平均循环次数 n p + ( n − 1 ) p + … … + 1 ⋅ p = n 2 np+(n−1)p+……+1⋅p=\frac{n}{2} np+(n−1)p+……+1⋅p=2n,平均时间复杂度为 O ( n ) O(n) O(n)
代码:
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个元素及以后的元素后移
L.data[j] = L.data[j - 1];
}
L.data[i - 1] = e; //在位置i处放入e
L.length++; //长度++
return true;
}
删除
删除顺序表L中第i个位置的元素,用引用变量e返回。若i的输入不合法,则返回false;否则,将被删元素赋给引用变量e,并将第i+1个元素及其后的所有元素依次往前移动一个位置,返回true。
时间复杂度:
- 最好情况: 删除表尾元素,不需要移动元素,最好时间复杂度为 O ( 1 ) O(1) O(1)
- 最坏情况: 删除表头元素,需要将原有的 n 个元素全都向后移动,最坏时间复杂度为 O ( n ) O(n) O(n)
- 平均情况: 删除任一元素概率 p = 1 ( n + 1 ) p = \frac{1}{(n+1)} p=(n+1)1,平均循环次数 ( n − 1 ) p + ( n − 2 ) p + … … + 1 ⋅ p = n − 1 2 (n−1)p+(n−2)p+……+1⋅p=\frac{n-1}{2} (n−1)p+(n−2)p+……+1⋅p=2n−1,平均时间复杂度为 O ( n ) O(n) O(n)
代码:
bool ListDelete(SqList &L, int i, int &e) {
if (i < 1 || i > L.length) { //判断i的范围是否有效
return false;
}
e = L.data[i - 1];
for (int j = i; j < L.length; j++) { //将第i个元素及以后的元素前移
L.data[j - 1] = L.data[j];
}
L.length--; //长度--
return true;
}
按位查找
按位查找操作,获取表 L 中第 i 个位置的元素的值。
时间复杂度:
- 由于顺序表的各个数据元素在内存中连续存放,可以根据起始地址和数据元素大小立即找到第 i 个元素,时间复杂度为 O ( 1 ) O(1) O(1)
随机存取
代码:
int GetElem(SqList L, int i) {
return L.data[i - 1];
}
按值查找
顺序查找,在顺序表L中查找第一个元素值等于e的元素,并返回其位序。
时间复杂度:
- 最好情况: 目标元素在表头,查找一次,最好时间复杂度为 O ( 1 ) O(1) O(1)
- 最坏情况: 目标元素在表尾,查找 n 次,最坏时间复杂度为 O ( n ) O(n) O(n)
- 平均情况: 目标元素在任一位置概率 1 n \frac{1}{n} n1,平均循环次数 1 ⋅ 1 n + 2 ⋅ 1 n + … … + n ⋅ 1 n = n + 1 2 1·\frac{1}{n}+2·\frac{1}{n}+……+n·\frac{1}{n}=\frac{n+1}{2} 1⋅n1+2⋅n1+……+n⋅n1=2n+1,平均时间复杂度为 O ( n ) O(n) O(n)
代码:
int LocateElem(SqList L, int e) {
for (int i = 0; i < L.length; i++)
if (L.data[i] == e)
return i + 1; //数组下标为i的元素值等于e,返回其位序i+1
return 0; //退出循环,说明查找失败
}
基本数据类型(int、char、double、float等)可以直接用运算符==比较,但结构类型的数据不能直接用运算符 == 比较
输出
#include <stdlib.h>
#include <iostream>
using namespace std;
void printList(SqList L) {
cout << "长度length = " << L.length << endl;
cout << "数据为:" << endl;
for (int i = 0; i < L.length; i++) {
cout << "data[" << i << "] = " << L.data[i] << endl;
}
}
链表(链式存储)
链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素在物理位置上也相邻,它通过指针建立起数据元素之间的逻辑关系,插入和删除操作不需要移动元素而只需修改指针,但也会失去顺序表随机存取的优点。
单链表
线性表的链式存储又称单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的线性关系,对每个链表结点,除存放元素自身的信息外,还需要存放一个指向其后继的指针。
优点:
- 不需要大量连续存储单元,数据元素离散存储,改变容量方便
缺点:
- 存储密度低,单链表附加指针域,会浪费部分存储空间
- 单链表的元素离散地分布在存储空间中,即单链表是非随机存取结构,查找某个特定的结点时,需要从表头开始遍历,依次查找
代码:
typedef struct LNode { //定义单链表结点类型
int data; //数据域
struct LNode *next; //指针域
} LNode, *LinkList;
LNode *
强调的是结点,LinkList
强调的是单链表
实现
不带头结点的单链表
通常用头指针来标识一个单链表,头指针为NULL时表示一个空表
带头结点的单链表
为了操作上的方便,在单链表第一个结点前附加一个结点,称为头结点,头结点的指针域指向线性表的第一个元素结点,通常不存储信息
头指针都始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,带头结点的形式更加常用
引入头结点的两个优点:
- 由于第一个数据结点的位置被存放在头结点的指针域中,链表第一个位置上的操作和在表其他位置上的操作一致,无须进行特殊处理
- 无论链表是否为空,其头指针都是指向头结点的非空指针(空表中头结点的指针域为空),对于空表和非空表的处理得到统一
基本操作
初始化
不带头结点的单链表
不带头结点的单链表的初始化方法:将链表头指针L置空。
代码:
bool InitList(LinkList &L) {
L = NULL; // 防止脏数据
return true;
}
带头结点的单链表
带头结点的单链表的初始化方法:申请一个头结点,判断内存是否能分配,并将指针域置空。
代码:
bool InitList(LinkList &L) {
L = (LNode *) malloc(sizeof(LinkList));
if (L == NULL) // 内存不足,分配失败
return false;
L->next = NULL;
return true;
}
建表
头插法、尾插法:核心就是初始化操作、指定结点的后插操作
头插法
从一个空表开始,生成新结点,并将读取到的数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后。
采用头插法建立单链表时,读入数据的顺序与生成的链表中的元素的顺序是相反的。每个结点插入的时间为 O ( 1 ) O(1) O(1),设单链表长为 n,则总时间复杂度为 O ( n ) O(n) O(n)
头插法的重要应用:链表的逆置
代码:
算法思想:首先初始化一个单链表,其头结点为空,然后循环插入新结点*s:将s的next指向头结点的下一个结点,然后将头结点的next指向s。(不带头结点:将s的next指向头指针指向的结点,然后将头指针指向s)
LinkList HeadInsert(LinkList &L) {
InitList(L); //初始化
int x; //设数据元素为int类型
LNode *s;
cin >> x;
while (x != -1) {
s = (LNode *) malloc(sizeof(LNode)); //申请内存
if (s == NULL) {
cout << "申请内存空间失败!" << endl;
break;
}
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
// s->data = x;
// s->next = L; //若是第一个结点,将NULL赋给s,从第二个开始,s->next指的是上一轮定义的结点
// L = s; //头指针指向最新的结点s
/*带头结点*/
s->data = x;
s->next = L->next; //若是第一个结点,则将NULL赋给s,从第二个开始,s->next指的是上一轮定义的结点
L->next = s; //头指针指向最新的结点s
//<----------------------------------------------------------------------》
cin >> x;
}
return L; //返回头指针,通过头指针可以遍历该链表
}
尾插法
尾插法通过增加一个始终指向当前链表的尾结点的尾指针 r,将新结点插入到当前链表的表尾,使得生成的链表中结点的次序和输入数据的顺序一致
附设了一个指向表尾结点的指针,时间复杂度和头插法的相同
代码:
算法思想:首先初始化一个单链表,然后声明一个尾指针r,让r始终指向当前链表的尾结点,循环向单链表的尾部插入新的结点*s。将尾指针r的next域指向新结点,再修改尾指针r指向新结点(当前链表的尾结点),最后将尾结点的指针域置空。
对于不带头结点的链表,若原链表为空,则将头指针和尾指针同时指向新结点s
LinkList TailInsert(LinkList &L) {
InitList(L);
LNode *s, *r = L; //定义尾指针,永远指向最后一个结点,开始尾指针指向头结点
int x;
cin >> x;
while (x != -1) {
s = (LNode *) malloc(sizeof(LNode));
if (s == NULL) {
cout << "申请内存空间失败!" << endl;
break;
}
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
// s->data = x;
// if (L == NULL) {
// L = s; //将头指针指向最新的结点
// r = s; //尾指针指向最新的结点
// } else {
// r->next = s; //将尾结点的next指向最新的结点
// r = s; //尾指针指向最新的结点
// }
/*带头结点*/
s->data = x;
r->next = s; //将尾结点的next指向最新的结点
r = s; //尾指针指向最新的结点
//<----------------------------------------------------------------------》
cin >> x;
}
r->next = NULL; //尾结点指针域置空
return L;
}
时间复杂度:O(n)
插入
插入结点操作将值为 x 的新结点插入到单链表的第 i 个位置上,先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第 i-1 个结点,再在其后插入新结点。
算法主要的时间开销在于查找第i—1个元素,时间复杂度为O(n),若在给定的结点后面插入新结点,则时间复杂度仅为 O(1)
代码:
算法思想:首先调用按序号查找算法GetElem(L,i-1),查找第i-1个结点。假设返回的第i-1个结点为*p,然后令新结点 *s的指针域指向 *p的后继结点,再令结点 *p的指针域指向新插入的结点 *s
bool ListInsert(LinkList L, int i, int e) {
if (i < 1)
return false;
LNode *p = L;
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
// if (i == 1) {
// LNode *s = (LNode *) malloc(sizeof(LNode));
// s->data = e;
// s->next = L;
// L = s;
// return true;
// }
// int j = 1; //用来表明当前指针位序
/*带头结点*/
int j = 0; //用来表明当前指针位序
//<----------------------------------------------------------------------》
while (p != NULL && j < i - 1) { //循环找到第i-1个结点
p = p->next;
j++;
}
if (p == NULL)
return false;
LNode *s = (LNode *) malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s; //将结点s连接到p之后
return true;
}
指定结点后插
后插操作是指在某结点的后面插入一个新结点,在单链表插入算法中,通常都采用后插操作。
代码:
判断操作是否合法(p指针是否为空+s是否能分配),插入元素操作
bool InsertNextNode(LNode *p, int e) {
if (p == NULL)
return false;
LNode *s = (LNode *) malloc(sizeof(LNode));
if (s == NULL)
return false;
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
某些情况下有可能分配失败(如内存不足)
时间复杂度:O(1)
指定节点前插
前插操作是指在某结点的前面插入一个新结点
方式一:
顺序查找到p的前驱结点q,再对结点q后插,时间复杂度为O(n)
bool InsertPriorNode(LinkList L, LNode *p, int e) {
LNode *q = L;
while (q != NULL && q->next != p) { //循环找到第i-1个结点
q = q->next;
}
if (q == NULL)
return false;
LNode *s = (LNode *) malloc(sizeof(LNode));
s->data = e;
s->next = q->next;
q->next = s; //将结点s连接到p之后
return true;
}
方式二:
设待插入结点为*s,将 *s插入到 *p 的前面,但实际操作中将 *s 插入到 *p的后面,然后将p->data与s->data交换,可以使得时间复杂度降低为O(1)
bool InsertPriorNode(LNode *p, int 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->data = p->data;
p->data = e;
return true;
}
按位序删除
删除结点操作是将单链表的第i个结点删除。先检查删除位置的合法性,后查找表中第i-1个结点,即被删结点的前驱结点,将其指针指向第i+1个结点,并释放第i个结点。
时间复杂度:
-
最坏、平均时间复杂度:O(n)
-
最好时间复杂度:O(1)
时间主要时间耗费在查找操作上
代码:
bool ListDelete(LinkList L, int i, int &e) {
if (i < 1)
return false;
LNode *p, *q;
p = GetElem(L, i - 1); //获取第i-1个节点
if (p == NULL) {
cout << "第i-1个结点不存在" << endl;
return false;
}
if (p->next == NULL) {
cout << "第i个结点不存在" << endl;
return false;
}
q = p->next;
p->next = q->next;
e = q->data;
free(q); //释放*q;
return true;
}
指定节点删除
删除某一指定的节点p,需要修改其前驱结点的next指针或者修改p结点数据
方式一:传入头指针,循环寻找p的前驱结点
链表无法逆向检索,如果p是最后一个结点,只能从表头开始依次寻找p的前驱,时间复杂度O(n)
方法二:偷天换日(类似于结点前插的实现)
交换指定结点和后一个结点的数据域,再删除新的后继结点,时间复杂度O(1),但当p是最后一个结点时会出现空指针异常
代码:
bool DeleteNode(LNode *p) {
if (p == NULL)
return false;
LNode *q = p->next;
p->data = q->data; // 和后继结点交换数据域
p->next = q->next;
free(q); // 释放后继结点存储空间
return true;
}
按位查找
在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点指针域NULL。
考虑边界条件,增强代码的健壮性
时间复杂度:O(n)
代码:
LNode *GetElem(LinkList L, int i) {
if (i < 1)
return NULL;
int j = 1;
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
//LNode *p = L;
/*带头结点*/
LNode *p = L->next;
//<----------------------------------------------------------------------》
while (p != NULL && j < i) {
p = p->next;
j++;
}
return p; //如果i大于表长,p=NULL,直接返回p即可
}
按值查找
从单链表的第一个结点开始,由前往后依次比较表中各结点数据域的值,若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL。
时间复杂度:O(n)
代码:
LNode *LocateElem(LinkList L, int x){
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
//LNode *p = L;
/*带头结点*/
LNode *p = L->next;
//<----------------------------------------------------------------------》
while(p && p->data != x){
p = p->next;
}
return p;
}
表长度
求表长操作就是计算单链表中数据结点(不含头结点)的个数,需要从第一个结点开始顺序依次访问表中的每个结点,设置一个计数器变量,每访问一个结点,计数器加1,直到访问到空结点为止,算法的时间复杂度为O(n)。
单链表的长度是不包括头结点的
代码:
int Length(LinkList L) {
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
//LNode *p = L;
/*带头结点*/
LNode *p = L->next;
//<----------------------------------------------------------------------》
int len = 0;
while (p) {
len++;
p = p->next;
}
return len;
}
打印
遍历链表,按链表遍历顺序打印元素内容
代码:
void PrintList(LinkList L) {
cout << "长度为:length = " << Length(L) << endl;
cout << "数据为:" << endl;
//<----------------------------------二选一-------------------------------》
/*不带头结点*/
//LNode *mid = L;
/*带头结点*/
LNode *mid = L->next;
//<----------------------------------------------------------------------》
int i = 0;
while (mid != NULL) {
cout << "data[" << i << "] = " << mid->data << endl;
mid = mid->next;
i++;
}
}
双链表
为了克服单链表的只能从头结点依次顺序地向后遍历的缺点,引入了双链表,双链表结点中有两个指针prior和next,分别指向其前驱结点和后继结点
与单链表相比,存储密度比单链表低
代码:
typedef struct DNode {
int data; //数据域
struct DNode *prior, *next; //前驱和后继指针
} DNode, *DLinkList;
基本操作
双链表在单链表的结点中增加了一个指向其前驱的prior指针,双链表中的按值查找和按位查找的操作与单链表的相同,但双链表在插入和删除操作的实现上,与单链表有着较大的不同。由于双链表可以很方便地找到其前驱结点,其插入、删除操作的时间复杂度仅为 O(1)
初始化
双链表与单链表一样,为了操作方便也可以加入头结点,那么初始化双链表其实就是定义一个头结点,然后将指针域置空。
bool InitList(DLinkList &L) {
L = (DNode *) malloc(sizeof(DNode)); //分配头结点
if (L == NULL) {
return false;
}
L->prior = NULL; //头结点的prior永远指向NULL
L->next = NULL;
return true;
}
插入
在双链表中p所指的结点之后插入结点*s
代码:
bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) {
return false;
}
s->next = p->next;
if (p->next != NULL) { //如果p结点有后继结点
p->next->prior = s;
}
s->prior = p;
p->next = s;
return true;
}
删除
删除p结点的后继结点q
代码:
bool DeleteNextDNode(DNode *p) {
if (p == NULL) {
return false;
}
DNode *q = p->next;
if (q == NULL) { //p没有后继
return false;
}
p->next = q->next;
if (q->next != NULL) { //q不是尾结点
q->next->prior = p;
}
free(q); //释放结点空间
return true;
}
void DestoryList(DLinkList &L) {
while (L->next != NULL) { //循环释放各个节点
DeleteNextDNode(L);
}
free(L); //释放头结点
L = NULL; //头指针指向NULL
}
遍历
双链表不可随机存取,按位查找、按值查找操作都只能用遍历的方式实现,时间复杂度(n)
DNode *ListFind(DLinkList &L, int x) {
DNode *cur = L->next;
while (cur != L) {
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
判空
当头结点的next指针和prior为空时,双链表为空
bool Empty(DLinkList &L) {
if (L->next == NULL && L->prior == NULL) {
return true;
} else {
return false;
}
}
循环链表
循环单链表
循环单链表和单链表的区别在于,表中最后一个结点的指针不是NULL,而改为指向头结点,从而整个链表形成一个环。
在循环单链表中,表尾结点*r的next域指向L,表中没有指针域为NULL的结点。循环单链表可以从表中的任意一个结点开始遍历整个链表,对循环单链表不设头指针而仅设尾指针,可以使得操作效率更高
r->next即为头指针,对表头与表尾进行操作都只需要O(1)的时间复杂度
代码:
typedef int ElemType;
typedef struct LNode {
ElemType data; //数据域
struct LNode *next; //节点域
} LNode, *CyclicList;
基本操作
循环单链表的插入、删除算法与单链表几乎一样,所不同的是若操作是在表尾进行,则执行的操作不同,以让单链表继续保持循环的性质
1、初始化
bool InitList(CyclicList &L) {
L = (LNode *) malloc(sizeof(LNode)); //向内存请求存储空间
if (L == NULL) {
return false;
}
L->next = L; //使头结点指向自己
return true;
}
2、查找
LNode *FindNode(CyclicList L, ElemType e) {
CyclicList current = L->next; // 从头节点的next开始遍历
do {
if (current->data == e) {
return current;
}
current = current->next;
} while (current != L->next); // 当遍历回到头节点的next时停止
return NULL;
}
应用场景:很多时候对链表的操作都是在头部或尾部,可以让L指向表尾元素(插入、删除时可能需要修改L)
3、判断是否为表尾元素
bool isTail(CyclicList &L, LNode *p) {
if (p->next == NULL) {
return true;
} else {
return false;
}
}
4、判空
bool Empty(CyclicList &L) {
if (L->next == NULL) {
return true;
} else {
return false;
}
}
5、销毁
void ClearList(CyclicList &L) {
CyclicList current = L, nextNode;
while (current != L) {
nextNode = current->next;
free(current);
current = nextNode;
}
free(L);
L = NULL;
}
6、打印
void TraverseList(CyclicList L) {
if (L == NULL) return;
CyclicList current = L->next; // 从头节点的next开始遍历
do {
printf("%d ", current->data);
current = current->next;
} while (current != L->next); // 当遍历回到头节点的next时停止
printf("\n");
}
7、删除
void DeleteNode(CyclicList &L, ElemType e) {
CyclicList current = L, prev = NULL;
while (current->next != L && current->data != e) {
prev = current;
current = current->next;
}
if (current->data == e) {
if (prev == NULL) { // 删除头节点
L = current->next;
} else {
prev->next = current->next;
}
free(current);
} else {
printf("Element not found\n");
}
}
8、尾插
void InsertTail(CyclicList &L, ElemType e) {
CyclicList newNode = (CyclicList) malloc(sizeof(LNode));
if (newNode == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(EXIT_FAILURE);
}
newNode->data = e;
newNode->next = L->next; // 新节点的next指向头节点的next
L->next = newNode; // 头节点的next指向新节点
}
循环双链表
与循环单链表不同的是,在循环双链表中,头结点的prior指针还要指向表尾结点。某结点*p为尾结点时,p->next==L;当循环双链表为空表时,其头结点的prior 域和next 域都等于L。
代码:
typedef int ElemType;
typedef struct LNode {
ElemType data; //数据域
struct LNode *prev; //节点域
struct LNode *next; //节点域
} LNode, *CyclicDList;
基本操作
1、初始化
bool InitList(CyclicDList &L) {
L = (LNode *) malloc(sizeof(LNode)); //向内存请求存储空间
if (L == NULL) {
return false;
}
L->prev = L;
L->next = L; //使头结点指向自己
return true;
}
2、插入
bool InsertNextLNode(LNode *p, LNode *s) {
if (p == NULL || s == NULL) {
return false;
}
s->next = p->next;
p->next->prev = s;
s->prev = p;
p->next = s;
return true;
}
该代码对于双链表的尾结点的修改会存在异常,但对于循环双链表不存在该问题
3、删除
bool DeleteNextLNode(LNode *p, LNode *s) {
if (p == NULL || s == NULL) {
return false;
}
p->next = s->next;
s->next->prev = p;
free(s);
return true;
}
该代码对于双链表的尾结点的修改会存在异常,但对于循环双链表不存在该问题
4、判断是否为表尾元素
bool isTail(CyclicList &L, LNode *p) {
if (p->next == L) {
return true;
} else {
return false;
}
}
5、判空
bool Empty(CyclicList &L) {
if (L->next == L) {
return true;
} else {
return false;
}
}
静态链表
静态链表是用数组的方式实现的链表,结点有数据域data和指针域next,指针是结点的数组下标(游标),静态链表以next==-1作为其结束的标志,用一个特殊的数值标记空闲结点。
优点:
- 增、删操作不需要大量移动元素
缺点:
- 不能随机存取,只能从头结点开始依次往后查找
- 需要预先分配一块连续的内存空间,且容量固定不可变
- 存储密度较低
适用场景:
- 不支持指针的低级语言
- 数据元素数量固定不变的场景(如操作系统的文件分配表FAT)
可以将空闲节点链接成一个备用链表,从而避免插入节点前对于空闲节点的查找
代码:
typedef int ElemType;
#define MaxSize 10 //静态链表的最大长度
typedef struct { //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
} SLinkList[MaxSize];
基本操作
静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。
初始化
void initList(SLinkList &L) {
for (int i = 0; i < MaxSize - 1; i++) {
if (i == 0) {
L[i].next = -1;
continue;
}
L[i].next = -2; // 每个节点的 next 设置为-2,代表空闲
}
}
按值查找
int findNodeByValue(SLinkList &L, int value) {
int curr = L[0].next; // 从头节点开始遍历
while (curr != -1) {
if (L[curr].data == value) {
return curr; // 找到节点,返回索引
}
curr = L[curr].next;
}
return 0; // 没找到节点,返回 0
}
按索引查找
int getNodeValueByIndex(SLinkList &L, int index) {
if (index < 0 || index >= MaxSize) {
return -1; // 索引非法,返回 -1
}
return L[index].data;
}
插入
在静态链表的头部插入节点
bool insertNode(SLinkList &L, int data) {
// 从头节点开始遍历,获取可用节点的索引(即可用数组空间的下标)
int curr = 0;
while (L[curr].next != -2 && curr < MaxSize) {
++curr;
}
if (curr == MaxSize)return false;
L[curr].data = data; // 设置插入节点的数据
L[curr].next = L[0].next; // 插入结点的 next 指向原先的头结点
L[0].next = curr; // 头节点指向插入节点
return true;
}
删除
bool deleteNode(SLinkList &L, int data) {
int prev = 1; // 记录当前节点的前一个节点
int curr = L[0].next; // 从头节点开始查找
while (curr != -1) {
if (L[curr].data == data) {
break; // 找到要删除的节点,跳出循环
}
prev = curr; // 未找到,则继续向下遍历
curr = L[curr].next;
}
if (curr == -1) {
return false; // 没有找到要删除的节点,删除失败
}
L[prev].next = L[curr].next; // 前一个节点的 next 指向要删除节点的 next
L[curr].next = -2; // 被删除的节点next标记为-2
return true;
}
长度
int getListLength(SLinkList &L) {
int count = 0;
int curr = L[1].next; // 从头节点开始遍历
while (curr != 0) {
count++;
curr = L[curr].next;
}
return count;
}
打印
void printList(SLinkList &L) {
int curr = L[0].next; // 从头节点开始遍历
while (curr != -1) {
cout << L[curr].data << " ";
curr = L[curr].next;
}
cout << endl;
}
比较
顺序表 | 链表 | |
---|---|---|
存储空间 | 对于静态存储分配,需要预先分配足够大的存储空间(预分配空间过大,可能会导致空间大量闲置;预先分配过小,又易造成溢出) 对于动态存储分配,虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败 | 只需预先分配头结点或头指针的存储空间,插入节点时再申请分配节点的存储空间,内存有空间就可分配(高效、易拓展) |
存取方式 | 顺序存取、随机存取 | 顺序存取 |
存储位置 | 逻辑上相邻的元素,对应的物理存储位置也相邻 | 逻辑上相邻的元素,物理存储位置不一定相邻( 逻辑关系通过指针链接表示) |
销毁 | 对于静态数组,只需要修改Length为0,系统自动回收静态数组空间 对于动态数组,需要手动释放空间(malloc函数分配的空间需要手动free) | 需要依次删除各个节点 |
查找 | 对于按值查找,顺序表无序时复杂度为O(n),顺序表有序时时间复杂度为O(log2n)(折半查找) 对于按序号查找,顺序表支持随机访问,时间复杂度仅为O(1) | 按位查找和按值查找的平均时间复杂度都为O(n) |
插入删除 | 插入/删除元素要将后续元素都后移/前移,平均需要移动半个表长的元素,时间复杂度O(n),时间开销主要来自移动元素,代价更高 | 插入/删除元素只需修改相关结点的指针域,时间复杂度O(n),时间开销主要来自查找目标元素 |
使用场景 | 1、数组元素较稳定的线性表 2、任何高级语言中都有数组类型,语言不支持指针时 | 1、频繁进行插入、删除操作的线性表 2、难以估计线性表的长度或存储规模时 |
附:思维导图