线性表的链式存储结构
线性表的链式存储结构
单链表
概念: 用一组地址任意的存储单元存放线性表中的数据元素。
以
元
素
(
数
据
元
素
的
映
象
)
+
指
针
(
指
示
后
继
元
素
存
储
位
置
)
‘
=
结
点
(
表
示
数
据
元
素
或
数
据
元
素
的
映
象
)
以元素(数据元素的映象)+ 指针(指示后继元素存储位置) \\ `\\ \ \ \ \ \ = 结点 (表示数据元素 或 数据元素的映象)
以元素(数据元素的映象)+指针(指示后继元素存储位置)‘ =结点(表示数据元素或数据元素的映象)
- 以线性表中第一个数据元素 a 1 a_1 a1的存储地址作为线性表的地址,称作线性表的头指针。链表是由头指针唯一确定的,知道了头指针就知道了链表。
- a i a_i ai的地址被它的直接前驱 a i − 1 a_{i-1} ai−1存放。
- 最后一个结点 a n a_n an的指针域为空。
头结点和头指针
有的时候为了操作方便,在第一个结点之前虚加一个“头结点”,以指向头结点的指针为链表的头指针。
若线性表为空时,头指针不再为空,头结点的指针域为空。
一般单链表都是带头结点的。
总结一下头指针和头结点的异同:
- 头指针是链表的必要条件,而头结点不是;
- 头指针是链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针;
- 头指针具有标识作用,常用头指针冠以链表名;
- 头结点使得在第一元素结点前插入结点和删除第一结点不再特殊,和其它结点的操作统一;
- 头结点将空表和非空表的操作统一;
- 头结点的数据域一般为空(或存放链表长度)。
结点和单链表的c语言描述
typedef struct LNode{
int data; //数据域
struct LNode* next;//指针域
}LNode,*LinkList;//结点别名 结点指针别名
LinkList L;//单链表的头指针,全局变量不是必须的
单链表操作的实现
单链表的初始化
添加头结点,链表的尾结点指针别忘了设成NULL
。
int InitList(LinkList& L)
{
//加入头结点
LNode* a = new LNode;
L = a;
L->next=NULL;
return 0;
}
添加元素至链表尾部
int ListAppend(LinkList& L,int e) {
LNode* p = L;
while (p->next) { //找到链表尾部
p = p->next;
}
LNode* node = new LNode;
node->data = e;
node->next = NULL; //结尾别忘了设成NULL
p->next = node; //加入新结点
return 0;
}
单链表获取第i个数据元素
对于单链表来说,无论是按下标获取元素还是按值获取元素都要对链表进行遍历操作,所以时间复杂度都是O(n)。
按下标获取元素思路:关键是使用一个计数器j记录访问过的结点数,不断用j和要查找的元素下标i进行比较。
int GetElem(LinkList& L, int i, int& e) {
LNode* p=L; int j = 0;
while (p&&j<i) { p=p->next; j++; }
if (!p || j>i) return -1;
e = p->data;
return 0;
}
插入数据元素
向位置i插入指定数据元素e。
思路:让待插入结点的指针*s指向插入位置i的后一结点*ai+1,然后让位置i的结点的指针*ai指向待插入结点*s。
在链表中插入结点只需要修改指针,时间复杂度O(1)。
但是,若在第i个结点插入指针,首先要找到第i个结点,这一步需要O(n)。那么,这和顺序表的时间消耗不是一样了吗?
不是的。虽然都是O(n)的消耗,但顺序表需要移动元素,这是在存储器上操作的;链表只需要比较操作,这是在运算器上操作的。二者不是一个数量级!
int ElemInsert(LinkList& L, int i, int e) {
LNode* p = L;
int j = 1; //位置i从1开始计算,如果需要从0开始,令j=0
while (p&&j<i) { p=p->next; j++; }
if (!p || j>i) return -1;
LNode* s = new LNode;
s->data = e;
s->next=p->next;
p->next=s;
return 0;
}
后插与前插
- 后插结点:p指向链表某结点,s指向待插入的、值为x的新结点,将*s插入到*p的后面。上边的例子就是一个后插结点的操作,直接修改*s让它指向*p的后继结点,再让*p指向*s即可。
- 前插结点:设p指向链表中某结点,s指向待插入的、值为x的新结点,将*s插入到*p的前面。与后插不同的是:首先要找到*p的前驱*q,然后再在*q之后插入*s,设单链表头指针为L。
前插伪代码:
q=L;
while(q->next!=p) q=q++;//找*p的直接前驱
s->next=q->next;
q->next=s;
显然,前插的时间复杂度是O(n)。
前插的时间主要消耗在了查找*p的前驱上,*p已知的情况下,能不能用后插实现前插,将时间复杂度降到O(1)呢?
思路:*s插到*p之后,交换p->dara与s->data。
代码略。
删除数据元素
删除第i个元素ai的操作:
思路:使待删除结点ai的直接前驱ai-1指向待删除结点的直接后继ai+1。
int ElemDelete(LinkList& L,int i,int& e){
LNode* p=L,*q;
int j=0;
while(p&&j<i-1) {p=p->next;j++;} //p指向a_{i-1}
if(!(p->next)||j>i-1) return -1;
q=p->next; //q指向a_{i}
p->next=q->next;//a_{i-1}指向a_{i+1}
e=q->data;
return 0;
}
时间复杂度O(n)。
重置为一个空表
思路:让头指针指向最后一个结点的指针,这个指针是空指针。
int ClearList(LinkList& L){
while(L->next){
LNode* p = new LNode;
p=L->next;L->next=p->next;//不断的删去当前的第一个结点
delete p;
}
return 0;
}
时间复杂度O(n)。
如何从线性表得到单链表(构造单链表)
链表是一个动态结构,生成链表的过程是逐个结点插入的过程。
- 第一步:建立空表(带头结点),也就是单链表初始化。
- 第二步:用元素构建结点,并插入。循环此步骤,直至所有元素都插入完成。
我们对上边提到的添加元素至链表尾部中的方法不断调用就可以实现第二步,这种方法也叫尾插法。
//传入使用 InitList 初始化之后的L
int CreatList(LinkList& L,int e[],int n){
LNode *r=L;//初始化尾指针
for(int i=0;i<n;i++){
LNode* p = new LNode;
p->data=e[i];
r->next=p;//尾部插入新结点
r=p;//新结点变为新的尾结点
}
r->next=NULL;//尾结点的next指向NULL
}
此外,还有一种头插法。
头插法: 通过逆位序输入n个元素的值,建立单链表。
//传入使用 InitList 初始化之后的L
int CreatList(LinkList& L,int e[],int n){
for(int i=0;i<n;i++){
LNode* p=new LNode;
p->data=e[i];
p->next=L->next;
L->next=p;
}
return 0;
}
循环链表(单向)
最后一个结点的指针域的指针又指回第一个结点的链表。
和单链表的差别仅在于,辨别链表中最后一个结点的条件不再是“后继是否为空”,而是“后继是否为头结点”。
特点:
- 对于单链表只能从头结点开始遍历整个链表,而对于单循环链表则可以从表中任意结点开始遍历整个链表。
- 有时对链表常做的操作是在表尾、表头进行,此时可以改变一下链表的标识方法,不用头指针而用一个指向尾结点的指针R来标识,可以使得操作效率提高。
- 在做链表合并和分裂时,如果不是必须从链表头开始,则可以直接在链表指针处合并,时间复杂度可达O(1)。
双向链表和双向循环链表
双向循环链表的空表的结构:头指针指向头结点;头结点的前向指针prior指向自身,后向指针next也指向自身,如图:
插入
s为待插入结点,p为插入位置原来的结点,q为p的直接后继。
已知项是p的后继是q。
需要修改的是p的后继,q的前驱,s的后继,s的前驱。
插入分四步:
- 令s的前驱指向p
- 令s的后继指向p的后继
- 令p的后继(或s的后继)结点的前驱q指向s
- 令p的后继指向s
这四步能不能交换?
可以交换,但不是无条件的。必须时刻保证能够通过s和p能找到q。
一个反例:上来就令p的后继指向s,这时我们就无法找到q了。
规律:第1步的顺序任意,第2步在第4步之前,第3步在第4步之前
删除
已知结点p,p的后继结点是s,s的后继结点是q,现在要删除s。
两步:
- p的后继指向p的后继的后继(q)
- p的后继(q)的前驱指向p
双向链表的操作特点:
- “查询”和单链表相同
- 插入和删除时需要同时修改两个方向上的指针
- 访问某个结点的直接前驱和直接后继时间复杂度都是O(1)。
- 查找第i个结点,向第i给结点插入或删除第i个结点,都要区分是哪个方向
- 如果是双向循环链表,修改指针要同时考虑前驱环链和后继环链上的修改。
- 某个结点的直接前驱的直接后继,或它的直接后继的直接前驱,即为该结点本身。
存储密度:
分母是结点所占的存储量,分子是数据元素所占的存储量。
双向链表和双向循环链表的优点是前驱操作更方便
缺点是存储密度变低了。
静态链表
前边的链表结构都属于动态链表,还有一类静态链表。
借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,与动态链表中的指针不同的是,这里的指针是结点的相对地址(数组的下标,整型),称之为静态指针。
#define MAXSIZE 100
typedef struct sLNode{
int data;
int next; //这个“指针”实际上是基址的相对偏移量
}component,SLinkList[MAXSIZE];
静态链表适用于不支持“指针”的高级语言,或者最大元素固定但插入、删除操作频繁的链表应用中。有关基于静态链表上的线性表操作基本与动态链表相同,除了一些描述方法有区别外,算法思路是相同的。
特点:
- 所有数据元素均存储在连续的空间段,但是相邻两个元素不一定处在相邻的空间,即物理上相邻,逻辑上不一定相邻;
- 修改指针域即可完成插入和删除操作,不需要移动元素,但是也不能随机访问静态链表中的元素;
- 一次性分配所有存储空间,插入、删除时无需再向操作系统申请或释放空间,但也限制了最大表长。
代码可以参考:
https://www.cnblogs.com/zrj-xjyd/p/8735145.html
顺序表和链表的比较
- 顺序表和链表各有优缺点。
顺序表优点:
- 方法简单,各种高级语言都有数组,容易实现。
- 不用为表示结点间的逻辑关系而增加额外的存储开销。
- 顺序表具有按元素序号随机访问的特点。
顺序表缺点:
- 在顺序表中做插入或删除,平均移动大约表中一半的元素,对n较大的顺序表效率低。
- 需要预先分配足够大的存储空间,估计过大会导致顺序表后部大量闲置;估计过小会导致溢出。
链表的优缺点和顺序表相反。
链表的优点:
- 插入和删除不需要移动元素,只需要修改指针
- 动态分配,不需要预先分配,没有溢出和闲置的问题
缺点:
- 链表需要指针,不是每一个高级语言都有。
- 需要借助附加信息,即指针,来描述逻辑关系
- 只能顺序访问,不能随机访问
- 实际中怎样选取存储结构?
- 基于存储的考虑
- 存储规模难以估计时,不宜采用顺序表
- 链表的存储密度较低
- 基于运算的考虑
- 经常按序号访问,顺序表更好
- 经常做插入、删除,链表更好
- 基于环境的考虑
- 顺序表容易实现,任何高级语言都有数组类型