一、单链表的定义和表示
我们所了解到的单链表实际上是线性表的一种链式存储结构,其特点是用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)。
正是因为这种可连续也可不连续的特性就造成了它不能像数组那样每个数据只进行对数据本身的存储。假如我们将前一个结点设为
a(i)
,那么后一个节点就是a(i+1)
,从上图就可以看出,除了存储本身的数据信息之外,每个结点还需要进行对后继信息的存储。
这两部分信息就构成了数据元素a(i)
的存储映像,我们称之为结点(node)。
我们如果对象上面的图进行更近一步的细化:
如此进行重复添加,最终就可以形成一条单链表。
根据链表中结点所含指针个数、指针指向、指针连接方式,可将链表分为单链表、循环链表、双向链表、二叉链表、十字链表、邻接表等。其中单链表、循环链表和双向链表用于实现线性表的链式存储结构,其他形式多用于实现树和图等非线性结构。
我们在这里先讨论单链表。我们看下面这张图,可以看出来单链表的存储必须从头指针开始,由头指针指向链表的第一个结点,这个几点结点称为首元结点。同时,由于最后一个结点没有后继,则单链表的最后一个节点的指针为空。
当我们使用链表进行存储线性表时,不能像数组那样使用下标索引。这时,指针就是数据元素之间的逻辑关系的映像,则逻辑上相邻的两个数据与苏其存储的物理位置不要求紧邻。
我们将上面的数据画成图后就能直观的感受到:我们在使用链表时,关心的只是它所表现出来的线性表中的数据元素之间的逻辑顺序,而不是每个数据元素在存储器中的实际位置。
用代码来描述一个链表:
typedef struct LNode
{
ElemType data; //结点的数据域
struct LNode *next; //结点的指针域
}LNode,*LinkList; //LinkList为指向结构体LNode的指针类型
在这里我要强调的是:为了提高程序的可读性,对同一结构体指针类型起了两个名称,LinkList与LNode*,两者本质上是等价的。通常用LinkList来定义单链表,强调定义的是单链表的头指针;LNode *用来定义单链表中的任意结点的指针变量。当然也可以使用定义LinkList p,这种定义形式与LNode *p一样。
(1)单链表是由表头唯一确定的,因此可以用头指针的名字来命名单链表。
(2)注意区分指针变量和结点变量两个不同的概念。若定义LinkList p或LNode *p,则p为指向某节点的指针变量,表示该节点的地址;而 *p为对应的结点变量,表示给结点的名称。
头指针、头结点、首元结点
- 首元结点是单链表的第一个存储数据的结点,我们之前有提到过。
- 头结点是在首元结点之前附设的一个结点,其指针指向首元结点。头结点的数据域可以不存储任何信息,也可以存储与数据元素相同类型的其他附加信息。如当数据元素为整形时,头结点的数据域可以存储链表的长度。
- 头指针是指向链表中的第一个结点的指针。当链表中存在头结点时指向头结点,不存在头结点时指向首元结点。
链表增设头结点的作用:
-
便于首元结点的处理
增加了头结点之后,首元结点的地址存储在头结点之中,对链表的第一个元素与其他元素的处理方式相同,不必做特殊处理。
-
便于空表和非空表的统一处理
当链表不设头结点时,链表L为单链表的头指针,它应该指向首元结点。当链表为空时,L指针为空(判空条件为L==NULL)
增加头结点之后,无论链表是否为空,头指针都会是一个指向头结点的非空指针。(判空条件为L—>next == NULL)
单链表是非随机存储的存储结构,要取得第i
个数据必须要从头指针出发顺链寻找,也称为顺序存取的数据结构。因此,其基本操作的实现与顺序表不同。
二、单链表的基本操作的实现
1.初始化
单链表的初始化操作就是构造一个空表。
- 生成新结点作为头结点,用头指针L指向头结点。
- 头结点的指针域设为空。
Status InitList(LinkList &L)
{
L = new LNode; //生成新结点作为头指针,指向头结点
L->next = NULL; //头结点的指针域设为空
return OK;
}
这里有一点需要格外注意,为什么LinkList &L中要加&?
这里要改变的是指针变量L本身的值,使它指向新开辟的内存空间L = (LinkList) malloc (sizeof(LNode))
。所以,要么向函数传入L的引用,要么传递指向L的指针(指向指针的指针)。
2. 取值
在链表中获取某个结点的值只能通过从链表的首元结点出发,顺着链域next逐个结点向下访问。
-
用指针p指向首元结点,用j做计数器初值赋为1。
-
从首元结点开始顺着链域next依次向下访问,只要指向当前结点的指针p不为空,并且没有达到序号为i的结点,则循环执行以下操作:
- p指向下一个结点
- 计数器j相应加1
-
退出循环时,如果指针p为空,或者计数器j的值大于i,说明指定的序号i不合法(i大于表长n或i小于等于0),取值失败返回ERROR;否则取值成功,此时j = i时,p所指向的结点就是要找的第i个结点,用参数e保存当前结点的数据域,返回OK。
Status GetElement(LinkList L,int i,ElemType e)
{ //在带头结点的单链表L中根据序号i获取元素的值,用e返回L中第i个元素的值
p = L->next; //初始化,p指向首元结点,j赋值为1
int j = 1;
while(p!=NULL && j<i) //顺链域向后扫描,知道p为空或者指向第i个元素
{
p = p->next;
j++;
}
if(p==NULL || j>i) //i值不合法
return ERROR;
else
e = p->data; //取第i个元素的数据域
return OK;
}
3. 查找
从链表的首元结点出发,依次将每个结点的数据域与要查找的值进行比较,返回查找结果。
- 用指针p指向首元结点
- 从首元结点开始依次顺着链域next向下查找,只要指向当前结点的指针p不为空,并且p所指结点的数据不等于给定数据e,则循环执行以下操作:p指向下一个结点。
- 返回p。若查找成功,p此时即为结点的地址值,如查找失败,p的值即为NULL。
Status *LocateElem(LinkList L,ElemType e)
{
p = L->next;
while(p!=NULL && p->data!=e)
{
p = p->next;
}
return p;
}
4. 插入
假设要在单链表的两个数据元素a和b之间插入一个数据元素x,一直p为指向结点a的指针,如图:
为插入x,首先要生成一个x结点,然后插入单链表中。修改a中的指针域,使其指向结点x,而结点x的指针域指向结点b。
将值为e的新节点插入到表的第i个结点的位置上,即插入到结点a(i-1)
与a(i)
之间,具体插入过程如图,图中对应的步骤为:
- 查找结点a(i-1)并由指针p指向该结点
- 生成一个新节点*s
- 将新节点*s的数据域置为e
- 将新节点的指针域指向a(i)
- 将结点*p的指针域指向 *s
Status ListInsert(LinkList &L,int i,ElemType e)
{
p = L;
int j = 0;
while(p!=NULL && (j<i-1)) //查找第i-1个值,p指向该结点
{
p = p->next;
j++;
}
if(p==NULL || j>i-1)
return ERROR;
s = new LNode; //创建新节点s
s->date = e; //将s的数据域设为e
s->next = p->next; //s的指针域指向a(i)
p->next = s; //a(i-1)的指针域指向s
return OK;
}
5. 删除
要删除单链表中的元素,首要要找到该元素的前驱结点。然后将这个前驱结点的指针域指向删除结点的后一个结点即可。但在删除结点时,处理修改前驱结点的指针域外,还要释放删除结点所占的空间,所以在修改前,应该引入另一指针q,临时保存删除结点的地址以备释放。
- 查找结点
a(i-1)
并且指针p指向该结点。 - 临时保存待删除结点
a(i)
的地址在q中,以备释放。 - 将结点*p的指针域指向
a(i)
的直接后继 - 释放
a(i)
的空间
Status ListDelete(LinkList &L,int i)
{
p = L;
int j=0;
while(p->next!=NULL && j<i-1) //查找第i-1个结点,p指向该结点
{
p = p->next;
j++;
}
if(p==NULL ||j>i-1)
return ERROR;
q = p->next; //临时保存被删除的结点以备释放
p->next = q->next; //改变删除的结点的前驱结点的指针域
delete q; //释放删除结点所占空间
return OK;
}
在这里我们应该注意到的是:在while循环的条件中,插入数据时p指针不为空的条件是
p!=NULL
,而在删除结点时变成了p->next!=NULL
。这是因为在插入时,合法的位置有n+1个,删除时合法的位置只有n个,这时如果还用插入的条件判断就会出现引用空指针的情况。
6. 创建单链表
根据结点插入的位置不同,插入的方法分为头插法和尾插法。
头插法创建单链表
-
创建一个只有头结点的空链表
-
根据待创建表中包括的元素个数n,循环n次以下操作
1) 生成一个新节点
*p
2) 输入元素的值赋值给新结点*p
的数据域
3) 将新结点*p
插入到头结点之后
void createList_H(Linklist L,int n)
{
L = new LNode; //创建有头结点的空链表
L->next = NULL;
for(int i=0;i<n;i++)
{
p = new LNode; //创建要插入的结点
cin>>p->data; //输入数据
p->next = L->next; //将创建的结点插入到头结点之后
L->next = p;
}
}
尾插法创建单链表
-
创建一个只有头结点的空链表
-
尾指针r初始化,指向头结点
-
根据待创建表中包括的元素个数n,循环n次以下操作
1) 生成一个新节点
*p
2) 输入元素的值赋值给新结点*p
的数据域
3) 将新结点*p
插入到尾结点*r
之后
4) 尾指针r
指向新的尾结点*p
void createList_R(LinkList L,int n)
{
L = new LNode; //创建有头指针的空链表
L->next = NULL;
r = L; //尾指针指向头结点
for(int i=0;i<n;i++)
{
p = new LNode;
cin>>p->data;
p-next = NULL; //将新结点插入到尾结点*r后
r->next = p;
r = p; //尾指针指向新节点
}
}
单链表的相关操作就此告一段路。这里我想说的是,所有这些的算法的时间复杂度都为O(n)。
三、循环链表
循环链表是另一种形式的链式存储结构。其特点是表中最后一个结点的指针域指向头结点,这个链表形成一个环。由此,从表中任意一个结点出发均可找到表中其他结点。
循环链表与单链表的唯一差别就在于:当遍历表时,循环链表的判空语句是
p! = L
或p->next != NULL
。
在某些情况下,循环链表设尾指针不设头指针能够简化一些操作。
例如:将两个线性表合并成一个表时,仅需将第一个表的尾指针指向第二个表的第一个结点,第二个表的尾指针指向第一个表的头指针,然后释放第二个表的头结点。
两个链表
合并后的表
上述操作时间复杂度为O(1)
四、双向链表
单链表和循环链表中查询直接后继的时间复杂度为O(1),而查询前驱的时间复杂度为O(n)。为了克服这种缺点,我们可以利用双向链表。
顾名思义,在双向链表的结点中有两个指针域,一个指向直接前驱,一个指向直接后继。
typedef struct DuLNode
{
ElemType data; //数据域
struct DuLNode *prior; //直接前驱
struct DuLNode *next; //直接后继
}DuLNode,*DulinkList;
我们还是用图来看具体结构。
在双向循环链表中,有些操作仅需涉及到一个方向的指针,则它们的算法描述和线性链表相同。但是在插入、删除时有很大的不同,需要修改四个指针。
双向链表的插入
Status ListInsert_DuL(DuLinkList &L,int i,ElemType e)
{
if(!(p = DetElem_DuL(L,i))) //在L中确定第i个元素的位置指针p
{
return ERROR; //p为NULL时,第i个元素不存在
}
s = new DuLNode; //创建要插入的结点
s->data = e;
s->piror = p->piror; //(1)
p->piror->next=s; //(2)
s->next = p; //(3)
p->piror = s; //(4)
return OK;
}
双向链表的删除
Status ListLink_DuL(DuLNode &L,int i)
{
if(!(p = DetElem_DuL(L,i))) //在L中确定第i个元素的位置指针p
{
return ERROR; //p为NULL时,第i个元素不存在
}
p->piror->next = p->next; //(1)
p->next->piror = p->piror; //(2)
delete p;
return OK;
}