数据结构入门之线性表(二)
概要
一、线性表的概念和性质
即线性表的逻辑结构和定义在逻辑结构上的数据运算
1.1 线性表的概念
1.1.1 线性表的定义
线性表是具有相同数据类型的n(n≥0)个数据元素的有限 序列(当n = 0时线性表是一个空表。一般表示为L = (a1, a2, … , ai, ai+1, … , an))
术语 | 解释 |
---|---|
位序 | 从1开始到表长n |
表头元素和表尾元素 | 位序为1和位序为n |
直接前驱和直接后继 | 除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继 |
1.1.2 线性表的特点
- 元素个数有限表中元素的个数有限
- 每个元素占有相同大小的存储空间
- 元素有逻辑上的顺序性(有先后次序)
1.2 线性表的性质
- 晚点写
二、线性表的存储结构
2.1 顺序表
2.1.1 顺序表的定义及特点
1. 定义
用顺序存储的方式实现线性表,即用一组地址连续的存储单元依次存储数据元素,元素之间的关系由存储单元的邻接关系来体现
2. 特点
- 随机访问(通过首地址和元素序号可在O(1)内找到指定元素)
- 存储密度高(每个节点只存储数据元素)
- 插入、删除需移动大量元素
- 拓展容量不方便(全部复制到新存储空间时间开销大)
2.1.2 顺序表的两种实现方式
- 静态数组实现(定长顺序存储):栈区,数组长度不能改变
- 动态数组实现(堆分配存储):堆区,数组长度可以改变
1. 静态分配实现顺序表
数据空间的大小和空间已经固定不能修改
代码实现:
#define MaxSize 10 //定义最大长度
//静态分配实现顺序表结构体(初始化需对数据元素和长度赋初值)
typedef struct{
ElemType data[MaxSize]; //用静态的“数组”存放数据元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义(Sq:sequence 顺序,序列)
2. 动态分配实现顺序表
数据空间占满可开辟更大的存储空间替换(注意要new和delete)
代码实现:
#define InitSize 10 //顺序表的初始长度
//动态分配实现顺序表结构体(初始化需对数据元素和长度赋初值)
typedef struct{
ElemType *data; //动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
} SeqList; //顺序表的类型定义
2.2 链式表
这里先介绍单链表
2.2.1 单链表的定义及特点
1. 定义
用链式存储的方式实现线性表,每个结点除了存放数据元素外(数据域),还要存储指向下一个节点的指针(指针域)
各结点间的先后关系用一个指针表示
2. 特点
- 插入、删除方便
- 拓展容量方便
- 不能随机访问(找某个结点时要从头遍历)
- 存储密度低(每个节点存储数据元素和指针)
2.2.2 单链表的两种实现方式
要表示一个单链表时,只需声明一个头指针L,指向单链表的第一个结点,分为不带头结点的单链表和带头结点的单链表
- 不带头结点实现:对第一个数据结点和后续数据结点的处理、对空表和非空表的处理都需要不同的代码逻辑
如判空:
不带头结点:L == NULL
带头结点:L->next == NULL
- 带头结点实现:方便
代码实现:
//结点结构(带头结点和不带头结点的结点结构)
typedef struct LNode{
ElemType data; //每个结点存放的数据元素
struct LNode *next; //指向下一个结点
}LNode, *LinkList;
typedef <数据类型> <别名> --数据类型重命名
typedef <struct LNode> <LNode>(强调是一个结点) =
typedef <struct LNode> <*LinkList>(强调是一个单链表--指向第一个结点的指针)
2.3 顺序表和链式表的比较
- 顺序表
优点:可随机存取,存储密度高
缺点:要求大片连续空间,改变容量不方便 - 单链表
优点:不要求大片连续空间,改变容量方便
缺点:不可随机存取,要耗费一定空间存放指针,找某个结点时要从头遍历
三、线性表的基本操作
对于不同存储结构有着不同的运算实现
3.1 创销赋清、增删改查
创销赋清 | 解释 |
---|---|
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个位置的元素的值 |
3.2 其他操作
其他操作 | 解释 |
---|---|
Empty(L) | 判空:若L为空表,则返回true,否则返回false |
Length(L) | 求表长:返回线性表L的长度,即L中数据元素的个数 |
PrintList(L) | 输出:按前后顺序输出线性表L的所有元素值。 |
3.3 用顺序表实现基本运算
顺序表有两种实现方式:静态分配和动态分配
#define MaxSize 10 //静态分配顺序表的最大长度
#define InitSize 10 //动态分配顺序表的初始长度
//1.静态分配实现顺序表结构体(初始化需对数据元素和长度赋初值)
typedef struct{
ElemType data[MaxSize]; //用静态的“数组”存放数据元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义(Sq:sequence 顺序,序列)
//2.动态分配实现顺序表结构体(初始化需对数据元素和长度赋初值)
typedef struct{
ElemType *data; //动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
} SeqList; //顺序表的类型定义
3.3.1 创
初始化时动态申请和释放堆区内存空间的方法:
- C —— malloc、free 函数
- C++ —— new、delete 关键字
malloc 函数返回一个指针,需要强制转型为你定义的数据元素类型指针,否则无法知道下一个元素的地址(不同数据类型所占空间不同)。如L.data = (ElemType *)malloc (sizeof(ElemType) * InitSize);
代码实现:
//1.静态分配
void InitList(SqList &L){
for(int i = 0; i<MaxSize;i++)//当L.length=0时,可不用赋初值
{
L.data[i]=0;
}
L.length = 0;
}
//2.动态分配
void InitList(SqList &L){
L.data = new ElemType[InitSize]; //释放时要delete[] L.data;
L.MaxSize = InitSize;
L.length = 0;
}
//2.1动态分配的顺序表可增加动态数组的长度(最大存储空间)
void IncreaseSize(SqList &L, int len){
ElemType *p = L.data; //p作为原数组指针的副本,用于复制原数组中的数据元素
L.data = new ElemType[MaxSize+len] //L.data
for(i = 0; i < L.length; i++)
{
L.data[i] = p[i];
}
L.MaxSize += len; //L.MaxSize
//L.length不变
delete[] p;
}
增加动态数组最大长度时,一定记得释放delete原来的空间!!!
3.3.2 增
时间复杂度:O(n)
代码实现:
考虑健壮性:插入位序i是否有效;当前顺序表空间是否已满
静态分配和动态分配操作相同
//1.静态分配
bool ListInsert(SqList &L, int i, int e){ //注意加引用符号
//1.健壮性
if(L.length>=MaxSize)
return false;
if(i<1 || i>L.length+1)
return false;
//2.下标为i-1及其之后的元素依次后移
for(int j=L.length-1; j>=i-1; j--)
L.data[j+1] = L.data[j];
//3.插入
L.data[i-1] = e;
//4.更改顺序表属性length
L.length++;
return true;
}
3.3.3 删
时间复杂度:O(n)
代码实现:
考虑健壮性:插入位序i是否有效
静态分配和动态分配操作相同
//1.静态分配
bool ListDelete(SqList &L, int i, int &e){ //注意加引用符号
//1.健壮性
if(i<1 || i>L.length)
return false;
//2.提取删除元素
e = L.data[i-1];
//3.下标为i-1及其之后的元素依次前移
for(int j=i-1;j<L.length-1;j++)
L.data[j] = L.data[j+1];
//4.更改顺序表属性length
L.length--;
return true;
}
3.3.4 查
分为按位查找操作和按值查找操作
1. 按位查找操作
时间复杂度:O(1)
代码实现:
考虑健壮性:插入位序i是否有效
静态分配和动态分配操作相同
//1.静态分配
ElemType GetElem(SqList L, int i){
//健壮性
if(i<1 || i>L.length)
return false;
return L.data[i-1];
}
2. 按值查找操作
时间复杂度:O(n),遍历数组
代码实现:
静态分配和动态分配操作相同
//1.静态分配
int LocateElem(SqList L, ElemType e){
for(int i = 0;i<L.length;i++)
{
if(L.data[i]==e) //若L.data[i]是结构体变量需要依次对比各个分量来判断两个结构体是否相等
return i+1;
}
return 0; //查找失败返回0
}
3.4 用单链表实现基本运算
单链表有两种实现方式:不带头结点的和带头结点的实现
结点结构是一样的
//结点结构
typedef struct LNode{
ElemType data; //每个节点存放的数据元素
struct LNode *next;//指向下一个节点
}LNode, *LinkList
.单链表的建立:
分为头插法和尾插法
尾插法:
3.4.1 创
代码实现:
健壮性:判断分配的指针是否为空(内存分配失败)
//1. 不带头结点的初始化
bool InitList(LinkList &L){
L = NULL; //空表,无结点,防止脏数据
return true;
}
//2. 带头结点的初始化
bool InitList(LinkList &L){
//1.分配头结点
L = new LNode;
//2.健壮性
if (L = NULL)//内存不足,分配失败
return false;
//3.更改头结点属性next指针(头结点一般不存放数据)
L->next = NULL //头结点之后暂时没有结点
return true;
}
一般先声明一个指向单链表的指针LinkList L; 再初始化InitList(L);
3.4.2 增
- 按位序插入
(按位序插入可看成找到第i-1个元素+指定(第i-1个)结点的后插) - 指定结点的后插
- 指定结点的前插
(指定结点的前插可看成指定结点的后插+交换两结点中的数据)
1. 按位序插入
找到第i-1个结点,将新结点插入其后(头结点可看作是第0个结点)
时间复杂度:O(n)
代码实现:
考虑健壮性:插入位序i是否大于等于1,是否存在第i-1个元素
//1.带头结点的插入
bool ListInsert(LinkList &L, int i, ElemType e){
//1.健壮性1
if(i<1)
return false;
//2.找到第i-1个元素
LNode *p = L;//p为当前扫描到的结点,初始化为第0个头结点
int j = 0; //j指示当前是第几个结点
//(每次循环结束,p指向第j个结点(使退出循环时p指向第i-1个结点))
while(p!=NULL && j<i-1){
p = p->next;
j++;
}
//健壮性2:第i-1个结点及其之前结点出现空,则插入不了
if(p==NULL)
return false;
//3.创建新的结点s,并更新结点s的属性(可以加一次判断内存分配是否失败)
LNode *s = new LNode;
s->data = e;
s->next = p->next;
//4.插入到第i-1个元素之后,更新结点p的属性
p->next = s;
return true;
}
//2.不带头结点的插入(不存在第0个结点,i=1时插入删除需要更改头指针L)
bool ListInsert(LinkList &L, int i, ElemType e){
//1.健壮性1
if(i<1)
return false;
//2. i=1时特殊处理
if(i == 1)
{
LNode *s = new LNode;
s->data = e;
s->next = L->next;
L = s; //头指针指向新结点
return true;
}
//3.找到第i-1个元素
LNode *p = L;//p为当前扫描到的结点,初始化为第1个结点
int j = 1; //j指示当前是第几个结点
//(每次循环结束,p指向第j个结点(使退出循环时p指向第i-1个结点))
while(p!=NULL && j<i-1){
p = p->next;
j++;
}
//健壮性2:判断该结点是否已不指向该堆区数据元素(第i-1个结点及其之前结点出现空,则插入不了)
if(p==NULL)
return false;
//4.创建新的结点s,并更新结点s的属性
LNode *s = new LNode;
s->data = e;
s->next = p->next;
//5.插入到第i-1个元素之后,更新结点p的属性
p->next = s;
return true;
2. 指定结点的后插
在p结点之后插入元素e(按位序插入可看成找到第i-1个元素+指定(第i-1个)结点的后插)
时间复杂度:O(1)
代码实现:
考虑健壮性:结点p!=NULL
//1.带头结点的后插
bool ListInsert(LNode *p,ElemType e){
//1.健壮性
if(p == NULL)
return false;
//2.创建新的结点s,并更新结点s的属性(可以加一次判断s==NULL内存分配是否失败)
LNode *s = new LNode;
s->data = e;
s->next = p->next;
//3.插入到第i-1个元素之后,更新结点p的属性
p->next = s;
}
3. 指定结点的前插
在p结点之前插入新结点
两种方法:
- 法一:遍历查找结点p的前驱结点q,再对q后插,时间复杂度为O(n)
- 法二:将新结点插入到p结点之后,然后互换两个结点的数据,时间复杂度为O(1)(可看成指定结点的后插+交换两结点中的数据)
代码实现:
考虑健壮性:结点p!=NULL
//1.带头结点的前插(法二)
bool InsertPriorNode(LNode *p, ElemType e){
//1.健壮性
if(p==NULL)
return false;
//2.创建新的结点s,并更新结点s的属性(可以加一次判断s==NULL内存分配是否失败)
LNode *s = new LNode;
s->data = e;
s->next = p->next;
//3.插入到第i-1个元素之后,更新结点p的属性
p->next = s;
//4.互换两个结点的数据
s->data = p->data;
p->data = e;
return true;
}
3.4.3 删
- 按位序删除(可看成找到第i-1个结点,将第i个结点删除)
- 指定结点的删除
1. 按位序删除
找到第i-1个结点,将其指针指向第i+1个结点,并释放第i个结点
时间复杂度:O(n)
代码实现:
考虑健壮性:插入位序i是否大于等于1,是否存在第i-1个元素、是否存在第i个元素
//1.带头结点的按位序删除
bool ListDelete(LinkList &L, int i, ElemType &e){
//1.健壮性1
if(i<1)
return false;
//2.找到第i-1个结点
LNode *p = L;//p为当前扫描到的结点,初始化为第0个头结点
int j = 0; //j指示当前是第几个结点
//(每次循环结束,p指向第j个结点(使退出循环时p指向第i-1个结点))
while(p!=NULL && j<i-1){
p = p->next;
j++;
}
//健壮性2:第i-1个结点及其之前结点出现空、第i-1个结点之后无其他结点,则删除不了
if(p==NULL)
return false;
if(p->next==NULL)
return false;
//3.指向被删除结点q(作为副本用于复制给结点p/元素e、然后释放该结点)
LNode *q = p->next;
//4.更改结点p属性,删除的数据e
p->next = q->next;
e = q->data;
//5.释放被删除结点的存储空间
delete q;
return true;
}
2. 指定结点的删除
删除指定结点p
- 法一:遍历查找结点p的前驱结点q,删除p后修改前驱结点的next指针,时间复杂度为O(n)
- 法二:互换结点p和其后继结点q的数据,然后将后继结点删除,时间复杂度为O(1)(可看成指定结点的后插+交换两结点中的数据)
法二的局限性:若p结点是最后一个结点,则只能用法一找到结点p的前驱
代码实现:
考虑健壮性:结点p!=NULL,q->next!=NULL
//1.带头结点的指定结点的删除(法二)
bool DeleteNode(LNode *p){
//1.健壮性
if(p==NULL)
return false;
//2.指向被删除结点q(后继结点)(作为副本用于复制给结点p、然后释放该结点)
LNode *q = p->next; //可增加判断q==NULL,因为p可能是最后一个结点
//3.更新结点p的属性
p->data = q->data;
p->next = q->next;
//4.释放后继结点的存储空间
delete q;
return true;
}
3.3.4 查
分为按位查找操作和按值查找操作
1. 按位查找操作
返回第i个元素
时间复杂度:O(n)
代码实现:
考虑健壮性:查找位序i是否大于等于1
//1.带头结点的按位查找
LNode* GetElem(LinkList L, int i){
//1.健壮性1
if(i<1)
return NULL;
//2.找到第i个元素
LNode *p = L;//p为当前扫描到的结点,初始化为第0个头结点
int j = 0; //j指示当前是第几个结点
//(每次循环结束,p指向第j个结点(使退出循环时p指向第i个结点))
while(p!=NULL && j<i){
p = p->next;
j++;
}
return p;
2. 按值查找操作
时间复杂度:O(n),遍历数组
代码实现:
//1.带头结点的按值查找
LNode * LocateElem(LinkList L, ElemType e){
//1.当前查找的结点(从第一个结点开始查找)
LNode *p = L->next;
//2.循环查找
while(p!=NULL && p->data != e){
p = p->next;
}
return p; //找到则返回该结点指针,否则返回NULL
}
3.3.5 赋
初始化后就是将数据元素存入到单链表中,两种方法:
核心就是对指定结点的后插操作
- 尾插法(设置一个表尾指针r)
- 头插法(头插法的重要应用:实现链表的逆置)
1. 尾插法
每次取一个数据元素,插入到表尾(对尾结点的后插操作)
代码实现:
//1.带头结点的尾插法建立单链表
LinkList List_TailInsert(LinkList &L, ElemType x[]){
int length = x.size();
LNode *r = L; //r为表尾指针,初始化为头结点
for(int i = 0; i < length; ++i){
//创建新的结点s,并更新结点s的属性(可以加一次判断内存分配是否失败)
LNode *s = new LNode;
s->data = x[i];
s->next = NULL; //也可删去该句,在循环结束后加上r->next = NULL
//插入到尾结点后面
r->next = s;
//更新尾结点r
r = s;
}
return L;
}
2. 头插法(可实现链表的逆置)
每次取一个数据元素,插入到表头(对头结点的后插操作)
代码实现:
//1.带头结点的头插法建立单链表
LinkList List_HeadInsert(LinkList &L, ElemType x[]){
int length = x.size();
for(int i = 0; i < length; ++i){
//创建新的结点s,并更新结点s的属性(可以加一次判断内存分配是否失败)
LNode *s = new LNode;
s->data = x[i];
s->next = L->next;
//插入到头结点后面,更新头结点的next指针
L->next = s;
}
return L;
}
3.3.6 其他
1. 判断是否空表操作
bool Empty(LinkList L){
if (L==NULL)//若带头结点则判断条件改为:if (L->next == NULL)
return true;
else
return false;
}
2. 带头结点的求表长度
//1.带头结点的求表长度
int Length(LinkList L){
int len = 0;
LNode *p = L->next;
while(p!=NULL){
p = p->next;
++len;
}
return len;
}
四、其他链式表实现基本运算
4.1 双链表
//双链表结点结构
typedef struct DNode{
ElemType data; //每个结点存放的数据元素
struct DNode *prior, *next; //前驱和后继指针
}DNode, *DLinkList;
以下都是基于带头结点的
4.1.1 创
代码实现:
健壮性:判断分配的指针是否为空(内存分配失败)
//带头结点的初始化
bool InitDLinkList(DLinkList &L){
//1.分配头结点
L = new DNode;
//2.健壮性
if (L == NULL)//内存不足,分配失败
return false;
//3.更改头结点属性next指针(头结点一般不存放数据)
L->prior = NULL; //头结点的prior永远指向NULL
L->next = NULL; //头结点之后暂时没有结点
return true;
}
4.1.2 增
指定结点的插入:在p结点之后插入s结点
代码实现:
健壮性:p != NULL, s !=NULL, p的后继是否为空
//带头结点的指定结点的插入
bool InsertNextDNode(DNode *p, DNode *s){
//1.健壮性
if(p == NULL || s == NULL)
return false;
//2.从后往前更改三个结点的属性
if(p->next != NULL) //结点p的后继(健壮性:如果有后继结点)
p->next->prior = s;
s->next = p->next; //要插入的结点s
s->prior = p;
p->next = s; //结点p
return true;
}
4.1.3 删
删除指定结点的后继结点
代码实现:
健壮性:判断分配的指针是否为空(内存分配失败)
//带头结点的删除指定结点的后继结点
bool DeleteNextDNode(DNode *p){
//1.健壮性
if(p==NULL)
return false;
//2.指向被删除结点q(后继结点)并判断q==NULL,因为p可能是最后一个结
DNode *q = p->next;
if(q==NULL)
return false;
//3.从后往前更改两个结点的属性(也可从前往后更新)
if(q->next != NULL) //结点q的后继(健壮性:如果有后继结点)
q->next->prior = p;
p->next = q->next; //结点q的前驱(即p结点)
//4.释放结点q的存储空间
delete q;
return true;
}
4.1.4 遍历
4.2 循环链表
-
循环单链表:表尾结点的next指针指向头结点(结点结构与单链表相同)
从一个结点出发可以找到其他任何一个结点
单手抱住空虚的自己
-
循环双链表:表头结点的prior指向表尾结点;表尾结点的next指向头结点(结点结构与双链表相同)
双手抱住空虚的自己
4.2.1 创
循环单链表: L->next = L;
循环双链表: L->prior = L; L->next = L;
4.2.2 增
循环双链表:不用判断结点p是否有后继结点
4.2.3 删
循环双链表:不用判断被删除结点q和q的后继结点
4.2.4 判空
循环单链表:if(L->next == L)
循环双链表:if(L->next == L)
4.3 静态链表
分配一整片连续的内存空间,各个结点集中安置,用数组的方式实现的链表
- 优点:增、删操作不需要大量移动元素
- 缺点:不能随机存取,只能从头结点开始依次往后查找;容量固定不可变
- 适用场景:①不支持指针的低级语言;②数据元素数量固定不变的场景(如操作系统的文件分配表FAT)
代码实现:
#define MaxSize 10
//静态链表结构类型的定义
typedef struct{
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
}SLinkList[MaxSize];
//声明时:SLinkList L 相当于定义了一个长度为MaxSize的Node型数组L
4.3.1 创
把头结点a[0]的next设为-1,把其他结点的next设为一个特殊值用来表示结点空闲,如-2
4.3.2 增
插入位序为i的结点:
①从头结点出发找到位序为i-1的结点
②找到一个空的结点,存入数据元素
③修改新结点的next
④修改i-1号结点的next
4.3.3 删
删除某个结点:
①从头结点出发找到前驱结点
②修改前驱结点的游标
③被删除结点next设为-2
4.3.4 查
从头结点出发挨个往后遍历结点