首页 > 代码库 > 数据结构——线性表
数据结构——线性表
提示:以下内容不适合零基础人员,仅供笔者复习之用。
一、线性结构的基本特征:
1.集合中必存在唯一的一个“第一元素”;
2.集合中必存在唯一的一个 “最后元素”;
3.除最后元素在外,均有 唯一的后继;
4.除第一元素之外,均有 唯一的前驱。
如:java中的List接口,就是线性表。ArrayList就是顺序线性表,LinkedList就是链表线性表。
二、线性表的基本操作:
1.InitList(*L): 初始化操作,建立一个空的线性表L。
2.ListEmpty(L): 判断线性表是否为空表,若线性表为空,返回true,否则返回false。
3.ClearList(*L): 将线性表清空。
4.GetElem(L,i,*e): 将线性表L中的第i个位置元素值返回给e。
5.LocateElem(L,e): 在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功;否则,返回0表示失败。
6.ListInsert(*L,i,e): 在线性表L中第i个位置插入新元素e。
7.ListDelete(*L,i,*e): 删除线性表L中第i个位置元素,并用e返回其值。
8.ListLength(L): 返回线性表L的元素个数。
——对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的。对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。
三、两种不同的线性表
我们知道,数据结构分为逻辑结构和物理结构,逻辑结构分为集合结构、线性结构、树形结构和图形结构四大类。物理结构分为顺序存储结构和链式存储结构。
线性表是线性结构的一种,那么线性表当然也有物理结构,也就是说,线性表有两种,分别是顺序结构的线性表(叫做顺序表)和链式结构的线性表(叫做链表)。
3.1 顺序存储结构的线性表
3.1.1 定义
指的是用一段地址连续的存储单元依次存储线性表的数据元素。和数组不一样,数组的长度是存放线性表的存储空间的长度,存储分配后这个量一般是不变的。线性表是线性表中数据元素的个数,随着插入和删除的操作,长度会变。所以,这里要区分两个概念,即数组长度和线性表的长度是不一样的。在任意时刻,线性表的长度应该小于等于数组的长度。
3.1.2 存储方式
因为每个数据元素的类型都相同,所以可以使用一维数组来实现。结构代码如下:
//线性表的顺序存储结构
#define MAXSIZE 20;//存储空间初始分配量为20
typedef int ElemType;//数据类型为int
type struct
{
ElemType data[MAXSIZE];//数组存储数据元素
int length;//线性表长度
}SqList;
这里可以看到,顺序存储结构需要三个属性:
- 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
- 线性表的最大存储容量:数组长度MaxSize。
- 线性表的当前长度:length。
3.1.3 地址计算方法
若每个存储元素占用c个存储单元,那么线性表中元素的位置可以由此计算出:
通过这个公式,可随时算出线性表中任意位置的地址,使用相同的时间。它的存取时间性能为O(1),这一特点的存储结构称之为随机存取结构。
3.1.4 操作
获取元素:
#define MAXSIZE 20 //存储空间初始分配量
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
typedef int ElemType; //ElemType类型根据实际情况而定,这里设为int
Status GetElem(SqList L, int i, ElemType *e){//获取元素
if (L.length == 0 || i<1 || i>L.length){
return ERROR;
}
*e = L.data[i - 1];
return OK;
}
插入元素:
- 如果插入位置不合理,抛出异常
- 如果线性表长度大于等于数组长度,则抛出异常或者动态增加容量
- 从最后一个元素开始向前遍历到第i个位置,分别将它们向后移动一个位置
- 将要插入元素填入位置i处
- 表长度加1
Status ListInsert(SqList L, int i, ElemType e){//插入操作
int k;
if (L.length == MAXSIZE){//顺序线性表已满
return ERROR;
}
if (i<1 || i>L.length + 1){//当i不在范围内时
return ERROR;
}
if (i <= L.length){//若插入数据的位置不在表尾
for (k = L.length - 1; k >= i - 1; k--)
{
L.data[k + 1] = L.data[k];
}
}
L.data[i - 1] = e;//将新元素插入
L.length++;
return OK;
}
删除元素:
- 如果删除位置不合理,抛出异常
- 取出删除元素
- 从删除元素位置开始遍历到最后一个元素位置,分别将它们向前移动一个位置
- 表长减1
Status ListDelete(SqList L, int i, ElemType *e){//删除操作
int k;
if (L.length==0){//线性表为空
return ERROR;
}
if (i<1 || i>L.length + 1){//删除位置不正确
return ERROR;
}
*e = L.data[i - 1];
if (i < L.length){//将删除位置的后继元素前移
for (k = i; k < L.length; k++)
{
L.data[k - 1] = L.data[k];
}
}
L.length--;
return OK;
}
3.1.5 时间复杂度
在存、读数据时,不管是哪个位置,时间复杂度都是O(1);而插入或删除操作时,时间复杂度都是O(n)。
3.1.6 优缺点
优点:
- 无须为表示表中元素之间的逻辑关系而增加额外的存储空间
- 可以快速地存取表中任一位置的元素
缺点:
- 插入和删除操作需要移动大量元素
- 当线性表长度变化较大时,难以确定存储空间的容量
- 造成存储空间的“碎片”
3.2 链式存储结构的线性表
3.2.1 定义
单链表:n个结点(ai的存储映像,每个结点中只包括一个指针域)链接成一个链表,即为线性表(a1,a2,….an)的链式存储结构。
头指针:链表中第一个结点的存储位置。
头结点:有时为了便于操作,在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存信息,可以存线性表的长度等附加信息,头结点的指针域指向第一个结点的指针。
头指针和头结点的区别:
头指针
- 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
- 头指针具有标识作用,常用头指针冠以链表的名字
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素
头结点
- 头结点是为了操作的统一和方便设立的,在第一个元素的结点之前,其数据域一般无意义(或存放链表长度)。
- 有了头结点,对在第一个元素结点前插入结点和删除第一结点,其操作与其他结点的操作就统一了
- 头结点不一定是链表必须要素
3.2.2 线性表链式存储结构
typedef struct Node{//线性表的单链表存储结构
ElemType data;
struct Node *next;
}Node;
typedef struct Node *LinkList;//定义LinkList
3.2.3 单链表的读取
- 声明一个结点p指向链表第一个结点,初始化j从1开始
- 当j < i时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1
- 若到链表末尾p为空,则说明第i个元素不存在
- 否则查找成功,返回结点p的数据
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:用e返回L中第i个数据元素的值 */
Status GetElem(LinkList L,int i,ElemType *e)
{
int j;
LinkList p; /* 声明一结点p */
p = L->next; /* 让p指向链表L的第一个结点 */
j = 1; /* j为计数器 */
while (p && j<i) /* p不为空或者计数器j还没有等于i时,循环继续 */
{
p = p->next; /* 让p指向下一个结点 */
++j;
}
if ( !p || j>i )
return ERROR; /* 第i个元素不存在 */
*e = p->data; /* 取第i个元素的数据 */
return OK;
}
说白了,就是从头开始找,直到第i个元素为止。最好情况的时间复杂度为O(1),最坏情况的时间复杂度为O(n)。
3.2.4 单链表的插入
① s->next = p->next;
② p->next = s;
注意以上顺序不能颠倒,否则p->next给覆盖成s的地址了,这样,a(i+1)结点就没有了上级。
算法思路:
- 声明一个结点p指向链表第一个结点,初始化j从1开始
- 当j<1时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1
- 若到链表末尾p为空,则说明第i个元素不存在
- 否则查找成功,在系统中新生成一个空结点s
- 将数据元素e赋值为s->data
- 单链表的插入标准语句s->next=p->next;p->next=s;
- 返回成功
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L), */
/* 操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1 */
Status ListInsert(LinkList *L,int i,ElemType e)
{
int j;
LinkList p,s;
p = *L;
j = 1;
while (p && j < i) /* 寻找第i个结点 */
{
p = p->next;
++j;
}
if (!p || j > i)
return ERROR; /* 第i个元素不存在 */
s = (LinkList)malloc(sizeof(Node)); /* 生成新结点(C语言标准函数) */
s->data = e;
s->next = p->next; /* 将p的后继结点赋值给s的后继 */
p->next = s; /* 将s赋值给p的后继 */
return OK;
}
3.2.5 单链表的删除
实际上就是一步,p->next=p->next->next;用q取代p->next,即是:
q=p->next;
p->next=q->next;
算法思路:
- 声明一个结点p指向链表第一个结点,初始化j从1开始
- 当j<1时,就遍历链表,让p的指针向后移动,不断指向下一个结点,j累加1
- 若到链表末尾p为空,则说明第i个元素不存在
- 否则查找成功,将要删除的结点p->next赋值给q
- 单链表的删除标准语句p->next=q->next;
- 将q结点中的数据赋值给e,作为返回
- 释放q结点
- 返回成功
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1 */
Status ListDelete(LinkList *L,int i,ElemType *e)
{
int j;
LinkList p,q;
p = *L;
j = 1;
while (p->next && j < i) /* 遍历寻找第i个元素 */
{
p = p->next;
++j;
}
if (!(p->next) || j > i)
return ERROR; /* 第i个元素不存在 */
q = p->next;
p->next = q->next; /* 将q的后继赋值给p的后继 */
*e = q->data; /* 将q结点中的数据给e */
free(q); /* 让系统回收此结点,释放内存 */
return OK;
}
3.2.6 单链表操作的时间复杂度
分析单链表的插入和删除算法,第一步就是遍历查找到第i个元素;第二步就是插入和删除元素。容易看出,它们的时间复杂度都是O(n),如果在不知道第i个元素的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个元素,对于顺序存储结构意味着,每一次插入都需要移动n-i个元素,每次都是O(n),而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。
3.2.7 单链表的整表创建
因为单链表占用空间的大小和位置是不需要预先分配划定的,可以根据系统的实际情况和需求即时生成。其创建的过程就是一个动态生成链表的过程,即从“空表”的初始状态起,依次建立各元素结点,并逐个插入链表。
算法思路:
- 声明一结点p和计数器变量i
- 初始化一空链表L
- 让L的头结点的指针指向NULL,即建立一个带头结点的单链表
- 循环:
生成一个新结点赋值给p
随机生成一数字赋值给p的数据域p->data
将p插入到头结点与前一新结点之间
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(头插法) */
void CreateListHead(LinkList *L, int n)
{
LinkList p;
int i;
srand(time(0)); /* 初始化随机数种子 */
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL; /* 先建立一个带头结点的单链表 */
for (i=0; i<n; i++)
{
p = (LinkList)malloc(sizeof(Node)); /* 生成新结点 */
p->data = http://www.mamicode.com/rand()%100+1; /* 随机生成100以内的数字 */
p->next = (*L)->next;
(*L)->next = p; /* 插入到表头 */
}
}
以上是使用头插法实现,还可以使用尾插法实现,即按排队顺序,先来后到,每次加入的新结点都插在终端结点后面:
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(尾插法) */
void CreateListTail(LinkList *L, int n)
{
LinkList p,r;
int i;
srand(time(0)); /* 初始化随机数种子 */
*L = (LinkList)malloc(sizeof(Node)); /* L为整个线性表 */
r=*L; /* r为指向尾部的结点 */
for (i=0; i<n; i++)
{
p = (Node *)malloc(sizeof(Node)); /* 生成新结点 */
p->data = http://www.mamicode.com/rand()%100+1; /* 随机生成100以内的数字 */
r->next=p; /* 将表尾终端结点的指针指向新结点 */
r = p; /* 将当前的新结点定义为表尾终端结点 */
}
r->next = NULL; /* 表示当前链表结束 */
}
3.2.8 单链表的整表删除
算法思路:
- 声明一结点p和q
- 将第一个结点赋值给p
- 循环:
将下一结点赋值给q
释放p
将q赋值给p
/* 初始条件:顺序线性表L已存在。操作结果:将L重置为空表 */
Status ClearList(LinkList *L)
{
LinkList p,q;
p=(*L)->next; /* p指向第一个结点 */
while(p) /* 没到表尾 */
{
q=p->next;
free(p);
p=q;
}
(*L)->next=NULL; /* 头结点指针域为空 */
return OK;
}
提示:q变量很重要,不能直接free(p);因为:p是一个结点,除了数据域,还有指针域。free(p);是对整个结点进行删除和内存释放的工作。而变量q的作用是,使得下一个结点得到了记录,以便于释放当前结点后,把下一结点拿回来补充。(类似皇帝的遗嘱)
3.3 单链表结构与顺序存储结构优缺点
对单链表结构和顺序存储作对比:
经分析,可得出一些经验结论:
- 若线性表需要频繁查找,很少进行插入、删除操作,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。比如游戏开发中,用户注册的个人信息,除注册时插入数据外,绝大多数是读取,所以应该考虑顺序存储结构。而玩家的武器或装备列表,可能随时增加或减少,这时可以考虑单链表结构。
- 当线性表中的元素个数变化较大或根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而事先知道线性表的大致长度,比如一年12个月,这种用顺序结构效率会高很多。
3.4 静态链表(链表的游标实现)
用数组描述的链表叫做静态链表。数组的每个下表都对应一个data和一个cur,数据域data用于存放数据元素,cur相当于单链表中的next指针,存放该元素后继在数组中的下标。
/* 线性表的静态链表存储结构 */
#define MAXSIZE 1000 /* 存储空间初始分配量 */
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef char ElemType; /* ElemType类型根据实际情况而定,这里假设为char */
/* 线性表的静态链表存储结构 */
typedef struct
{
ElemType data;
int cur; /* 游标(Cursor) ,为0时表示无指向 */
} Component,StaticLinkList[MAXSIZE];
补充概念:备用链表——未被使用的数组元素。
静态链表特点:
- 数组的第一个元素,即下标为0的元素的cur就存放备用链表的第一个结点的下标;
- 数组的最后一个元素的cur则存放第一个有数值的元素的下标,相当于单链表中头结点的作用,当整个链表为空时为0。
3.4.1 初始化:
/* 将一维数组space中各分量链成一个备用链表,space[0].cur为头指针,"0"表示空指针 */
Status InitList(StaticLinkList space)
{
int i;
for (i=0; i<MAXSIZE-1; i++)
space[i].cur = i+1;
space[MAXSIZE-1].cur = 0; /* 目前静态链表为空,最后一个元素的cur为0 */
return OK;
}
假设已存入甲、乙、丁、戊、己、庚等数据,则存储分配示意如下:
3.4.2 插入:
静态链表中要解决的是,如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放。可将所有未被使用的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新结点。
/* 若备用空间链表非空,则返回分配的结点下标,否则返回0 */
int Malloc_SSL(StaticLinkList space)
{
int i = space[0].cur; /* 当前数组第一个元素的cur存的值 */
/* 就是要返回的第一个备用空闲的下标 */
if (space[0]. cur)
space[0]. cur = space[i].cur; /* 由于要拿出一个分量来使用了, */
/* 所以我们就得把它的下一个 */
/* 分量用来做备用 */
return i;
}
这段代码用于返回一个下标值,即数组头元素的cur存的第一个空闲的下标,如上图的话,应该返回7。
如果在上述存储内容中继续插入丙,步骤是,先把丙放在位置7,把乙的cur改为7,再把丙的cur改为3,这样就完成了插入。
/* 在L中第i个元素之前插入新的数据元素e */
Status ListInsert(StaticLinkList L, int i, ElemType e)
{
int j, k, l;
k = MAXSIZE - 1; /* 注意k首先是最后一个元素的下标 */
if (i < 1 || i > ListLength(L) + 1)
return ERROR;
j = Malloc_SSL(L); /* 获得空闲分量的下标 */
if (j)
{
L[j].data = http://www.mamicode.com/e; /* 将数据赋值给此分量的data */
for(l = 1; l <= i - 1; l++) /* 找到第i个元素之前的位置 */
k = L[k].cur;
L[j].cur = L[k].cur; /* (逻辑重点)把第i个元素之前的cur赋值给新元素的cur */
L[k].cur = j; /* (逻辑重点)把新元素的下标赋值给第i个元素之前元素的cur */
return OK;
}
return ERROR;
}
调用时输入i为3,
第4行k=MAXSIZE-1=999。
第7行,j=7。此时下标为0的cur也因为7要被占用而更改备用链表的值为8。(Malloc_SSL方法内更新备用链表首结点位置)
第11-12行,for循环l由1到2,执行两次,代码k=L[k].cur;使得k=999,得到k=L[999].cur=1,再得到k=L[i].cur=2。
第13行,L[j].cur=L[k].cur;因j=7,而k=2得到L[7].cur=L[2].cur=3。就是刚说的把丙的cur改为3。
第14行,L[k].cur=j;意思就是L[2].cur=7。也就是把乙的cur改为指向丙的下标7。
3.4.3 删除:
如果要删除甲,这个位置就空出来,有新元素进来就优先考虑这里,所以原来的第一个空位分量,即下标是8 的分量要降级了(后退为备用链表的第二个结点),把8给“甲”所在下标为1的分量的cur,也就是space[1].cur=space[0].cur=8,而space[0].cur=k=1就是让这个删除的位置称为第一个优先空位,把它存入第一个元素的cur中,如图:
相关代码描述:
/* 将下标为k的空闲结点回收到备用链表 */
void Free_SSL(StaticLinkList space, int k)
{
space[k].cur = space[0].cur; /* 把第一个元素的cur值赋给要删除的分量cur */
space[0].cur = k; /* 把要删除的分量下标赋值给第一个元素的cur */
}
/* 删除在L中第i个数据元素 */
Status ListDelete(StaticLinkList L, int i)
{
int j, k;
if (i < 1 || i > ListLength(L))
return ERROR;
k = MAXSIZE - 1;
for (j = 1; j <= i - 1; j++)
k = L[k].cur;
j = L[k].cur;
L[k].cur = L[j].cur;
Free_SSL(L, j);
return OK;
}
3.4.4 静态链表优缺点:
总的说,静态链表是为了给没有指针的高级语言设计的一种实现单链表能力的方法。虽使用较少,但思考方式比较巧妙,思想值得借鉴。
3.5 循环链表
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表。
循环链表解决了一个问题:如何从当中一个结点出发,访问到链表的全部结点。
为使空链表与非空链表处理一致,通常设置一个头结点。
循环链表带有头结点的空链表如图:
非空的循环链表如图:
循环链表和单链表的主要差异就在于循环的条件判断上,原来是p->next是否为空,现在是p->next不等于头结点,则循环未结束。
如果用头指针表示循环链表,则需O(n)时间找到最后一个结点。若改用尾指针表示循环链表,此时查找开始结点和终端结点都很方便了。(这是由循环链表的特点决定的)如图:
此时若尾指针用rear指示,则查找终端结点时间是O(1),而开始结点,其实就是rear->next->next,其时间复杂也为O(1)。
3.5.1 循环链表的合并:
合并时,有了尾指针就非常简单了。如图:
操作如下:
p=rearA->next; /*保存A表的头结点,即①*/
rearA->next=rearB->next->next; /*将本是指向B表的第一个结点(不是头结点)赋值给reaA->next,即②*/
rearB->next=p; /*将原A表的头结点赋值给rearB->next,即③*/
free(p); /*释放p*/
以上代码free(p);出自书中,笔者认为有误,应该是释放rearB->next。敬请读者发表意见。
3.6 双向链表
在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。
双向链表的循环带头结点的空链表如图:
非空的循环的带头结点的双向链表如图:
其中某个结点的前驱的后继是自身,后继的前驱也是自身。
3.6.1 插入
注意顺序:
顺序是:先搞定s的前驱和后继,再搞定后结点的前驱,最后解决前结点的后继。
s->prior=p;//①
s->next=p->next;//②
p->next->prior=s;//③
p->next=s;//④
3.6.2 删除
p->prior->next=p->next;//①
p->next->prior=p->prior;//②
3.6.3 特点
由于多了prior指针,对于插入和删除操作要注意。因为每个结点是两份指针,所以在空间上是要占用略多。不过因为良好的对称性,使得对某个结点的前后结点的操作带来了方便,可以有效提高算法的时间性能。即,用空间换时间。
3.7 总结
线性表
—顺序存储结构
—链式存储结构(单链表、静态链表、循环链表、双向链表)
参考:
《大话数据结构》
数据结构——线性表