线性表的定义和基本操作
在上一节中提到过,我们研究一种数据结构,应该关注数据结构的三要素,即逻辑结构,物理结构,运算,三个方面,本节围绕逻辑结构和运算两个方面来展开。
线性表顾名思义就是一个线性的表,所谓线性就是被穿到一起,数据元素之间存在一个前后关系,如下图所示
线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用命名线性表,则其一般表示为
L
=
(
a
1
,
a
2
,
⋯
,
a
i
,
a
i
+
1
,
⋯
,
a
n
)
L=(a_{1},a_{2},\cdots ,a_{i},a_{i+1},\cdots,a_{n})
L=(a1,a2,⋯,ai,ai+1,⋯,an)
根据这个定义,需要注意以下几点
- 线性表中各个数据元素的类型都是相同的
- 线性表存在先后次
- 线性表的数据元素个数是有限的
- 线性表中的元素角标从1开始,而非0
接下来介绍几个概念
- a i a_{i} ai表示线性表中第i个元素,这个i我们称之为位序,位序从1开始
- 线性表中第一个元素称为表头元素,最后一个元素称为表尾元素
- 除了第一个元素之外,每一个元素都有一个直接前驱,除了最后一个元素之外,每一个元素都有一个直接后继
接下来介绍一些线性表应该实现的基本操作/运算
- InitList(&L):初始化表。构造一个空的线性表L,分配内存空间。
- DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
- Listlnsert(&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++相关课程
线性表的顺序表示
顺序表的定义
顺序表一一用顺序存储的方式实现线性表。
顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现
其实很好理解顺序表就可以理解为一个数组,由于线性表中各个数据元素的类型是相同的,所以每个数据元素所占内存空间一样大,有了这个特点,我们就可以直接根据第一个数据元素的存放位置以及一个元素所占大小,就可以算出任何一个元素所在位置,假设顺序表L的第一个元素存放位置为LOC(L),设每个元素元素所占内存空间为i,那么第n个元素所在位置就是LOC(L)+(n-1)i
在C语言中,我们可以使用sizeof关键字来检测数据元素大小
当然,顺序表中不一定只可以存储简单的数据类型,我们也是可以存储结构类型的,其实也很好理解,因为C语言本身的数组就支持存储结构体。
顺序表的实现有两种,静态分配和动态分配,先介绍静态分配,所谓静态分配,就是在顺序表内部直接建立一个静态数组,如下代码所示:
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int length;
}SqList;
这就是一个静态分配的顺序表,在线性表内部直接存放一个静态数组,这种顺序表一旦被声明就已经分配好空间了,所以自然就会产生一个问题,就是如果数据存满了怎么办,很显然,由于这种静态数组的长度是声明的时候就已经定义好了的,所以是无法更改的,我们当然也可以声明一个非常大的空间,让他根本存不满,但这样又会导致空间的严重浪费,所以静态分配这种方式是存在一定局限性的,为了让顺序表的空间大小可变,我们可以采用动态分配。
动态分配实现的顺序表就不需要在顺序表内部建立一个数组,而是在内部设立一个指针,用这个指针去控制一个数组,在初始化的时候让这个指针去指向一个数组,本质其实也是一个数组,只不过将分配空间的任务交给了初始化函数,这使得我们可以随意控制其分配空间的大小,数据结构定义如下:
typedef struct{
ElemType *data;
int MaxSize;
int length;
}SeqList;
由于这里顺序表的大小不是事先预定的,是我们初始化时给定的,为了在初始化完成之后获取到他的大小,我们还需要加一个MaxSize变量把大小保存起来。
至于如何在初始化过程中分配内存,C语言提供了malloc和free来申请和释放内存空间,如果使用了C++语法,可以使用new和delete关键字来更方便地申请和释放。
malloc函数需要传入一个整型参数,这个参数表示申请多少个字节的内存,调用函数之后,会自动申请一块连续的内存,并且把这块内存的首地址以指针的方式返回,返回的指针类型为void *,为了更方便的操作,我们通常会在申请的时候转换为我们需要的指针类型,如下代码所示:
L.data = (ElemType *)malloc(sizeof(ElemType) * InitSize);//分配
free(L.data);//销毁
这行代码就是申请了InitSize个ElemType类型的内存空间,需要注意的是,由于malloc和free这两个函数包含在了stdlib.h这个头文件中,所以需要事先引用stdlib.h这个头文件
当我们用完了这个内存空间之后我们可以将指针作为参数传入free函数销毁
如果使用new和delete来申请和释放,则会高效很多
L.data = new ElemType[InitSize];//分配
delete L.data;//销毁
这句话完全等价于前面那句话,但new关键字需要C++语法支持,所以在纯C语言环境下是不能使用的,我们同样可以使用delete关键字来销毁已分配的空间
接下来来看一下动态分配的顺序表是如何实现扩展的
#define InitSize 10
typedef struct{
ElemType *data;
int MaxSize;
int length;
}SeqList;
void InitList(SeqList &L){//初始化
L.data = new ElemType[InitSize];//分配空间
L.MaxSize = InitSize;//初始大小
L.length = 0;//初始为0
}
void IncreaseSize(SeqLize &L,int len){//扩展len个元素大小
int *temp = L.data;//保存这份空间
L.data = new ElemType[temp.MaxSize + len];//分配新的空间
for(int i = 0;i<L.length;i++){//拷贝到新空间
L.data[i] = temp[i];
}
L.MaxSize = temp.MaxSize + len;//更新最大空间
delete temp;//释放原有空间
}
从代码中可以看出,内存扩展主要分为以下三步
- 创建新空间
- 将旧空间内容拷贝到新空间
- 销毁旧空间
由于每次扩展都需要将内容进行拷贝,所以时间开销实际上还是很大的
接下来总结一下顺序表的一些特点
- 随机访问:由于我们可以直接通过首地址和元素大小直接算出每一个元素所在内存的位置,所以对于访问操作的时间复杂度为O(1)
- 存储密度高:每个存储节点只存储数据元素本身,所以存储密度会更高,这一个特性在学习了链式存储之后会有更深的理解
- 拓展容量不方便:静态存储不能拓展容量,动态存储可以拓展,但时间复杂度也很高
- 插入删除操作不方便:插入删除操作需要移动大量的元素,这个特性在下个小结中会更深的体会
顺序表的插入和删除操作
顺序表的初始化操作本节主要介绍顺序表的插入和删除两个操作
首先来研究插入操作:Listlnsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e.
由于顺序表的存储是按顺序存储的,如果要插入,就需要把后面所有的元素往后挪一位,说具体一点,如果我要在第三个位置插入一个元素,那么原来第三个位置的元素就变成了第四个位置,原来第四个位置的元素就变成了第五个位置,所以就要把后面所有的元素依次往后移
接下来来实现一下这个操作
#include <iostream>
using namespace std;
#define MAXSIZE 10
typedef struct {
int data[MAXSIZE];//数据类型为int
int len;
} List;
void init(List &L){
L.len = 0;
}
bool ListInsert(List &L,int i,int e){//在i的位置插入e
if(L.len>= MAXSIZE){
return false;//如果已经满了,不插入
}
if(i>L.len+1 || i<0){//最多只能在L.len+1这个位置插入,也就是最后一个的下一个位置插入,否则就是非法插入
return false;
}
//将第i个以及后面的元素都向后移动一位
for(int t = L.len-1;t>=i-1;t--){//最后一个元素下标为L.len-1,第i个元素的下标为i-1
L.data[t+1] = L.data[t];//移动位置
}
//插入
L.data[i-1] = e;
//更新长度
L.len++;
return true;
}
void print(List L){
for(int i =0;i<L.len;i++){
cout << L.data[i]<<endl;
}
}
int main(){
List L;
init(L);
ListInsert(L,1,1);
ListInsert(L,1,2);
ListInsert(L,1,3);
print(L);
}
以上代码按顺序在第一个位置插入了1,2,3三个元素,由于都是在第一个位置插入的,所以最后的顺序应该是3,2,1
执行结果如下:
需要注意的细节是,在把元素往后移动的过程中,需要从后往前,如果从前往后移动,后面的元素会被覆盖掉
接下来我们分析一下这个算法的时间复杂度,在插入操作中,只有一个for循环,我们就分析for循环内部的代码需要运行多少次,很显然这与插入的位置有关,我们假设数组长度为n,如果是在最后一个插入,则不需要移动,时间复杂度是O(1),如果是在第一个插入,则需要移动n个元素,时间复杂度为O(n),一共可插入的位置为n+1个,假设在每个位置插入的概率都为1/(n+1),则其加权期望为((0+1+2+3+4+5+…+n)/(n+1))=n/2,大O表示法即为O(n),所以其平均时间复杂度为O(n)
接下来我们看删除操作,删除操作很类似,如果要将第i个元素删除,只需要将第i+1个到最后一个元素依次向前挪一位即可,为了避免误操作,我们还可以将已删除的元素用一个引用类型的参数保存出来
bool ListDelete(List &L,int i,int &e){//e用来保存删除掉的元素内容
if(L.len == 0){//如果满
return false;
}
if(i<1 || i>L.len){//如果非法
return false;
}
e = L.data[i-1];//保存要删除的元素
for(int j = i;j<=L.len-1;j++){//第i+1的下标为i,最后一个元素的下标为len-1
L.data[i-1] = L.data[i];//依次向前移动
}
L.len--;//更新长度
return true;
}
不管是删除还是插入,都要注意位序和下标的关系,接下来我们分析一下其时间复杂度
我们假设数组长度为n,如果删除的是最后一个元素,则不需要移动,时间复杂度为O(1),如果删除的是第一个元素,需要移动n-1次,时间复杂度为O(n-1),设每个元素被删除的概率均为1/n,则加权平均数为(0+1+2+3+…+(n-1)/n)=(n-1)/2,大O表示法为O(n)
顺序表的查找操作
顺序表的查找操作主要有两个类型,按值查找和按址查找,按值查找是指给定一个值,找到第一个等于这个值的元素所在的位置,而按址查找则是给出元素的位置,返回元素的值
首先来看按址查找,其实在顺序表中按址查找非常简单,因为数组本身的访问就支持按址查找,以下为具体代码
bool GetElement(List &L,int i,int &e){
if(i<0||i>L.len){
return false;
}
e = L.data[i-1];
return true;
}
此方法对于动态分配的顺序表也同样适用,至于为什么可以参考C语言相关课程
由于按址查找的操作的核心只包含了一个语句,所以这里的时间复杂度为O(1)
下面来看按值查找,按值查找就是找到第一个和我们传入的参数相同的数据,返回这个数据的位序,如果没有找到,返回-1,下面是具体实现代码。
int GetElementByValue(List &L,int e){
for(int i = 0;i<L.len;i++){
if(L.data[i]==e){
return i+1;
}
}
return -1;
}
我们这里对两个数据的比较使用的是等于,但如果是一个复杂的结构体则不能这样比较,需要单独写一个比较函数去对结构体的每个分量进行比较
接下来分析时间复杂度,假设一共有n个元素,最好情况下是第一个就找到了,时间复杂度为O(1),最坏情况下是都没找到,时间复杂度为O(n),平均时间复杂度为(1+2+3+…+n)/n,大O表示法为O(n)
#include <iostream>
using namespace std;
#define MAXSIZE 10
typedef struct {
int data[MAXSIZE];//数据类型为int
int len;
} List;
void init(List &L){
L.len = 0;
}
int GetElementByValue(List &L,int e){
for(int i = 0;i<L.len;i++){
if(L.data[i]==e){
return i+1;
}
}
return -1;
}
bool GetElement(List &L,int i,int &e){
if(i<0||i>L.len){
return false;
}
e = L.data[i-1];
return true;
}
bool ListInsert(List &L,int i,int e){//在i的位置插入e
if(L.len>= MAXSIZE){
return false;//如果已经满了,不插入
}
if(i>L.len+1 || i<0){//最多只能在L.len+1这个位置插入,也就是最后一个的下一个位置插入,否则就是非法插入
return false;
}
//将第i个以及后面的元素都向后移动一位
for(int t = L.len-1;t>=i-1;t--){//最后一个元素下标为L.len-1,第i个元素的下标为i-1
L.data[t+1] = L.data[t];//移动位置
}
//插入
L.data[i-1] = e;
//更新长度
L.len++;
return true;
}
bool ListDelete(List &L,int i,int &e){
if(L.len == 0){//如果满
return false;
}
if(i<1 || i>L.len){//如果非法
return false;
}
e = L.data[i-1];//保存要删除的元素
for(int j = i;j<=L.len-1;j++){//第i+1的下标为i,最后一个元素的下标为len-1
L.data[i-1] = L.data[i];//依次向前移动
}
L.len--;//更新长度
return true;
}
void print(List L){
for(int i =0;i<L.len;i++){
cout << L.data[i]<<endl;
}
}
int main(){
int e;
List L;
init(L);
ListInsert(L,1,1);
ListInsert(L,1,2);
ListInsert(L,1,3);
GetElement(L,2,e);
// ListDelete(L,2,e);
// print(L);
cout << e;
// cout<<e;
}
线性表的链式表示
单链表
单链表的定义
学完了用顺序存储如何表示线性表,接下来就应该是用链式存储表示的链表,链表就是用链式存储表示的线性表,链表也可以分为单链表,双链表,循环链表,静态链表
首先来看什么是单链表,如下图所示:
在单链表中,一个节点除了存储数据元素之外,还要包含一个指向下一个节点的指针,这样的链表是单向查找的,也就是只能向后查找,后一个元素无法找到前一个元素,所以才叫单链表。
在上一节中,经过前面的学习,我们知道,顺序表的优点就是存储密度高并且支持随机存取,缺点就是需要大面积的连续存储空间,改变容量不方便,而链表正好是互补的,单链表不要求大面积连续的存储空间,单链表节点之间可以是离散分布在内存的各个区域的,通过指针来建立联系,只要内存够用,理论上就可以没有容量限制,但缺点也很明显,单链表不能随机存取,因为单链表节点之间的关系仅通过指针来联系,你要找到第三个元素你就必须通过第二个元素去找,你要找到第二个元素就只有通过第一个元素去找,想要找到某个元素只有一个一个去找,所以不可随机存取,而且单链表和顺序表不同的是,单链表每一个节点还需要存放一个指针用来指向下一个元素,所以每个节点都要耗费一个空间来存放指针,也就导致了存储密度低。
接下来我们来研究如何定义单链表,从上图可以看出,单链表是由一个一个节点组成的,我们可以把节点定义为一个数据结构,单链表里存储一个指针去指向第一个节点,这样就可以实现对单链表的控制。
typedef struct Node{
int data;
struct Node *next;
}Node,*linkList;
可以看到,在一个节点中,包含了数据和指针两部分,指针用于保存下一个节点的地址,这里需要强调一下,就是为什么这里要加上一个*linkList,加上这个以后,使用linkList p和Node *p本质上就是一样的,都是创建一个指向该节点的指针,所以完全可以全部用Node p,那为什么要加上这个看似毫无意义的linkList呢,其实是有原因的,作为一个单链表而言,我们拿到了第一个元素的地址,我们就相当于拿到了整个单链表,这一点是要明确的,因为通过第一个元素是可以找到后面所有元素的,所以可以把第一个元素的地址理解为单链表的入口,所以第一个元素的指针对我们来说有特殊的意义,我们使用Node *p来声明一个节点的时候,实际上是强调这是一个节点,而使用linkList p来声明一个节点指针的时候,一定是在强调他是一个链表,或者说在强调他是第一个元素的指针,这一点务必明确。
上一段说过,单链表中拿到了第一个节点就相当于可以控制整个单链表,所以我们大可以拿一个指向第一个节点的指针用来表示一个单链表,接下来我们给出初始化和判空操作
bool init(linkList &L){
L = NULL;
return true;
}
bool isEmpty(linkList L){
return L == NULL;
}
在实际操作中,我们更常见的是给链表添加一个头结点,首先要了解什么是头结点,头节点也是一个节点,和其他节点没有任何区别,原来我是拿到一个指向节点的指针,这个指针要指向第一个节点,此时不一样的,我这个指针始终指向一个头结点,由头结点的next去指向第一个指针,如下图所示
以上是一个带头结点和不带头结点的链表示例图,其中L都表示链表,不带头结点的L直接指向第一个元素,而带头节点的L指向头结点,头结点的next指向第一个元素,从而实现对链表的控制,头结点的data可以用来记录一些信息,也可以完全废弃不用,是否带头结点并不影响我们对链表数据结构的定义,只会影响对链表的操作,接下来我们给出其初始化代码和判空操作
bool init(linkList &L){
L = new Node;//分配头结点
L->next = NULL;//头结点的下一个元素指向NULL
return true;
}
bool isEmpty(linkList L){
return L->next == NULL;
}
可以对比一下没有头结点的初始化和判空操作
接下来我们说一下带不带头结点的区别,可能有一个很反常识的点,带头节点看起来多了一个头结点会更麻烦,但实际上确实更方便的那一个,这里我只给出这个结论,也就是带头节点更方便,至于是不是更方便,可以在以后的学习中深刻体会到,在本节中只需要知道,带头节点的链表的头指针指向的是头结点,而不带头结点的头指针指向的是第一个存放数据的节点。
单链表的插入和删除
相比于顺序表,单链表的插入和删除操作会方便很多,我们先探讨插入操作
对于带头节点的单链表,要实现按位序插入,比如要插入到第i个位置,那就得让第i-1个位置的next指向该节点,并且让该节点的next指向原来的第i个,说起来可能比较复杂,可以通过下图帮助理解
那如果我们要在第一个位置插入节点呢,实际上也很简单,我们可以直接把头结点看做第0个节点,当我们在第一个位置插入节点的时候,只要让第0个节点的next指向该节点,再让该节点的next指向原来的第一个节点即可,在这里就可以体会到带头节点的单链表的好处了,接下来给出具体代码实现。
#include <iostream>
using namespace std;
typedef struct Node{
int data;
struct Node *next;
}Node,*linkList;
bool init(linkList &L){
L = new Node;//分配头结点
L->next = NULL;//头结点的下一个元素指向NULL
return true;
}
bool isEmpty(linkList L){
return L->next == NULL;
}
bool insert(linkList &L,int i,int e){
//判断i的合法性
if(i<=0){
return false;
}
Node *p = L;//创建一个临时节点,用来保存第i-1个节点
Node *newNode = new Node;//创建新节点
newNode -> data = e;
for(int j = 0;j<i-1;j++){//找到第i-1个节点
p = p->next;
if(p == NULL){
return false;//如果还没找到就已经是NULL了,就表示i不合法
}
}
//此时p指向第i-1个节点
newNode -> next = p -> next;
p -> next = newNode;
return true;
}
void print(linkList L){
Node *p = L;
while(p -> next != NULL){
p = p->next;
cout << p->data << endl;
}
}
int main(){
linkList L;
init(L);
insert(L,1,3);
insert(L,1,1);
insert(L,2,2);
print(L);
return 0;
}
来具体分析一下insert函数,首先是判断合法性,也就是如果小于等于0一定是错误的,只能在第一个位置之后插入,然后申请一个指针用来记录第i-1个节点,然后就是创建节点申请空间,最关键的是这个循环,循环的j表示当前p指向的是第几个节点,我们知道我们要让p指向第i-1个节点,所以当p小于i-1时,让他指向下一个节点,但是这里还有一个问题,因为p是指向第i-1个节点,我们是要在这个p节点后面插入,那么这个节点必须存在,所以这里如果p指向NULL了,那么说明他给我的下标越界了,就返回false,循环结束后p一定指向第i-1个节点,此时在p后面插入即可。
该算法的时间复杂度为O(n)
接下来我们研究一下不带头结点的单链表实习起来怎么样,不管带不带头结点,我们要在第i个元素插入,本质都是在第i-1个元素后面插入,我们都要找到第i-1个节点,那问题来了,如果要在第一个元素插入,那就会产生问题,因为我们找不到第0个元素,所以对于第一个元素,我们需要单独处理
bool insert2(linkList &L,int i,int e){
//判断i的合法性
if(i<=0){
return false;
}
Node *p = L;//创建一个临时节点,用来保存第i-1个节点
Node *newNode = new Node;//创建新节点
newNode -> data = e;
if(i == 1){
newNode -> next = L->next;
L ->next = newNode;
}else{
for(int j = 1;j<i-1;j++){//找到第i-1个节点
p = p->next;
if(p == NULL){
return false;//如果还没找到就已经是NULL了,就表示i不合法
}
}
//此时p指向第i-1个节点
newNode -> next = p -> next;
p -> next = newNode;
}
return true;
}
很显然,对于带头节点的单链表,我们在第一个元素插入的时候是不需要更改头指针的,但如果不带头结点我们就需要对第一个节点单独处理,并且还需要更改头指针,从这里也可以看出不带头结点的单链表的劣势了,所以在后面的学习中,我们都默认使用带头结点的方式。
接下来我们讨论另一种插入操作,我们前面讨论的是在指定位序插入,如果给我们的是一个节点指针,让我们在这个节点后面插入一个元素,我们称这种操作为后插操作,那应该怎么实现呢,其实实现起来会更加简单
bool insertNext(Node *p,int e){
if(p == NULL){
return false;
}
Node *newNode = new Node;
newNode -> data = e;
newNode -> next = p -> next;
p -> next = newNode;
return true;
}
在单链表中,只能实现后插操作,因为我们只能通过next指针往后找,找不到前面的节点,如果我们要实现前插操作,我们必须传入头指针,然后依次遍历找到p的前驱结点,这样的时间复杂度显然是O(n),前插操作看起来无解,但其实我们可以用一个巧妙的方法实现,以下给出代码
bool insertBefore(Node *p,int e){
if(p == NULL){
return false;
}
Node *newNode = new Node;
newNode -> next = p -> next;
p ->next = newNode;
//至此,插入到了p的后面
newNode -> data = p -> data;
p -> data = e;
return true;
}
在这个代码中,虽然我们没有直接加到p节点的前面,但是我们通过修改p和新节点的内容,实现了间接的前插,并且这种方式的时间复杂度为O(1)
接下来来看如何删除节点,删除节点和插入节点很类似,想要删除第i个节点,也要找到第i-1个节点,删除其后面一个节点,具体实现代码如下
bool deleteNode(linkList &L,int i,int &e){
if(i<=0){
return false;//下标异常
}
if(L -> next == NULL){
return false;//空表不能删除
}
Node *p = L;//指向第i-1个节点
for(int j = 0;j<i-1;j++){//如果小于i-1,就指向下一个
p = p->next;
//要保证p的下一个元素有值,所以需要判断
if(p -> next == NULL){
return false;//如果p的下一个元素没有值,说明下标非法
}
}
//此时p指向第i-1个元素
Node *temp = p -> next;
e = temp -> data;
p -> next = temp -> next;
delete temp;
return true;
}
该算法的平均时间复杂度为O(n)
这里是按位序删除,那如果是给定的节点,应该如何删除呢,同样,我们无法找到上一个节点,要么给我们整个链表,要么就和前插操作的实现逻辑类似,下面给出代码
bool deleteNode2(Node *p){
if(p == NULL){
return false;
}
Node *temp = p -> next;
p -> next = temp -> next;
p -> data = temp -> data;
delete temp;
return true;
}
这里也不难理解,我们删除的相当于是p的下一个节点,我们只是把p的值改成下一个节点的值了,所以看起来像是p本身被删除了一样,但是如果仔细思考,这种方式其实是有BUG的,如果此时p节点恰好是最后一个节点,他的下一个节点是null,那么这个代码就会报错,所以如果是删除最后一个节点,只有给出整个链表来依次遍历找到其前面的节点
从这里也能看出单链表的局限性,也就是他只能单向搜索,有时候并不方便。
我们介绍的前插,后插以及删除指定节点其实都不难,那为什么要说这些简单的算法呢,因为我们按位序删除节点的时候就是找到前一个节点,然后在他的后面插入节点,相当于就是一个后插算法,可以直接调用,便于封装
单链表的查找
单链表的查找也分为按位查找和按值查找,我们已经学习了插入操作,查找操作应该是比插入要简单的,所以这里直接给代码,首先来看按位查找
Node *getElement(linkList &L,int i){//找到第i个元素
if(i<0){
return NULL;
}
Node *p = L;//p用来检索
for(int j = 0;j<i;j++){
p = p->next;
if(p == NULL){
return NULL;//如果找到NULL了,不再往后找
}
}
return p;
}
此时我们可以对insert函数进行改写
bool insert(linkList &L,int i,int e){
Node *p = getElement(L,i-1);
return insertNext(p,e);
}
这样的代码充分体现的封装的特性
接下来我们来考虑按值查找,也是直接给出代码
Node *getElementByValue(linkList L,int e){
Node *p = L;
while(p -> next != NULL){//遍历所有节点
p = p -> next;
if(p -> data == e){//如果找到了,返回
return p;
}
}
return NULL;
}
同样,这里由于是int类型,所以可以用等号来比较两个值是否相等,但如果不是int这种简单数据类型,就需要单独写一个方法来比较。
该算法的平均时间复杂度为O(n)
接下来再给出求表长的操作,这个操作相对更简单
int length(linkList L){
//表长就是最后一个元素的位序,len表示p当前指向的元素位序,p用来指向
int len = 0;//统计表长
Node *p = L;
while(p->next != NULL){
p = p -> next;
len++;
}
return len;
}
单链表的建立方法
这里的建立方法其实就是给你输入很多个元素,如何快速建立一个单链表,其实也很简单,分为以下几步
- 初始化一个单链表
- 每取一个数据元素,插入到表头/表尾
如果插入到表头,就是前插法,如果插入到表尾,就是尾插法
实际上这些方法都很可以用我们前面实现的方法快速实现,这里给出尾插法的实现
linkList createLinkListAfter(){
linkList L;
init(L);
Node *p = L;//始终指向最后一个节点
int e;
cin >> e;
while(e != 9999){//输入9999结束插入
insertNext(p,e);
p = p->next;
cin >> e;//更新e
}
return L;
}
尾插法需要用一个指针去跟踪最后一个元素,头插法相对就会简单一点,接下来来看头插法的实现
linkList createLinkListBefore(){
linkList L;
init(L);
int e;
cin >> e;
while(e != 9999){//输入9999结束插入
insert(L,1,e);//第一个元素插入e
cin >> e;//更新e
}
return L;
}
头插法创建的链表和输入顺序是相反的,所以可以用来对链表进行逆置操作,我们甚至可以通过这种方法实现对链表的原地逆置操作。
#include <iostream>
using namespace std;
typedef struct Node{
int data;
struct Node *next;
}Node,*linkList;
Node *getElement(linkList &L,int i);
bool insertNext(Node *p,int e);
bool init(linkList &L){
L = new Node;//分配头结点
L->next = NULL;//头结点的下一个元素指向NULL
return true;
}
bool isEmpty(linkList L){
return L->next == NULL;
}
Node *getElementByValue(linkList L,int e){
Node *p = L;
while(p -> next != NULL){//遍历所有节点
p = p -> next;
if(p -> data == e){//如果找到了,返回
return p;
}
}
return NULL;
}
bool insert(linkList &L,int i,int e){
// //判断i的合法性
// if(i<=0){
// return false;
// }
// Node *p = L;//创建一个临时节点,用来保存第i-1个节点
// Node *newNode = new Node;//创建新节点
// newNode -> data = e;
// for(int j = 0;j<i-1;j++){//找到第i-1个节点
// p = p->next;
// if(p == NULL){
// return false;//如果还没找到就已经是NULL了,就表示i不合法
// }
// }
// //此时p指向第i-1个节点
// newNode -> next = p -> next;
// p -> next = newNode;
// return true;
Node *p = getElement(L,i-1);
return insertNext(p,e);
}
bool insertBefore(Node *p,int e){
if(p == NULL){
return false;
}
Node *newNode = new Node;
newNode -> next = p -> next;
p ->next = newNode;
//至此,插入到了p的后面
newNode -> data = p -> data;
p -> data = e;
return true;
}
bool deleteNode2(Node *p){
if(p == NULL){
return false;
}
Node *temp = p -> next;
p -> next = temp -> next;
p -> data = temp -> data;
delete temp;
return true;
}
bool insertNext(Node *p,int e){
if(p == NULL){
return false;
}
Node *newNode = new Node;
newNode -> data = e;
newNode -> next = p -> next;
p -> next = newNode;
return true;
}
bool deleteNode(linkList &L,int i){
if(i<=0){
return false;//下标异常
}
if(L -> next == NULL){
return false;//空表不能删除
}
Node *p = L;//指向第i-1个节点
for(int j = 0;j<i-1;j++){//如果小于i-1,就指向下一个
p = p->next;
//要保证p的下一个元素有值,所以需要判断
if(p -> next == NULL){
return false;//如果p的下一个元素没有值,说明下标非法
}
}
//此时p指向第i-1个元素
Node *temp = p -> next;
p -> next = temp -> next;
delete temp;
return true;
}
int length(linkList L){
//表长就是最后一个元素的位序,len表示p当前指向的元素位序,p用来指向
int len = 0;//统计表长
Node *p = L;
while(p->next != NULL){
p = p -> next;
len++;
}
return len;
}
Node *getElement(linkList &L,int i){//找到第i个元素
if(i<0){
return NULL;
}
Node *p = L;//p用来检索
for(int j = 0;j<i;j++){
p = p->next;
if(p == NULL){
return NULL;//如果找到NULL了,不再往后找
}
}
return p;
}
void print(linkList L){
Node *p = L;
while(p -> next != NULL){
p = p->next;
cout << p->data << endl;
}
}
linkList createLinkListAfter(){
linkList L;
init(L);
Node *p = L;//始终指向最后一个节点
int e;
cin >> e;
while(e != 9999){//输入9999结束插入
insertNext(p,e);
p = p->next;
cin >> e;//更新e
}
return L;
}
linkList createLinkListBefore(){
linkList L;
init(L);
int e;
cin >> e;
while(e != 9999){//输入9999结束插入
insert(L,1,e);//第一个元素插入e
cin >> e;//更新e
}
return L;
}
int main(){
linkList L = createLinkListBefore();
// init(L);
//
// insert(L,1,3);
// insert(L,1,1);
// insert(L,2,2);
// deleteNode(L,2);
print(L);
// cout << getElement(L,2) -> data;
return 0;
}
双向链表
在单链表中,由于每个节点只包含其后继节点的指针,所以如果给定一个节点,想要找到其前驱节点只有去遍历整个链表,这样是非常麻烦的,双链表就是在单链表的基础上增加了一个指向前驱的指针域,让节点与前驱和后继都建立联系。
typedef struct Node{
int data;
Node *prior,*next;
}Node,*linkList;
对于双链表而言,初始化操作其实也很简单,申请头结点,并且把头结点的两个指针域都设为NULL即可
bool init(linkList &L){
L = new Node;//头结点
L -> prior = NULL;
L -> next = NULL;
return true;
}
bool isEmpty(linkList L){
return L -> next ==NULL;
}
对于双链表的插入操作,由于每个节点都有两个指针,所以插入的时候一共要修改四个指针域,但是不管怎么样,都记住一个原则,先修改新节点,再修改旧节点,这样肯定错不了
bool insertNext(Node *p,Node *q){
if(p == NULL || q == NULL){
return false;
}
//在p后面插入q
q -> prior = p;//修改q
q -> next = p -> next;//修改q
//判断是否是要插入到最后一个节点
if(p -> next != NULL){
//如果不是最后一个节点,需要修改后继节点的prior
p -> next -> prior = q;
}
p -> next = q;//修改p
return true;
}
有了后插操作之后,我们想要实现按位序插入就非常简单,如果要插入到第i位,我们只需要找到第i-1位,对i-1位执行后插操作,对于双链表来说,如果要对p执行前插操作,我们也只需要通过prior找到p的前一个节点,对p的前一个节点进行后插操作即可,非常方便。
接下来考虑删除,下面看代码:
bool deleteNode(Node *p){
//删除p的后继节点
if(p == NULL||p->next == NULL){
return false;
}
Node *q = p -> next;//要删除的节点
p -> next = q -> next;
if(q -> next != NULL){//如果q不是最后一个节点
q -> next -> prior = p;//修改节点
}
delete q;//删除
return true;
}
从这两个例子可以看出,双链表的删除和插入和单链表并不完全一样,由于最后一个元素指向NULL一定是单链的,所以很容易出现空指针相关的错误,所以在写算法的时候需要充分考虑最后一个节点的情况
对于销毁操作也非常简单,我们可以直接用循环删除头结点的下一个节点即可
bool destroyLinkList(linkList &L){
while(L -> next != NULL){
deleteNode(L);
}
delete L;
L = NULL;
return true;
}
双链表的遍历操作也是非常简单的,如下代码所示
有了这些知识之后,双链表的按位查找,按值查找这些操作都非常简单,双链表的查找操作时间复杂度为O(n)
循环链表
循环链表就是对单链表和双链表的改进,循环链表和非循环链表的结构体定义上是完全没有区别的,区别主要在于初始化和相关操作,循环链表和非循环链表的区别如下图所示
也就是说,对于单链表而言,循环单链表的最后一个指针会指向头结点,对于双链表而言,头结点的prior会指向最后一个节点,而最后一个节点的next会指向头结点,说的直白一点,循环链表相当于把头结点看做最后一个节点的下一个节点
bool init(linkList &L){
L = new Node;//头结点
L -> next = L;//因为没有元素,所以自己指向自己
return true;
}
从这个初始化也能看出,如果要判断循环链表的某个节点是不是表尾结点,只需要判断其下一个节点是否是头结点,如果要判断一个循环单链表是否为空,只需要看他头结点的下一个节点是否是自己。
循环单链表和普通的单链表也很不一样,我们前面提到过,对于普通的单链表,如果给了我们一个节点P,我们是无法找到前面的节点的,我们只能找到P后面的节点,但对于循环单链表而言,如果给了我们一个P节点,由于循环的特性,我们可以找到这个循环单链表的任何一个节点。除此之外,循环单链表相对于普通单链表还有一些优势,比如我们对于循环单链表而言,我们甚至可以让指针L指向表尾,因为表尾的下一个元素就是表头,所以我们从表尾可以很快找到表头,这样我们对表尾和表头的操作都可以非常方便。
对于循环双链表而言,和单链表差不多,以下是循环双链表的初始化操作
bool init(linkList &L){
L = new Node;//头结点
L -> next = L;//因为没有元素,所以自己指向自己
L -> prior = L;
return true;
}
对于循环双链表而言,判空操作就是看头结点的下一个节点是否是自己,判断一个节点是否是表尾结点也是看这个节点的下一个节点是不是头节点
循环双链表和非循环双链表比起来有很大的优势,我们在上一节学习循环双链表的时候,往往要判断是否为最后一个节点,我们需要对最后一个节点做单独的处理,但在循环双链表中,我们不需要这些繁琐的判断操作,因为最后一个节点一定有后继节点,就是头结点,所以就不再需要对最后一个节点做单独处理。
在学习链表的时候,我们应该着重关心以下三个问题
- 判空操作
- 判断节点p是否为表尾/表头节点
- 节点的插入和删除
搞清楚了判空操作自然就搞清楚了如何进行初始化,知道如何判断表尾节点也就搞清楚了节点的遍历操作,而节点的插入和删除则更是基础操作,对于节点的插入和删除操作,要着重关心对表头和表尾是否需要特殊处理。
静态链表
静态链表其实并不是传统意义上的链表,静态链表其实还是一个顺序表,他也需要一个连续的存储空间,静态链表完全可以看做是一个数组,只不过这个数组的每一项会存放两个信息,一个信息为数据,另一个信息为下一个节点的下标,这个存放下一个节点下标的位置我们通常称为游标,如下图所示
从图中可以看出,数组下标为0的节点充当头结点,游标位置相当于就是一个指针,而最后一个元素的游标为-1,接下来我们定义一个静态链表
#define MAX 100
typedef struct Node{
int data;
int next;
};
typedef struct{
struct Node arr[MAX];
}staticNode;
bool init(staticNode &L){
L.arr[0].next = -1;
return true;
}
对于静态链表而言,如果我们要按位序查找节点,我们也只能从头结点出发依次向后找,如下代码所示
bool findNode(staticList &L,int i,int e){
int p = 0;//表示当前数组下标
for(int j = 0;j<i;j++){//查找第i个,那么循环i次
p = L.arr[p].next;
if(p == -1){
return false;
}
}
e = L.arr[p].data;
return true;
}
插入操作则显得比较繁琐,如果要将一个数据插入到第i个节点,需要以下几步
- 可以通过一个算法找到空节点,存入数据元素
- 从头到尾遍历找到位序为i-1的节点
- 将刚才找到的空节点的next值设置为第i-1个节点的next值
- 将第i-1个节点的next值设为刚才找到的空节点下标
如果细心的读者可能会发现,如果像我刚才那样的初始化代码,时不可能通过算法很方便地找到空节点的,因为内存中肯定会存在脏数据,所以我们很难通过程序快速判断出那些数据是有效的,哪些数据是无效的
bool init(staticList &L){
L.arr[0].next = -1;
for(int i = 1;i<MAX;i++){
L.arr[i].next = -2;
}
return true;
}
我们将空闲节点的next指针设置为-2之后,就可以很快速地找到哪些节点的空闲的
静态链表增删改查不需要移动大量的元素,但也不支持随机存取,而且容量固定,非常不方便,只适用于不支持指针的一些低级语言,除此之外,对于数据元素数量固定不变的场景也可以用静态链表实现,例如OS的FAT。
静态链表作为一个非重点考点,难度不算高,考察频次相对较少,极少会出现代码实现,并且有了前面的代码基础,相信读者也能自己实现静态链表的相关操作。
顺序表和链表的对比
如何选择链表和顺序表
顺序表 | 链表 | |
---|---|---|
弹性(可扩容) | 😭 | 😀 |
增加,删除 | 😭 | 😀 |
查找 | 😀 | 😭 |
- 链表:表长难以预估、经常要增加/删除元素
- 顺序表:表长可预估、查询(搜索)操作较多
对于一些开放性问题,比如给你一个场景,问你用链表和顺序表哪个更好,可以对链表和顺序表的存储结构,物理结构以及基本操作进行分析,然后再来回答是哪个表更好,并给出原因,需要培养这种框架性问题。