链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
知识点:
以单链表为例
知识点:
虽然有这么多的链表的结构,但是我们实际中最常用是一下两种结构:
┌───┬───┐
│data │next │
└───┴───┘
data域 --> 存放结点值的数据域
next域 --> 存放结点的直接后继的地址(位置)的指针域;
链表通过每个结点的指针域将链表的n个结点按其逻辑顺序链接在一起的,每个结点只有一个指针域的链表称为单链表(Single Linked List)。
单链表顾名思义——单向链表,单链表的运行方向只有一个,链表结点存储这当前结点的数据以及下一结点的地址,链表末尾处为NULL空指针。
参考代码
C语言
#pragma once
#include
#include
typedef int SLTDataType;
typedef struct SListNode // 单链表结点结构
{
SLTDateType data; // 单链表数据信息
struct SListNode* next; // 当前结点的后继结点信息
}SLTNode;
void IniSLTNode()
{
SLTNode* plist = NULL; // 初始化单链表
}
Java
class ListNode {
public int val;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public class MyLinkedList {
public ListNode head; //链表的头引用
public void IniSLTNode()
{
ListNode listNode1 = new ListNode(12); // 链表插入12
ListNode listNode2 = new ListNode(23); // 链表插入23
ListNode listNode3 = new ListNode(34); // 链表插入34
ListNode listNode4 = new ListNode(45); // 链表插入45
ListNode listNode5 = new ListNode(56); // 链表插入56
listNode1.next = listNode2;
listNode2.next = listNode3;
listNode3.next = listNode4;
listNode4.next = listNode5;
this.head = listNode1;
}
}
设计思路:
因为单链表的尾部指针默认为空(NULL),那么当我们需要遍历输出整个链表时只需要判断是否到达最后一个尾结点位置输出即可;在遍历时,输出完成后需要将遍历指针调整为下一个结点的指针,以此类推。
**时间复杂度:**O(N)
参考代码:
C语言
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
}
逻辑实现:
设计思路:
因为单链表是前向单向遍历的,一个结点存储着结点值和下一结点的地址,所以在实现单链表的尾插操作时逻辑操作应该是:先判断链表内是否有数据,如果没有就将数据插入在头部;在插入时,通过头部遍历到尾部指针为NULL处,申请插入一个结点放入data,将新申请的结点后继指针位置置为空指针NULL。最后再将新申请到的结点地址连接到原结点尾部的指针域处,实现链表尾插。
**时间复杂度:**O(N)
参考代码:
C语言
void SListPushBack(SLTNode** phead, SLTDataType x)
{
// 新申请一个结点
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
// 如果头节点是空的,就直接将申请到的节点插入
if (*phead == NULL)
{
*phead = newnode;
}
else // 如果头节点非空,那就去寻找尾结点
{
SLTNode* tail = *phead;
while (tail->next != NULL)
{
tail = tail->next;
}// 找到尾结点后
tail->next = newnode; // 赋值新的尾结点;
}
}
逻辑实现:
设计思路:
*在尾插时,我们要考虑头结点是否为空指针,因为空指针不能作为头结点。而在头插时,即使头结点是NULL空指针,同样相当于把头结点的NULL转为尾部指针,所以在头插时可以不考虑头结点是否为空指针。
**时间复杂度:**O(1)
参考代码:
C语言
void SListPushFront(SLTNode** phead, SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = *phead;
*phead = newnode;
}
逻辑实现:
设计思路:
因为要在删除掉最后一个数据以后要将上一个数据的链接指针置为空,理想方案是:设置一个prev指针记录当前遍历指针的地址,当遍历指针temp走到尾并删除结点后,prev指针所在的位置就是被删除结点的上一结点的位置,此时只要将prev的链接指针指向NULL,即可完整实现链表删除操作。同时,这里需要注意的是:如果链表当前只有一个结点,就不需要将上一结点链接指针置空的操作。
**时间复杂度:**O(N)
参考代码:
C语言
void SListPopBack(SLTNode** phead)
{
if (*phead == NULL) // 判断单链表是否为空,如果为空无法进行尾插操作
return;
if ((*phead)->next == NULL) // 如果单链表只有一个数据,只需要释放当前结点即可
{
free(*phead);
*phead = NULL;
} // 如果单链表长度 > 1
else
{
SLTNode* prev = NULL; // 记录指针
SLTNode* temp = *phead; // 遍历指针
while (temp->next != NULL) // 当遍历指针没有到达链表末尾
{
prev = temp; // 在进入下一次遍历之前,遍历指针记录着遍历指针当前的位置
temp = temp->next; // 遍历指针进入下一步
}
free(temp);
temp = NULL; // 找到尾结点后,释放尾结点并置为空指针
prev->next = NULL; // 记录指针存储着被删除结点的上一结点信息,此时将其结点的链接指针置为空完成尾删。
/* 第二种写法,不需要prev记录指针,连续解引用。
SLTNode* temp = *phead;
while (temp->next->next != NULL)
{
temp = temp->next;
}
free(temp->next);
temp->next = NULL;
*/
}
}
逻辑实现:
设计思路:
**时间复杂度:**O(1)
参考代码:
C语言版本
void SListPopFront(SLTNode** phead)
{
if (*phead == NULL) // 考虑链表是否为空
return;
SLTNode* next = (*phead)->next; // 将新的头结点改为第二个结点地址
free(*phead);
*phead = next; // 释放头结点
}
逻辑实现:
设计思路:
**时间复杂度:**O(N)
参考代码:
C语言
SLTNode* SListFindElem(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead; // 遍历指针cur
while (cur) // 只要指针非空
{
if (cur->data == x) // 找到数据data
return cur; // 找到返回结点信息
else
cur = cur->next; // 没找到继续遍历
}
return NULL; // 没找到返回空NULL
}
思考:
能不能改写成,返回结点的信息同时返回序列位置呢?或是找出链表内所有元素呢?
逻辑实现:
设计思路:
**时间复杂度:**O(N)
参考代码:
C语言
SLTNode* SListGetIndexData(SLTNode* phead, int index)
{
if (phead == NULL)
return NULL;
if (index == 0)
return phead;
int count = 1;
SLTNode* cur = phead;
// while(cur != NULL)
while (cur && count < index)
{
cur = cur->next;
count++;
// if (count == index) break;
}
return cur;
}
结点法的意思是:传入的参数是链表的一个结点,只需要在这个结点后插入即可
设计思路:
**时间复杂度:**O(1)
参考代码:
void SListInsertAfterUsingElem(SLTNode* pos, SLTDataType x)
{
if (pos == NULL)
return;
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = pos->next;
pos->next = newnode;
}
设计思路:
**时间复杂度:**O(N)
参考代码:
C语言
void SListInsertAfterUsingIndex(SLTNode** phead, int index, SLTDateType x)
{
if (index == 0 && phead == NULL)
return;
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
SLTNode* prev = *phead;
for (int count = 0; count < index; count++)
{
prev = prev->next;
if (prev == NULL)
{
printf("超出链表长度导致了插入失败\n");
return;
}
}
newnode->next = prev->next;
prev->next = newnode;
}
(向前插入要比向后插入复杂一些,但尽量要熟悉前插操作的思路,这对于后续的C++ STL库剖析非常有帮助)
如果需要在结点之前插入元素,需要改变上一结点的链接指为新节点,以及设置新结点链接指针为当前结点的地址。这句话是不是听着很熟悉?没错,与链表尾删法思路相似(现场考察尾删法!!)!但这里我们不需要删除元素,我们最主要做的就是替换上一结点的链接指针。
设计思路:
**时间复杂度:**O(N)
参考代码:
void SListInsertBeforeUseElem(SLTNode** phead, SLTNode* pos, SLTDateType x)
{
if (phead == NULL)
return;
if (pos == NULL) // 确定信息在链表中存在
return;
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
if (*pphead == pos) // 如果插入元素的地址是头元素
{
newnode->next = *phead;
*phead = newnode;
}
else
{
SLTNode* posPrev = *phead; // 用来找到pos的前一个位置的遍历指针
while (posPrev->next != pos) // 找到pos的前一个位置
{
posPrev = posPrev->next;
}
posPrev->next = newnode; // 将前一个位置的链接指针为新结点
newnode->next = pos; // 将新结点的链接指针为插入位置
}
}
设计思路:
**时间复杂度:**O(N)
参考代码:
void SListInsertBeforeUseIndex(SLTNode** phead, int index, SLTDateType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
if (index == 0) // 如果插入元素的地址是头元素, 直接插入
{
newnode->next = *phead;
*phead = newnode;
}
else
{
int count = 0;
SLTNode* posPrev = *phead; // 遍历指针
SLTNode* prev = NULL; // 记录指针
while (posPrev->next != NULL)
{
prev = posPrev; // 在进入下一次遍历之前,记录指针会记录着遍历指针当前的位置
posPrev = posPrev->next; // 遍历指针进入下一步
count++;
if (count == index)
{
prev->next = newnode; // 将前一个位置的链接指针的指向为新结点
newnode->next = posPrev; // 将新结点的链接指针为插入位置
return;
}
}
printf("超出链表长度导致了插入失败\n"); // 如果未达到指定位置而已经遍历完成
return;
}
}
设计思路:
**时间复杂度:**O(N)
参考代码:
void SListEraseUsingElem(SLTNode** phead, SLTNode* pos)
{
if(phead == NULL || pos == NULL) // 确定传入合法性
return;
if (*phead == pos) // 头删
{
SListPopFront(phead);
}
else
{
SLTNode* prev = *phead; // 遍历指针prev
while (prev->next != pos)
{
prev = prev->next; // 找到删除结点的上一个结点位置
}
prev->next = pos->next; // 将上一结点的链接指针 指向 删除结点的下一结点地址
free(pos); // 释放删除结点
pos = NULL; // 这里的pos可以置空也可以不置空,因为pos是一个形参,出了函数就会被栈帧销毁
}
}
设计思路:
**时间复杂度:**O(N)
参考代码:
void SListEraseUsingIndex(SLTNode** phead, int index)
{
if (phead == NULL) // 确定传入合法性
return;
if (index == 0)
{
SListPopFront(phead);
}
else
{
int count = 0;
SLTNode* posPrev = *phead; // 遍历指针,等会它会指向删除结点
SLTNode* prev = NULL; // 记录指针,之后会指向删除结点的前一个位置
while (posPrev->next != NULL)
{
prev = posPrev; // 在进入下一次遍历之前,记录指针会记录着遍历指针当前的位置
posPrev = posPrev->next; // 遍历指针进入下一步
count++;
if (count == index)
{
prev->next = posPrev->next; // 将前一个位置的链接指针指向删除结点的下一个位置
free(posPrev);
posPrev = NULL; // 思考一下,这里的posPrev要不要置空呢?
return;
}
}
printf("超出链表长度导致了删除失败\n"); // 如果未达到指定位置而已经遍历完成
return;
}
}
设计思路:
删除结点后的元素,需要获取到其往后数2个序列位置的结点的地址并将其链接上,随后释放到后面的结点,所以我们有以下方案:
**时间复杂度:**O(1)
参考代码:
void SListEraseAfterUsingElem(SLTNode* pos)
{
if (pos == NULL || pos -> next == NULL) // 检查传入合法性
return;
SLTNode* next = pos->next; // next为结点后的元素,这里先拷贝一份出来
pos->next = next->next; // 将结点指向删除元素的下一个结点
free(next); // 释放被删除的结点
next = NULL; // 同样,这里的next是否置空都可以,但出于职业素养我还是置空吧OWO~
}
设计思路:
思路与结点法一致,而序列法只有序列位置信息,所以我们需要循环遍历到该位置上即可;
时间复杂度:O(N)
参考代码:
void SListEraseAfterUsingIndex(SLTNode** phead, int index)
{
printf("%d: ", index);
SLTNode* posPrev = *phead;
for (int count = 0; count < index; count++)
{
posPrev = posPrev->next;
if (posPrev == NULL)
{
printf("超出链表长度导致了删除失败\n"); // 如果已经到达尾部仍为到达序列位置时直接退出
return;
}
}
SListEraseAfterUsingElem(posPrev); // 因为遍历指针已经走到尾删的结点处,直接调用结点法偷个小懒OWO吧~
}
设计思路:
销毁链表同输出链表思路一致,一路遍历,一路释放空间;
**时间复杂度:**O(N)
参考代码:
C语言
void SListDestory(SLTNode** pphead)
{
if (phead == NULL)
return;
SLTNode* cur = *phead;
while (cur)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*phead = NULL;
}
顺序表:一白遮百丑
白:
丑:
链表:一(胖黑)毁所有
胖黑:
所有:
顺序表
优点
缺点:
链表
优点:
缺点:
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连 续 |
随机访问 | 支持O(1) | 不支持:O(N) |
任意位置插入或者删除元 素 | 可能需要搬移元素,效率低O(N) | 只需修改指针指向 |
插入 | 动态顺序表,空间不够时需要扩 容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
可以浏览我往期CPU高速缓存的介绍:什么是CPU告诉缓存?
单链表完~散会!
一个 单链表实现与介绍 笔记 结束啦!~因为相关的知识点不仅重要,还非常非常的多,所以篇幅较长!相信你一定能收获满满的。如果有疑问或者纰漏,欢迎在评论区内交流学习!
如果你觉得本系列文章对你用帮助,别忘了点赞关注作者哦!你的鼓励是我继续创作分享加更的动力!愿我们都能一起在顶峰相见。欢迎来到作者的公众号:“01编程小屋” 做客哦!关注小屋,学习编程不迷路!