数据结构笔记(王道考研) 第二章:线性表

大部分内容基于中国大学MOOC的2021考研数据结构课程所做的笔记,该课属于付费课程(不过盗版网盘资源也不难找。。。)。后续又根据23年考研的大纲对内容做了一些调整,将二叉排序树和平衡二叉树的内容挪到了查找一章,并增加了并查集、平衡二叉树的删除、红黑树的内容。

排序一章的各种算法动态过程比较难以展现,所以阅读体验可能不是特别好。

西电的校内考试分机试和笔试。笔试占50分,机试2小时4道题占30分,做出2道满分,多做一道总分加5分。机试尽量把老师平时发的OJ题目都过一遍。笔试内容偏基础,但考的量比较大。

 

其他各章节的链接如下:

数据结构笔记(王道考研) 第一章:绪论

数据结构笔记(王道考研) 第二章:线性表

数据结构笔记(王道考研) 第三章:栈和队列

数据结构笔记(王道考研) 第四章:串

数据结构笔记(王道考研) 第五章:树和二叉树

数据结构笔记(王道考研) 第六章:图

数据结构笔记(王道考研) 第七章:查找

数据结构笔记(王道考研) 第八章:排序

其他各科笔记汇总

线性表

在这里插入图片描述

线性表的定义和基本操作

在这里插入图片描述

我们在学习一个具体的数据结构的时候,需要关注数据结构的三个方面:逻辑结构,存储结构(物理结构)和数据的运算。这一小节就是要探讨这种数据的逻辑结构和对这种数据结构需要实现哪些运算。后面会探讨使用不同的存储结构实现线性表,存储结构不同,运算的实现方式不同

线性表的定义

在这里插入图片描述

线性表的基本操作

操作描述
InitList(&L)初始化表。构造一个空的线性表L,分配内存空间
DestroyList(&L)销毁操作。销毁线性表,并释放线性表L所占用的内存空间
ListInsert(&L,i,e)插入操作。在表L中的第i个位置上插入指定元素e
ListDelete(&L,i,&e)删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值
LocateElem(L,e)按值查找操作。在表L中查找具有给定关键字值的元素
GetElem(L,i)按位查找操作。获取表L中第i个位置的元素的值
Length(L)求表长。返回线性表L的长度,即L中数据元素的值
PrintList(L)输出操作。按前后顺序输出线性表L的所有元素值
Empty(L)判空操作。若L为空表,则返回true,否则返回false

对数据的基本操作基本可以概括为创销,增删改查("改"之前也要"查"到才能改)。当然在实际开发中可根据实际需求定义其他的基本操作

"&“是C++的用法,表示引用。是否要传入引用”&“取决于对参数的修改结果是否需要"带回来”

为什么要实现对数据结构的基本操作?

1.团队合作编程时,你定义的数据结构要让别人能够很方便的使用(封装)

2.将常用的操作/运算封装成函数,避免重复工作,降低出错风险

顺序表的定义

在这里插入图片描述

顺序表的定义

在这里插入图片描述

顺序表用存储位置的相邻来体现数据元素之间的逻辑关系

顺序表的实现——静态分配

#define MaxSize 10   //定义最大长度
typedef struct{      
    ElemType data[MaxSize];  //用静态的"数组"存放数据元素
    int length;              //顺序表的当前长度
}SqList;                     //顺序表的类型定义(静态分配方式)

//基本操作——初始化一个顺序表
void InitList(SqList &L){
    L.length=0;    //顺序表初始长度为0
}

要函数内声明一个顺序表和初始化一个顺序表,只需

SqList L;       //声明一个顺序表
InitList(L);    //初始化一个顺序表

声明一个顺序表时在内存中分配存储顺序表L的连续空间。包括:MaxSize × \times ×sizeof(ElemType)和存储Length的空间

初始化顺序表时可以不用把各个数据元素的值设为默认值,直接将Length的值设为0即可。尽管内存中会有遗留的"脏数据",但是因为访问超过当前长度的值是违法的,所以并不会有什么影响

尽量使用基本操作来访问顺序表中的各个数据元素

顺序表的实现——动态分配

因为静态分配实现的顺序表的表长开始确定后就无法更改(存储空间是静态的),刚开始就声明一个很大的内存空间又很浪费。所以可以用动态分配的方式实现顺序表大小可变

#define InitSize 10           //顺序表的初始长度 
typedef struct{      
    ElemType *data;           //指示动态分配数组的指针
    int MaxSize;              //顺序表的最大容量
    int length;               //顺序表的当前长度
}SeqList;                     //顺序表的类型定义(动态分配方式)

