【数据结构】带头双向循环链表的增删查改(C语言实现)(2)

简介: 【数据结构】带头双向循环链表的增删查改(C语言实现)(2)

11、在pos位置之前删除数据

和pos位置之前插入数据类似,这里我们的时间复杂度也为O(1),并且我们也可以通过调用此函数来完成头删和尾删的功能。

但是这里有一个问题,那就是pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,但是如果要避免这种情况出现,我们 Erase 函数就需要接受头结点的地址;

但是其实这个问题不应该由函数的实现者来注意,而是应该有函数的调用者来避免,另外感觉为了仅仅为了检查它把头结点传过来又没必要,所以我这里就没对其进行检查;大家可以根据自己的想法来实现这个函数。

//在pos位置之前删除数据
void ListErase(LTNode* pos)
{
  //这里有一个问题:pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,需要函数调用者自己注意
  assert(pos);
  //记录pos的前一个节点的前一个节点
  LTNode* prev = pos->prev->prev;
  //free pos前的节点
  free(pos->prev);
  //修改链接关系(当pos为第二个节点/头结点节点时逻辑也成立)
  //ps:头删和尾删可以通过直接调用此函数来完成
  prev->next = pos;
  pos->prev = prev;
}
//在头部删除数据
void ListPopFront(LTNode* phead)
{
  assert(phead);
  assert(!IsEmpty(phead));  //删空时继续删除报错
  ListErase(phead->next->next);  //相当于删除第二个节点前的数据
}
//在尾部删除数据
void ListPopBack(LTNode* phead)
{
  assert(phead);
  assert(!IsEmpty(phead));  //删空时继续删除报错
  ListErase(phead);  //相当于删除头结点前的数据
}

12、修改pos位置处的数据

其实在C++ STL 的 List 中是没有单独的修改函数的,因为 Find 就可以实现修改的功能;Find 函数返回一个数据的地址 pos ,然后我们直接修改 pos->data 即可,但是这里我还是单独实现了一个修改函数。

//修改链表数据
void ListModify(LTNode* pos, LTDataType x)
{
  assert(pos);
  pos->data = x;
}

13、返回链表长度

由于链表长度的计算需要遍历链表,时间复杂度为O(N),而带头双向链表其他接口的时间复杂度都是O(1),所以这里显得有些突兀;

所以有的书上或者学校就用头结点来存储链表元素,反正头结点也不用于存储数据,乍一看这样设计好像没有什么问题,但是当我们存储的数据不是整形,而是其他类型,比如 char 时,这种设计就有问题了;

当 data 的类型是 char时,我们知道 char 最大为127,超过127就会发生截断,这种情况下当链表长度大于127时,头结点中的 data 存储的链表长度就是错误的了;更别说我们用其来存储结构体类型的数据了。

所以说用头结点来存储链表长度这种设计是因小失大、不合理的;如果实在想让计算链表长度的时间复杂度变为O(1),我们可以在结构体中增加一个size变量,专门用于记录链表长度。

//计算链表长度
size_t ListSize(LTNode* phead)
{
  assert(phead);
  size_t size = 0;
  LTNode* cur = phead->next;  //链表长度不包含头结点,因为头结点不存储有效数据
  while (cur != phead)
  {
    size++;
    cur = cur->next;
  }
  return size;
}

14、打印链表数据

这里我们需要注意循环结束的条件,由于我们的链表是循环的,所以 cur 永远不可能为空,而是当 cur 回到头时代表遍历完成。

//打印链表数据
void ListPrint(LTNode* phead)
{
  assert(phead);  //因为链表是带头的,所以phead不可能为空
  LTNode* cur = phead->next;  //从第一个有效元素开始打印
  printf("head<=>");
  while (cur != phead)  //当cur回到头结点时循环结束
  {
    printf("%d<=>", cur->data);
    cur = cur->next;
  }
  printf("\n");
}

15、销毁链表

和 Init 函数相反,销毁链表需要同时销毁哨兵位头结点,也就是说我们需要改变头结点;要改变头结点有两种方法:


1、传递二级指针:考虑到接口的一致性,我们不使用此方法;


2、把函数返回值改为结构体指针:在销毁链表时我们还要去接受链表的返回值,感觉很别扭,所以我们也不用;


基于上面这两点:头结点置空的操作需要函数调用者在函数外来执行。

