由于顺序表的插入、删除操作需要移动大量的元素,影响了运行效率,由此引入了线性表的链式存储。链式存储线性表时,不需要使用地址连续的存储单元,即它不要求逻辑上相邻的两个元素在物理位置上也相邻。它通过“链”建立起数据元素之间的逻辑关系,因此对线性表的插入、删除不需要移动元素,而只需要修改指针,
单链表的定义
线性表的链式存储又称单链表,它是通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的线性关系,对每个链表结点,除存放元素自身的信息外,还要存放一个指向其后继的指针。data为数据域,存放数据元素;next为指针域,存放其后继结点的地址。
单链表中结点类型的描述如下:
typedef struct LNode{//定义单链表结点类型
ElemType data;//数据域
Struct LNode *next; //指针域
}LNode,*LinkList;
利用单链表可以解决顺序表需要大量连续存储空间的缺点,但单链表附加指针域,也存在浪费存储空间的缺点。由于单链表的元素是离散的分布在存储空间中的,所以单链表是非随机存取的存储结构,即不能直接找到表中某个特定结点,查找某个特定节点时,需要从表头开始遍历,依次查找。
通常用头指针来标识一个单链表,如单链表L,头指针为NULL时表示一个空表。此外,为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点,头结点的数据域可以不设任何信息,也可以记录表长等相关信息。头结点的指针域指向线性表的第一个元素结点。
头结点和头指针的区分
不管带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,结点内部通常不存储信息。
引入头结点后,可以带来两个好处:
- 由于开始节点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
- 无论链表是否为空,其头指针都指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理就得到了统一
单链表上基本操作的实现
用头插法建立单链表
该方法从一个空表开始,生成一个新结点,并将读取到的数据存放在新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后
头插法建立单链表的算法如下:
LinkList List_HeadInsert(LinkList &L){
//从表尾到表头逆向建立单链表L,每次均在头结点之后插入元素
LNode *s;
int x;
L=(LinkList)malloc(sizeof(LNode));//创建头结点
L->next=NULL;
scanf("%d",&x);//输入结点的值
while(x!=9999){
//输入9999表示结束
s=(LNode *)malloc(sizeof(LNode));//创建新结点
s->data=x;
s->next=L->next;
L->next=s;
scanf("%d",&x);
}
return L;
}
采用头插法建立单链表时,读入数据的顺序与生成的链表中的元素的顺序是相反的。每个结点插入的时间为O(1),设单链表长为n,则总的时间复杂度为O(n);
采用尾插法建立单链表
头插法建立单链表的算法虽然简单,但生成的链表中的结点的次序和输入数据的顺序不一致。若希望两者一致,则采用尾插法,该方法将新结点插入到当前链表的表尾,为此必须增加一个尾指针r,使其始终指向当前链表的尾节点
尾插法建立单链表的算法如下:
LinkList List_TailInsert(LinkList &L){
//从表头到表尾正向建立单链表L,每次均在表尾插入元素
int x;
L=(LinkList)malloc(sizeof(LNode));//创建头结点
LNode *s,*r=L;//r为表尾指针
scanf("%d",&x);//输入结点的值
while(x!=9999){
//输入9999表示结束
s=(LNode *)malloc(sizeof(LNode));//创建新结点
s->data=x;
r->next=s;
r=s;//r指向新的表尾结点
scanf("%d",&x);
}
r->next=NULL;//尾节点指针置为空
return L;
}
因为附设了一个指向表尾结点的指针,故时间复杂度和头插法的相同
按序号查找结点值
在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点的指针域NULL;
按序号查找结点的值的算法如下:
LNode *GetElem(LinkList L,int i){
//本算法取出单链表L(带头结点)中第i个位置的结点指针
int j=1;//计数,初始为1
LNode *p=L->next;//头结点指针赋给p
if(i==0) return L;//若i等于0,则返回头结点
if(i<1) return L;//若i无效,返回NULL
while(p&&j<i){
//从第1个结点开始找,查找第i个结点
p=p->next;
j++;
}
return p;;//返回第i个结点的指针,如果i大于表长
//p=NULL,直接返回p即可
}
按序号查找操作的时间复杂度为O(n)
按值查找表结点
从单链表的第一个节点开始,从前往后依次比较表中各结点数据域的值,若某节点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL
按值查找表结点的算法如下:
LNode *LocateElem(LinkList L,ElemType e){
LNode *p=L->next;
while(p!=NULL&&p->data!=e)
p=p->next;
return p;
}
按值查找操作的时间复杂度是O(n)
插入结点操作
插入节点操作将值为x的新结点插入到单链表的第i个位置上,先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第i-1个结点,再在其后面插入新结点
算法首先调用按序号查找算法GetElem(L,i-1),查找第i-1个结点,假设返回的第i-1个结点为*p,然后令新结点*s的指针域指向*p的后继结点,再令结点*p的指针域指向新插入的结点*s,
实现插入结点的代码如下:
p=GetElem(L,i-1);
s->next=p->next;
p->next=s;
扩展:对某一结点进行前插操作
前插操作说指在某节点的前面插入一个新结点,后插操作的定义刚好与之相反。在单链表的插入算法中,通常都采用后插操作
以上面的算法为例,首先调用GetElem()找到第i-1个结点,即插入结点的前驱结点后,再对其进行后插操作。由此可知,对结点的前插操作均可转化为后插操作,前提是从单链表的头结点开始顺序查找到其前驱结点,时间复杂度为O(n)
此外,可采用另一种方式将其转化为后插操作来实现。设插入结点为*s,将*s插入到*p的前面,我们仍然将*s插入到*p的后面,然后将p->data与s->data交换,这样既满足了逻辑关系,又能使得时间复杂度为O(1),算法的代码片段如下:
//将*s结点插入到*p之前的主要代码片段
s->next=p->next;
p->next=s;
temp=p->data;
p->data=s->data;
s->data=temp;
删除结点操作
删除结点操作是将单链表的第i个结点删除,先检查删除位置的合法性,然后查找表中第i-1个结点,即被删除结点的前驱结点,再将其删除
代码片段如下:
p=GetElem(L,i-1);//查找删除位置的前驱结点
q=p->next;
p->next=q->next;
free(q);//释放结点的存储空间
和插入算法一样,该算法的时间复杂度也消耗在查找操作上,时间复杂度为O(n)
扩展,删除结点*p
其实,删除结点*p的操作也可用删除*p的后继结点的操作来实现,实质就是将其后继节点的值赋予自身,然后删除后继结点,也使得时间复杂度为O(1)
实现上述操作的代码如下:
q=p->next;
p->data=p->next->data;
p->next=q->next;
free(q);//释放后继节点的存储空间
求表长操作
求表长操作是计算单链表中数据节点(不含头结点)的个数,需要从第一个结点开始顺序依次访问表中的每个结点,为此需要设置一个计数器变量,每访问一个结点,计数器加1,直到访问到空结点为止,算法的时间复杂度为O(n)