C提供了malloc和free函数来实现动态申请和释放内存空间。使用时加头文件#include<stdlib.h>。 malloc函数会申请一整片连续的存储空间,返回指向这片存储空间开始地址的指针。需要强制转型来决定这片存储空间用来存储什么类型的数据元素。如L.data=(ElemType *)malloc(sizeof(ElemType) * InitSize)就强制转型为了你定义的数据元素类型指针,并让data指针变量指向这一整片存储空间的起始地址也就是顺序表的第一个数据元素。

C++可以用new和delete关键字实现类似与malloc和free的功能,但要涉及到面向对象的一些相关知识

下面以int类型为例,演示如何实现初始化顺序表和增加动态数组的长度。如果看不懂过程中指针指向的变化,可以画一下图

#include<stdlib.h>

#define InitSize 10           //默认的最大长度 
typedef struct{      
    int* data;                //指示动态分配数组的指针
    int MaxSize;              //顺序表的最大容量
    int length;               //顺序表的当前长度
}SeqList;  

void InitList(SeqList &L){
    //用malloc函数申请一片连续的存储空间
    L.data=(int*)malloc(InitSize*sizeof(int));
    L.length=0;
    L.MaxSize=InitSize;
}

//增加动态数组的长度
void IncreaseSize(SeqList &L,int len){
    int *p=L.data;
    L.data=(int*)malloc((L.MaxSize+len))*sizeof(int));
    for(int i=0;i<L.length;i++){             
        L.data[i]=p[i];                  //将数据复制到新区域
    }
    L.MaxSize=L.MaxSize+len;             //顺序表最大长度增加len
    free(p);                             //释放原来的内存空间
}

int main(){
    SeqList L;   //声明一个顺序表
    InitList(L);  //初始化顺序表
    //...往顺序表中随便插入几个元素...
    IncreaseSize(L,5);
    return 0;
}

可以看到这种增加长度的方法时间开销很大

顺序表的特点

在这里插入图片描述

顺序表的插入与删除

在这里插入图片描述

上一节课学习了如何定义一个顺序表并完成初始化的工作,本节课学习顺序表的插入与删除,代码建立在顺序表的“静态分配”实现方式之上,“动态分配”也雷同

顺序表的基本操作——插入

ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e

要把第i个位置开始的n-i+1个元素全部后移一位,再在第i个位置上填入要插入的元素e

#define MaxSize 10 //定义最大长度
typedef struct{
    int data[MaxSize];  //用静态的"数组"存放数组元素
    int length;         //顺序表的当前长度
}SqList;                //顺序表的类型定义

bool ListInsert(SqList &L,int i,int e){
    if(i<1||i>L.length+1)         //判断i的范围是否有效
        return false;
    if(L.length>=MaxSize)         //当前存储空间已满,不能插入
        return false;
    for(int j=L.length;j>=i;j--)  //将第i个元素及之后的元素后移
        L.data[j]=L.data[j-1];    //注意位序,数组下标的关系,并从后面的元素依次移动
    L.data[i-1]=e;                //在位置i处放入e
    L.length++;                   //长度加1
    return true;
}

好的算法,应该具有“健壮性”。能处理异常情况,并给使用者反馈

分析时间复杂度,关注最深处循环语句的执行次数与问题规模n的关系。这里的问题规模n=L.length(表长)

最好情况:新元素插入到表尾,不需要移动元素
i=n+1,循环0次;最好时间复杂度= O ( 1 ) O(1) O(1)
最坏情况:新元素插入到表头,需要将原有的n个元素全部向后移动
i=1,循环n次;最坏时间复杂度= O ( n ) O(n) O(n)
平均情况:假设新元素插入到任何一个位置的概率相同,即i=1,2,3,…,length+1的概率都是 p = 1 n + 1 p=\frac{1}{n+1} p=n+11
i=1,循环n次;i=2时,循环n-1次 … i=n+1时,循环0次。平均循环次数=np+(n-1)p+(n-2)p+…+1 ⋅ \cdot p= n ( n + 1 ) 2 1 n + 1 = n 2 \frac{n(n+1)}{2}\frac{1}{n+1}=\frac{n}{2} 2n(n+1)n+11=2n,故平均时间复杂度= O ( n ) O(n) O(n)

顺序表的基本操作——删除

ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值

bool ListDelete(SqList &L,int i,int &e){
    if(i<1||i>L.length)                //判断i的范围是否有效
        return false;
    e=L.data[i-1];                     //将被删除的元素赋值给e
    for(int j=i;j<L.length;j++)        //将第i个位置后的元素前移
        L.data[j-1]=L.data[j];         //注意位序,数组下标的关系,并从后面的元素依次移动
    L.length--;                        //线性表长度减1
    return true;
}

删除时是先移动前面的元素再移动后面的元素,插入操作时则是先移动后面的元素再移动前面的元素