//销毁链表
void ListDestory(LTNode* phead)
{
  assert(phead);  //链表带头
  LTNode* cur = phead->next;
  //释放掉除头结点以外的其他节点
  while (cur != phead)
  {
    LTNode* next = cur->next;
    free(cur);
    cur = next;
  }
  //释放头结点 (这里需要调用者在函数外部手动把phead置为NULL(要改变phead需要用二级指针或者函数返回值)
  free(phead);
}

三、完整代码

1、List.h

#pragma once  //防止头文件重复包含
//头文件的定义
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
//结构和符号的定义
typedef int LTDataType;
typedef struct ListNode
{
  LTDataType data;          //用于存放数据
  struct ListNode* prev;    //用于存放下一个节点的地址
  struct ListNode* next;    //用于存放上一个节点的地址
}LTNode;
//函数的声明
//初始化双链表
LTNode* ListInit();
//开辟新节点
LTNode* BuyLTNode(LTDataType x);
//打印链表数据
void ListPrint(LTNode* phead);
//销毁链表
void ListDestory(LTNode* phead);
//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x);
//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x);
//查找数据
LTNode* ListFind(LTNode* phead, LTDataType x);
//在pos位置之前插入数据
void ListInsert(LTNode* pos, LTDataType x);
//判断链表是否为空
bool IsEmpty(LTNode* phead);
//在头部删除数据
void ListPopFront(LTNode* phead);
//在尾部删除数据
void ListPopBack(LTNode* phead);
//在pos位置之前删除数据
void ListErase(LTNode* pos);
//计算链表长度
size_t ListSize(LTNode* phead);
//修改链表数据
void ListModify(LTNode* pos, LTDataType x);

2、List.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "LIst.h"
//初始化双链表
LTNode* ListInit()
{
  //创建哨兵位头结点
  LTNode* guard = (LTNode*)malloc(sizeof(struct ListNode));
  if (guard == NULL)
  {
    perror("malloc fail");
    return NULL;
  }
  //让双链表具有双向循环结构
  guard->prev = guard;
  guard->next = guard;
  return guard;
}
//开辟新节点
LTNode* BuyLTNode(LTDataType x)
{
  LTNode* newnode = (LTNode*)malloc(sizeof(struct ListNode));
  if (newnode == NULL)
  {
    perror("malloc fail");
    return NULL;
  }
  newnode->data = x;
  newnode->prev = NULL;
  newnode->next = NULL;
  return newnode;
}
//打印链表数据
void ListPrint(LTNode* phead)
{
  assert(phead);  //因为链表是带头的,所以phead不可能为空
  LTNode* cur = phead->next;  //从第一个有效元素开始打印
  printf("head<=>");
  while (cur != phead)  //当cur回到头结点时循环结束
  {
    printf("%d<=>", cur->data);
    cur = cur->next;
  }
  printf("\n");
}
//销毁链表
void ListDestory(LTNode* phead)
{
  assert(phead);  //链表带头
  LTNode* cur = phead->next;
  //释放掉除头结点以外的其他节点
  while (cur != phead)
  {
    LTNode* next = cur->next;
    free(cur);
    cur = next;
  }
  //释放头结点 (这里需要调用者在函数外部手动把phead置为NULL(要改变phead需要用二级指针或者函数返回值)
  free(phead);
}
//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x)
{
  assert(phead);
  ListInsert(phead->next, x);  //相当于第一个节点前面插入元素
  //assert(phead);  //因为链表是带头的,所以phead不可能为空
  //LTNode* newnode = BuyLTNode(x);
  //LTNode* first = phead->next;  //记录第一个节点
  改变链接关系(当链表中没有节点,即只有一个头时,下面逻辑也正常)
  //phead->next = newnode;
  //newnode->prev = phead;
  //newnode->next = first;
  //first->prev = newnode;
}
//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x)
{
  assert(phead);
  ListInsert(phead, x);  //相当于头结点前面插入元素
  //assert(phead);
  //LTNode* newnode = BuyLTNode(x);
  找尾:头结点的prev指向链表的尾
  //LTNode* tail = phead->prev;
  修改链接关系(当链表中没有节点时逻辑也成立)
  //phead->prev = newnode;
  //newnode->next = phead;
  //newnode->prev = tail;
  //tail->next = newnode;
}
//查找数据
LTNode* ListFind(LTNode* phead, LTDataType x)
{
  assert(phead);
  LTNode* cur = phead->next;
  //遍历链表,找到返回数据所在节点的地址
  while (cur != phead)
  {
    if (cur->data == x)
      return cur;
    cur = cur->next;
  }
  //找不到就返回NULL
  return NULL;
}
//在pos位置之前插入数据
void ListInsert(LTNode* pos, LTDataType x)
{
  assert(pos);
  LTNode* newnode = BuyLTNode(x);
  //找pos的前一个节点
  LTNode* prev = pos->prev;
  //修改链接关系(当pos为第一个节点/最后一个节点时逻辑也成立)
  //ps:头插和尾插可以通过直接调用此函数来完成
  prev->next = newnode;
  newnode->prev = prev;
  newnode->next = pos;
  pos->prev = newnode;
}
//判断链表是否为空
bool IsEmpty(LTNode* phead)
{
  assert(phead);
  return phead == phead->next;  //当链表中只剩下头结点时链表为空,返回true
}
//在头部删除数据
void ListPopFront(LTNode* phead)
{
  assert(phead);
  assert(!IsEmpty(phead));  //删空时继续删除报错
  ListErase(phead->next->next);  //相当于删除第二个节点前的数据
  //assert(phead);
  //assert(!IsEmpty(phead));  //删空时继续删除报错
  记录第一个节点的下一个节点
  //LTNode* second = phead->next->next;
  释放第一个节点
  //free(phead->next);
  修改链接关系
  //phead->next = second;
  //second->prev = phead;
}
//在尾部删除数据
void ListPopBack(LTNode* phead)
{
  assert(phead);
  assert(!IsEmpty(phead));  //删空时继续删除报错
  ListErase(phead);  //相当于删除头结点前的数据
  //assert(phead);
  //assert(!IsEmpty(phead));  //删空时继续删除报错
  记录尾结点的上一个节点
  //LTNode* prev = phead->prev->prev;
  释放尾结点
  //free(phead->prev);
  修改链接关系
  //phead->prev = prev;
  //prev->next = phead;
}
//在pos位置之前删除数据
void ListErase(LTNode* pos)
{
  //这里有一个问题:pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,需要函数调用者自己注意
  assert(pos);
  //记录pos的前一个节点的前一个节点
  LTNode* prev = pos->prev->prev;
  //free pos前的节点
  free(pos->prev);
  //修改链接关系(当pos为第二个节点/头结点节点时逻辑也成立)
  //ps:头删和尾删可以通过直接调用此函数来完成
  prev->next = pos;
  pos->prev = prev;
}
//计算链表长度
size_t ListSize(LTNode* phead)
{
  assert(phead);
  size_t size = 0;
  LTNode* cur = phead->next;  //链表长度不包含头结点,因为头结点不存储有效数据
  while (cur != phead)
  {
    size++;
    cur = cur->next;
  }
  return size;
}
//修改链表数据
void ListModify(LTNode* pos, LTDataType x)
{
  assert(pos);
  pos->data = x;
}

