目录
线性表:零个或多个数据元素的有限序列。
1. 线性表的定义
线性表,从名字就能感觉到,是具有线一样的性质的表。每个元素之间如同有一根线一样把他们穿在了一起,元素之间除了第一个和最后一个元素,中间的元素相互之间都是紧密挨在一起的。可以想象成珍珠项链那样,珍珠就是元素。
线性表(List):有零个或多个数据元素的有限序列。
需要强调的几点是:
<1> 首先它是一个序列。也就是说,元素之间是有顺序的,若元素存在存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继。线性表强调是有限的,元素个数当然是有限的。事实上,在计算机中处理的对象都是有限的,那种无限的数列,只存在于数学的概念中。
如果用数学语言来进行定义。可如下:
若将线性表记为(),则表中领先于,领先于,称是的直接前驱元素,是的直接后继元素。当i=1,2,…, n-1 时,有且仅有一个直接后继,当i=2,3,…,n 时,有且仅有一个直接前驱。如下图所示:
所以线性表元素的个数n (n>0) 定义为线性表的长度,当n=0时,称为空表。
在非空表中的每个数据元素都有一个确定的位置,如是第一个数据元素,是最后一个数据元素,是第 i 个数据元素,称 i 为数据元素在线性表中的位序。
在较复杂的线性表中,一个数据元素可以由若干个数据项组成。比如老师的点名册,它是一个有限序列,也满足类型相同的特点,每一个元素除了学生的学号外,还可以有学生的姓名、性别、出生年月什么的,这些就是数据项。
线性表的的定义很重要的一点就是数据的类型要相同。
2. 线性表的抽象数据类型
ADT 线性表(List )
Data线性表的数据对象集合为{},每个元素的类型均为DataType。其中,除第一个元素外,每一个元素有且只有一个直接前驱元素,除了最后一个元素外,每一个元素有且只有一个直接后继元素。数据元素之间的关系是一对一的关系。
OperationInitList ( *L): 初始化操作,建立一个空的线性表L。
ListEmpty ( L): 若线性表为空,返回true,否则返回false。
ClearList ( *L ): 将线性表清空。
GetElem ( L, i,*e ): 将线性表L中的第 i 个位置元素值返回给e。
LocateElem(L,e): 在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功;否则,返回0表示失敗。
Listlnsert ( *L, i, e ): 在线性表L中的第i个位置插入新元素e。
ListDelete ( *L, i, *e ):删除线性表L中第i个位置元素,并用e返回其值。
ListLength ( L ):返回线性表L的元素个数。
endADT
对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。
比如,要实现两个线性表集合A和B的并集操作。即要使得集合A=AUB。说白了,就是把存在集合B中但并不存在A中的数据元素插入到A中即可。
仔细分析一下这个操作,发现我们只要循环集合B中的每个元素,判断当前元素是否存在A中,若不存在,则插入到A中即可。思路应该是很容易想到的。
我们假设La表示集合A, Lb表示集合B,则实现的代码如下:
/*将所有的在线性表Lb中但不在La中的元素插入到La中*/
void union(List *La,List Lb)
{
int La_len,Lb_len,i;
ElemType e; //声明与La和Lb相同的数据元素e
La_len = ListLength(La); //求线性表的长度
Lb_len = ListLength(Lb);
for(i=0;i<Lb_len;++i)
{
GetElem(Lb,i,e); //取Lb中第i个数据元素赋值给e
if(!LocateElem(La,e,equal)) //La中不存在和e相同的数据元素
{
ListInsert(La,++La_len,e); //插入
}
}
}
这里,我们对于union操作,用到了前面线性表基本操作ListLength、GetElem、 LocateElem、Listlnsert等,可见,对于复杂的个性化的操作,其实就是把基本操作组合起来实现的。
3.线性表的顺序存储结构
3.1 顺序存储定义
线性表的两种存储结构的第一种——顺序存储结构。
线性表的顺序存储结构,指的是用一段地址连续的存储单元以此存储线性表的数据元素。
线性表()的顺序存储示意图如下所示:
3.2 顺序存储方式
线性表的顺序存储结构,说白了,就是在内存中找了块地儿,通过占位的形式,把一定内存空间给占了,然后把相同数据类型的数据元素依次存放在这块空地中。既然线性表的每个数据元素的类型都相同,所以可以用C语言 (其他语言也相同)的一维数组来实现顺序存储结构,即把第一个数据元素存到数组下标为0的位置中,接着把线性表相邻的元素存储在数组中相邻的位置。
为了建立一个线性表,要在内存中找一块地,于是这块地的第一个位置就非常关键,它是存储空间的起始位置。
线性表中,我们估算这个线性表的最大存储容量,建立一个数组,数组的长度就是这个最大存储容量。
我们已经有了起始的位置,也有了最大的容量,我们可以在数组元素还未全部定义完,还有空的位置的情况下,在里面增加数据了。随着数据的插入,我们线性表的长度开始变大,不过线性表的当前长度不能超过存储容量,即数组的长度。
看一下线性表的顺序存储的结构代码:
#define MAXSIZE 20 //存储空间初始分配量
typedef int ElemType; //ElemType 类型根据实际情况而定,这里假设为int
typedef struct
{
ElemType data[MAXSIZE]; //数组存储数据元素,最大值为MAXSIZE
int length; //线性表当前长度
} SqList;
这里,我们就发现描述顺序存储结构需要三个属性:
■ 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
■ 线性表的最大存储容:数组长度MAXSIZE。
■ 线性表的当前长度:length。
3.3 数据长度与线性表长度区别
注意哦,这里有两个概念“ 数组的长度 ”和“ 线性表的长度 ”需要区分一下。
数组的长度是存放线性表的存储空间的长度,存储分配后这个量是一般是不变的。有个别同学可能会问,数组的大小一定不可以变吗?我怎么看到有书中谈到可以动态分配的一维数组。是的,一般高级语言,比如C、VB、C++都可以用编程手段实现动态分配数组,不过这会带来性能上的损耗。
线性表的长度是线性表中数据元素的个数,随着线性表插入和删除操作的进行, 这个量是变化的。
在任意时刻,线性表的长度应该小于等于数组的长度。
3.4 地址计算方法
由于我们数数都是从1开始数的,线性表的定义也不能免俗,起始也是1,可C语言中的数组却是从0开始第一个下标的,于是线性表的第 i 个元素是要存储在数组下标为 i-1 的位置,即数据元素的序号和存放它的数组下标之间存在对应关系。如下图所示:
用数组存储 顺序表 意味着要分配固定长度的数组空间,由于线性表中可以进行插入和删除操作,因此分配的数组空间要大于等于当前线性表的长度。
其实,内存中的地址,就和图书馆或电影院里的座位一样,都是有编号的。存储器中的每个存储单元都有自己的编号,这个编号称为地址。当我们占座后,占座的第一个位置确定后,后面的位置都是可以计算的。试想一下,我是班级成绩第五名,我后面的10名同学成绩名次是多少呢?当然是6,7,…、15,因为5 + 1, 5 + 2,…,5 + 10。
由于每个数据元素,不管它是整型、实型还是字符型,它都是需要占用一定的存储单元空间的。假设占用的是c个存储单元,那么线性表中第 i+1 个数据元素的存储位置和第 i 个数据元素的存储位置满足下列关系(LOC表示获得存储位置 的函数)。
所以对于第 i 个数据元素的存储位置可以由推算得出:
从下图理解:
4. 顺序存储结构的插入与删除
4.1 获得元素操作
对于线性表的顺序存储结构来说,如果我们要实现GetElem操作,即将线性表L中的第 i 个位置元素值返回,其实是非常简单的。就程序而言,只要 i 的数值在数组 下标范围内,就是把数组第 i-1下标的值返回即可。来看代码:
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
//Status是函数的类型,其值是函数结果状态代码,如OK等
//初始条件:顺序线性表已存在,1<=i<=ListLength(l)
//操作结果:用e返回L中第i个数据元素的值
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;
}
注意这里返回值类型Status是一个整型,返回OK代表1, ERROR代表0。
4.2 插入操作
线性表插入操作的时间复杂度为O(1)。我们现在来考虑,如果我们要实现 Listlnsert (*L,i,e),即在线性表L中的第 i 个位置插入新元素e,应该如何操作?
举个例子,本来我们在春运时去买火车票,大家都排队排的好好的。这时来了一个美女,对着队伍中排在第三位的你说,“大哥,求求你帮帮忙,我家母亲有病,我得急着回去看她,这队伍这么长,你可否让我排在你的前面? ”你心一软,就同意了。这时,你必须得退后一步,否则她是没法进到队伍来的。这可不得了,后面的人像蠕虫一样,全部都得退一步。骂起四声。但后面的人也不清楚这加塞是怎么回事,没什么办法。
这个例子其实已经说明了线性表的顺序存储结构,在插入数据时的实现过程,如下图所示。
插入算法的思路:
■ 如果插入位置不合理,抛出异常;
■ 如果线性表长度大于等于数组长度,则抛出异常或动态增加容置;
■ 从最后一个元素开始向前遍历到第 i 个位置,分别将它们都向后移动一个位置;
■ 将要插入元素填入位置 i 处;
■ 表长加1。
实现代码如下所示:
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
//Status是函数的类型,其值是函数结果状态代码,如OK等
//初始条件:顺序线性表已存在,1<=i<=ListLength(l)
//操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加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;
}
4.3 删除操作
接着刚才的例子。那个美女插进队伍后,此时后面排队的人群意见都很大,都说怎么可以这样,不管什么原因,插队就是不行,有本事,找火车站开后门去。就在这时,远处跑来一胖子,对着这美女喊,可找到你了,你这骗子,还我钱。只见这女子二话不说,突然就冲出了队伍,胖子追在其后,消失在人群中。哦,原来她是倒卖火车票的黄牛,刚才还装可怜。于是排队的人群,又像蠕虫一样,均向前移动了一步,骂声渐息,队伍又恢复了平静。
这就是线性表的顺序存储结构删除元素的过程,如下图所示:
删除算法的思路:
■ 如果删除位置不合理,抛出异常;
■ 取出删除元素;
■ 从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;
■ 表长减1。
实现代码如下所示:
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
//Status是函数的类型,其值是函数结果状态代码,如OK等
//初始条件:顺序线性表已存在,1<=i<=ListLength(l)
//操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1
Status ListDelete(SqList *L,int i,ElemType *e)
{
int k;
if(L->length==0)//顺序线性表为空
{
return ERROR;
}
if(i<1||i>L->length)//删除位置不正确
{
return ERROR;
}
*e=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;
}
现在我们来分析一下,插入和删除的时间复杂度。
先来看最好的情况,如果元素要插入到最后一个位置,或者删除最后一个元素,此时时间复杂度为〇(1),因为不需要移动元素的,就如同来了一个新人要正常排队,当然是排在最后,如果此时他又不想排了,那么他一个人离开就好了,不影响任何人。
最坏的情况呢,如果元素要插入到第一个位置或者删除第一个元素,那就意味着要移动所有的元素向后或者向前,所以这个时间复杂度为〇(n)。
至于平均的情况,由于元素插入到第 i 个位置,或删除第 i 个元素,需要移动n-i 个元素。根据概率原理,每个位置插入或删除元素的可能性是相同的,也就说位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的
移动次数相等,为。通过时间复杂度的推导,可以得出,平均时间复杂度还是〇(n)。
这说明什么?线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是〇(1);而插入或删除时,时间复杂度都是〇(n)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用。当然,它的优缺点还不只这些……
4.4 线性表顺序存储结构的优缺点
线性表的顺序存储结构的优缺点如下表所示:
优点 | 缺点 |
无须为表示表中元素之间的逻辑关系而增加额外的存储空间 | 插入和删除操作需要移动大量元素 |
可以快速地存取表中任意位置的元素 | 当线性表长度变化较大时,难以确定存储空间的容量 |
造成存储空间的“碎片” |
5. 线性表的链式存储结构
5.1 顺序存储结构不足的解决办法
线性表的顺序存储结构最大的缺点就是插入和删除时需要移动大量元素,这显然就需要耗费时间。
能不能想办法解决呢?要解决这个问题,我们就得考虑一下导致这个问题的原因。
为什么当插入和删除时,就要移动大量元素,仔细分析后,发现原因就在于相邻两元素的存储位置也具有邻居关系。它们编号是1,2, 3,…,n,它们在内存中的位置也是挨着的,中间没有空隙,当然就无法快速介入,而删除后,当中就会留出空隙,自然需要弥补。问题就出在这里。
思路:为了元素之间插入删除方便,要让相邻元素间留有足够余地,那干脆所有的元素都不要考虑相邻位置了,哪有空位就到哪里,而只是让每个元素知道它下一个元素的位置在哪里,这样,我们可以在第一个元素时,就知道第二个元素的位置(内存地址),而找到它;在第二个元素时,再找到第三个元素的位置(内存地址)。这样所有的元素我们就都可以通过遍历而找到。链式存储结构就这样出现了。
5.2 线性表的链式存储结构定义
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存未被占用的任意位置。如下图所示:
以前在顺序结构中,每个数据元素只需要存数据元素信息就可以了。
现在链式结构中,除了要存数据元素信息外,还要存储它的后继元素的存储地址。
因此,为了表示每个数据元素与其直接后继数据元素之间的逻辑关系,对数据元素来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息 (即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素的存储映像,称为结点(Node)。
n个结点(ai的存储映像)链结成一个链表,即为线性表()的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起。如下图所示:
我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。最后一个结点,它的直接后继不存在了,所以我们规定,线性链表的最后一个结点指针为“空”(通常用NULL或符号“^”表示)。如下图所示:
有时,我们为了更加方便地对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,谁叫它是第一个呢,有这个特权。也可以存储如线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针。如下图所示:
5.3 头指针与头结点的异同
头指针与头结点的异同点,如下图所示:
头指针 | 头结点 |
头指针是指链表指向第一个结点的头指针,若链表有头结点,则是指向头结点的指针 | 头结点是为了操作的统一和方便而设立的,放在第一元素的头结点之前,其数据域一般无意义(也可存放链表的长度) |
头指针具有标识作用,所有常用头指针冠以链表的名字 | 有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其它结点的操作就统一了 |
无论链表是否为空,头指针均不为空。头指针是链表的必要元素 | 头结点不一定是链表必须要 |
5.4 线性表链式存储结构代码描述
若线性表为空表,则头结点的指针域为“空”。如下图所示:
这图只是它所表示的线性表中的数据元素及数据元素之间的逻辑关系。所以我们改用更方便的存储示意图来表示单链表。如下图所示:
若带有头结点的单链表,则如下图所示:
空链表下图所示:
单链表,在C语言中可用结构指针来描述。
typedef int ElemType
//线性表的单链表存储结构
typedef struct Node
{
ElemType data;
struct Node *next;
} Node;
typedef struct Node *LinkList; //定义LinkList
从这个结构定义中,我们也就知道,结点由存放数据元素的数据域和存放后继结点地址的指针域组成。假设P是指向线性表第 i 个元素的指针,则该结点的数据域我们可以用p->data来表示,p->data的值是一个数据元素,结点的指针域可以用p->next来表示,p->next的值是一个指针。p->next指向谁呢?当然是指向第 i+1 个元素,即指向的指针。也就是说,如p->data=,那么p->next->data=。如下图所示:
6. 单链表的读取
在单链表中,由于第 i 个元素到底在哪?没办法一开始就知道,必须得从头开始找。因此,对于单链表实现获取第i个元素的数据的操作GetElem,在算法上,相对要麻烦一些。
获得链表第i个数据的算法思路:
1. 声明一个结点 p 指向链表第一个结点,初始化 j 从1开始;
2. 当 j<i 时,就遍历链表,让 p 的指针向后移动,不断指向下一结点,j累加1;
3. 若到链表末尾p为空,则说明第i个元素不存在;
4. 否则查找成功,返回结点 p 的数据。
实现代码算法如下:
#define OK 1
typedef int Stutas
//初始条件:顺序线性表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指向链表的第一个结点
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个元素为止。由于这个算法的时间复杂度取决于i的位置,当 i=1时,则不需遍历,第一个就取出数据了,而当 i=n 时则遍历n次才可以。因此最坏情况的时间复杂度是〇(n)。
由于单链表的结构中没有定义表长,所以不能事先知道要循环多少次,因此也就不方便使用for来控制循环。其主要核心思想就是“工作指针后移”,这其实也是很多算法的常用技术。
世间万物总是两面的,有好自然有不足,有差自然就有优势。
7. 单链表的插入与删除
7.1 单链表的插入
先来看单链表的插入。假设存储元素 e 的结点为 s ,要实现结点p、p->next和 s 之间逻辑关系的变化,只需将结点s插入到结点p和p->next之间即可。可如何插入呢?如下图所示:
根本用不着惊动其他结点,只需要让s->next和p->next的指针做一点改变即可。
s->next=p->next; p->next=s;
解读这两句代码,也就是说让 p 的后继结点改成 s 的后继结点,再把结点 s 变成 p 的后继结点。如下图所示:
考虑一下,这两句的顺序可不可以交换?如果先p->next=s;再s->next=p->next;会怎么样?此时第一句会使得将p->next给覆盖成s的地址了。那么s->next=p->next,其实就等于s->next=s,这样真正的拥有数据元素的结点就没了上级。这样的插入操作就是失败的,造成了临场掉链子的尴尬局面。所以这两句是无论如何不能反的。
插入结点s后,链表下图所示:
对于单链表的表头和表尾的特殊情况,操作是相同的。如下图所示:
单链表第i个数据插入结点的算法思路:
1. 声明一结点 p 指向链表第一个结点,初始化 j 从1开始;
2. 当 j<i 时,就遍历链表,让 p 的指针向后移动,不断指向下一结点,j累加1;
3. 若到链表末尾 p 为空,则说明第 i 个元素不存在;
4. 否则查找成功,在系统中生成一个空结点 s ;
5. 将数据元素 e 赋值给 s->data ;
6. 单链表的插入标准语句 s->next=p->next; p->next=s;
7. 返回成功。
实现代码算法如下:
#define OK 1
#define ERROR 0
typedef int Stutas
//初始条件:顺序线性表L已存在,1<=i<=ListLength(L)
//操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加 1
Status ListInsert(LinkList *L, int i, ElemType e)
{
int j;
LinkList p,s; //声明结点p和s
p=*L;
j=1;
while(p&&j<i) //寻找第i个结点
{
p=p->next; //让p指向下一个结点
++j;
}
if(!p||j>i)
{
return ERROR; //第i个元素不存在
}
s=(LinkList)malloc(sizeof(Node)); //生成新结点
s->data=e;
s->next=p->next; //将p的后继结点赋值给s的后继
p->next=s; //将s赋值给p的后继
return OK;
}
在这段算法代码中,我们用到了C语言的malloc标准函数,它的作用就是生成一个新的结点,其类型与Node是一样的,其实质就是在内存中找了一小块空地,准备用来存放 e 数据 s 结点。
7.2 单链表的删除
现在我们来看看单链表的删除。设存储元素的结点为q,实现将结点 q 删除单链表的操作,其实就是将它的前继结点的指针绕过,指向它的后继结点即可。如下图所示:
我们所要做的,实际上就是一步,p->next = p->next->next,用 q 来取代 p->next,即是
q=p->next; p->next=q->next;
解读这两句代码,也就是说让 p 的后继的后继结点改成 p 的后继结点。有点拗口呀,打个形象的比方。本来是爸爸左手牵着妈妈的手,右手牵着宝宝的手在马路边散步。突然迎面走来一美女,爸爸一下子看呆了,此情景被妈妈逮个正着,于是她生气地甩开牵着的爸爸的手,绕过他,扯开父子俩,拉起宝宝的左手就快步朝前走去。此时妈妈是 p 结点,妈妈的后继是爸爸p->next,也可以叫 q 结点,妈妈的后继的后继是儿子p->next->next,即q->next。当妈妈去牵儿子的手时,这个爸爸就已经与母子俩没有牵手联系了。如下图所示:
单链表第i个数据删除结点的算法思路:
1. 声明一结点 p 指向链表第一个结点,初始化 j 从1开始;
2. 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一个结点,j累加 1;
3. 若到链表末尾 p 为空,则说明第i个元素不存在;
4. 否则查找成功,将欲删除的结点 p->next 赋值给q;
5. 单链表的删除标准语句 p->next = q->next;
6. 将 q 结点中的数据赋值给 e,作为返回;
7. 释放 q 结点;
8. 返回成功。
实现代码算法如下:
#define OK 1
#define ERROR 0
typedef int Stutas
//初始条件:顺序线性表L已存在,1<=i<=ListLength(L)
//操作结果:删除L中的第i个数据元素,并用e返回其值,L的长度减 1
Status ListDelete(LinkList *L, int i, ElemType *e)
{
int j;
LinkList p,q; //声明结点p和q
p=*L;
j=1;
while(p&&j<i) //寻找第i个结点
{
p=p->next; //让p指向下一个结点
++j;
}
if(!p||j>i)
{
return ERROR; //第i个元素不存在
}
q=p->next;
p->next=q->next; //将q的后继赋值给p的后继
*e=q->data; //将p结点中的数据赋给e
free(q); //让系统回收此结点,释放内存
return OK;
}
这段算法代码里,我们又用到了另一个C语言的标准函数free。它的作用就是让系统回收一个Node结点,释放内存。
小小的总结一下,单链表插入和删除算法,其实都是由两部分组成:第一部分就是遍历查找第i个元素;第二部分就是插入和删除元素。
从整个算法来说,我们很容易推导出:它们的时间复杂度都是O(n)。如果在我们不知道第 i 个元素的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个元素,对于顺序存储结构意味着,每一次插入都需要移动 n-i 个元素,每次都是O(n)。而单链表,我们只需要在第一次时,找到第 i 个位置的指针,此时为O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。
8. 单链表的整表创建
顺序存储结构的创建,其实就是一个数组的初始化,即声明一个类型和大小的数组并赋值的过程。而单链表和顺序存储结构就不一样,单链表不像顺序存储结构这么集中,它可以很散,是一种动态结构。对于每个链表来说,它所占用空间的大小和位置是不需要预先分配划定的,可以根据系统的情况和实际的需求即时生成。
所以创建单链表的过程就是一个动态生成链表的过程。即从“空表”的初始状态起,依次建立各元素结点,并逐个插入链表。
单链表整表创建的算法思路 (头插法) :
1. 声明一结点 p 和计数器变量 i ;
2. 初始化一空链表 L;
3. 让 L 的头结点的指针指向NULL,即建立一个带头结点的单链表;
4. 循环:
♦ 生成一新结点赋值给 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 = 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));//为整个线性表
r=*L; //r为指向尾部的结点
for(i=0; i<n; i++)
{
p=(Node *)malloc(sizeof(Node)); //生成新结点
p->data = rand()%100+1; //随机生成100以内的数字
r->next=p; //将表尾终端结点的指针指向新结点
r=p; //将当前的新结点定义为表尾终端结点
}
r-next=NULL; //表示当前链表结束
}
注意 L 与 r 的关系,L是指整个单链表,而 r 是指向尾结点的变量,r 会随着循环不断地变化结点,而 L 则是随着循环增长为一个多结点的链表。
这里需解释一下,r->next=p;的意思,其实就是将刚才的表尾终端结点 r 的指针指向新结点 p,如下图所示,当中①位置的连线就是表示这个意思。
r->next=p;这一句应该还好理解, r=p;是什么意思,请看下图:
它的意思,就是本来 r 是在元素的结点,可现在它已经不是最后的结点了,现在最后的结点是,所以应该要让将 p 结点这个最后的结点赋值给 r。此时 r 又是最终的尾结点了。
循环结束后,那么应该让这个链表的指针域置空,因此有了 “r->next=NULL;”,以便以后遍历时可以确认其是尾部。
9. 单链表的整表删除
当我们不打算使用这个单链表时,我们需要把它销毁,其实也就是在内存中将它释放掉,以便于留出空间给其他程序或软件使用。
单链表整表删除的算法思路如下:
1. 声明一结点p和q;
2. 将第一个结点赋值给p;
3. 循环:
♦ 将下一结点赋值给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=p->next;即可。可这样会带来什么问题?
要知道 p 是一个结点,它除了有数据域,还有指针域。你在做free (p);时,其实是在对它整个结点进行删除和内存释放的工作。
这就好比皇帝快要病死了,却还没有册封太子,他儿子五六个,你说要是你脚一蹬倒是解脱了,这国家咋办,你那几个 儿子咋办?这要是为了皇位,什么亲兄弟血肉情都成了浮云,一定会打起来。所以不行,皇帝不能马上死,得先把遗嘱写好,说清楚,哪个儿子做太子才行。而这个遗嘱就是变量q的作用,它使得下一个结点是谁得到了记录,以便于等当前结点释放后, 把下一结点拿回来补充。
10. 单链表结构与顺序存储结构优缺点
简单的对单链表结构和顺序存储结构作对比:
存储分配方式 | 空间性能 | 查找时间性能 | 删除和查找时间性能 | |
顺序存储结构 | 顺序存储结构用一段连续的存储单元依次存储线性表的数据元素 | 顺序存储结构需要预分配空间。分大了,浪费,分小了已发生上溢 | 顺序存储结构O(1) | 顺序存储结构需要平均移动表长一半的元素,时间为O(1) |
单链表结构 | 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素 | 单链表不需要预分配存储空间,只要有就可以分配,元素个数也不受限制 | 单链表O(1) | 单链表在找出某位置的指针后,插入和删除时时间仅为O(1) |
若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。
若线性表需要频繁插入和删除时,宜采用单链表结构。
比如说游戏开发中, 对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况都是读取,所以应该考虑用顺序存储结构。而游戏中的玩家的武器或者装备列表,随着玩家的游戏过程中,可能会随时增加或删除,此时再用顺序存储就不太合适了,单链表结构就可以大展拳脚。当然,这只是简单的类比, 现实中的软件开发,要考虑的问题会复杂得多。
当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。
如果事先知道线性表的大致长度,比如一年12个月,一周就是星期一至星期日共七天,这种用顺序存储结构效率会高很多。
总之,线性表的顺序存储结构和单链表结构各有其优缺点,不能简单的说哪个好,哪个不好,需要根据实际情况,来综合平衡采用哪种数据结构更能满足和达到需求和性能。
11. 静态链表
C语言和C++都具有指针的功能,使得它可以非常容易的操作内存中的地址和数据。除了C++其它的面向对象,如Java、C#等,虽然不使用指针,但因为启用了对象引用机制,从某种角度也间接实现了指针的某些作用。但是对于一些语言,如Basic、Fortran等早期的编程高级语言,由于没有指针,链表结构就无法按照使用指针那样实现了。
于是就出现了用数组来代替指针,来描述单链表。首先我们让数组的元素都是由两个数据域组成,data 和 cur。也就是说,数组的每个下标都对应一个data和一个cur。数据域 data,用来存放数据元素,也就是通常我们要处理的数据;而游标 cur 相当于单链表中的next指针,存放该元素的后继在数组中的下标。
我们把这种用数组描述的链表叫做静态链表,这种描述方法还有起名叫做游标实现法。
为了方便插入数据,我们通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出。
定义代码如下所示:
#define MAXSIZE 1000 //假设链表的最大长度是1000
typedef int ElemType;
//线性表的静态链表存储结构
typedef struct
{
ElemType data;
int cur;
}Component, StaticLinkList[MAXSIZE];//对于不提供结构struct的程序设计语言,可以使用一对并行数组data和cur来处理。
另外我们对数组第一个和最后一个元素作为特殊元素处理,不存数据。我们通常把未被使用的数组元素称为备用链表。而数组第一个元素,即下标为0的元素的 cur 就存放备用链表的第一个结点的下标;而数组的最后一个元素的cur则存放第一个有数值的元素的下标,相当于单链表中的头结点作用,当整个链表为空时,则为0。(有些把教组的第二个元素用来作为头结点实现原理相同,只不过是取的存放位置不同。)如下图所示:
此时的图示已经相当于初始化的数组状态,下面见代码:
#define MAXSIZE 1000 //假设链表的最大长度是1000
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE
typedef int ElemType;
typedef int Status;
//将一维数组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;
}
假设我们已经将数据存入静态链表,假如分别存放着“甲”、“乙”、“丁”、“戊”、“己”、“庚”等数据,则它将处于下图所示这种状态:
此时“甲”这里就存放有下一个元素“乙”的游标 2,“乙”则存有下一元素“丁”的下标 3。而“庚”是最后一个有值元素,所以它的 cur 设置为0。而最后一个元素的 cur 则因“甲”是第一有值元素而存有它的下标为1。而第一个元素则因空闲空间的第一个元素下标为7,所以它的cur存有7。
11.1 静态链表的插入操作
静态链表中要解决的是:如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放。
我们前面说过,在动态链表中,结点的申请和释放分别借用malloc()和free()两个函数来实现。在静态链表中,操作的是数组不存在像动态链表的结点申请和释放问题,所以在静态链表中我们需要自己实现这两个函数,才可以做插入和删除的操作。
为了辨明数组中哪些分量未被使用,解决的办法是将所有未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新结点。
//若备用空间链表非空,则返回分配的结点下标,否则返回0
int Malloc_SLL(StaticLinkList space)
{
//当前数组第一个元素的cur存的值,就是要返回的的一个备用空闲元素的下标
int i = space[0].cur;
if (space[0].cur)
{
space[0].cur = space[i].cur;
//由于要拿出一个分量来使用了,所以我们就得把它的下一个分量用来做备用
}
return 0;
}
这段代码有意思,一方面它的作用就是返回一个下标值,这个值就是数组头元素的 cur 存的第一个空闲的下标。从上面的图示例子来看,其实就是返回7。
那么既然下标为7的分量准备要使用了,就得有接替者,所以就把分量7的 cur 值赋值给头元素,也就是把8给 space[0].cur,之后就可以继续分配新的空闲分量,实现类似malloc()函数的作用。
现在我们如果需要在“乙”和“丁”之间,插入一个值为“丙”的元素,按照以前顺序存储结构的做法,应该要把“丁”、“戊”、“己”、“庚”这些元素都往后移一位。但目前不需要,因为我们有了新的手段。
新元素“丙”,想插队是吧?可以,你先悄悄地在队伍最后一排第7个游标位置待着,我一会就能帮你搞定。我接着找到了“乙”,告诉他,你的 cur 不是游标为 3 的“丁”了,这点小钱,意思意思,你把你的下一位的游标改为7就可以了。“乙”叹了口气,收了钱把cur值改了。此时再回到“丙”那里,说你把你的cur改为3。就这样,在绝大多数人都不知道的情况下,整个排队的次序发生了改变。如下图所示:
实现代码如下所示:
#define MAXSIZE 1000 //假设链表的最大长度是1000
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE
typedef int ElemType;
typedef int Status;
//在L中第i个元素之前插入新的元素e
Status ListInsert(StaticLinkList L, int i, ElemType e)
{
int j, k, l;
k = MAXSIZE - 1; //注意k首先是最后一个元素的下标
if (i<1 || i>ListLenght(L) + 1)
{
return ERROR;
}
j = Malloc_SLL(L); //获得空闲分量的下标
if (j)
{
L[j].data = e; //将数据赋值给此分量的data
for (l = 1; l <= i - 1; l++)
{
k = L[k].cur;
}
L[j].cur = L[k].cur; //把第i个元素之前的cur赋值给新元素的cur
L[k].cur = j; //把新元素的下标赋值给第i个元素之前元素的cur
return OK;
}
return ERROR;
}
代码就不做过多的解释了,通过上面这个代码,我们就实现了在数组中,实现不移动元素,却插入了数据的操作(如下图所示)。没理解可能觉得有些复杂,理解了,也就那么回事。
11.2 静态链表的删除操作
接着上面的那个故事,排在第一个的甲突然接到一电话,看着很急,多半不是家里有紧急情况,就是单位有突发状况,反正稍有犹豫之后就急匆匆离开。这意味着第一位空出来了,那么自然刚才那个收了好处的乙就成了第一位——有人走运起来,喝水都长肉。
和前面一样,删除元素时,原来是需要释放结点的函数free()。现在我们也得自己实现它,代码如下所示:
#define MAXSIZE 1000 //假设链表的最大长度是1000
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE
typedef int ElemType;
typedef int Status;
//删除在L中第i个数据元素e
Status ListDelete(StaticLinkList L, int i)
{
int j, k;
if (i<1 || i>ListLenght(L))
{
return ERROR;
}
k = MAXSIZE - 1;
for (i = 1; i <= i - 1; ++j)
{
k = L[k].cur;
}
j = L[k].cur;
L[k].cur = L[j].cur;
Free_SLL(L, j);
return OK;
}
有了刚才的基础,这段代码就很容易理解了。前面代码都一样,for循环因为 i=1 而不操作,j=k[999].cur=1,L[k].cur=L[j].cur 也就是 L[999].cur=L[1].cur=2 。这其实就是告诉计算机现在“甲”已经离开了,“乙”才是第一个元素。Free_SLL (L, j);是什么意思呢?来看代码:
//将下标为k的空闲结点回收到备用链表
void Free_SLL(StaticLinkList space, int k)
{
space[k].cur = space[0].cur; //把第一个元素cur值赋给要删除的元素cur
space[0].cur = k; //把要删除的分量下标赋值给第一个元素的cur
}
意思就是“甲”现在要走,这个位置就空出来了,也就是,未来如果有新人来, 最优先考虑这里,所以原来的第一个空位分量,即下标是8的分量,它降级了,把8给“甲”所在下标为1的分量的cur,也就是space[1].cur=space[0].cur=8,而space[0].cur=k=1其实就是让这个删除的位置成为第一个优先空位,把它存入第一个元素的cur中,如下图所示:
当然,静态链表也有相应的其他操作的相关实现。比如我们代码中的ListLength就是一个,来看代码。
//初始条件:静态链表L已存在。操作结果:返回L中数据元素个数
int ListLenght(StaticLinkList L)
{
int j = 0;
int i = L[MAXSIZE - 1].cur;
while (i)
{
i = L[i].cur;
j++;
}
return j;
}
11.3 静态链表优缺点
总结一下静态链表的优缺点:
优点 | 缺点 |
在插入和删除操作时,只需要修改游标,不需移动元素,从而改进了在顺序存储结构中的插入和删除操作需要移动大量的缺点 | 没有解决连续存储分配带来的表长难以确定的问题 |
失去了顺序存储结构随机存取的特性 |
总的来说,静态链表其实是为了给没有指针的高级语言设计的一种实现单链表能力的方法。尽管不一定会用得上,但这样的思考方式是非常巧妙的,应该理解其思想,以备不时之需。
12. 循环链表
对于单链表,由于每个结点只存储了向后的指针,到了尾标志就停止了向后链的操作,这样,当中某一结点就无法找到它的前驱结点了。
举个例子,比如,你是一业务员,家在上海。需要经常出差,行程就是上海到北京一路上的城市,找客户谈生意或分公司办理业务。你从上海出发,乘火车路经多个城市停留后,再乘飞机返回上海,以后,每隔一段时间,你基本还要按照这样的行程开展业务,如下图所示:
有一次,你先到南京开会,接下来要对以上的城市走一遍,此时有人对你说,不行,你得从上海开始,因为上海是第一站。你会对这人说什么?神经病。哪有这么傻的,直接回上海根本没有必要,你可以从南京开始,下一站蚌埠,直到北京,之后再考虑走完上海及苏南的几个城市。显然这表示你是从当中一结点开始遍历整个链表, 这都是原来的单链表结构解决不了的问题。
事实上,把北京和上海之间连起来,形成一个环就解决了前面所面临的困难。这 就是我们现在要讲的循环链表。
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。
从刚才的例子,可以总结出,循环链表解决了一个很麻烦的问题。如何从当中一个结点出发,访问到链表的全部结点。
为了使空链表与非空链表处理一致,我们通常设一个头结点,当然,这并不是说,循环链表一定要头结点,这需要注意。循环链表带有头结点的空链表,如下图所示:
对于非空的循环链表,如下图所示:
其实循环链表和单链表的主要差异就在于循环的判断条件上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环未结束。
在单链表中,我们有了头结点时,我们可以用O(1)的时间访问第一个结点,但对于要访问到最后一个结点,却需要O(N)时间,因为我们需要将单链表全部扫描一遍。
有没有可能用O(1)的时间由链表指针访问到最后一个结点呢?当然可以。
不过我们需要改造一下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表(如下图所示),此时查找开始结点和终端结点都很方便了。
从上图中可以看到,终端结点用尾指针rear指示,则査找终端结点是O(1),而开始结点,其实就是rear->next->next,其时间复杂也为O(1)。
举个程序的例子,要将两个循环链表合并成一个表时,有了尾指针就非常简单了。比如下面的这两个循环链表,它们的尾指针分别是rearA和rearB,如下图所示:
要想把它们合并,只需要如下的操作即可,如下图所示:
//保存A表的头结点,即操作1
p=rearA->next;
//将本是指向B表的第一个结点(不是头结点)赋值给rear->next,即操作2
rearA->next=rearB->next->next;
//将原A表的头结点赋值给rearB->next,即操作3
rearB->next=p;
//释放p
free(p);
13. 双向链表
继续上面的例子,你平时都是从上海一路停留到北京的,可是这一次,你得先到北京开会,谁叫北京是首都呢,会就是多。开完会后,你需要例行公事,走访各个城市,此时你怎么办?
有人又出主意了,你可以先飞回上海,一路再乘火车走遍这几个城市,到了北京后,你再飞回上海。
你会感慨,人生中为什么总会有这样出馊主意的人存在呢?真要气死人才行。哪来这么麻烦,我一路从北京坐火车或汽车回去不就完了吗。
对呀,其实生活中类似的小智慧比比皆是,并不会那么的死板教条。我们的单链表,总是从头到尾找结点,难道就不可以正反遍历都可以吗?当然可以,只不过需要加点东西而已。
在单链表中,有了next指针,这就使得我们要查找下一结点的时间复杂度为O(1)。可是如果我们要查找的是上一结点的话,那最坏的时间复杂度就是O(n)了,因为我们每次都要从头开始遍历查找。
为了克服单向性这一缺点,科学家们设计出了双向链表。双向链表 (double linked list)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。
既然单链表也可以有循环链表,那么双向链表当然也可以是循环表。 双向链表的循环带头结点的空链表如下图所示:
typedef int Elemtype;
//线性表的双向链表存储结构
typedef struct DulNode
{
Elemtype data;
struct DulNode *prior; //直接前驱
struct DulNode *next; //直接后继指针
} DulNode,*DuLinkList;
非空的循环的带头结点的双向链表如图下图所示:
由于这是双向链表,那么对于链表中的某一个结点p,它的后继的前驱是谁?当然还是它自己。它的前驱的后继自然也是它自己,即:
p->next->prior = p = p->prior->next
双向链表是单链表中扩展出来的结构,所以它的很多操作是和单链表相同的,比如求长度的ListLength,查找元素的GetElem,获得元素位置的LocateElem等,这些操作都只要涉及一个方向的指针即可,另一指针多了也不能提供什么帮助。
双向链表既然是比单链表多了如可以反向遍历查找等数据结构,那么也就需要付出一些小的代价;在插入和删除时,需要更改两个指针变量。
插入操作时,其实并不复杂,不过顺序很重要,千万不能写反了。
我们现在假设存储元素e的结点为s,要实现将结点s插入到结点p和p->next之间需要下面几步,如下图所示:
s->pripor=p; //把p赋值给s的前驱,如图中操作1
s->next=p->next; //把p->next赋值给s的后继,如图中操作2
p->next->prior=s; //把s赋值给p->next的前驱,如图中操作3
p->next=s; //把s赋值给p的后继,如图中操作4
关键在于它们的顺序,由于第2步和第3步都用到了 p->next 。如果第4步先执行,则会使得p->next提前变成了 s,使得插入的工作完不成。所以我们不妨把上面这张图在理解的基础上记忆,顺序是先搞定s的前驱和后继,再搞定后结点的前驱,最后解决前结点的后继。
如果插入操作理解了,那么删除操作,就比较简单了。
若要删除结点p,只需要下面两步骤,如下图所示:
p->prior->next=p->next; //把p->next赋值给p->prior的后继,如图中操作1
p->next->prior=p->prior; //把p->prior赋值给p->next的前驱,如图中操作2
总的来说,双向链表相对于单链表来说,要更复杂一些,毕竟它多了 prior 指针,对于插入和删除时,需要格外小心。另外它由于每个结点都需要记录两份指针,所以在空间上是要占用略多一些的。不过,由于它良好的对称性,使得对某个结点的前后结点的操作,带来了方便,可以有效提高算法的时间性能。说白了,就是用空间来换时间。
14. 总结
我们先谈了线性表的定义,线性表是零个或多个具有相同类型的数据元素的有限序列。然后谈了线性表的抽象数据类型,如它的一些基本操作。
之后我们就线性表的两大结构做了讲述,先讲的是比较容易的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。通常我们都是用数组来实现这一结构。
后来是我们的重点,由顺序存储结构的插入和删除操作不方便,引出了链式存储结构。它具有不受固定的存储空间限制,可以比较快捷的插入和删除操作的特点。然后我们分别就链式存储结构的不同形式,如单链表、循环链表和双向链表做了讲解,另外我们还讲了若不使用指针如何处理链表结构的静态链表方法。
总的来说,线性表的这两种结构(如下图所示)其实是后面其他数据结构的基础。