仿造对插入操作的时间复杂度分析可得删除操作的时间复杂度:

最好情况:删除表尾元素,不需要移动其他元素
i=n,循环0次;最好时间复杂度= O ( 1 ) O(1) O(1)
最坏情况:删除表头元素,需要将后续的n-1个元素全都向前移动
i=1,循环n-1次;最坏时间复杂度= O ( n ) O(n) O(n)
平均情况:假设删除任何一个元素的概率相同,即i=1,2,3,…,length的概率都是 p = 1 n p=\frac1n p=n1
i=1,循环n-1次;i=2时,循环n-2次;i=3,循环n-3次 … i=n时,循环0次。平均循环次数=(n-1)p+(n-2)p+ … +1 ⋅ \cdot p= n ( n − 1 ) 2 1 n = n − 1 2 \frac{n(n-1)}{2}\frac1n=\frac{n-1}{2} 2n(n1)n1=2n1,故平均时间复杂度为 O ( n ) O(n) O(n)

顺序表的查找

在这里插入图片描述

顺序表的按位查找

GetElem(L,i) 按位查找操作。获取表L中第i个位置的元素的值

不管是采用静态分配的还是动态分配,都可以用如下方式实现按位查找

ElemType GetElem(SqList L,int i){
    return L.data[i-1];
}

如果想让你的代码健壮性更强一点的话,也可以加上对i的值判断是否合法的操作

用某一个类型的指针加上数组下标的方式来访问数据的方法的原理自己理解,这里不记

只有一个return语句,没有递归调用也没有循环,时间复杂度为 O ( 1 ) O(1) O(1)

由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素。这就是顺序表的“随机存取”特性

顺序表的按值查找

LocateElem(L,e) 按值查找操作。在表L中查找具有给定关键字值的元素

同样不管是采用静态分配的还是动态分配,都可以用如下方式实现按值查找

//在顺序表L中查找第一个元素值等于e的元素,并返回其位序
int LocateElem(SeqList L,int e){
    for(int i=0;i<L.length;i++)
        if(L.data[i]==e)
            return i+1;
    return 0;
}

这里是假定顺序表存储的数据类型为int,如果类型是结构体,判断相等不能直接用“==”。判断相等时如果是C语言要自己写,C++还可以用运算符重载

现在分析时间复杂度。最好情况是目标元素在表头,最好时间复杂度为 O ( 1 ) O(1) O(1)。最坏情况是目标元素在表尾,最坏时间复杂度为 O ( n ) O(n) O(n)。平均时间复杂度为 O ( n ) O(n) O(n)

单链表的定义

在这里插入图片描述

这一节如果对代码的逻辑不清楚可以采用画图表示指针指向的方式来清晰思路

什么是单链表

在这里插入图片描述

采用这种存储方式要找到某一个位序的结点,只能从第一个结点开始利用指针的信息依次往后寻找直到找到我们想要的那个结点,因此单链表这种实现方式不支持随机存取

用代码定义一个单链表

typedef struct LNode{                  //定义单链表结点类型
    ElemType data;                     //每个节点存放一个数据元素
    struct LNode *next;                //指针指向下一个节点
}LNode,*LinkList;  
//强调这是一个单链表使用LinkList,强调这是一个结点用LNode*,实际上LinkList和LNode*是等价的

这段代码等价于

typedef struct LNode{                  //定义单链表结点类型
    ElemType data;                     //每个节点存放一个数据元素
    struct LNode *next;                //指针指向下一个节点
};
typedef struct LNode LNode;
typedef struct LNode *LinkList;

要表示一个单链表时,只需声明一个头指针L,指向单链表的第一个结点

LinkList L; //声明一个指向单链表第一个结点的指针

不带头结点的单链表

typedef struct LNode{                  //定义单链表结点类型
    ElemType data;                     //每个节点存放一个数据元素
    struct LNode *next;                //指针指向下一个节点
}LNode,*LinkList;

//初始化一个空的单链表
bool InitList(LinkList &L){
    L=NULL; //空表,暂时还没有结点,防止脏数据
    return true;
}

void test(){
    LinkList L;  //声明一个指向单链表的指针,注意此处并没有创建一个结点
    //初始化一个空表
    InitList(L);
    //......后续代码......
}
判空操作
//判断单链表是否为空
bool Empty(LinkList L){
    if(L==NULL)
        return true;
    else
        return false;
}

或者

bool Empty(LinkList L){
    return(L==NULL);
}

带头结点的单链表

typedef struct LNode{                  //定义单链表结点类型
    ElemType data;                     //每个节点存放一个数据元素
    struct LNode *next;                //指针指向下一个节点
}LNode,*LinkList;