3、test.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "LIst.h"
void test1()  //测试插入
{
  //创建并初始化链表
  LTNode* plist = ListInit();
  //在头部插入数据
  ListPushFront(plist, 1);
  ListPushFront(plist, 2);
  ListPushFront(plist, 3);
  ListPrint(plist);
  //在尾部插入数据
  ListPushBack(plist, 4);
  ListPushBack(plist, 5);
  ListPushBack(plist, 6);
  ListPrint(plist);
  //在pos位置之前插入
  LTNode* pos = ListFind(plist, 3);
  if (pos)
  {
    ListInsert(pos, 30);
  }
  pos = ListFind(plist, 6);
  if (pos)
  {
    ListInsert(pos, 60);
  }
  pos = ListFind(plist, 1);
  if (pos)
  {
    ListInsert(pos, 10);
  }
  ListPrint(plist);
  //销毁链表
  ListDestory(plist);
  plist = NULL;  //需要我们手动置空plist
}
void test2()  //测试删除
{
  //创建并初始化链表
  LTNode* plist = ListInit();
  //在头部插入数据
  ListPushFront(plist, 1);
  ListPushFront(plist, 2);
  ListPushFront(plist, 3);
  ListPrint(plist);
  //在头部删除数据
  //ListPopFront(plist);
  //ListPopFront(plist);
  //ListPrint(plist);
  //在尾部删除数据
  //ListPopBack(plist);
  //ListPopBack(plist);
  //ListPopBack(plist);
  //ListPrint(plist);
  //删除pos位置之前的数据
  LTNode* pos = ListFind(plist, 2);
  if (pos)
  {
    ListErase(pos);
  }
  ListPrint(plist);
  pos = ListFind(plist, 1);
  if (pos)
  {
    ListErase(pos);
  }
  ListPrint(plist);
  //销毁链表
  ListDestory(plist);
  plist = NULL;  //需要我们手动改变plist
}
void test3()  //测试其他功能
{
  //创建并初始化链表
  LTNode* plist = ListInit();
  //在头部插入数据
  ListPushFront(plist, 1);
  ListPushFront(plist, 2);
  ListPushFront(plist, 3);
  ListPrint(plist);
  //计算链表长度
  size_t size = ListSize(plist);
  printf("%u\n", size);
  //修改链表数据
  LTNode* pos = ListFind(plist, 1);
  if (pos)
  {
    ListModify(pos, 10);
  }
  pos = ListFind(plist, 2);
  if (pos)
  {
    ListModify(pos, 20);
  }
  ListPrint(plist);
  //销毁链表
  ListDestory(plist);
  plist = NULL;  //需要我们手动改变plist
}
int main()
{
  //test1();  //测试插入
  //test2();  //测试删除
  //test3();  //测试其他功能
  return 0;
}

