由于顺序表的插入、删除操作需要移动大量的元素,影响了运行效率,由此引入了线性表的
链式存储。链式存储线性表时,不需要使用地址连续的存储单元,即它不要求逻辑上相邻的两个
元素在物理位置上也相邻,它是通过 “ 链 ” 建立起数据元素之间的逻辑关系,因此,对线性表
的插入、删除不需要移动元素,而只需要修改指针。
-------------单链表的定义
线性表的链式存储又称为单链表,它是指通过一组任意的存储单元来存储线性表中的数据
元素。为了建立起数据元素之间的线性关系,对每个链表结点,除了存放元素自身的信息之外,
还需要存放一个指向其后继的指针。单链表结点结构如下图,其中,data 为数据域,存放数据
元素;next 为指针域,存放其后继结点的地址。
--------------------------------------------------------------------------------------------------
单链表中结点类型的描述如下:
typedef struct LNode{ // 定义单链表结点类型
ElemType data ; // 数据域
struct LNode *next ; // 指针域
} LNode , * LinkList ;
---------------------------------------------------------------------------------------------------
利用单链表可以解决顺序表需要大量的连续存储空间的缺点,但是单链表附加指针域,也存在
浪费存储空间的缺点。由于单链表的元素是离散地分布在存储空间中的,所以单链表是非随机存取的
存储结构,即不能直接找到表中某个特定的结点。查找某个特定的结点时,需要从头开始遍历,依次
查找。
通过 “ 头指针 ” 来标识一个单链表,如单链表 L ,头指针为 “ NULL ” 时则表示一个空表。此外,
为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点。头结点的数据域不设任何信
息,也可以记录表长等相关信息。头结点的指针域指向线性表的第一个元素结点,如下图:
头结点和头指针的区分:不管带不带头结点,头指针始终指向链表的一个结点,而头结点是带头结点
链表中的第一个结点,结点内通常不存储信息。
引入头结点后,可以带来两个优点:
》》由于开始结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他
位置上的操作一致,无须进行特殊处理。
》》无论链表是否为空,其头指针是指向头结点的非空指针(空表中头结点的指针域为空),因此空表
和非空表的处理也就统一了。
-------------单链表上基本操作的实现
1. 采用头插法建立单链表
该方法从一个空表开始,生成新结点,并将读取到的数据存放到新结点的数据域中,然后将新结点
插入到当前链表的表头,即头结点之后。如下图:
--------------------------------------------------------------------------------------------------------------------------
头插法建立单链表的算法如下:
LinkList CreateList1(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为头指针
L->next = s ;
scanf ( " %d " , &x) ;
} // while 结束
return L;
}
--------------------------------------------------------------------------------------------------------------------------------------
采用头插法建立单链表,读入数据的顺序与生成的链表中元素的顺序是相反的。每个结点插入
的时间为 O(1) , 设单链表长为 n , 则总的时间复杂度为 O( n ) 。
2. 采用尾插法建立单链表
头插法建立单链表的算法虽然简单,但生成的链表中结点的次序和输入数据的顺序不一致。若
希望两者次序一致,可采用尾插法。该方法是将新结点插入到当前链表的表尾上,为此必须增加一个
尾指针 r , 使其始终指向当前链表的尾结点。如下图所示:
---------------------------------------------------------------------------------------------------------------
尾插法建立单链表的算法如下:
LinkList CreateList2( 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 ;
}
------------------------------------------------------------------------------------------------------------------------------
因为附设了一个指向表尾结点的指针,故时间复杂度和头插法的相同。
3. 按序号查找结点值
在单链表中从第一个结点出发,顺指针 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 NULL; // 若 i 无效,则返回 NULL
}
while( p && j < i ){
p = p->next ;
j++;
}
return p ; // 返回第 i 个结点的指针,如果 i大于表长,
p = NULL , 直接返回 p 即可
}
------------------------------------------------------------------------------------------------------------------
按序号查找操作的时间复杂度为 O( n ) 。
4. 按值查找结点
从单链表的第一个结点开始,由前往后依次比较表中各结点数据域的值,若某结点数据域的
值等于给定值 e , 则返回该结点的指针;若整个单链表中没有这样的结点,则返回 NULL。
-----------------------------------------------------------------------------------------------------------------
按值查找结点的算法如下:
LNode * LocateElem ( LinkList L , ElemType e ){
//本算法直接查找单链表 L (带头结点)中数据域值等于 e 的结点指针,否则返回 NULL
LNode * p = L->next ;
while( p != NULL && P->data != e){ // 从 第 1 个结点开始查找 data 域为 e 的结点
p = p->next ;
}
return p ; // 找到返回该结点指针,否则返回 NULL
}
----------------------------------------------------------------------------------------------------------------------
按值查找操作的时间复杂度为 O( n)
5. 插入结点操作
插入操作是将值为 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 ; // 上图中的操作步骤 1
第三步: p->next = s ; // 上图中的操作步骤 2
---------------------------------------------------------------------------------------------------------------------------------
上面的代码片段中,第二步和第三步顺序不能颠倒。
本算法主要的时间开销在于查找第 i -1 个元素,时间复杂度为 O( n ) 。若是在给定的结点
后面插入新结点,则时间复杂度仅为 O( 1 ) 。
扩展:对某结点进行前插操作。
前插操作是指在某结点的前面插入一个新结点,后插操作的定义刚好与之相反,在单链表
插入算法中,通常都是采用后插算法的。
-----------------------------------------------------------------------------------------------------------
// 将 *s 结点插入到 *p 之前的主要代码片段
s->next = p->next ; // 修改指针域,不能颠倒
p->next = s ;
temp = p->data ; // 交换数据域部分
p->data = s->data ;
s->data = temp ;
小总结:先把申请的空间插入进去,然后交换一下数据域部分,即后插法
变为前插法。
------------------------------------------------------------------------------------------------------------------
6. 删除结点操作
删除结点是将单链表的第 i 个结点删除。先检查删除位置的合法性,然后查找
表中第 i -1 个结点,即被删除结点的前驱结点,再将其删除。其操作过程如下图:
假设结点 *p 为找到的被删除结点的前驱结点,为了实现这一操作后的逻辑关系
的变换,仅需要修改 *p 的指针域,即将 *p 的指针域 next 指向 *q 的下一个结点。
------------------------------------------------------------------------------------------------------------
实现删除结点的代码片段如下:
p = GetElem( L , i - 1 ) ; // 查找删除位置的前驱结点
q = p->next ; // 令 q 指向被删除结点
p->next = q -> next ; // 将 *q 结点从链中 “ 断开 ”
free( q ) ; // 释放结点的存储空间
-------------------------------------------------------------------------------------------------------------
和插入的算法一样,该算法的主要时间也是耗费在查找操作上,时间复杂度为 O( n ) 。
扩展:删除结点 *p
要实现删除某一给定结点 *p ,通常的做法是先从链表的头结点开始顺序找到其前驱
结点,然后再执行删除操作即可,算法的时间复杂度为 O( n ) 。
其实,删除结点 *p 的操作可以用删除 *p 的后继结点操作来实现,实质就是将其后继
结点的值赋予其自身,然后删除后继结点,也能使得时间复杂度为 O( 1 ) 。
---------------------------------------------------------------------------------------------------------------
实现上述操作的代码片段如下:
q = p->next ; // 令 q 指向 *p 的后继结点
p->data = p->next->data; // 用后继结点中的数据覆盖要删除结点的数据
p->next = q->next ; // 将 *q 结点从链中 “ 断开 ”
free( q ) ; // 释放后继结点的存储空间
--------------------------------------------------------------------------------------------------------------------
7. 求表长操作
求表长操作就是计算机单链表中数据结点(不含头结点)的个数,需要从第一个结点开始
顺序依次访问表中的每一个结点,为此需要设置一个计数器变量,每访问一个结点,计数器
加 1 ,直到访问到空结点为止。算法的时间复杂度为 O( n ) 。
需要注意的是,因为单链表的长度时不包括头结点的,因此,不带头结点和带头结点的
单链表在求表长操作上会略有不同。对不带头结点的单链表,当表为空时,但单独处理。
单链表是整个链表的基础,读者一定要熟练掌握单链表的基本操作算法,在设计算法时,
建议先通过图示的方法理清算法的思路,然后再进行算法的编写。
-------------双链表
单链表结点中只有一个指向其后继的指针,这使得单链表只能从头结点依次顺序地向后遍历。
若要访问某个结点的前驱结点(插入、删除操作时),只能从头开始遍历,访问后继结点的时间
复杂度为 O(1) , 访问前驱结点的时间复杂度为 O( n ) 。
为了克服单链表的删除缺点,引入了双链表,双链表结点中有两个指针 prior 和 next ,分别
指向其前驱结点和后继结点。如下图所示:
-----------------------------------------------------------------------------------------------------------------------------
双链表中结点类型的描述如下:
typedef struct DNode { // 定义双链表结点类型
ElemType data ; // 数据域
struct DNode *prior , * next ; // 前驱和后继指针
}DNode , * DLinkList ;
---------------------------------------------------------------------------------------------------------------------------
双链表仅仅是在单链表结点中增加了一个指向其前驱的 prior 指针,因此,在双链表中
执行按值查找和按位查找的操作和单链表相同。但双链表在插入和删除操作的实现上,和
单链表有着较大的不同。这是因为 “ 链 ” 变化时也需要对 prior 指针做出修改,其关键在于
保证在修改的过程中不断链。此外,双链表可以很方面地找到其前驱结点,因此,插入、
删除结点算法的时间复杂度为 O(1) 。
1. 双链表的插入操作
在双链表中 p 所指的结点之后插入结点 *s ,其指针的变化过程如下图:
-----------------------------------------------------------------------------------------------------------
插入操作的代码片段如下:
第一步: s->next = p->next ; // 将结点 *s 插入到结点 *p 之后
第二步: p->next->prior = s ;
第三步: s->prior = p ;
第四步: p->next = s ;
------------------------------------------------------------------------------------------------------------
上面的代码的语句顺序不是唯一的,但也不是任意的,第一步和第二步必须在第四步
之前,否则 *p 的后继结点的指针就丢掉了,导致插入失败。
2. 双链表的删除操作
删除双链表中结点 *p 的后继结点 *q ,其指针的变化过程如下图:
--------------------------------------------------------------------------------------------------------------
删除操作的代码片段如下:
p->next = q->next ; // 上图中的第一步
q->next->prior = p ; // 上图中的第二步
free( q ) ; // 释放结点空间
建立双链表的操作中,也可以采用如同单链表的头插法和尾插法,但是在操作上需要
注意指针的变化和单链表有所不同。
-------------循环链表
1. 循环单链表
循环单链表和单链表的区别在于,表中最后一个结点指针不是 NULL ,而改为指向
头结点,从而整个链表形成了一个环,如下图所示:
在循环单链表中,表尾结点 *r 的 next 域指向 L ,故表中没有指针域为 NULL 的结点,因此,
循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针。
循环单链表的插入、删除算法与单链表几乎一样,所以不同的是如果操作是在表尾进行,则
执行的操作不同,以让单链表继续保持循环的性质。当然,正是因为循环单链表是一个 “ 环 ” ,
因此,在任何一个位置的插入和删除操作都是等价的,无须判断是否是表尾。
在单链表中只能从表头结点开始往后顺序遍历整个链表,而循环单链表可以从表中的任一结
点开始遍历整个链表。有时对单链表常做的操作是在表头和表尾进行的,此时可以对循环单链表
不设头指针而仅设尾指针,从而使得操作效率更高。其原因是若设的是头指针,对表尾进行操作
需要 O( n ) 的时间复杂度,而如果设的是尾指针 r , r->next 即为头指针,对于表头与表尾进行
操作都只需要 O( 1 ) 的时间复杂度。
2. 循环双链表
由循环单链表的定义不难推出循环双链表,不同的是在循环双链表中,头结点的 prior 指针还要
指向表尾结点,如下图所示:
在循环双链表 L 中,某结点 *p 为尾结点时, p->next = = L ; 当循环双链表为空表时,其
头结点的 prior 和 next 域都等于 L 。
-------------静态链表
静态链表是借助数组来描述线性表的链式存储结构,结点也有数据域 data 和 指针域 next ,
与前面所讲的链表中的指针不同的是,这里的指针是结点的相对地址(数组下标),又称为游标。
和顺序表一样,静态链表也要预先分配一块连续的内存空间。
静态链表和单链表的对应关系如下图:
---------------------------------------------------------------------------------------------------------------------
静态链表结构类型的描述如下:
# define MaxSize 50 // 静态链表的最大长度
typedef struct { // 静态链表结构类型的定义
ElemType data ; // 存储数据元素
int next ; // 下一个元素的数组下标
} SLinkList[ MaxSize ] ;
-------------------------------------------------------------------------------------------------------------------
静态链表以 next == -1 作为其结束的标志。静态链表的插入、删除操作与动态链表相同,
只需要修改指针,而不需要移动元素。总体来说,静态链表没有单链表使用起来方便,但是在
一些不支持指针的高级语言(如 Basic)中 ,这又是一种非常巧妙的设计方法。
-------------顺序表和链表的比较
1. 存取方式
顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。
2. 逻辑结构和物理结构
采用顺序存储时,逻辑上相邻的元素,其对应的物理存储位置也相邻。而采用链式存储时,
逻辑上相邻的元素,其物理存储位置则不一定相邻,其对应的逻辑关系是通过指针链接来表示的。
这里请读者注意区别存取方式和存储方式。
3. 查找、插入和删除操作
对于按值查找,当顺序表在无序的情况下,两者的时间复杂度均为 O( n ) ; 而当顺序表有序时,
可采用折半查找,此时时间复杂度为 O() 。
对于按序号查找,顺序表支持随机访问,时间复杂度为 O( 1 ) ,而链表的平均时间复杂度为
O( n ) 。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需
要修改相关结点的指针域即可。由于链表每个结点带有指针域,因而在存储空间上比顺序存储要
付出较大的代价,存储密度不够大。
4. 空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,如果再加入新元素将出现内存
溢出,需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置;
预先分配过小,又会造成溢出。
动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中
没有更大块的连续存储空间将导致分配失败。
链式存储的结点空间只在需要的时候申请分配,只要内存有空间就可以分配,操作灵活、高效。
---------------在实际中应该怎样选取存储结构呢?
1. 基于存储的考虑
对线性表的长度或存储规模难以估计时,不宜采用顺序表;链表不用事先估计存储规模,但是链表
的存储密度较低,显然链表存储结构的存储密度是小于 1 的。
2. 基于运算的考虑
在顺序表中按序号访问 a (i) 的时间复杂度为 O( 1 ) ,而链表中按序号访问的时间复杂度为 O( n ) ,
所以如果经常做的运算是按序号访问数据元素,显然顺序表优于链表。
在顺序表中做插入、删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,
这一点是不应忽视的;在链表中做插入、删除操作时,虽然也要找插入位置,但是操作是比较简单的,
从这个角度考虑显然后者优于前者。
3. 基于环境的考虑
顺序表容易实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现
较为简单,这也是用户考虑的一个因素。
总之,两种存储结构各有长短,选择哪一种由实际问题的主要因素决定。通常较稳定的线性表选择
顺序存储,而频繁做插入、删除操作的线性表(即动态性较强)宜选择链式存储。