//初始化一个空的单链表
bool InitList(LinkList &L){
    L=(LNode *)malloc(sizeof(LNode)); //分配一个头结点
    if(L==NULL)                       //内存不足,分配失败
        return false;
    L->next=NULL;                     //头结点不存储数据,头结点之后暂时没有结点
    return true;
}

void test(){
    LinkList L;  //声明一个指向单链表的指针
    //初始化一个空表
    InitList(L);
    //......后续代码......
}

不带头结点与带头结点

在这里插入图片描述

单链表的插入与删除

在这里插入图片描述

按位序插入(带头结点)

ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e

这里的i指的是位序,不包括头结点,从1开始计数

思路是找到第i-1个结点,将新结点插入其后,这里就体现了带头结点的好处,可以把头结点视为第0个结点进行处理

插入的位置越小时间复杂度越小,比如在第1个位置上插入新结点为最好时间复杂度,为 O ( 1 ) O(1) O(1)。最坏时间度为在表尾插入新的结点,时间复杂度为 O ( n ) O(n) O(n)。平均时间复杂度为 O ( n ) O(n) O(n)

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
    if(i<1)
        return false;
    
    LNode* p; //指针p指向当前扫描到的结点
    int j=0;  //当前p指向的是第几个结点
    p=L;      //L指向头结点,头结点是第0个结点(不存数据)
    
    while(p!=NULL && j<i-1){   //循环 找到第i-1个结点
        p=p->next;
        j++;
    }
    
    if(p==NULL)           //i值不合法
        return false;
    
    LNode* s=(LNode*)malloc(sizeof(LNode));
    s->data=e;
    s->next=p->next;
    p->next=s;   //将结点s连到p之后
    
    return true; //插入成功
}

按位序插入(不带头结点)

不存在第0个结点,所以i=1要特殊处理。因为如果插入,删除第1个元素时,需要更改头指针L。后续逻辑和带头结点的一样

不带头结点写代码更不方便,推荐用带头结点。除非特别声明,之后的代码默认带头结点

bool ListInsert(LinkList &L,int i,ElemType e){
    if(i<1)
        return false;
    
    if(i==1){    //插入第1个结点的操作与其他结点操作不同
        LNode* s=(LNode*)malloc(sizeof(LNode));
        s->data=e;
        s->next=L;
        L=s;          //头指针指向新结点
        return true;
    }
    
    LNode* p; //指针p指向当前扫描到的结点
    int j=1;  //当前p指向的是第几个结点,注意从1开始
    p=L;      //p指向第1个结点(注意:不是头结点)
    
    while(p!=NULL && j<i-1){   //循环 找到第i-1个结点
        p=p->next;
        j++;
    }
    
    if(p==NULL)           //i值不合法
        return false;
    
    LNode* s=(LNode*)malloc(sizeof(LNode));
    s->data=e;
    s->next=p->next;
    p->next=s;   //将结点s连到p之后
    
    return true; //插入成功
}

指定结点的后插操作

由于单链表的链接指针只能往后寻找,所以如果给定一个结点p的话,p之后的结点都是可知的,可以用循环的方式把它们都找出来,但是p结点之前的那些结点就没法知道了

时间复杂度为 O ( 1 ) O(1) O(1)

//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p,ElemType e){
    if(p==NULL)
        return false;
    
    LNode* s=(LNode *)malloc(sizeof(LNode));
    if(s==NULL)           //某些情况下有可能分配失败(如内存不足),自己写代码可以不写
        return false;
    
    s->data=e;                    //用结点s保存数据元素e
    s->next=p->next;
    p->next=s;                    //将结点s连到p之后
    return true;
}

利用封装的思想,之前的按位序插入操作可以化简为

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
    if(i<1)
        return false;
    
    LNode* p; //指针p指向当前扫描到的结点
    int j=0;  //当前p指向的是第几个结点
    p=L;      //L指向头结点,头结点是第0个结点(不存数据)
    
    while(p!=NULL && j<i-1){   //循环 找到第i-1个结点
        p=p->next;
        j++;
    }
    
   return InsertNextNode(p,e);
}

指定结点的前插操作

时间复杂度为 O ( 1 ) O(1) O(1)

注意这里运用了交换数据的思想

//前插操作:在p结点之前插入元素e
bool InsertPriorNode(LNode *p,ElemType e){
    if(p==NULL)
        return false;
    LNode* s=(LNode *)malloc(sizeof(LNode););
    if(s==NULL)
        return false;  //内存分配失败
    s->next=p->next;   
    p->next=s;         //新结点s连到p之后
    s->data=p->data;   //将p中元素复制到s中
    p->data=e;         //p中元素覆盖为e
    return true;
}

书上则是直接传入了待插入的结点

在这里插入图片描述

按位序删除(带头结点)

ListDelete(&L,i,&e):删除操作。删除表中第i个位置的元素,并用e返回删除的元素