大家也可以去我的 Gitee 仓库中获取完整代码:List/List · 野猪佩奇/日常学习 - 码云 - 开源中国 (gitee.com)

四、顺序表和链表的区别

image.png

顺序表的优点

1、尾插尾删效率高;

2、支持随机访问 (下标访问);

3、相比于链表结构,CPU 高速缓存命中率更高;

顺序表缺点

1、在其他位置的插入删除效率低;

2、扩容存在内存消耗和空间浪费;

链表 (带头双向循环) 的优点

1、任意位置插入删除效率都很高;

2、空间按需申请,没有空间浪费;

链表 (带头双向循环) 的缺点

1、由于需要频繁 malloc,所以和顺序表的内存消耗其实差不多;

2、不支持随机访问 (下标访问);

3、相比于顺序表结构,CPU 高速缓存命中率更低;

综合比较顺序表和链表的优缺点,其实在实际生活中,顺序表的应用比链表的应用还要多一些;其中顺序表支持随机访问是一个重要因素,另外还有顺序表 CPU 高速缓存命中率高和其他原因;下面我们来探讨 CPU 高速缓存命中率的问题。

我们知道,为了提高效率与降低成本,我们的计算机是分层存储的,具体的存储体系结构如下图:

2020062310470442.png

从程序环境那一节的学习中我们知道,一个程序经过编译链接后被翻译成二进制指令,这些二进制指令由由 CPU 来执行;


但是,CPU 执行指令时,并不会直接去访问内存中的数据,而是会看数据是否存在于三级缓存中,如果在,就代表命中;如果不在,就代表未命中,未命中情况下数据会被先加载到三级缓存中,然后再次进行访问;


同时,计算机领域有一个局部性原理:当我们访问一个数据时,我们极有可能也会访问其周边的数据;所以在将数据加载到缓存时,我们并不是只将我们要访问的那个数据加载到缓存中,而是会将该数据所在的一长段内存空间的数据都加载到缓存中去,具体加载多少个字节取决于硬件;


对于我们的顺序表来说,它其中的数据是连续存放的,所以我们访问其中的数据不需要每次访问都去加载数据,可能我们第一次访问加载数据之后,我们第十次访问才再次加载数据;


而对于我们的链表来说,链表的每个节点的地址是不具有关联性的,所以在多数情况下我们加载一个数据所在的一长段内存空间时,该内存空间并不包含该节点后面的节点;从而使得我们的 CPU 在访问数据时需要去频繁的去加载数据,导致效率降低;


另外,链表加载数据时还会造成缓存污染,因为我们会将一个数据所在的一长段内存空间的数据都加载到缓存中去,而由于其后面的空间中可能并不包含链表的其他节点,即我们将无用的数据加载进了缓存中,就会造成缓存污染;

2020062310470442.png

image.png

关于缓存的更多知识可以参考下面这篇文章:与程序员相关的CPU缓存知识 | 酷 壳 - CoolShell




