友情链接:【数据结构与算法】首篇 - 思维导图 - 各部分内容目录
故事导入:
幼儿园放学时,一个班级的小朋友,一个跟着一个排队出校,有一个打头,有一个收尾,当中的小朋友每一个都知道他前面和后面的一个是谁,就如同一根线把他们串联了起来。如此具有像线一样的性质的表就叫线性表
线性表(List):零个或多个数据元素的有限序列。
顺序表:用一段地址连续的存储单元依次存储线性表的数据元素,这种存储结构的线性表称为顺序表
在内存中找了块地,占用了这块内存空间,然后把相同数据类型的数据元素依次存放在这块空地中。既然线性表的每个数据元素的类型都相同,所以可以用C语言(其他语言也相同)的一维数组来实现顺序表,即把第一个数据元素存到数组下标为0的位置中,接着把线性表相邻的元素存储在数组中相邻的位置
顺序表的三个关键:
显然,我们在定义顺序表结构的时候需要包含这三个关键,不过需要注意的是,数据结构并不是死的,我们只是建立一个标准,从而按照这个标准书写能方便每个程序员代码之间的互通。所以实际上我们能在一些细节上改动,比如有些人定义顺序表时并没有size这个成员,
区别说明:地址计算方法
在C语言数组中,是从0开始第一个下标的,但我们数数都是从1开始数的,在大多数书记讲解中,线性表以及其它许多数据结构起始也是1,于是线性表的第i个元素是要存储在数组下标为i-1的位置(i>=1),如果我们从0开始数,则第i个元素就存储在数组下标为i的位置(i>=0)。为了习惯C语言,作为使用C/C++语言的我来说,我还是经常使用后者,两者代码实现上需要注意细节即可,在具体位置我都会提到
#define MAX_SIZE 100 //定义最大长度
typedef int ElemType; //ElemType的类型根据实际情况而定,这里假定为int
typedef struct{
ElemType *elems; // 顺序表的基地址 用动态分配的一维数组表示。
int length; // 顺序表的长度
int size; // 顺序表的空间
}SqList;
解释:
int elems[MAX_SIZE];
这些都是最基本的知识,不过后面的初始化等都要相应做出改变 //构造一个空的顺序表 L
bool initList(SqList &L) {
L.elems=new int[MAX_SIZE]; //为顺序表分配 Maxsize 个空间
if(!L.elems) return false; //存储分配失败
L.length=0; //空表长度为 0
L.size = MAX_SIZE;
return true;
}
解释:
有些地方将此函数返回值设置为void,不过很明显存在存储分配失败情况,设置为bool类型比较合适
采用动态分配内存的方法分配一段连续的存储空间,如果按int elems[MAX_SIZE];
方式给出基地址,那么初始化就可以直接通过循环全部赋0即可。建议采用动态分配内存的方法
void initList(SqList &L){
for (int i = 0; i < MaxSize; i++)
{
L.elems[i] = 0; //将所有数据元素初始化为0
}
L.length = 0;
L.size = MAX_SIZE;
}
c语言的动态内存分配方法
L.elems = (int *)malloc(MAXSIZE * sizeof(int));
函数参数使用引用,有时在其他地方能看到使用指针,不过实际上没什么太大区别,用指针到时候就传递地址,用引用到时候就直接传递即可,推荐使用引用,基本知识不过多讲解
bool listInsert(SqList &L,int i, int e) {
if(i<0 || i>L.length)return false; //i 值不合法
if(L.length==MAX_SIZE) return false; //存储空间已满
if (i != L.length){ //若插入位置不在表尾
for(int j=L.length-1; j>=i; j--) {
L.elems[j+1]=L.elems[j]; //从最后一个元素开始后移,直到第 i 个元素后移
}
}
L.elems[i]=e; //将新元素 e 放入第 i 个位置
L.length++; //表长增 1
return true;
}
解释:
前面说过,我习惯上说第0个位置,与C语言数组相嵌合,就叫采用下标法吧,如果不采用下标法,从1开始数,应该如此编写:
bool ListInsert(SeqList &L, int i, int j){
if(i<1 || i>L.length+1){ //判断i的范围是否有效
return false;
}
if(L.length >= MaxSize){ //判断存储空间是否已经满了
return false;
}
for(int k=L.length; K>=i; K--){ //将第i个元素及其以后的元素后移
L.elems[k] = L.elems[k-1];
}
L.elems[i-1] = e; //在位置i处放入新增元素j
L.length++; //长度加1
}
bool listDelete(SqList &L,int i,int &e) {
if(i<0 || i>=L.length) return false; //不合法
if(L.length == 0) return false; //线性表为空
e = L.elems[i]; //返回被删除元素
if(i == L.length-1) { //删除最后一个元素,直接删除
L.length--;
return true;
}
for (int j=i; j<L.length-1; j++) {
L.elems[j] =L.elems[j+1]; //被删除元素之后的元素前移
}
L.length--;
return true;
}
解释:
bool GetElem(SqList L, int i, int &e) {
if(L.length == 0 || i<0 || i>L.length-1) {
return false;
}
e = L.elems[i];
return true;
}
解释:
获取操作不涉及到对顺序表的改动,不需要传递引用或者指针
i同样采用下标法,这种方法为按位查找,也可以按值查找:
bool GetElem(SqList L, int i, int &e) {
for(int i=0; i<L.length; i++) {
if(L.elems[i] == e) {
//获取查找到的元素,不过好像按值查找还获取值,这里我有点多余了,今天学懵了,不好意思
e = L.elems[i];
return true;
}
return false; //退出循环,说明没有找到元素
}
}
同样返回值应该设置为bool类型
//c语言:
void OutPut(SqList L){
printf("当前顺序表的长度:%d\n", L.length);
for(int i = 0; i < L.length; i++){
printf("%d ",L.elems[i]);
}
printf("\n");
}
//c++语言:
void OutPut(SqList L){
cout << "当前顺序表的长度:" << L.length << endl;
for(int i = 0; i < L.length; i++) {
cout << L.elems[i];
}
cout << endl;
}
解释:
void destroyList(SqList &L) {
if (L.elems) delete []L.elems; //释放存储空间
L.length = 0;
L.size = 0;
}
解释:
返回类型其实也可以设置为bool类型,当顺序表不存在(未初始化时),删除失败返回false
c语言的释放存储空间方式:
if (L.elems) free(L.elems);
链表:用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。数据元素可以存在内存中未被使用的任意位置
链表逻辑上相邻的数据在计算机内的存储位置不必须相邻,那么怎么表示逻辑上的相邻关系呢?我们可以给每个元素附加一个指针域,指向下一个元素的存储位置:
每个元素包含两个域:数据域和指针域,数据域存储本身的信息,指针域中存储的信息称为指针或链,存储其后继元素的地址,因此指针指向的类型也是相同元素类型,这两部分信息组成数据元素ai的存储映像,称为结点,n个结点(ai的存储映像)链结成一个链表,即为线性表(a1, a2, …, an)的链式存储结构,
线性表顺序存储的弱点是在做插入和删除操作时,需要移动大量元素。由于链式存储结构不要求逻辑上相邻的元素在物理位置上也相邻,因此它没有顺序存储结构所具有的弱点,但同时也失去了顺序表可随机存取的优势,
因为链表的每个结点中只包含一个指针域,均单向指向下一个节点,形成一条单向访问的数据链,所以叫做单链表。
链表的两个关键:
几个重要概念:
头指针:指示链表中第一个结点的存储位置
头结点:有时为了方便对链表进行操作,会在单链表的第一个结点前附设一个节点,称为头结点,此时头指针指向的结点就是头结点。头节点一般不存数据,单纯用来牵引整个链条
空链表:头结点的直接后继为空。
假设p是指向线性表第i个数据元素的指针,p->data表示第i个位置的数据域,p->next则表示第i+1个位置的元素,p->next->data则表示第i+1个位置元素的数据域:
typedef int ElemType; //ElemType的类型根据实际情况而定,这里假定为int
typedef struct _LinkNode {
ElemType data; //结点的数据域
struct _LinkNode *next; //结点的指针域
}LinkNode, LinkList; //链表节点、链表
解释:
struct _LinkNode *next
:之前提到指针域中存储的信息称为指针或链,存储其后继元素的地址,因此指针指向的类型也是相同元素类型
typedef
:为了操作方便,起了两个别名LinkNode, LinkList
,LinkList
理解为头节点,有了头节点就拿到了整个链表,所以LinkList
直接表示链表,LinkNode
表示其中一个结点,实际上都是一个结点,结构体定义是一样的。如链表中增加几个元素,例如:长度,那么两者就需要分开定义
//构造结点
typedef struct LinkNode {
ElemType data;
struct LinkNode *next;
}LinkNode;
//构造LinkList
typedef struct {
int lenght;
LinkNode *next;
}LinkList;
分别定义的好处就是更加灵活,比如链表的定义中增加了length表示链表的长度,不过后面的初始化操作等都有细节上的改变,读者自行拓展,本篇还是采用相同结构体的方法
//构造一个空的单链表 L
bool InitList(LinkList* &L) {
L=new LinkNode; //生成新结点作为头结点,用头指针 L 指向头结点
if(!L)return false; //生成结点失败
L->next=NULL; //头结点的指针域置空
return true;
}
解释:
LinkList *L = NULL
但此时仅仅创建了一个指针变量,根据语法,这是一个未指明地址指向的野指针,需对其进行初始化;此处我们选取函数对其进行初始化;LinkList *p = L
代替头指针去遍历,头指针是不能变的,查找这种操作做子函数时,链表不会发生改变,就用头结点的指针做参数就可以了。但在增加,修改,删除这种操作时,链表会发生改变,这就表示头指针所指的这块内存会发生改变,也就是指针的指向可能会发生改变,这种情况下就要用头指针的引用或双指针传递头指针地址。//前插法
bool ListInsert_front(LinkList* &L, LinkNode * node){
if(!L || !node ) return false;
node->next = L->next;
L->next = node;
return true;
}
//尾插法
bool ListInsert_back(LinkList* &L, LinkNode *node){
LinkNode *last = NULL;
if(!L || !node ) return false;
//找到最后一个节点
last = L;
while(last->next) last=last->next;
//新的节点链接到最尾部
node->next = NULL;
last->next = node;
return true;
}
//在带头结点的单链表 L 中第 i 个位置插入值为 e 的新结点
bool LinkInsert(LinkList* &L, int i, int &e) {
int j; //j用来计数跳过的结点数,也可理解为当前p指针的是第几个结点
LinkList *p, *s; //定义两个临时变量
p=L; //临时变量P指向头结点
j=0;
while (p && j<i-1) { //查找第 i-1 个结点,p 指向该结点
p=p->next;
j++;
}
if (!p || j>i-1){ //i>n+1 或者 i<1
return false;
}
s = new LinkNode; //生成新结点
s->data = e; //将新结点的数据域置为 e
s->next = p->next; //将新结点的指针域指向结点 ai
p->next = s; //将结点 p 的指针域指向结点 s
return true;
}
解释:
//在带头结点的单链表 L 中,删除第 i 个位置
bool LinkDelete(LinkList* &L, int i) {
LinkList *p, *q; //p用来代替头指针进行遍历,q等会用来存储被删结点
int j; //j用来计数跳过的结点数,也可理解为当前p指针的是第几个结点
p=L;
j=0;
while((p->next)&&(j<i-1)) { //查找第 i-1 个结点,p 指向该结点
p=p->next;
j++;
}
if (!(p->next)||(j>i-1)) { //当 i>n 或 i<1 时,删除位置不合理
return false;
}
q=p->next; //临时保存被删结点的地址以备释放空间
p->next=q->next; //改变删除结点前驱结点的指针域
delete q; //释放被删除结点的空间
return true;
}
解释:
p && j而删除操作中是(p->next)&&(j,因为插入是能在末尾插入的,即前一个结点p->next为空是可以的,但再往后p为空就不行了,而在删除中是不能删除末尾不存在的元素的,所以查找i-1个结点必须p->next不能为空
//单链表的取值
bool Link_GetElem(LinkList* L, int i, int &e)
{
//在带头结点的单链表 L 中查找第 i 个元素
//用 e 记录 L 中第 i 个数据元素的值
int j;
LinkList* p;
p=L->next;//p 指向第一个结点,
j=1; //j 为计数器
while (j<i && p) //顺链域向后扫描,直到 p 指向第 i 个元素或 p 为空
{
p=p->next; //p 指向下一个结点
j++; //计数器 j 相应加 1
}
if (!p || j>i){
return false; //i 值不合法 i>n 或 i<=0
}
e=p->data; //取第 i 个结点的数据域
return true;
}
//按值查找
bool Link_FindElem(LinkList *L, int e) {
//在带头结点的单链表 L 中查找值为 e 的元素
LinkList *p;
p=L->next;
while (p && p->data!=e) { //顺链域向后扫描,直到 p 为空或 p 所指结点的数据域等于 e
p=p->next; //p 指向下一个结点
}
if(!p) return false; //查找失败 p 为 NULL
return true;
}
解释:
LinkNode *
类型,找到后直接返回即可//单链表的输出
void LinkPrint(LinkList* L) {
LinkNode* p;
p=L->next;
while (p) {
cout <<p->data <<"\t";
p=p->next;
}
cout<<endl;
}
解释:
void LinkDestroy(LinkList* &L) { //单链表的销毁
//定义临时节点 p 指向头节点
LinkList *p = L;
cout<<"销毁链表!"<<endl;
while(p) {
L=L->next; //L 指向下一个节点
cout<<"删除元素: "<<p->data<<endl;
delete p; //删除当前节点
p=L; //p 移向下一个节点
}
}
解释:
这里因为要删除指向的当前节点,在往下遍历的同时,要有一个指针来临时保存被删结点的地址以备释放空间,但其实不需要再多定义一个临时变量,利用好L进行遍历就好,直接移动头指针,同时也符合从头到尾一个一个结点删除后头指针的改变,p就用来临时保存要删除的结点
静态链表:用数组描述的链表,还有起名叫做游标实现法
顺序表插入和删除操作需要移动大量元素;当线性表长度较大时,难以确定存储空间的容量;造成存储空间的“碎片”。链表不要求逻辑上相邻的元素在物理位置上也相邻,因此它没有顺序存储结构所具有的弱点,但同时也失去了顺序表可随机存取的优势,
那么,是否存在一种存储结构,可以融合顺序表和链表各自的优点,从而既能快速访问元素,又能快速增加或删除数据元素,答案就是静态链表:
举例:
图中从 a[1] 存储的数据元素 1 开始,通过存储的游标变量 3,就可以在 a[3] 中找到元素 1 的直接后继元素 2;同样,通过元素 a[3] 存储的游标变量 5,可以在 a[5] 中找到元素 2 的直接后继元素 3,这样的循环过程直到某元素的游标变量为 0 截止(因为 a[0] 默认不存储数据元素)。
区别说明:
在《大话数据结构》中采用最后一个元素为数据链表头结点,数据域不存放数据,游标域存放首结点的数组下标。本篇总结采用a[1]为数据链表的头结点方法,最后一个数组下标和普通数组下标作用一样,所以代码上有一点点的差别,我个人觉得后者比较好,关于另外一种方法的理解和代码可以自己去看看书
备用链表:
举例说明:
图中备用链表依次是:a[0] -> a[2] -> a[4],数据链表依次是:a[1] -> a[3] -> a[5]
例如:假设使用静态链表(数组长度为 7)存储 {1,2,3},
(1)在数据未存储之前,数据链表当前是不存在的,数组中所有位置都处于空闲状态,因此都应被链接在备用链表上
(2)添加元素1
当向静态链表中添加数据时,需提前从备用链表中摘除节点,以供新数据使用。备用链表摘除节点最简单的方法是摘除 a[0] 的直接后继节点;同样,向备用链表中添加空闲节点也是添加作为 a[0] 新的直接后继节点。因为 a[0] 是备用链表的第一个节点,我们知道它的位置,操作它的直接后继节点相对容易,无需遍历备用链表,耗费的时间复杂度为 O(1)。
(3)添加元素2
(4)添加元素3
#define MAXSIZE 1000 //假设链表的最大长度是1000
typedef struct _Component {
int data; //数据域
int cur; //游标(Cursor),为0时表示无指向
}Component,StaticLinkList[MAXSIZE];
解释:
为了方便插入数据,我们通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出。
Component
:每个结构体(元素、组成部分),StaticLinkList[MAXSIZE]
:结构体数组,这样写可以极大的简化代码
其等价于
#define MaxSize 1000
typedef struct _Component {
int data;
int cur;
}Component;
//这里相当于SLinkList可用来定义为一个数组,数组长度为Maxsize,类型为Component
typedef struct _Component SLinkList[MaxSize];
void testSLinkList(){
SLinkList a; //等价于struct _component a[Maxsize]
}
//申请下一个分量的资源,返回下标
int Malloc_SLL(StaticLinkList space){
int i = space[0].cur; //当前数组第一个元素的cur存的值,就是要返回的第一个备用空间的下标
if(space[0].cur){
space[0].cur = space[i].cur; //把下一个分量用来做备用
}
return i;
}
//将下标为k的空闲节点收回到备用链表
void Free_SSL(Component *space, int k){
space[k].cur = space[0].cur; //把第一个元素cur值赋值给要删除的分量cur
space[0].cur = k; //把要删除的分量下标赋值给第一个元素的cur
}
解释:
bool InitList(Component *space){
for(int i=0; i<MAXSIZE; i++){
space[i].cur = i+1;
}
space[MAXSIZE-1].cur = 0; //目前静态链表为空,最后一个元素的cur为0
return OK;
}
//得到静态列表的长度,初始条件:静态列表L已存在。操作结果:返回L中数据元素的个数
int ListLength(StaticLinkList L){
int j = 1; //要算上数据链表表头本身
int i = L[1].cur;
while(i){
i = L[i].cur;
j++;
}
return j;
}
//在静态链表L中第i个元素之前插入新的元素e
bool ListInsert(Component *L, int i, int e){
int insertlc,k;
k = 1; //k表示数据链表头结点的位置
if(i<1 || i>ListLength(L) + 1){
return false;
}
insertlc = Malloc_SLL(L); //申请空间(从备用链表中拿出一个空间),准备插入
if(insertlc){
L[insertlc].data = e; //将数据赋值给此分量的data
for(int t=1; t< i-1; t++){
k = L[k].cur; //找到要插入位置的上一个结点在数组中的位置
}
L[insertlc].cur = L[k].cur; //新插入结点的游标等于其直接前驱结点的游标
L[k].cur = insertlc; //直接前驱结点的游标等于新插入结点所在数组中的下标
return true;
}
return false;
}
解释:
对于位置的说法,同样是下标法,下标法从a[0]开始,而不用下标法的方法从数据链表表头开始数1,所以又一次统一了
此时的数据链表为:甲 -> 乙 -> 丙 -> 戊 -> 丁
//删除在L中第i个数据元素e
bool ListDelete(Component *L, int i){
int j,k;
if(i<1 || i>ListLength(L)+1){
return false;
}
k = 1;
for(j=1; j<i-1; j++){
k = L[k].cur; //找到第i个元素之前的位置
}
j = L[k].cur;
L[k].cur = L[j].cur; //改变前一个元素的cur为删除元素的cur
OUTPUT(L);
Free_SSL(&L, j);
return OK;
}
解释:
此时的数据链表为:甲 -> 乙 -> 戊 -> 丁
void SLLinkPrint(Component *L)
{
int i = 1;
while (i) //通过循环遍历
{
printf("%c ", space[i].data);//输出链表中的数据
i = sapce[i].next;
}
printf("\n");
}
还能延伸出许多其他操作,比如修改某个元素值,查找某个元素值,等等,这里就不多花篇章介绍了,实际上静态链表用的不是特别多
单链表最后一个结点的next指针指向null,表示单向链表结束,循环链表,顾名思义,将单链表首尾相连,最后一个结点的next指针重新指向头结点从而形成一个循环
P—>next
是否为空,而是是否等于头指针空的循环链表:
仅设尾指针的循环链表:
上面我们讨论的仅设置头指针的链表,这样的循环链表有一个弊端,我们可以用O(1)的时间访问第一个节点,但对于最后一个节点,却需要O(n)的时间,于是就有了仅设尾指针的循环链表。
两个循环链表合成一个表:
向上面这样子设置循环链表有一个好处,当我们要将两个循环链表合成一个表时,有了尾指针就非常简单了,例如:我们只需要将A链表的尾指针指向结点的next指针指向B链表的第一个结点,即B -> next -> next,把B链表尾指针指向结点的next指针再指向A的头结点即可,即rearA -> next
//第一步:保存A的头结点
p = rearA->next;
//第二步:将本是指向B表的第一个节点(不是头结点)赋值给rearA->next
rearA->next = rearB->next->next;
//第三步:将原A表的头结点赋值给rearB->next
rearB->next=p;
//释放p
free(p);
前面我们提到的单链表,每个结点除了存储自身数据之后,还存储了下一个结点的地址,因此可以轻松访问下一个结点,以及后面的后继结点,但是如果想访问前面的结点就不行了,再也回不去了。由此,从某结点出发只能顺指针往后查询其他结点,若要寻查结点的直接前驱,则需要从头指针出发。双向链表则克服了单链表这种单向性的缺点:
typedef struct _DoubleLinkNode {
int data; //结点的数据域
struct _DoubleLinkNode *next; //下一个节点的指针域
struct _DoubleLinkNode *prev; //上一个结点的指针域
}DbLinkNode, DbLinkList; //LinkList 为指向结构体 LNode 的指针类型
解释:
//构造一个空的双向链表 L
bool DbInit_List(DbLinkList* &L) {
L=new DbLinkNode; //生成新结点作为头结点,用头指针 L 指向头结点
if(!L)return false; //生成结点失败
L->next=NULL; //头结点的 next 指针域置空
L->prev=NULL; //头结点的指针域置空
L->data = -1;
return true;
}
//前插法
bool DbListInsert_front(DbLinkList* &L, DbLinkNode *node){
if(!L || !node) return false;
//1.只有头节点
if(L->next==NULL){
node->next=NULL;
node->prev=L; //新节点 prev 指针指向头节点
L->next=node; //头节点 next 指针指向新节点
} else {
L->next->prev=node; //第二个节点的 prev 指向新节点
node->next = L->next; //新节点 next 指针指向第二个节点
node->prev=L; //新节点 prev 指针指向头节点
L->next=node; //头节点 next 指针指向新节点,完成插入
}
return true;
}
//尾插法
bool DbListInsert_back(DbLinkList* &L, DbLinkNode *node){
DbLinkNode *last = NULL;
if(!L || !node) return false;
last = L;
while(last->next) last = last->next;
node->next = NULL;
last->next = node;
node->prev = last;
return true;
}
//指定位置插入
bool DbLink_Insert(DbLinkList* &L, int i, int &e){
if(!L||!L->next) return false;
if(i<1) return false;
int j =0;
DbLinkList *p, *s;
p = L;
while(p && j<i){//查找位置为 i 的结点,p 指向该结点
p = p->next;
j++;
}
if(!p || j!=i){
cout<<"不存在节点:"<<i<<endl;
return false;
}
cout<<"p: "<<p<<endl;
s=new DbLinkNode;//生成新节点
s->data = e;
s->next = p;
s->prev = p->prev;
p->prev->next = s;
p->prev = s;
return true;
}
解释:
//任意位置删除
bool DbLink_Delete(DbLinkList* &L, int i) //双向链表的删除
{
DbLinkList *p;
int index = 0;
if(!L || !L->next) {
cout<<"双向链表为空!"<<endl;
return false;
}
if(i<1) return false; //不能删除头节点
p=L;
while(p && index<i){ //找到要删除的节点
p = p->next;
index++;
}
if(!p){ //当节点不存在时,返回失败
return false;
}
p->prev->next=p->next; //改变删除结点前驱结点的 next 指针域
if(p->next){
p->next->prev = p->prev; //改变删除节点后继节点的 prev 指针域
}
delete p; //释放被删除结点的空间
return true;
}
void DbLink_Destroy(DbLinkList* &L) //双向链表的销毁
{
//定义临时节点 p 指向头节点
DbLinkList *p = L;
cout<<"销毁链表!"<<endl;
while(p){
L=L->next;//L 指向下一个节点
cout<<"删除元素: "<<p->data<<endl;
delete p; //删除当前节点
p = L; //p 移向下一个节点
}
}
双向链表查找、获取元素:与单链表的操作差不多,比较简单,这里就不过多讲解了。双向链表的遍历也与单链表差距不大,双向链表可以逆向打印输出元素,还有其它可延伸操作就不一一展示了
在 linux 内核中,有大量的数据结构需要用到双向链表,例如进程、文件、模块、页面等。若采用双向链表的传统实现方式,需要为这些数据结构维护各自的链表,并且为每个链表都要设计插入、删除等操作函数。因为用来维持链表的 next 和 prev 指针指向对应类型的对象,因此一种数据结构的链表操作函数不能用于操作其它数据结构的链表。
这里先简单了解一下即可:我们将链表结点中的数据域分离出来,通过结点来访问上面的数据,从而我们能把数据定义成结构体或其他类型,但同时要用到offsetof来根据链表节点在结构体中的地址来逆推出数据结构体变量的位置(节点结构体在数据结构体里面)
总结参考资料:
程杰:大话数据结构
严蔚敏:数据结构C语言版
数据结构:线性表(List)【详解】
(排版结构等都借鉴了此位前辈的博客,对我的学习总结起到了很大的帮助)