思路是找到第i-1个结点,将其指针指向第i+1个结点并释放第i个结点

头结点可以看作第0个结点

由于要循环遍历,因此最坏时间复杂度和平均时间复杂度都是 O ( n ) O(n) O(n),最好时间复杂度为 O ( 1 ) O(1) O(1)

bool ListDelete(LinkList &L,int i,ElemType &e){
    if(i<1)
        return false;
    
    LNode* p; //指针p指向当前扫描到的结点
    int j=0;  //当前p指向的是第几个结点
    p=L;      //L指向头结点,头结点是第0个结点(不存数据)
    
    while(p!=NULL && j<i-1){   //循环 找到第i-1个结点
        p=p->next;
        j++;
    }
    
    if(p==NULL)           //i值不合法
        return false;
    
    if(p->next==NULL)     //第i-1个结点之后已无其他结点
        return false;
    LNode *q=p->next;     //令q指向被删除结点
    e=q->data;            //用e返回元素的值
    p->next=q->next;      //将*q结点从链中“断开”
    free(q);              //释放结点的存储空间
    return true;          //删除成功
}

指定结点的删除

删除结点p,需要修改其前驱结点的next指针

方法1:传入头指针,循环寻找p的前驱结点

方法2:偷天换日(类似于结点前插的实现)

如果采用方法二,时间复杂度为 O ( 1 ) O(1) O(1)

//删除指定结点p
bool DeleteNode(LNode *p){
    if(p==NULL){
        return false;
    }
    LNode *q=p->next;          //令q指向*p的后继结点
    p->data=p->next->data;     //和后继结点交换数据域
    p->next=q->next;           //将*q结点从链中"断开"
    free(q);                   //释放后继结点的存储空间
    return true;
}

方法二在删除最后一个结点的时候会出问题,p->next->data会因为p->next为NULL而出错,因此如果p结点为最后一个结点只能从表头开始依次寻找p的前驱,时间复杂度为 O ( n ) O(n) O(n)

所以单链表的局限性是无法逆向检索,有时候不太方便

单链表的查找

在这里插入图片描述

按位查找

GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值

其实在上一节的按位插入和按位删除两个基本操作里面已经实现了按位查找的相关代码逻辑,只不过之前找的是第i-1个结点,改成找第i个结点即可

//按位查找,返回第i个元素(带头结点)
LNode* GetElem(LinkList L,int i){
    if(i<0)
        return NULL;
    LNode* p;   //指针p指向当前扫描到的结点
    int j=0;    //当前p指向的是第几个结点
    p=L;        //L指向头结点,头结点是第0个结点(不存数据)
    while(p!=NULL&&j<i){   //循环找到第i个结点
        p=p->next;
        j++;
    }
    return p;
}

i=0时返回头结点,i<0或者大于链表的实际长度时则返回NULL。通过返回值是否为NULL判断按位操作是否执行成功

平均时间复杂度为$ O(n)$

书上的代码稍微有些不一样,大同小异

LNode* GetElem(LinkList L,int i){
    int j=1;
    LNode* p=L->next;
    if(i==0)
        return L;
    if(i<1)
        return NULL;
    while(p!=NULL&&j<i){
        p=p->next;
        j++;
    }
    return p;
}

上一节的按位插入和按位删除都要找到第i-1个结点,这可以直接调用按位查找来实现,如按位插入可以化简为

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
 if(i<1)
     return false;
 LNode* p=GetElem(L,i-1);
 return InsertNextNode(p,e);
}

对按位删除的化简同理。这里就体现了封装(基本操作)的好处:避免重复代码,简洁,易维护

注意在上一小节中,我们在写InsertNextNode函数实现后插操作的时候对传入的指针p进行了判断,如果p指针为NULL的话就直接返回false。但是为什么会有人在调用函数的时候给你一个空指针呢?

这段代码看上去没有必要,但是在上面调用InsertNextNode函数实现按位插入时就很显得重要了。因为如果传入的i值不合法的话,GetElem函数会返回NULL,所以p指针是有可能指向NULL的。当p=NULL的时候说明第i个结点是不存在的,在这种情况下InsertNexrNode就直接返回false表示后插操作失败,按位插入也就失败了

按值查找

LocateElem(L,e):按值查找操作,在表L中查找具有给定关键字值的元素

//按位查找,找到数据域==e的结点
LNode* LocateElem(LinkList L,ElemType e){
    LNode* p=L->next;
    //从第1个结点开始查找数据域为e的结点
    while(p!=NULL&&p->data!=e)
        p=p->next;
    return p;  //找到后返回该结点指针,否则返回NULL
}

这里假设了ElemType的类型为int,如果是更复杂的结构类型,判断结点数据与待查找值是否相等就不能直接!=判断了

