写在前面
这一章对于一些有一定算法经验或者一定编程基础的同学来说,是比较容易的。但是,回顾自己当时学这一章的时候,确实是走了不少的弯路,因此自己写这一章的目的,旨在记录链表的一些常见问题以及这些问题的分析思路和解决方案。
一、链表的一些易混淆概念
1.头指针 or 头结点 or 首结点?
初学数据结构时,我对这几个概念也是分不清楚,所以开门见山,首先梳理这三个概念。
- 对于有头结点的链表(^表示空):
- 对于无头结点的链表:
从上面的两张图可作以下总结:
- 头指针的意义在于,在访问链表时,知道链表存储在什么位置(从何处开始访问),由于链表的特性(next指针),知道了头指针,那么整个链表的元素都能够被访问。
- 若链表中含有头结点,则头指针指向链表的头结点;
- 若链表中不含有头结点,则头指针指向链表的首个数据节点。
- 引入头结点后,可以带来两个优点:
- 由于开始结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
- 无论链表是否为空,其头指针是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就统一了。
从以上两点可以得知,头指针和头结点的区分:无论带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点链表中的第一个节点,结点内通常不存储信息。
另外还有以下两点注意事项:
* 有些教材上可能还有首节点的说法,首节点一般是指链表中首个数据结点,也就是以上两图中所展示的。
* 对于链表求长度,链表逆置等操作,是不包括头结点的,也就是说,对于这些基本操作,一般都是对链表的数据结点进行操作的,而引入头结点的目的只是为了统一某些链表的操作(如上述优点)。
2.传值 or 传址?
传值和传址是链表这一块要搞清楚的重要内容,下面先展示一个小例子。
#include <stdio.h>
void pointerTrans(int *tranPointer);
void valueTrans(int tranValue);
int main(int argc, char *argv[]) {
int value = 3;
int *pointer = &value;
printf("pointer = %x\n", pointer);
pointerTrans(pointer);
printf("value's address is = %x\n", &value);
valueTrans(value);
return 0;
}
void pointerTrans(int *tranPointer) {
printf("tranPointer = %x\n", tranPointer);
}
void valueTrans(int tranValue) {
printf("tranValue's address is = %x\n", &tranValue);
}
输出结果:
pointer = e8cc6d5c
tranPointer = e8cc6d5c
value's address is = e8cc6d5c
tranValue's address is = e8cc6d3c
从以上的结果可以看出,pointer和tranPointer的地址是一致的,value和tranValue的地址是不一致的,究其原因,其实就是传值和传址之间的区别所在。
图示理解:
总结说来:
- 传址也就是指针之间地址的传递,从上述例子中,首先pointer指针中存储的是value变量在内存中的存储地址,而其通过函数
pointerTrans
将地址值传递给形参变量tranPointer,因此此时pointer指针和tranPointer指针中存储的均是value变量的地址。 - 传值则是实参变量和形参变量之间值的传递,形参变量也就是实参变量复制了一份,在函数体中使用,在函数体内修改参数变量时修改的是实参的一份拷贝,而实参本身是没有改变的。
- 传址的本质还是传值,只不过是指针变量之间值的传递,而传址和传值之间的本质区别在于,传址不产生实参变量的拷贝,传值产生实参变量的拷贝,对应于上述程序,实参变量为value。更多内容可参考:
传值、传引用、传址
回到链表这块,因为是链式结构,会经常涉及到对地址的操作,因此搞清楚上述问题显得尤为重要。一般来说,只要清楚上述内存模型,然后一步一步结合图形分析,这类问题并不难解决。有时候,问题往往集中在”形参的值发生了改变,而实参值并未发生变化”、”链表断链”等易错点上。自己在初学这块时,也遇到过上述的问题,详细可参考:
深入剖析指针传参——入门篇
二、例题分析
1.单链表逆置
最后,以一道经典例题结束链表知识的梳理。
将带头结点的单链表就地逆置,所谓”就地”是指辅助空间复杂度为O(1)。
单链表逆置有两种方法,下面一一介绍。
方法一(头插法重新建立单链表的方法):
将头结点摘下,然后从第一个数据结点开始,依次头插入头结点的后面(也就是头插法重新建立单链表),直到最后一个结点为止,这样就实现了链表的逆置,如下图所示:
(图片来源于单链表的就地逆置)
故完整代码实现如下:
#include <stdio.h>
#include <stdlib.h>
typedef struct node {
int data;
struct node *next;
}Node;
Node *Init(Node *head);
void Create(Node *head);
void Reverse(Node *head);
void Output(Node *head);
int main(int argc, char *argv[]) {
Node *head = Init(head); //链表初始化,创立头结点
Create(head);
printf("逆置前的链表为: ");
Output(head);
Reverse(head);
printf("逆置后的链表为: ");
Output(head);
return 0;
}
Node *Init(Node *head) {
head = (Node *)malloc(sizeof(Node));
head -> next = NULL;
return head;
}
void Create(Node *head) { //尾插法创建链表
int i;
Node *r = head, *s; //链表的尾指针
for (i = 1;i <= 10;i++) {
s = (Node *)malloc(sizeof(Node));
s -> data = i;
r -> next = s;
r = s;
}
r -> next = NULL;
}
void Reverse(Node *head) {
Node *p = head -> next;
head -> next = NULL; //初始化,也作为最后一个结点的指针域
Node *q; //q指针用于保存后继,防止"断链"
while (p) {
q = p -> next;
p -> next = head -> next;
head -> next = p;
p = q;
}
}
void Output(Node *head) {
Node *p = head -> next;
while (p) {
printf("%d ", p -> data);
p = p -> next;
}
printf("\n");
}
输出结果:
逆置前的链表为: 1 2 3 4 5 6 7 8 9 10
逆置后的链表为: 10 9 8 7 6 5 4 3 2 1
方法二(三指针法):
该种方法主要介绍思想,由于图形不太好画,可以根据算法描述把图形手画出。
三指针就是使用三个指针分别记录操作每个结点的前驱结点pre,本身结点p,后继结点r。算法实现过程如下:
- 令*p结点的next域指向*pre结点,而一旦调整指针的指向后,*p的后继结点的链就断开了,为此需要用指针r来记录p结点的后继结点。
- 在处理第一个结点时,应将其next域置为NULL,而不是指向头结点(因为它将作为新表的尾结点)
- 在处理完最后一个结点后,需要调节头结点的next域并指向它。
因此,从上述过程可知,具体实现代码应该分成三个部分处理,处理链表的第一个数据结点的next域,处理链表的第二个数据结点到最后一个数据结点的next域,处理链表的头结点的next域。
故完整实现代码如下:
#include <stdio.h>
#include <stdlib.h>
typedef struct node {
int data;
struct node *next;
}Node;
Node *Init(Node *head);
void Create(Node *head);
void Reverse(Node *head);
void Output(Node *head);
int main(int argc, char *argv[]) {
Node *head = Init(head); //链表初始化,创立头结点
Create(head);
printf("逆置前的链表为: ");
Output(head);
Reverse(head);
printf("逆置后的链表为: ");
Output(head);
return 0;
}
Node *Init(Node *head) {
head = (Node *)malloc(sizeof(Node));
head -> next = NULL;
return head;
}
void Create(Node *head) { //尾插法创建链表
int i;
Node *r = head, *s; //链表的尾指针
for (i = 1;i <= 10;i++) {
s = (Node *)malloc(sizeof(Node));
s -> data = i;
r -> next = s;
r = s;
}
r -> next = NULL;
}
void Reverse(Node *head) {
Node *pre, *p = head -> next, *r = p -> next;
p -> next = NULL; //处理第一个结点
while (r) {
pre = p;
p = r; //指针前移操作
r = p -> next;
p -> next = pre; //指针反转操作
}
head -> next = p; //处理最后一个结点
}
void Output(Node *head) {
Node *p = head -> next;
while (p) {
printf("%d ", p -> data);
p = p -> next;
}
printf("\n");
}
关于更多图示解释,可参考:单链表的逆置–普通循环方法(详细图解)
4.小结
关于链表部分,自己有以下小结:
- 链表结构比较单一,但也要注意方法。一般来说,在做涉及链表的相关题目时,应该动手绘制出链表每一步应当如何操作,列出一二三步,然后再将其”翻译”成代码即可。
- 链表相关算法主要涉及头插法,尾插法,逆置法,归并法,双指针法等方法求解,在实际求解问题时,还要灵活运用,另外注意链表中”传值和传址”、尾插法要处理最后一个结点的next域、头插法初始化链表并保存后继等易错点,要勤加总结。
- 无论是上述知识点中提及的单链表还是单链表的各种变形(双链表,循环单链表,循环双链表等),都是以单链表为基础的。只有扎实的掌握了单链表的基本操作,多犯一点错误,然后分析并记录起来自己错误的原因,再来看各种变形也就不难了。