C语言链表篇-从入门到精通
链表是C语言中一种重要的数据类型,很多复杂的工程如一些系统设计都依赖于链表来实现。同时链表的实现需要前面学到的指针,数组,结构体方面知识的综合应用。笔者在链表部分卡了很久遇到过很多问题,本文会将我遇到的一些有价值的问题逐一剖析,从链表的概念出发讲到链表的增删改查,尽量通过一篇文章让大家掌握链表
一.链表概念
链表是一种采用动态存储分配的数据结构。链表中有一个头指针变量,用它来保存一个变量的地址,也就是指向下一个元素的指针,每一个元素包含两部分,存放数据的数据域与指向下一个元素的指针域。头指针指向的第一个结点叫头节点,头结点的数据域不存放数据,其指针域指向下一个元素,即第一个存放数据的有效节点。链表可以没有头结点,但是一个带有头结点的链表可以更简洁地实现插入,删除等操作
二.链表的简单实现
我们先类比结构体数组,对于一个结构体数组,它的代码实现非常容易,如下
#include<stdio.h>
typedef struct node {
int num;
char name[15];
}student;
int main() {
student stu[3] = {1,"gao",2,"li",3,"zhang"};
for(int i=0;i<3;i++) {
printf("%d ",stu[i].num);
printf("%s ",stu[i].name);
}
return 0;
}
输出结果:1 gao 2 li 3 zhang
这段代码定义了一个简单的结构体数组,每个数组元素包含序号与姓名。链表也通过结构体数据,但不同的是,数组的存储空间是连续的,在插入删除等操作过程中涉及大量元素的移动,同时对于新手,从实现难度上来说,结构体数组的实现和我们刚接触数组时实现一个数组一样简单,而链表的实现远比这复杂,下面用链表实现这些数据的输出
#include <stdio.h>
#include <stdlib.h>
#include<string.h>
typedef struct node {
int num;
char name[15];
struct node *next;
} student;
int main() {
student *head = (student *)malloc(sizeof(student));
student *second = (student *)malloc(sizeof(student));
student *third = (student *)malloc(sizeof(student));
head->num = 1;
strcpy(head->name, "gao");
head->next = second;
second->num = 2;
strcpy(second->name, "li");
second->next = third;
third->num = 3;
strcpy(third->name, "zhang");
third->next = NULL;
student *current = head;
while (current != NULL) {
printf("%d ", current->num);
printf("%s ", current->name);
current = current->next;
}
free(head);
free(second);
free(third);
return 0;
}
与上面结构体数组相比,这段代码的结构体中多了一个指向下一个元素的指针‘*next’,尾结点的next为空,而在这之前的每个next都指向下一个结点。这段结构体链表先分配三个结构体内存作为三个结点然后挨个赋值,最后对这段链表进行遍历输出(下文会详细讲解链表的遍历)。这段链表的实现参考下图
需要注意的是,链表的内存分布并不连续,此图只是帮助理解。同样的,上面这段链表代码也只是为了帮助新手理解链表,这种费力的写法即便在教科书上也几乎找不到。
三.链表的函数
我们知道C语言中函数参数的传递实际上是值的传递,想通过函数对实际参数修改,一种方法设一个全局变量,另一种方法是传变量的地址。我们显然不可能定义一个全局变量的链表,因此只能通过修改变量的地址来实现用函数对链表的创建。我们一般将头指针的地址作为实参传进函数,那么,函数此时的形参应该是什么?头指针是一个指针,形参又存放着头指针的地址-存放地址的地址,显然是一个二级指针。了解完这些,我们来举一个例子
#include <stdio.h>
#include <stdlib.h>
#include<string.h>
typedef struct node {
int num;
char name[15];
struct node *next; // 指向下一个节点的指针
} stu;
void Link_List(stu**head) {
int num=0;
char name[15];
stu*p=(stu*)malloc(sizeof(stu));
stu*last=*head;
scanf("%d",&num);
scanf("%s",name);指针变量
p->num=num;
strcpy(p->name,name);
p->next=NULL;
if(*head) {
while(last->next) {
last=last->next;
}
last->next=p;
}
else {
*head=p;
}
}
void Link_Free(stu**head) {
stu*last=*head,*p;
while(last) {
p=last;
free(p);
last=last->next;
}
}
int main() {
stu*head=NULL;
for(int i=0;i<3;i++) {
Link_List(&head);
}
Link_Free(&head)
return 0;
}
这段链表的作用是分别存储3个人的序号与姓名。
首先,我们在主函数中将head的地址传进创建链表的函数Link_List
以便后续对头结点的修改,然后输入数据,在Link_List
中我们定义了两个指针变量*p和*last,p指针用来存储数据,last用来遍历链表,找到链表最后一个结点,注意最后一个结点的指针域为空,因为它不指向下一个结点。这里我们详细解释一下链表的创建和链表的遍历。
创建链表时,我们需要先判断头指针是否为空,即是否指向一个结点,如果head指向一个结点即head不为空,那么我们便让head等于p,让head指向第一次输入的数据,此时head不为空指针,第二次输入数据后继续执行stu*last=*head;
,此处要特别注意的是两个结构体指针之间赋值实际上是让两个指针指向同一个内存地址,,对一个指针的任何修改都会反映到另一个指针上。 因此第二次执行完 last->next=p;
后head的next也将指向了第二个结点,(千万不要认为head只进行过一次head=p,从而纠结stu*last=*head;
后while(last->next)
是否会被执行)如此继续下去我们就完成了一段链表。
上面的描述已经包含了链表的遍历,但这里还需强调一点,while(last->next) last=last->next;
while循环的条件是last的指针域即next是否为空,并不是last是否为空。如果改成了last,那么最后一个结点的指针就不指向新接入的结点从而导致程序出错。
四.链表的增删改查
只要将前面的内容理解,链表的增删改查就是件水到渠成的事了。这里根据代码实现难度先后讲解查,删,改,增。
在四大块的剖析前,为了使讲解更直观我们写一个输出函数便于查看输出结果,代码如下
void Print_List(stu*head) {
stu*last=head;
while(last) {
printf("%d %s \n",last->num,last->name);
last=last->next;
}
}
注意,这里的形式参数是一个结构体指针,并不是一个二级指针-为什么前面的Link_List和Link_Free需要我们传进head的地址进去?
因为Print_List不需要对链表做任何的修改,一个head的拷贝指针,即与head指向相同的一个指针即可。尽管如此,那我们就传进head的地址可以吗?这个问题留给大家自己思考,可以通过编译器检验答案。
链表结点的查找
以上面这串代码为例,我们依次输入1 gao 2 li 3 zhang,其输出结果也应该是1 gao 2 li 3 zhang。
我们如果要查找序号为3的人的姓名,那么只需找到元素num为3的结点输出其中数据即可,找元素只需要依次遍历链表即可,下面是代码实现
void Find_Node(stu*head) {
stu*p=head;
int num = 0;
printf("Input the number you want to find:");
scanf("%d",&num);
for(p->num; p; p=p->next) {
if(p->num == num) {
printf("%s\n",p->name);
break;
}
}
}
这个函数不需要对链表修改,所以不需要传进head的地址。
链表结点的删除
链表的删除和查找同理,都是遍历链表,比较结点元素。
还是以上面这串代码为例,我们依次输入1 gao 2 li 3 zhang
如果此时我们要删除第二个结点也就是2 li我们该如何操作?—很简单,让第一个结点的指针域指向第三个即可,不过第二个结点的寻找需要遍历,也就是找到要删除的结点后,让前一个结点的指针域指向要删除的结点的下一个结点即可,最后不要忘了释放掉删除的结点,下面是代码实现
void Delete_Node(stu**head) {
stu *p ,*ps;
p = ps = *head;
int num;
printf("Please input the number you want to delete:");
scanf("%d",&num);
while(p->num!= num&&p->next!=NULL) {
ps = p;
p=p->next;
}
if(p->num==num) {
if(p == *head) {
p=p->next;
} else {
ps->next = p->next;
}
free(p);
}
else printf("not exist");
}
这里用了序号寻找要删除的结点,用name当然也可以,比较两个字符串即可
链表结点的修改
删改查三者确实是一通百通,思想基本一样,对于链表的修改,遍历找到要修改的序号后重新给要修改的元素重新赋值即可,下面是代码实现
void Revise_Node(stu*head) {
int num=0;
char new_name[15];
stu*p=head;
printf("Please input the number you want to revise:");
scanf("%d",&num);
scanf("%s",new_id);
for(p; p->num!=num; p=p->next) {
}
strcpy(p->name,new_name);
}
链表函数的修改也不需要传进head的地址,因为这个修改只是对链表一个结点的数据域的修改,并不需要改动头指针。另外,这里遍历链表的形式和前面的不太一样,一个for循环就优雅地搞定了。我们学习循环时应该有认识到,任何一个while循环都可以改成for循环。
链表结点的增加(插入)
事实上,链表插入和前三种的核心思想没有区别,只不过多了两个判断而已。
对上面创建链表的代码,我们输入1 gao 3 li 4 zhang,想将2 chen插入到链表中该怎么办?有了前面的经验我们可以轻松的想到遍历链表,依次将结点元素num比较,如果num小于某个结点的num,就让插入结点在这个结点的位置上,让新节点的指针域指向下一个结点。但是,如果要插入到头结点呢,还能让别的结点的指针域指向它吗?如果要插入到尾结点呢,还能让它的指针域指向被取代的结点吗?因此我们要分别判断插入到头结点,尾结点和中间的情况,以下是代码实现
void Add_Node(stu**head) {
stu *p_new= (stu*)malloc(sizeof(stu));
stu *p ;
printf("Please input the number you want to add:");
scanf("%d",&p_new->num);
scanf("%s",p_new->name);
stu *temp = (stu*)malloc(sizeof(stu));
p=*head;
while(p_new->num>=p->num&&p->next) {
temp = p;
p = p->next;
}
if(p->next==NULL) {
p->next = p_new;
p_new->next = NULL;
}
else if(p==*head) {
*head = p_new;
p_new->next = p;
}
else{
temp->next = p_new;
p_new->next = p;
}
}
五.总结
链表的综合性很强,需要扎实的基本功,属于C语言学习中的拦路虎。但是掌握链表之后,我们就可以写如学生信息管理系统这样的一些较复杂的工程。