数据结构学习笔记(五)--单链表
数据结构学习笔记(五)--单链表
点击进入上一篇:数据结构学习笔记(四)--顺序表
单链表的定义
为用链式存储的方式实现的线性表,与顺序表的异同如图所示:
用代码定义一个单链表
用c/c++实现,如下:
typedef struct LNode{ //定义单链表节点类型
int data; //每个节点存放一个整型元素
struct LNode *next; //指针指向下一个节点
} LNode, *LinkList; //对变量及指针分别命名
代码其实等价于:
typedef struct LNode{ //定义单链表节点类型
int data; //每个节点存放一个整型元素
struct LNode *next; //指针指向下一个节点
};
typedef struct LNode LNode; //对结构体变量 struct LNode 重命名为LNode
typedef struct LNode *Linklist; //对指向结构体变量 struct LNode 的指针重命名为LinkList(等价于LNode *,但侧重于单链表本身,可读性更强)
初始化一个单链表
不带头结点
用C/C++表示,如下:
typedef struct LNode{ //定义单链表节点类型
int data; //每个节点存放一个整型元素
struct LNode *next; //指针指向下一个节点
} LNode, *LinkList; //对变量及指针分别命名
//初始化一个空的单链表(即不带头结点)
bool InitList(LinkList &L) {
L = NULL; //空表,暂时还没有任何节点(防止脏数据)
return true;
}
int main(){
LinkList L; //声明一个指向单链表的指针
//初始化一个空表
InitList(L);
//......后续代码....
return 0;
}
判断是否为空
用C/C++表示,如下:
bool Empty(LinkList L){
return (L==NULL);
}
带头结点
头指针并不初始化,而分配一个初始值,用C/C++表示,如下:
//定义节点类型不变,还是一样
typedef struct LNode{ //定义单链表节点类型
int 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;
}
判断是否为空
用C/C++表示,如下:
bool Empty(LinkList L){
//这里判断头结点的下一个节点是否为空
return (L->next==NULL);
}
是否带头结点的区别
不带头结点,写代码更麻烦,对第一个数据节点和后续节点的代码逻辑不同,对空表与非空表的处理也需要用不同的代码逻辑;而带头结点则不然。因此,带头结点更方便。
不带头结点的头指针指向的结点会存放数据元素。
带头结点的头指针指向的结点不会存放数据元素,只存放下一节点的指针
如图所示:
单链表的基本操作
位序插入
分为带头结点与不带头结点两部分。
带头结点
大致思路为:
- 根据单链表指针指向遍历,从表头一直指向所需位序的上一个结点P。
- 新建一个结点S。
- 将新建结点S的data值赋值,再将next指针赋值为上一个结点P的next指针指向。
- 再将P的next指针指向S。
代码实现
用C/C++表示,如下:
//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,int 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; //插入成功
}
插入操作的时间复杂度
- 最好情况:结点插入到表头,最好时间复杂度 = O(1)
- 最坏情况:结点插入到表尾,最坏时间复杂度 = O(n)
- 平均情况:结点插入到表中,平均时间复杂度 = O(n)
不带头结点
大致思路与带头结点类似,但需要注意:
- 不带头结点的单链表对插入表头需要特殊处理
- 插入表头时,先由内存分配一个新节点s,对其赋值data,并将节点的next指针赋值,指向原来的表头所在节点。
- 然后将原来的头指针指向新结点s
- 之后的结点处理与带头结点的相同,但不带头结点的单链表没有位序为0的头结点,所以位序从1开始。
代码实现
用C/C++表示,如下:
//在第i个位置插入元素e(不带头结点)
bool ListInsert(LinkList &L,int i,int e){
if(i<1)
return false;
if(i==1){ //插入第一个结点的操作与其他结点操作不同
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = L; //第一个结点next指针指向原来的第一个结点
L = s; //头指针指向新结点
return true;
}
LNode *p; //指针p指向当前扫描到的结点
int j = 1; //当前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; //插入成功
}
插入操作的时间复杂度
与带头节点的插入操作时间复杂度一致,不再赘述。
指定结点的插入
可分为指定结点的后插与前插,这里默认以带头结点执行。
后插
在指定结点p后插入元素 e,实际上为位序插入的后半段代码逻辑。
代码实现
用C/C++表示,如下:
//后插操作:在P结点之后插入元素e
bool InsertNextNode(LNode *p,int 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; //插入成功
}
插入操作的时间复杂度
时间复杂度为O(1),牛逼的很。
前插
单链表的结点不指向前一个结点,故初步逻辑为从头指针开始遍历,找到需要插入的结点的前一位,再将其插入,如果这样的话,方法还需要传染头指针的参数,时间复杂度也为O(n),很不爽。故采用第二种方案。
方案二:依旧采用指定结点后插的方式,插入新结点,但插入之后将指定结点p与新结点s的data值调换,实现偷天换日的骚操作。
代码实现
这里采用方案二,用C/C++表示,如下:
//前插操作:在P结点之前插入元素e
bool InsertPriorNode(LNode *p,int 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; //插入成功
}
插入操作的时间复杂度
时间复杂度为O(1),骚的很。
删除
这里只探讨带头结点的删除,分为按位序删除和指定结点删除。
按位序删除
逻辑为遍历单链表L,将指定位序的前一个结点的指针指向指定位序的后一个结点(即指定位序的next值),再通过free函数释放掉指定位序的结点,并返回指定结点的data值
代码实现
用C/C++表示,如下:
//在第i个位置删除结点q(带头结点)
bool ListDelete(LinkList &L,int i,int &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; //删除成功
}
删除操作的时间复杂度
- 最好情况:删除表头结点,最好时间复杂度 = O(1)
- 最坏情况:删除表尾结点,最坏时间复杂度 = O(n)
- 平均情况:删除表中结点,平均时间复杂度 = O(n)
指定结点删除
由于指定结点并不能通过指针定位到结点本身(结点中的指针数据只指向下一位),故传统方案只能通过遍历头结点来找到自己的指定结点,这里不做赘述。
取巧方案逻辑与指定结点的前插类似。将指定结点的data值赋值为指定结点的后继结点,再将指定结点的next指针值赋值为后继结点的next指针值,此时指定结点直接指向后继的后继结点。并且指定结点的data值也等于后继结点,此时可以说指定结点在效果上被删除了(自己的理解),这时,我们再将指定结点的后续节点释放内存,防止内存泄漏。
注:如果指定结点是最后一个结点,该方案无效,依旧得按传统方案从表头遍历。
代码实现
取巧方案的删除法,用C/C++表示,如下:
//删除指定结点p(不适用于删除最后一个结点)
bool DeleteNode(LNode *p){
if(p==NULL)
return false;
LNode *q = p->next; //用q指向p的后继结点
p->data = q->data; //将后继结点的数据域赋值给自己,把自己变成后续节点的模样
p->next = q->next; //指向原本后续结点指向的next
free(q); //释放后继结点的存储空间
return true;
}
删除操作的时间复杂度
通过取巧方案的骚操作,时间复杂度为O(1)。
查找
这里只探讨单链表带头结点的查找。
按位查找
按位查找的逻辑其实已经在实现插入和删除算法时有所实现,唯一区别为插入删除时,所要查找的结点为位序为i-1的结点,而我们现在要找的是i所在节点并返回。
代码实现
用C/C++表示,如下:
//按位查找,返回第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;
}
时间复杂度
- 最好情况:表头结点,最好时间复杂度 = O(1)
- 最坏情况:表尾结点,最坏时间复杂度 = O(n)
- 平均情况:表中结点,平均时间复杂度 = O(n)
其实这些与问题规模n(单链表L的长度)有直接的关系的算法,时间复杂度直接为O(n)即可,之后不赘述
按值查找
代码实现
用C/C++表示,如下:
//按值查找,找到数据域==e的结点(带头结点)
LNode * LocateElem(LinkList L, int e){
LNode *p = L->next;
//从第一个结点开始查找数据域为e的结点
while(p!=NULL && p->data !=e)
p = p->next;
return p; //找到后返回该指针,否则返回NULL
}
时间复杂度
时间复杂度就是O(n),这里不赘述好坏情况。
求表的长度
这里默认不带头结点。
思考:带头结点咋子整?
代码实现
用C/C++表示,如下:
//求表的长度(带头结点)
int Length(LinkList L){
int len = 0; //统计表长
LNode * p = L;
while(p->next !=NULL){
p = p->next;
len++;
}
return len;
}
时间复杂度
O(n)
单链表的建立
如果多个数据元素,需要存入单链表中。如何?
- 初始化一个单链表。
- 每次取一个数据元素,插入到表尾\表头(尾插法/头插法)。
这里我们仅探讨带头结点的情况。
尾插法
核心思想与后插相同,唯一区别为多了一个指向表尾的记录指针r,以减少时间复杂度。
代码实现
用C/C++表示,如下:
//尾插法建立单链表
LinkList List_TailInsert(LinkList &L){ //正向建立单链表
int x;
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)
思考:不带头结点咋子整?
头插法
核心思想与后插相同,每次头插向头结点的下一个节点。
代码实现
用C/C++表示,如下:
//头插法建立单链表
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; //指针指向新的表尾结点(永远表示r指向最后一个结点)
scanf("%d",&x);
}
return L;
}
时间复杂度
随问题规模变化,为O(n)
重要应用!!!链表的逆置
思考:不带头结点咋子整?
单链表的局限性
单链表每个结点都只指向下一个结点而不指向上一个,检索时只能从头检索无法逆向检索,有时便不太方便。因此有双链表解决这个问题。