概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
链表分为:
-
带哨兵位/不带哨兵位
-
双向/单向
-
循环/不循环
单链表(不带哨兵位单向不循环链表)
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next; // 指向下一个节点,通过这个指针将这个节点与下个节点连接起来
}SLTnode;
单链表的常见函数接口实现.
// 创建节点,传入
SLTNode* CreateLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode)); // 通过malloc函数创建一个新的节点
if(newnode == NULL)
{
perrot("malloc fail");
return;
}
newnode->data = x; // 对新创建的节点内的值进行初始化
newnode->next = NULL; // 对新创建的节点内的指针做初始化
return newnode;
}
// 在链表头部插入数据。注意,因为我们需要在头部插入数据,即改变第一个节点的指向
int main()
{
int x = 10;
SLTNode* head = CreateLTNode(x); // 此时head指向的就是存储数据10的那个节点
SLPushFront(&head, 20); // 头插之后,head就需要指向存储数据为20的那个节点
// 想要改变一级指针的值,我们就需要传入二级指针,所以当函数有可能会改变head的指向时,就需要传入二级指针
}
void SLPushFront(SLTNode** phead, SLTDAtaType x)
{
assert(phead); // 检查传入的指针是否是有效指针
SLTNode* newnode = CreateLTNode(x);
newnode->data = x;
newnode->next = NULL;
newnode->next = *phead; // 将老节点连接在新节点后面
*phead = newnode; // 更新头节点
}
// 在链表的尾部插入数据,如果链表为空,那么此时尾插就等同于头插。所以需要传入二级指针
void SLPushBack(SLTNode** phead, SLDataType x)
{
assert(phead);
SLTNode* newnode = CreateLTNode(x);
if(*phead == NULL) // 通过检查头节点是否位空来判断链表是否为空,为空则需要改变链表的头节点
{
*phead = newnode;
}
else
{
SLTNode* tail = *phead; // 链表不为空,那么就遍历链表,找到链表的末尾去插入数据
while(tail->next != NULL)
// 想要将节点链入,我们就需要在前一个节点的next之中,存入节点的地址,否则就无法将节点链入链表
{
tail = tail->next;
}
tail->next = newnode;
}
}
// while(tail != NULL)
// {
// tail = tail->next;
// }
// tail = newnode;
// SLTNode* tail = *phead;
// 此写法是错的,虽然看上去我们最后找到了那个尾节点,并将新节点链入链表了,但实际上,这种写法根本没有将新节点链入链表。
// 下图中tail指向的值是链表的最后一个节点,此时才能通过解引用去改变next指向.
// 否则,当tail为最后一个节点的next时,将tail的值改变并不会影响next.
// 最后,tail确实是最后一个节点的next,为NULL,但是tail并不是最后一个节点的next,tail是next的拷贝,他们是两个变量,只是值相同罢了。
// 我们需要改变的是最后一个节点的next,而不是next的拷贝。所以,tail需要保留的是最后一个节点的地址,通过地址解引用去改变next的指向。 。
插入的示意图
// 删除链表尾部的数据,有可能链表只有一个数据了,那么删除尾部就是在删除头部数据。面说过,凡是需要修改头指针的行为,都需要传入二级指针
void SLPopBack(SLTNode** phead)
{
assert(phead);
assert(*phead); // 检查链表是否为空,为空就不能删除了,不使用assert的话,也可以使用if语句来判断
if((*phead->next) == NULL)
{
free(*phead); // 释放空间,防止内存泄漏
*phead = NULL; // 如果只有一个数据,那么就需要将头指针指控,防止野指针
}
else
{
SLTNode* prev = NULL; // 有多个数据,那么就需要遍历,找到最后一个节点,释放空间,但是如果只是释放了空间,
SLTNode* tail = *phead; // 那么前一个指针还会保留一个无效的地址,下一次遍历就会造成野指针问题,
while(tail->next) // 所以我们需要记录倒数第二个节点,并将倒数第二个节点的next置空: prev->next = NULL;
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
// 删除链表头部数据,在头部删除,肯定需要改变头指针,所以也需要传入二级指针
void SLPopFront(SLTNode** phead)
{
assert(phead);
assert(*phead); // 判断链表是否为空,为空则不需要进行删除
SLTNode* next = (*phead)->next; // 提前记录next,这样在释放空间以后,就不会出现找不到下一个节点的问题
free(*phead);
*phead = next;
}
// 在链表中查找是否存在某个值,存在则返回这个值所在节点的地址,否则返回NULL。因为只是查找,不需要改变链表,所以传一级指针即可
SLTNode* SLFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while(cur) // 从头节点开始查找,遍历链表
{
if(cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
// 再pos位置之前插入数据,既是插入,就有可能发生头插,所以须传入二级指针
void SLInsert(SLTNOde** phead, SLTNode* pos, SLTDataType x)
{
assert(phead);
assert(pos); // 检查插入位置是否合法,排除NULL
if(*phead == pos) // 检查是否头插
{
SLPushFront(phead, x); // 直接复用头插
}
else
{
SLTNode* prev = *phead; // 如果不是头插,那么和查找的逻辑一样,只不过这次比较的不是里面的数据,而是地址,
while(prev->next != pos) // 同样,插入节点需要更新前一个节点的next,所以还需要记录前一个结点的地址
{
prev = prev->next;
}
SLTNode* newnode = CreateLTNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
// 删除pos位置的值,有可能发生头删,所以依旧传入二级指针
void SLErase(SLTNode** phead, SLTNode* pos)
{
assert(pphead);
assert(pos); // 检查删除位置的合法性,排除NULL
if(pos == *phead) // 删除位置为头节点,直接复用头删
{
SLPopFront(phead);
}
else
{
SLTNode* prev = *phead; // 与插入一样,需要先找到这个节点,通过地址比较寻找,
while(prev->next != pos) // 同样,在删除之后,还需要将被删除节点的前后指针连接起来,否则会造成数据丢失
{ // 寻找删除节点的前一个结点
prev = prev->next;
}
prev->next = pos->next; // 将前一个指针指向被删除节点的后一个指针
free(pos);
}
}
带头双向循环链表
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next; // 指向下一个节点
struct ListNode* prev; // 指向前一个结点
LTDataType data;
}LTNode;
int main()
{
LTNode* head = Init(); // 这个就是哨兵位,这个节点并不存储数据,
// 只是为了方便查找,插入等操作
}
// 有了哨兵位之后,想要进行头插,头删等操作时,就没有那么多种情况了,无论怎么插入删除,都不会改变哨 兵位,插入删除都是在head之后举行的
// 从下面这个图中可以看出,这个链表的不再是只能从一个方向遍历了,再任意一个节点都可以向前或向后遍历
// head与链表的最后一个节点相连接,当我们需要进行尾插时,就不需要在去遍历链表了,而是直接通过head来 访问
一些函数接口实现:
// 初始化链表,主要是创建哨兵位
LTNode* Init()
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNOde));
newnode->prev = newnode;
newnode->next = newnode;
return newnode;
}
// 创建链表节点
LTNode* CreatNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if(newnode == NULL)
{
perror("malloc fail");
return NULL;
}
newnode->data = x;
newnode->next = NULL; // 置空,防止野指针问题
newnode->prev = NULL;
return LTNode;
}
// 在链表末尾插入数据,尾部与head相连,所以不需要再去遍历找尾
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = CreatNode(x); // 插入数据时,一定要注意,节点之间的连接关系
newnode->next = phead; // 在进行断开连接(改变指针指向)时,要注意先进行连接的建立
phead->prev->next = newnode;// 否则,断开连接之后,我们会与节点失联,这样也就无法继续接
newnode->prev = phead->prev;// 下来的操作。
phead->prev = newnode; // 如: 先执行 phead->prev = newnode;
// 那么此时,head与链表的最后一个节点连接断开,但是,新插入
// 的节点还没有与前最后节点建立联系,就会导致链表结构错误
}
// 如下图,phead->prev改变之后,phead->prev也就指向了newnode,
// phead->prev->next = newnode;
// 即->newnode->next = newnode;
// 此时连接就发生了错误,简单一点的方法就是建立一个对象,用来记录tail的地址
// LTNode* tail = phead->prev; 这样就不用关注先断哪个连接,先建立哪个连接了
插入示意图:
// 头插,过程与尾插差不多,具体就不赘述了
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = CreatNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
// 判断链表是否为空,只要判断head的next指向的是不是自己就行了
bool LTEmpty(LTNode* phead)
{
if(phead->next == phead)
{
return true;
}
else
{
return false;
}
}
// 尾删,删除也变得简单,现在可以直接通过prev指针找到前一个节点,我们不用在自己记录了
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* node = phead->prev; // 临时对象,记录要删除数据地址
phead->prev = node->prev; // 然后重新建立连接
phead->prev->next = phead;
free(node); // 最后销毁节点,释放空间
}
// 头删,和尾删差不多。
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* first = phead->next; // 要删除的节点
LTNode* second = first->next; // 被删除结点的下一个节点
phead->next = second; // 重新映射
second->prev = phead;
free(first); // 释放空间
}
// 插入,在pos位置前面插入。
// 由此可以看出,双向链表的插入不用区分是否是空链表,即无所谓是否是头插等
// 头插完全可以调用这个函数
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev; // 记录pos的前一个节点
LTNode* newnode = CreatNode(x);
prev->next = newnode; // 重新映射关系
newnode->prev = prev;
node->next = pos;
pos->prev = newnode;
}
// 删除,删除pos位置
// 删除也是一样,无论删除哪个位置,逻辑与过程都一样
// 头删也可以直接调用这个函数
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* posprev = pos->prev;
LTNode* posnext = pos->next;
posprev->next = posnext;
posnext->prev = posprev;
free(pos);
}
// 最后,当链表使用完成之后,要将链表继续销毁,释放空间,防止内存泄漏
void Destroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while(cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
顺序表链表异同
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持O(1) | 不支持:O(N) |
任意位置插入或删除元素 | 可能需要移动元素,效率低O(N) | 只需要修改指针指向 |
插入 | 动态顺序表,空间不够时需要扩容 | 没有容量的概念 |
引用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
链表:
优点:任意位置插入删除O(1)。按需申请空间
缺点:
- 不支持下标的随机访问。
- 缓存的命中率低。
顺序表:
缺点:
- 前面部分的插入删除数据,效率是O(N),需要挪动数据。
- 空间不够,需要扩容:扩容需要付出代价的,一般还会伴随空间浪费。
优点:
- 尾插尾删效率不错。
- 下标的随机访问。