目录
一、单链表的定义
1.单链表定义
通过一组任意的存储单元来存储线性表中的数据元素
逻辑一对一关系通过指针表示
是线性表的链式存储
2.单链表结点类型表述
描述:因为逻辑相邻不一定物理相邻,所以需要额外的指针来存储后继信息
单链表结点的组成:
data 数据域,存放数据元素 next 指针域,存放后继结点的地址 代码实现:
typedef struct LNode { ElemType data; //数据域 struct LNode* next; //指针域,指向后继 }LNode, * LinkList; //LinkList等价于LNode*
注意代码中的ElemType根据具体需求确定,比如int,则再加一句typedef int ElemType;
3.单链表特点
解决了顺序表插删需要大量移动元素的缺点
引入了额外的指针域,浪费了空间
单链表是非随机存取的存储结构,查找需要从头遍历
最后一个结点的next指向“空”(通常用NULL或“^”符号表示)
4.例子
5.“头指针”与“头结点”
注:
(1)指针变量pointer的两种身份:
①表示指针变量本身
指针变量也是变量;它存储地址值;存储的值本身是可以修改的
涉及pointer =···时(pointer修改)
②表示指针变量存储的地址上的值
不涉及pointer的修改操作时 比如说p->next=q是在修改next指针变量本身;p->next->data是在访问next指针指向的地址上的量的data数据域
(2)p与p->next
头结点:
(1)头结点定义:在单链表第一个元素结点之前额外附加的一个结点
(2)头节点内容:
数据域 可以不记录信息,也可以记录表长等信息 指针域 指向单链表的第一个元素结点 (3)有头结点图示:
(4)无头结点图示:
(5)头结点引入原因:
①使得对链表第一个位置上的操作和其它位置的操作一致,无需特别处理
②空表和非空表处理统一 若有头结点,头指针指向头结点,那么无论链表是否为空,头指针均不为空,头结点的设置使得对链表的第一个位置上的操作与在表中其它位置上的操作一致;若无头结点,空表时头指针为空,非空表时头指针不为空
(6)有头结点和无头结点2个例子比较(可帮助理解引入头结点的好处):
①删除第一个元素结点,且该表只有一个元素结点
有头结点时
关键操作 p=L->next;
L->next=L->next->next;
free(p);
图示 无头结点时
关键操作 free(L);
L=NULL;
图示 ②删除第一个元素结点,且该表有多个元素结点
有头结点时
关键操作 p=L->next;
L->next=L->next->next;
free(p);
图示 无头结点时
关键操作 p=L;
L=L->next;
free(p);
图示 上面两个例子我们更好的发现,当单链表引入头结点时,可以实现空表和非空表的统一;并且使得链表第一个位置上的操作和其它位置的操作一致,无需特别处理。这就是引入头结点的好处。
头指针:
(1)头指针定义:头指针始终指向链表的第一个结点(无论是否有头结点)
(2)注解:当前链表有头结点时,头指针指向头结点;当前链表没有头结点时,头指针指向第一个元素结点
链表是顺序存取的存储结构——要取得第i个数据元素必须从头指针出发顺链进行寻找
二、单链表上基本操作的实现
1.头插法建立单链表
(1)思想:
①创建一个只有头结点的空链表
②根据待创建链表包括的元素个数n,循环n次执行以下操作:
生成一个新结点*p 输入元素值赋给新结点*p的数据域 将新结点*p插入到头结点之后 (2)将新结点B插入到头结点之后:
(3)关键操作:
//插入s到表头 s->next = L->next; //s后继设置L L->next = s; //L后继设置s
(4)例子:
依次插入edcba
(5)代码实现:
LinkList createListByHead(LinkList &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到表头 s->next = L->next; //s后继设置L L->next = s; //L后继设置s scanf("%d", &x); } return L; //返回头结点 }
(6)注解:
①由于函数里有直接修改L变量的语句,要实参形参一起改变则必须使用引用
②读入数据的顺序与链表中元素的顺序相反
③时间复杂度为O(N),基本操作为插入操作,对n个结点都需要进行插入
④给定一个单链表,如何得到逆序的单链表?依次摘下来,头插到表中
2.尾插法建立单链表
(1)思想:
①创建一个只有头结点的空链表
②尾指针r初始化,指向头结点
③根据创建链表包括的元素个数n,循环n次执行以下操作:
生成一个新结点*p 输入元素值赋给新结点*p的数据域 将新结点*p插入到尾结点*r之后 尾指针r指向新的尾结点*p (2)将B插入到链表尾部:
(3)关键操作:
//插入s到表尾 r->next = s; r = s;
(4)例子:
依次插入abcde
(5)代码实现:
LinkList createListByTail(LinkList &L) { int x; //存输入元素 L = (LinkList)malloc(sizeof(LNode)); //创建头结点 LNode* s, //s为工作指针 * r = L; //r为表尾元素 scanf("%d", &x); //读取输入值 while (x != 9999) { //约定,输入9999表示创建结束 s = (LNode*)malloc(sizeof(LNode)); //创建新结点 s->data = x; //设置数据域 //插入s到表尾 r->next = s; r = s; scanf("%d", &x); } r->next = NULL; //尾巴置空 return L; //返回头结点 }
(6)注解:
①由于函数里有直接修改L变量的语句,要实参形参一起改变则必须使用引用
②读入数据的顺序与链表中元素的顺序相同
③时间复杂度为O(N),基本操作为插入操作,对n个结点都需要进行插入
3.按序号查找结点值
(1)思想:
①查找序号i位置上的结点
②从头开始遍历链表,并记录当前遍历到的位置,该位置与i相等时则查找成功,遍历到了表尾还有达到i,查找失败(i大于表长)
(2)代码实现:
/* * 取出单链表L(带头结点)中第i个位置的结点指针 */ LNode* GetElemByPos(LinkList L, int i) { int j = 1; //计数,初始为1 LNode* p = L->next; if (i == 0) { //i为0,返回头结点 return L; } if (i < 1) return NULL; //无效的i,返回NULL while (p != NULL && //链表还没有遍历完 j < i) { //还没有到达所求的结点 p = p->next; //指针后移 j++; //计数++ } return p; //i大于表长,NULL;否则返回找到的结点 }
(3)复杂度:
①时间复杂度
最坏情况:i=n,时间复杂度为O(n)
最好情况:i=1,时间复杂度为O(1)
平均情况:有n个查找位置(1到n),总的比较次数为1+2+3+······+n=n(1+n)/2,在每个位置查找的概率相等,则除以n,即n(1+n) / 2n = (1+n)/2,因此平均复杂度为O(N)
②空间复杂度O(1)
4.按值查找结点
(1)注:首元结点就是第一个元素结点
没有头结点的时候 就是链表的第一个结点 有头结点的时候 就是头结点的直接后继结点 (2)思想:
①从链表的首元结点出发,依次将结点值和给定值e进行比较
②中途遇见数据域与e相等的结点则查找成功,返回该结点
③链表遍历完毕都没有找到数据域与e相等的结点则查找失败,返回NULL
(3)代码实现:
/* * 从头开始遍历,返回其中数据域值为给定e的结点的指针,不存在则返回NULL */ LNode* locateElemByValue(LinkList L, ElemType e) { LNode* p = L->next; while (p != NULL //表中还有元素 && p->data != e) { //还没有找到数据域的值是e的结点 p = p->next; } return p; //没找到,p是NULL;找到了,就是对应结点 }
(4)复杂度:
时间复杂度O(N);空间复杂度O(1);这类复杂度的分析不再赘述了
5.求表长
(1)思想:就是求表中除头结点外的结点的个数;设置一个计数器,遍历即可
(2)代码实现:
//求有头结点的单链表长度 int length(LinkList L) { //初始长度 int len = 0; //去掉头结点 LNode* p = L->next; //每向后遍历一个,长度加1 while (p != NULL) { len++; p = p->next; } return len; }
(3)复杂度:
时间复杂度O(N);空间复杂度O(1);这类复杂度的分析不再赘述了
6.插入结点
(1)前言:和头插法建立单链表非常类似,把第i-1个结点看作头结点就行
(2)思想:
①将值为x的结点插入到单链表的第i个位置上
②先检查插入位置i(1<=i<=length(L)+1)的合法性
③找到待插入结点的前驱结点,在其后执行插入
(3)在p之后插入s:
(4)关键操作:
//在p之后插入s s->next = p->next; //想要插入的,你先主动 p->next = s; //我配合
(5)代码实现:
/* * 在L的第i个位置上插入元素值为x的结点 */ void insertNode(LinkList L, int i, ElemType x) { if (i < 1 || i > length(L) + 1) { //非法插入位置 return ; } LNode* s = (LNode*)malloc(sizeof(LNode)); //创建一个新的结点 s->data = x; //数据域赋值 LNode* p = GetElemByPos(L, i - 1); //找到第i个结点的前驱结点 s->next = p->next; //执行插入 p->next = s; }
(6)复杂度:
时间复杂度为O(N),主要是查找第i-1个结点;空间复杂度为O(1)
(7)注:
①将s插入到p前面的两种方法
方法一 找到p的前驱,O(N) 方法二(一般不考虑该方法) 将s插到p后面,再交换s与p的数据域,O(1) ②这里L为什么不用引用&?
这不是在修改这个链表吗?不需要引用吗?能看到修改后的链表吗?答案是不需要的,用不用引用仅仅只看一点,是不是需要形参实参一起改变;这里L量本身并不会被修改,其它结点的修改都通过L->next串起来了;所以是可以看到修改后的链表的
③考研代码题的伪代码—类似当前的代码,对于一些不太复杂的基本函数可以直接使用(length、GetElemByPos)只关注当前问题的核心代码就行
7.删除结点
(1)思想:
①将单链表的第i个位置上的结点删除
②先检查删除位置i(1<=i<=length(L))的合法性
③找到单链表的第i-1个位置上的结点,再执行删除第i个结点
(2)删除A:
(3)关键操作:
//删除p的后继结点 q = p->next; //q指向需要删除的结点 p->next = q->next; //将需要删除的结点从链表上取下 free(q); //释放需要删除的结点
(4)代码实现:
/* * 删除第i个结点,并将数据域通过e返回,删除成功返回true,否则返回false */ bool deleteNode(LinkList L, int i, ElemType &e) { if (i<1 || i>length(L)) { //删除位置非法 return false; } LNode* p, //被删除结点的前驱结点 * q; //辅助指针 p = GetElemByPos(L, i - 1); //被删除结点的前驱结点 //删除p的后继结点 q = p->next; //q指向需要删除的结点 p->next = q->next; //将需要删除的结点从链表上取下 e = q->data; //将值交给e free(q); //释放需要删除的结点 return true; }
(5)复杂度:
时间复杂度为O(N),主要是查找第i-1个结点;空间复杂度为O(1)
(6)注:
删除结点p的两种方法
方法一 需要先遍历到p的前驱结点,O(N) 方法二(一般不考虑该方法) 将p的数据域与后继结点交换,然后删除后继结点,O(1)
三、双链表
1.引入
单链表只能从前向后遍历,这对于插入删除【O(N)复杂度】来说不方便
单链表找直接后继:O(1);找直接前驱:O(N)
双链表使得可以通过某结点访问它的直接前驱、直接后继
2.双链表构成
(1)构成:数据域、prior前驱指针域、next后继指针域
(2)示意图:
3.双链表的结点类型描述
typedef struct DNode { ElemType data; //数据域 struct DNode *prior, //前驱指针域 *next; //后驱指针域 }DNode, *DLinkList;
4.相关操作
双链表的插入
(1)关键操作:
//在p之后插入s s->next = p->next; p->next->prior = s; s->prior = p; p->next = s;
(2)解释:
s->next = p->next; p->next->prior = s; s->prior = p; p->next = s; (3)记忆提示:插入操作,我应该主动,你应该配合
我先主动指向后面的,后面配合着指向我;我先主动指向前面的,前面的配合着指向我
(4)注:上述语句过程不是唯一的,但是第1、2步必须在第4步之前;总之,这类题的做法就是逐步画图,看看指针是否丢失了
双链表的删除
(1)关键操作:
//删除p的后继结点q p->next = q->next; q->next->prior = p; free(q);
(2)解释:
p->next = q->next; q->next->prior = p; free(q); (3)记忆提示:删除操作,前后失守
前驱指向了我的后继;后继指向了我的前驱(二者顺序可换)
其他操作
与单链表类似
四、循环链表
1.循环单链表
循环单链表定义
(1)定义:与单链表不同的是,循环单链表表尾结点指向了头结点,从而形成环
(2)示意图:
循环单链表与单链表的相关操作
(1)判断某结点是否为尾结点:
单链表 该结点的next是否为null 循环单链表 该结点的next是否为头结点 (2)判断表是否为空:
单链表 头结点的next是否为null 循环单链表 头结点的next是否为头结点 (3)尾结点后插入:
单链表 新插入的结点next值为NULL 循环单链表 新插入的结点的next为头结点 (4)删除尾结点:
单链表 删除尾结点,尾结点的直接前驱的next置为NULL 循环单链表 删除尾结点,尾结点的直接前驱的next置为头结点 (5)遍历操作:
单链表 只能从头结点开始,向后遍历 循环单链表 从任意结点开始,向后遍历 (6)频繁操作表头表尾:
单链表 除非同时设置表头指针、表尾指针,否则复杂度是O(N) 循环单链表 只需设置表尾指针(表尾的next就是头结点)复杂度是O(1)【增查改为O(1);删除表尾是O(N),需要去到表尾元素的直接前驱】 (7)注:
①尾结点的next指向的是头结点,不是元素结点
②尾指针、循环类链表——在插入、删除结点之后,一定要保证尾指针依然指向的最后一个结点,依然是循环的;上述操作可能含带来额外的时间开销,容易忽略
2.循环双链表
循环双链表定义
(1)定义:与双链表不同的是,循环双链表的头结点的prior指向了表尾结点,表尾结点的next指向了头结点
(2)示意图:
循环双链表与双链表的相关操作
(1)判断某结点是否为尾结点:
双链表 该结点的next是否为null 循环双链表 该结点的next是否为头结点 (2)判断表是否为空:
双链表 头结点的next和prior都是null 循环双链表 头结点的next和prior都是头结点 (3)尾结点后插入:
双链表 新插入的结点next指为NULL 循环双链表 新插入的结点的next为头结点,头结点的prior修改为指向新插入结点的地址 (4)删除尾结点:
双链表 删除尾结点,尾结点的直接前驱的next置为NULL 循环双链表 删除尾结点,尾结点的直接前驱的next置为头结点,头结点的prior修改为新尾结点的地址 (5)遍历操作:
双链表 从头结点开始,向后遍历;从尾结点开始,向前遍历 循环双链表 从任意结点开始,双向遍历 (6)频繁操作表尾、表头:
双链表 除非同时设置表头指针、表尾指针,否则复杂度是O(N) 循环双链表 只需设置表尾指针(表尾的就是头结点),复杂度是O(1) (7)注:
尾结点的next指向的是头结点,不是元素结点
五、静态链表
(1)前言:
指针 绝对指针 就是咱们前面所提的指针,用指针变量存储地址值,存储的地址是变量在内存中的地址 相对指针 其实数组的下标也是指针,只是地址是相对与首地址来说的 有的语言不支持绝对指针,但是它们也想拥有链表
(2)静态链表定义:是借用数组实现的描述线性表的链式存储结构
(3)静态链表的结点构成:
数据域data 指针域next ★说明next★
①与链表中的指针域不同,这里的指针域next指的是数组的下标,是相对地址,也叫作游标
②游标0处的next记录着该线性表的表头元素的相对地址
③以next=-1标记该线性表的结尾
(4)示意图:
游标0的next值为2——线性表的表头元素存放在下标为2处(a)
游标2的next值为1——表示a的直接后继元素存放在下标为1处(b)
游标1的next值为6——表示b的直接后继元素存放在下标为6处(c)
游标6的next值为3——表示c的直接后继元素存放在下标为3处(d)
游标3的next值为-1——表示d为表尾
(5)静态链表结构类型描述:
#define MaxSize 50 //静态链表的最大长度 typedef struct{ ElemType data; //数据域:该结点内存放的数据元素 int next; //指针域:下一个元素的数组下标 }SLinkList[MaxSize];
(6)静态链表特点:
①插删不需要移动元素,只需修改指针
②需要一次性分配大量空间,静态链表不允许扩容,定义就是这样的
六、顺序表链表总结
1.对比
(1)存取方式 | 顺序表:顺序存取+随机存取 | |
链表:顺序存取 | ||
(2)逻辑结构与物理结构 | 顺序存储:逻辑相邻,也物理相邻 | |
链式存储:逻辑相邻,物理不一定相邻 | ||
(3)查找 | 按值查找 | 表无序时,链表O(N),顺序表O(N) |
表有序时,链表O(N),顺序表O( | ||
按序查找 | 顺序表:O(1)【随机访问】 | |
链表:O(N) | ||
(4)插入删除 | 顺序表:O(N)【移动元素】 | |
链表:O(1)【只需修改指针】 | ||
(5)存储密度 | 顺序表:存储密度大 | |
链表:存储密度不大(需要额外存储指针信息) | ||
(6)空间分配 | 顺序存储 | 静态分配存储:一次性分配(分配过大浪费、分配过小溢出);存储空间满后,不能扩充,再加入元素会导致内存溢出 |
动态分配存储:分配的存储空间可以扩充;扩充空间会移动大量元素,效率低;扩充空间不一定成功 扩充即找一块足够大的空间(不一定找得到), 全体搬家(效率低) | ||
链式存储 | 结点空间只在需要的时候申请分配;以结点为单位进行分配,失败率更低,操作更灵活【找的空间不是特别大】 |
2.选择
(1)从存储考虑出发 | 线性表的长度、存储规模难以估计:链表 |
(2)从运算考虑出发 | 按序访问:顺序表【复杂度为O(1)】 插删结点:链表【复杂度为O(1)】 |
(3)从环境考虑出发 | 顺序表在各种编程语言中都容易实现【指针()引用)不是所有语言都有】 |
(4)从表是否稳定出发 | 表结构稳定:顺序表 表结构不稳定(频繁插删):链表 |
3.注
(1)单链表删除操作(给定当前结点p)
删除当前结点p | 时间复杂度为O(N),因为需要遍历到p的前驱结点才能完成删除 |
删除p的后继结点 | 时间复杂度为O(1) |
(2)单链表的插入操作(给定当前结点p)
插入新的结点在p这个结点的位置 | 时间复杂度为O(N),因为需要遍历到p的前驱结点才能完成插入 |
插入新的结点在p之后 | 时间复杂度为O(1) |