相关文章
|
9天前
|
存储 人工智能 C语言
数据结构基础详解(C语言): 栈的括号匹配(实战)与栈的表达式求值&&特殊矩阵的压缩存储
本文首先介绍了栈的应用之一——括号匹配,利用栈的特性实现左右括号的匹配检测。接着详细描述了南京理工大学的一道编程题,要求判断输入字符串中的括号是否正确匹配,并给出了完整的代码示例。此外,还探讨了栈在表达式求值中的应用,包括中缀、后缀和前缀表达式的转换与计算方法。最后,文章介绍了矩阵的压缩存储技术,涵盖对称矩阵、三角矩阵及稀疏矩阵的不同压缩存储策略,提高存储效率。
|
9天前
|
C语言
数据结构基础详解(C语言):图的基本概念_无向图_有向图_子图_生成树_生成森林_完全图
本文介绍了图的基本概念,包括图的定义、无向图与有向图、简单图与多重图等,并解释了顶点度、路径、连通性等相关术语。此外还讨论了子图、生成树、带权图及几种特殊形态的图,如完全图和树等。通过这些概念,读者可以更好地理解图论的基础知识。
|
11天前
|
存储 算法 C语言
数据结构基础详解(C语言): 二叉树的遍历_线索二叉树_树的存储结构_树与森林详解
本文从二叉树遍历入手,详细介绍了先序、中序和后序遍历方法,并探讨了如何构建二叉树及线索二叉树的概念。接着,文章讲解了树和森林的存储结构,特别是如何将树与森林转换为二叉树形式,以便利用二叉树的遍历方法。最后,讨论了树和森林的遍历算法,包括先根、后根和层次遍历。通过这些内容,读者可以全面了解二叉树及其相关概念。
|
11天前
|
存储 C语言
数据结构基础详解(C语言): 树与二叉树的应用_哈夫曼树与哈夫曼曼编码_并查集_二叉排序树_平衡二叉树
本文详细介绍了树与二叉树的应用,涵盖哈夫曼树与哈夫曼编码、并查集以及二叉排序树等内容。首先讲解了哈夫曼树的构造方法及其在数据压缩中的应用;接着介绍了并查集的基本概念、存储结构及优化方法;随后探讨了二叉排序树的定义、查找、插入和删除操作;最后阐述了平衡二叉树的概念及其在保证树平衡状态下的插入和删除操作。通过本文,读者可以全面了解树与二叉树在实际问题中的应用技巧和优化策略。
|
11天前
|
存储 算法 C语言
C语言手撕数据结构代码_顺序表_静态存储_动态存储
本文介绍了基于静态和动态存储的顺序表操作实现,涵盖创建、删除、插入、合并、求交集与差集、逆置及循环移动等常见操作。通过详细的C语言代码示例,展示了如何高效地处理顺序表数据结构的各种问题。
|
11天前
|
存储 C语言
数据结构基础详解(C语言): 栈与队列的详解附完整代码
栈是一种仅允许在一端进行插入和删除操作的线性表,常用于解决括号匹配、函数调用等问题。栈分为顺序栈和链栈,顺序栈使用数组存储,链栈基于单链表实现。栈的主要操作包括初始化、销毁、入栈、出栈等。栈的应用广泛,如表达式求值、递归等场景。栈的顺序存储结构由数组和栈顶指针构成,链栈则基于单链表的头插法实现。
|
13天前
|
Java
【数据结构】栈和队列的深度探索,从实现到应用详解
本文介绍了栈和队列这两种数据结构。栈是一种后进先出(LIFO)的数据结构,元素只能从栈顶进行插入和删除。栈的基本操作包括压栈、出栈、获取栈顶元素、判断是否为空及获取栈的大小。栈可以通过数组或链表实现,并可用于将递归转化为循环。队列则是一种先进先出(FIFO)的数据结构,元素只能从队尾插入,从队首移除。队列的基本操作包括入队、出队、获取队首元素、判断是否为空及获取队列大小。队列可通过双向链表或数组实现。此外,双端队列(Deque)支持两端插入和删除元素,提供了更丰富的操作。
14 0
【数据结构】栈和队列的深度探索,从实现到应用详解
|
1月前
栈的几个经典应用,真的绝了
文章总结了栈的几个经典应用场景,包括使用两个栈来实现队列的功能以及利用栈进行对称匹配,并通过LeetCode上的题目示例展示了栈在实际问题中的应用。
栈的几个经典应用,真的绝了
|
17天前
|
Linux C++ Windows
栈对象返回的问题 RVO / NRVO
具名返回值优化((Name)Return Value Optimization,(N)RVO)是一种优化机制,在函数返回对象时,通过减少临时对象的构造、复制构造及析构调用次数来降低开销。在C++中,通过直接在返回位置构造对象并利用隐藏参数传递地址,可避免不必要的复制操作。然而,Windows和Linux上的RVO与NRVO实现有所不同,且接收栈对象的方式也会影响优化效果。
|
1月前
|
负载均衡 网络协议 安全
DKDP用户态协议栈-kni
DKDP用户态协议栈-kni