线性表之单链表
这篇文章来讲述线性表中的单链表
,完整的实现请自行导航到gitee托管仓库。
传送门:https://gitee.com/prexer/data-structure-and-algorithms
所有的样例,都可以通过如下方式进行编译和检测:
$ git clone https://gitee.com/prexer/data-structure-and-algorithms.git
$ cd data-structure-and-algorithms/liner_list/single_linked_list
$ make run
单链表数据类型的抽象
事实上,任何程序的构建都是建立在一定的约束下的,这个约束通常称为环境。
同样的,我们构建单链表的时候,也对它进行一定的约束,相关属性如下:
- 单链表中的元素是有序的,且从小到大
- 表中的元素允许重复
接下来需要说明,任何数据类型在抽象时,都会分为两部分:
- 属性描述域
- 操作该类型对象的多种函数,或者叫做方法
然而,在C语言中,并没有类似Java语言这样的class关键字来统筹数据类型的属性和方法。
而且,通常数据类型的属性和对应的方法是分开实现的,只不过会在同一个源文件中定义。
看看我们如何抽象单链表的数据类型属性:
typedef int ItemType;
typedef struct NODE {
struct Node * next;
ItemType value;
} Node;
注.注意指针域的位置,放在结构体的第一个元素有助于快速定位,因为指针的地址和结构的地址是一样的。
对应的数据类型的操作方法:
Node ** sll_locate(Node **, ItemType);
int sll_insert(Node **, ItemType);
int sll_modify(Node *, ItemType, ItemType);
int sll_delete(Node **, ItemType);
void sll_show(Node *);
void sll_destroy(Node **);
我们把链表的每个元素称为节点,而每个节点中都会含有两个区分域(只是通常的叫法而已):
- 数据域,
value
,我们进行了抽象,如果你喜欢可以自己定义想要的类型 - 指针域,
next
,这是用来找到后继节点的唯一方法
由于我们的操作可以应用于通用的排序链表,所以你会发现,链表的头指针
你可以自己定义,只要你保护好它,不让它丢失,那你就可以很方便的拥有一个链表结构。
以后的文章都是以先描述
,然后看实现
,最后根据实现来讲述一些知识点和分析总结
。
插入操作的实现
描述:插入操作函数,应该接收一个头指针的指针和一个待插入的元素的值,并返回是否成功的区别码。
实现细节:
int sll_insert(Node ** linkp, ItemType new_value){
Node * current = *linkp;
while (current != NULL && current->value < new_value){
linkp = &(current->next);
current = *linkp;
}
Node * new = malloc(sizeof(Node));
if (new == NULL)
return ERROR;
new->value = new_value;
*linkp = new;
new->next = current;
return OK;
}
思考:插入操作有三中情况,在头部插入,在中间插入,在尾部插入,分情况考虑:
- 在头部插入,就表示我们的头指针需要修改,头指针需要修改,那就需要传入
指针的指针
- 在中间插入,由于是单向链表,所以后继节点根本不会知道自己是否有前驱节点,所以真正敏感的操作是前驱节点和新元素
- 在尾部插入,这就涉及到遍历的边界,通常,链表最后如果没有元素了,那它的next应该保存NULL
根据我们的分析,其实函数的原型就已经确定了,这里比较特殊的就是头指针的指针
由于链表的特性,它是需要内存动态分配机制配合动态装填来完成的,那么它的步骤应该是:
- 使用malloc类似的内存分配函数,在堆内存中为节点数据结构分配存储空间
- 判断空间是否可以获得
- 动态填充数据域的值
再思考下,由于前面我们已经分析了,我们传入了头指针的指针,它是指针的指针,在C语言中有了它意味着什么呢?
- 首先,考虑C语言中,所有的函数调用都是
值传递
,叫做call-by-value
,所以,如果形参定义的是指针的指针,那么我们就可以放心的修改传进来的指针的指针,它不会影响实参的值,但请注意,你不能修改它解引用后的值,毕竟你拿的可是指针。 - 模式的相似性,如:指针的指针=>指针=>节点空间=>利用
&节点空间->next
再次获得指针的指针,然后循环,如果你不明白,在纸上画出来就懂了,自己试试。
其实,我们可以通过前面的分析,将函数中的current变量去掉,因为指针的指针可以表示很多层含义,比如我们可以写成:
int sll_insert(Node ** linkp, ItemType new_value){
while (*linkp != NULL && (*linkp)->value < new_value){
linkp = &((*linkp)->next);
}
Node * new = malloc(sizeof(Node));
if (new == NULL)
return ERROR;
new->value = new_value;
new->next = *linkp;
*linkp = new;
return OK;
}
只不过,它的可读性会很差,所以,还是引入中间变量来方便表达。
然后,到了链表插入阶段,这里要注意一个细节,就是current,这是一个临时变量,他是运行时在栈内存空间分配的,它是一个指针的拷贝,如果你要修改真真的指针,一定要使用指针的指针,也就是linkp,而不是current,而用current给其他指针赋值是可以的。
*linkp = new;
new->next = current;
其他操作的重点分析
其实其他操作,和插入操作比起来还是简单一些。
这里主要讲解这些操作的重点细节。
对于索引操作ssl_locate
,我们返回的是指向找到节点指针的指针,为什么呢?
其实它是为删除操作ssl_delete
而准备的,对于单向链表而言,如果你找到一个元素,然后返回指向这个元素的指针没有有用,因为它无法找到前驱节点,你得到的指针,只不过是前驱节点的next指针的拷贝
而已,自己思考下。
对于修改操作ssl_modify
,你要注意,因为我们是有序链表,不能因为你修改了一个节点的值,然后导致链表变得无序,一个比较有效的方法是使用排序算法,进行内部调整,而对于我们简单的数据结构来看,其实只需要先通过旧值找到对应的节点,然后删除这个节点,最后将新节点插入链表,这样就可以简单的实现有序操作了,不过这里要注意,这会引入一个bug,看笔者的实现,这里应该判断ssl_delete
的返回值,这很重要。
还有一个链表销毁操作,由于进程退出的时候,需要释放我们手动申请的内存,所以销毁链表是必要的。
它的基本思想是:
- 使用
去头法
,挨个将链表头上的节点去掉 - 需要一个变量来存储从链表上切下来的节点,用于后续释放
- 需要一个变量来更新头指针,并非一定要有这个变量,如果引入能够更清晰,还是值得的
- 最后,让头指针指向NULL,表示空表
说一些其他的关联话题:
链表的存储空间是使用操作系统提供的动态分配函数完成的,实际上,他们的位置在堆空间内,这是进程在内存镜像中的一种描述形式而已,而真正规划他们的是编译器和操作系统。
通过指针连接的节点,使得让节点的空间不是连续的,这和数组不同
通常,链表和数组存储的节点元素都是相同结构类型的数据(其实链表可以更加灵活,如果一个结构继承
了另一个结构(我们称之为父结构),那么添加结构的子结构
一样可以完美的使用链表,当然,他们的父子关系并没有面向对象语言那么丰富,但也足够表达一些东西,只不过,对于C语言来说要进行强制类型转换)。