目录
1.2 为什么还需要指针变量来保存下一个节点的位置?(了解)
2.2 插入(申请空间-新建节点;尾插、头插、指定位置之前/之后插入)
2.3 删除(尾删、头删、删除指定位置/指定位置之后的数据)
顺序表的问题
1. 中间/头部的插入删除,需要挪动数据,程序效率低下时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:如何解决以上问题呢?使用链表(针对顺序表:中间/头部插入效率低下、增容降低运行效率、增容造成空间浪费)。
1. 链表的概念及分类
1.1 链表概念
链表也是线性表的一种,逻辑结构是连续的,物理结构不一定是连续的。
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
车厢是独立存在的,且每节车厢都有车门。想象一下这样的场景,假设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法:每节车厢里都放一把下一节车厢的钥匙。
在链表里,每节“车厢”是什么样的呢?
与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为“结点/节点”节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。图中指针变量 plist保存的是第一个节点的地址,我们称plist此时“指向”第一个节点,如果我们希望plist“指向”第二个节点时,只需要修改plist保存的内容为0x0012FFA0。
补充说明:
1、链式机构在逻辑上是连续的,在物理结构上不一定连续
2、节点一般是从堆上申请的
3、从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
1.2 为什么还需要指针变量来保存下一个节点的位置?(了解)
链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。结合前面学到的结构体知识,我们可以给出每个节点对应的结构体代码:假设当前保存的节点为整型
struct SListNode
{
int data; //节点数据
struct SListNode* next; //指针变量用保存下一个节点的地址
};
当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数据,也需要保存下一个节点的地址(当下一个节点为空时保存的地址为空)。当我们想要从第一个节点走到最后一个节点时,只需要在前一个节点拿上下一个节点的地址(下一个节点的钥匙)就可以了。
1.3 链表分类(带/不带头、单/双向、循环/不循环)
链表的结构非常多样,以下情况组合起来就有8种(2 x 2 x 2)链表结构:
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:单链表和双向带头循环链表。(本文章实现的是不带头单向不循环链表)
1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
注:
带头链表,的头节点也叫哨兵位。
2. 单链表的实现(附完整版代码)
完整版单链表实现代码:【免费】单链表-C语言实现代码资源-CSDN下载
代码中使用的变量含义:
指针名 | 全称 | 含义 |
pcur | pointer to current(当前节点指针,current: 当前的) | 当前节点指针 |
prev | pointer to previous(前驱节点指针) | 前驱节点指针 |
ptail | pointer to tail(尾节点指针,tail: 尾巴) | 尾节点指针 |
2.1单链表结构体声明
单链表的结构体一般有两个成员变量:节点保存的数据,指向下一个节点的指针。
同时可以给单链表结构体重定义,方便以后使用。
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int SLTDataType; // 类型重定义,方便以后修改为其他类型的数据
// 定义节点的结构
typedef struct SListNode
{
SLTDataType data; // 节点数据
struct SListNode* next; // 指向下一个节点的指针,类型为struct SListNode*
}SLTNode; // 重定义结构体类型名,方便以后使用
2.2 插入(申请空间-新建节点;尾插、头插、指定位置之前/之后插入)
单链表插入分为:尾插、头插、指定位置之前插入、指定位置之后插入。
注意:
1. 为什么要传入二级指针
注:为什么*pphead可以改变后面的节点,因为头节点->next就指向后面的节点,头节点->next属于头节点(第一个节点)的内容。 (这里所说的头节点并不是带头链表的头节点,而是不带头单向链表的第一个有效节点)
2. 在指定位置之前插入节点要记录指定位置之前的指针prev。
// 申请空间-新建节点
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
// 尾插 - 尾插要先找到尾节点
// pphead只对第一个节点有效,因为第一个节点创建的时候要改变*pphead,其余的节点只要改变*pphead指向的内容
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead); // 防止传入NULL,SLTPushBack(NULL,x);
SLTNode* newnode = SLTBuyNode(x);// 新增节点
if (*pphead == NULL)// 如果没有节点,则新建节点为头节点
{
*pphead = newnode; // 改变的头节点
return;
}
else // 如果有节点,则再最后一个节点后面插入节点
{
SLTNode* ptail = *pphead;
while (ptail->next) // 找到最后一个节点
{
ptail = ptail->next;// 改变的头节点的内容
}
// ptail指向的就是尾节点
ptail->next = newnode;
}
}
// 头插
// 因为要改变头节点,即改变*pphead,所以要传入**pphead才可以
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead); // 防止传入NULL,SLTPushBack(NULL,x);
SLTNode* newnode = SLTBuyNode(x);// 新增节点
newnode->next = *pphead;
*pphead = newnode; // 改变的头节点
}
// 在指定位置之前插入数据
// 要找到指定位置的前一个节点,改变prev->next
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead && *pphead); // 防止传入NULL,防止传入的指针没有节点
assert(pos); // 防止指定位置为空
SLTNode* newnode = SLTBuyNode(x);
if (pos == *pphead) // 如果指定位置为头节点
{
SLTPushFront(pphead, x); // 头插,改变的头节点
}
else // 如果不是头节点
{
SLTNode* prev = *pphead; // 找到指定位置之前的节点
while (prev->next != pos)
{
prev = prev->next;
}
newnode->next = pos;
prev->next = newnode;
}
}
// 在指定位置之后插入数据
// 这里只要改变pos的数据,不需要改变pos节点地址的内容,则只需要传一级指针
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
2.3 删除(尾删、头删、删除指定位置/指定位置之后的数据)
1. 尾删和删除指定位置pos位置的节点要记录指定位置之前的指针prev。
// 尾删
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead); // 防止NULL && 链表不能为空
// 链表只有一个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL; // 改变的头节点
}
else
{
// 链表有多个节点
// 找到尾部节点的前一个结点,prev->next置为NULL
SLTNode* ptail = *pphead;
SLTNode* prev = *pphead;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
free(ptail);
ptail = NULL;
prev->next = NULL;
}
}
// 头删
void SLTPopFront(SLTNode** pphead)
{
// pphead是为了防止传入SLTPopFront(NULL),*pphead是为了防止传入的指针没有节点
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
*pphead = pcur->next; // 改变的头节点
free(pcur);
pcur = NULL;
}
// 删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && *pphead);
assert(pos);
if (pos == *pphead)
{
SLTPopFront(pphead);// 头删
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
// 删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
2.4 查找(查找指定数据节点的地址)
和顺序表相比较,顺序表查找的是下标,链表查找的是地址。
找到返回指针,找不到返回NULL。
// 查找
// 查找不需要改变节点内容,只是传递一级指针即可
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
assert(phead);
SLTNode* pcur = phead;
while (pcur)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
2.5 销毁链表
链表要一个节点一个节点的销毁。因为要改变头节点,所以传二级指针。如果传一级指针则需要在外部将*pphead置为空。
// 销毁链表
void SListDestory(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL; // 改变头节点
}
3. 带头循环双向链表的实现(附完整版代码)
完整版单链表实现代码:【免费】双向链表-C语言实现代码资源-CSDN下载
3.1 双向链表介绍
注意:这里的“带头”跟前面我们说的“头节点”是两个概念,实际前面的在单链表阶段称呼不严谨,但是为了同学们更好的理解就直接称为单链表的头节点。带头链表里的头节点,实际为“哨兵位”,哨兵位节点不存储任何有效元素,只是站在这里“放哨的”
“哨兵位”存在的意义:遍历循环链表避免死循环。
3.2 双向链表结构体声明
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
//重定义数据类型
typedef int LTDataType;
// 双向链表结构体
typedef struct ListNode
{
LTDataType data;
struct ListNode* prev; //指针保存下一个节点的地址
struct ListNode* next; //指针保存前一个节点的地址
}LTNode;
3.3 双向链表实现注意问题
1. 头节点(哨兵位)在初始化中创建,在插入、删除时不需要改变头节点,只需要改变头节点的内容,所以传入的指针为一级指针即可。
2. 带头双向循环链表的指定位置插入、删除可以直接复用头插、头删。
3. 判断带头双向循环链表是否走到尾节点的标志是:看当前节点的是否==头节点。
4. 顺序表和链表的优缺点分析
不同点 | 顺序表 | 链表(带头双向循环链表) |
存储空间 | 物理上连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持:O(1) | 不支持:O(N) |
任意位置插入或者删除元素 | 可能需要搬移元素,效率低O(N) | 只需要修改指针指向 |
插入 | 动态顺序表,空间不够时需要扩容,扩容本身消耗、空间浪费 | 没有容量的概念,按需申请释放 |
应用场景 | 元素高校存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
备注:缓存利用率参考存储体系结构 以及 局部原理性
5. 链表相关题目及解析
5.1 简单题目
1. 删除链表中等于给定值 val 的所有结点。 移除链表元素
2. 反转一个单链表。(提示:三指针法) 反转单链表
3. 给定一个带有头结点 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。(提示:快慢指针)链表中间节点
4. 输入一个链表,输出该链表中倒数第k个结点。(提示:快慢指针) 倒数第k个节点
5. 将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有结点组成的。合并链表
6. 编写代码,以给定值x为基准将链表分割成两部分,所有小于x的结点排在大于或等于x的结点之前 。(提示:创建头节点“哨兵位”)链表分割
7. 链表的回文结构(回文即对称结构)。(提示:1.找中间节点;2.反转链表;3.比较)回文结构判断
8. 输入两个链表,找出它们的第一个公共结点。(提示:1.找相差数量;2.遍历链表并判断地址相等。使用了假设法)相交链表
9. 随机链表的深拷贝(拷贝节点插入在原节点的后面,拷贝节点和原节点建立一个关联关系,再抽出拷贝的节点)随机链表的复制
5.2 较难问题
10. 给定一个链表,判断链表中是否有环。 环形链表
【思路】
快慢指针,即慢指针一次走1步,快指针一次走2步,两个指针从链表起始位置开始运行,如果链表带环则一定会在环中相遇,否则快指针率先走到链表的末尾。比如:陪女朋友到操作跑步减肥。
bool hasCycle(struct ListNode *head) {
struct ListNode* slow = head, *fast = head;
while(fast && fast->next)
{
slow = slow->next;
fast = fast->next->next;
if(slow == fast)
return true;
}
return false;
}
【扩展问题】
(1)为什么快指针每次走两步,慢指针走一步可以相遇?
假设链表带环,两个指针最后都会进入环,快指针先进环,慢指针后进环。当慢指针刚进环时,最好情况是直接就和快指针相遇了,最差情况下两个指针之间的距离刚好就是环的长度-1。此时,两个指针每移动一次,之间的距离就缩小一步,因此:在慢指针走到一圈之前,快指针肯定是可以追上慢指针的,即相遇。(假设两个指针相差最远,即:C-1(设环长为C),快慢指针的差值每次-1,即差值为:C-1,C-2,C-3…3,2,0。差值为0时相遇,即慢指针最长走C-1步,两指针相遇)(判断要在fast走之后判断,因为只能是fast追上slow,不可能是slow追上fast)
(2)快指针一次走3步,走4步,...n步行吗?
11. 给定一个链表,返回链表开始入环的第一个结点。 如果链表无环,则返回 NULL。环形链表 II
解法1:slow一次1步,fast一次2步,相遇后。相遇点和起始点同时走,一次走1步,相等时即为环的入口点。(证明如图)
解法2:在相遇点截断,即为判断链表相较的问题。