显然平均时间复杂度为 O ( n ) O(n) O(n)

求表的长度

平均时间复杂度为 O ( n ) O(n) O(n)

//求表的长度
int Length(LinkList L){
    int len=0; //统计表长
    LNode *p=L;
    while(p->next!=NULL){
        p=p->next;
        len++;
    }
    return len;
}

单链表的创建操作

在这里插入图片描述

怎么初始化一个带头结点单链表在之前单链表的定义一节已经提过

尾插法建立单链表

最开始的想法或许是借助按位序插入实现

初始化单链表

设置变量length记录链表长度

while 循环{
 每次取一个数据元素e;
 ListInsert(L,length+1,e)插到尾部;
 length++;
}

但是这种方法的缺点是每次在表尾插入元素的时候都要从头开始遍历直到找到最后一个结点,时间复杂度会达到很大的 O ( n 2 ) O(n^2) O(n2)

因此我们根本不需要每次都从头往后寻找,可以设置一个表尾指针r指向表尾最后的一个数据结点,当我们要在尾部插入一个新的数据元素的时候只需要直接对r结点做后插操作,后插操作可以借助InsertNextNode函数实现。完成后插操作后把表尾指针往后移指向新的表尾元素

尾插法建立单链表基本上是结合了初始化链表和后插操作。先初始化空表,再在r结点之后插入元素x。然后永远保持r指向最后一个结点

LinkList List_TailInsert(LinKList &L){  //正向建立单链表
    int x;                              //设ElemType为整型
    L=(LinkList)malloc(sizeof(LNode));  //建立头结点,初始化恐表
    LNode *s,*r=L;                      //r为表尾指针
    scanf("%d",&x);                     //输入结点的值
    while(x!=9999){                     //输入9999表示结束
        s=(LNode*)malloc(sizeof(LNode));
        s->data=x;
        r->next=s;
        r=s;                            //r指向新的表尾结点
        scanf("%d",&x);
    }
    r->next=NULL;                       //尾结点指针置空
    return L;
}

时间复杂度为 O ( n ) O(n) O(n)

头插法建立单链表

每次都插入新元素于表头,即头结点之后

其实也是链表初始化结合对指定结点的后插操作,插入新元素就是对头结点执行后插操作。所以头插法建立单链表的思想就是

头插法建立单链表

初始化单链表

while 循环{
 每次取一个数据元素e;
 InsertNextNode(L,e);
}

具体代码实现如下

LinkList List_HeadInsert(LinkList &L){ //逆向建立单链表
    LNode *s;
    int x
    L=(LinkList)malloc(sizeof(LNode)); //创建头结点
    L->next=NULL;                      //初始化为空链表
    scanf("%d",&x);                    //输入结点的值
    while(x!=9999){                    //输入9999表示结束
        s=(LNode*)malloc(sizeof(LNode));  //创建新结点
        s->data=x;
        s->next=L->next;
        L->next=s;                        //将新结点插入表中,L为头指针
        scanf("%d",&x);
    }
    return L;
}

注意与尾插法不同,L->next=NULL这句不能少,如果怕错可以养成习惯只要是初始化单链表,都先把头指针指向NULL

显然头插法的元素先后顺序与输入顺序刚好相反,头插法的这种性质是十分重要的,链表的逆置就可以用到与头插法类似的思想。现在如果给你一个单链表L,让你把单链表逆置,核心的代码逻辑是不变的,只不过你取数据元素的时候并不是通过scanf取得的,而是可以用一个指针循环着扫描,按顺序依次从L当中取得数据元素,当取出一个数据元素的时候又用头插法把它插入到另一个新的链表当中,这样建立的新链表就相当于把老链表逆置了

当然也可以每次从链表中取下一个结点,把取下的结点又重新插回到头结点之后,这样就不需要建立新的链表,而是把L原地逆置

双链表

在这里插入图片描述

在单链表中每个结点只包含指向它后继结点的指针,所以给定一个结点p的话,我们要找到它的前驱结点是很麻烦的,即无法逆向检索。双链表在单链表的基础上再增加一个指针域指向结点的前驱结点,可进可退,但是存储密度低一些

typedef struct DNode{                //定义双链表结点类型
    ElemType data;                   //数据域
    struct DNode *prior,*next;       //前驱和后继指针
}DNode,*DLinklist;

在这里插入图片描述

双链表的初始化(带头结点)

//初始化双链表
bool InitDLinkList(DLinklist &L){
    L=(DNode*)malloc(sizeof(DNode));      //分配一个头结点
    if(L==NULL)                           //内存不足,分配失败
        return false;
    L->prior=NULL;                        //头结点的prior永远指向NULL
    L->next=NULL;                         //头结点之后暂时没有结点
    return true;
}

