1. 链表的概念和结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的
2. 链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都 是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带 来很多优势,实现反而简单了,后面我们代码实现了就知道了。
3. 单链表的代码实现
对链表进行分析:
- 链表不像顺序表需要初始化空间,再不断realloc进行调整空间大小。
- 链表的各节点都是孤立的(在物理空间上并不连续),再通过指针存储下一节点的地址来进行链接,所以称之为链表。
3.1 声明结点类型
结点分析:首先是结构体类型的 要:1.存储数据 2.存储下一结点地址
// 单链表的数据类型为int
typedef int SLLDataType;
// 单链表结点
typedef struct Single_Linked_List_Node
{
// 数据
SLLDataType data;
// 下一结点指针
struct Single_Linked_List_Node* next;
}SLLNode,*PSLLNode;
//其中SLLNode 是struct Single_Linked_List_Node 类型 结构体
3.2 打印链表 SSLPrint
先实现打印模块,进一步认知链表
遍历链表中的每一个数据,通常不会对链表的头指针进行移动,不然移动之后找不到链表起始位置。
所以要创建中间变量来进行遍历
提问 为啥SSLPrint 打印链表之前不加上assert 断言呢?
因为SSLPrint 中phead 指针可能指向NULL ,也就是空链表的情况 ,所以不需要断言
3.3 创建结点 BuySLLNode
因为在链表创建的过程中,结点的创建是非常频繁的,所以将其封装成小模块,需要时调用即可。
// 这里设计成SLLNode 是有意义的
// 新结点创建出来,是孤立的结点,若为NULL 则找不到该结点
SLLNode* BuySLLNode(SLLDataType x)
{
// malloc出1块SLLNode 的空间 单链表结点
SLLNode* newnode = (SLLNode*)malloc(sizeof(SLLNode));
// 判断是否开辟成功
if (newnode == NULL)
{
perror("malloc fail");
// 既然结点无法创建,直接结束程序 - 无法实现单链表
exit(-1);
}
// 开辟成功
newnode->data = x;
newnode->next = NULL;
// 返回创建出来的结点
return newnode;
}
3.4 头插接口 SLLPushFront
3.4.1 错误版头插
// 头插
void SLLPushFront(SLLNode* phead, SLLDataType x)
{
// 创建结点
SLLNode* newnode = BuySLLNode(x);
// 要将新结点插入到头结点之前,成为新的头节点
// 新结点的指针域本来是NULL,改为phead 之前的头结点的地址(phead)
newnode->next = phead;
// 将头结点改为newnode
phead = newnode;
}
既然头插接口实现了,那接下来对头插进行测试:
因为传参时采用的时传值调用,改变形参并不会影响实参
那么到这里可能许多小伙伴不理解了,plist不是SLLNode *类型的指针吗,这不是传址调用吗?
画个图就明白了
3.4.2 正确版头插
&plist plist 本身就是SSLNode* 类型(一级指针类型) ,那么phead 就需要用二级指针接收
void SLLPushFront(SLLNode** pphead, SLLDataType x)
{
// 为啥这里可以用assert,之前却不行
assert(pphead);
SLLNode* newnode = BuySLLNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
因为pphead 是&plist plist在TestSLList1函数中已经创建,即使plist 指向NULL;但是plist的地址是有效的
而之前的phead 是plist 那么当plist指向NULL时,phead也是NULL 一旦断言就会报错
经过测试发现头插实现成功
3.5 尾插接口 SLLPushBack
要实现尾插要首先找到链表的尾结点
// 尾插
void SLLPushBack(SLLNode** pphead, SLLDataType x)
{
assert(pphead);
SLLNode* newnode = BuySLLNode(x);
// 链表为NULL
if (*pphead == NULL)
{
*pphead = newnode;
}
// 链表中有结点
else
{
//找尾
SLLNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
//tail->next =NULL
tail->next = newnode;
}
}
3.6 头删接口 SLLPopFront
// 头删
void SLLPopFront(SLLNode** pphead)
{
assert(pphead);
// 如果*pphead 也就是plist为NULL 也就意味着链表中没有结点,直接报assert错误
assert(*pphead != NULL);
/*
//只是单纯的这样可以吗?
*pphead = (*pphead)->next;
// 答案是不行的,链表中的结点都是通过malloc来的,也就意味这对应的free
// 虽然这样是将头结点从链表当中移除,但是空间仍然存在,需要释放
*/
SLLNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
del = NULL;
}
3.7 尾删接口 SLLPopBack
// 尾删
void SLLPopBack(SLLNode** pphead)
{
assert(pphead);
/*
// 暴力,温柔二选一
// 温柔的检查
if (*pphead == NULL)
{
return;
}
*/
// 暴力检查
assert(*pphead != NULL);
// 1、一个节点
// 2、多个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
// 找尾
/*SLLNode* prev = NULL;
SLLNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
prev->next = NULL;
free(tail);
tail = NULL;*/
SLLNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
3.8 销毁接口 SLLDestroy
3.9 在pos位置前插入结点 SLLInsert
// 链表在pos位置之前插入(pos 不是下标 pos SLLNode* 类型 指向结点的指针)
void SListInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x)
{
assert(pphead);
assert(pos);
//分情况讨论
// pos指向的是首结点 调用头插接口
if (pos == *pphead)
{
SLLPushFront(pphead,x);
}
else
{
// 不是首结点 那么就需要找到pos之前的结点
// 又因为是单链表形式,只能往后找结点,所以需要重新创建一个指针,指向pos前结点的位置
SLLNode* prev = *pphead;
//指向pos前结点的位置
while (prev->next != pos)
{
prev = prev->next;
// 暴力检查,pos不在链表中.prev为空,还没有找到pos,说明pos传错了(防止pos传递过来的是随机值)
assert(prev);
}
SLLNode* newnode = BuySLLNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
3.10 在pos位置后插入结点 SLLInsertAfter
// 链表在pos位置之后插入
void SLLInsertAfter(SLLNode* pos, SLLDataType x)
{
assert(pos);
SLLNode* newnode = BuySLLNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
在pos后面插入甚至不需要传pphead,因为不需要分情况讨论而且单链表是可以找到后面元素的(但是找不到前面的元素)
3.11 查找元素 SLLFind
SLLNode* SLLFind(SLLNode* phead, SLLDataType x)
{
assert(phead);
SLLNode* cur = phead;
// 遍历一遍链表即可
while (cur != NULL)
{
if (cur->data == x)
{
return cur;
}
// 往后找结点
cur = cur->next;
}
return NULL;
}
测试查找、pos前插入、pos后插入
3.12 删除pos位置结点 SLLErase
void SLLErase(SLLNode** pphead, SLLNode* pos)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
// 头删 调用头删接口
SLLPopFront(pphead);
}
else
{
SLLNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
// 检查pos不是链表中节点,参数传错了
assert(prev);
}
prev->next = pos->next;
free(pos);
// pos = NULL; 这里的pos置为NULL是无效的
// pos 是形参 是SLLNode* 类型的 接受pos的参数也是SLLNode*类型
// 要改变SLLNode* 类型的变量,要传SLLNode**类型 二级指针
// 要改变一级指针变量,要传一级指针的地址 也就是二级指针
}
}
3.13 删除pos位置后的结点 SLLEraseAfter
void SSLEraseAfter(SLLNode* pos)
{
assert(pos);
//该接口无法实现删除尾结点
if (pos->next == NULL)
{
return;
}
else
{
SLLNode* next = pos->next;
pos->next = next->next;
free(next);
}
}
此接口还是存在一定问题,尾结点因为next==NULL 无法指向下一节点
测试SLLErase 、SLLEraseAfter接口
到这里,所有的接口都实现完成!(●’◡’●) 单链表到这里就结束喽🤭
4. 单链表的缺陷
经过单链表的接口实现,我们发现单链表对于头部的操作很便捷,头插头删 时间复杂度为O(1)
而对于尾部来说非常麻烦 时间复杂度为O(N) 要遍历整个链表才能找到尾部
只是单纯的解决了顺序表中不适合头插头删,开辟大空间造成浪费的问题,仍然存在缺陷。
这就引出了更加高级的数据结构 双向循环链表。
5. 链表OJ题
5.1 移除链表元素
guard(哨兵) 不存储有效数据,只是用来指向头结点。
带哨兵位头结点:
之前实现的单链表采用的是二级指针的形式
经过OJ题的练习发现,要改变单链表当中的结点还可以采用返回值的形式:1. 返回新的链表头结点 2. 设计成带哨兵位的链表
5.2 合并有序链表
不带哨兵位:
带哨兵位头结点:
5.3 反转链表
两种思路:
第一种:取结点头插到新链表
第二种:倒过来连接链表结点
5.4 返回链表中间结点
如果只是单纯的找到中间结点只需遍历一遍,计算出有多少个结点,在取一半遍历即可
但是如果加个附加条件,只能遍历链表1次,那该如何操作呢?
思路:
中间结点和链表的长度的关系是一半,那么我们可以采用两个指针(快慢指针),快指针每次移动两个结点,当快指针走到NULL时,慢指针正好走到中间结点
5.5 倒数第k个结点
思路:同样是两个指针,快指针先走k步,再让快慢指针同时走 快指针为NULL结束 慢指针指向的就是倒数第k个结点
当然也可以采用–k的形式:
5.6 链表分割
分析过程:
5.7 回文链表
前两个模块都是使用上述反转和返回中间结点的代码
5.8 相交链表
5.9 环形链表
bool hasCycle(struct ListNode *head) {
struct ListNode* fast = head;
struct ListNode* slow = head;
while(fast&&fast->next)
{
fast = fast->next->next;
slow = slow->next;
if(fast == slow)
{
return true;
}
}
return NULL;
}
思维拓展:
5.10 环形链表 II
返回入口点
另一种思路就是:转换成相交问题
5.11 复制带随机指针的链表