//判断双链表是否为空(带头结点)
bool Empty(DLinklist L){
    if(L->next==NULL)
        return true;
    else
        return false;
}

双链表的插入

//在p结点之后插入s结点
bool InsertNextDNode(DNode *p,DNode *s){
    if(p==NULL||s==NULL)     //非法参数
        return false;
    s->next=p->next;
    if(p->next!=NULL)        //如果p结点有后继结点
        p->next->prior=s;
    s->prior=p;
    p->next=s;
}

修改指针时一定要注意顺序

注意p结点为最后一个结点的情况

这里介绍的是后插操作,只要搞定了后插操作,按位序插入和前插操作都很容易实现。比如要实现按位序插入,只要从头结点开始找到某一个位序的前驱结点,然后对这个前驱结点执行后插操作就行了。如果我们想在一个结点的前面进行前插操作,由于双链表的特性可以很方便的找到这个结点的前驱结点,然后对该前驱结点进行后插操作,这样就实现了前插操作

也就是其他的插入操作都可以转换为后插操作来实现

双链表的删除

//删除p结点的后继结点
bool DeleteNextNode(DNode *p){
    if(p==NuLL)    return false;
    DNode *q=p->next;    //找到p的后继结点q
    if(q==NULL)    return false;  //p没有后继
    p->next=q->next;
    if(q->next!=NULL)    //q结点不是最后一个结点
        q->next->prior=p;
    free(q);             //释放结点恐慌
    return true;
}

同样要注意没有后继结点和后继结点是最后一个结点的情况,书上的代码是有一点bug的

//销毁双链表
void DestroyList(DLinklist &L){
    //循环释放各个数据结点
    while(L->next!=NULL)
        DeleteNextDNode(L);
    free(L);   //释放头结点
    L=NULL;    //头指针指向NULL
}

双链表的遍历

后向遍历
while(p!=NULL){
    //对结点p做相应处理,如打印
    p=p->next;
}
前向遍历
while(p!=NULL){
    //对结点p做相应处理
    p=p->prior;
}

//如果只想处理数据结点而不处理头结点,可以跳过头结点
while(p->prior!=NULL){
    //对结点p做相应处理
    p=p->prior;
}

双链表不可随机存取,按位查找和按值查找操作都只能用遍历的方式实现。时间复杂度为 O ( n ) O(n) O(n)。如果你要实现按位查找,只需要在循环里加一个计数变量记录此时指向的是哪一个位序的元素就可以了。要实现按值查找操作则只需要在这个循环里对当前指向的结点进行一个值的对比就可以了

循环链表

在这里插入图片描述

循环单链表

在这里插入图片描述

typedef struct LNode{                  //定义单链表结点类型
    ElemType data;                     //每个节点存放一个数据元素
    struct LNode *next;                //指针指向下一个节点
}LNode,*LinkList;  

//初始化一个循环单链表
bool InitList(LinkList &L){
    L=(LNode*)malloc(sizeof(LNode));  //分配一个头结点
    if(L==NULL)            //内存不足,分配失败
        return false;
    L->next=L;              //头结点next指向头结点
    return true;
}

//判断循环单链表是否为空
bool Empty(LinkList L){
    if(L->next==L)
        return true;
    else
        return false;
}

//判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L,LNode *p){
    if(p->next==L)
        return true;
    else
        return false;
}

对于普通的单链表给你一个结点p,只能知道这个结点p的后续结点,对于其前面的结点是不可知的,除非你也能获得头结点的指针,但是对于循环单链表来说只要给你一个结点p,就能顺着链遍历找到循环单链表中的任意一个结点,这种特性是有一些作用的。

比如如果现在让你实现一个功能:删除结点p。则删除这个结点之后肯定要修改前驱结点的next指针,但是对于普通的单链表,只知道结点p的指针是肯定找不到它的前驱结点的,而循环单链表就可以做到

很多时候对链表的操作都是在头部或尾部,比如用头插法建立链表和用尾插法建立链表。如果是普通单链表,尾结点指针指向NULL,L指向头结点,现在给定头结点,要找到表尾结点只能写一个循环依次往后扫描直到找到最后一个结点,时间复杂度为 O ( n ) O(n) O(n)。而对于循环单链表来说,如果我们让这个循环单链表的指针L不是指向头结点而是指向尾结点,从尾部找到头部时间复杂度仅为 O ( 1 ) O(1) O(1)。同时由于L这个指针是指向尾部的,所以当我们对链表的尾部进行操作的时候,也可以在 O ( 1 ) O(1) O(1)的时间复杂度就找到我们要操作的位置,而不需要从头往后依次遍历

所以如果经常要对表头或者表尾进行操作,使用循环单链表的时候可以让L指向表尾元素(在表尾插入,删除时可能需要修改L的指向)

循环双链表

在这里插入图片描述

typedef struct DNode{              
    ElemType data;                   
    struct DNode *prior,*next;       
}DNode,*DLinklist;

//初始化空的循环双链表
bool InitDLinkList(DLinklist &L){
    L=(DNode*)malloc(sizeof(DNode));  //分配一个头结点
    if(L==NULL)            //内存不足,分配失败
        return false;
    L->prior=L;            //头结点的prior指向头结点
    L->next=L;             //头结点的next指向头结点
    return true;
}

//判断循环双链表是否为空
bool Empty(DLinklist L){
    if(L->next==L)
        return true;
    else
        return false;
}

//判断结点p是否为循环双链表的表尾结点
bool isTail(DLinklist L,DNode *p){
    if(p->next==L)
        return true;
    else
        return false;
}
循环双链表的插入

普通的双链表在后插插入结点时要注意插入在尾部时的特殊情况。而循环双链表则完全不需要考虑,直接如下处理即可

//在p结点之后插入s结点
bool InsertNextNode(DNode *p,DNode *s){
    s->next=p->next;    //将结点s插入到结点p之后
    p->next->prior=s;   //这段代码相对普通双链表有可能出错
    s->prior=p;
    p->next=s;
}
循环双链表的删除

同理,普通的双链表在删除最后一个结点时要特别考虑。而循环双链表则完全不需要考虑,直接如下处理即可

//删除p的后续结点q
p->next=q->next;
q->next->prior=p;  //这段代码相对普通双链表有可能出错
free(q);

静态链表

在这里插入图片描述

虽然静态链表的存储空间是一整片连续存储空间,但是在这一片空间内各个逻辑上相邻的数据元素也可以在物理上不相邻,各个元素之间的逻辑关系通过游标来表示

什么是静态链表。

在这里插入图片描述

用代码定义一个静态链表

#define MaxSize 10  //静态链表的最大长度
struct Node{        //静态链表结构类型的定义
    ElemType data;  //存储数据元素
    int next;       //下一个元素的数组下标
};

void testSLinkList(){
    struct Node a[MaxSize];   //数组a作为静态链表
    //......后续代码
}

书上的写法比较奇怪

#define MaxSize 10  //静态链表的最大长度
typedef struct{     //静态链表结构类型的定义
    ElemType data;  //存储数据元素
    int next;       //下一个元素的数组下标
}SLinkList[MaxSize];

这种写法等价于

#define MaxSize 10  //静态链表的最大长度
struct Node{     //静态链表结构类型的定义
    ElemType data;  //存储数据元素
    int next;       //下一个元素的数组下标
};
typedef struct Node SLinkList[MaxSize];

这样定义后,可以用SLinkList定义"一个长度为MaxSize的struct Node型数组"。以后要声明一个静态链表可以这样写

void testSLinkList(){
    SLinkList a;
    //......后续代码
}

这样写等价于

void testSLinkList(){
    struct Node a[MaxSize];
    //......后续代码
}

之所以要这么写是因为可以比较直观的看出来a是一个静态链表,而用后面一种会有可能让人看上去认为a是一个Node型数组

简述基本操作的实现

初始化静态链表

把a[0]的next设为-1(类比单链表初始化时让头结点指向NULL),其余所有结点next全部置为某个特殊值如-2来表示结点为空,以避免脏数据的干扰

查找

查找某一位序的结点,从头结点出发通过游标记录的线索依次往后遍历结点,显然时间复杂度为 O ( n ) O(n) O(n)

注意这里说的是某一个位序的结点,而不是某一个数组下标的结点。位序指的是各个结点在逻辑上的顺序,数组下标则只是反映了各个结点在物理上的顺序

插入位序为i的结点

1.找到一个空的结点,存入数据元素

2.从头结点出发找到位序为i-1的结点

3.修改新结点的next

4.修改i-1号结点的next

删除某个结点

1.从头结点出发找到前驱结点

2.修改前驱结点的游标

3.被删除结点next设为-2

顺序表和链表的比较

下面分别从逻辑结构,存储结构,基本操作的角度对顺序表和链表进行比较

逻辑结构

都属于线性表,都是线性结构

存储结构

在这里插入图片描述

基本操作

对于任何一个数据结构,基本操作基本都能归纳为创销,增删改查。其中改建立在查的基础上

创建

在这里插入图片描述

销毁

在这里插入图片描述

增加与删除

在这里插入图片描述

查找

在这里插入图片描述

用链表还是顺序表

顺序表链表
弹性(可扩容)×
增,删×
×

表长难以预估,经常需要增加/删除元素——链表

表长可预估,查询(搜索)操作较多——顺序表

  • 7
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值